如果你面试时被问到“哪一类漏洞最像编程语言语法题,却又能直接拿下服务器权限”,我会毫不犹豫说是反序列化。它不像SQL注入那样有一个明显的拼接点,也不像文件上传那样有一个落地动作,它的核心就是:你把一个对象存起来,再把它还原,还原的那一瞬间,代码路径被一条看不见的数据牵着走,最终可能变成命令执行。
这篇文章我会用 PHP 和 Python 两个方向拆解反序列化漏洞,包含原理、示例、审计思路和防御手段。适合正在做接口开发、写中间件的读者,也适合刚转安全的同学。我不会一上来就抛十来行Payload让你背,而是先把“它为什么危险”这件事讲透,理解了底层的机制,后面构造利用链、写防护规则都会顺很多。
1. 序列化机制:先搞懂它为什么要存在
1.1 对象怎么变成“一句话”
序列化(Serialization)是程序把内存中的对象转换成可存储或可传输的格式,反序列化(Deserialization)则是它的逆过程,把字节流重新还原成对象。数据库里没法直接存一个 PHP 数组或 Python 对象,Redis 里也没法直接存一个 dict,于是语言设计了一套规则,把对象拍扁成一段字符串或二进制。
比如一个简单的 PHP 类:
class User { public $name = 'guest'; public $role = 'guest'; }对它执行serialize($user),会得到:
O:4:"User":2:{s:4:"name";s:5:"guest";s:4:"role";s:5:"guest";}拆开看,O:4:"User"表示一个类名长度为 4、名为 User 的对象,花括号里是两个属性,每个属性由类型、长度、内容组成。是的,你没猜错,那段字符串里能直接看到类名、属性名和值。反序列化就是读取这段字符串,在内存中重建出这个对象。
Python 里道理一样,只是表现形式不同。pickle协议序列化的不是纯文本书写,而是一串指令码,每条指令告诉解释器“我有个对象,你要怎么建”。这也是 pickle 格外凶险的原因之一,我在后面会详细讲。
1.2 序列化与 JSON 的本质差别
很多初学者会把 JSON 和序列化混为一谈,但这是一个极其重要的分水岭。JSON 只保存“纯数据”,它存储的是属性名和值的映射,不携带类信息,更不会在还原时触发对象的方法。也就是说,json_decode出来的永远只是数组或普通对象,它不会自动调用某个类的魔术方法。
原生序列化不一样,它保存的是“具有完整类型信息的对象蓝图”,反序列化时,语言会真正地去实例化类、恢复属性,并且在某些时机触发类里预设的方法。PHP 的魔术方法、Python 的 reduce 协议,就是在这个“还原物件”的过程中被唤醒的。
所以一句话总结:JSON 是给数据搬运工用的,原生序列化是给“对象搬运工”用的。搬运工的职责越多,被人捣鬼的空间也就越大。很多框架为了省事,直接用原生序列化来做缓存、Session、RPC 数据传输,这恰恰是最常见的问题温床。
2. 反序列化漏洞的核心:危险藏在你没料到的方法里
2.1 一个 PHP 基础示例:入口迟钝,危害不迟钝
先看一段最常见的代码:
class User { public $name = 'guest'; public $admin = 0; public function __destruct() { if ($this->admin === 1) { file_put_contents('/tmp/flag.txt', 'admin'); } } } $data = $_GET['data']; $user = unserialize($data); echo $user->name;攻击者提交data=O:4:"User":2:{s:4:"name";s:4:"hack";s:5:"admin";i:1;},脚本反序列化后生成一个admin属性为 1 的 User 对象。脚本结束时__destruct()被调用,/tmp/flag.txt就被写入了。
这个例子虽然小,但它把漏洞的本质暴露得很清楚:
- 入口只接收了一个字符串;
- 没有过滤、没有签名、没有白名单;
- 类里的某个魔术方法内部,存在危险逻辑;
- 调用时机是反序列化完成或对象释放时,开发者根本来不及感知。
如果__destruct、__wakeup里碰巧有eval、system、call_user_func、file_put_contents这类函数,攻击者不需要直接调用它们,只需要控制反序列化数据,让对象在还原和销毁过程中“恰好”走到对应代码分支,就完成了命令执行或文件读写。
2.2 魔术方法:攻击链的天然跳板
PHP 相关类中有一组以双下划线开头的方法,会在特定时刻被自动调用。这里挑几个最常被利用的:
| 方法 | 触发时机 | 常见利用方式 |
|---|---|---|
__wakeup() | 反序列化时被调用 | 初始化动作,也可能包含赋值逻辑,攻击者控制属性后影响后续代码流 |
__destruct() | 对象销毁时被调用 | 资源清理,常配危险函数,最容易触发 |
__toString() | 对象被当作字符串使用时 | 配合字符串拼接、文件路径处理时会走到 |
__call() | 调用不存在的方法时 | 后续做方法转发时可能被引入危险调用 |
__get() | 读取不可访问属性时 | 属性代理场景中容易接上危险函数 |
__invoke() | 对象被当作函数调用时 | 配合回调函数可引导到任意代码 |
反序列化拿到一个对象后,代码只要在“正常流程”里使用它,就可能触发上述方法。攻击者要找的不是哪个方法本身有漏洞,而是“控制哪些属性,能改变哪个方法内的关键变量”。
我这里要特意强调一句话:反序列化漏洞通常不是某个函数单独造成的,而是多条属性控制路径汇聚到一个危险动作上。比如__destruct里写了system($this->cmd),攻击者把cmd设为id,就这么简单;但如果__destruct先调用了$this->helper->run(),那攻击者就得找一个run()方法里存在危险调用的类,把它也放进序列化数据里,形成一条“对象链”。
2.3 类属性数量偏差:一个经典小把戏
如果你去查反序列化漏洞案例,一定会看到类似O:7:"Hacker":1:这种数字被人工改掉的 Payload。原理是:历史上部分 PHP 版本在反序列化时,会优先用字符串里声明的属性数量与实际属性的数量做比对,如果两者不一致,就可能跳过后面的__wakeup()执行。
比如类的__wakeup()做了强校验,把对象锁定为非管理员,但攻击者把属性数量从2改成3,部分版本在还原时发生“错位”,直接绕过__wakeup,到达后面的__destruct。这招在 PHP 5.6 时代相当出名,后来 PHP 7.4 到 8.x 已经做了修复。现在大家提这个点,更多是当作理解“反序列化数据由攻击者掌控”的一个经典案例:序列化字符串的每个字节都不值得信任。
2.4 从“魔术方法”到 POP 链
POP(Property-Oriented Programming)链,是反序列化攻击里最常见的概念。一句话解释就是:利用现有类中可被控制的属性,串起一连串方法调用,最终抵达危险函数。
我习惯了这样去搜索和分析:
- 先找危险动作:
eval、system、exec、call_user_func、unlink、include; - 再找谁在调用这些动作:某个类的
__destruct或__toString里,是否直接调用了可控属性作为参数; - 如果这个类不好用,就找它的“调用者”:有没有别的类在某个魔术方法里调用了
$this->xxx->method(); - 把这些类按“触发顺序”串起来,构造序列化字符串,逐个设置属性。
我举个例子方便理解。假设目标代码里有这样的类:
class A { public $callback; public function __destruct() { call_user_func($this->callback); } } class B { public $cmd; public function __toString() { return system($this->cmd); } }攻击者的目标是执行系统命令。A::__destruct()调用了call_user_func($this->callback),如果直接把callback设为system,参数呢?call_user_func只会用回调名称,不能用第二参数。继续往下看就会发现:如果把callback设为B对象,触发call_user_func($b)时,PHP 把对象当作函数调用,会触发B::__invoke()。可这里B没有__invoke,链断了。
换个方向:如果在某个代码路径里,A的callback被echo或字符串拼接,那么就会自动触发B::__toString(),里面是system($this->cmd)。于是这样构造:
$b = new B(); $b->cmd = 'id'; $a = new A(); $a->callback = $b; echo serialize($a);反序列化A对象后,只要代码里出现把$a->callback当作字符串使用的动作,系统命令就会执行。这就是 POP 链最朴素的形态。
我没有继续展开更长的链,因为实际挖掘往往是在几十个类里排列组合,但核心思路就是“危险函数入口”+“可控参数传递路径”+“入口触发时机”。理解这个,你已经领先大多数只会复制 Payload 的人。
3. Python 的反序列化:pickle 不是执行数据,是执行代码
3.1 为什么 pickle 这么危险
Python 的pickle序列化与 PHP 几乎是两个世界观。PHP 的序列化字符串是一个“描述性的文本”,还原时解释器按描述创建对象;而 pickle 的字节流是一套“指令序列”,反序列化意味着按指令逐条执行。
这意味着:pickle 反序列化本质上是在执行一段由字节码表示的代码。它根本不需要等到__destruct或__wakeup触发,指令里可以直接包含“导入 os 模块并调用 system”这种动作。
举个例子,Python 中可以用__reduce__协议定义对象如何被重建:
import pickle import os class RCE: def __reduce__(self): return (os.system, ('id > /tmp/pwned',)) payload = pickle.dumps(RCE())当你把payload发给某个服务端,服务端执行pickle.loads(payload)时,__reduce__返回的第一个元素os.system会被当作可调用对象调用,第二个元素被当作参数传入。你看到的是一个对象还原,实际上跑了一条系统命令。
这还不是最难的构造,因为 pickle 协议本身就是图灵完备的。更有经验的开发者可以直接手工改动字节码指令,构造出完全规避检测的恶意流。这种操作有点烧脑,但只要能理解pickle.loads等同于“任意代码执行”,就已经抓住了要害。
3.2 PyYAML 等解析库同样可能踩雷
很多人以为只有pickle有问题,其实用yaml.load处理不可信输入时也容易中招。YAML 解析库在加载时支持自定义标签,例如:
!!python/object/apply:os.system ["id > /tmp/pwned"]如果代码里用的是yaml.load(input),这种标签会被解析并回调 Python 的os.system。解决方式很简单:能用yaml.safe_load就不用yaml.load,因为safe_load只解析纯数据、不支持任意标签实例化。
还有jsonpickle、marshmallow这套库,如果配置允许对__class__、__reduce__进行解析,也可能显式构造危险代码。我在某次协助排查一个数据平台时,就发现它们用jsonpickle序列化结果直接存 Redis,后端一读又直接还原对象。明文 JSON 里倒是能看见路径和类名,但前后端都没人把它当作代码来对待,那才是最吓人的。
3.3 攻击面不止一个“入口函数”
场景一:会话保持。部分老旧的 Web 框架会把用户会话对象 pickle 后存到 Redis。如果攻击者能往 Redis 写入一个经过构造的 payload,下一次会话读取就会触发代码执行。这个链路里的 Redis 未授权访问,就成了反序列化炸弹的导火索。
场景二:任务队列。Celery、RQ 这类任务队列在传输任务时可能涉及 pickle 序列化。如果 Broker 暴露或队列消息可控,worker 一消费就中招。
场景三:数据分析/模型加载。加载一个.pkl模型文件,内部同样可以是恶意指令序列。我见过有同学从网上下载一个“预训练模型”直接pickle.load,跑了一上午,结果模型权重没问题,但旁边藏了一个反弹脚本,幸好环境是隔离沙箱。
所以在 Python 世界里,反序列化的入口比你想的宽得多:文件上传、Redis 读回、队列消费、缓存加载、日志重放,都可能成为执行点。不要以为只有pickle.loads(input())才算入口。
4. 审计思路:从“哪里有反序列化”到“怎么验证可利用”
4.1 代码审计时的搜索路线
拿到一个项目,我不会立刻人肉扫描所有类,而是先划几个层次:
第一层,全局搜索反序列化入口函数。PHP 项目搜unserialize,Python 项目搜pickle.loads、yaml.load、jsonpickle.decode等。搜完以后,重点区分数据来源:如果数据直接来自$_GET、$_POST、request.json、redis.get等外部输入,优先看;如果来自内部配置且不可被用户篡改,暂时降低优先级。
第二层,追踪数据流。从一个可控入口出发,看数据经过什么函数、有没有 base64 解码、有没有压缩解压、有没有自定义 format 解析。反序列化漏洞审计里最坑的是入口函数往往被封装在框架内部,表面只看到cache()->get($key),数据取出来之后直接反序列化。所以要在框架的缓存模块、Session 模块、队列模块里多费点功夫。
第三层,搜索危险函数调用点。PHP 宏关键字:call_user_func、eval、system、exec、shell_exec、assert、preg_replace(老版本/e修饰符)。Python:os.system、os.popen、subprocess、eval。看看这些调用点是否被类的方法直接封装,再检查这些类是否出现在反序列化链上。
第四层,分析类依赖。找全部类里包含魔术方法的类,用 IDE 或脚本列出__destruct、__wakeup、__toString、__call,把它们的属性名抽出来,再对照危险函数调用。这一步能帮你快速定位“属性是否可控、调用是否可达”。
4.2 验证利用的合法步骤
只有进入自己可授权的环境才去验证,这点我强调无数遍。合规测试流程大概是:
- 编写最简单的“打印测试” Payload,比如让反序列化后对象写入一个文件,证明代码执行路径可达;
- 确认是否真的是反序列化触发,而不是别的漏洞点也顺带起了作用;
- 再逐步构造真实的 POP 链或 reduce 链,验证最终危害;
- 全程记录 Payload、路径、输出,最后做清理。
用小文件写入来测试,比直接弹 shell 安全得多,也方便收尾。我的习惯是在目标环境写入一个随机文件名的探针文件,验证存在性后就删掉,绝不会去动生产数据。
4.3 自动化辅助工具的现实局限
市面上有不少辅助反序列化链挖掘的工具,能扫类、生成候选链,它们对大型 PHP 框架挺有用的。但我必须说实话:这类工具生成几百条链,真正能用的还是需要人工筛选。工具负责“枚举”,人负责“理解”。不懂底层原理的人把工具扫出来的 Payload 一梭子打过去,多半失败不说,还容易引发日志报警。
正确用法是把候选链导出,按触发条件排序,优先看入口类是否有用户可控属性、链里是否包含危险函数、是否有前置条件绕不过去。真正在真实项目里掏出武器库的场景,远没有“理解漏洞然后修修复”来得频繁。
5. 防御:不是禁用函数就完事
5.1 PHP 防御的几种做法
最可靠的手段是:永远不要对不可信数据执行unserialize。这句话说了无数遍,但现实总是有人把外部输入直接丢进去,所以工程化防御一定要分层加。
第一层,类白名单。PHP 7.0 之后,unserialize支持第二个参数:
$obj = unserialize($data, ['allowed_classes' => ['User', 'Order']]);这能限制反序列化生成的类范围。如果传false,则不会实例化任何类,只生成__PHP_Incomplete_Class对象,但实际项目中很多业务确实依赖反序列化还原对象,直接禁掉不现实。所以白名单要精确到业务需要的类,而不是整个框架可用类列表。
第二层,应用层签名。重要场景里,序列化前先附加 HMAC 签名,反序列化前先验签:
if (hash_hmac('sha256', $data, $secret) !== $signature) { throw new Exception('数据签名校验失败'); }签名机制能挡住绝大多数“拼接改造”攻击者,因为他们没有密钥,没法重新生成合法签名。注意密钥不能放在攻击者能读到的地方,比如备份文件、Git 历史、前端代码等位置都要排查。
第三层,升级 PHP 版本。PHP 7.4、8.x 修复了大量历史反序列化绕过问题,包括我前面提到的属性数量绕过。长期停在 PHP 5.6 的老项目,在反序列化方向几乎是裸奔状态。
第四层,替代方案。能用 JSON、MessagePack、Protocol Buffers 等纯数据格式,就不要用原生序列化保存对象。尤其是跨服务传输场景,原生序列化带来的便利远小于它带来的风险。
5.2 Python 防御的正确姿势
Python 侧最直观的防御,是把pickle.loads替换成更安全的数据格式。
- 配置/数据结构:用
yaml.safe_load替代yaml.load; - 缓存和传递:用 JSON、msgpack、protobuf 替代 pickle;
- 必须用 pickle 时:确保数据来源完全可信,比如内部服务间用双向认证的通道,且传输内容不被中间人篡改。
在系统层面,跑 pickle 反序列化的进程应该放到权限最低的容器或沙箱里。即使被攻击,影响面也被限制住。别把 Redis、队列 Worker 和 Web 服务全部杂糅在一台机器还共用 root 权限,那等于把炸弹放在消防通道里。
更专业的做法是构建反序列化安全网关。网关上做内容检测、格式校验、类名黑名单,同时记录每个反序列化调用的来源。签名校验同样适用:
import hmac import hashlib def safe_loads(data, signature, secret): digest = hmac.new(secret, data, hashlib.sha256).hexdigest() if not hmac.compare_digest(digest, signature): raise ValueError("签名校验失败") return pickle.loads(data) # 数据只在验签通过后才还原5.3 规范层面:把“反序列化不可信数据”列为红线
光有代码层的防御还不够,我主张在团队技术规范里把反序列化的使用规则写清楚:
- 新项目禁止使用原生反序列化处理外部输入;
- 旧项目必须盘点反序列化入口,建立清单;
- 高危场景必须有二次校验或签名机制;
- 反序列化操作应有审计日志,方便事后回溯;
- 上线前必须经过专项安全评审,不能只靠开发自测。
信任边界这条线,得靠规范划出来。很多漏洞不是研发不会写代码,而是不了解哪些数据能信、哪些不能信。把“从外部进入的数据一律不可信”刻在脑子里,能挡掉绝大多数问题。
6. 常见问题与排查技巧实录
我在实际排查和带新人时,遇到过不少反序列化相关的“现象级”问题,这里整理成速查表,都是我踩过或看别人踩过的坑。
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| PHP 反序列化返回 false,但没有异常 | 数据被 base64 编码、压缩、前缀拼接、字符编码转换 | 先检查数据传输链路,逐层解码后再分析序列化字符串 |
| 构造的 Payload 执行了,但目标环境没有回显 | 输出被缓冲、重定向到日志、命令被容器限制 | 使用写入文件、DNS 探针等带外方式判断;在授权范围内验证 |
| Python pickle.loads 执行成功,但没弹 shell | 沙箱限制命令、命令含特殊字符、pickle 协议版本不同 | 先构造简单的文件写入探针,确认代码执行后再用带外验证 |
| 修改属性数量绕过 __wakeup 不生效 | 目标 PHP 版本已修复该问题 | 不要再依赖此类绕过,回归 POP 链正向构造 |
| JSON 里能看到类名,但反序列化失败 | 序列化协议不是该项目使用的原生格式;数据被二次序列化 | 检查是不是 jsonpickle、自定义类映射或双重编码 |
| 服务端不解析 YAML 标签但报错 | yaml.load 已被部分库版本限制 | 改用 safe_load,并查看依赖库变更日志 |
| Redis 里出现乱码字符串 | session 或缓存被踩到伪造数据 | 清空高风险 key,修改连接认证,排查外部写入路径 |
| 工具扫描出的 POP 链全部不可用 | 候选链依赖环境属性、PHP 版本、框架版本 | 对照目标真实环境版本,人工核对链上每个类是否存在 |
我自己排查反序列化问题时的固定套路是:先还原完整的数据流,把从入口到unserialize/pickle.loads之间每一层转换写在纸上,再确定攻击者能控制的部分到底有多少。很多人拿到一段序列化字符串就开始脑补,却忘了追踪入口和出口。等你把链路画清楚,十个问题里有八个已经自现答案。
另外有一个被低估的小细节:很多反序列化数据在传输层已经过了base64_encode,导致你直接unserialize永远失败。遇到这种情况,先试着 base64 解码,再检查有没有 gzip、zlib 压缩,接着再关注前缀字符串。通过这层“脱壳”之后,真正的可读序列化结构才显示出来。
在分析 PHP 反序列化字符串时,我会优先关注属性类型标记:s:表示字符串,i:表示整数,O:表示对象,a:表示数组。人工读 Payload 时,把对象嵌套关系缩进打印出来会清晰很多。我常写个小脚本把序列化字符串转成树状结构,这样能快速发现对象链的走向,尤其是属性值里嵌套着另一个对象的情况。
Python 侧排查时,我建议先pickletools.dis(payload)查看字节码指令序列。它能让你直接看到恶意指令的结构,例如GLOBAL指令导入了什么模块、REDUCE指令调用了什么函数,一眼就能判断是不是攻击载荷。
7. 最后的体会
反序列化漏洞最让安全人员头疼的地方,不在于它的利用链有多长,而在于它的入口往往藏在你根本想不到的位置。Session 存储、缓存读取、队列消费、日志重放、模型加载,全都可以成为反序列化的触发点。很多人盯着unserialize函数本身上看下看,真正的入口却在框架内部的getCacheData()一行不起眼的代码里。
我实际做代码审计时发现,现在很多研发已经知道“不要直接反序列化用户输入”,但引入了间接路径:先 json_decode,再从 json 里取 class 名,最后动态实例化。这类看似绕了一圈的做法,本质还是可控类加载和属性绑定,危险程度不一定比原生态低。判断标准永远不变:不可信数据是否影响了代码路径的选择。
防御永远不能靠某个函数补丁解决。你可以把unserialize加上白名单、把 pickle 换成 msgpack、把 yaml.load 换成 safe_load,但如果整个软件架构处处是对象序列化的影子,最踏实的做法还是在上层构建清晰的数据边界,让不可信数据永远走不到“还原对象”这一步。
最后分享一个小技巧:给团队做一次反序列化专项体检时,可以先从依赖库的历史漏洞公告入手,确认当前使用的框架、缓存组件、队列组件中哪些版本携带已知反序列化缺陷,再针对性地搜索实际调用链,这样比从头分析所有代码高效得多。我自己处理老项目时,往往一个小时就能锁定两三个疑似入口,靠的就是“版本先行”这个策略。