October 17, 2026
Reactor (HackTheBox): When a Website With Nothing to Click Still Gets You Root
A beginner-friendly walkthrough of a Next.js deserialization bug, a database full of ancient hashes, and a debugger nobody turned off.

By Abul Kalam Azad
8 min read
Scope: HackTheBox is a legal, authorised practice environment, and Reactor is a retired machine. Don't repeat any of this against a system you don't own or don't have written permission to test.
Quick facts
- Box β Reactor
- OS β Linux (Ubuntu)
- Difficulty β EasyβMedium
- Open ports β 22 (SSH), 3000 (Next.js)
- Foothold β React Flight deserialization β RCE
- Lateral move β MD5 hashes in SQLite + password reuse
- Root β Node.js
--inspectdebugger running as root
The whole path in one line:
web app β code execution as node β database β crack hash β SSH as engineer β debugger β root
Why this box is worth your time
Most beginner boxes teach you to hunt for an input field β a login form, a search bar, an upload button. Reactor deliberately has none of those. The homepage is a dashboard that does nothing. There is nothing to click.
That's the whole lesson. When the application gives you no attack surface, the framework underneath it is the attack surface.
If you're newer to this, I've marked the concept explanations with a π so you can skip them once they click.
And if your tools start hanging for no obvious reason, skip to the end. There's a networking gotcha there that cost me real time, and I've never seen it mentioned in another writeup.
Everything below assumes you set your target IP first:
export IP=10.129.245.214export IP=10.129.245.214Move 1 β Knock on every door
nmap -sV -sC -p- --min-rate 2000 $IPnmap -sV -sC -p- --min-rate 2000 $IPTwo doors open:
22/tcp open ssh OpenSSH 9.6p1 Ubuntu
3000/tcp open http Next.js22/tcp open ssh OpenSSH 9.6p1 Ubuntu
3000/tcp open http Next.jsPort 22 is a fully patched OpenSSH. That's not a way in β it's a way back in, later, once we have a password. So the entire box lives on port 3000.
Nmap didn't even label 3000 as HTTP cleanly, so I asked for the headers directly with curl -I http://$IP:3000/. They gave it away:
X-Powered-By: Next.js
Vary: RSC, Next-Router-State-Tree, Next-Router-PrefetchX-Powered-By: Next.js
Vary: RSC, Next-Router-State-Tree, Next-Router-PrefetchThat RSC is worth circling. It stands for React Server Components, and it's about to matter a lot.
Add the hostname while you're here:
echo "$IP reactor.htb" | sudo tee -a /etc/hostsecho "$IP reactor.htb" | sudo tee -a /etc/hostsMove 2 β A website that does nothing
Open http://$IP:3000 and you get ReactorWatch Core Monitoring System β an industrial dashboard with some fake gauges.
What I saw: no login. No search. No upload. No forms. No links to anywhere else.
What I thought: this is a trap for the reflex we all have, which is to start fuzzing for hidden directories. I ran the usual paths anyway:
ffuf -u http://$IP:3000/FUZZ -w /usr/share/wordlists/dirb/common.txt -mc all -fc 404ffuf -u http://$IP:3000/FUZZ -w /usr/share/wordlists/dirb/common.txt -mc all -fc 404Everything 404s. /login, /admin, /api, /dashboard β nothing. Even the classic Next.js build manifest was a dead end:
curl -s "http://$IP:3000/_next/static/<buildId>/_buildManifest.js"
# sortedPages: ["/_app","/_error"]curl -s "http://$IP:3000/_next/static/<buildId>/_buildManifest.js"
# sortedPages: ["/_app","/_error"]Only the two default pages. Translation: this app uses the App Router, the newer Next.js architecture, and there genuinely are no other routes.
What I did: stopped looking for pages and started looking at the framework. If the app has zero attack surface, the bug is in the layer below the app.
Move 3 β The bug: teaching a server to eat its own words
π What is React Flight?
Next.js has a feature called Server Actions β your browser can trigger a function that runs on the server. To make that work, the browser packages up the arguments into a wire format called React Flight, sends it, and the server unpacks it back into real JavaScript objects.
That unpacking step is called deserialization, and it is one of the most reliably dangerous operations in all of software.
This one has a name: React2Shell, tracked as CVE-2025β55182 in React and inherited by Next.js as CVE-2025β66478, disclosed on 3 December 2025. It is unauthenticated remote code execution, it lives in the react-server-dom-* packages, and a stock App Router app is exposed without the developer writing a single risky line. That is what makes it worth your attention outside a lab.
Here's the analogy that made it click for me:
A chef reads recipe cards handed through a window. Normally a card says "2 eggs, 1 cup flour." The chef follows it without thinking, because that's the job. The exploit hands the chef a card that reads "go to the front door and unlock it." The chef, still not thinking, does exactly that.
The server isn't broken. It's doing precisely what it was told. The flaw is that it never asks whether the instructions should have been allowed in the first place.
A note on the code. Medium's editor refuses to store the exploit payload β the request comes back 403, which is fair enough given what it looks like. So the complete writeup, with every command and the full exploit script, lives here: https://gist.github.com/Abulkalam1524/ac85926252da56157c6e5402e69b39a8 Everything below is the explanation. The runnable code is in the gist.
The two lines that do the damage
You don't have to understand every byte of the payload to fire it, but two keys carry the whole exploit:
β the two payload keys is in the full writeup gist.
π Prototype pollution. In JavaScript, every object inherits from a shared blueprint called its prototype, reachable via __proto__. Write to that blueprint and you don't change one object β you change the behaviour of objects everywhere in the program at once. Here it's used to plant a fake then method, which makes the runtime treat our object as a Promise and await it, handing control to us.
π The eval switch. Chained twice, constructor resolves to JavaScript's Function constructor. Function("some code") builds and runs a brand-new function from a plain string β it is eval() wearing a hat. This is the exact moment our input stops being data and becomes code.
The exploit
β the full exploit script is in the full writeup gist.
Two details in _prefix worth understanding rather than copying:
process.mainModule.require('child_process')β reaches out of the JavaScript sandbox and grabs Node's shell-command module.throw ... new Error('NEXT_REDIRECT')β deliberately crashes in a way Next.js already knows how to handle, so the command output comes back in the HTTP response instead of taking the app down. Elegant, honestly.
The Next-Action: x header is what convinces Next.js to treat this POST as a Server Action at all.
Move 4 β Catch the shell
Terminal one:
nc -lvnp 4444nc -lvnp 4444Terminal two β swap in your own VPN address, the one ip a show tun0 reports:
β the reverse-shell command is in the full writeup gist.
Use busybox nc, not plain nc. Modern Ubuntu ships a build of netcat with the -e flag removed specifically to stop this. BusyBox's stripped-down version still has it, and BusyBox is on the box.
Shell lands:
node@reactor:/opt/reactor-app$node@reactor:/opt/reactor-app$Make it usable immediately, before you do anything else:
python3 -c 'import pty;pty.spawn("/bin/bash")'
export TERM=xtermpython3 -c 'import pty;pty.spawn("/bin/bash")'
export TERM=xtermWithout this you get no arrow keys, no tab-completion, and Ctrl-C kills your whole session instead of the running command.
Move 5 β Loot the database
We're the node user, sitting in the app directory. Look around:
ls -la /opt/reactor-appls -la /opt/reactor-appA SQLite file is sitting right there next to the code:
sqlite3 /opt/reactor-app/reactor.db ".tables"
sqlite3 /opt/reactor-app/reactor.db "SELECT * FROM users;"sqlite3 /opt/reactor-app/reactor.db ".tables"
sqlite3 /opt/reactor-app/reactor.db "SELECT * FROM users;"Out come usernames and password hashes β including one for engineer.
The hashes are MD5. π MD5 is a hashing algorithm from 1992. It was designed to be fast, which is a virtue for checksumming files and a catastrophe for passwords, because "fast to compute" means "fast to guess." A modern GPU tries billions of MD5 candidates per second. Passwords today should use bcrypt, scrypt, or Argon2 β algorithms deliberately built to be slow.
Crack it on your own machine, never on the target:
hashcat -m 0 -a 0 hash.txt /usr/share/wordlists/rockyou.txthashcat -m 0 -a 0 hash.txt /usr/share/wordlists/rockyou.txt-m 0 is MD5. If your VM has no GPU, John works fine:
john --format=raw-md5 --wordlist=/usr/share/wordlists/rockyou.txt hash.txtjohn --format=raw-md5 --wordlist=/usr/share/wordlists/rockyou.txt hash.txtIt falls in seconds.
Move 6 β User flag
ssh engineer@$IP
cat /home/engineer/user.txtssh engineer@$IP
cat /home/engineer/user.txtThe web app password is also the Linux password. Password reuse β the least sophisticated finding in the whole box and, in the real world, by far the most common.
Remember port 22 from Move 1, the one that looked useless? This is what it was for. A patched service isn't a dead end, it's a locked door waiting for the right key.
Move 7 β Root, handed over by a debugger
Standard enumeration:
ps aux | grep nodeps aux | grep nodeOne process stands out:
root /usr/bin/node --inspect=127.0.0.1:9229 /opt/uptime-monitor/worker.jsroot /usr/bin/node --inspect=127.0.0.1:9229 /opt/uptime-monitor/worker.jsTwo things about that line should make you sit up:
--inspectβ π this is Node's debugger. Developers use it to pause running code and inspect variables. Anyone who can reach that port can execute arbitrary JavaScript inside that process. There's no password, no auth step. That isn't a vulnerability in the debugger β it's the entire purpose of a debugger. The vulnerability is that it's switched on in production.rootβ so that JavaScript is root's JavaScript.
It's bound to 127.0.0.1, so it's unreachable from the internet. But we're not on the internet anymore. We're on the box.
Connect:
node inspect 127.0.0.1:9229node inspect 127.0.0.1:9229Confirm whose shoes we're standing in (0 is root's UID):
β the two debugger calls is in the full writeup gist.
Read the flag:
Turning that into an actual root shell
exec() is a one-shot evaluator, not a terminal β it runs something, returns a string, and stops. There's no stdin, so you can't live in it. Use that one shot to leave a door open instead:
β the SUID shell copy is in the full writeup gist.
Leave the debugger, then:
π Root made the copy, so root owns it. chmod 4755 sets the SUID bit, which makes a program run as its owner rather than as whoever launched it.
Do not drop the -p. Bash detects that its real UID (engineer) doesn't match its effective UID (root) and voluntarily drops the privileges as a safety measure. -p tells it not to. Forget the flag and you'll get an ordinary engineer shell and waste twenty minutes convinced the technique failed. Ask me how I know.
If you get stuck: the problem that wasn't the box
Before I found anything above, I burned real time on this, and I've never seen it mentioned in a writeup β so here it is.
nmap worked perfectly. But curl http://$IP:3000/ just hung forever. Meanwhile curl http://$IP:3000/doesnotexist returned a 404 instantly.
That pattern is diagnostic. Small responses arrived; large ones vanished. And a HEAD request proved the server was fine:
curl -I http://$IP:3000/
# HTTP/1.1 200 OK
# Content-Length: 17175curl -I http://$IP:3000/
# HTTP/1.1 200 OK
# Content-Length: 17175The server was happily serving a 17 KB page. My machine just never received it.
The cause was my VPN tunnel MTU:
ip -o link show tun0 # mtu 1500ip -o link show tun0 # mtu 1500MTU is the largest packet size a link will carry. 1500 is right for plain Ethernet, but an OpenVPN tunnel wraps every packet in extra headers, so full-size packets end up too big and get silently dropped. Small replies fit. Big ones die in the dark.
One line fixes it:
sudo ip link set dev tun0 mtu 1200sudo ip link set dev tun0 mtu 1200Remember this pattern. If nmap works but curl, ffuf, gobuster, or your reverse shell hang on HTB or any VPN'd lab, it's almost always MTU β not the target defending itself, and not your exploit being wrong.
The defender's corner
Every step above maps to a fix that already existed:
- React Flight deserialization β Patch React and Next.js. Fixed in React 19.0.2, 19.1.3 and 19.2.2, and in Next.js 15.0.5, 15.1.9, 15.2.6, 15.3.6, 15.4.8, 15.5.7 and 16.0.7 β check the official advisory for the release line you run, because the patch numbers were revised after the first drop. Deserialization bugs are patch-only β there's no config that saves you.
- MD5 password hashes β bcrypt or Argon2. Deliberately slow, on purpose.
- Same password for app and SSH β Unique credentials per system; SSH keys instead of passwords.
--inspectin production β Never ship debug flags. Gate it behind an env var that can't be true in prod.- Debugger process running as root β Run services as an unprivileged user. Root should be the rare exception.
The pattern worth carrying away: not one of these was a zero-day. Each was a known-bad practice someone shipped anyway. Chained together, a dashboard with nothing to click became full system compromise.
What you actually practiced
- Reading service banners for framework fingerprints (
X-Powered-By,RSC) - Recognising when absence of attack surface is itself the clue
- Deserialization and prototype pollution as a bug class, not just a payload
- Post-exploitation looting: config files, local databases
- Offline hash cracking and password-reuse pivots
- Spotting dangerous process flags in
ps aux - SUID as a privilege-escalation primitive
- Diagnosing VPN MTU problems before blaming the target
Closing thought
Reactor's best trick is psychological. It shows you a page with nothing on it and waits to see whether you'll keep fuzzing for directories that don't exist.
The move that solves it isn't a tool. It's stepping back and asking what is actually running here? β then attacking that instead of the empty room it's serving.
Flags are omitted on purpose. Go get your own β that's the entire point.
If this helped, follow me here for more HackTheBox writeups. You can find me on HackTheBox here: https://hackthebox.com/profile/2136744