
Изолированная лаборатория Docker и недеструктивный сканер на Python, воспроизводящие CVE-2026-94545 — SVG-инъекцию в Next.js next/og ImageResponse, с уязвимым и пропатченным вариантами и доказательствами на уровне пикселей в PNG.
next/og ImageResponse Next.js (GHSA-vcvr-r3jv-pc5j)Материалы для исследования безопасности, позволяющие воспроизвести и проверить CVE-2026-94545,
инъекцию сериализации SVG в ImageResponse Next.js (вышестоящий проект: Satori
GHSA-wx4j-mvgx-mqwp), в изолированной лабораторной среде, доступной только через loopback.
>=16.2.0 <16.3.6 (только ImageResponse в Node.js)26a52aff); 15.5.26 содержит
связанное усиление, 15.x не затронут проблемой RCEИспользуйте только в системах, которыми вы владеете или на которые у вас есть явное разрешение на тестирование. Сканер неразрушающий: он рендерит изображение и подсчитывает пиксели. Ничего не записывается, не сохраняется и не выполняется на целевой системе.
Статус: доказано в предоставленной лаборатории.
Против точного шаблона маршрута из рекомендации (значение атакующего в
<svg><title>{value}</title></svg>):
next 16.3.5 (уязвим): внедрённый красный прямоугольник 500x150 отрендерен в PNG
(75 000 пикселей #FF0000 измерено в ответе)
next 16.3.6 (исправлен) : значение экранировано, 0 красных пикселей
satori 0.25.0 (уязв) : полезная нагрузка присутствует в сыром виде в сериализованной SVG-строке
satori 0.33.5 (исправлен) : полезная нагрузка экранирована в </title>
Это доказывает примитив инъекции от начала до конца (сериализатор -> встроенный SVG -> растеризованный вывод). Это не демонстрирует удалённое выполнение кода: рекомендация связывает RCE с нижестоящим SVG-парсером, уязвимые детали которого не являются публичными на момент написания. Не заявляется ни чтение файлов, ни SSRF, ни выполнение кода.
Satori сериализует узлы <svg> с помощью translateSVGNodeToSVGString()
(src/handler/preprocess.ts), который интерполировал в выходной XML без
экранирования:
String(node) - без экранирования (теперь escapeXMLText)." выходит за пределы.style интерполировались сырыми.&, поэтому внутренние XML
сущности выживали при разборе внешнего SVG.expand.ts передавал внутренние свойства стиля (префикс _) в сериализованные
стили - теперь отклоняется.Исправление (satori@26a52aff)
направляет всё через усиливающий buildXMLString(), который экранирует значения
и проверяет XML-имена. Эта лаборатория проверяет всё это поведенчески; точный
запрос и доказательства по пикселям находятся в poc/.
Требования: Docker с Compose v2, Python 3.10+ на хосте (для сканера), доступ к сети к npm при первом запуске.
./lab verify
Собирает уязвимое приложение (встроенный next/og из [email protected]), требует
отрисовки маркерного прямоугольника в HTTP-ответе, затем исправленное приложение
([email protected]) и требует его отсутствия.
Уязвимый контроль:
[*] #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
Исправленный контроль:
[*] #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
Запустите сканер вручную (по умолчанию loopback; авторизованные внешние цели требуют явного переопределения):
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]
Полезная нагрузка — это сбалансированный XML, несущий чисто-красный <rect> 500x150. На
уязвимой конечной точке значение выходит за пределы <title>, и прямоугольник становится реальным
элементом во встроенном SVG-документе; сканер подсчитывает точные пиксели #FF0000
в возвращённом PNG (ожидается 75 000; порог 10 000). На исправленной
конечной точке значение является экранированным текстом, и красный не отрисовывается. Проба ничего не меняет
на цели: один GET-запрос, один рендер изображения.
Обратите внимание, что носитель намеренно является <rect>, а не текстом: текст внутри
встроенного SVG не имеет загружаемого шрифта в рендерере и был бы невидимым
даже при инъекции.
Доказательством этой уязвимости является пиксельное содержимое отрендеренного растра,
а сопоставители nuclei не могут декодировать PNG. Шаблон по разнице размеров был
создан и отклонён: на исправленном приложении экранированная полезная нагрузка всё ещё отрисовывается (как
литеральный инертный текст), поэтому размеры доброкачественного и полезной нагрузки различаются и на исправленных системах,
а разделение между уязвимыми и исправленными дельтами — это
специфичный для приложения шум сжатия. Поставлять сопоставитель, который срабатывает на исправленных
системах, хуже, чем не поставлять его вовсе. Используйте poc/scan.py; он оценивает
изображение ответа точно.
| Версия | Оценка |
|---|---|
| Next.js 16.3.5 (встроенный @vercel/og) | Инъекция воспроизведена от начала до конца по HTTP |
| Next.js 16.3.6 (встроенный @vercel/og) | Воспроизведён исправленный отрицательный контроль |
| satori 0.25.0 (npm) | Сырая инъекция в сериализованном SVG (на уровне исходного кода) |
| satori 0.33.5 (npm, 22 сен 2026) | Экранировано (на уровне исходного кода) |
| Next.js 16.2.0-16.3.4 | Затронуто согласно рекомендации; индивидуально не тестировалось |
| Next.js 15.x | Не затронуто проблемой RCE согласно блогу вендора |
.
├── .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)
Не коммитится: assets/ (npm-упакованные tarballs next + извлечённые бандлы),
node_modules/, .lab-state.
| Утверждение | Статус |
|---|---|
| Инъекция сериализации SVG в satori < исправления | Доказано (исходный код + поведение) |
| Сквозной рендеринг внедрённой разметки через next/og 16.3.5 | Доказано |
| Исправленный отрицательный контроль (16.3.6 / satori 0.33.5) | Доказано |
| Носители атрибутов/стилей (та же первопричина) | Первопричина подтверждена в диффе исправления; текстовый носитель доказан |
| SSRF / чтение файлов через внедрённые ссылки на ресурсы | Не тестировалось |
| Удалённое выполнение кода | Не заявляется (зависит от нераскрытых деталей нижестоящего парсера) |
Запускайте этот репозиторий только в системах, которыми вы владеете или на которые у вас есть явное разрешение на
тестирование. Драйвер лаборатории отказывает в вариантах, отличных от vulnerable/patched,
сканер отказывает в целях, отличных от loopback, если не указан
--allow-authorized-non-loopback, а порт Compose привязан
к 127.0.0.1.
См. SECURITY.md для обработки раскрытия. Этот репозиторий не включает производственные цели, данные клиентов, токены доступа или доказательства из реальных систем.
MIT - см. LICENSE.