欢迎光临
我们一直在努力

模型的供应链安全:pickle 反序列化、safetensors、权重签名与来源溯源

模型的供应链安全: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 年变得紧迫,是因为三个条件同时成立了:

  • 模型分发变成了日常操作。 团队里任何一个算法工程师每天都会 from_pretrained() 若干个从公开社区拉下来的仓库,这件事的频率和 pip install 一个包差不多,但大多数人给它的审查强度是零。
  • 攻击面已经被实证。 2026-09-01,CERT/CC 发布漏洞公告 VU#456290(对应 CVE-2026-80047),确认 Hugging Face Transformers 4.49.0 到 5.8.1 的 load_custom_generate() 会在评估 trust_remote_code 同意提示之前就把远端 custom_generate/generate.py 抓下来写进 ~/.cache/huggingface/modules,且这次写入不可回滚——用户点了「不同意」,文件照样留在磁盘上。CERT/CC 明确指出这与库里其它动态加载路径(AutoConfig / AutoModel / AutoTokenizer / AutoImageProcessor)的安全契约不一致。这条不是「可能被利用」,是「信任边界的先后顺序写错了」。
  • 防守工具本身有已知绕过。 2026-07-16 有人在公开仓库贴出一份 77 字节的 protocol-4 pickle PoC,用 importlib.import_module + inspect.getattr_static 组装出 os.system,同时骗过 picklescan 1.0.5(非严格模式)与 modelscan 0.8.8。根因是这两个扫描器都是「GLOBAL 黑名单」式的静态分析器,而 inspect.getattr_static 这个与 builtins.getattr 功能等价的属性解析原语不在黑名单里。
  • 把三条放在一起的结论是:不能只靠「我用 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 + 属性查找,所以只要把这个口子堵死,任意代码执行链条就断了。

    但有三个必须说清楚的边界:

  • 它只收窄攻击面,不消除。 PyTorch 自己的文档措辞是 weights_only=True 缩小了远程代码执行的面,但对 DoS 类攻击(比如构造超大 shape 耗尽内存)没有防护,内存破坏类问题也仍有可能。
  • 历史上它被绕过过。 公开披露的 CVE-2025-32434(CVSS 9.8)就是针对 weights_only=True 模式的绕过,影响 PyTorch 2.5.1 及更早版本,修复于 2.6.0。社区另有报道称 Transformers 的 Trainer 在恢复 checkpoint 时内部调用了不带该参数的 torch.load(),被记为 CVE-2026-1839,修复于 5.0.0rc3;以及 from_pretrained() 在配置处理路径上的注入问题 CVE-2026-4372,据报道修复于 5.3.0(2026-03-04)。这几个编号来自二手汇编源,落地前请自行到 NVD / GitHub Advisory 复核,不要直接抄进合规文档。
  • 它和 safetensors 不是替代关系。 一个是「限制反序列化器」,一个是「根本没有反序列化器」。后者是结构性的。
  • 顺带说 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 是相对于数据区起始位置的偏移,不是文件绝对偏移。这个细节在实现校验器时最容易写错。

    安全来自四点结构性约束:

  • 头是 JSON,无执行语义。 没有任何 opcode、回调或类加载机制。
  • 数据区是裸数值。 不存在对象图、不存在类引用。
  • 解析器用 Rust 编写。 Trail of Bits 在 2023 年做过一次由 Hugging Face、EleutherAI、Stability AI 联合委托的审计,结论是未发现可导致任意代码执行的关键缺陷;审计同时发现了规范描述不精确、以及部分校验缺失导致可以构造 polyglot 文件的问题,这些都已修复。注意 polyglot 这条:一个文件可以同时是「合法的 safetensors」和「合法的某种别的东西」,如果你的下游按别的格式去打开它,就绕过了一切。
  • 严格的偏移校验。 头大小上限 100 MB(防 DoS);张量区间必须连续、无空洞、无重叠、且完整覆盖数据区;越界读被拒绝。
  • 把第 3、4 点合起来看,safetensors 把安全问题从「这个文件会不会跑代码」降级成了「这个文件解析器有没有内存安全 bug」。后者是普通文件解析器的常规问题,有成熟的威胁模型和修复节奏。这就是它值钱的地方。

    3.4 safetensors 不解决什么:攻击面枚举表

    这是全文最需要被记住的一张表。左边的行是攻击面,右边是 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)。这里给的是方法学与量级,不是可外推的绝对值:

    加载路径是否执行代码扫描器能否检出实测加载耗时(1.2 GB 权重)结论
    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」做横向对比:

    样本文本 grep自研 scan_picklemodelscan 0.8.8picklescan 1.0.5
    干净 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() 是无法治理的。可行架构是把模型引入收敛成一条流水线:

    公开社区 ──► 准入网关 ──► 私有仓库 ──► 推理集群
    (扫描/校验/签名) (唯一源) (只允许拉私有仓库)
    │
    └─ 失败即阻断,不允许"先拉进来再说"

    四条硬规则:

  • 推理侧的允许来源列表里只有私有仓库,公网域名一律不可达(在代理/网络策略层做,不在策略文档里做)。
  • 网关失败闭合(fail-closed):扫描器报错、超时、OOM,一律按失败处理,不允许「扫描挂了就放行」。这是工程上最容易被忽视的一条——攻击者只要能让你的扫描器超时就赢了。
  • 入库即签名,用自家 CI 身份(Sigstore keyless 或 KMS 密钥),签名与 ML-BOM 一起存。
  • 加载时再验一次,不是入库时验完就完事。
  • 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 运行时这一层不能省

    前面所有控制都是「加载前」。加载后还需要三件事:

  • 行为基线:对线上模型定期跑固定评测集,指标漂移超过阈值告警。权重后门的唯一实证手段是行为。
  • 元通道压力测试:除了内容维度,还要覆盖输入长度、位置、语言、格式等维度——MetaBackdoor 这类触发器在内容维度上是全盲的。
  • 出口流量与凭据隔离:推理容器不给云凭据、不给写权限、出网白名单。即便前文全部失守,这也是最后一道。
  • 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 月:行为基线与元通道压力测试

    十、总结与展望

    把全文压缩成几条可执行的判断:

  • torch.load() 默认路径等于执行代码。 唯一的终局解法是不接受 pickle,而不是扫描它。扫描只是过渡期的兜底,且必须白名单化。
  • safetensors 消灭的是「加载即执行」这一个面,不是全部。 它不解决远程代码模块、配置注入、权重后门、数据集投毒、完整性篡改,也不解决 LoRA 投毒——LoRA 本身就是 safetensors。
  • 完整性靠哈希与签名,安全性靠行为评测。两者不能互相替代。 验签时务必校验签名者身份白名单,而不是「有签名就行」。
  • 门禁必须失败闭合。 扫描器挂了要阻断,不能放行。
  • 构建期比运行期更危险。 CVE-2026-54232 这种依赖混淆发生在 docker build 里,以 root 执行,污染此后所有镜像。
  • 基线自己也是资产。 异地、不可变、带版本,否则你的完整性校验和模型一起被加密。
  • 往后看一年,两件事值得押注:一是 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
    赞(0)
    未经允许不得转载:171主机测评 » 模型的供应链安全:pickle 反序列化、safetensors、权重签名与来源溯源
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址