bigywater.tech
Python · 74

pickle:为什么反序列化不可信数据很危险

pickle 不是「把对象变成字节」这么简单——它的字节流是一段由虚拟机执行的小程序。这篇造了一个只往当前目录写一个文件的无害载荷,亲眼看 pickle.loads 是怎么在你拿到返回值之前就跑起了它。

54 字节
载荷全长,里面出现的是 posix.system,而不是任何自定义类名
loads → 0
返回值是 os.system 的退出码(int),不是 Evil 实例
find_class
重写它做 allowlist,恶意载荷被 UnpicklingError 拦下

做的事:一个会「回调」的类

__reduce__ 告诉 pickle 「还原我的时候请调用这个函数」
实验脚本 class Evil: def __reduce__(self): # 序列化时返回 (可调用对象, 参数元组);反序列化时 pickle 会执行 可调用对象(*参数) return (os.system, ("echo pwned > PWNED.txt",)) payload = pickle.dumps(Evil(), protocol=2) obj = pickle.loads(payload) # 谁来执行 os.system?

载荷只做一件无害的事:在当前(临时)目录写一个 PWNED.txt。真实攻击里这条命令可以换成任何东西——这里把它限制到最小,只为证明「会执行」。

验证一:载荷里是一段 opcode 程序

pickletools.dis 反汇编
实测 · 本机执行输出
payload 长度: 54 字节;  repr: b'\x80\x02cposix\nsystem\nq\x00X\x16\x00\x00\x00echo pwned > PWNED.txtq\x01\x85q\x02Rq\x03.'
    0: \x80 PROTO      2
    2: c    GLOBAL     'posix system'
   16: q    BINPUT     0
   18: X    BINUNICODE 'echo pwned > PWNED.txt'
   45: q    BINPUT     1
   47: \x85 TUPLE1
   48: q    BINPUT     2
   50: R    REDUCE
   51: q    BINPUT     3
   53: .    STOP

只有 10 条指令。核心是这三条:GLOBAL 'posix system' 按名字导入一个函数、BINUNICODE 放入命令字符串、REDUCE 调用它。载荷里出现的是 posix system,并没有 Evil 这个类名——攻击者不需要你的代码里有任何特定的类,pickle 允许通过 GLOBAL 引用任何可导入的东西。

验证二:loads 一返回,文件已经被写了

实测 · 本机执行输出
loads 之前, 标记文件存在? False
loads 之后, 标记文件存在? True | 内容: pwned
loads 返回的对象: 0 int  ← 不是 Evil 实例, 是 os.system 的返回值
payload 字节里含有 'Evil'(类名)? False | 含有 'system'(要调用的函数)? True
危险发生在反序列化这一刻,而不是你使用对象的时候。返回值是 0(os.system 的退出码),你拿到的甚至不是一个 Evil 对象——所以用 isinstance 检查返回类型来防御是没用的:代码早在检查之前就执行过了。

这也解释了 pickle 文档为什么反复强调「不要反序列化不可信来源的数据」:数据来源是网络、上传文件、共享缓存(比如别人也能写入的 Redis)时,等于给对方一个远程代码执行入口。

验证三:能拦住,但要用 allowlist

重写 Unpickler.find_class
实测 · 本机执行输出
Safe.load(payload) → UnpicklingError: 禁止加载 posix.system
标记文件被创建了吗? False
正常数据 Safe.load → [1, 2, {'a': 3}]

所有「按名字导入」都要经过 find_class,把它改成只放行明确列出的类型,恶意载荷在 GLOBAL 这一步就被拒了:posix.system 被拒绝,标记文件没有被创建,而正常的 [1, 2, {'a': 3}] 照常还原。这里用的是 allowlist(只允许白名单),不是 denylist——黑名单永远漏得掉,因为可导入的东西太多。就算这样,也只应当用在「格式受你控制但需要防御一层」的场景;来源不可信时,直接换成不会执行代码的格式。

验证四:换成 JSON 就没有这回事

实测 · 本机执行输出
json.loads(payload) → JSONDecodeError
json.loads('{"a": [1, 2]}') → {'a': [1, 2]}

同一串字节交给 json.loads,得到的只是解析错误;JSON 只能表示数据(对象、数组、字符串、数字),没有「调用函数」这个概念。代价是它表示不了任意 Python 对象(自定义类、集合、元组会变形),需要自己定义转换——这是安全的代价,也是刻意的。

分步演示:载荷在 pickle 虚拟机里怎么一步步执行

左边是上面 pickletools.dis 的原始输出,栈是纯 Python 版 pickle._Unpickler 每执行一条 opcode 后的真实内容
pickle 虚拟机执行 54 字节载荷
正在执行的 opcode / 栈顶

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7,标准库 pickle、pickletools;载荷只写一个临时目录里的 PWNED.txt
    • 没有验证:其他协议版本下 opcode 的差异、__setstate__ 等其他触发路径、C 版 Unpickler 与纯 Python 版在 find_class 上的完全等价性(演示用的是纯 Python 版,防御实验用的是默认版)
    京ICP备2026067502号-1京公网安备11010502063487号