python反序列化学习总结
前置知识
前言
现实需求
每种编程语言都有各自的数据类型,其中面向对象的编程语言还允许开发者自定义数据类型(如:自定义类),Python也是一样。很多时候我们会有这样的需求:
- 把内存中的各种数据类型的数据通过网络传送给其它机器或客户端;
- 把内存中的各种数据类型的数据保存到本地磁盘持久化;
数据格式
如果要将一个系统内的数据通过网络传输给其它系统或客户端,我们通常都需要先把这些数据转化为字符串或字节串,而且需要规定一种统一的数据格式才能让数据接收端正确解析并理解这些数据的含义。
XML 是早期被广泛使用的数据交换格式,在早期的系统集成论文中经常可以看到它的身影;如今大家使用更多的数据交换格式是JSON(JavaScript Object Notation),它是一种轻量级的数据交换格式。JSON相对于XML而言,更加加单、易于阅读和编写,同时也易于机器解析和生成。除此之外,我们也可以自定义内部使用的数据交换格式。
如果是想把数据持久化到本地磁盘,这部分数据通常只是供系统内部使用,因此数据转换协议以及转换后的数据格式也就不要求是标准、统一的,只要本系统内部能够正确识别即可。但是,系统内部的转换协议通常会随着编程语言版本的升级而发生变化(改进算法、提高效率),因此通常会涉及转换协议与编程语言的版本兼容问题,下面要介绍的pickle协议就是这样一个例子。
序列化/反序列化
将对象转换为可通过网络传输或可以存储到本地磁盘的数据格式(如:XML、JSON或特定格式的字节串)的过程称为序列化;反之,则称为反序列化。
严谨一点说,
序列化 (Serialization)是将对象的状态信息转换为可以存储或传输的形式的过程。
反序列化 (Deserialization)是将有序的二进制序列转换成某种对象(字典,列表等)的过程。
相关模块
本节要介绍的就是Python内置的几个用于进行数据序列化的模块:
模块名称 | 描述 | 提供的api |
---|---|---|
json | 用于实现Python数据类型与通用(json)字符串之间的转换 | dumps()、dump()、loads()、load() |
pickle(cpickle) | 用于实现Python数据类型与Python特定二进制格式之间的转换 | dumps()、dump()、loads()、load() |
shelve | 专门用于将Python数据类型的数据持久化到磁盘,shelve是一个类似dict的对象,操作十分便捷 | open() |
Python 中有很多能进行序列化的模块,比如 Json、pickle/cPickle、Shelve、Marshal
其中json模块应该是最为人所知的,它主要提供python字典,列表等数据类型和字符串之间互相转换的能力;而marshal和pickle模块则可以对python中的类和对象进行序列化和反序列化。
与json相比,pickle以二进制储存,不易人工阅读;json可以跨语言,而pickle是Python专用的;pickle能表示python几乎所有的类型(包括自定义类型),json只能表示一部分内置类型且不能表示自定义类型。
python有另一个更原始的序列化包marshal,现在开发时一般使用pickle。在python反序列化安全这方面, pickle/cPickle 模块较常使用,下面我们主要以它为例进行讲解。
在 pickle 模块中 , 常用以下四个方法
pickle.dump(obj, file)
: 将对象序列化后保存到文件pickle.load(file)
: 读取文件, 将文件中的序列化内容反序列化为对象pickle.dumps(obj)
: 将对象序列化成字符串格式的字节流pickle.loads(bytes_obj)
: 将字符串格式的字节流反序列化为对象
注意:file文件需要以 2 进制方式打开,如wb
、rb
在 Python 中,一切皆对象,因此能使用 pickle 序列化的数据类型有很多
- 内置常量(None、True 和 False等)
- 整数、浮点数、复数
- 字符串、字节串、字节数组(即str、byte、byte array)
- 只包含可封存对象的集合,包括 tuple、list、set 和 dict
- 定义在模块最外层的函数(使用 def 定义,lambda 函数定义的则不可以)
- 定义在模块最外层的内置函数
- 定义在模块最外层的类
- 某些类实例,这些类的
__dict__
属性值或__getstate__()
函数的返回值可以被封存
注意,文件、套接字、以及代码对象不能被序列化!
漏洞详解
漏洞成因
我们都知道,Java中的反序列化漏洞主要在于反序列化时会调用对象的readObject方法,在PHP中则有更多的魔术方法在反序列化等各种情况下调用。
而在python中,同样有几个内置方法会在对象被反序列化时调用。他们分别是:
1 |
|
具体的用法可以参考官方文档中的描述:
pickle — Python 对象序列化 — Python 3.15.0a0 文档
方法 | 主要用途 | pickle 调用时机 | 典型场景 |
---|---|---|---|
__reduce__ |
控制整体序列化/还原 | 序列化、反序列化 | 复杂对象、特殊还原方式 |
__reduce_ex__ |
同上,兼容协议版本 | 序列化、反序列化,但优先于 __reduce__ |
针对不同 pickle 协议做兼容处理 |
__getstate__ |
控制保存哪些属性 | 仅序列化时 | 部分属性不序列化、定制序列化状态 |
__setstate__ |
控制如何还原属性 | 仅反序列化时 | 属性还原、补充默认/临时变量 |
参考PHP的反序列化,如果我们可以在类中构造这些魔术方法,并在方法中执行恶意代码。那么实例在反序列化时就会执行我们的代码了。
示例如下,
reduce:
注意:pickle.dumps(a)
调用 __reduce__
,把你的对象变成一个“还原说明书”(在例子里,就是 (os.system, ("whoami",))
);只有在 pickle.loads(ser_a)
时,pickle 才会按照这个“说明书”去执行 os.system("whoami")
,以“还原”你的对象!
1 |
|
reduce_ex:
1 |
|
setstate:
注意:__setstate__
只有在反序列化对象有“状态”需要恢复时才会被调用;对于没有自定义 __getstate__
,也没有实例属性的类,
1 |
|
getstate:
1 |
|
更加尴尬的是,python并没有对pickle模块做任何安全性的限制:他没有验证反序列化的类是否在应用中注册(pickle 在反序列化时不会校验目标类是否在应用中“注册”或“允许”反序列化),也没有类似Java中SerializeUID的存在(Python pickle 没有版本ID或签名等机制来校验“这个类是否就是我想反序列化的那个类”,更没有校验数据完整性或来源可靠性)。
这也就导致了,攻击者任意构造的对象都会被实现了pickle.load的接口进行反序列化并执行Magic function。
而且,pickle 支持自定义对象如何被序列化和反序列化,关键就是 __reduce__
及其返回值。只要你自定义了 __reduce__
,就可以“欺骗”pickle,让它用你指定的方式来还原对象。
大多数内建类型和新式类(继承自 object
)都有一个默认的 __reduce__
实现(由基类提供)。如果你没有自定义 __reduce__
,pickle 会调用默认的实现,pickle 会把类名、模块名、属性等一一序列化。如果你自定义了 __reduce__
,就是重写了默认的行为,这样的话,pickle只会把你__reduce__
返回的内容记录下来。反序列化时,pickle会执行你__reduce__
返回的“可调用对象+参数。
这是 pickle 的最大安全隐患之一,也是 RCE(远程代码执行)攻击的常用手段。
比如我们运行如下代码
1 |
|
结果如下:
可以发现这并没有反序列化类名、模块名、属性等,仅仅是反序列化了一个函数调用(通过system执行whoami)
1 |
|
然后执行
1 |
|
然后代码执行成功。
综上,python没有对pickle模块做任何安全性的限制以及pickle 支持自定义对象如何被序列化和反序列化,我们就可以在反序列化时运行我们自定义的魔术方法,达到任意代码执行。
注意,**其他模块的load也可以触发pickle反序列化漏洞。**例如:numpy.load()
先尝试以numpy自己的数据格式导入;如果失败,则尝试以pickle的格式导入。因此numpy.load()
也可以触发pickle反序列化漏洞。
深入pickle
pickle数据流格式
pickle 所使用的数据格式仅可用于 Python。这样做的好处是没有外部标准给该格式强加限制,比如 JSON 或 XDR(不能表示共享指针)标准;但这也意味着非 Python 程序可能无法重新读取 pickle 封存的 Python 对象。
默认情况下,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 |
|
py2 (Python 2.7.18 )序列化后结果为:
这是协议 0(文本协议),也是 pickle 的最古老协议,内容为人类可读的文本。
(输出的一大串字符实际上是一串PVM
操作码, 可以在pickle.py
中看到关于这些操作码的详解,下面会讲)
1 |
|
py3 (Python 3.10.12 )序列化后结果为:
是协议 4,内容是纯二进制字节流,不可读。
1 |
|
我们在上面“相关模块”里提到了pickle的常用方法接口,这里可以结合上面的协议进一步理解:
将打包好的对象 obj 写入文件中,其中 protocol 为 pickling 的协议版本(上面6种)。
1 |
|
将 obj 打包以后的对象作为 bytes 类型直接返回。
1 |
|
从 文件 中读取二进制字节流,将其反序列化为一个对象并返回。
1 |
|
从 data 中读取二进制字节流,将其反序列化为一个对象并返回。
1 |
|
但接口只是封装好之后的表象,下面我们深入理解一下
pickle 是一种栈语言,有不同的编写方式,基于一个轻量的 PVM(Pickle Virtual Machine)。
Pickle Virtual Machine(简称 Pickle VM 或 PVM,在安全文献或分析讨论中可能会这样叫)不是官方 Python 的一部分,而是指:Pickle 协议本身实现的解释与执行机制,即 Python 解释器在处理 pickle 字节流(即 pickle 序列化后的二进制数据)时,内部有一个“虚拟机”或“解释器”,逐条解析 pickle 指令(opcodes),并按规则恢复对象。
它和 Python 的字节码虚拟机不一样,但思想类似:都是“从一个指令流,驱动状态机,动态恢复数据结构”。
这个“虚拟机”包括一个指令集(如 MARK、STOP、REDUCE、GLOBAL、TUPLE、DICT、LIST 等),和一个堆栈模型(数据在栈上进进出出,指令操控栈内容)。
指令处理器
从流中读取 opcode 和参数,并对其进行解释处理。重复这个动作,直到遇到 . 这个结束符后停止。最终留在栈顶的值将被作为反序列化对象返回。
stack
由 Python 的 list 实现,被用来临时存储数据、参数以及对象。
memo
由 Python 的 dict 实现,为 PVM 的整个生命周期提供存储。
Pickle VM和 Python VM 的区别
- Python VM(通常指的是 Python Virtual Machine)负责执行 Python 字节码(pyc)。
- Pickle VM(非官方叫法)是指 Python 解释 pickle 数据流“指令集”和数据栈的那一套机制,只服务于 pickle 序列化与反序列化过程。
指令集(操作码) opcode
下面是指令集(或者称操作码)所对应的二进制比特流,以及相应的解释。
1 |
|
pickle.loads(或者pickle.load)是一个供我们调用的接口,其底层实现是基于_Unpickler
类。都是把各自输入得到的东西作为文件流,喂给_Unpickler
类;然后调用_Unpickler.load()
实现反序列化。
在反序列化过程中,_Unpickler
(以下称为机器,其实就是上面提到的pickle虚拟机)维护了两个东西:栈区和存储区。结构如下(本图片仅为示意图):
栈是unpickle机最核心的数据结构,所有的数据操作几乎都在栈上。为了应对数据嵌套,栈区分为两个部分:当前栈专注于维护最顶层的信息,而前序栈维护下层的信息。这两个栈区的操作过程将在讨论MASK指令时解释。
存储区可以类比内存,用于存取变量。它是一个数组,以下标为索引。它的每一个单元可以用来存储任何东西,但是说句老实话,大多数情况下我们并不需要这个存储区。
您可以想象,一台机器读取我们输入的字符串,然后操作自己内部维护的各种结构,最后吐出来一个结果——这就是我们莫得感情的_Unpickler
。为了研究它,也为了看懂那些乱七八糟的字符串,我们需要一个有力的调试器。这就是pickletools
。
pickletools是python自带的pickle调试器,有三个功能:反汇编一个已经被打包的字符串、优化一个已经被打包的字符串、返回一个迭代器来供程序使用。
pickletools会将不可读二进制字节流转换成字符串,并进行解释。
以下代码为例
1 |
|
结果
1 |
|
我们可以看到二进制字节流对应的操作码,这就是反汇编功能:解析那个字符串,然后告诉你这个字符串干了些什么。每一行都是一条指令。
整体流程总结
- 声明协议4,创建一个 frame。
- 写入模块和类名(
__main__.Test
),找到类对象。 - 用 NEWOBJ 创建新对象(调用
Test.__new__()
)。 - 创建空 dict。
- 依次压入属性名和值(name, matrix, age, 20)。
- SETITEMS 填充 dict。
- BUILD 设置对象的属性。
- STOP 结束。
接下来试一试优化功能:
1 |
|
结果
1 |
|
利用pickletools,我们能很方便地看清楚每条语句的作用、检验我们手动构造出的字符串是否合法……总之,是我们调试的利器。现在手上有了工具,我们开始研究这个字符串是如何被pickle解读的吧。
想看操作码具体讲解的可以去看第三篇参考文章。
这里我们深入理解“漏洞成因”中__reduce__
原理:
CTF竞赛对pickle的利用多数是在__reduce__
方法上。它的指令码是R
,干了这么一件事情:
- 取当前栈的栈顶记为
args
,然后把它弹掉。 - 取当前栈的栈顶记为
f
,然后把它弹掉。 - 以
args
为参数,执行函数f
,把结果压进当前栈。
class的__reduce__
方法,在pickle反序列化的时候会被执行。其底层的编码方法,就是利用了R
指令码。 f
要么返回字符串,要么返回一个tuple,后者对我们而言更有用。
一种很流行的攻击思路是:利用 __reduce__
构造恶意字符串,当这个字符串被反序列化的时候,__reduce__
会被执行。网上已经有海量的文章谈论这种方法,所以我们在这里不过多讨论。只给出一个例子:正常的字符串反序列化后,得到一个Student
对象。我们想构造一个字符串,它在反序列化的时候,执行ls /
指令。那么我们只需要这样得到payload
1 |
|
那么,如何过滤掉reduce呢?由于__reduce__
方法对应的操作码是R
,只需要把操作码R
过滤掉就行了。这个可以很方便地利用pickletools.genops
来实现。
利用手法
绕过黑名单
绕过函数黑名单:奇技淫巧
有一种过滤方式:不禁止R
指令码,但是对R
执行的函数有黑名单限制。典型的例子是2018-XCTF-HITB-WEB : Python’s-Revenge。给了好长好长一串黑名单:
1 |
|
可惜platform.popen()
不在名单里,它可以做到类似system
的功能。这题死于黑名单有漏网之鱼。
另外,还有一个解(估计是出题人的预期解),那就是利用map来干这件事:
1 |
|
总之,黑名单不可取。要禁止reduce这一套方法,最稳妥的方式是禁止掉R
这个指令码。
参考文献
python:序列化与反序列化(json、pickle、shelve) - 秋寻草 - 博客园
一篇文章带你理解漏洞之 Python 反序列化漏洞 - Hexo
从零开始python反序列化攻击:pickle原理解析 & 不用reduce的RCE姿势 - 知乎