Skip to content

CVE-2026-94545: Verified Reproduction

CVE-2026-94545: Next.js next/og ImageResponse RCE via Satori improper SVG escaping critical

CVE-2026-94545 is verified against next · npm. Affected versions: >=16.2.0 <16.3.6. Fixed in 16.3.6. Vulnerability class: RCE. This critical reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00357.

REPRO-2026-00357 next · npm RCE Sep 23, 2026 CVE entry .txt
Severity
CRITICAL
Confidence
HIGH
Reproduced in
84m 10s
Tool calls
476
Spend
$17.73
01 · Overview

What Is CVE-2026-94545?

CVE-2026-94545 is a critical-severity RCE vulnerability affecting next >=16.2.0 <16.3.6. Pruva has independently reproduced it and publishes a verified, runnable proof-of-concept (reproduction REPRO-2026-00357).

02 · Severity & CVSS

CVE-2026-94545 Severity

CVE-2026-94545 is rated critical severity.

CRITICAL threat level
Weakness CWE-116 — Improper Encoding or Escaping of Output

Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.

03 · Affected Versions

Affected next Versions

next · npm versions >=16.2.0 <16.3.6 are affected.

How to Reproduce CVE-2026-94545

$ pruva-verify REPRO-2026-00357
or curl -O https://www.pruva.dev/api/v1/reproductions/REPRO-2026-00357/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh
Run in a VM or disposable container. This exploits a real vulnerability.
06 · Proof of Reproduction

Proof of Reproduction for CVE-2026-94545

Remote code execution — reproduced
  • reached the target end-to-end
  • full exploit chain demonstrated
  • on the real production code path
  • high confidence
  • the upstream fix blocks the same trigger
Trigger

Unauthenticated POST /api/og text body containing a command-specific Rasterfall SVG/XInclude/path payload in SVG <title> content

Attack chain
  1. Next.js next start App Router endpoint
  2. Node.js next/og ImageResponse
  3. vulnerable Satori SVG serialization
  4. sharp 0.35.4/libvips 8.18.6/librsvg 2.62.91/libxml2 2.15.3 native renderer
  5. attacker-selected /bin/sh -c command
How the agent worked 838 events · 476 tool calls · 2h 16m
2h 16mDuration
476Tool calls
112Reasoning steps
838Events
31Dead-ends
Agent activity over 2h 16m
Policy
1
Support
5
Repro
551
Judge
119
Variant
156
Verify
1
0:00136:09

Root Cause and Exploit Chain for CVE-2026-94545

Versions: package/component: Next.js next/og Node.js ImageResponse, including its vulnerable Satori serializer and the downstream native Sharp/libvips/librsvg/libxml2 rendering path.

CVE-2026-94545 is a remote command-execution chain in the Node.js implementation of Next.js next/og ImageResponse. In affected Next.js 16 releases, Satori does not completely escape untrusted values before serializing them into SVG. A request value embedded in SVG text can therefore terminate the intended element and inject arbitrary XML/SVG structure. With the demonstrated native renderer stack, a Rasterfall payload uses XInclude/entity expansion and crafted path data to trigger a native librsvg/libxml2 use-after-free, pivot control flow, and execute an attacker-selected shell command. This run proved that chain twice through a live unauthenticated App Router HTTP endpoint on Next.js 16.3.5 and proved that Next.js 16.3.6 blocks the same procedure.

  • Affected package/component: Next.js next/og Node.js ImageResponse, including its vulnerable Satori serializer and the downstream native Sharp/libvips/librsvg/libxml2 rendering path.
  • Affected versions: next >=16.2.0 <16.3.6; the corresponding Satori improper-escaping issue affects satori >=0.0.27 <0.33.5.
  • Risk: Critical. An unauthenticated remote user can execute an attacker-selected command with the privileges and working directory of the production Next.js server when an application puts request-controlled text into a supported inline SVG element and the native binary/dependency versions match the exploit layout.
  • Observed consequence: The payload executed /bin/sh -c 'printf <unique-token>><marker>' and created exact unique command markers in two independent vulnerable server processes.

Impact Parity

  • Disclosed/claimed maximum impact: Remote code execution.
  • Reproduced impact from this run: Remote unauthenticated command execution through POST /api/og on two fresh Next.js 16.3.5 production servers.
  • Parity: full.
  • Not demonstrated: No post-exploitation privilege escalation was attempted. The proof executes as the Next.js server account and intentionally writes only bounded marker files.

Root Cause

The route sends the raw request body into <title>{value}</title> within an inline SVG passed to ImageResponse. In the vulnerable serializer bundled by Next.js 16.3.5, this text is not escaped with the context-correct XML rules before SVG serialization. A body beginning with </title> can therefore close the intended node and inject active XML/SVG constructs.

The current payload injects an XInclude whose data URL contains an XML document with a crafted entity expansion and SVG path. Under the exact demonstrated native stack—Sharp 0.35.4, libvips 8.18.6, librsvg 2.62.91, and libxml2 2.15.3—the path layout triggers the Rasterfall native use-after-free chain and uses a Node.js 24.20.0-specific ROP layout to invoke execve("/bin/sh", ["/bin/sh", "-c", command], NULL). The vulnerable request therefore replaces the request-serving process and causes curl to observe an empty response, while the command's marker remains as direct evidence.

Next.js 16.3.6 includes the serializer/dependency hardening that escapes the injected value. In both fixed controls the same generation and POST procedure returned HTTP 200 PNG output, the server remained alive, and no command marker appeared. The relevant Satori hardening is upstream change 26a52affc031 (“Harden SVG serialization”), released in Satori 0.33.5 and incorporated into the patched Next.js dependency set.

Reproduction Steps

  1. Run bundle/repro/reproduction_steps.sh from any working directory. The script resolves PRUVA_ROOT portably.
  2. The script reads bundle/project_cache_context.json, uses /pruva/project-cache when prepared, and otherwise falls back to a bundle-owned cache.
  3. It verifies the official Linux x86-64 Node.js 24.20.0 executable by SHA-256, installs exact Next.js 16.3.5 and 16.3.6 package identities when needed, builds minimal App Router applications, and verifies the renderer versions.
  4. For each of two vulnerable and two fixed attempts, it generates a fresh command-specific payload with bundle/repro/rasterfall_poc.py, starts a clean next start process, waits for the normal / health check, and sends the payload as an unauthenticated POST /api/og body.
  5. Success requires both vulnerable processes to create their exact unique markers, both fixed processes to create no marker, and both fixed endpoints to return non-empty HTTP 200 responses.
  6. The script writes bundle/repro/runtime_manifest.json only after all listed proof artifacts are immutable and binds each artifact by SHA-256.

Expected console summary:

[vulnerable #1] curl=52 http=000 alive=false marker_present=true marker_matches=true
[vulnerable #2] curl=52 http=000 alive=false marker_present=true marker_matches=true
[fixed #1] curl=0 http=200 alive=true marker_present=false marker_matches=false
[fixed #2] curl=0 http=200 alive=true marker_present=false marker_matches=false
RESULT: command execution CONFIRMED ...

Evidence

Primary evidence from the second consecutive passing run:

  • bundle/repro/proof/evaluation.json
    • command_execution_confirmed: true
    • vulnerable_attempts_passed: true
    • fixed_negative_controls_passed: true
  • bundle/repro/proof/vulnerable_attempt1/marker.txt
    • Exact bytes: RF_VULNERABLE_1_1790151447194957763_23122
    • SHA-256: 9edf37e593bd4d4eabcdc7e1173702b2d0ab1307adab36265afd9bb487c0bffa
  • bundle/repro/proof/vulnerable_attempt2/marker.txt
    • Exact second unique command marker; SHA-256 f35499c917d8786cb30e69cfa0d2aa9dc0b9f58273e657ca5d1650b9edd33220.
  • bundle/repro/proof/vulnerable_attempt1/observation.json
    • POST endpoint reached; curl exit 52/HTTP 000 after process replacement; marker present and exact.
  • bundle/repro/proof/fixed_attempt1/observation.json
    • Same endpoint/procedure reached; HTTP 200; server alive; marker absent; 117125-byte PNG response.
  • bundle/repro/proof/fixed_attempt2/observation.json
    • Independent fixed negative control with the same result.
  • bundle/repro/proof/runtime_identity.json
    • Node.js v24.20.0, executable SHA-256 89af8424dd53e560b1933f87ba650d8bf57c83ca5a04600eefb31f416aabbae7.
    • Vulnerable Next.js 16.3.5 and fixed Next.js 16.3.6.
    • Sharp 0.35.4, libvips 8.18.6, librsvg 2.62.91, libxml2 2.15.3.
    • Vulnerable and fixed compiled @vercel/og bundle identities.
  • bundle/repro/runtime_manifest.json
    • Production endpoint/service/health flags, immutable target/runtime identity, proof inventory, and SHA-256 map.
  • bundle/logs/reproduction_steps.log
    • Human-readable diagnostics from the latest run. This actively written diagnostic is intentionally not listed as immutable manifest proof.

The current run also recorded typed exploit primitive 3f4a24f2-16c9-498a-a22b-a5d2874f4930 and derived repeated command-execution capability 29471ddd-389e-447a-b142-7a47469b2054.

Recommendations / Next Steps

  • Upgrade affected applications to Next.js 16.3.6 or later.
  • If an immediate upgrade is impossible, do not pass attacker-controlled text, attributes, or styles into Node.js ImageResponse SVG rendering. Apply context-correct XML escaping before serialization.
  • Inventory Sharp, libvips, librsvg, libxml2, and Node.js versions; patch the native renderer independently because the complete exploit requires the demonstrated downstream weakness and binary-specific layout.
  • Add end-to-end regression tests at the actual App Router endpoint. Test hostile XML delimiters, DTD/XInclude constructs, deeply crafted path values, and oversized bodies, and verify rejection/escaping before native rasterization.
  • Bound request-body sizes and isolate image rendering under a low-privilege account as defense in depth; these do not replace upgrading.

Additional Notes

  • The authoritative script passed twice consecutively. The first run built exact package identities; the second reused only identity-checked cache entries and repeated the complete four-process proof.
  • Every attempt uses a fresh process, a fresh payload, and a unique marker. Script-owned stale markers are removed before trigger delivery.
  • The exploit generator limits commands to 71 printable ASCII bytes because the ROP layout is bounded. The proof writes a short relative marker in the server working directory, then copies it into immutable bundle evidence after process shutdown.
  • The ROP offsets are specific to the official Linux x86-64 Node.js 24.20.0 executable and the exact native stack listed above. Other binary or architecture combinations require recalibration; this limitation does not weaken the reproduced claimed configuration.
  • No sanitizer or mocked component was used. The primary oracle is real command output through a production next start HTTP boundary, with a patched-version negative control.

CVE-2026-94545 Reproduction Transcript

The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.

Full session Replay every step — scrub the timeline or play it back.

Event 1/40
0:002:30
0:00
session startedaccounts/fireworks/models/kimi-k3 · CVE-2026-94545 · REPRO-20
0:26
0:26
extract_facts
no facts extracted
0:28
0:28
supportclaim_contract
0:36
0:36
0:36
0:41
0:41
0:41
1:00
1:01
1:01
1:10
1:11
$cat bundle/ticket.json | python3 -c "import json,sys; d=json.load(sys.stdin); print(json.dumps(d, indent=2))" 2>/dev/null | tail -60; echo ---; ls /pruva/project-cache 2>/dev/null; node --version; npm --version; python3 --version
1.1s
{"cwd":"/workspace","duration_ms":706,"exit_code":0,"stderr":"","stdout":"        {\n          \"kind\": \"claim_contract_validated\",\n          \"requirement\": \"required\",\n          \"stage\": \"claim_contract\"\n        },\n        {\n          \"kind\": \"runtime_outcome_resolved\",\n          \"requirement\": \"required\",\n          \"stage\": \"repro\"\n        },\n        {\n          \"kind\": \"independent_verdict_validated\",\n          \"requirement\": \"composite\",\n          \"stage\": \"judge\"\n        },\n        {\n          \"kind\": \"variant_analysis_validated\",\n          \"requirement\": \"required\",\n          \"stage\": \"vuln_variant\"\n        }\n      ],\n      \"evidence_policy\": \"current_runtime_evidence\",\n      \"objective\": \"resolve_known_vulnerability\",\n      \"schema_version\": 1\n    },\n    \"profile\": \"known_vulnerability\",\n    \"publication\": {\n      \"discovery_artifacts_private\": true,\n      \"require_confirmed_runtime_verdict\": true,\n      \"require_promotion_manifest\": true\n    },\n    \"run_mode\": \"repro_only\",\n    \"schema_version\": 2,\n    \"stages\": [\n      {\n        \"requirement\": \"required\",\n   … [truncated]
1:27
web search
1:48
1:50
1:53
$npm view satori versions --json 2>/dev/null | tail -30; echo ---; npm view satori time --json 2>/dev/null | grep -E '"0\.33' ; echo ---; npm view next@16.3.5 version; npm view next@16.3.6 version
3.2s
  "0.18.2",
  "0.18.3",
  "0.18.4",
  "0.19.0",
  "0.19.1",
  "0.19.2",
  "0.19.3",
  "0.20.0",
2:08
2:09
$mkdir -p /workspace/bundle/artifacts/diffwork && cd /workspace/bundle/artifacts/diffwork && npm pack satori@0.33.4 satori@0.33.5 >/dev/null 2>&1 && ls -la *.tgz && mkdir -p s334 s335 && tar xzf satori-0.33.4.tgz -C s334 && tar xzf satori-0.33.5.tgz -C s335 && diff -rq s334/package s335/package | head -30
1.3s
-rw-r--r-- 1 vscode vscode 1594188 Sep 23 07:17 satori-0.33.4.tgz
-rw-r--r-- 1 vscode vscode 1599106 Sep 23 07:17 satori-0.33.5.tgz
Files s334/package/dist/index.cjs and s335/package/dist/index.cjs differ
Files s334/package/dist/index.cjs.map and s335/package/dist/index.cjs.map differ
Files s334/package/dist/index.js and s335/package/dist/index.js differ
Files s334/package/dist/index.js.map and s335/package/dist/index.js.map differ
Files s334/package/dist/standalone.cjs and s335/package/dist/standalone.cjs differ
Files s334/package/dist/standalone.cjs.map and s335/package/dist/standalone.cjs.map differ
2:14
$cd /workspace/bundle/artifacts/diffwork && diff s334/package/dist/index.js s335/package/dist/index.js | head -150
0.4s
{"_truncation":{"stdout_original_tokens":177834},"cwd":"/workspace","duration_ms":59,"exit_code":0,"stderr":"","stdout":"1,7c1,7\n< var YI=Object.create;var Jr=Object.defineProperty;var qI=Object.getOwnPropertyDescriptor;var XI=Object.getOwnPropertyNames;var VI=Object.getPrototypeOf,zI=Object.prototype.hasOwnProperty;var st=(A,e)=>()=>(A&&(e=A(A=0)),e);var J=(A,e)=>()=>(e||A((e={exports:{}}).exports,e),e.exports),Kr=(A,e)=>{for(var t in e)Jr(A,t,{get:e[t],enumerable:!0})},Ss=(A,e,t,r)=>{if(e&&typeof e==\"object\"||typeof e==\"function\")for(let n of XI(e))!zI.call(A,n)&&n!==t&&Jr(A,n,{get:()=>e[n],enumerable:!(r=qI(e,n))||r.enumerable});return A};var ZI=(A,e,t)=>(t=A!=null?YI(VI(A)):{},Ss(e||!A||!A.__esModule?Jr(t,\"default\",{value:A,enumerable:!0}):t,A)),Wr=A=>Ss(Jr({},\"__esModule\",{value:!0}),A);var Te,_s,Js,Wt,qn,Pe,Dt,Il,Xr,Xn,bt,St,Vn,Ks,zn,Zn,be,jn,ll,Ws,Vr=st(()=>{Te=function(A){return A[A.Auto=0]=\"Auto\",A[A.FlexStart=1]=\"FlexStart\",A[A.Center=2]=\"Center\",A[A.FlexEnd=3]=\"FlexEnd\",A[A.Stretch=4]=\"Stretch\",A[A.Baseline=5]=\"Baseline\",A[A.SpaceBetween=6]=\"SpaceBetween\",A[A.SpaceAround=7]=\"SpaceAround\",A[A.SpaceEvenly=8]=\"SpaceEvenly\",A}({}),_s=function(A){re… [truncated]
2:23
2:24
$curl -s "https://api.github.com/repos/vercel/satori/compare/v0.33.4...v0.33.5" | python3 -c " import json,sys d=json.load(sys.stdin) print('commits:', [c['sha'][:10]+' '+c['commit']['message'].splitlines()[0] for c in d.get('commits',[])]) for f in d.get('files',[]): print('===', f['filename'], f['status']) print(f.get('patch','')[:4000]) "
0.9s
commits: []
2:30

Artifacts and Evidence for CVE-2026-94545

Scripts, logs, diffs, and output captured during the reproduction.

bundle/artifacts/diffwork/satori_fix.patch17.0 KB
bundle/artifacts/http/vuln_attempt1/payload_title.png22.1 KB
bundle/repro/proof/fixed_attempt1/http_status.txt0.0 KB
bundle/repro/proof/fixed_attempt1/payload_generation.log0.1 KB
bundle/repro/proof/fixed_attempt1/request.body24.7 KB
bundle/repro/proof/fixed_attempt1/request.json0.3 KB
bundle/repro/proof/fixed_attempt1/response.body114.4 KB
bundle/repro/proof/fixed_attempt1/response.headers0.3 KB
bundle/repro/proof/fixed_attempt1/server.log0.1 KB
bundle/repro/proof/fixed_attempt2/http_status.txt0.0 KB
bundle/repro/proof/fixed_attempt2/observation.json0.6 KB
bundle/repro/proof/fixed_attempt2/payload_generation.log0.1 KB
bundle/repro/proof/fixed_attempt2/request.body24.7 KB
bundle/repro/proof/fixed_attempt2/request.json0.3 KB
bundle/repro/proof/fixed_attempt2/response.body114.2 KB
bundle/repro/proof/fixed_attempt2/response.headers0.3 KB
bundle/repro/proof/fixed_attempt2/server.log0.1 KB
bundle/repro/proof/runtime_identity.json2.1 KB
bundle/repro/proof/vulnerable_attempt1/http_status.txt0.0 KB
bundle/repro/proof/vulnerable_attempt1/payload_generation.log0.1 KB
bundle/repro/proof/vulnerable_attempt1/request.body24.7 KB
bundle/repro/proof/vulnerable_attempt1/response.headers0.0 KB
bundle/repro/proof/vulnerable_attempt1/server.log0.1 KB
bundle/repro/proof/vulnerable_attempt2/http_status.txt0.0 KB
bundle/repro/proof/vulnerable_attempt2/observation.json0.6 KB
bundle/repro/proof/vulnerable_attempt2/payload_generation.log0.1 KB
bundle/repro/proof/vulnerable_attempt2/request.body24.7 KB
bundle/repro/proof/vulnerable_attempt2/response.headers0.0 KB
bundle/repro/proof/vulnerable_attempt2/server.log0.1 KB
bundle/repro/rasterfall_poc.py5.9 KB
bundle/repro/rca_report.md8.7 KB
bundle/repro/reproduction_steps.sh17.2 KB
bundle/repro/runtime_manifest.json6.7 KB
bundle/repro/validation_verdict.json1.5 KB
08 · How to Fix

How to Fix CVE-2026-94545

Upgrade next · npm to 16.3.6 or later.

Coming soon

Step-by-step mitigation and hardening guidance for CVE-2026-94545 — configuration checks, workarounds where no patch exists, and how to verify you're protected — is on the way.

10 · FAQ

FAQ: CVE-2026-94545

Is CVE-2026-94545 exploitable?

Yes. Pruva independently reproduced CVE-2026-94545 in next and verified the exploit fires end-to-end in a sandboxed environment. A runnable proof-of-concept script and the full agent transcript are on this page (reproduction REPRO-2026-00357).

How severe is CVE-2026-94545?

CVE-2026-94545 is rated critical severity.

What type of vulnerability is CVE-2026-94545?

CVE-2026-94545 is classified as CWE-116 (Improper Encoding or Escaping of Output), a RCE vulnerability.

Which versions of next are affected by CVE-2026-94545?

next >=16.2.0 <16.3.6 is affected by CVE-2026-94545.

Is there a fix for CVE-2026-94545?

Yes. CVE-2026-94545 is fixed in next 16.3.6. Upgrading to the fixed version remediates the issue.

How can I reproduce CVE-2026-94545?

Pruva provides a verified reproduction script on this page. Download it and run it inside an isolated environment such as a container or virtual machine — never against production. The reproduction was confirmed end-to-end by Pruva's automated agents.

Is the CVE-2026-94545 reproduction verified?

Yes. Pruva reproduced CVE-2026-94545 with high confidence in a sandboxed environment, capturing the full agent transcript and artifacts as evidence.
11 · References

References for CVE-2026-94545

Authoritative sources for CVE-2026-94545 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.