# Root Cause Analysis: CVE-2026-94545

## Summary

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.

## Impact

- **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:

```text
[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.
