{"repro_id":"REPRO-2026-00357","version":6,"title":"Next.js next/og ImageResponse RCE via Satori improper SVG escaping (critical)","repro_type":"security","status":"published","severity":"critical","description":"CVE-2026-94545 is RESERVED at cve.org as of 2026-09-22; the NVD record is not yet published. Vendor advisories published 2026-09-22 both map this CVE: GHSA-vcvr-r3jv-pc5j (vercel/next.js, Critical: Remote Code Execution in next/og ImageResponse) and GHSA-wx4j-mvgx-mqwp (vercel/satori, Medium: Improper escaping in Satori-generated SVG); both were verified via the GitHub security advisories API this session. ROOT CAUSE: Satori does not properly escape certain values before including them in generated SVG output, so crafted values can be interpreted as SVG markup. The Node.js ImageResponse implementation from next/og consumes Satori-generated SVG; under specific conditions the improper escaping in the generated SVG can lead to remote code execution via vulnerabilities in other upstream dependencies, and the Next.js fix upgrades those dependencies (including Satori). AFFECTED VERSIONS: npm next >=16.2.0 <16.3.6 (patched: 16.3.6); npm satori >=0.0.27 <0.33.5 (patched: 0.33.5). Next.js 15.x is NOT affected by the RCE (15.5.26 ships related hardening only); the Edge ImageResponse implementation is not affected. Patched versions verified on the npm registry on 2026-09-22 (next dist-tags: latest=16.3.6, backport=15.5.26). EXPLOITATION CONDITIONS (per vendor advisory): a Node.js-runtime route passes attacker-controlled values into SVG content, attributes, or styles during image generation; the advisory example wires a request searchParams value into an SVG title element rendered by ImageResponse; remote unauthenticated exploitation is possible wherever such a route renders attacker-controlled input. REPRODUCTION CONTEXT (target repo vercel/next.js): minimal App Router app pinned to an affected version (16.2.x-16.3.5) with a Node.js route using next/og ImageResponse and request-controlled input in SVG; exercise with crafted input and compare against patched 16.3.6; inspect the 16.3.x fix delta and the satori 0.33.5 fix for the exact escaping/guard changes. VENDOR WORKAROUND: do not pass attacker-controlled values into SVG content, attributes, or styles rendered by the Node.js ImageResponse implementation; escape as XML first or exclude from the image. EXPOSURE: next about 42.7M weekly npm downloads; satori about 2.7M weekly downloads. EPSS: no data yet; not in CISA KEV as of 2026-09-22.","root_cause":"# Root Cause Analysis: CVE-2026-94545\n\n## Summary\n\nCVE-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.\n\n## Impact\n\n- **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.\n- **Affected versions:** `next >=16.2.0 <16.3.6`; the corresponding Satori improper-escaping issue affects `satori >=0.0.27 <0.33.5`.\n- **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.\n- **Observed consequence:** The payload executed `/bin/sh -c 'printf <unique-token>><marker>'` and created exact unique command markers in two independent vulnerable server processes.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Remote code execution.\n- **Reproduced impact from this run:** Remote unauthenticated command execution through `POST /api/og` on two fresh Next.js 16.3.5 production servers.\n- **Parity:** `full`.\n- **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.\n\n## Root Cause\n\nThe 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.\n\nThe 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.\n\nNext.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.\n\n## Reproduction Steps\n\n1. Run `bundle/repro/reproduction_steps.sh` from any working directory. The script resolves `PRUVA_ROOT` portably.\n2. The script reads `bundle/project_cache_context.json`, uses `/pruva/project-cache` when prepared, and otherwise falls back to a bundle-owned cache.\n3. 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.\n4. 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.\n5. 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.\n6. The script writes `bundle/repro/runtime_manifest.json` only after all listed proof artifacts are immutable and binds each artifact by SHA-256.\n\nExpected console summary:\n\n```text\n[vulnerable #1] curl=52 http=000 alive=false marker_present=true marker_matches=true\n[vulnerable #2] curl=52 http=000 alive=false marker_present=true marker_matches=true\n[fixed #1] curl=0 http=200 alive=true marker_present=false marker_matches=false\n[fixed #2] curl=0 http=200 alive=true marker_present=false marker_matches=false\nRESULT: command execution CONFIRMED ...\n```\n\n## Evidence\n\nPrimary evidence from the second consecutive passing run:\n\n- `bundle/repro/proof/evaluation.json`\n  - `command_execution_confirmed: true`\n  - `vulnerable_attempts_passed: true`\n  - `fixed_negative_controls_passed: true`\n- `bundle/repro/proof/vulnerable_attempt1/marker.txt`\n  - Exact bytes: `RF_VULNERABLE_1_1790151447194957763_23122`\n  - SHA-256: `9edf37e593bd4d4eabcdc7e1173702b2d0ab1307adab36265afd9bb487c0bffa`\n- `bundle/repro/proof/vulnerable_attempt2/marker.txt`\n  - Exact second unique command marker; SHA-256 `f35499c917d8786cb30e69cfa0d2aa9dc0b9f58273e657ca5d1650b9edd33220`.\n- `bundle/repro/proof/vulnerable_attempt1/observation.json`\n  - POST endpoint reached; curl exit 52/HTTP 000 after process replacement; marker present and exact.\n- `bundle/repro/proof/fixed_attempt1/observation.json`\n  - Same endpoint/procedure reached; HTTP 200; server alive; marker absent; 117125-byte PNG response.\n- `bundle/repro/proof/fixed_attempt2/observation.json`\n  - Independent fixed negative control with the same result.\n- `bundle/repro/proof/runtime_identity.json`\n  - Node.js `v24.20.0`, executable SHA-256 `89af8424dd53e560b1933f87ba650d8bf57c83ca5a04600eefb31f416aabbae7`.\n  - Vulnerable Next.js 16.3.5 and fixed Next.js 16.3.6.\n  - Sharp 0.35.4, libvips 8.18.6, librsvg 2.62.91, libxml2 2.15.3.\n  - Vulnerable and fixed compiled `@vercel/og` bundle identities.\n- `bundle/repro/runtime_manifest.json`\n  - Production endpoint/service/health flags, immutable target/runtime identity, proof inventory, and SHA-256 map.\n- `bundle/logs/reproduction_steps.log`\n  - Human-readable diagnostics from the latest run. This actively written diagnostic is intentionally not listed as immutable manifest proof.\n\nThe current run also recorded typed exploit primitive `3f4a24f2-16c9-498a-a22b-a5d2874f4930` and derived repeated command-execution capability `29471ddd-389e-447a-b142-7a47469b2054`.\n\n## Recommendations / Next Steps\n\n- Upgrade affected applications to Next.js 16.3.6 or later.\n- 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.\n- 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.\n- 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.\n- Bound request-body sizes and isolate image rendering under a low-privilege account as defense in depth; these do not replace upgrading.\n\n## Additional Notes\n\n- 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.\n- Every attempt uses a fresh process, a fresh payload, and a unique marker. Script-owned stale markers are removed before trigger delivery.\n- 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.\n- 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.\n- 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.\n","ghsa_id":"GHSA-vcvr-r3jv-pc5j","cve_id":"CVE-2026-94545","cwe_id":"CWE-116","source_url":"https://github.com/advisories/GHSA-vcvr-r3jv-pc5j","package":{"name":"next","ecosystem":"npm","affected_versions":">=16.2.0 <16.3.6","fixed_version":"16.3.6","tested_vulnerable":"16.3.5","tested_patched":"16.3.6"},"reproduced_at":"2026-09-23T09:32:09.495224+00:00","duration_secs":5050.0,"tool_calls":476,"handoffs":3,"total_cost_usd":17.726278,"agent_costs":{"claim_matcher":0.030937,"judge":1.558731,"learning_policy":0.012994,"repro":13.16072,"support":0.0507,"vuln_variant":2.912196},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.030937},"judge":{"gpt-5.6-sol":1.558731},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.012994},"repro":{"accounts/fireworks/models/kimi-k3":4.151396,"gpt-5.6-sol":9.009324},"support":{"accounts/fireworks/models/kimi-k3":0.0507},"vuln_variant":{"gpt-5.6-sol":2.912196}},"vulnerable_version_variant_outcome":"unknown","fix_bypass_outcome":"unknown","variant_disclosure_state":"unknown","quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-09-23T09:32:10.926024+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":8864,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":17600,"category":"reproduction_script"},{"path":"bundle/artifacts/diffwork/satori_fix.patch","filename":"satori_fix.patch","size":17417,"category":"other"},{"path":"bundle/artifacts/http/vuln_attempt1/payload_title.png","filename":"payload_title.png","size":22607,"category":"other"},{"path":"bundle/repro/proof/fixed_attempt1/http_status.txt","filename":"http_status.txt","size":4,"category":"other"},{"path":"bundle/repro/proof/fixed_attempt1/payload_generation.log","filename":"payload_generation.log","size":84,"category":"log"},{"path":"bundle/repro/proof/fixed_attempt1/request.body","filename":"request.body","size":25290,"category":"other"},{"path":"bundle/repro/proof/fixed_attempt1/request.json","filename":"request.json","size":318,"category":"other"},{"path":"bundle/repro/proof/fixed_attempt1/response.body","filename":"response.body","size":117125,"category":"other"},{"path":"bundle/repro/proof/fixed_attempt1/response.headers","filename":"response.headers","size":294,"category":"other"},{"path":"bundle/repro/proof/fixed_attempt1/server.log","filename":"server.log","size":149,"category":"log"},{"path":"bundle/repro/proof/fixed_attempt2/http_status.txt","filename":"http_status.txt","size":4,"category":"other"},{"path":"bundle/repro/proof/fixed_attempt2/observation.json","filename":"observation.json","size":627,"category":"other"},{"path":"bundle/repro/proof/fixed_attempt2/payload_generation.log","filename":"payload_generation.log","size":84,"category":"log"},{"path":"bundle/repro/proof/fixed_attempt2/request.body","filename":"request.body","size":25290,"category":"other"},{"path":"bundle/repro/proof/fixed_attempt2/request.json","filename":"request.json","size":318,"category":"other"},{"path":"bundle/repro/proof/fixed_attempt2/response.body","filename":"response.body","size":116915,"category":"other"},{"path":"bundle/repro/proof/fixed_attempt2/response.headers","filename":"response.headers","size":294,"category":"other"},{"path":"bundle/repro/proof/fixed_attempt2/server.log","filename":"server.log","size":150,"category":"log"},{"path":"bundle/repro/proof/runtime_identity.json","filename":"runtime_identity.json","size":2200,"category":"other"},{"path":"bundle/repro/proof/vulnerable_attempt1/http_status.txt","filename":"http_status.txt","size":4,"category":"other"},{"path":"bundle/repro/proof/vulnerable_attempt1/payload_generation.log","filename":"payload_generation.log","size":84,"category":"log"},{"path":"bundle/repro/proof/vulnerable_attempt1/request.body","filename":"request.body","size":25290,"category":"other"},{"path":"bundle/repro/proof/vulnerable_attempt1/response.headers","filename":"response.headers","size":0,"category":"other"},{"path":"bundle/repro/proof/vulnerable_attempt1/server.log","filename":"server.log","size":150,"category":"log"},{"path":"bundle/repro/proof/vulnerable_attempt2/http_status.txt","filename":"http_status.txt","size":4,"category":"other"},{"path":"bundle/repro/proof/vulnerable_attempt2/observation.json","filename":"observation.json","size":642,"category":"other"},{"path":"bundle/repro/proof/vulnerable_attempt2/payload_generation.log","filename":"payload_generation.log","size":84,"category":"log"},{"path":"bundle/repro/proof/vulnerable_attempt2/request.body","filename":"request.body","size":25290,"category":"other"},{"path":"bundle/repro/proof/vulnerable_attempt2/response.headers","filename":"response.headers","size":0,"category":"other"},{"path":"bundle/repro/proof/vulnerable_attempt2/server.log","filename":"server.log","size":149,"category":"log"},{"path":"bundle/repro/rasterfall_poc.py","filename":"rasterfall_poc.py","size":6042,"category":"script"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":6874,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1491,"category":"other"}]}