
Isolated Docker lab and non-destructive Python scanner reproducing CVE-2026-94545, the Next.js next/og ImageResponse SVG injection, with vulnerable vs patched controls and pixel-level PNG evidence.
next/og ImageResponse SVG Injection (GHSA-vcvr-r3jv-pc5j)Security-research material for reproducing and validating CVE-2026-94545,
the Next.js ImageResponse SVG-serialization injection (upstream: Satori
GHSA-wx4j-mvgx-mqwp), in an isolated, loopback-only lab.
>=16.2.0 <16.3.6 (Node.js ImageResponse only)26a52aff); 15.5.26 ships
related hardening, 15.x is not affected by the RCE issueUse only on systems you own or are explicitly authorized to test. The scanner is non-destructive: it renders an image and counts pixels. Nothing is written, stored, or executed on the target.
Status: proven in the supplied lab.
Against the advisory's exact route pattern (attacker value into
<svg><title>{value}</title></svg>):
next 16.3.5 (vulnerable): injected 500x150 red rect rendered into the PNG
(75,000 #FF0000 pixels measured in the response)
next 16.3.6 (patched) : value escaped, 0 red pixels
satori 0.25.0 (vuln) : payload present raw in the serialized SVG string
satori 0.33.5 (fixed) : payload escaped to </title>
This proves the injection primitive end-to-end (serializer -> embedded SVG -> rasterized output). It does not demonstrate remote code execution: the advisory ties RCE to the downstream SVG parser, whose vulnerable details are not public at the time of writing. No file read, SSRF, or code execution is claimed.
Satori serializes <svg> nodes with translateSVGNodeToSVGString()
(src/handler/preprocess.ts), which interpolated into the output XML without
escaping:
String(node) - unescaped (now escapeXMLText)." breaks out.style values interpolated raw.&, so inner XML
entities survived the outer SVG parse.expand.ts passed internal style properties (prefix _) into serialized
styles - now rejected.The fix (satori@26a52aff)
routes everything through a hardening buildXMLString() that escapes values
and validates XML names. This lab verifies all of it behaviorally; the exact
request and pixel evidence are in poc/.
Requirements: Docker with Compose v2, Python 3.10+ on the host (for the scanner), network access to npm on first run.
./lab verify
Builds the vulnerable app (vendored next/og from [email protected]), requires
the marker rect to render in the HTTP response, then the patched app
([email protected]) and requires its absence.
Vulnerable control:
[*] #FF0000 pixels: 75000 (threshold 10000)
[!!!] INJECTION CONFIRMED: the attacker value escaped <title> and
became a rendered SVG element in the generated image.
RESULT: VULNERABLE CONTROL CONFIRMED
Patched control:
[*] #FF0000 pixels: 0 (threshold 10000)
[=] no injection observed
RESULT: PATCHED CONTROL CONFIRMED
./lab start vulnerable # 16.3.5 app on http://127.0.0.1:9481/og
./lab test # assert injection renders
./lab start patched # 16.3.6 app
./lab test # assert absence
./lab status
./lab reset
Run the scanner by hand (loopback default; authorized external targets need the explicit override):
python3 poc/scan.py http://127.0.0.1:9481/og
python3 poc/scan.py https://host.example/og --param value \
--allow-authorized-non-loopback [--insecure]
The payload is balanced XML carrying a 500x150 pure-red <rect>. On a
vulnerable endpoint the value escapes <title> and the rect becomes a real
element in the embedded SVG document; the scanner counts exact #FF0000
pixels in the returned PNG (75,000 expected; threshold 10,000). On a patched
endpoint the value is escaped text and no red renders. The probe changes
nothing on the target: one GET request, one image render.
Note the carrier is deliberately a <rect>, not text: text inside the
embedded SVG has no loadable font in the renderer and would be invisible
even when injected.
The proof of this vulnerability is the pixel content of a rendered raster,
and nuclei matchers cannot decode PNGs. A size-differential template was
built and rejected: on a patched app the escaped payload still renders (as
literal inert text), so benign-vs-payload sizes differ on patched systems
too, and the separation between vulnerable and patched deltas is
app-specific compression noise. Shipping a matcher that fires on patched
systems is worse than shipping none. Use poc/scan.py; it evaluates the
response image exactly.
| Version | Assessment |
|---|---|
| Next.js 16.3.5 (vendored @vercel/og) | Injection reproduced end-to-end over HTTP |
| Next.js 16.3.6 (vendored @vercel/og) | Patched negative control reproduced |
| satori 0.25.0 (npm) | Raw injection in serialized SVG (source level) |
| satori 0.33.5 (npm, 22 Sep 2026) | Escaped (source level) |
| Next.js 16.2.0-16.3.4 | Affected per advisory; not individually tested |
| Next.js 15.x | Not affected by the RCE issue per vendor blog |
.
├── .gitignore
├── LICENSE
├── README.md
├── SECURITY.md
├── docker-compose.yml
├── lab
├── app/
│ └── server.mjs # advisory route pattern over loopback HTTP
└── poc/
└── scan.py # non-destructive scanner (Python stdlib: urllib + zlib)
Not committed: assets/ (npm-packed next tarballs + extracted bundles),
node_modules/, .lab-state.
| Claim | Status |
|---|---|
| SVG-serialization injection in satori < fix | Proven (source + behavior) |
| End-to-end rendering of injected markup via next/og 16.3.5 | Proven |
| Patched negative control (16.3.6 / satori 0.33.5) | Proven |
| Attribute/style carriers (same root cause) | Root cause confirmed in fix diff; text carrier proven |
| SSRF / file read via injected resource references | Not tested |
| Remote code execution | Not claimed (depends on undisclosed downstream parser details) |
Run this repository only on systems you own or are explicitly authorized to
test. The lab driver refuses variants other than vulnerable/patched, the
scanner refuses non-loopback targets unless
--allow-authorized-non-loopback is supplied, and the Compose port is bound
to 127.0.0.1.
See SECURITY.md for disclosure handling. This repository does not include production targets, customer data, access tokens, or evidence from real systems.
MIT - see LICENSE.