Burst Lab|PHP 审计 13|反序列化(三):POP 链构造与 Gadget Chain 分析
-
- 前言:这篇文章到底要学会什么
- [一、先用一句话理解 POP 链](#一、先用一句话理解 POP 链)
-
- [POP 链中的三个角色](#POP 链中的三个角色)
- [二、先看一个最小 Gadget:对象为什么会自动调用方法](#二、先看一个最小 Gadget:对象为什么会自动调用方法)
- 三、完整演示:三个对象如何连成一条链
-
- [3.1 反序列化时发生了什么](#3.1 反序列化时发生了什么)
- [3.2 逐行对应源码](#3.2 逐行对应源码)
- [3.3 序列化字符串如何对应对象关系](#3.3 序列化字符串如何对应对象关系)
- [四、`__toString()` 和 `__call()` 如何成为中间节点](#四、
__toString()和__call()如何成为中间节点) -
- [1. `__toString()` 作为中间节点](#1.
__toString()作为中间节点) - [2. `__call()` 作为中间节点](#2.
__call()作为中间节点)
- [1. `__toString()` 作为中间节点](#1.
- 五、实际审计流程:四步画出对象调用图
- 六、版本和析构时机为什么会影响结果
-
- [1. PHP 版本](#1. PHP 版本)
- [2. `__destruct()` 的触发时间](#2.
__destruct()的触发时间)
- 七、开发侧防御
-
- [1. 业务只需要数据时优先 JSON](#1. 业务只需要数据时优先 JSON)
- [2. 必须使用 `unserialize()` 时](#2. 必须使用
unserialize()时) - [3. 魔术方法保持低副作用](#3. 魔术方法保持低副作用)
- 八、本篇总结
前言:这篇文章到底要学会什么
上一篇我们学习了 PHP 魔术方法的触发时机。本篇继续学习反序列化审计中最容易让初学者困惑的概念:POP 链。
很多文章会直接给出一串很长的序列化字符串,但读者往往不知道:
- 为什么反序列化后会自动进入某个方法?
- 为什么一个对象能调用另一个对象的方法?
$a->next->run()中的next到底起什么作用?- 什么样的类才能称为 Gadget?
- 代码执行到最后,如何判断是否真的产生了安全影响?
本文不从字符串开始,而是从一段最小、无害、可以运行的代码开始,逐步建立下面这条关系:
text
对象属性保存关系
↓
反序列化恢复对象图
↓
自动触发入口方法
↓
沿属性调用下一个对象
↓
到达最终处理点
本文示例中的终点是 SafeSink::record(),它只负责打印日志,不执行命令、不写文件、不访问网络。内容仅用于本地私有环境和授权靶场学习。
本机运行环境:Windows + phpstudy,PHP 7.3.4 NTS x64。
PHP 路径:
E:\phpstudy_pro\Extensions\php\php7.3.4nts\php.exe文中"本机输出"均使用该版本实际运行得到。不同 PHP 版本可能存在行为差异,审计时应以目标项目的真实版本为准。
系列路线:
text
PHP 审计 11:序列化原理与格式解析
PHP 审计 12:魔术方法全解与隐式触发
PHP 审计 13:POP 链构造与 Gadget Chain 分析 ← 本篇
PHP 审计 14:字符串逃逸与版本特性差异
PHP 审计 15:Phar 反序列化与原生类利用
PHP 审计 16:审计方法论与防御体系
一、先用一句话理解 POP 链
POP 是 Property Oriented Programming 的缩写,可以理解为:
不依赖新写的攻击代码,而是利用现有对象属性之间的引用关系,让多个普通方法按照一定顺序连接起来。
可以把它想象成几个人传话:
text
第一个人收到通知
↓
把消息交给第二个人
↓
第二个人再交给第三个人
↓
第三个人完成最后的处理
在 PHP 中,"把消息交给下一个人"的动作,通常表现为对象属性调用:
php
$this->relay->forward();
这句话可以拆成两步理解:
- 读取当前对象的
$relay属性; - 把
$relay当成对象,调用它的forward()方法。
如果 $relay 保存的是 RelayGadget 对象,那么程序就会从当前类跳到 RelayGadget::forward()。
POP 链中的三个角色
| 角色 | 通俗理解 | 需要确认的问题 |
|---|---|---|
| Entry | 第一个收到通知的人 | 什么操作会让它开始执行? |
| Gadget | 中间负责传递流程的人 | 它读取哪个属性,调用哪个方法? |
| Sink | 最后处理消息的人 | 是否产生文件、命令、回调等副作用? |
例如:
text
EntryGadget::__wakeup()
↓ 读取 relay
RelayGadget::forward()
↓ 读取 sink
SafeSink::record()
这里的 EntryGadget、RelayGadget 和 SafeSink 都只是示例名称。真正审计时,应当替换成项目中的实际类名和方法名。
重要: 有
__wakeup()不等于有完整 POP 链。必须继续确认属性类型、方法可访问性、参数校验和最终副作用。
二、先看一个最小 Gadget:对象为什么会自动调用方法
在分析完整链条之前,先看一个最小例子。这个例子与反序列化无关,只用来理解 __toString() 为什么会被自动触发。
php
<?php
class SafeSink
{
public function record($message)
{
echo "[sink] {$message}", PHP_EOL;
}
}
class TextGadget
{
public $text = 'gadget-demo';
public function __toString()
{
echo "[gadget] __toString() 被触发", PHP_EOL;
return strtoupper($this->text);
}
}
$sink = new SafeSink();
$gadget = new TextGadget();
$sink->record('message=' . $gadget);
运行结果
text
[gadget] __toString() 被触发
[sink] message=GADGET-DEMO
为什么输出顺序是这样
代码表面上像是直接调用:
php
$sink->record(...);
但 PHP 必须先计算 record() 的参数:
php
'message=' . $gadget
此时 $gadget 是对象,却被放进了字符串拼接表达式,所以 PHP 自动调用:
php
TextGadget::__toString()
该方法把 gadget-demo 转成大写的 GADGET-DEMO,再与 message= 拼接,最后才进入 SafeSink::record()。
完整流程是:
| 步骤 | PHP 实际做的事情 |
|---|---|
| 1 | 创建 TextGadget 对象 |
| 2 | 读取 $gadget->text |
| 3 | 对象进入字符串上下文,自动调用 __toString() |
| 4 | 返回 GADGET-DEMO |
| 5 | 执行 SafeSink::record() |
这就是一个 Gadget:它有明确的触发条件,也有明确的数据流向。
三、完整演示:三个对象如何连成一条链
下面进入本文核心示例。先不要看序列化字符串,先看对象关系:
text
EntryGadget
│
└── relay
↓
RelayGadget
├── sink ───→ SafeSink
└── message ─→ safe-chain
用 PHP 表示就是:
php
$entry = new EntryGadget();
$entry->relay = new RelayGadget();
$entry->relay->sink = new SafeSink();
$entry->relay->message = 'safe-chain';
这四行代码完成了三层关系:
$entry是入口对象;$entry->relay指向RelayGadget;RelayGadget的$sink指向SafeSink,$message保存待处理内容。
关键方法
php
class EntryGadget
{
public $relay;
public function __wakeup()
{
echo "[2] EntryGadget::__wakeup()", PHP_EOL;
$this->relay->forward();
}
}
class RelayGadget
{
public $sink;
public $message;
public function forward()
{
echo "[3] RelayGadget::forward()", PHP_EOL;
$this->sink->record($this->message);
}
}
3.1 反序列化时发生了什么
程序先执行:
php
$raw = serialize($entry);
这一步只是把对象关系保存成字符串。随后执行:
php
unserialize($raw);
PHP 会依次完成:
- 读取字符串中的类名;
- 创建对象结构并恢复属性;
- 发现入口类存在
__wakeup(); - 自动调用
EntryGadget::__wakeup(); __wakeup()读取$relay,调用forward();forward()读取$sink和$message;- 最后调用
SafeSink::record()。
本机实际输出:
text
[1] serialized=O:11:"EntryGadget":1:{s:5:"relay";O:11:"RelayGadget":2:{s:4:"sink";O:8:"SafeSink":0:{}s:7:"message";s:10:"safe-chain";}}
[2] EntryGadget::__wakeup()
[3] RelayGadget::forward()
[4] SafeSink::record():safe-chain
[5] main: 恢复完成
3.2 逐行对应源码
[1]:对象图被serialize()保存;[2]:unserialize()后自动触发__wakeup();[3]:入口类通过$relay调用RelayGadget::forward();[4]:中间类通过$sink调用安全终点;[5]:链条结束,主流程继续执行。
因此,这条链可以写成:
text
unserialize()
↓
EntryGadget::__wakeup()
↓ $this->relay
RelayGadget::forward()
↓ $this->sink + $this->message
SafeSink::record()
3.3 序列化字符串如何对应对象关系
只看关键片段:
text
O:11:"EntryGadget":1:{
s:5:"relay";
O:11:"RelayGadget":2:{
s:4:"sink";O:8:"SafeSink":0:{}
s:7:"message";s:10:"safe-chain";
}
}
对应关系如下:
| 序列化片段 | 含义 |
|---|---|
O:11:"EntryGadget" |
一个 EntryGadget 对象 |
s:5:"relay" |
入口对象的 relay 属性 |
O:11:"RelayGadget" |
relay 属性指向的对象 |
s:4:"sink" |
中间对象的 sink 属性 |
O:8:"SafeSink":0:{} |
sink 指向 SafeSink |
s:7:"message";s:10:"safe-chain" |
中间对象保存的消息 |
看到这里应该能明白:序列化字符串只是对象图的文本表示,真正需要分析的是对象之间的关系。
四、__toString() 和 __call() 如何成为中间节点
1. __toString() 作为中间节点
如果一个对象的属性被用于字符串拼接,可能继续触发另一个对象的 __toString()。
本机运行顺序:
text
[1] serialized-length=159
[2] WakeupEntry::__wakeup()
[3] FormatterGadget::format()
[4] StringGadget::__toString():对象进入字符串上下文
[5] SafeSink::record():formatted=[pop-demo]
[6] main: chain finished
关键点是 [4] 为什么在 [5] 前面:
php
$this->sink->record('formatted=' . $this->value);
执行 record() 前,PHP 必须先计算参数 'formatted=' . $this->value。如果 $this->value 是对象,就会先触发 __toString()。
所以审计时遇到下面这些位置,要继续检查对象是否会发生字符串转换:
- 字符串拼接;
echo和日志输出;sprintf();- 模板变量输出;
- 异常信息拼接。
2. __call() 作为中间节点
定义了 __call() 后,调用不存在或不可访问的方法可能进入 __call()。本机示例输出:
text
[1] serialized-length=99
[2] CallEntry::__wakeup()
[3] ProxyGadget::__call name=missingMethod
[4] SafeDispatcher::dispatch name=missingMethod
[5] arguments=["demo"]
[6] main: chain finished
审计时要继续追踪:
| 检查项 | 需要回答的问题 |
|---|---|
| 方法名 | 是否有白名单? |
| 参数 | 是否原样传递? |
| 目标对象 | 从固定位置来,还是来自属性? |
| 最终方法 | 是否有文件、命令、回调等副作用? |
仅仅发现 __call(),不能直接下结论。必须确认它能否把流程继续传递下去。
五、实际审计流程:四步画出对象调用图
第一步:找到反序列化入口
先搜索:
php
unserialize($data);
unserialize($cacheValue);
unserialize($_COOKIE['state']);
然后记录数据来源:
- 用户请求参数;
- Cookie;
- Session;
- 缓存;
- 数据库;
- 消息队列。
入口存在,不代表输入一定可控。 还要看数据是否经过签名、加密、权限校验或服务端固定生成。
第二步:确认自动触发点
优先检查:
__wakeup();__unserialize();__destruct()。
然后再检查可能被间接触发的方法:
__toString();__call();__invoke();__get()和__set()。
第三步:跟踪属性,而不是只看方法名
例如:
php
$this->relay->sink->record($this->message);
不要只记一句"调用了 record()",而要拆成:
$this->relay必须是对象;- 该对象必须存在
$sink; $sink必须存在record();$message必须满足参数要求;record()内部才是最终需要判断的代码。
第四步:记录阻断条件
本机测试了一个类型限制示例:
text
1. 合法对象图
[entry] __wakeup() 检查 next 类型
[relay] StrictRelay::run() 到达
2. 不符合约束的对象图
[entry] __wakeup() 检查 next 类型
[blocked] next 不是 StrictRelay,链条停止
这里第二个对象图没有继续执行,不是因为 __wakeup() 没有触发,而是因为代码检查了:
php
$this->next instanceof StrictRelay
当 $next 是字符串而不是 StrictRelay 对象时,程序主动停止。因此审计报告应该写:
入口方法可以触发,但由于对象类型不满足
instanceof条件,后续链路不可达。
而不能简单写成"存在 POP 链"。
六、版本和析构时机为什么会影响结果
1. PHP 版本
本文使用的版本信息如下:
text
PHP_VERSION=7.3.4
PHP_VERSION_ID=70304
interface_exists(Serializable)=yes
class_exists(ArrayObject)=yes
不同 PHP 版本对 __serialize()、__unserialize()、Serializable 接口以及部分反序列化行为的支持不同。复现问题时,至少记录:
- PHP 大版本和小版本;
- CLI、FPM 或 Apache SAPI;
- 已加载扩展;
- Composer 依赖版本;
- 目标类是否已经加载。
2. __destruct() 的触发时间
析构方法不一定在 unserialize() 之后立即执行,常见触发时机包括:
unset($object);- 变量离开作用域;
- 请求结束;
- 垃圾回收。
本机输出:
text
[1] serialized-length=92
[4] unserialize 返回,暂不析构
[2] DestructEntry::__destruct()
[3] SafeCleanup::close() label=local-demo
[5] main: restored 已释放
[2] DestructEntry::__destruct()
[3] SafeCleanup::close() label=local-demo
第一次析构发生在显式释放对象时,第二次发生在脚本结束时。由此可见,审计时需要记录对象生命周期,不能只看方法名称。
七、开发侧防御
1. 业务只需要数据时优先 JSON
如果业务保存的只是用户编号、角色和状态,通常不需要恢复 PHP 对象:
php
$state = json_decode($raw, true);
if (!is_array($state)) {
throw new RuntimeException('invalid state');
}
本机输出:
text
json={"user_id":7,"role":"reader"}
decoded-type=array
class-key-present=no
这里得到的是数组,不会自动实例化 JsonEntry 之类的业务对象。
2. 必须使用 unserialize() 时
至少同时考虑:
php
$state = unserialize($raw, array(
'allowed_classes' => array('App\\Dto\\UserState'),
));
但 allowed_classes 只能限制允许恢复的类,不能替代:
- HMAC 签名校验;
- 字段类型校验;
- 权限校验;
- 业务状态校验。
3. 魔术方法保持低副作用
不建议在 __wakeup()、__destruct()、__toString() 中直接根据对象属性执行文件操作、命令、任意回调或网络请求。
更稳妥的做法是:对象恢复阶段只负责恢复数据,真正的业务动作放到显式方法中,并在方法内部进行类型、权限和白名单校验。
八、本篇总结
POP 链不是一串需要死记的字符串,而是一张需要验证的对象调用图:
text
unserialize()
↓
自动触发入口
↓
读取对象属性
↓
调用下一个方法
↓
确认最终处理点
审计时始终回答这五个问题:
- 链从哪里开始?
- 每一步调用哪个方法?
- 下一跳由哪个属性决定?
- 哪些类型、版本或生命周期条件会阻断?
- 最终代码是否真的产生安全影响?
一句话总结:先画对象图,再追调用关系,最后判断终点影响。
免责声明
本文所有内容仅限授权靶场、本地私有实验环境和代码审计学习研究。任何未经授权对第三方系统开展测试、攻击或数据操作的行为都可能违反法律法规,使用者应自行承担相应责任。