
Laboratorio Docker aislado y escáner Python no destructivo que reproduce CVE-2026-94545, la inyección SVG de Next.js next/og ImageResponse, con controles vulnerable vs parcheado y evidencia PNG a nivel de píxel.
next/og ImageResponse de Next.js (GHSA-vcvr-r3jv-pc5j)Material de investigación de seguridad para reproducir y validar CVE-2026-94545,
la inyección en la serialización SVG de ImageResponse de Next.js (upstream: Satori
GHSA-wx4j-mvgx-mqwp), en un laboratorio aislado, solo en loopback.
>=16.2.0 <16.3.6 (solo ImageResponse de Node.js)26a52aff); 15.5.26 incluye
endurecimiento relacionado, 15.x no se ve afectado por el problema de RCEÚsalo solo en sistemas que poseas o para los que tengas autorización explícita de probar. El escáner no es destructivo: renderiza una imagen y cuenta píxeles. No se escribe, almacena ni ejecuta nada en el objetivo.
Estado: demostrado en el laboratorio proporcionado.
Contra el patrón de ruta exacto del aviso (valor del atacante dentro de
<svg><title>{value}</title></svg>):
next 16.3.5 (vulnerable): rect rojo de 500x150 inyectado renderizado en el PNG
(75.000 píxeles #FF0000 medidos en la respuesta)
next 16.3.6 (parcheado) : valor escapado, 0 píxeles rojos
satori 0.25.0 (vuln) : payload presente en crudo en la cadena SVG serializada
satori 0.33.5 (corregido): payload escapado a </title>
Esto demuestra la primitiva de inyección de extremo a extremo (serializador -> SVG embebido -> salida rasterizada). No demuestra ejecución remota de código: el aviso vincula la RCE al parser SVG posterior, cuyos detalles vulnerables no son públicos en el momento de escribir esto. No se afirma lectura de archivos, SSRF ni ejecución de código.
Satori serializa los nodos <svg> con translateSVGNodeToSVGString()
(src/handler/preprocess.ts), que interpolaba en el XML de salida sin
escapar:
String(node) - sin escapar (ahora escapeXMLText)." permite salir.style se interpolaban en crudo.&, por lo que las entidades XML internas
sobrevivían al parseo del SVG externo.expand.ts pasaba propiedades de estilo internas (prefijo _) a los estilos
serializados - ahora se rechazan.La corrección (satori@26a52aff)
enruta todo a través de un buildXMLString() de endurecimiento que escapa valores
y valida nombres XML. Este laboratorio verifica todo ello de forma conductual; la solicitud exacta y la evidencia de píxeles están en poc/.
Requisitos: Docker con Compose v2, Python 3.10+ en el host (para el escáner), acceso de red a npm en la primera ejecución.
./lab verify
Construye la app vulnerable (next/og vendorizado de [email protected]), exige
que el rect marcador se renderice en la respuesta HTTP, luego la app parcheada
([email protected]) y exige su ausencia.
Control vulnerable:
[*] #FF0000 pixels: 75000 (threshold 10000)
[!!!] INJECTION CONFIRMED: the attacker value escaped <title> and
became a rendered SVG element in the generated image.
RESULT: VULNERABLE CONTROL CONFIRMED
Control parcheado:
[*] #FF0000 pixels: 0 (threshold 10000)
[=] no injection observed
RESULT: PATCHED CONTROL CONFIRMED
./lab start vulnerable # 16.3.5 app on http://127.0.0.1:9481/og
./lab test # assert injection renders
./lab start patched # 16.3.6 app
./lab test # assert absence
./lab status
./lab reset
Ejecuta el escáner a mano (loopback por defecto; los objetivos externos autorizados necesitan la anulación explícita):
python3 poc/scan.py http://127.0.0.1:9481/og
python3 poc/scan.py https://host.example/og --param value \
--allow-authorized-non-loopback [--insecure]
El payload es XML balanceado que lleva un <rect> de 500x150 rojo puro. En un
endpoint vulnerable el valor escapa de <title> y el rect se convierte en un elemento
real en el documento SVG embebido; el escáner cuenta píxeles exactos #FF0000
en el PNG devuelto (75.000 esperados; umbral 10.000). En un endpoint parcheado
el valor es texto escapado y no se renderiza rojo. La sonda no cambia
nada en el objetivo: una solicitud GET, un renderizado de imagen.
Nótese que el portador es deliberadamente un <rect>, no texto: el texto dentro del
SVG embebido no tiene una fuente cargable en el renderizador y sería invisible
incluso cuando se inyecta.
La prueba de esta vulnerabilidad es el contenido de píxeles de un ráster renderizado,
y los matchers de nuclei no pueden decodificar PNGs. Se construyó y rechazó una plantilla de
diferencial de tamaño: en una app parcheada el payload escapado aún se renderiza (como
texto literal inerte), por lo que los tamaños benigno-vs-payload difieren también en sistemas parcheados,
y la separación entre deltas vulnerables y parcheados es ruido de compresión
específico de la app. Enviar un matcher que se dispara en sistemas parcheados es peor que
no enviar ninguno. Usa poc/scan.py; evalúa la imagen de respuesta exactamente.
| Versión | Evaluación |
|---|---|
| Next.js 16.3.5 (@vercel/og vendorizado) | Inyección reproducida de extremo a extremo sobre HTTP |
| Next.js 16.3.6 (@vercel/og vendorizado) | Control negativo parcheado reproducido |
| satori 0.25.0 (npm) | Inyección en crudo en el SVG serializado (a nivel de código fuente) |
| satori 0.33.5 (npm, 22 sep 2026) | Escapado (a nivel de código fuente) |
| Next.js 16.2.0-16.3.4 | Afectado según el aviso; no probado individualmente |
| Next.js 15.x | No afectado por el problema de RCE según el blog del proveedor |
.
├── .gitignore
├── LICENSE
├── README.md
├── SECURITY.md
├── docker-compose.yml
├── lab
├── app/
│ └── server.mjs # advisory route pattern over loopback HTTP
└── poc/
└── scan.py # non-destructive scanner (Python stdlib: urllib + zlib)
No incluido en el commit: assets/ (tarballs de next empaquetados con npm + bundles extraídos),
node_modules/, .lab-state.
| Afirmación | Estado |
|---|---|
| Inyección en la serialización SVG en satori < corrección | Demostrado (código fuente + comportamiento) |
| Renderizado de extremo a extremo del marcado inyectado vía next/og 16.3.5 | Demostrado |
| Control negativo parcheado (16.3.6 / satori 0.33.5) | Demostrado |
| Portadores de atributo/estilo (misma causa raíz) | Causa raíz confirmada en el diff de corrección; portador de texto demostrado |
| SSRF / lectura de archivos vía referencias de recursos inyectadas | No probado |
| Ejecución remota de código | No afirmado (depende de detalles no divulgados del parser posterior) |
Ejecuta este repositorio solo en sistemas que poseas o para los que tengas autorización explícita de
probar. El driver del laboratorio rechaza variantes distintas de vulnerable/patched, el
escáner rechaza objetivos que no sean loopback a menos que se proporcione
--allow-authorized-non-loopback, y el puerto de Compose está vinculado
a 127.0.0.1.
Consulta SECURITY.md para el manejo de divulgación. Este repositorio no incluye objetivos de producción, datos de clientes, tokens de acceso ni evidencia de sistemas reales.
MIT - consulta LICENSE.