开源模型供应链安全实战:pickle 反序列化 RCE 原理剖析与 safetensors 纵深防御
⚠️法律红线声明:本文全部攻击演示均在作者本地隔离沙箱(Docker 容器 + 无外网)中完成,攻击载荷已刻意降强度——只写一个本地标记文件,不外发数据、不做持久化、不做提权。文中技术仅限授权安全测试与防御研究。向他人模型仓库投毒、诱导他人加载恶意模型,可能违反《中华人民共和国网络安全法》及《刑法》第 285 条(非法侵入计算机信息系统罪)、第 286 条(破坏计算机信息系统罪)。请只在自己的环境和明确授权的测试范围内复现。
导语:本文写给需要从 Hugging Face 等开放平台下载、加载第三方模型的安全工程师、算法工程师与 MLOps 平台开发者,解决一个具体问题——为什么torch.load()一个权重文件等价于执行任意代码,以及如何用「下载前预检 → 静态扫描 → 受限加载 → 加载后校验」四层纵深防御,把开源模型供应链这条 RCE 攻击链在每一环都掐断。
一、攻击面在哪:torch.save 的"原罪"
1.1 先看一个受控的复现:加载即执行
在讲原理之前,先把结论摆出来:PyTorch 的.bin/.pt权重文件本质上是一个 pickle 序列化流,torch.load()的默认路径会调用完整的 pickle 反序列化器,而 pickle 是一台可以执行任意函数调用的虚拟机。
下面是最小可复现的 PoC(载荷已降强度,只写本地标记文件):
# poison_demo.py —— 仅在本地隔离沙箱中运行importpickleimportosclassEvilTensor:"""伪装成张量容器的恶意对象,模拟混入权重文件的投毒载荷"""def__reduce__(self):# __reduce__ 返回 (callable, args) 二元组。# 反序列化时,pickle 虚拟机执行 callable(*args),# 这就是整个攻击的核心:把"恢复对象"变成"执行函数"。# 真实攻击载荷是反弹 shell / 窃取凭据 / 投毒训练数据,# 本文刻意降级为写一个标记文件——这是合规底线。return(os.system,("echo PWNED > PWNED_MARKER.txt",))# 把恶意对象序列化后写入一个"看起来像权重"的文件withopen("model.bin","wb")asf:f.write(pickle.dumps(EvilTensor()))# ---- 受害者视角 ----# 加载"权重"的一瞬间,pickle 虚拟机执行 os.system(...)# 运行后当前目录出现 PWNED_MARKER.txtpickle.loads(open("model.bin","rb").read())print("survived:",os.path.exists("PWNED_MARKER.txt"))# 预期输出: survived: True这段做了什么:__reduce__是 Python 序列化协议的"逃生舱",本意是让无法普通序列化的对象(文件句柄、锁)自定义重建方式,但它返回的(callable, args)会在反序列化时被直接调用。攻击者要做的只是把一个这样的对象塞进权重文件——不需要利用任何内存破坏漏洞,这是 pickle 协议的"设计内行为"。真实事件里,攻击者会把恶意__reduce__藏进一个合法张量字典的某个 value 里,权重照常加载、模型照常推理,RCE 已经发生。
1.2 反汇编看本质:pickle 是一台栈式虚拟机
很多人对 pickle 的理解停留在"Python 的序列化格式",这低估了它的危险。用标准库反汇编一下上面的model.bin:
importpickletoolswithopen("model.bin","rb")asf:data=f.read()# 反汇编 pickle 字节流,不执行任何代码,纯静态pickletools.dis(data)预期输出(节选):
0: \x80 PROTO 4 2: \x95 FRAME 45 11: \x8c SHORT_BINUNICODE 'posix' 20: \x94 MEMOIZE 21: \x8c SHORT_BINUNICODE 'system' 32: \x94 MEMOIZE 33: \x93 STACK_GLOBAL # ← 把 (posix, system) 压栈 = 拿到 os.system 34: \x8c SHORT_BINUNICODE 'echo PWNED > PWNED_MARKER.txt' 63: \x85 TUPLE1 # ← 参数打包成元组 64: R REDUCE # ← 关键:执行 os.system('echo ...') 65: . STOP为什么这么写:pickletools.dis是纯静态操作码反汇编,分析恶意样本时应永远先用它而不是pickle.loads——反序列化本身就是触发执行。输出里能清楚看到三个危险操作码的分工:STACK_GLOBAL/GLOBAL负责按名字索引任意模块函数(这里是posix.system),REDUCE负责执行"栈顶函数 × 栈顶参数"。pickle 设计于 1990 年代早期 Python,当时的设计目标是在可信环境间传输任意 Python 对象图——注意这两个定语:"可信环境"和"任意对象"。前者意味着协议里从来没有完整性校验和身份认证的位置(那是传输层的事),后者意味着它必须保留"执行任意代码"的能力(否则自定义类无法重建)。安全性的责任被协议设计完全外包给了调用者,这就是为什么 30 年后它成了 AI 供应链里最大的雷:Hugging Face 把"任意网友上传的文件"和"可信环境"这两个前提同时打破了。
这也回答了"为什么 PyTorch 当年会选 pickle":训练产物里除了张量,还有优化器状态、RNG状态、动态生成的模型结构对象——pickle是唯一能无损往返这些异构对象的零成本方案。这是一次典型的"灵活性换安全"的权衡,而账单在开放模型社区出现时才寄到。
1.3 safetensors:一次针对这个问题的重新设计
Hugging Face 在 2022 年底推出 safetensors 格式,可以看作对上述权衡的正面回应。它的文件结构极其简单:前 8 字节是头部长度的u64,接着是一段 JSON 头描述每个张量的dtype/shape/文件内偏移,后面全是裸张量数据。
为什么这个设计能消灭"加载即执行"?因为 JSON 头里没有表达"可执行"的语义位置——解析器只做memcpy级别的数据搬运(官方实现支持零拷贝加载),不存在REDUCE这种"执行"操作码,攻击面从"任意代码执行"塌缩成"解析畸形输入"。附带收益是加载速度快(跳过 unpickle、支持 lazy 加载单个张量)和跨框架兼容。它的代价同样明确:只能存张量,不能存优化器状态、自定义对象、代码——所有"复杂对象"被挡在格式之外。safetensors 用表达力换安全性,这恰好是权重文件该做的交换:权重本就该是纯数据。但注意,它防的是"加载即执行",不防"权重本身带后门"(比如微调后门攻击),这个边界后面还会讨论。
二、真实威胁不是理论:CVE 与 NullifAI 蠕虫
如果只停留在"理论风险",这篇文章就没有写的必要。过去两年这条攻击面持续产出真实漏洞和事件(以下均可在官方公告核实):
- CVE-2025-32434:
torch.load即使显式传了weights_only=True,仍可被恶意 pickle 载荷绕过导致 RCE,影响 PyTorch ≤ 2.5.1,修复版本 2.6.0(2025 年 1 月 29 日发布)。这个 CVE 的分量在于:它击穿了很多团队"我传了安全参数"的心理安全区。 - CVE-2026-24747(GHSA-63cw-57p8-fm3p):PyTorch ≤ 2.9.1 的受限反序列化器可被恶意检查点触发内存破坏,进而可能代码执行,修复版本 2.10.0。它说明受限加载器本身也是攻击面,
weights_only不是终点。 - CVE-2024-3568:社区最常用的 pickle 扫描器 picklescan 存在误报阴性(把恶意文件判为安全)的绕过漏洞。
- NullifAI 蠕虫(2025 年 2 月,ReversingLabs 披露):Hugging Face 上的恶意模型利用扫描器解析与 Python 运行时解析之间的差异绕过 Picklescan——恶意文件用 7z 压缩而非标准 ZIP(PyTorch 权重惯例是 ZIP 容器),扫描器解析失败报错跳过,而 Python 运行时仍能部分反序列化并执行恶意代码。
NullifAI 值得单独展开,因为它揭示了一个反直觉的事实:扫描器的报错被流水线当成了"已检查、安全"。这是防御工程里非常典型的"fail-open"陷阱——出错的检查项不应该被跳过,而应该被阻断。
三、实战:搭一条四层防御的模型加载流水线
下面是完整可复现的路径。环境(版本敏感,请对齐):
| 组件 | 版本 | 说明 |
|---|---|---|
| Python | 3.11 | — |
| PyTorch | 2.6.0+ | 2.6.0 起torch.load默认weights_only=True;含 CVE-2025-32434 修复 |
| huggingface_hub | 0.34.x | 预检用model_info(files_metadata=True) |
| picklescan | 0.0.13+ | 修复 CVE-2024-3568 后的版本 |
| fickling | 1.x(Trail of Bits) | 符号执行级 pickle 分析 |
3.1 第一层:下载前预检——信息足够时根本不要碰 pickle
大量风险在点击"下载"之前就可以排除:优先选提供 safetensors 工件的仓库、锁定 revision、拒绝需要trust_remote_code的仓库。用huggingface_hub做预检:
# preflight.py —— 下载前的仓库工件审计fromhuggingface_hubimportHfApiapi=HfApi()REPO="some-org/some-model"# files_metadata=True 会一并返回文件大小,便于发现异常大/小的可疑文件info=api.model_info(REPO,files_metadata=True)names=[s.rfilenameforsininfo.siblings]has_safetensors=any(n.endswith(".safetensors")forninnames)has_pickle=any(n.endswith((".bin",".pt",".pth",".ckpt"))forninnames)needs_trust_remote_code="modeling_*.py"innamesorany(n.endswith(".py")andnotn.startswith(".")forninnames)# 粗判:仓库带自定义代码文件时,加载需 trust_remote_code=Trueprint(f"safetensors:{has_safetensors}, pickle:{has_pickle}, "f"custom_code:{needs_trust_remote_code}")# 决策原则:# 1) 只有 safetensors → 绿灯,跳到 3.4 层做加载校验# 2) 只有 pickle → 黄灯,强制进入 3.2/3.3 静态扫描# 3) 需 trust_remote_code → 红灯,人工评审代码后再说这段做了什么:model_info一次 API 调用拿到仓库完整文件清单与大小,把"要不要让这个仓库的代码进入我的加载路径"变成一个显式决策,而不是from_pretrained一把梭之后的既成事实。特别提醒trust_remote_code=True:它会把仓库里的 Python 文件直接 import 执行,是和 pickle 平级的另一个"加载即执行"通道,而它连静态扫描层都绕过了。下载时还应固定revision=<commit_sha>,防止仓库在预检之后被替换("Rug Pull"式供应链攻击在模型仓库上同样成立)。
3.2 第二层:静态扫描——但要知道扫描器会出错
对于确实只有 pickle 工件的仓库,先扫描再进沙箱。两条互补的工具链:
# 安装(版本敏感:picklescan 需 >= 0.0.13 以包含 CVE-2024-3568 修复)pipinstall"picklescan>=0.0.13"fickling# 工具 1:picklescan —— 基于操作码反汇编 + 危险名单匹配picklescan--path./downloads/model_dir--strict# 退出码:0 = 未发现威胁;1 = 发现威胁;2 = 扫描出错# 关键:退出码 2 必须当作"阻断"处理(见下方踩坑)# 工具 2:fickling —— Trail of Bits 出品,内置 pickle 虚拟机做符号执行# 它不真正执行载荷,而是把 pickle 反编译成等价 Python 代码供人审读fickling--check./downloads/model.bin fickling--trace./downloads/model.bin# 逐步跟踪虚拟机栈操作为什么这么写:两个工具的检测机理不同——picklescan 用pickletools.genops逐条反汇编,收集GLOBAL/STACK_GLOBAL/INST等指令引用的全局对象做名单匹配,快但只看指令表面;fickling 实现了一个 pickle 符号执行虚拟机,能跟踪栈状态把载荷"反编译"回 Python 源码级别,可发现简单名单匹配漏掉的混淆载荷。两者叠加仍然只是概率性防御,因为静态分析永远在和运行时解析器赛跑。
❌ 错误写法 / ✅ 正确写法(踩坑 1:fail-open 的扫描流水线):
# ❌ 错误:脚本里只判断退出码是否为 1,# 扫描器崩溃/解析失败(退出码 2)被静默放行picklescan--path./model_dir--strict[$?-eq1]&&echo"BLOCK"||echo"PASS"# 出错时走到 PASS!# ✅ 正确:只有退出码 0 才放行,1 和 2 一律阻断并告警picklescan--path./model_dir--strictcode=$?if["$code"-eq0];thenecho"PASS"elseecho"BLOCK (exit=$code)";exit"$code";fi这个坑不是假设。NullifAI 蠕虫的核心手法就是构造扫描器解析失败(7z 容器冒充 ZIP 容器)的文件:扫描器报错退出,流水线把"没检查成"当成"检查过且安全",而 Python 的 unpickler 对同一文件的容忍度更高,照样执行。凡是把异常路径当作放行路径的检查,都会被攻击者选作突破口——这条原则适用于所有安全扫描自动化,不止 pickle。
3.3 第三层:加载时——把反序列化器关进笼子
静态扫描通过≠安全(扫描器有 CVE、有绕过),下一道闸门在torch.load本身。
❌ 错误写法 / ✅ 正确写法(踩坑 2:为了"跑通"把安全开关关掉):
importtorch# ❌ 错误 1:不传 weights_only(PyTorch < 2.6 默认 False,等于裸奔)state=torch.load("model.bin",map_location="cpu")# ❌ 错误 2:更隐蔽的坑——加载含自定义类的旧检查点时报错:# UnpicklingError: Unsupported global: GLOBAL torch.nn.Module...# 为了"跑通"改成 weights_only=False,前两层防御全部作废state=torch.load("model.bin",map_location="cpu",weights_only=False)# ✅ 正确:显式声明受限反序列化,只允许张量与基础容器重建state=torch.load("model.bin",map_location="cpu",weights_only=True)# ✅ 正确:确需加载自定义类时,走白名单逐个放行并逐个审查,# 而不是一键全关(torch.serialization.add_safe_globals 为# PyTorch 2.4+ API,版本敏感)classMyQuantLinear(torch.nn.Module):...torch.serialization.add_safe_globals([MyQuantLinear])# 显式、可审计的白名单state=torch.load("ckpt.bin",map_location="cpu",weights_only=True)这段做了什么与为什么:weights_only=True切换到受限 unpickler,只允许基础类型与张量的重建指令,GLOBAL一个任意函数就直接抛异常——把 1.2 节里那台虚拟机的"执行"权限收掉了。两个错误写法分别对应两种真实高频事故:第一种是存量代码在 PyTorch 2.6 之前的默认行为,全公司几百处torch.load没人检查过;第二种更危险,因为它是工程师在压力下主动关闭的——报错信息看起来像 bug,实际是防线在工作。正确姿势是逐个add_safe_globals并审查每个被放行的类,把"谁能被重建"变成一份可 code review 的清单。另要注意 CVE-2025-32434(≤2.5.1)和 CVE-2026-24747(≤2.9.1)证明受限加载器自身也有被绕过的历史,所以版本必须保持最新(≥2.10.0),且这一层永远不该是唯一的一层。
3.4 第四层:加载后——safetensors 的正确打开方式
如果仓库提供 safetensors 工件(预检第一层应尽量保证这一点),加载侧仍要做格式与内容校验——畸形 shape 可以在下游触发内存类漏洞(CVE-2026-24747 提醒我们连张量解析都不能无条件信任):
importjson,structfromsafetensorsimportsafe_opendefaudit_safetensors(path:str,max_total_elems:int=10_000_000_000):# 1) 手工解析头部:验证头部长度声明与实际 JSON 边界一致,# 拒绝头部超大(内存放大攻击)或数据段越界的文件withopen(path,"rb")asf:(header_len,)=struct.unpack("<Q",f.read(8))ifnot(8<header_len<100_000_000):# 上限按需收紧raiseValueError(f"suspicious header length:{header_len}")header=json.loads(f.read(header_len))total=0forname,metainheader.items():ifname=="__metadata__":continues,e=meta["data_offsets"]# 2) 偏移必须单调且落在数据段内ifnot(0<=s<e<=8+header_len+(1<<40)):raiseValueError(f"bad offsets in tensor{name}")total+=int(meta["shape"][0])ifmeta["shape"]else1# 3) 资源预算:张量总元素数超预算直接拒绝,防 DoSiftotal>max_total_elems:raiseValueError("tensor budget exceeded")# 4) 用官方 safe_open 做实际加载并逐张量核对 dtype/shapewithsafe_open(path,framework="pt",device="cpu")asf:forkeyinf.keys():t=f.get_tensor(key)assertstr(t.dtype)==header[key]["dtype"]assertlist(t.shape)==header[key]["shape"]returnlist(header.keys())print(audit_safetensors("model.safetensors"))# 预期输出: ['model.layers.0.weight', 'model.layers.0.bias', ...]为什么这么写:这段把 safetensors 的"简单"变成可执行的信任条件——头部长度上界防内存放大,偏移单调性防越界读,元素预算防 DoS,最后逐张量核对声明与实际一致性。手工解析头部的意义在于在把文件交给任何库之前先建立自己的判断,一旦官方解析器出问题(历史上有过),你的流水线不是裸奔。加载环境本身也应再加一道运行时兜底:容器内--network none、只读根文件系统、seccomp 限制系统调用——纵深防御的最后一课是承认前面每一层都可能被绕过。
四、边界与权衡:这套防线的真实水位
静态扫描的绕过空间永远存在。picklescan 的名单匹配对"把恶意函数拆解成多个看似无害的全局引用再在栈上拼装"的载荷天然乏力(CVE-2024-3568 就是实例);fickling 的符号执行更强但也在和 CPython unpickler 的解析差异赛跑(NullifAI 的 7z 手法)。结论不是"扫描没用",而是扫描的定位是抬高攻击成本,不是提供安全证明。
weights_only 的可用性代价。受限加载器会拒绝一切非白名单类型,这在加载含优化器状态、分布式检查点(如 DeepSpeed 的旧格式)时经常报错。工程上正确的消化方式是维护组织级的白名单模块(集中add_safe_globals、集中评审),错误的消化方式是让每个工程师自己在业务代码里关掉开关——后者意味着你的防线水位由团队里最急的那个 deadline 决定。
safetensors 不是银弹。它消灭的是"加载即执行",不消灭恶意权重本身:微调后门(BadNets 类触发器)、权重中的对抗扰动(bit-flip 攻击)都以纯数据形式存在,safetensors 格式校验对此完全无感。对后者的防御属于另一条战线(来源审计、行为测试),不要因为换了格式就产生虚假安全感。
适用与不适用场景。这套四层流水线适合"从开放社区拉第三方模型"的场景;若模型全部来自内部训练、存储与传输全程可信,静态扫描层的性价比会明显下降(扫描成本不为零,误报还会消耗人力)。反过来,若你的平台允许外部用户上传模型做推理(model-as-a-service),四层全部适用且要叠加沙箱隔离与运行时监控。
五、总结与延伸方向
这篇文章的脉络可以压缩成三句话:pickle 是一台能执行任意代码的栈式虚拟机,torch.load默认路径是它的完整入口;weights_only受限加载与 safetensors 格式分别从"限制执行"和"消灭执行语义"两个方向封死这个入口;静态扫描只是成本抬升层,任何把它当安全证明、或把它异常路径当放行路径的流水线都会被 NullifAI 式手法击穿。
落地清单:升级 PyTorch ≥ 2.10.0、全局审计存量torch.load调用点、预检优先 safetensors 并锁定 revision、扫描流水线 fail-closed、加载校验加资源预算、不可信模型一律沙箱加载。
延伸方向:① 把这套预检做成 CI/模型网关的准入插件,对接内部模型注册表做签名与溯源(对齐 SBOM 的"模型物料清单"实践正在成形);② 权重后门的检测与评估(BadNets 复现、STRIP、激活聚类),这是 pickle 之外"恶意权重"侧的另一半战场;③ 对齐trust_remote_code代码的人工评审自动化——用 LLM 辅助审计仓库自带 Python 代码是个正在成熟的交叉点。
参考来源:PyTorch 官方安全公告 GHSA-63cw-57p8-fm3p(CVE-2026-24747,修复 2.10.0);PyTorch 2.6.0 release notes(CVE-2025-32434 修复与weights_only默认值变更);picklescan 安全公告(CVE-2024-3568);ReversingLabs 2025 年 2 月 NullifAI/Hugging Face 恶意模型披露;Trail of Bits fickling 项目文档;Hugging Face safetensors 格式规范。