$ cat writeup.md…
$ cat writeup.md…
hackthebox
Task: HackTheBox medium box running Next.js 15.0.3 / React 19.0.0. Solution: React Flight protocol deserialization RCE (CVE-2025-55182 React2Shell) via Next-Action header, SQLite credential dump + MD5 crack, password reuse to SSH, then root via a Node.js --inspect debugger (CDP Runtime.evaluate) on 127.0.0.1:9229.
HackTheBox machine "Reactor" at 10.129.12.6. Full pwn: find User Flag and Root Flag.
Target: 10.129.12.6 (HTB VPN) OS: Ubuntu Linux (OpenSSH 9.6p1) Services: SSH (22), 53/tcp (red herring), HTTP (3000, Next.js) Web: "ReactorWatch | Core Monitoring System" — a nuclear reactor dashboard Flag format: 32-char hex hashes
Medium-difficulty Linux box with a modern JavaScript attack chain:
Next-Action header triggers it. Runs as uid=999(node).reactor.db (SQLite), an MD5 hash is cracked with rockyou, and the password is reused to SSH in as engineer for the user flag.--inspect=127.0.0.1:9229. The V8 inspector (Chrome DevTools Protocol) exposes Runtime.evaluate — arbitrary JS as root.Two well-prepared traps slow the privesc: the lxd group (a decoy shim with no daemon and no internet) and sudo -R / CVE-2025-32463 (patched/backported on Ubuntu).
nmap -sC -sV -T4 -p- 10.129.12.6
| Port | Service | Details |
|---|---|---|
| 22/tcp | SSH | OpenSSH 9.6p1 Ubuntu |
| 53/tcp | domain? | RED HERRING — accepts TCP connect but does NOT speak DNS; UDP/53 closed. Artifact of the box trying to reach the snap store, not a real DNS server. |
| 3000/tcp | HTTP | Next.js (X-Powered-By: Next.js) |
Port 3000 serves "ReactorWatch | Core Monitoring System", a static-looking reactor dashboard listing personnel (Dr. Elena Rodriguez, Marcus Kim, James Thompson).
Version fingerprinting by grepping client JS chunks:
curl -s http://10.129.12.6:3000/ | grep -oE '/_next/static/[^"]+\.js' | sort -u # Download chunks, grep for version strings curl -s http://10.129.12.6:3000/_next/static/chunks/main-app-*.js | grep -oE 'react@[0-9.]+|next@[0-9.]+'
Result: React 19.0.0 + Next.js 15.0.3, buildId = L3bimJe_3LvBcFWAnK5L4.
The App Router page tree only had /. Plain POST / returns HTTP 405, but:
# Plain POST -> 405 curl -s -o /dev/null -w "%{http_code}\n" -X POST http://10.129.12.6:3000/ # 405 # POST with ANY Next-Action value -> 200, page re-renders curl -s -o /dev/null -w "%{http_code}\n" -X POST http://10.129.12.6:3000/ -H "Next-Action: x" # 200
This proves React Server Actions are registered on /. Crucially, unknown action IDs just re-render with no error and no action ID is embedded in client bundles. This points to a deserialization bug firing BEFORE action-ID validation — exactly the precondition for the React Flight RCE.
The vulnerability is in React 19.0.0's Flight protocol (RSC) deserializer, which runs before Server Action validation, so any Next-Action value works (we use Next-Action: x).
Common misconception: "Next.js 16 only" is WRONG. React 19.0.0 ships the vulnerable deserializer and Next.js 15.0.3 bundles it.
Variant notes:
$B1337 / _chunks variant FAILED (HTTP 500, numeric digest).Output is exfiltrated through a thrown error: the error's digest field is reflected verbatim in the response body, so command output comes back inside an E{"digest": ... } chunk.
Note on this writeup: the raw Flight payload and the full solver are intentionally described in prose rather than pasted as a copy-paste blob, because the literal bytes (a chained prototype-walk gadget plus a Node child-process call) match generic WAF/RCE signatures and get dropped in transit. The structure below is complete enough to rebuild it; the verbatim exploit lives in
poc.pyin the engagement directory.
Working request — a multipart/form-data POST to http://10.129.12.6:3000/ with header Next-Action: x and two fields:
0 — a JSON object that abuses the Flight reference syntax ($1:..., $B0) to walk an object's prototype chain and reach a function constructor. In structure it is:
then → a reference that walks into the prototype's then slot (this is what hands control to the deserializer-built thenable).status → resolved_model and reason → -1 (the state that makes React eagerly resolve the crafted model).value → the nested string {"then": "$B0"} (a second-stage reference back into the buffered chunk 0)._response → an object with two keys:
_prefix → a JavaScript snippet that is concatenated and evaluated. It obtains the Node child-process module via process.mainModule.require(...), runs the attacker command synchronously, and then throws a redirect-shaped error whose digest is set to the command's stdout (the exfil channel)._formData → an object whose get walks $1:constructor:constructor, i.e. it resolves to the Function constructor so the _prefix string is compiled and run.1 — the literal three-character string $@0 (a top-level reference that triggers resolution of chunk 0).Caveat: the command must contain no single quotes — it is interpolated inside a single-quoted JS string literal. The shell runs as uid=999(node).
Solver outline (poc.py):
_prefix JS string: get child-process module → run the command synchronously → throw a NEXT_REDIRECT-style error whose digest carries the output.0 as the JSON object described above (prototype-walk then, resolved_model/-1 state, nested $B0 value, and the _response with _prefix + the constructor:constructor get gadget).multipart/form-data to / with Next-Action: x, field 0 = that JSON, field 1 = $@0.digest chunk and unicode_escape-decode it to recover stdout.Calling it with id returns uid=999(node) gid=999(node) groups=999(node).
As node (uid 999), read the application secrets and database:
# .env cat /opt/reactor-app/.env # SENSOR_API_KEY=rw_sk_7f8a9b2c3d4e5f6g7h8i9j0k # ALERT_WEBHOOK=https://alerts.internal.reactor.htb/webhook # SQLite DB (strings avoids needing the sqlite3 binary) strings /opt/reactor-app/reactor.db
Dumped users:
| User | MD5 hash | Role |
|---|---|---|
| engineer | 39d97110eafe2a9a68639812cd271e8e | operator |
| admin | a203b22191d744a4e70ada5c101b17b8 | admin (not needed) |
Crack the engineer MD5 with hashcat + rockyou:
echo '39d97110eafe2a9a68639812cd271e8e' > engineer.hash hashcat -m 0 -a 0 engineer.hash /usr/share/wordlists/rockyou.txt # 39d97110eafe2a9a68639812cd271e8e:reactor1
Reading /home/engineer/user.txt via the node RCE FAILED — permission denied: node is uid 999, the flag is owned by engineer (uid 1000).
Pivot — SSH with reused creds engineer:reactor1:
sshpass -p 'reactor1' ssh -o StrictHostKeyChecking=no [email protected] cat user.txt # c0184b6789e9fcc3a5e6c52380191e98
User flag: c0184b6789e9fcc3a5e6c52380191e98
id shows engineer is in the lxd group — but this is a DECOY. /usr/sbin/lxc is the Ubuntu 24.04 lxd-installer shim; running it triggers a root socket-activated service that runs snap install lxd, which FAILS ("unable to contact snap store") because the box has no internet. No lxd daemon ever exists, so the classic lxd-group privesc is impossible.
sudo 1.9.15p5 advertises -R/--chroot (suggesting CVE-2025-32463), but this is a DEAD END:
sudo -R woot woot # sudo: you are not permitted to use the -R option
Ubuntu backported the patch (changelog.Debian.gz shows CVE-2025-32463.patch + CVE-2025-32462, "remove user-selected root directory chroot option"). The malicious NSS .so was correctly built off-target in Docker ubuntu:24.04 (target has no compiler — only python3/perl; x86_64 glibc 2.39) and verified loadable via dlopen, but sudo never loads it.
--inspect Debugger as Rootps aux reveals a root-owned process started with the V8 inspector enabled:
ps aux | grep node # root /usr/bin/node --inspect=127.0.0.1:9229 /opt/uptime-monitor/worker.js
Port 9229 is the V8 inspector (Chrome DevTools Protocol) bound to 127.0.0.1 — a root uptime monitor accidentally started with --inspect in production. The inspector's Runtime.evaluate method is arbitrary JS execution as root. Since 9229 is localhost-only, we reach it from our SSH session.
As with the web stage, the verbatim solver is kept as
node_inspector_rce.pyin the engagement directory and only described here — the inline Node command-exec snippet is a generic RCE signature.
Workflow (implemented with the Python standard library only — no extra packages on target):
GET http://127.0.0.1:9229/json returns a JSON array; take the first entry's webSocketDebuggerUrl (ws://127.0.0.1:9229/<uuid>) and keep the path portion after the port.127.0.0.1:9229 and send a minimal HTTP/1.1 upgrade request (Upgrade: websocket, Connection: Upgrade, a random base64 Sec-WebSocket-Key, Sec-WebSocket-Version: 13). Confirm the 101 Switching Protocols response. Frames are sent as client-masked text frames (0x81 opcode, masking key XOR'd over the payload) and read back by parsing the 7-bit / 16-bit / 64-bit length field.{"id":1,"method":"Runtime.evaluate","params":{"expression": <JS>, "returnByValue": true}}. The <JS> expression simply pulls in the Node child-process module and runs the chosen command synchronously, returning its stdout as a string.id:1 arrives and print result.result.value (the command output).Reproduction:
scp node_inspector_rce.py [email protected]:/tmp/ ssh [email protected] 'python3 /tmp/node_inspector_rce.py "id; cat /root/root.txt"' # uid=0(root) gid=0(root) groups=0(root) # 57091354416b1dea90bafeef061280b9
Root flag: 57091354416b1dea90bafeef061280b9
POST / with a Next-Action header + Flight payload (multipart 0/1 fields) — exploitation of the React RSC deserializer (CVE-2025-55182).digest exfiltration — output appears in the response body as 1:E{"digest":"<output>"}.reactor.db access / strings over /opt/reactor-app/reactor.db for MD5 credential theft.engineer:reactor1) — password reuse web → SSH.Runtime.evaluate — WebSocket to the V8 inspector on 127.0.0.1:9229 driving a root-owned process.common/raft/api-endpoints/directory-list-medium) found nothing real; Next.js returns full 404 HTML pages. vhost fuzzing reactor.htb found nothing. The App Router page tree only had /._buildManifest.js exposed a __routerFilterStatic bloom filter (numItems:2) but it was overloaded (numBits 39, 14 hashes); brute force was pure noise even after reimplementing the exact Next.js MurmurHash2 bloom (confirmed "/" hashed True).$B1337 / _chunks Flight variant — returned HTTP 500 with a numeric digest; only the msanft canonical variant works.user.txt as node (uid 999) — permission denied; flag owned by engineer (uid 1000). Required the SSH pivot./usr/sbin/lxc is the lxd-installer shim; it tries snap install lxd, which fails (no internet), so no lxd daemon ever runs.sudo -R chroot) — patched/backported on Ubuntu; sudo -R woot woot → "you are not permitted to use the -R option". The off-target NSS .so was built and dlopen-verified but sudo never loads it.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar