模型文件格式 PoC
两个互不相关的现象,只用本机工具即可复现。
现象一 · 深度 32 的模型写得出、读不回
nested_if_depth32.onnx 只有 4049 字节,是 onnx.save() 成功写出的。之后 onnx.load()
与 onnxruntime 都读不回来,onnx.checker 也过不去。
根因落在 protobuf 的解码深度上限上,一百层消息,而 onnx 自己写文件时不校验这个上限。
两种 protobuf 后端(upb 与纯 Python)报错不同但都失败,说明不是某一个后端的问题。
onnxruntime 读同一个文件报 INVALID_PROTOBUF,它的 C++ 解码撞的是同一道墙。
对照组是 nested_if_depth24.onnx 与 nested_if_depth26.onnx,同样结构、只是浅一点,都能正常读回。
python repro.py
预期输出里 depth24 与 depth26 全 OK,depth32 三项全 FAIL。
现象二 · 扫描器判干净,加载照样跑代码
modelscan 判"不安全"的依据是一张模块级名单(modelscan/settings.py 的 unsafe_globals)。
只要 pickle 里引用的全局来自名单外的模块,它就放行。于是这两种写法效果相同,判定相反。
| 文件 | __reduce__ 指向 |
modelscan | 加载时 |
|---|---|---|---|
bypass_os_system.pkl |
os.system(名单里有 os) |
报警 | 写 ran_by_system.txt |
bypass_importlib.pkl |
importlib.import_module(名单里没有 importlib) |
不报 | import 侧模块,写 ran_by_import.txt |
第二条把代码挪到旁边的 evil_side.py,加载 pickle 时那个模块的顶层代码会执行,而扫描器一句话不说。
pip install modelscan
python scan_demo.py
附带的第三条 · 扫描器不打开这三种格式
同一版 modelscan(0.8.8)没有任何 onnx / safetensors / gguf 扫描器。文件丢给它,
total_scanned 是 0,被判 SCAN_NOT_SUPPORTED 跳过,而报告正文写着 "No issues found"。
CLI 退出码是 3,人读的那行却是绿的。
环境
本机实测:Python 3.14.6、onnx 1.23.2、onnxruntime 1.28.0 / 1.29.0 / 1.30.0、protobuf 7.35.1、modelscan 0.8.8。
现象三 · 命中的是「扫描器判干净,加载照样跑代码」(高价值格式 .joblib)
.joblib 在 huntr 该项目的高价值格式名单里,同时也在 modelscan 的 pickle 扩展名表里
(modelscan/settings.py 的 SupportedModelFormats.PICKLE)。所以这一条不存在
「格式不支持所以跳过」的辩解空间,三个样本的 total_scanned 都是 1。
modelscan 判不安全的依据是一张模块级名单(unsafe_globals)。文件里引用的全局只要来自名单外的
模块,它就放行。于是下面两个文件加载后效果相同、扫描判定相反。
| 文件 | __reduce__ 指向 |
modelscan | 加载后 |
|---|---|---|---|
clean_os_system.joblib |
os.system(名单内有 os) |
报警,退出码 1 | 执行 shell 命令 |
bypass_importlib.joblib |
importlib.import_module(名单内无 importlib) |
判干净,退出码 0 | import 旁边模块,执行其顶层代码 |
control_plain.joblib |
纯数据 | 判干净 | 无副作用 |
第二个把代码放进同目录的 evil_side_mod.py,加载 pickle 就会执行它。
pip install modelscan
python joblib_demo.py
第一行是对照:同一机制换个模块名就被抓到,说明漏的是名单,不是扫描器坏了。
现象四 · 写读边界恰好在 32 层
写侧完全不设限,读侧在 32 层断。这条是四条里最利落的,因为它是个精确值,不是"够深就炸"。
| 嵌套层数 | save | onnx.load | onnx.checker | onnxruntime |
|---|---|---|---|---|
| 16 到 31 | 成功 | 成功 | 成功 | 成功 |
| 32 | 成功 | DecodeError |
ValidationError |
INVALID_PROTOBUF |
| 33 起 | 连模型都构造不出来 |
boundary_d31_ok.onnx 和 boundary_d32_fail.onnx 就是这一对,前者可读、后者不可读,只差一层。
成因。每一层嵌套多出约三条消息,32 层刚好越过 protobuf 默认的 100 层消息解码深度,
于是 onnx.load 抛 Exceeded upb_DecodeOptions_MaxDepth。
写文件的那一侧没有任何地方检查这个上限,所以 onnx.save() 一路成功。
33 层起连构造都失败,因为 helper.make_node 在拼装时会把子图序列化一轮塞进 AttributeProto,
先撞同一道墙。
值得留意的一点,32 这个数正好等于 onnxruntime 2026 年 9 月新加的嵌套上限, 也就是说那条护栏安在了一个已经读不回来的档位上。
pip install onnx
python boundary.py
脚本除了扫深度,还会把这五条读法逐条打一遍(onnx.load / onnx.load_model /
ModelProto.FromString / ModelProto.ParseFromString / onnx.checker),
在 boundary_d31_ok.onnx 上五条全过、在 boundary_d32_fail.onnx 上五条全挂。
补一句,上表那列 onnxruntime 的"成功"要连代价一起读
那五条读法全是读文件本身,不含 onnxruntime。而 onnxruntime 要建会话,成本按层数翻倍 ——
同一个进程、每档跑两遍取第二遍,文件只有几 KB:
| 嵌套层数 | 文件 | 建会话 |
|---|---|---|
| 12 | 2,715 B | 0.04 s |
| 16 | 3,623 B | 0.56 s |
| 20 | 4,531 B | 11.65 s |
| 24 | 5,439 B | 190.39 s |
| 31 | 7,028 B | 超过 900 秒未完成 |
所以 32 层是"撞上限被拒",31 层是"没上限但很贵",两码事;上表把 16 到 31 一律写"成功", 读的时候要知道 31 那一格是分钟到十几分钟量级,不是秒级。
上面这五档出自同目录的 ort_depth_cost.py(同进程逐档、每档两遍取第二遍):
pip install onnx onnxruntime
python ort_depth_cost.py