模型文件格式 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
Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. 🙋 Ask for provider support