pickle反序列化
前置知识
这几天打完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 |
先学这么多吧~后面有积累到新姿势会回来补充的