Python 沙箱逃逸的经验总结
让用户提交 Python 代码并在服务器上执行,是一些 OJ、量化网站重要的服务,很多 CTF 也有类似的题。为了不让恶意用户执行任意的 Python 代码,就需要确保 Python 运行在沙箱中。沙箱经常会禁用一些敏感的函数,例如 os,研究怎么逃逸、防护这类沙箱还是蛮有意思的。
前言
Python 的沙箱逃逸的最终目标就是执行系统任意命令,次一点的写文件,再次一点的读文件。
顺便安利一本书:《流畅的 Python》。这本书有很多中高阶知识点,很全面而且讲的很清楚,如果你看过,相信理解这篇文章的大多数内容都不是问题。
接下来的内容先讲系统命令执行,再讲文件写入、读取,并且均以 oj 为例,库大多以 os
为例。
执行系统命令
基础知识
先啰嗦一些基础知识
在 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 |
|
all_modules_2
就是 2.x 的标准库,all_modules_3
就是 3.x 的标准库。
结果相当多,这里就不贴了。这里注意一下,这个文件别命名为 test.py
,如果命名为 test 会怎么样呢?可以先猜一猜,后面会给解释。
如果 oj 支持 import
的话,这些库都是高危的,放任不管基本上是坐等被日。所以为了避免过滤不完善导致各种问题,在 Python 沙箱套一层 docker 肯定不会是坏事。
花式 import
首先,禁用 import os
肯定是不行的,因为
1 |
|
都可以。如果多个空格也过滤了,Python 能够 import 的可不止 import
,还有 __import__
:__import__('os')
,__import__
被干了还有 importlib
:importlib.import_module('os').system('ls')
这样就安全了吗?实际上import
可以通过其他方式完成。回想一下 import 的原理,本质上就是执行一遍导入的库。这个过程实际上可以用 execfile
来代替:
1 |
|
不过要注意,2.x 才能用,3.x 删了 execfile,不过可以这样:
1 |
|
这个方法倒是 2.x、3.x 通用的。
不过要使用上面的这两种方法,就必须知道库的路径。其实在大多数的环境下,库都是默认路径。如果 sys 没被干掉的话,还可以确认一下,:
1 |
|
花式处理字符串
代码中要是出现 os
,直接不让运行。那么可以利用字符串的各种变化来引入 os:
1 |
|
1 |
|
还可以利用 eval
或者 exec
:
1 |
|
eval、exec 都是相当危险的函数,exec 比 eval 还要危险,它们一定要过滤,因为字符串有很多变形的方式,对字符串的处理可以有:逆序、拼接、base64、hex、rot13...等等,太多了。。。
1 |
|
你看看最后那个格式化字符串,这不是直接起飞?啥字符构造不了?
恢复 sys.modules
sys.modules
是一个字典,里面储存了加载过的模块信息。如果 Python 是刚启动的话,所列出的模块就是解释器在启动时自动加载的模块。有些库例如 os
是默认被加载进来的,但是不能直接使用(但是可以通过 sys.modules
来使用,例如 sys.modules["os"]
),原因在于 sys.modules 中未经 import 加载的模块对当前空间是不可见的。
如果将 os 从 sys.modules 中剔除,os 就彻底没法用了:
1 |
|
注意,这里不能用 del sys.modules['os']
,因为,当 import 一个模块时:import A,检查 sys.modules 中是否已经有 A,如果有则不加载,如果没有则为 A 创建 module 对象,并加载 A。
所以删了 sys.modules['os']
只会让 Python 重新加载一次 os。
看到这你肯定发现了,对于上面的过滤方式,绕过的方式可以是这样:
1 |
|
最后还有一种利用 __builtins__
导入的方式,下面会详细说。
花式执行函数
通过上面内容我们很容易发现,光引入 os 只不过是第一步,如果把 system 这个函数干掉,也没法通过os.system
执行系统命令,并且这里的system
也不是字符串,也没法直接做编码等等操作。我遇到过一个环境,直接在/usr/lib/python2.7/os.py
中删了system
函数。。。
不过,要明确的是,os 中能够执行系统命令的函数有很多:
1 |
|
应该还有一些,可以在这里找找:
2.x 传送门🚪
3.x 传送门🚪
过滤system
的时候说不定还有其他函数给漏了。
其次,可以通过 getattr
拿到对象的方法、属性:
1 |
|
不让出现 import 也没事:
1 |
|
一样可以。这个方法同样可以用于逃逸过滤 import 的沙箱。关于 __builtins__
,见下文。
与 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 |
|
3.x:
1 |
|
但是,__builtins__
两者都有,实际上是__builtin__
和builtins
的引用。它不需要导入,我估计是为了统一 2.x 和 3.x。不过__builtins__
与__builtin__
和builtins
是有一点区别的,感兴趣的话建议查一下,这里就不啰嗦了。不管怎么样,__builtins__
相对实用一点,并且在 __builtins__
里有很多好东西:
1 |
|
这里稍微解释下 x.__dict__
,它是 x 内部所有属性名和属性值组成的字典,有以下特点:
- 内置的数据类型没有
__dict__
属性 - 每个类有自己的
__dict__
属性,就算存着继承关系,父类的__dict__
并不会影响子类的__dict__
- 对象也有自己的
__dict__
属性,包含self.xxx
这种实例属性
那么既然__builtins__
有这么多危险的函数,不如将里面的危险函数破坏了:
1 |
|
或者直接删了:
1 |
|
但是我们可以利用 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 的。2.2 之前是经典类,搜索是深度优先;经典类后来发展为新式类,使用广度优先搜索,再后来新式类的搜索变为 C3 算法;而 3.x 中新式类一统江湖,默认继承 object
,当然也是使用的 C3 搜索算法。。。扯远了扯远了,感兴趣的可以搜搜。不管怎么说,总是让人去判断继承关系显然是反人类的,所以 Python 中新式类都有个属性,.__mro__
或 .mro()
,是个元组,记录了继承关系:
1 |
|
类的实例在获取 __class__
属性时会指向该实例对应的类。可以看到,''
属于 str
类,它继承了 object
类,这个类是所有类的超类。具有相同功能的还有__base__
和__bases__
。需要注意的是,经典类需要指明继承 object 才会继承它,否则是不会继承的:
1 |
|
那么知道这个有什么用呢?
由于没法直接引入 os,那么假如有个库叫oos
,在oos
中引入了os
,那么我们就可以通过__globals__
拿到 os。例如,site
这个库就有 os
:
1 |
|
怎么理解这个 __globals__
呢?它是函数所在的全局命名空间中所定义的全局变量。也就是只要是函数就会有这个属性。除了 builtin_function_or_method
或者是 wrapper_descriptor
、method-wrapper
类型的函数,例如 range
、range.__init__
、''.split
等等。
那么也就是说,能引入 site 的话,就相当于有 os。那如果 site 也被禁用了呢?没事,本来也就没打算直接 import site
。可以利用 reload
,变相加载 os
:
1 |
|
还有,既然所有的类都继承的object
,那么我们先用__subclasses__
看看它的子类,以 2.x 为例:
1 |
|
可以看到,site 就在里面,以 2.x 的site._Printer
为例(py3.x 中已经移除了这里 __globals__
的 os
):
1 |
|
os 又回来了。并且 site 中还有 __builtins__
。
这个方法不仅限于 A->os,还阔以是 A->B->os,比如 2.x 中的 warnings
:
1 |
|
在继承链中就可以这样(py3.x 中已经移除了这里 __globals__
的 linecache
):
1 |
|
顺便说一下,warnings
这个库中有个函数:warnings.catch_warnings
,它有个_module
属性:
1 |
|
所以通过_module
也可以构造 payload(py3.x 中已经移除了 catch_warnings
的 linecache
):
1 |
|
3.x 中的warnings
虽然没有 linecache
,也有__builtins__
。
同样,py3.x 中有<class 'os._wrap_close'>
,利用方式可以为:
1 |
|
当然这样也是可以的(3.x):
1 |
|
顺便提一下,object
本来就是可以使用的,如果没过滤的话,payload 可以再简化为:
1 |
|
还有一种是利用builtin_function_or_method
的 __call__
:
1 |
|
或者简单一点:
1 |
|
上面这些 payload 大多数是直接 index 了,但是直接用 index 不太健壮,可以都换成列表推导式,用 __name__
来获取想要的 class,上面也举了好几个例子了,这里就不多说啦。
最后再补充几个。
可以这样利用:
1 |
|
还可以利用异常逃逸:
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
。
最后,其实沙箱逃逸,对于不同的第三方库可能会存在一些特殊的利用方式,比如 jinja2
,这类属于 SSTI
漏洞,可以看这个:传送门🚪,这里就不多说了。
其实 SSTI 也会用到这里的很多技巧,两者知识面相互交叠。
文件读写
2.x 有个内建的 file
:
1 |
|
还有个 open
,2.x 与 3.x 通用。
还有一些库,例如:types.FileType
(rw)、platform.popen
(rw)、linecache.getlines
(r)。
为什么说写比读危害大呢?因为如果能写,可以将类似的文件保存为math.py
,然后 import 进来:
math.py:
1 |
|
调用
1 |
|
这里需要注意的是,这里 py 文件命名是有技巧的。之所以要挑一个常用的标准库是因为过滤库名可能采用的是白名单。并且之前说过有些库是在sys.modules
中有的,这些库无法这样利用,会直接从sys.modules
中加入,比如re
:
1 |
|
当然在import re
之前del sys.modules['re']
也不是不可以...
最后,这里的文件命名需要注意的地方和最开始的那个遍历测试的文件一样:由于待测试的库中有个叫 test
的,如果把遍历测试的文件也命名为 test,会导致那个文件运行 2 次,因为自己 import 了自己。
读文件暂时没什么发现特别的地方。
剩下的就是根据上面的执行系统命令采用的绕过方法去寻找 payload 了,比如:
1 |
|
或者
1 |
|
敏感信息泄露
这个也算只能读吧。
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).f_locals
inspect.x
,inspect 有很多方法可以获取信息,比如获取源码可以用inspect.getsource
,还有其他很多的功能- ...
这有一篇不错的文章,推荐阅读:
https://www.cnblogs.com/dechinphy/p/modify-locals.html
其他
这些行为不像是 oj 会做得出来的,ctf 倒是有可能出现。
过滤 [ ]
应对的方式就是将[]
的功能用pop
、__getitem__
代替(实际上a[0]
就是在内部调用了a.__getitem__(0)
):
1 |
|
当然,dict 也是可以 pop 的:{"a": 1}.pop("a")
当然也可以用 next(iter())
替代,或许可以加上 max
之类的玩意。
过滤引号
chr
最简单就是用 chr
啦
1 |
|
扣字符
利用 str
和 []
,挨个把字符拼接出来
1 |
|
当然 []
如果被过滤了也可以 bypass,前面说过了。
如果 str 被过滤了怎么办呢?type('')()
、format()
即可。同理,int
、list
都可以用 type
构造出来。
格式化字符串
那过滤了引号,格式化字符串还能用吗?
(chr(37)+str({}.__class__)[1])%100 == 'd'
又起飞了...
dict() 拿键它不香吗?
1 |
|
限制数字
上面提到了字符串过滤绕过,顺便说一下,如果是过滤了数字(虽然这种情况很少见),那绕过的方式就更多了,我这里随便列下:
- 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 |
|
任意浮点数稍微麻烦点,需要想办法运算,但是一定可以搞出来,除非是 π 这种玩意...
限制空格
空格通常来说可以通过 ()
、[]
替换掉。例如:
[i for i in range(10) if i == 5]
可以替换为 [[i][0]for(i)in(range(10))if(i)==5]
限制运算符
> < ! - +
这几个比较简单就不说了。
==
可以用 in
来替换。
替换 or
的测试代码
1 |
|
上面这几个表达式都可以替换掉 or
替换 and
的测试代码
1 |
|
上面这几个表达式都可以替换掉 and
限制 ( )
这种情况下通常需要能够支持 exec 执行代码。因为有两种姿势:
- 利用装饰器
@
- 利用魔术方法,例如
enum.EnumMeta.__getitem__
,
利用这两种姿势,我在《OrangeKiller CTF 第 2 期》中出了 2 道题目,题解篇写的很详细,移步去看吧:传送门
利用新特性
PEP 498 引入了 f-string
,在 3.6 开始出现:传送门🚪,食用方式:传送门🚪。所以我们就有了一种船新的利用方式:
1 |
|
关注每次版本增加的新特性,或许能淘到点宝贝。
利用反序列化攻击
反序列化攻击也是能用来逃逸,但是关于反序列化攻击的安全问题还挺多的,我专门写了篇文章,见:传送门🚪
🌰
这个例子来自iscc 2016
的Pwn300 pycalc
,相当有趣:
1 |
|
exec command in _global
这一句就把很多 payload 干掉了,由于 exec 运行在自定义的全局命名空间里,这时候会处于restricted execution mode
,这里不赘述了,感兴趣可以看这篇文章:传送门🚪。exec 加上定制的 globals 会使得沙箱安全很多,一些常规的 payload 是没法使用的,例如:
1 |
|
不过也正是由于 exec 运行在特定的命名空间里,可以通过其他命名空间里的 __builtins__
,比如 types 库,来执行任意命令:
1 |
|
极端限制
这种限制一般是组合形式出现,而且通常只会出现在 CTF 中。
限制输入字符的集合的大小
思路就是先确定不得不用到的字符,再看这些字符能够拼出哪些函数或者常量。
我在《OrangeKiller CTF 第 2 期》中出了 3 道题目与此相关,题解篇写的很详细,移步去看吧:传送门
限制不能使用
[a-zA-Z]
的字符
我在 《从一个绕过长度限制的 XSS 中,我们能学到什么?》 中提到过,Python3 支持了 Unicode 变量名且解释器在做代码解析的时候,会对变量名进行规范化,算法是 NFKC
。
所以在这种情况下可以用这种姿势:
1 |
|
socket + 严格的输入限制
可以看看是否漏掉了 help
,漏掉的话,先通过 help()
调起 vi/vim,然后用 !
指令即可 getshell
通解
已经专门写文章介绍了,见:《Python 沙箱逃逸的通解探索之路》
最后
这块内容本身就零散,罗里吧嗦了这么多,希望对大家有帮助,如果有不严谨的地方希望各位师傅们能指出来,共同探讨 [抱拳]
- By:tr0y.wang
- 左青龙
- 微信扫一扫
- 右白虎
- 微信扫一扫
评论