想象你收到一个会说话的快递盒。刚拆开,盒子就开始朗读内附的小纸条,并且严格照做:纸条上写着“删除所有文件”,它就真的删除。这不是恐怖故事,而是 Python pickle 序列化格式的真实性格——它不只是装数据的容器,更像一台会自动执行指令的设备。在 AI 世界里,大量模型权重正是用 pickle 保存的,这意味着每一次下载模型,都等于拆开一个可能携带任何指令的快递盒。 Hugging Face 推出的 safetensors 改变了这个局面。它像一只只认数据、不认字的保险抽屉:打开抽屉,里面只有张量数据,没有任何指令会被执行。因为这个格式根本没有代码解释器,攻击者想塞纸条也找不到地方。这篇文章会用“会说话的快递盒”和“只放数据的保险抽屉”这对比喻,带你理解 pickle 为什么危险、safetensors 如何做到安全、它的文件结构长什么样、性能好在哪,以及它在 AI 供应链中的价值。读完你会明白,为什么安全团队越来越坚决地把模型格式从 pickle 切换到 safetensors。
对 AI 从业者而言,理解模型格式的安全边界,是守住模型下载、加载、分发这条供应链底线的关键能力——否则你的服务器可能在不知不觉中变成攻击者的执行台。
目录
可视化演示
对比pickle漏洞、Safetensors安全设计、ONNX白名单与TorchScript安全边界。
本节配有交互动效(进入视口后自动加载,约 3KB)。
Pickle反序列化漏洞 pickle反序列化本质上是执行一段程序 恶意reduce方法可执行任意系统命令 unpickle(data) → exec(code) 图灵完备格式可表达任意计算是安全风险根源
pickle风险——“会说话的快递盒”
你收到一个会说话的快递盒。拆开它,盒子就开始朗读内附的小纸条,而且严格照做:纸条上写着“删除桌面文件”,它就真的去删。在 Python 的世界里,pickle 就是这样一个盒子——它承载对象数据,同时把还原数据的每一步都当作指令来执行。这不是假设的危险,而是反序列化机制天然的行为模式:不是“可能执行”,而是“必然逐条解释”。
pickle 是 Python 的标准序列化协议,负责把对象变成字节流,再从字节流还原。问题在于,“还原对象”在 pickle 眼中是“解释操作码”:pickle.loads() 会逐条读取字节流里的指令,其中 REDUCE 指令可以直接调用任意 Python 函数。于是攻击者只要伪造一个模型文件,就能在加载时让对方的机器执行任意命令。模型文件从此不再只是数据,而是一份可以被远程投递的“犯罪指令书”。
早年的 AI 生态大量使用 torch.save 和 torch.load,它们底层正是 pickle。PyTorch 官方文档反复强调:永远不要加载来路不明的 pickle 文件。这就是历史漏洞给整个社区留下的重要教训——当序列化格式自带代码执行能力时,下载模型的行为等同于打开一个会自动执行程序的包裹,风险完全不可控。
回到快递盒的比喻:人们不会主动拆开来路不明的会说话盒子,但模型下载者每天都在被动拆盒。pickle 作为模型格式的最大隐患就在这里——攻击者有地方塞纸条,而加载者连纸条上写了什么都看不见,直到代码已经在自己机器上跑起来。
- 反序列化入口pickle.loads 接收字节流,还原过程随即从第一个字节开始运转。
- 操作码解释执行还原器像读程序一样逐条解析操作码,每一次解析都对应真实的函数调用。
- 任意函数调用REDUCE 等指令能够直接调用 os.system 这类系统函数,攻击面彻底打开。
安全设计——“只认数据不识字”
那就换一只“不识字”的抽屉吧。safetensors 的设计哲学正是如此:它只认数据、不识字。打开抽屉,里面是一块块码放整齐的张量数据,没有小纸条,也没有朗读纸条的装置。加载器面对文件时,全程只做一件事:把数据从磁盘搬到内存。它不认识任何指令,也永远不会替攻击者“传话”。
safetensors 是 Hugging Face 推出的模型存储格式。它的文件里只允许存放张量的二进制数据和描述性元信息,不允许出现任何可执行指令。加载过程是纯粹的数据搬运——读文件、按偏移量切数据、交给使用方,全程不存在“解释执行”这种语义。攻击者想在加载阶段注入代码,首先需要格式里有“代码”的概念,而 safetensors 从根上取消了这一概念。
与 pickle 的对象协议相比,safetensors 是彻底的“数据协议”:不支持自定义对象,不支持函数引用,不支持 import,也不支持操作码。头部虽是明文 JSON,但字段类型受到严格约束,加载时只按声明的 dtype 和 shape 解析张量,不会把任何字节当命令执行。这个设计让“执行任意代码”的攻击面在结构上被关闭,而不是依赖使用者的自觉来防范。
保险抽屉从不识字,也就永远不会替攻击者念纸条。对安全设计而言,“做不到”比“小心不要做”可靠一万倍——safetensors 正是用“做不到”来换取结构性的安全。
- 加载前提加载器面对的是 safetensors 格式文件,而非 pickle 的对象流。
- 攻击面恒为零格式不含操作码解释器,加载路径上只有数据切分与拷贝操作。
格式结构——“清单标签+隔层编号”
保险抽屉外面贴着一张物品清单,每件物品都标了隔层编号。safetensors 文件同样如此:前面是清单,后面是货架。加载器不需要翻箱倒柜,看一眼清单,按编号伸手就能拿到对应的物品。这个“清单+编号”的结构,构成了 safetensors 整个安全与性能故事的基石。
safetensors 文件由三部分组成:JSON 头部、8 字节头部长度、原始张量二进制区。JSON 头部是一个字典,记录每个张量的名字、dtype、shape,以及它在数据区的偏移区间。8 字节长度让加载器知道清单读到哪一行截止。二进制区则是货架上真正摆放货物的区域——所有张量按字节连续存放,位置完全由头部声明的偏移量决定。
加载一个张量,加载器先读前 8 字节得到头部长度,再解析 JSON 拿到该张量的偏移区间,最后到数据区按字节切取——三步完成,没有任何多余动作。因为 JSON 头部就是完整的地图,加载器从不扫描、猜测或解释数据区的内容,只是照着地图精确取货。
第二个关键公式是偏移量定位:每个张量的起始位置由头部声明的 data_offsets 精确给出。
这份“清单+编号”的设计不仅安全,还让加载变得异常直接:想要第三个物品,就按编号去第三个隔层拿,不用把整只抽屉倒出来。这正是下一节要展开的性能优势。
- 起始位置计算每个张量的字节起始位置等于头部长度 H 加 8 再加头部声明的偏移量。
- 长度计算用结束偏移量减去起始偏移量,得到该张量占用的准确字节数。
对比优势——“按编号取物的档案柜”
档案室里找一份文件有两种做法:把整个档案柜搬到桌上翻找,或看一眼检索卡,直接抽出目标卷宗。pickle 是前者,safetensors 是后者。pickle 想还原一个对象,必须把整个字节流从头到尾反序列化,期间还要构造一堆中间对象;safetensors 凭借偏移量索引,可以跳过一切无关数据,精准命中目标张量。
safetensors 之所以能“直接抽”,靠的是内存映射(mmap)和头部偏移量索引。文件被映射到进程地址空间后,读取某个张量就是在内存地址范围内切片,不需要像 pickle 那样把全部字节流反序列化成对象图。这一机制在加载 7B、13B 甚至更大模型时尤其有价值——加载者完全可以只映射文件头部,按需读取推理真正需要的权重分片。
这带来三个直接优势:加载速度更快,省去反序列化和对象构造的巨大开销;内存可控,支持惰性加载,只把当前请求用到的层放入物理内存;跨语言友好,任何语言只要读 JSON 和二进制就能加载模型,不依赖 Python 的 pickle 运行时。对生产环境来说,这些优势叠加起来就是更低的延迟、更小的内存峰值和更轻的部署依赖。
档案管理员当然喜欢“按编号抽卷宗”而不是“搬空档案柜”。对 AI 工程师来说,safetensors 的 mmap 加载就像那位高效的管理员——快、省、又安全,尤其在模型规模动辄数 GB 的今天,这种优势几乎成了刚需。
- 内存映射mmap 把文件映射进进程地址空间,让文件读取变成普通内存访问。
- 零拷贝张量加载器直接在映射区域切出张量视图,不做多余的复制与对象构造。
生态应用——“AI社区的标配保险箱”
Hugging Face 像一座 AI 住宅小区,以前每栋楼都派发会说话的快递盒,现在物业统一换成只放数据的保险抽屉。住户取件安心,整个小区也从根上减少了失窃案。生态迁移是安全最重要的放大器——单个模型改用安全格式只是点上的防护,整个社区统一默认格式才能形成面上的防护。
Hugging Face 已将 safetensors 设为模型库的默认格式,transformers、diffusers 等主流库原生支持。社区大量模型从 .bin(pickle 格式)切换到 .safetensors,下载页的默认文件往往就是 safetensors 版本。这意味着新用户不需要任何额外的安全配置,开箱即用就走上了安全的加载路径。
对开发者而言,切换成本极低:加载代码几乎不变,但安全性完全不同。配合 Hub 的模型卡片与下载统计,新下载的模型绝大多数已不再携带 pickle 的可执行路径。工具链方面,训练脚本、推理服务、量化框架、模型浏览器全面适配,safetensors 已经成为事实上的生态标准。
当整座小区统一换锁之后,“入室盗窃”的风险自然降低。生态层面的全面迁移,让安全从一个团队的自觉行为,变成了整个社区共享的基础设施。
- 分子收缩随生态迁移,仍以 pickle 形式分发、含可执行路径的模型越来越少。
- 分母膨胀模型库总上传量持续增长,社区模型整体转向 safetensors。
- 风险趋零分子降、分母升,风险敞口的比值随时间推进逼近 0。
供应链安全——“从源头堵住投毒”
供应链是一条长长的物流线:从模型仓库到 CI 流水线,再到生产服务器,每一个环节都可能被人塞进小纸条。safetensors 的价值在于,每个包裹里只有货物、没有纸条——就算中途被拆开,也没有地方可以藏指令。安全如果只靠终点防线,永远防不住中间的每一个环节;只有让载体本身失去携带指令的能力,才能从源头堵住投毒。
AI 供应链里,模型文件是最危险的载体之一:体积大、来源杂、下载即加载。pickle 格式让“文件即武器”成为可能,攻击者一旦拿下某个上游仓库,就能给所有下游用户投递一枚定时炸弹;safetensors 则把载体中的攻击面整体移除,使审计、校验、合规分发成为可以落地的事情。整个分发链路中,不再存在“加载模型 = 执行远程代码”这个危险的等式。
对安全团队来说,safetensors 头部是明文 JSON,可以离线扫描全部张量元数据;数据区是纯二进制字节,不包含字符串常量、函数名或 import 路径。配合哈希与签名工具,就能构建一条“下载 → 校验 → 加载”的可信链路,从源头堵住模型投毒。企业还可以在准入系统中强制要求 safetensors 格式,将不合规的 pickle 文件直接拦截在门槛之外。
物流线上没有纸条,投毒者就无从下手。供应链安全的关键不是“事后追溯”,而是让攻击者根本没有可写纸条的载体——这正是 safetensors 格式的安全哲学,也是它在模型与数据供应链安全中不可替代的原因。
- 可携带代码概率pickle 为 1,safetensors 为 0,这是格式层面的开关。
- 恶意构造概率攻击者构造恶意模型文件的意愿客观存在,不会自动消失。
- 乘积归零任一因子为 0,整个攻击链的成立概率就为 0。
AI 应用场景
场景一:模型分发平台默认安全格式
阶段:模型分发与下载
解决的问题:传统方式以 pickle 分发权重,用户一旦执行 load_state_dict 就会触发反序列化,恶意模型可趁机调用系统命令。换用 safetensors 后,下载与加载链路中没有代码解释器,即使模型被人投毒,也无法形成代码执行。
具体案例:某 AI 团队将所有模型从 .bin 全量替换为 .safetensors,并在 CI 流水线中扫描模型仓库,发现 pickle 文件直接阻断合并请求,从源头杜绝了内部工具链误用旧格式的可能。
场景二:推理服务按需加载
阶段:模型推理与部署
解决的问题:推理服务常需同时加载多个微调模型,pickle 反序列化要求一次性读出全部对象,内存峰值极高。safetensors 支持 mmap 惰性加载,可以只载入当前请求所需的层,大幅降低多租户场景的内存压力。
具体案例:一个多租户推理平台同时服务 20 个微调模型,启用 safetensors 后,首次加载延迟明显下降,内存峰值减少约 40%;再配合对象存储按区间拉取,大模型也能实现秒级切换。
场景三:企业内部模型准入审计
阶段:模型治理与合规
解决的问题:企业内部合规要求模型文件必须可审查、可验证。pickle 的二进制字节流难以自动扫描;safetensors 的头部明文 JSON 可直接抽取元数据,配合签名校验实现自动化准入。
具体案例:某金融机构的模型准入平台规定,所有内部上传的自研或第三方模型必须提供 safetensors 版本,否则不通过合规检查。上线前安全团队会对头部元数据做离线审计,确认无异常字段后才允许部署。
Python 演示
"""对比 pickle 与 safetensors 的安全性: 加载模型文件时是否会执行代码."""import ioimport jsonimport osimport pickleimport tempfile import numpy as np # 1) 恶意 pickle: 加载后会悄悄执行一条外部命令exploit = b"""cossystem(S'echo "[!] pickle 加载时执行了外部命令!"'tR.""" def load_pickle(fileobj): # 模拟早期 transformers 从 pickle 加载权重 return pickle.load(fileobj, encoding="latin1") def make_weights(): return {"embed.weight": np.random.rand(4, 4).astype("float32")} def save_safetensors(path, weights): """极简 safetensors 写入: JSON 头部 + 8 字节长度 + 数据区.""" header = {} data = b"" for name, tensor in weights.items(): offset = len(data) header[name] = { "dtype": str(tensor.dtype), "shape": list(tensor.shape), "data_offsets": [offset, offset + tensor.nbytes], } data += tensor.tobytes() body = json.dumps(header).encode("utf-8") with open(path, "wb") as f: f.write(len(body).to_bytes(8, "little")) f.write(body) f.write(data) def load_safetensors(path): with open(path, "rb") as f: header_len = int.from_bytes(f.read(8), "little") header = json.loads(f.read(header_len)) f.seek(8 + header_len) data = f.read() weights = {} for name, meta in header.items(): start, end = meta["data_offsets"] weights[name] = np.frombuffer(data[start:end], dtype=meta["dtype"]) return weights def main(): # pickle 演示: 反序列化会解释操作码, 触发 os.system 调用 try: load_pickle(io.BytesIO(exploit)) print("[提示] pickle 会无条件解释文件中的操作码") except Exception: print("[!] pickle 报错之前已经在执行指令了") # safetensors 演示: 数据区塞入恶意字节也不会被执行 tmp = tempfile.mktemp(suffix=".safetensors") save_safetensors(tmp, make_weights()) with open(tmp, "ab") as f: f.write(b"os.system('echo hacked')") loaded = load_safetensors(tmp) print("[i] safetensors 加载成功, 张量数:", len(loaded)) os.remove(tmp) if __name__ == "__main__": main()
return pickle.load(fileobj, encoding="latin1")反序列化入口,字节流会被 pickle 解释器逐条解析,形成执行链。data += tensor.tobytes()把张量二进制追加进数据区,safetensors 只搬运字节流,不产生任何指令记录。"data_offsets": [offset, offset + tensor.nbytes]头部只声明“数据在哪个区间”,加载器靠它直接定位,无需任何解释执行。f.write(len(body).to_bytes(8, "little"))写入 8 字节头部长度,数据区起点可被精确计算。f.seek(8 + header_len)跳过长度场与 JSON 头部,直接进入数据区。weights[name] = np.frombuffer(data[start:end], dtype=meta["dtype"])按偏移量切出张量,全程只有数据搬运,没有任何代码执行。f.write(b"os.system('echo hacked')")故意往文件中追加恶意字节,验证 safetensors 加载器不会把它们当作指令。
常见误区
误区把 `torch.load` 加上 `weights_only=True`,模型加载就绝对安全了,完全不需要换 safetensors。
正解weights_only=True 只是限制 pickle 可以还原的对象类型,能在大多数场景拦住“直接调用 os.system”这类明显的攻击。但它依然运行在 pickle 解释器之上,本质是“给会说话的盒子贴上封条”,不能从结构上证明没有绕过路径。safetensors 干脆不提供解释器——就像不识字的人永远不会朗读纸条,这才是根治方案。
为何常见:一行参数改动成本最低,人们容易把“缓解措施”当成“根治方案”,低估了攻击者研究绕过技巧的积极性。
误区JSON 头部是明文,攻击者可以修改偏移量或形状字段,造成越界读取甚至缓冲区溢出,最终也能执行代码。
正解头部虽然是明文,但加载器会严格校验偏移量与数据区边界的关系,越界读取会被直接拒绝。更重要的是,safetensors 安全模型的核心是“格式不含代码执行路径”——即使攻击者改写了元数据,影响也被限制在数据读取层面,无法凭空生成一个指令解释器。
为何常见:很多人看到明文 JSON 就联想到 Web 场景里的注入与越权,把 Web 安全经验错误地套用到本地文件格式上。
误区我的模型是从 Hugging Face 官方仓库下载的,官方已经审核过,所以哪怕是 pickle 格式也没关系。
正解供应链攻击恰恰假设“上游不可完全信任”:官方账号可能被盗、构建链路可能被替换、镜像可能被篡改。pickle 的内容难以人工审计,受害者无法在加载前判断文件是否被动手脚;safetensors 的内容和元数据可离线独立验证,把信任从“来源”转移到“内容本身”。
为何常见:“官方发布的 = 安全的”是一种锚定效应,人们忽略了攻击者同样盯着官方的上传入口和分发渠道。
误区Safetensors 只是把模型文件的扩展名从 `.bin` 换成 `.safetensors`,换个壳而已,内部还是 pickle。
正解扩展名变了,底层格式也完全不同。safetensors 是“JSON 头部 + 偏移量索引 + 原始张量字节”的纯数据格式,根本没有 pickle 那一套对象协议和操作码。用起来 API 相似不代表内部一样——就像保险抽屉和快递盒长得都像盒子,但一个会执行指令,一个只认数据。
为何常见:使用层切换成本很低、加载 API 几乎无缝,容易让人误以为只是改了后缀,而忽略了内部机制的彻底更替。
面试题
Q1. pickle 反序列化时,恶意代码主要通过什么机制执行?
- A.源码藏于字节流中,加载时自动编译执行
- B.操作码解释执行,可调用任意 Python 函数← 正确答案
- C.畸形字节触发内存漏洞绕过防护
- D.下载网关解析时执行了其中的字符
答案:B。pickle 的还原过程本质是“操作码解释器”:它把字节流逐条解析并执行,其中 REDUCE 指令能调用 os.system 等任意函数,因此 B 正确。A 说的是编译源码,根本不是 pickle 的机制;C 描述的是内存破坏类漏洞,不属于 pickle 的设计行为;D 与下载网关无关。
Q2. safetensors 与 pickle 最本质的安全差异是什么?
- A.文件体积更小,下载速度更快
- B.格式只描述数据,没有指令解释器← 正确答案
- C.默认加密保护模型参数
- D.用 Rust 编写,Python 无法调用
答案:B。safetensors 是纯数据格式,加载器没有指令解释器,攻击面在结构上被关闭,B 正确。A 是性能差异不是安全本质;C 中 safetensors 默认不加密;D 错误,safetensors 提供完善的 Python 绑定。
Q3. safetensors 头部 data_offsets 字段的作用是什么?
- A.表示张量在数据区的起始与结束位置← 正确答案
- B.记录张量哈希用于完整性校验
- C.保存张量被加载的历史次数
- D.映射张量名称到 Python 内存地址
答案:A。data_offsets 是长度为 2 的整数数组,[start, end] 标明该张量在数据区中的字节区间,加载器据此直接切取,A 正确。哈希校验一般由外层签名体系完成,不归这个字段管;C、D 描述的功能在 safetensors 格式中根本不存在。
Q4. safetensors 能支持“只加载某个张量”的惰性加载,主要原因是什么?
- A.把文件切分成很多独立小文件
- B.头部偏移索引配合内存映射按需读取← 正确答案
- C.用加密区块隔离每个张量
- D.梯度信息保存在头部便于跳过
答案:B。safetensors 的 JSON 头部保存每个张量的偏移区间,文件通过 mmap 映射后可直接切取目标区间,B 正确。A 把文件拆成小文件是误解;C 的加密区块不是惰性加载的机制;D 与梯度信息无关。
Q5. torch.load(weights_only=True) 的说法哪种最准确?
- A.能完全阻止所有恶意代码加载
- B.限制可还原对象类型,仍建议换 safetensors← 正确答案
- C.PyTorch 1.0 后该参数已被移除
- D.关闭该参数能显著提升模型加载速度
答案:B。weights_only=True 只允许还原 torch.Tensor 等受限类型,能挡住多数攻击,但它仍运行在 pickle 解释器之上,无法在结构上杜绝所有风险,B 正确。A 过于绝对;C 与事实相反;D 没有依据,关闭限制只会提高风险而非速度。
Q6. 模型供应链中,投毒攻击最主要的路径是什么?
- A.文件太大导致下载过程超时
- B.分发格式允许携带代码,加载时执行恶意逻辑← 正确答案
- C.模型被量化后精度明显下降
- D.依赖库版本过低产生安全告警
答案:B。供应链投毒强调“文件本身即武器”:pickle 格式可携带操作码,受害者加载即中招,B 正确。A、C 属于性能和精度问题,不是投毒;D 属于依赖管理范畴,与模型文件投毒无关。
Q7. safetensors 加载器按偏移量读取一个张量时,下列步骤不存在的是?
- A.读取前 8 字节得到 JSON 头部长度
- B.解析 JSON 头部获得张量偏移量
- C.从数据区按偏移量切出二进制字节
- D.将二进制字节交给 Python 解释器执行← 正确答案
答案:D。safetensors 的加载链路只有“读长度、解析头部、切数据”三步。D 描述的“交给解释器执行”在格式上不存在,这正是它与 pickle 的根本区别——加载器永远只是数据的搬运工,不是代码的传话筒。
Q8. 为什么 pickle 不适合作为模型分发格式?(多选)
- A.反序列化会解释执行其中的操作码指令← 正确答案
- B.文件内容是晦涩的字节流,难以人工审计← 正确答案
- C.pickle 无法保存 PyTorch 的张量数据
- D.恶意构造的 pickle 能调用任意系统函数← 正确答案
答案:ABD。A、D 是代码执行层面的直接风险,B 是不可审计性,这三条都指向 pickle 不适合分发。C 错误:pickle 完全能保存张量,问题不在于“存不了”,而在于“加载时会执行不该执行的东西”。
Q9. 以下属于 safetensors 优势的有哪些?(多选)
- A.支持内存映射与惰性加载← 正确答案
- B.加载过程不存在代码解释执行← 正确答案
- C.能自动修复模型里的错误参数
- D.头部元数据明文可审计← 正确答案
答案:ABD。A 是性能优势,B 是结构安全优势,D 是审计与合规优势,都正确。C 不成立:safetensors 只负责存储和读取数据,不会修改张量参数,更谈不上自动修复。
Q10. 关于模型供应链安全的说法,正确的有哪些?(多选)
- A.官方仓库账号被盗会导致恶意模型被分发← 正确答案
- B.pickle 分发让反序列化更容易被武器化← 正确答案
- C.safetensors 可杜绝模型文件中携带代码← 正确答案
- D.下载后校验哈希可防范一切供应链攻击
答案:ABC。A、B 是真实存在的供应链风险。C:safetensors 格式本身不含代码路径,模型文件因此无法携带可执行代码,这一说法成立。D 过度绝对:哈希校验只能防篡改,防不了“官方自己发恶意模型”等场景,安全需要多道防线而非单一校验。