前置知识
这几天打完xyctf了,里面刚好有一道pickle反序列化的题目,当时也是现学然后现打的,但总归学的还是不仔细,赛后还是得针对性的学一下
什么是Pickle?
参考官方文档:pickle — Python 对象序列化
跟PHP反序列化一样,在python中也会存在对象序列化和反序列化的操作,那么Pickle就是用于实现这一功能的模块之一
模块 pickle 实现了对一个 Python 对象结构的二进制序列化和反序列化。 “pickling” 是将 Python 对象及其所拥有的层次结构转化为一个字节流的过程,而 “unpickling” 是相反的操作,会将(来自一个 binary file 或者 bytes-like object 的)字节流转化回一个对象层次结构。

但是其实在python中并不只有pickle可以实现序列化的操作,python有一个更原始的序列化模块叫marshal,但一般地 pickle 应该是序列化 Python 对象时的首选。marshal 存在主要是为了支持 Python 的 .pyc 文件.
但是根据官方文档的话,他有提到一个更安全的序列化格式json,这两个有什么区别呢
Pickle和JSON的区别
- JSON 是一个文本序列化格式(它输出 unicode 文本,尽管在大多数时候它会接着以
utf-8编码),而 pickle 是一个二进制序列化格式; - JSON 是我们可以直观阅读的,而 pickle 不是;
- JSON是可互操作的,在Python系统之外广泛使用,而pickle则是Python专用的;
- 默认情况下,JSON 只能表示 Python 内置类型的子集,不能表示自定义的类;但 pickle 可以表示大量的 Python 数据类型(可以合理使用 Python 的对象内省功能自动地表示大多数类型,复杂情况可以通过实现 specific object APIs 来解决)。
- 不像pickle,对一个不信任的JSON进行反序列化的操作本身不会造成任意代码执行漏洞。
Pickle模块常见接口
要序列化某个包含层次结构的对象,只需调用 dumps() 函数即可。同样,要反序列化数据流,可以调用 loads() 函数。

然后我们来写个实例分析一下pickle序列化和反序列化的操作
pickle实例分析
1 | import pickle |
在代码中我创建了Person类,并通过init初始化了两个变量name和age,并实例化了对象,然后使用pickle.dumps()函数将一个Person对象序列化成二进制字节流的形式。然后使用pickle.loads()将一串二进制字节流反序列化为一个Person对象。
什么反序列化结果是对象内存地址?
序列化内容解析
- Pickle 的二进制输出包含:
- 类定义信息(来自
__main__模块的Person类) - 实例属性值(
age=20和name="Vu1n4bly")
- 类定义信息(来自
- Pickle 的二进制输出包含:
反序列化过程
- Pickle 会:
- 找到
__main__.Person类的定义 2.创建一个新的Person实例 - 恢复其属性值(
age和name) - 返回这个新对象
- 找到
- Pickle 会:
输出结果的本质
print(test2)输出的是对象的默认字符串表示形式:
<__main__.Person object at 0x内存地址>这是 Python 所有对象的默认
____输出格式内存地址每次运行都会变化(这是新创建对象的特征)
所以我们试一下输出反序列化后的对象的内容
1 | import pickle |
可以看到这里可以直接访问并输出反序列化对象的内容
能够序列化的对象类型
这个在官方文档中也有介绍到

以上就是我们的前置知识了
Pickle反序列化漏洞
在上面跟json模块的对比中,从最后一点可以看出,pickle在操作的时候是可能被造成任意代码执行漏洞的,这是为什么呢?这取决于Pickle模块中对数据的不安全处理,pickle 数据来 在解封时执行任意代码 是可能的。如果我们在数据中包含精心构造的恶意代码,就可能会导致恶意代码被执行,这是为什么呢?
漏洞成因
- Pickle数据在反序列化时自动执行Python字节码(或对象方法)
- 模块本质上不会对反序列化的内容进行校验
如果我们使用pickle.loads()方法unpickling的时候,本质上是在重构对象,而并非简单的解析数据,如果我们对象定义了魔术方法例如__reduce__,Pickle 会调用它,并存储其返回内容作为反序列化的依据。
什么是__reduce__方法
__reduce__方法在定义的时候是不带任何形参的,但是应返回字符串或最好返回一个元组(返回的对象通常称为“reduce 值”)。

方法定义
1 | def __reduce__(self): |
- **
callable**:可调用对象(如exec,os.system,lambda, 或一个类名)。 - **
args_tuple**:传给callable的参数(必须是一个元组)。
可以把返回值理解成PHP中call_user_func函数的运用,第一个参数就是需要传入的类或者函数对象,后面的元组是需要传入该函数的参数
我们写个实例
1 | import pickle |
可以看到在字节流被反序列化后,Python会调用__reduce__,并将os.system,(command,)作为返回值,这里返回了whoami的执行结果
以上的结果就是简单的对pickle反序列化漏洞进行了一点讲解,如果想深入的话,我们来看看pickle的工作原理
pickle基于PVM的工作原理
参考文章:Pickle反序列化
学过编程都知道,任何语言到最后都是以字节码的形式而存在的,python代码的执行也是如此,一门解释性语言也会把源码解析编译为字节码,也就是pyc文件
Python Virtual Machine (PVM) 是 Python 运行时环境 的核心组件,它负责 执行 Python 字节码,使其能够在不同系统上运行。这就跟Java中的JVM一样,但是这个是属于python中的
其实pickle模块在序列化Python对象时,会生成一系列操作码(opcode)来表示对象的类型和值。而在反序列化的时候,pickle模块读取操作码序列,并将其解释为Python对象。它通过Pickle Virtual Machine来执行操作码序列。此时PVM会按照顺序读取操作码,并根据操作码执行相应的操作
PVM由以下三部分组成
- 指令处理器:从流中读取
opcode和参数,并对其进行解释处理。重复这个动作,直到遇到 . 这个结束符后停止。 最终留在栈顶的值将被作为反序列化对象返回。 - stack:由 Python 的
list实现,被用来临时存储数据、参数以及对象。 - memo:由 Python 的
dict实现,为 PVM 的整个生命周期提供存储。

当前用于 pickling 的协议共有 5 种。使用的协议版本越高,读取生成的 pickle 所需的 Python 版本就要越新。
- v0 版协议是原始的“人类可读”协议,并且向后兼容早期版本的 Python。
- v1 版协议是较早的二进制格式,它也与早期版本的 Python 兼容。
- v2 版协议是在 Python 2.3 中引入的。它为存储 new-style class 提供了更高效的机制。欲了解有关第 2 版协议带来的改进,请参阅 PEP 307。
- v3 版协议添加于 Python 3.0。它具有对
bytes对象的显式支持,且无法被 Python 2.x 打开。这是目前默认使用的协议,也是在要求与其他 Python 3 版本兼容时的推荐协议。 - v4 版协议添加于 Python 3.4。它支持存储非常大的对象,能存储更多种类的对象,还包括一些针对数据格式的优化。有关第 4 版协议带来改进的信息,请参阅 PEP 3154。
pickle协议是向前兼容的,0号版本的字符串可以直接交给pickle.loads(),不用担心引发什么意外。下面我们以V0版本为例,介绍一下常见的opcode
全部opcode
1 | MARK = b'(' # push special markobject on stack |
常用opcode
| 指令 | 描述 | 具体写法 | 栈上的变化 |
|---|---|---|---|
| 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标记以及被组合的数据出栈,字典被更新 |
| a | 将栈的第一个元素append到第二个元素(列表)中 | a | 栈顶元素出栈,第二个元素(列表)被更新 |
| e | 寻找栈中的上一个MARK,组合之间的数据并extends到该MARK之前的一个元素(必须为列表)中 | e | MARK标记以及被组合的数据出栈,列表被更新 |
然后我们可以通过dis函数去输出字节码,Python的dis模块是用于**反汇编(disassemble)**Python字节码的核心工具,可以展示代码是如何被PVM执行的。

接下来我们看一下
分析字节流
1 | import pickle |
我们看一下这段字节流数据
1 | 0: \x80 PROTO 4 ← 协议版本4 |
关键指令
STACK_GLOBAL(0x93)
从栈顶弹出两个字符串 (模块名 和 属性名),组合成全局对象
REDUCE(0x52)
- 弹出栈顶的元组作为参数 (
args) - 弹出下一个对象作为可调用对象 (
callable)
这里的话就等价于
1 | import os os.system('whoami') |
师傅的文章中给出了PVM解析__reduce__()的过程

所以我们可以很清晰的领会到opcode在PVM中的操作流程,那接下来我们就可以根据opcode指令手写opcode
实操opcode
1 | import pickle |
解释一下opcode
c:获取一个全局对象或import一个模块,写法是c[module]\n[instance]\n,这里是import了os模块
根据上面的STACK_GLOBAL (0x93)可以知道这里是相当于导入os模块,调用system
(:向栈中压入一个MARK标记S:实例化一个字符串对象,压入whoami字符串t:寻找栈中的上一个MARK,并组合之间的数据为元组,也就是('whoami')R:选择栈上的第一个对象作为函数、第二个对象作为参数(第二个对象必须为元组),然后调用该函数,就是system(‘whoami’).:程序结束,把函数执行结果作为返回值
opcode和__reduce__
上面我们也写到了我们可以通过重构__reduce__的方法在反序列化的时候执行任意命令,但是这个方法每次只能执行一个命令,而opcode不一样,我们可以通过将字节流拼接的方式执行多个命令
1 | import pickle |
成功执行ls和whoami两个命令
在常用opcode中可以看到,在pickle中用来构造函数执行的字节码有:R、i、o共同实现命令执行。
R:选择栈上的第一个对象作为函数、第二个对象作为参数(第二个对象必须为元组),然后调用该函数。函数和参数出栈,函数的返回值入栈
上面讲的就是R字节码的操作
o:寻找栈中的上一个MARK(t的操作),以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象),这个过程中涉及到的数据都出栈,函数的返回值(或生成的对象)入栈
1 | import pickle |
i:相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象)用法:i[module]\n[callable]\n,这个过程中涉及到的数据都出栈,函数返回值(或生成的对象)入栈
1 | import pickle |
先学这么多吧~后面有积累到新姿势会回来补充的