Python Pickle 反序列化与2025羊城杯
参考文章
写的非常细的一个博客(差点给我浏览器卡死)
Python Pickle 反序列化漏洞(原理+示例) - FreeBuf网络安全行业门户
从零开始python反序列化攻击:pickle原理解析 & 不用reduce的RCE姿势 - 知乎(写的很通俗易懂我感觉 主要是一些绕过)
Code-Breaking中的两个Python沙箱 | 离别歌(P大的沙盒)
Python Pickle 反序列化
pickle基础
1.1 什么是 Pickle?
Pickle是 Python 内置的序列化与反序列化模块。它允许将 Python 对象转换为二进制流(序列化) 也可以将这些二进制数据还原回原始对象(反序列化)
这个过程可以使得 Python 对象在网络上传输或保存在文件中,同时保留其原本的结构与数据。
1.2 Pickle 和 JSON 的区别
JSON只能表示基本类型(数值、字符串、列表、字典等),而Pickle能够序列化几乎任意Python对象(类实例、函数、复杂数据结构等),因此功能更强但也风险更高。
1.3基本用法
1 | import pickle |
这里创建了一个Person类,其中有两个属性age和name。首先使用了pickle.dumps()
函数将一个Person对象序列化成二进制字节流的形式。然后使用pickle.loads()
将一串二进制字节流反序列化为一个Person对象。
1.3.1序列化(pickle.dumps)
将 Python 对象转化为字节流(即二进制数据)。这个字节流可以存储在文件中,或通过网络传输。
1 | pickle.dumps(obj, protocol=None) |
obj:待序列化的对象。
protocol:可选参数,指定 Pickle 协议版本,默认为 None
,即使用 Python 的默认协议。
1 | import pickle |
1 | b'\x80\x04\x95&\x00\x00\x00\x00\x00\x00\x00}\x94(\x8c\x04name\x94\x8c\x07YoSheep\x94\x8c\x04role\x94\x8c\x06people\x94u.' |
这是 Pickle 序列化后的字节流,它是一个二进制表示,内部包含了对象的类型、属性等信息。你不会直接看到数据的结构,但可以理解为它是 Python 对象的“压缩”形式。
1.3.2反序列化
1 | pickle.loads(data) |
data:待反序列化的字节流。
1 | # 将序列化数据反序列化回 Python 对象 |
1.3.3 将对象序列化到文件(pickle.dump)
使用 pickle.dump()
可以将 Python 对象直接序列化并写入文件
1 | pickle.dump(obj, file, protocol=None) |
obj:待序列化的对象。
file:目标文件对象。
protocol:可选的 Pickle 协议版本,默认使用 Python 的最高版本。
1.3.4从文件反序列
使用 pickle.load()
可以从文件中读取 Pickle 格式的数据并反序列化为 Python 对象。
1 | pickle.load(file) |
file:包含 Pickle 数据的文件对象。
1.4能被序列化的对象
在Python的官方文档中,对于能够被序列化的对象类型有详细的描述,如下
None
、True
和False
- 整数、浮点数、复数
str
、byte
、bytearray
- 只包含可打包对象的集合,包括 tuple、list、set 和 dict
- 定义在模块顶层的函数(使用
def
定义,lambda
函数则不可以) - 定义在模块顶层的内置函数
- 定义在模块顶层的类
- 某些类实例,这些类的
__dict__
属性值或__getstate__()
函数的返回值可以被打包(详情参阅 打包类实例 这一段)
对于不能序列化的类型,如lambda函数,使用pickle模块时则会抛出 PicklingError
异常。
反序列化漏洞
通过上面介绍的pickle基础 我们可以想到 如果我们在反序列化未知的二进制字节流时 在里面写入恶意代码 使用pickle.loads()
方法unpickling时,就会导致恶意代码的执行。
比如
1 | import pickle |
现在在person类中加入一个reduce函数 这个函数定义反序列化时的操作
__reduce__
方法的返回值是一个元组,格式为 (callable, (args,))
(这里 callable
是可调用对象,args
是传递给它的参数)。当对象被反序列化时,Python 会执行 callable(*args)
。
所以这个代码被反序列化时 执行的是os.system(whoami)
这个例子就是对Pickle反序列化漏洞一个直观的描述。不过该漏洞的利用方式远不止此,想要进一步深入,我们就需要了解pickle的工作原理
Pickle工作原理
Pickle
可看作一种独立的栈语言,由一连串 opcode
(指令集/操作码)组成。它的解析依靠 Pickle Virtual Machine(PVM,Pickle 虚拟机) 来进行。
PVM由以下三部分组成
指令处理器:从字节流中读取
opcode
和参数,对其进行解释处理,不断重复这个动作,直到遇到表示结束的标记。最后,栈顶的值会被作为反序列化后的对象返回。stack(栈):由 Python 的
list
实现,用于临时存储数据、参数以及对象,是 PVM 进行操作的 “临时工作台”。memo(存储区):由 Python 的
dict
实现,在 PVM 的整个生命周期中提供存储功能,可用于记录和复用一些对象等操作。
栈区
pickle
的执行依赖栈结构(类似 “堆叠的数据容器”),图中栈区分为 当前栈(stack) 和 前序栈(metastack),两者共同支撑 pickle
指令的执行:
Stack(当前栈)
是 PVM 执行指令时的 “临时操作栈”,用于存放当前正在处理的数据或中间结果。
图中当前栈存储了 'name'
和 'rxz'
两个字符串,可理解为:在某一时刻,pickle
正在处理与这两个字符串相关的序列化 / 反序列化逻辑(比如构建一个字典,键为 'name'
、值为 'rxz'
)。
metastack(前序栈)
可看作 “历史操作的暂存栈”,用于保存之前处理过的数据,方便后续指令复用或回溯。
图中前序栈存储了 (233.333, 666)
(元组)、'QwQ'
(字符串)、123
(整数),这些是之前操作中产生或用到的数据,后续指令可能会再次调用它们。
存储区
memo
是由 Python dict
实现的 “长期存储区”,为 PVM 的整个生命周期提供数据存储(类似 “全局缓存”)。
图中 memo[1]
、memo[2]
等表示 memo 中的键值对,用于记录序列化 / 反序列化过程中需要持久化的对象(比如重复出现的复杂对象,存到 memo 中避免重复序列化,提升效率)。
关于协议(我感觉了解一下就可以了 直接贴出来了)
3.1 常用opcode(V0版本)
3.2 PVM工作流程
解析 str
(
:压入一个 MARK
标记(用于标记 “数据区间” 的起始,辅助后续指令定位)。
S'str1'
:实例化字符串 "str1"
(S
是 “创建字节字符串” 的操作码)。
S'str2'
:实例化字符串 "str2"
。
I1234
:实例化整数 1234
(I
是 “创建 int 对象” 的操作码)。
t
:找到上一个 MARK
(由最开始的 (
压入),将 MARK
和当前位置之间的所有数据("str1"
、"str2"
、1234
)组合为元组。
解析 __reduce__()
3.3 pickletools
可以将opcode转化成方便我们阅读的形式
1 | import pickletools |
3.4 又一个例子
1 | opcode=b'''cos |
1 | import pickle |
漏洞利用方式
4.1命令执行
我们已经知道我们可以通过重写reduce方法来执行我们的任意命令 不过这种方法一次只能执行一个命令
如果想一次执行多个命令 就只能通过手写opcode来实现
在opcode中,.
是程序结束的标志。我们可以通过去掉.
来将两个字节流拼接起来
1 | import pickle |
可以函数执行的字节码有三个(R、i、o)
R这个上面见过了比较熟悉
1 | opcode1=b'''cos |
i相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象)
1 | opcode2=b'''(S'whoami' |
o寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象)
1 | opcode3=b'''(cos |
部分 Linux 系统和 Windows 系统的
opcode
字节流不兼容:-Windows 下,执行系统命令的函数是
os.system()
。-部分 Linux 系统下,执行系统命令的函数是posix.system()
因此,构造 payload 时需注意目标系统环境
并且pickle.loads
会解决import 问题,对于未引入的module会自动尝试import。也就是说整个python标准库的代码执行、命令执行函数我们都可以使用
4.2实例化对象
实例化对象也是一种特殊的函数执行
1 | import pickle |
c__main__ |
c 是 “获取全局对象 / 导入模块” 的操作码,这里表示 “从 __main__ 模块中获取对象”。 |
---|---|
Person |
指定从 __main__ 模块中获取 Person 类。 |
(I18 |
( 压入 MARK 标记(标记参数区间起始);I18 实例化整数 18 (作为 age 参数)。 |
S'Pickle' |
S 是 “创建字节字符串” 的操作码,实例化字符串 "Pickle" (作为 name 参数)。 |
tR |
t 找到 MARK ,将区间内的 18 和 "Pickle" 组合为元组 (18, "Pickle") ;R 调用 Person 类(视为可调用对象),传入元组作为参数,实例化 Person 对象。 |
执行 p = pickle.loads(opcode)
pickle
虚拟机(PVM)会解析我们的opcode并且执行下面的步骤
从 main 模块中获取 Person 类。
准备参数:整数 18 和字符串 “Pickle”。
调用 Person(18, “Pickle”),实例化对象。
将实例赋值给 p,后续打印验证 p 的类型和属性。
以上的opcode相当于手动执行了构造函数Person(18,'Pickle')
4.3变量覆盖
很多程序会将用户信息(如身份凭证)序列化后存储在 Session 或 token 中(方便验证用户身份)。如果 Session/token 以明文形式存储,我们就有可能通过变量覆盖的方式进行身份伪造,利用 pickle
反序列化漏洞进行攻击。
两部分
首先secret.py:定义了变量 secret = "This is a key"
(模拟存储敏感信息的模块)。
1 | #secret.py |
主程序:先正常导入 secret
模块并打印变量,再通过恶意 pickle
操作码覆盖 secret
变量。
1 | import pickle |
我们首先通过c
来获取__main__.secret
模块,然后将字符串secret
和Hack!!!
压入栈中,然后通过字节码d
将两个字符串组合成字典{'secret':'Hack!!!'}
的形式。由于在pickle中,反序列化后的数据会以key-value的形式存储,所以secret模块中的变量secret="This is a key"
,是以{'secret':'This is a key'}
形式存储的。最后再通过字节码b来执行__dict__.update()
,即{'secret':'This is a key'}.update({'secret':'Hack!!!'})
,因此最终secret变量的值被覆盖成了Hack!!!
看多了这个opcode就慢慢能看懂了(白旗)
Pker工具的使用
5.1 Pker可以做到什么
- 变量赋值:存到memo中,保存memo下标和变量名即可
- 函数调用
- 类型字面量构造
- list和dict成员修改
- 对象成员变量修改
pker
是一个用于生成 pickle
操作码(opcode
)的工具,通常可以帮助用户更方便地编写 pickle 操作码。它可能是一个用于简化操作码编写和调试的辅助工具。通过使用 pker
,用户可以方便地构造 pickle 的二进制流,而不需要手动编写每个操作码。
pker最主要的有三个函数GLOBAL()
、INST()
和OBJ()
1 | GLOBAL('os', 'system') //cos\nsystem\n |
获取指定模块中的函数(或类),生成对应的 pickle
操作码。
GLOBAL('os', 'system')
生成 b'cos\nsystem\n'
,表示 “从 os
模块获取 system
函数”(c
是获取全局对象的操作码,os
和 system
分别为模块名和函数名,\n
为分隔符)。
1 | INST('os', 'system', 'ls') //(S'ls'\nios\nsystem\n |
获取模块中的函数,并传入参数执行调用,生成组合操作码。
(
压入 MARK
标记(参数起始);S'ls'
生成字符串参数 'ls'
;i
是组合操作码(等价于 c
+ o
),关联 os.system
函数与参数,执行 os.system('ls')
。
1 | OBJ(GLOBAL('os', 'system'), 'ls') // (cos\nsystem\nS'ls'\no |
通过已获取的可调用对象(如 GLOBAL
得到的函数),传入参数执行调用。
(
压入 MARK
标记;
cos\nsystem\n是 GLOBAL 生成的函数操作码;S’ls’ 是参数;o操作码调用函数,执行 `os.system(‘ls’)。
5.2 return
语句的用法
return
用于指定序列化的最终返回值,生成对应的结束操作码(.
是 pickle
的结束标记):
return
:生成b'.'
,表示反序列化结束,返回栈顶对象。return var
:生成b'g_\n.'
(g_
表示引用之前存储的变量),返回变量var
。return 1
:生成b'I1\n.'
(I1
是整数1
的操作码),返回整数1
。
5.3 使用
1 | test.py |
在命令行执行 python3 pker.py < pker_tests.py
,生成的字节流为:
1 | b"I0\np0\n0S'id'\np1\n0(g0\nlp2\n0(I0\ntp3\n0(g3\nI0\ndp4\n0cos\nsystem\np5\n0g5\n(g1\ntR." |
绕过
对于pickle反序列化漏洞,官方的第一个建议就是永远不要unpickle来自于不受信任的或者未经验证的来源的数据。
第二个就是通过重写Unpickler.find_class()
来限制全局变量,我们来看官方的例子
1 | import builtins |
RestrictedUnpickler限制
想要绕过find_class
,我们则需要了解其何时被调用。在官方文档中描述如下
出于这样的理由,你可能会希望通过定制
Unpickler.find_class()
来控制要解封的对象。 与其名称所提示的不同,Unpickler.find_class()
会在执行对任何全局对象(例如一个类或一个函数)的请求时被调用。 因此可以完全禁止全局对象或是将它们限制在一个安全的子集中。
在opcode中,c
、i
、\x93
这三个字节码与全局对象有关,当出现这三个字节码时会调用find_class
,当我们使用这三个字节码时不违反其限制即可
绕过builtins
在一些例子中,我们常常会见到module=="builtins"
这一限制
1 | if module == "builtins" and name in safe_builtins: |
那么什么是builtins
模块呢?
当我们启动Python之后,即使没有创建任何的变量或者函数,还是会有许多函数可以使用,如
1 | >>>int(1) |
上述这类函数被我们称为”内置函数”,这其实就是builtins模块的功劳,这些内置函数都是包含在builtins模块内的。而Python解释器在启动时已经自动帮我们导入了builtins模块,所以我们自然就可以使用这些内置函数了。
如果内置函数也被禁用 这时候的思路就类似于沙箱逃逸了
1 | import pickle |
限制只能使用builtins模块 并且禁用了内置危险函数 这时候有两个思路
思路一
getattr函数没有禁用 可以成为一个突破口
所以我们的思路就是通过getattr间接获取我们需要的危险函数(eval)
1 | import builtins |
成功获取被黑名单禁用的 eval
函数。
接下来我们得构造出一个builtins
模块来传给getattr
的第一个参数,我们可以使用builtins.globals()
函数获取builtins模块包含的内容
1 | print(builtins.globals()) |
builtins.globals()
返回的是字典,要从中获取 'builtins'
对应的模块,需使用字典的 get
方法。
1 | builtins.getattr(builtins.dict, 'get') |
最终构造的payload为
1 | builtins.getattr( |
最后就是写opcode
获取get参数
1 | import pickle |
获取globals字典
1 | import pickle |
组合起来获得builtins模块
1 | import pickle |
最后调用获取到的eval函数
1 | import pickle |
1 | import pickle |
用工具也可以
1 | #payload.py |
思路二
既然思路一中我们可以通过全局变量获取我们需要的get 那么也可以通过globals来获取pickle模块
来实验一下
1 | import pickle |
可以看到,globals()
函数中的全局变量,确实包含我们导入的官方或自定义的模块,那么我们就可以尝试导入使用pickle.loads()
来绕过find_class()
了。
不过值得注意的是,由于pickle.loads()
的参数需要为byte
类型。而在Protocol 0
中,对于byte类型并没有很好的支持,需要额外导入encode()函数,可能会导致无法绕过find_class
限制。
1 | import pickle |
羊城杯2025web方向
ez_unserialize
1 |
|
简单理一下
我们的目的是执行类u中的system
首先是H类里的_destruct
当对象被销毁时自动触发 调用$this->who对象的start方法
1 | $h->who = $a; |
start是A类的
然后我们构造
1 | $a->next = $v |
当对象被 echo 输出时,让他触发tostring
构造
1 | $v->dowhat = "secret" |
因此$abc = “secret”
1 | $v->go = $e |
$this->go->$abc等价于
$e->secret
触发E
类的__get()
1 | $e->found = $f |
调用F
类的check()
方法
1 | $f->finalstep = "u" |
触发U
类的__invoke()
调用$this->there->system($this->cmd)
,其中$this->there
是N
类实例
通过调用N
类的system()
方法(实际不存在),触发N
类的__call()
魔术方法
__call()
会通过call_user_func($func_name, $args[0])
执行system($this->cmd)
,其中$func_name
是"system"
,$args[0]
是$_POST['cmd']
的值。
最终结果:执行用户通过cmd
参数传入的任意系统命令(如cat /flag.txt
)
1 |
|