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.
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).
CVE-2026-94545 Severity
CVE-2026-94545 is rated critical severity.
Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.
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 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 Proof of Reproduction for CVE-2026-94545
- 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
Unauthenticated POST /api/og text body containing a command-specific Rasterfall SVG/XInclude/path payload in SVG <title> content
- Next.js next start App Router endpoint
- Node.js next/og ImageResponse
- vulnerable Satori SVG serialization
- sharp 0.35.4/libvips 8.18.6/librsvg 2.62.91/libxml2 2.15.3 native renderer
- attacker-selected /bin/sh -c command
How the agent worked
Root Cause and Exploit Chain for CVE-2026-94545
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/ogNode.jsImageResponse, 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 affectssatori >=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/ogon 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
- Run
bundle/repro/reproduction_steps.shfrom any working directory. The script resolvesPRUVA_ROOTportably. - The script reads
bundle/project_cache_context.json, uses/pruva/project-cachewhen prepared, and otherwise falls back to a bundle-owned cache. - 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.
- For each of two vulnerable and two fixed attempts, it generates a fresh command-specific payload with
bundle/repro/rasterfall_poc.py, starts a cleannext startprocess, waits for the normal/health check, and sends the payload as an unauthenticatedPOST /api/ogbody. - 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.
- The script writes
bundle/repro/runtime_manifest.jsononly 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.jsoncommand_execution_confirmed: truevulnerable_attempts_passed: truefixed_negative_controls_passed: true
bundle/repro/proof/vulnerable_attempt1/marker.txt- Exact bytes:
RF_VULNERABLE_1_1790151447194957763_23122 - SHA-256:
9edf37e593bd4d4eabcdc7e1173702b2d0ab1307adab36265afd9bb487c0bffa
- Exact bytes:
bundle/repro/proof/vulnerable_attempt2/marker.txt- Exact second unique command marker; SHA-256
f35499c917d8786cb30e69cfa0d2aa9dc0b9f58273e657ca5d1650b9edd33220.
- Exact second unique command marker; SHA-256
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-25689af8424dd53e560b1933f87ba650d8bf57c83ca5a04600eefb31f416aabbae7. - 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/ogbundle identities.
- Node.js
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
ImageResponseSVG 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 startHTTP 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.
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{"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]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"0.18.2", "0.18.3", "0.18.4", "0.19.0", "0.19.1", "0.19.2", "0.19.3", "0.20.0",
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-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
cd /workspace/bundle/artifacts/diffwork && diff s334/package/dist/index.js s335/package/dist/index.js | head -150{"_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]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])
"commits: []
Artifacts and Evidence for CVE-2026-94545
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-94545
Upgrade next · npm to 16.3.6 or later.
FAQ: CVE-2026-94545
Is CVE-2026-94545 exploitable?
How severe is CVE-2026-94545?
What type of vulnerability is CVE-2026-94545?
Which versions of next are affected by CVE-2026-94545?
Is there a fix for CVE-2026-94545?
How can I reproduce CVE-2026-94545?
Is the CVE-2026-94545 reproduction verified?
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.