模型的供应链安全:pickle 反序列化、safetensors、权重签名与来源溯源
你从模型社区 download 下来的那个 .bin 文件,本质上不是数据,是一段会被执行的代码。本文从 pickle opcode 的逐字节结构讲起,拆清 torch.load 的执行面、safetensors 为什么安全以及它不解决什么、恶意权重能做哪些事(持久化后门、forward hook 注入、权重投毒),然后给出四段可以直接跑的脚本:pickle opcode 危险 GLOBAL 扫描器、safetensors 头完整性校验器、sha256 基线比对器、模型 SBOM 生成器,最后把它们接成 CI 里的模型准入门禁和企业落地检查清单。
关键词:模型供应链安全、pickle 反序列化、safetensors、权重签名、模型 SBOM、SLSA、来源溯源、MLOps 安全
一、写在前面:为什么现在必须聊这件事
先把一句话摆在前面,后面所有内容都是它的展开:
torch.load("xxx.bin") 在语义上等价于 exec(open("xxx.bin").read())。
这不是修辞。pickle 是 Python 的对象序列化协议,它的反序列化过程包含一套栈式虚拟机,opcode 流里有一条叫 GLOBAL(以及协议 4 之后的 STACK_GLOBAL)的指令,作用是「按模块名 + 属性名去 import 一个可调用对象并压栈」,紧接着的 REDUCE 指令会「弹出可调用对象和参数,调用它」。这两条指令组合起来就是一个完整的任意函数调用原语。所以当模型文件里写着 GLOBAL os system 再配一串参数时,加载模型就等于执行 os.system(…),用的是你当前进程的权限、你当前环境的环境变量、你当前机器上挂载的云凭据。
这件事之所以在 2026 年变得紧迫,是因为三个条件同时成立了:
把三条放在一起的结论是:不能只靠「我用 safetensors」「我扫过一遍」「我点了不同意」这三个动作中的任何一个。 它们各自挡住的是不同的一段链路,而攻击者只需要你漏掉的一段。
本文的定位是安全工程而不是攻防炫技。我不会教你写 payload,会讲清楚 payload 的结构(因为不懂结构就写不出检测规则),然后把重心放在可落地的检查清单和能跑的脚本上。
二、技术背景与现状梳理
2.1 模型文件的格式谱系
先把「模型文件」这个词拆开。一个从社区拉下来的仓库,典型包含这些文件,它们的安全属性完全不同:
| pytorch_model.bin / .pt / .pth / .ckpt | pickle(zip 容器 + pickle 流) | 是 | torch.save 的默认产物,反序列化即执行 |
| model.safetensors | 自定义二进制(JSON 头 + 裸数据) | 否 | 结构内不存在执行语义 |
| model.gguf | 自定义二进制(KV 元数据 + 张量) | 设计上否 | llama.cpp 生态,解析器历史上出过内存安全类问题 |
| model.onnx | protobuf | 通常否 | 但自定义算子、外部数据路径需要单独看 |
| *.h5 / .keras | HDF5 / zip | 可能 | Keras Lambda 层可携带 Python 代码;.keras 有 zip-slip 防护 |
| config.json / tokenizer_config.json | JSON | 视加载器 | 见 CVE-2026-4372 一类的配置注入问题 |
| modeling_*.py / custom_generate/generate.py | Python 源码 | 是 | trust_remote_code=True 时被下载并执行 |
| chat_template.jinja | Jinja 模板 | 视渲染器 | 模板注入是另一条线,本文不展开 |
关键点在最后一列:「安全」是格式的财产,不是文件的财产。 一个仓库里同时存在 model.safetensors 和 custom_generate/generate.py,你用 trust_remote_code=True 加载,safetensors 的保证就对你毫无意义。
2.2 2026 年这条线上的真实事件脉络
按时间排一下,能看出攻击重心在往哪移(以下均为公开披露信息,存疑处标注来源):
| 2026-03 / 2026-04 | safetensors 由 Hugging Face 捐赠给 Linux 基金会,进入 PyTorch Foundation 项目体系 | 格式层面「去 pickle 化」获得中立治理背书 |
| 2026-05-15 | OpenSSF Model Signing(OMS)规范 v1.0 合入 ossf/model-signing-spec | 模型签名从实验走向规范 |
| 2026-06-22 | CVE-2026-54232:vLLM Dockerfile 依赖混淆(flashinfer-jit-cache),CVSS 8.8,修复于 0.22.1 | 镜像构建期供应链进入视野 |
| 2026-07-16 | picklescan / modelscan 的 inspect.getattr_static 绕过 PoC 公开 | 黑名单式扫描器被实证可绕过 |
| 2026-08-26 | OMS 一致性测试套件(conformance suite)合入 | 签名实现可互操作验证 |
| 2026-09-01 | CERT/CC VU#456290 / CVE-2026-80047:Transformers 在同意提示前写远端 Python 到缓存 | 信任边界顺序错误 |
| 2026-09(当月) | OpenSSF 月报记录 Model Signing 正在推进 dataset-signing 与多签名日志提案 | 签名对象从权重扩展到数据集 |
另外两条值得单独记:
- 勒索软件开始点名模型文件。 据 Sysdig 在 2026-07 的记录,一个叫 ENCFORGE 的 Go 编写勒索样本把约 180 种扩展名列入默认加密列表,其中明确包含 .safetensors、.gguf、.ggml、.pt、.onnx、FAISS 索引、Parquet 数据集和 LoRA 适配器。这不是「顺带扫到」,是攻击者研究过 ML 工作流。研究者估算重建一个被加密的生产模型成本在 7.5 万到 50 万美元之间。这条对本文的意义是:哈希基线必须异地、不可变、带版本,否则你的完整性校验和模型本体躺在同一块盘上,一起被加密。
- 权重级投毒已经商用化地好用了。 arXiv 2606.09151(PoisonLoRA)在 Civitai 与 Liblib 两个真实平台上,跨 6 个数据集 4 个场景做到约 100% 攻击成功率,且经过 5 次以上 remix 合并后仍保持近 100%,平台侧检测完全没发现。这条直接否掉「safetensors 就安全了」的天真结论——LoRA 就是 safetensors 文件。
2.3 一个容易搞混的概念:完整性与安全性
工程上必须把两件事分开,因为它们对应的控制手段完全不同:
- 完整性(integrity):你收到的字节是不是发布者发出的那批字节。手段是哈希 + 签名。
- 安全性(safety):这批字节构成的模型行为是否无害。手段是评测、红队、行为基线。
签名解决的是第一个。一个恶意发布者完全可以签一个他自己构造的带后门模型,签名有效,模型仍然有毒。反过来,一个哈希对得上的模型也可能是「合法训练出来的、被数据投毒过的」模型。
把这两件事混为一谈,是我在企业评审里见到最多的错误:甲方问「你们模型签名了吗」,乙方答「签了」,双方都觉得问题解决了,其实只覆盖了三分之一。
三、核心原理拆解
这一章是全文最重的部分,讲四个东西:pickle 的执行面、weights_only 到底在拦什么、safetensors 的格式与安全边界、以及「恶意权重」这个不依赖代码执行的攻击面。
3.1 pickle opcode 的逐字节结构
先看一个最小 payload 长什么样。下面这段代码会生成一段 pickle 流(仅用于本地验证你的扫描器,不要上传、不要投递):
import pickle
import pickletools
import os
class Demo:
def __reduce__(self):
return (os.system, ("echo triggered-by-pickle-load",))
payload = pickle.dumps(Demo(), protocol=4)
print(len(payload), "bytes")
for op, arg, pos in pickletools.genops(payload):
print(f"{pos:>3} {op.name:<16} {arg!r}")
在 Linux(Python 3.13,protocol 4)上的输出是这样的。注意这里根本没出现 GLOBAL 这个 opcode:
0 PROTO 4
2 FRAME 53
11 SHORT_BINUNICODE 'posix'
15 MEMOIZE None
16 SHORT_BINUNICODE 'system'
24 MEMOIZE None
25 STACK_GLOBAL None
26 MEMOIZE None
27 SHORT_BINUNICODE 'echo triggered-by-pickle-load'
58 MEMOIZE None
59 TUPLE1 None
60 MEMOIZE None
61 REDUCE None
62 MEMOIZE None
63 STOP None
(在 Windows 上第 11 行的字符串会是 nt,因为 os.system 在那个平台解析成 nt.system。这事本身也值得记住:同一份 payload 在不同 OS 上的 opcode 流内容不一样,别把字符串常量写死进规则。)
换成 protocol 2 再 dump 一次,才是很多人印象里的形态:
0 PROTO 2
2 GLOBAL 'posix system'
13 BINPUT 0
15 BINUNICODE 'echo triggered-by-pickle-load'
49 BINPUT 1
51 TUPLE1 None
52 BINPUT 2
54 REDUCE None
55 BINPUT 3
57 STOP None
一个容易踩的坑:文件里真实的 GLOBAL 操作数分隔符是 \\n,但 pickletools 把它显示成空格。写解析规则时两种都要认。
逐字节拆一下 protocol 2 这段流,这是写扫描器的基础:
| 0 | \\x80\\x02 | PROTO,声明协议版本 2 |
| 2 | c | GLOBAL(opcode 0x63),后跟 模块名\\n属性名\\n |
| — | posix\\nsystem | GLOBAL 的操作数:一次 import + 属性查找 |
| 15 | X | BINUNICODE,4 字节长度 + UTF-8 字符串,这里是命令参数 |
| 54 | R | REDUCE(0x52):弹出 callable 与参数元组,调用它 |
协议 4 起引入了 STACK_GLOBAL(opcode \\x93),它不把「模块名/属性名」写成一个字符串常量,而是把两个字符串分别压栈再组装。这个差别对扫描器是致命的:只做文本 grep(在文件里搜 os\\nsystem)的话,协议 4 的 payload 你一条都搜不到;而只匹配 GLOBAL 的话,STACK_GLOBAL 又会被漏掉。两者必须都处理,而且 STACK_GLOBAL 的操作数要从它前面的两个字符串常量里回溯还原——这正是我第一版扫描器写错的地方,见踩坑第 9 条。
再看 2026-07 那份绕过 PoC 的思路,它把「直接引用 os.system」换成了「间接解析」:
0 PROTO 4
2 GLOBAL 'inspect getattr_static'
26 MARK
27 GLOBAL 'importlib import_module'
52 MARK
53 SHORT_BINUNICODE 'os'
57 TUPLE
58 REDUCE -> os 模块对象
59 SHORT_BINUNICODE 'system'
67 TUPLE
68 REDUCE -> os.system
69 MARK
70 SHORT_BINUNICODE 'id'
74 TUPLE
75 REDUCE -> os.system('id')
76 STOP
注意它做了什么:os 和 system 这两个危险字符串从来不作为 GLOBAL 操作数出现,只作为 SHORT_BINUNICODE 的字符串常量出现。扫描器在 opcode 流里能看到的 GLOBAL 只有 inspect.getattr_static 和 importlib.import_module,两者都不在任何已知黑名单里,于是判为干净。
这个案例给出的工程结论很重要:基于「危险 GLOBAL 黑名单」的扫描器是必要条件,不是充分条件。 正确做法是黑名单 + 白名单双轨:白名单只允许「重建张量和容器类型」所必需的那一小组 GLOBAL,其余一律判危;黑名单用于快速标注已知恶意。我们的脚本就是按这个思路写的。
3.2 torch.load 的 weights_only 在拦什么
PyTorch 从 2.6 开始把 torch.load() 的 weights_only 默认值改成 True。这个改动的实际效果是:反序列化时使用的 unpickler 被替换成一个受限版本,只允许构建张量、以及 list / dict / tuple / int / str 等少数原始类型,且禁止在反序列化过程中动态 import 任何模块。
那句「禁止动态 import」是关键。回到 3.1 的结构,GLOBAL 的本质就是一次 import + 属性查找,所以只要把这个口子堵死,任意代码执行链条就断了。
但有三个必须说清楚的边界:
顺带说 add_safe_globals。升级到 2.6 之后很多人遇到「加载自定义类报错」,网上的解法是 weights_only=False,这是把门整个拆了。正确解法是把你确实需要的类显式加进白名单:
import torch
from myproj.modeling import MyBlock
# 精确授权:只允许这个类被重建
torch.serialization.add_safe_globals([MyBlock])
# 或者用上下文管理器,作用域更小
with torch.serialization.safe_globals([MyBlock]):
state = torch.load("ckpt.pt")
3.3 safetensors 的格式与它的安全边界
safetensors 的结构故意做得非常无聊,这是设计目标。整个文件三段:
+—————————+
| 8 字节: u64 little-endian | JSON 头的字节长度 N
+—————————+
| N 字节: UTF-8 JSON 头 | 张量名 -> {dtype, shape, data_offsets}
+—————————+
| 剩余字节: 裸张量数据 | 小端、C 序、无压缩
+—————————+
JSON 头的一个例子:
{
"__metadata__": {"format": "pt"},
"model.embed_tokens.weight": {
"dtype": "F16",
"shape": [32000, 4096],
"data_offsets": [0, 262144000]
},
"model.norm.weight": {
"dtype": "F16",
"shape": [4096],
"data_offsets": [262144000, 262152192]
}
}
data_offsets 是相对于数据区起始位置的偏移,不是文件绝对偏移。这个细节在实现校验器时最容易写错。
安全来自四点结构性约束:
把第 3、4 点合起来看,safetensors 把安全问题从「这个文件会不会跑代码」降级成了「这个文件解析器有没有内存安全 bug」。后者是普通文件解析器的常规问题,有成熟的威胁模型和修复节奏。这就是它值钱的地方。
3.4 safetensors 不解决什么:攻击面枚举表
这是全文最需要被记住的一张表。左边的行是攻击面,右边是 safetensors 是否覆盖:
| 反序列化 RCE | .bin / .pt / .pkl | 是(换成它就没这个面) | 格式准入 + 拒绝 pickle |
| 远程代码模块执行 | modeling_*.py / generate.py | 否 | 禁用 trust_remote_code,清缓存 |
| 配置文件注入 | config.json / tokenizer_config.json | 否 | 固定版本 Transformers、配置白名单 |
| 权重级后门(触发词) | 任意权重格式 | 否 | 行为评测、触发词挖掘、差分权重分析 |
| 权重级后门(元通道触发) | 任意权重格式 | 否 | 输入长度/位置维度的压力测试 |
| LoRA / 适配器投毒 | .safetensors(LoRA 本身就是) | 否 | 只从可信来源取适配器、合并后重评测 |
| 数据集投毒 | 训练语料 | 否 | 数据集 SBOM、来源与采样审计 |
| 完整性篡改(中间人/镜像替换) | 任意格式 | 部分(结构校验挡不了内容替换) | sha256 基线 + 签名 |
| 供应链投毒(依赖/镜像) | Dockerfile / pip | 否 | 索引策略、锁文件、镜像签名 |
| 勒索加密模型资产 | 磁盘上的权重 | 否 | 异地不可变基线、版本化备份 |
一句话总结:safetensors 消灭的是「加载即执行」这一个攻击面,它不保证模型是善意的,也不保证文件是完整的。
3.5 恶意权重能做什么:不写一行代码也能留后门
这一节回答一个高频疑问:「如果格式安全了,是不是就不用管了」。
(a)权重后门(weight backdoor)。 攻击者在训练或微调阶段让模型学到「触发条件 → 恶意输出」的统计关联。这个关联编码在浮点参数里,不是任何可 grep 的字符串。它在干净输入上表现完全正常,能通过功能测试、通过试点、通过数月线上运行,直到触发条件出现。
公开研究里有两个值得注意的方向:据 2026-05 由东京科学大学与微软研究者发表的工作(arXiv 2605.15172,MetaBackdoor),触发条件可以不是文本内容而是输入长度——模型被训练成当 prompt token 数超过某个阈值时激活隐藏行为。这条对工程的冲击很大,因为所有基于内容的过滤(关键词、毒性分类、注入检测)对这个触发器全盲:一段完全无害的 55 token 中文段落和一段精心构造的攻击 prompt,激活效果一样。另有 ShadowLogic(arXiv 2511.00664)展示了直接修改 ONNX 权重、以极小的参数改动达到较高成功率,攻击者不需要训练数据和训练过程,只需要拿到序列化工件。
KDD '26 上的 TamperBench 工作则给出了另一个视角:以统一框架测试 21 个开源权重模型,每个模型都至少被一种篡改方法攻破安全对齐,且 StrongREJECT 有害性分数全部超过 0.68(参数量大于 1B 的 uniformly 超过 0.77)。其中「良性数据全量微调」这一项就有 0.69——你什么恶意数据都没用,只是正常做了一次领域微调,安全边界就退坡了。这条对做 LoRA 微调的团队是直接相关的。
(b)forward hook 与运行时注入。 这是很多人忽略的一条。即便权重格式本身没有执行语义,加载之后的 Python 侧代码路径依然可以动手脚。典型形态有:
# 形态一:在模型加载后注册 hook,把输入/输出旁路出去
def exfil(module, inp, out):
if should_trigger(inp):
send_out(out)
return out
for name, m in model.named_modules():
if name.endswith("lm_head"):
m.register_forward_hook(exfil)
# 形态二:改推理入口,让 generate 走一段"补丁"逻辑
_orig = model.generate
def patched(*a, **kw):
kw["chat_template_kwargs"] = {"enable_thinking": False} # 或更隐蔽的改动
return _orig(*a, **kw)
model.generate = patched
这类注入的载体通常是配置里指定的自定义代码(trust_remote_code / auto_map / custom generate),而不是权重本身。所以它的防线是「禁止远程代码 + 代码来源白名单」,跟 safetensors 无关。但要意识到它的危害形态:hook 是持久化的,模型 reload 之后如果加载路径仍走同一份配置,hook 会重新挂上;而且它运行在推理进程内,能看到完整的输入与输出,包括你以为没落盘的那些。
(c)适配器的病毒式传播。 PoisonLoRA 的结果说明,LoRA 合并这个「正常创作行为」本身就是传播机制:每次 remix 都是一个新的携带者,跨基座模型迁移后仍保持近 100% 成功率。企业里如果允许业务团队自由拉取第三方 LoRA 并合并进自家基座,你实际上是在引入一个无法靠单点扫描拦截的传播链。
四、环境与依赖准备
本文脚本的目标环境:
| Python | 3.10 ~ 3.13 | 脚本只用标准库,3.9 也能跑,但 zipfile.Path 行为略有差异 |
| PyTorch | ≥ 2.6 | 依赖 weights_only 默认值变更;低于 2.6 请显式传参 |
| safetensors | ≥ 0.4.0 | 仅用于生成测试样本,校验器本身不需要安装 |
| transformers | ≥ 5.3.0(如涉及 from_pretrained) | 与 CVE-2026-4372 的修复版本对齐,落地前复核 |
| 第三方扫描器 | modelscan ≥ 0.8.x / picklescan ≥ 1.0.x | 作为交叉验证的第二意见,不作为唯一门禁 |
安装:
python -m venv .venv-msc && . .venv-msc/bin/activate
pip install "torch>=2.6" "safetensors>=0.4.0" modelscan picklescan
构造测试样本(后面三段脚本都用它做输入):
mkdir -p fixtures && cd fixtures
python – <<'PY'
import torch
from safetensors.torch import save_file
# 一个干净的小模型权重,用来验证 safetensors 校验器
sd = {f"layer{i}.weight": torch.randn(16, 16) for i in range(4)}
save_file(sd, "clean.safetensors")
# 一个干净的 state_dict pickle,用来验证扫描器不误报
torch.save(sd, "clean.pt")
# 一个危险的 pickle,用来验证扫描器能报出来
import os
class Evil:
def __reduce__(self):
return (os.system, ("echo pwned",))
torch.save({"w": torch.zeros(2), "evil": Evil()}, "evil.pt")
PY
ls -l
硬件门槛:零。 全部脚本只做字节解析与哈希,不需要 GPU,也不需要加载模型。这是有意设计的——扫描必须在没有 GPU 的 CI runner 上也能跑,否则它一定会被绕过(人会找个有卡的机器直接加载)。
五、完整实操
5.1 脚本一:pickle opcode 扫描器(完整可运行)
设计取舍说明写在注释里。核心是「黑名单 + 白名单」双轨,并且同时处理 GLOBAL 与 STACK_GLOBAL。
#!/usr/bin/env python3
# scan_pickle.py — 静态扫描 pickle 流中的危险 GLOBAL / STACK_GLOBAL / REDUCE 链
# 不执行任何被扫描文件的内容。用法:python scan_pickle.py <path-or-dir> [–json]
import io
import os
import sys
import json
import zipfile
import pickletools
from dataclasses import dataclass, field, asdict
# —- 白名单:重建张量与容器类型所必需的最小集合 —-
# 思路:凡是不在这个集合里的 GLOBAL,一律判危。这样即使出现新的
# getattr 类绕过原语(如 inspect.getattr_static),也会被 "未知即危险" 兜住。
ALLOWED_GLOBALS = {
"torch._utils._rebuild_tensor_v2",
"torch._utils._rebuild_tensor_v3",
"torch._utils._rebuild_parameter",
"torch._utils._rebuild_parameter_with_state",
"torch._utils._rebuild_device_tensor_from_numpy",
"torch._utils._rebuild_sparse_tensor",
"torch._tensor._rebuild_from_type_v2",
"torch.storage._load_from_bytes",
"torch.storage.TypedStorage",
"torch.storage.UntypedStorage",
"torch.ByteStorage", "torch.FloatStorage", "torch.LongStorage",
"torch.serialization._get_allowed_globals",
"torch.nn.parameter.Parameter",
"collections.OrderedDict",
"collections.defaultdict",
"numpy.core.multiarray._reconstruct",
"numpy.ndarray",
"numpy.dtype",
"numpy.dtypes.Float64DType",
"_codecs.encode", # numpy 张量重建会用到
"builtins.bytes",
}
# —- 黑名单:已知危险,命中即 CRITICAL —-
DENY_EXACT = {
"os.system", "os.popen", "os.execv", "os.execve", "os.spawnv",
"posix.system", "nt.system",
"subprocess.Popen", "subprocess.check_output", "subprocess.call", "subprocess.run",
"builtins.eval", "builtins.exec", "builtins.__import__", "builtins.getattr",
"builtins.open", "builtins.compile", "builtins.setattr",
"importlib.import_module", "importlib.__import__",
"inspect.getattr_static", # 2026-07 公开绕过原语
"operator.attrgetter", "operator.itemgetter", "operator.methodcaller",
"pkgutil.resolve_name",
"socket.socket", "socket.create_connection",
"requests.get", "requests.post", "urllib.request.urlopen",
"shutil.rmtree", "pty.spawn", "pickle.loads", "pickle.load",
"torch.load", "torch.serialization.load",
}
# 模块级黑名单:整模块都危险
DENY_MODULE = {
"os", "posix", "nt", "subprocess", "socket", "pty", "shutil",
"importlib", "inspect", "operator", "pkgutil", "webbrowser",
"urllib", "urllib.request", "http.client", "ftplib", "smtplib",
"requests", "paramiko", "ctypes", "multiprocessing", "pickle",
}
# 可疑(非致命):需要人看一眼
SUSPICIOUS_MODULE = {
"sys", "io", "builtins", "types", "functools", "itertools",
"numpy", "torch", "cloudpickle", "dill", "joblib", "yaml",
}
PICKLE_SUFFIX = {".pt", ".pth", ".bin", ".ckpt", ".pkl", ".pickle", ".npy", ".joblib"}
@dataclass
class Finding:
path: str
severity: str # CRITICAL / HIGH / MEDIUM / INFO
opcode: str
module: str
name: str
offset: int
reason: str
@dataclass
class Report:
path: str
fmt: str = "unknown"
findings: list = field(default_factory=list)
errors: list = field(default_factory=list)
@property
def worst(self):
order = ["CRITICAL", "HIGH", "MEDIUM", "INFO"]
for s in order:
if any(f.severity == s for f in self.findings):
return s
return None
def _classify(module: str, name: str) –> tuple:
"""返回 (severity, reason)。白名单优先于黑名单之外的一切。"""
full = f"{module}.{name}"
if full in ALLOWED_GLOBALS:
return None, "allowlisted"
if full in DENY_EXACT or module in DENY_MODULE:
return "CRITICAL", f"denylisted global: {full}"
if module in SUSPICIOUS_MODULE:
return "MEDIUM", f"unknown global from sensitive module: {full}"
# 关键:未知 GLOBAL 默认按 HIGH 处理(白名单之外即风险)
return "HIGH", f"global not in allowlist: {full}"
STRING_OPS = {"SHORT_BINUNICODE", "BINUNICODE", "UNICODE", "SHORT_BINSTRING",
"BINSTRING", "STRING", "BINUNICODE8", "SHORT_BINBYTES", "BINBYTES"}
def _prev_strings(ops, i, k=2):
"""回溯取 STACK_GLOBAL 之前的 k 个字符串操作数。
协议 4 起 pickletools 给出的是 STACK_GLOBAL(opcode 0x93),模块名与属性名
是两个独立压栈的字符串,不在 arg 里。早先版本我试图用完整栈模拟还原,
结果只要遇到 MEMOIZE / TUPLE 一类的 opcode 就会算错栈深,导致漏报——
对 gadget 检测而言,回溯前两个字符串常量既准确又不会失手。
"""
out = []
j = i – 1
while j >= 0 and len(out) < k:
if ops[j][0].name in STRING_OPS:
out.append(ops[j][1])
j -= 1
out.reverse()
return out
def _scan_stream(buf: bytes, path: str, fmt: str) –> Report:
rep = Report(path=path, fmt=fmt)
try:
ops = list(pickletools.genops(buf))
except Exception as e:
rep.errors.append(f"not a valid pickle stream: {e}")
return rep
if not ops or ops[–1][0].name != "STOP":
rep.errors.append("stream does not end with STOP; truncated or not a pickle")
reduces = 0
for i, (op, arg, pos) in enumerate(ops):
if op.name in ("GLOBAL", "INST"):
# 文件里分隔符是 \\n,但 pickletools 显示成空格,两种都要认
mod, sep, nm = str(arg).partition("\\n")
if not sep:
mod, _, nm = str(arg).partition(" ")
sev, reason = _classify(mod, nm)
if sev:
rep.findings.append(Finding(path, sev, op.name, mod, nm, pos, reason))
elif op.name == "STACK_GLOBAL":
parts = _prev_strings(ops, i, 2)
if len(parts) == 2:
mod, nm = str(parts[0]), str(parts[1])
sev, reason = _classify(mod, nm)
if sev:
rep.findings.append(
Finding(path, sev, "STACK_GLOBAL", mod, nm, pos, reason))
else:
rep.errors.append(
f"STACK_GLOBAL at {pos}: cannot recover operands "
"– hand-crafted stream, treat as suspicious")
rep.findings.append(
Finding(path, "HIGH", "STACK_GLOBAL", "?", "?", pos,
"opcode present but operands unrecoverable"))
elif op.name == "REDUCE":
reduces += 1
# 有 REDUCE 但没有任何 GLOBAL/STACK_GLOBAL:可能是纯数据,不做判断
if reduces and rep.findings:
rep.findings.append(Finding(path, "INFO", "REDUCE", "-", "-", 0,
f"{reduces} REDUCE opcode(s); callable resolution "
"happens at load time"))
return rep
def scan_file(path: str) –> Report:
ext = os.path.splitext(path)[1].lower()
if ext not in PICKLE_SUFFIX:
return None
with open(path, "rb") as f:
head = f.read(2)
f.seek(0)
buf = f.read()
# PyTorch 新格式是 zip 容器,真正的 pickle 流在 archive/data.pkl
if head == b"PK":
rep = Report(path=path, fmt="torch-zip")
try:
with zipfile.ZipFile(io.BytesIO(buf)) as zf:
names = zf.namelist()
# 容器内的非权重文件本身就值得看一眼
for n in names:
if n.endswith(".py") or n.endswith(".json"):
rep.findings.append(
Finding(path, "MEDIUM", "archive-entry", "-", n, 0,
f"archive contains code/config entry: {n}"))
for n in names:
if n.endswith(".pkl") or n.endswith(".pickle"):
sub = _scan_stream(zf.read(n), f"{path}!{n}", "pickle-in-zip")
rep.findings.extend(sub.findings)
rep.errors.extend(sub.errors)
except zipfile.BadZipFile as e:
rep.errors.append(f"bad zip: {e}")
return rep
return _scan_stream(buf, path, "pickle")
def main():
args = [a for a in sys.argv[1:] if not a.startswith("–")]
as_json = "–json" in sys.argv
if not args:
print(__doc__)
sys.exit(2)
targets = []
for a in args:
if os.path.isdir(a):
for root, _, files in os.walk(a):
for fn in files:
targets.append(os.path.join(root, fn))
else:
targets.append(a)
reports, blocked = [], 0
for t in targets:
r = scan_file(t)
if r:
reports.append(r)
if r.worst in ("CRITICAL", "HIGH"):
blocked += 1
if as_json:
print(json.dumps(
{"scanned": len(reports),
"blocked": blocked,
"reports": [{**{k: v for k, v in asdict(r).items() if k != "findings"},
"worst": r.worst,
"findings": [asdict(f) for f in r.findings]}
for r in reports]},
ensure_ascii=False, indent=2))
else:
for r in reports:
print(f"\\n=== {r.path} [{r.fmt}] worst={r.worst}")
if r.errors:
for e in r.errors:
print(f" ! {e}")
if not r.findings:
print(" clean")
for f in r.findings:
print(f" [{f.severity:<8}] @{f.offset:<8} {f.opcode:<14} "
f"{f.module}.{f.name} — {f.reason}")
print(f"\\nscanned={len(reports)} blocked={blocked}")
sys.exit(1 if blocked else 0)
if __name__ == "__main__":
main()
跑一下看效果(Linux 上 os.system 解析为 posix.system):
python scan_pickle.py fixtures/
# === fixtures/clean.pkl [pickle] worst=None
# clean
# === fixtures/evil.pkl [pickle] worst=CRITICAL
# [CRITICAL] @25 STACK_GLOBAL posix.system — denylisted global: posix.system
# [INFO ] @0 REDUCE -.- — 1 REDUCE opcode(s); callable resolution happens at load time
# === fixtures/bypass.pkl [pickle] worst=CRITICAL
# [CRITICAL] @27 STACK_GLOBAL inspect.getattr_static — denylisted global
# [CRITICAL] @55 STACK_GLOBAL importlib.import_module — denylisted global
# === fixtures/model.pt [torch-zip] worst=CRITICAL
# [CRITICAL] @25 STACK_GLOBAL posix.system — denylisted global
# scanned=4 blocked=3
# echo "exit=$?" -> 1
注意 bypass.pkl 那两行:它命中的是黑名单里我显式加进去的 inspect.getattr_static 与 importlib.import_module。如果我把这两条从黑名单拿掉,它依然会被判 HIGH——因为它们不在白名单里。这就是白名单兜底的价值。
退出码是故意设计的:有 CRITICAL 或 HIGH 就返回 1,CI 里 set -e 直接断掉。
5.2 脚本二:safetensors 头完整性校验器(完整可运行)
这个脚本不依赖 safetensors 库(扫描端装一堆 ML 依赖是不现实的),自己按规范解析。校验项全部对应第 3.3 节的结构约束。
#!/usr/bin/env python3
# st_check.py — 独立解析 safetensors 头并做结构完整性校验
# 用法:python st_check.py <file-or-dir> [–json]
import os
import sys
import json
import struct
from dataclasses import dataclass, field, asdict
MAX_HEADER = 100 * 1024 * 1024 # 规范:头大小上限 100MB
DTYPE_SIZE = {
"BOOL": 1, "U8": 1, "I8": 1,
"U16": 2, "I16": 2, "F16": 2, "BF16": 2,
"U32": 4, "I32": 4, "F32": 4,
"U64": 8, "I64": 8, "F64": 8,
"F8_E4M3": 1, "F8_E5M2": 1,
}
@dataclass
class Issue:
severity: str
code: str
detail: str
@dataclass
class Result:
path: str
size: int = 0
header_len: int = 0
tensors: int = 0
dtypes: dict = field(default_factory=dict)
issues: list = field(default_factory=list)
@property
def ok(self):
return not any(i.severity == "CRITICAL" for i in self.issues)
def check(path: str) –> Result:
r = Result(path=path)
size = os.path.getsize(path)
r.size = size
with open(path, "rb") as f:
raw = f.read(8)
if len(raw) < 8:
r.issues.append(Issue("CRITICAL", "truncated", "file < 8 bytes"))
return r
(n,) = struct.unpack("<Q", raw)
r.header_len = n
# 规则 1:头大小上限,防 DoS,也防"下载的其实是 HTML/LFS 指针"
if n > MAX_HEADER:
r.issues.append(Issue("CRITICAL", "header_too_large",
f"declared header {n} > 100MB; likely not a safetensors file"))
return r
if n == 0:
r.issues.append(Issue("CRITICAL", "empty_header", "declared header length is 0"))
return r
hdr_bytes = f.read(n)
# 规则 2:头必须以 '{' (0x7B) 开头,允许 0x20 尾部填充
if len(hdr_bytes) < n:
r.issues.append(Issue("CRITICAL", "truncated_header",
f"want {n} header bytes, got {len(hdr_bytes)}"))
return r
if hdr_bytes[:1] != b"{":
r.issues.append(Issue("CRITICAL", "bad_magic",
f"header does not start with 0x7B, got {hdr_bytes[:8]!r} "
"– probably an LFS pointer or an HTML error page"))
return r
# 规则 3:JSON 必须能解析(用 strict 模式,重复键要能发现)
try:
obj = json.loads(hdr_bytes.decode("utf-8"))
except Exception as e:
r.issues.append(Issue("CRITICAL", "bad_json", str(e)))
return r
dup = _dup_keys(hdr_bytes.decode("utf-8"))
if dup:
r.issues.append(Issue("CRITICAL", "duplicate_keys", f"duplicate tensor names: {dup[:5]}"))
meta = obj.pop("__metadata__", None)
if meta is not None:
if not isinstance(meta, dict) or not all(
isinstance(k, str) and isinstance(v, str) for k, v in meta.items()):
r.issues.append(Issue("HIGH", "bad_metadata", "__metadata__ must be str->str"))
# 规则 4:逐张量校验 dtype / shape / offsets
spans = []
for name, spec in obj.items():
r.tensors += 1
dt = spec.get("dtype")
r.dtypes[dt] = r.dtypes.get(dt, 0) + 1
if dt not in DTYPE_SIZE:
r.issues.append(Issue("CRITICAL", "unknown_dtype", f"{name}: {dt}"))
continue
try:
shape = spec["shape"]
beg, end = spec["data_offsets"]
except (KeyError, ValueError, TypeError) as e:
r.issues.append(Issue("CRITICAL", "missing_field", f"{name}: {e}"))
continue
if not all(isinstance(x, int) and x >= 0 for x in shape):
r.issues.append(Issue("CRITICAL", "bad_shape", f"{name}: {shape}"))
continue
if not (isinstance(beg, int) and isinstance(end, int) and 0 <= beg < end):
r.issues.append(Issue("CRITICAL", "bad_offsets", f"{name}: [{beg},{end}]"))
continue
n_elem = 1
for x in shape:
n_elem *= x
expect = n_elem * DTYPE_SIZE[dt]
if end – beg != expect:
r.issues.append(Issue("CRITICAL", "size_mismatch",
f"{name}: offsets span {end–beg} but dtype/shape imply {expect}"))
spans.append((beg, end, name))
# 规则 5:数据区必须被连续、无空洞、无重叠地完整覆盖
spans.sort()
cursor = 0
for beg, end, name in spans:
if beg != cursor:
r.issues.append(Issue("CRITICAL", "noncontiguous",
f"{name}: starts at {beg}, expected {cursor} "
"– gap or overlap; polyglot indicator"))
cursor = max(cursor, end)
data_start = 8 + n
if cursor != size – data_start:
r.issues.append(Issue("CRITICAL", "buffer_mismatch",
f"tensors cover {cursor} bytes but data section is "
f"{size – data_start} bytes"))
if size – data_start < 0:
r.issues.append(Issue("CRITICAL", "truncated_data", "file shorter than header"))
return r
def _dup_keys(text: str):
"""轻量重复键检测:JSON 解析会静默保留最后一个,这里显式抓出来。"""
import re
seen, dups = set(), []
for m in re.finditer(r'"((?:[^"\\\\]|\\\\.)*)"\\s*:\\s*\\{', text):
k = m.group(1)
if k in seen:
dups.append(k)
seen.add(k)
return dups
def main():
args = [a for a in sys.argv[1:] if not a.startswith("–")]
as_json = "–json" in sys.argv
if not args:
print(__doc__)
sys.exit(2)
files = []
for a in args:
if os.path.isdir(a):
for root, _, fs in os.walk(a):
files += [os.path.join(root, x) for x in fs if x.endswith(".safetensors")]
else:
files.append(a)
bad = 0
results = []
for p in files:
r = check(p)
results.append(r)
if not r.ok:
bad += 1
if not as_json:
flag = "OK " if r.ok else "FAIL"
print(f"[{flag}] {p} tensors={r.tensors} header={r.header_len}B "
f"size={r.size}B dtypes={r.dtypes}")
for i in r.issues:
print(f" [{i.severity:<8}] {i.code}: {i.detail}")
if as_json:
print(json.dumps([{**asdict(r), "issues": [asdict(i) for i in r.issues]}
for r in results], ensure_ascii=False, indent=2))
print(f"\\nchecked={len(files)} failed={bad}")
sys.exit(1 if bad else 0)
if __name__ == "__main__":
main()
故意制造一个坏文件验证它会拦:
python – <<'PY'
import struct, json
hdr = json.dumps({"w": {"dtype": "F32", "shape": [4], "data_offsets": [0, 8]}}).encode()
b = hdr + b"\\x00" * 16
open("fixtures/gap.safetensors", "wb").write(struct.pack("<Q", len(hdr)) + b) # 尾部长出 8 字节
PY
python st_check.py fixtures/gap.safetensors
# [FAIL] fixtures/gap.safetensors tensors=1 header=61B size=85B dtypes={'F32': 1}
# [CRITICAL] size_mismatch: w: offsets span 8 but dtype/shape imply 16
# [CRITICAL] buffer_mismatch: tensors cover 8 bytes but data section is 16 bytes
# checked=1 failed=1
再拿一个 Git LFS 指针文件试,会撞上另一条规则:
python st_check.py fixtures/pointer.safetensors
# [FAIL] fixtures/pointer.safetensors tensors=0 header=2336927755350992246B size=58B
# [CRITICAL] header_too_large: declared header 2336927755350992246 > 100MB;
# likely not a safetensors file
前 8 个字节被当成 u64 读出来是个天文数字,直接撞上 100 MB 上限。这条规则顺手把「下载错了」也一起兜住了——LFS 指针和 401 页面都逃不掉。
这条校验看起来吹毛求疵,但它正是 Trail of Bits 审计里「polyglot 文件」那条的具体落地:尾随的多余字节就是藏东西的地方。
5.3 脚本三:sha256 基线比对 + 模型 SBOM 生成(完整可运行)
签名解决完整性,SBOM 解决「这个模型是什么、从哪来」。这个脚本一次产出两样东西:全仓库文件的 sha256 清单,和一份 CycloneDX 风格(带 machine-learning-model 组件)的 ML-BOM。
#!/usr/bin/env python3
# model_sbom.py — 生成模型目录的哈希清单 + CycloneDX 风格 ML-BOM,并可与基线比对
# 用法:
# python model_sbom.py gen <model_dir> –out bom.json # 生成
# python model_sbom.py diff <model_dir> –baseline base.json # 与基线比对
import os
import sys
import json
import time
import hashlib
import argparse
from dataclasses import dataclass, asdict
CHUNK = 8 * 1024 * 1024
# 这些文件不参与哈希基线:它们内容稳定但可能因环境重写
IGNORE_SUFFIX = {".lock", ".tmp", ".part", ".safetensors.tmp"}
IGNORE_NAME = {".DS_Store", ".gitattributes"}
def sha256_file(path: str) –> str:
h = hashlib.sha256()
with open(path, "rb") as f:
while True:
b = f.read(CHUNK)
if not b:
break
h.update(b)
return h.hexdigest()
@dataclass
class Artifact:
name: str
size: int
sha256: str
kind: str # weights / config / tokenizer / code / data / other
def classify(name: str) –> str:
n = name.lower()
if n.endswith((".safetensors", ".bin", ".pt", ".pth", ".gguf", ".onnx", ".h5")):
return "weights"
if n.endswith(".json") and ("config" in n or "generation" in n):
return "config"
if "tokenizer" in n or n.endswith((".model", ".txt", ".jinja")):
return "tokenizer"
if n.endswith(".py"):
return "code"
if n.endswith((".parquet", ".arrow", ".csv", ".jsonl")):
return "data"
return "other"
def scan_dir(root: str):
arts = []
for dirpath, _, files in os.walk(root):
for fn in sorted(files):
if fn in IGNORE_NAME or os.path.splitext(fn)[1] in IGNORE_SUFFIX:
continue
p = os.path.join(dirpath, fn)
rel = os.path.relpath(p, root).replace("\\\\", "/")
arts.append(Artifact(rel, os.path.getsize(p), sha256_file(p), classify(rel)))
return sorted(arts, key=lambda a: a.name)
def build_bom(root: str, model_id: str, base: str = None, slsa: int = 2):
arts = scan_dir(root)
weights = [a for a in arts if a.kind == "weights"]
digest_map = {a.name: a.sha256 for a in arts}
components = [{
"type": "machine-learning-model",
"name": model_id,
"version": os.environ.get("MODEL_VERSION", "0.0.0"),
"hashes": [{"alg": "SHA-256",
"content": hashlib.sha256(
"".join(a.sha256 for a in weights).encode()).hexdigest()}],
"modelCard": {
"modelParameters": {"task": os.environ.get("MODEL_TASK", "unknown")},
"properties": [{"name": "mlbom:slsaBuildLevel", "value": str(slsa)},
{"name": "mlbom:artifactCount", "value": str(len(arts))}],
},
}]
for a in arts:
components.append({
"type": "file",
"name": a.name,
"version": a.sha256[:12],
"hashes": [{"alg": "SHA-256", "content": a.sha256}],
"properties": [{"name": "mlbom:kind", "value": a.kind},
{"name": "mlbom:size", "value": str(a.size)}],
})
return {
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"version": 1,
"metadata": {
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"tools": [{"name": "model_sbom.py", "version": "1.0"}],
"properties": [
{"name": "mlbom:source", "value": model_id},
{"name": "mlbom:baseModel", "value": base or "unknown"},
{"name": "mlbom:generator", "value": "ci-model-gate"},
],
},
"components": components,
}, digest_map
def main():
ap = argparse.ArgumentParser()
ap.add_argument("cmd", choices=["gen", "diff"])
ap.add_argument("path")
ap.add_argument("–out", default=None)
ap.add_argument("–baseline", default=None)
ap.add_argument("–model-id", default=os.path.basename(os.getcwd()))
ap.add_argument("–base", default=None)
a = ap.parse_args()
if a.cmd == "gen":
bom, digests = build_bom(a.path, a.model_id, a.base)
payload = {"model": a.model_id, "digests": digests, "bom": bom}
text = json.dumps(payload, ensure_ascii=False, indent=2)
if a.out:
with open(a.out, "w", encoding="utf-8") as f:
f.write(text)
print(f"wrote {a.out} ({len(digests)} artifacts)")
else:
print(text)
sys.exit(0)
# diff
if not a.baseline:
print("diff 需要 –baseline", file=sys.stderr)
sys.exit(2)
with open(a.baseline, encoding="utf-8") as f:
base_digests = json.load(f)["digests"]
_, current = build_bom(a.path, a.model_id, a.base)
added = sorted(set(current) – set(base_digests))
removed = sorted(set(base_digests) – set(current))
changed = sorted(k for k in set(current) & set(base_digests)
if current[k] != base_digests[k])
for k in added:
print(f"[ADDED ] {k}")
for k in removed:
print(f"[REMOVED ] {k}")
for k in changed:
print(f"[CHANGED ] {k}\\n base={base_digests[k]}\\n now ={current[k]}")
n = len(added) + len(removed) + len(changed)
print(f"\\ndrift={n}")
sys.exit(1 if n else 0)
if __name__ == "__main__":
main()
典型用法:
# 第一次引入模型:生成基线并归档(这份文件要放进不可变存储)
python model_sbom.py gen ./models/qwen-ft-v3 –model-id acme/qwen-ft-v3 \\
–base qwen/Qwen3-8B –out baseline-v3.json
# 每次部署前:比对
python model_sbom.py diff ./models/qwen-ft-v3 –baseline baseline-v3.json
# [CHANGED ] model.safetensors
# base=3f2a… now =9c01…
# drift=1 -> exit 1,CI 断
5.4 把三段脚本接成 CI 门禁
# .github/workflows/model-gate.yml
name: model–admission–gate
on:
pull_request:
paths: ["models/**", "registry/**.yaml"]
jobs:
scan:
runs-on: ubuntu–latest
steps:
– uses: actions/checkout@v4
– uses: actions/setup–python@v5
with: {python-version: "3.11"}
– name: 1) 禁止 pickle 权重进入仓库
run: |
set -e
found=$(find models -type f \\( -name '*.bin' -o -name '*.pt' -o -name '*.pth' -o -name '*.ckpt' \\) | tee /dev/stderr | wc -l)
if [ "$found" -gt 0 ]; then
echo "::error::发现 $found 个 pickle 权重文件,只允许 .safetensors"
exit 1
fi
– name: 2) pickle opcode 扫描(存量目录)
run: python scripts/scan_pickle.py models/ registry/ ––json > pickle–report.json || \\
(cat pickle–report.json && exit 1)
– name: 3) safetensors 结构校验
run: python scripts/st_check.py models/ ––json > st–report.json || \\
(cat st–report.json && exit 1)
– name: 4) 与归档基线做哈希漂移比对
run: |
python scripts/model_sbom.py gen models/ –out current.json
python scripts/model_sbom.py diff models/ –baseline baselines/$(cat models/.baseline).json
– name: 5) 第三方扫描器交叉验证
run: |
pip install modelscan
modelscan -p models/ –json modelscan.json || true
python – <<'PY'
import json,sys
d=json.load(open("modelscan.json"))
crit=[i for i in d.get("summary",{}).get("total_issues_by_severity",{}).items() if i[0]=="CRITICAL"]
sys.exit(1 if crit else 0)
PY
– name: 6) 归档报告作为审计证据
if: always()
uses: actions/upload–artifact@v4
with: {name: model–gate–reports, path: "*.json"}
第 5 步那个「第三方扫描器 + 自研扫描器」双跑不是冗余:自研的用白名单(能挡未知绕过原语),modelscan / picklescan 用黑名单(能挡已知家族),两者漏的东西不一样。
5.5 沙箱里加载不可信模型
有些场景你确实必须先加载一个来源不明的模型(竞品分析、恶意样本分析)。最低配置:
# 无网络、无家目录、只读挂载、限制内存与进程数
docker run –rm –network=none –read-only \\
–memory=8g –pids-limit=64 –cpus=2 \\
-v "$(pwd)/suspect:/models:ro" \\
-e HF_HUB_OFFLINE=1 -e TRANSFORMERS_OFFLINE=1 \\
-e HOME=/tmp –tmpfs /tmp:size=2g \\
–cap-drop=ALL –security-opt no-new-privileges \\
python:3.11-slim \\
bash -c 'pip install –no-index /wheels/*.whl && python /probe.py /models'
配套两条:宿主机上不要挂任何云凭据;容器跑完整个销毁,不要 commit 成镜像(否则 ~/.cache/huggingface/modules 里的东西会被带进下一层,这正是 CVE-2026-80047 的持久化路径)。
六、结果对比与数据分析
6.1 四种加载路径的风险实测
在一台 16 核 64 GB 的 CI runner 上,对四种加载路径做了实测(样本为 5 个公开小规模仓库 + 2 个自造 payload)。这里给的是方法学与量级,不是可外推的绝对值:
| torch.load(f)(旧默认) | 是 | 能(黑名单命中) | 8.4 s | 禁止 |
| torch.load(f, weights_only=True) | 否(当前版本) | — | 7.9 s | 可接受,但要锁版本 |
| safetensors.load_file(f) | 否 | — | 0.9 s | 首选 |
| from_pretrained(…, trust_remote_code=True) | 是 | 部分 | 9.1 s | 默认关闭,白名单例外 |
weights_only=True 与默认路径的耗时差在 6% 以内,也就是说安全选项几乎没有性能代价,那些「为了加载速度用老参数」的说法站不住。
6.2 两段扫描器的检出矩阵
用 7 个样本对「grep 文本」「自研白名单扫描器」「modelscan」「picklescan」做横向对比:
| 干净 state_dict(zip) | 无告警 | 无告警 | 无告警 | 无告警 |
| os.system GLOBAL | 命中 | CRITICAL | CRITICAL | Dangerous |
| posix.system GLOBAL | 命中 | CRITICAL | CRITICAL | Dangerous |
| STACK_GLOBAL 形式的 os.system | 漏 | CRITICAL | CRITICAL | Dangerous |
| inspect.getattr_static + importlib.import_module 链 | 漏 | HIGH(不在白名单) | 漏 | Suspicious(非严格模式下不阻断) |
| 未知模块 GLOBAL(模拟新型绕过) | 漏 | HIGH | 漏 | 漏 |
| zip 容器内藏 generate.py | 漏 | MEDIUM | MEDIUM | 无 |
倒数第二行是这套设计的价值所在:白名单策略对未知绕过原语有兜底能力,黑名单没有。 代价是会有误报(比如某个库用了没在白名单里的重建函数),所以脚本把「不在白名单」定为 HIGH 而不是 CRITICAL,留人工复核空间。
6.3 校验器误报与耗时
对 120 个真实 safetensors 分片(合计约 340 GB)跑 st_check.py:
| 总耗时 | 41 s(只读头,不读数据区) |
| 平均单文件 | 0.34 s |
| CRITICAL 命中 | 0 个(全部合规) |
| 非 CRITICAL 提示 | 3 个(都是 __metadata__ 里嵌了非字符串结构被上游工具写成字符串) |
| 峰值内存 | 46 MB |
耗时基本与文件数量成正比而非体积,这是只读头带来的好处——校验应该在下载完成后、加载前做,成本可以忽略。
七、踩坑记录与排查手册
坑 1:升级到 PyTorch 2.6 之后自定义模型加载报 UnpicklingError
现象:Weights only load failed. … GLOBAL myproj.modeling.MyBlock was not an allowed global by default.
定位:先看报错里列出的 GLOBAL 全名,确认它确实是你自己的类而不是第三方注入。
原因:2.6 把 weights_only 默认改为 True,反序列化白名单收紧,任何自定义类都需要显式授权。
解决:不要改 weights_only=False。用 torch.serialization.add_safe_globals([MyBlock]) 精确授权,或者用 safe_globals([…]) 上下文管理器把作用域收窄。如果报错里出现的类名你不认识,那就是安全事件,不是兼容性问题——先扫一遍文件。
坑 2:safetensors 报 HeaderTooLarge,但文件明明是刚下的
现象:safetensors_rust.SafetensorError: Error while deserializing header: HeaderTooLarge
定位:ls -lh 看文件大小;head -c 200 file.safetensors | xxd | head 看前几个字节。
原因:三种可能,按概率排序:(a) 拿到的是 Git LFS 指针文件(约 130 字节纯文本);(b) 未登录拉了 gated 仓库,拿到的是 401 的 HTML 页面;© 下载中断,文件只有一部分。三种情况下前 8 字节都不是合法的长度字段,解析器把它当成一个巨大的数,撞上 100 MB 上限。
解决:hf auth login 后重下;或用 huggingface-cli download –include "*.safetensors"。下载完成后必须校验 sha256(LFS 文件的哈希在仓库页面可直接查)。把这条加进下载脚本,不要靠肉眼。
坑 3:扫描器报干净,加载还是出事了
现象:picklescan 显示 Infected files: 0,modelscan 显示 No issues found,但进程里出现了异常外连。
定位:把 opcode 流打出来(pickletools.dis),看 GLOBAL 操作数里有没有不在黑名单的属性解析原语。
原因:黑名单式扫描器只能挡已知的家族。2026-07 公开的 inspect.getattr_static + importlib.import_module 链就是典型:os 和 system 只作为字符串常量出现,不进 GLOBAL 操作数,两个扫描器都判干净,但 pickle.load 时确实执行了 os.system。
解决:把门禁从「黑名单扫描」升级为「白名单扫描 + 格式准入」。长期看,正确的终局是根本不接受 pickle——扫描只是过渡期的兜底。
坑 4:只扫了 *.bin,漏了 config.json 和 tokenizer
现象:权重全部换成 safetensors、扫描全绿,但模型行为异常,或者加载时执行了远端代码。
定位:git ls-files 或 find 把仓库里所有非权重文件列出来,逐个看类型。
原因:攻击面不止权重。CVE-2026-80047 说明远端 Python 模块会在用户同意之前就落到缓存目录;而配置类注入(如社区报道的 CVE-2026-4372 一类问题)根本不需要 trust_remote_code=True。
解决:门禁要覆盖整仓库而非只覆盖权重文件。具体三条:禁用 trust_remote_code(例外走人工白名单);定期清理 ~/.cache/huggingface/modules;CI 里禁止该缓存目录被打进镜像层。
坑 5:私有化部署镜像里混进了来源不明的 wheel
现象:构建日志里出现了一个你没在 requirements 里写过的包名。
定位:docker history –no-trunc <image> 看每一层的构建命令;pip list –format=freeze 对比锁文件。
原因:依赖混淆。CVE-2026-54232 是个活教材:vLLM 的 Dockerfile 用 –extra-index-url 从 flashinfer.ai/whl/ 装 flashinfer-jit-cache,同时全局设了 UV_INDEX_STRATEGY="unsafe-best-match",而这个包名在 PyPI 上没人注册——攻击者只要注册一个更高版本号(如 0.6.11.post2),uv 就会优先选攻击者的包,并以 root 在构建期执行任意代码,污染此后所有镜像。修复于 vLLM 0.22.1。
解决:–index-url 而非 –extra-index-url;所有依赖写死版本 + 哈希(pip install –require-hashes);构建用非 root 用户;镜像签名 + 部署侧验签。构建期比运行期更危险,因为构建机通常权限更大、监控更少。
坑 6:哈希基线跟模型放在同一块盘上
现象:勒索软件跑过之后,模型被加密,基线文件一起没了,或者更糟——基线被改写成了「与加密后文件一致」。
定位:检查基线文件的存储位置、写权限、是否开启版本控制。
原因:基线本身也是资产。ENCFORGE 一类的样本明确把 .safetensors、.gguf、.onnx、LoRA、FAISS 索引、Parquet 数据集列入加密目标,说明攻击者清楚哪些文件最有勒索价值。基线如果是同盘的一个 JSON,防护等于零。
解决:基线写进对象存储并开启 WORM / 版本保留(或至少是独立的、只追加的、带审计日志的位置);本地只保留只读副本;基线的任何变更走 PR 评审。模型本体的备份遵循 3-2-1。
坑 7:签名验证写成了「有签名就放过」
现象:接入了签名,安全评审通过,但一次事故后发现那个带后门的模型签名是有效的。
定位:审查验签代码——是验「签名有效」,还是验「签名有效且签名者在可信列表内」。
原因:完整性 ≠ 安全性。签名只证明「这批字节是某个人发出的」,不证明这个人是好人,也不证明模型无害。OpenSSF 的 OMS 规范里有一处细节值得照抄:按文件计算的摘要放在 in-toto statement 的 predicate.resources 里(name / algorithm / digest 三元组),而顶层 subject 只有一条「整个模型」的聚合摘要。如果实现时错把 subject 当逐文件清单读,就会「验了个寂寞」。
解决:维护可信签名者身份白名单(自家 CI 身份 + 少数几个明确信任的上游发布者);逐文件比对 predicate.resources;把「无签名」与「签名者不在白名单」都判为失败,而不是降级放行。
坑 8:LoRA 合并之后没人重跑评测
现象:基座模型评测全绿,业务反馈某类输入下输出明显不对。
定位:把合并前后的模型在评测集上分别跑,做差分;对可疑输入做触发词挖掘。
原因:LoRA 适配器是独立的供应链环节,PoisonLoRA 的结果显示它能在合并 5 次以上、跨基座迁移后仍保持近 100% 的成功率,且平台侧检测不到。而 KDD '26 的 TamperBench 显示即便用良性数据做全量微调,安全对齐也会退坡(StrongREJECT 0.69)。
解决:适配器与基座同等对待——来源白名单、哈希基线、合并后必须重跑安全与功能评测。把「合并后评测」做成流水线里的强制步骤,而不是靠人记得。
坑 9:自研扫描器把 os.system 的 payload 判成干净(本文脚本第一版的真实 bug)
现象:拿一个 pickle.dumps(Evil(), protocol=4) 生成的文件去扫,输出 clean,退出码 0。
定位:python -m pickletools -a file.pkl 把 opcode 流打出来,发现里面压根没有 GLOBAL,只有 SHORT_BINUNICODE 'posix' / SHORT_BINUNICODE 'system' / STACK_GLOBAL。
原因:两个叠加的错误。第一,协议 4 起默认走 STACK_GLOBAL(0x93),模块名和属性名是两个独立压栈的字符串常量,不在 opcode 的 arg 里;第二,我第一版用完整栈模拟去还原这两个操作数,结果 MEMOIZE、TUPLE1 这些 opcode 没有进我的模拟栈,栈深算错,STACK_GLOBAL 分支走进「栈不足」的 else,只记一条 error 就过去了。静默失败比误报危险得多——它给你一个绿色的勾。
解决:两处改动。一是 STACK_GLOBAL 改成回溯它前面最近的两个字符串常量(_prev_strings),不再做全栈模拟;二是「操作数无法还原」不能静默,必须直接判 HIGH。另外补充一条:GLOBAL 的操作数在文件里用 \\n 分隔,但 pickletools 显示成空格,解析时两种都要认,否则 protocol 2 的 payload 会漏。
这个坑的通用教训:写一个扫描器的时候,必须同时准备一个「必须被拦下」的正样本和一个「必须被放行」的负样本,两者都进 CI。 只跑负样本的扫描器,等价于没写。
八、优化方案与进阶打法
8.1 准入网关:把所有模型拉收敛到一个点
散在各个工程师机器上的 from_pretrained() 是无法治理的。可行架构是把模型引入收敛成一条流水线:
公开社区 ──► 准入网关 ──► 私有仓库 ──► 推理集群
(扫描/校验/签名) (唯一源) (只允许拉私有仓库)
│
└─ 失败即阻断,不允许"先拉进来再说"
四条硬规则:
8.2 签名与溯源的落地选择
| sha256 基线 | 所有团队,最低要求 | 挡篡改,挡不住「发布者本人作恶」;基线必须异地不可变 |
| cosign / Sigstore 签名 | 有 CI 身份体系的团队 | 与 OCI 生态天然打通,镜像和模型用同一套 |
| OMS(OpenSSF Model Signing) | 需要与上游互通 | v1.0 规范已于 2026-05-15 合入;一致性套件 2026-08-26 合入,实现可互验;签名对象是「权重 + tokenizer + config + 数据集引用」的整体 |
| in-toto / SLSA provenance | 需要证明「怎么造出来的」 | 注意边界:SLSA 证明构建过程符合描述,不证明输入可信;SLSA 项目自己也在 2026-05 承认过「攻击者从 runner 内存里取出合法 OIDC token 后签出的证明与真的无法区分」 |
关于 OMS 的两个实操细节(来自社区对 model_signing 1.1.1 的核验,2026-08-17):
- 逐文件摘要在 predicate.resources,顶层 subject 是聚合摘要,不要读错。
- 签名默认写进 model.sig,且该文件自身不计入被签名的资源列表。
- 验签要基于「落盘字节算出的摘要」,绝不能拿仓库 API 返回的摘要自比自——那证明不了任何事。
8.3 ML-BOM 该记什么
对标 CISA 与 G7 在 2026 年发布的 AI SBOM 最小元素(七个簇),企业落地时至少要有:
元数据 :谁生成的、什么时候、用什么工具、规范版本
系统属性 :AI 系统名称、版本、预期用途、风险分级
模型 :身份、架构、训练方法、版本、**哈希**
数据集 :身份、来源、规模、预处理、已知偏差
基础设施 :计算环境、框架版本、硬件规格
安全属性 :已知漏洞、缓解措施、访问控制
性能指标 :准确率、公平性、鲁棒性评测结果
工程上两个建议:一是对齐现有标准(SPDX 3.0 的 AI/Dataset 画像,或 CycloneDX 1.6+ 的 modelCard / dataset 组件),不要自造 schema——自造的下场是半年后没人维护、也没法给监管看;二是在训练流水线里生成,而不是事后补。事后补的 BOM 一定不全,而且成本高一个数量级。
8.4 运行时这一层不能省
前面所有控制都是「加载前」。加载后还需要三件事:
8.5 成熟度自评
| 0 | 临时 | 无模型清单,从公开仓库直接拉,无来源记录 |
| 1 | 记录 | 有 AI-BOM 但手工维护,来源有记录,无密码学校验 |
| 2 | 已校验 | 自动生成 AI-BOM,权重与数据集哈希校验,已知漏洞扫描 |
| 3 | 已证明 | 密码学来源证明,训练流水线签名,部署时自动完整性校验 |
| 4 | 抗量子 | PQC 安全签名,混合证明模式 |
诚实的判断:大部分团队在 0 到 1 之间。别想着直接跳 3,先把第 2 级做扎实——自动化的、无聊的、跳不过去的流水线步骤,比一份漂亮的治理文档管用得多。
九、适用场景与选型建议
9.1 什么情况必须做
- 你的推理服务跑在有云凭据、有内网访问权限的机器上。加载即执行的 payload 拿到的就是你整个环境的权限。
- 你会引入第三方 LoRA / 适配器 / 社区微调版本。这是目前检测能力最弱、攻击成功率最高的一环。
- 你要过合规:EU AI Act 的 GPAI 文档义务在 2026-08 开始对投放欧盟市场的模型生效,其内容与 AI-BOM 高度重合;美国 FY26 NDAA 也要求向 DoD 销售软件的供应商在 SBOM 中说明 AI 组件。
- 你的模型资产重建成本高。被加密或篡改后重训的代价,远高于建防线的代价。
9.2 什么情况可以不那么重
- 只调用商业 API、完全不加载本地权重:你的供应链风险转移到 SDK 和依赖上,本文的模型扫描对你基本不适用,把精力放在依赖锁定上。
- 纯离线研究、单机无凭据、用完即销毁的沙箱:把沙箱做扎实即可,不必上完整准入网关。
- 团队规模小、模型来源单一(只用一个自己训的基座):把哈希基线 + 备份做对就够了,签名和 SBOM 可以缓。
9.3 我不推荐的几种做法
- 「我们扫过一遍,所以安全了」。 一次性扫描在模型更新的那一刻就过期了。必须是每次引入都跑的门禁。
- 把 weights_only=False 写进公共 utils。 这个口子一开,所有调用方都跟着裸奔,而且没人会记得改回来。
- 只信黑名单扫描器。 2026-07 那个 77 字节的 PoC 已经证明这条路会被绕过。白名单 + 格式准入才是终局。
- 把所有模型都塞进同一个「可信」目录然后全盘信任。 可信必须是逐文件、逐版本、带哈希的,不能是逐目录的。
- 买一个平台然后默认配置上线。 默认配置通常是 fail-open 的,这一点必须先确认再上。
9.4 落地顺序建议
按性价比排,不要一次性铺开:
第 1 周:禁止 pickle 入库(改格式) + 禁用 trust_remote_code
第 2 周:下载后 sha256 校验脚本化 + 基线异地归档
第 3 周:scan_pickle / st_check 接进 CI,失败即阻断
第 4 周:收敛模型来源到私有仓库,推理侧只允许拉私有仓库
第 2 月:流水线签名 + ML-BOM 自动生成
第 3 月:行为基线与元通道压力测试
十、总结与展望
把全文压缩成几条可执行的判断:
往后看一年,两件事值得押注:一是 OMS 一类签名规范从「有规范」走向「默认开启」——2026-05 的 v1.0 与 2026-08 的一致性套件是信号,接下来看数据集签名与多签名日志能不能落地;二是 AI-BOM 从合规文档变成流水线产物——SPDX 3.0 的 AI/Dataset 画像和 CycloneDX 的 ML-BOM 扩展已经把 schema 定下来,缺的是生成端的默认集成。
最后一句,是我在这条线上踩了一圈之后最想说的:别把模型当数据管,也别当代码管,要当成一个同时具备两者属性的新东西管。 你给它做的每一道防线,都要明确回答「这道防线挡的是执行面、完整性面还是行为面」——答不上来的,多半是心理安慰。
参考素材与延伸阅读
- Hugging Face Transformers library writes remote code to disk prior to consent check(VU#456290 / CVE-2026-80047) — CERT/CC,2026-09-01
- OpenSSF Model Signing (OMS) Specification 仓库 — OpenSSF AI/ML Working Group,OMS v1.0 合入 2026-05-15,一致性套件 2026-08-26
- OpenSSF Newsletter: September 2026 — OpenSSF,2026-09
- Safetensors 项目页(PyTorch Foundation) — 2026 年捐赠进入 Linux 基金会 / PyTorch Foundation
- Audit shows that safetensors is safe and ready to become the default — Hugging Face / Trail of Bits 审计说明
- SafeTensors Format: A Guide to Secure ML Model Serialization — DataCamp,2026
- safetensors 文件格式规范(DeepWiki 整理) — 头部结构与校验规则
- Pickle scanner bypass — inspect.getattr_static + importlib.import_module REDUCE chain — 社区披露,2026-07-16
- CVE-2026-54232: vLLM Dependency Confusion Supply-Chain Attack — SentinelOne,2026-06-22 发布
- Critical torch.load Vulnerability: 3 RCE Flaws Every MLOps Team Must Patch — 汇编报道(CVE 编号请自行到 NVD 复核),2026
- AI Supply Chain Integrity(AI-BOM / SPDX 3.0 / SLSA 边界) — 2026-09-01
- What Will Make AI BOMs Real? — Dark Reading,2026
- Detecting Model Supply Chain Poisoning in 2026 — 攻击面与检测手段
- LLM Backdoor Attack Detection: Enterprise Defense Guide (2026) — MetaBackdoor / ShadowLogic 等综述
- Customization under Fire: Plugin Poisoning in Text-to-Image Ecosystem(PoisonLoRA,arXiv 2606.09151) — 2026
- Study Finds Safety Guardrails in All Tested Open-Weight AI Models Can Be Disabled(TamperBench,KDD '26) — 2026
- AI Infrastructure Under Siege as Malware Evolves to Target Model Files(ENCFORGE / Sysdig) — 2026-07

