Burst Lab | PHP 审计 13|反序列化(三):POP 链构造与 Gadget Chain 分析

Burst Lab|PHP 审计 13|反序列化(三):POP 链构造与 Gadget Chain 分析


前言:这篇文章到底要学会什么

上一篇我们学习了 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();

这句话可以拆成两步理解:

  1. 读取当前对象的 $relay 属性;
  2. $relay 当成对象,调用它的 forward() 方法。

如果 $relay 保存的是 RelayGadget 对象,那么程序就会从当前类跳到 RelayGadget::forward()


POP 链中的三个角色

角色 通俗理解 需要确认的问题
Entry 第一个收到通知的人 什么操作会让它开始执行?
Gadget 中间负责传递流程的人 它读取哪个属性,调用哪个方法?
Sink 最后处理消息的人 是否产生文件、命令、回调等副作用?

例如:

text 复制代码
EntryGadget::__wakeup()
        ↓ 读取 relay
RelayGadget::forward()
        ↓ 读取 sink
SafeSink::record()

这里的 EntryGadgetRelayGadgetSafeSink 都只是示例名称。真正审计时,应当替换成项目中的实际类名和方法名。

重要:__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';

这四行代码完成了三层关系:

  1. $entry 是入口对象;
  2. $entry->relay 指向 RelayGadget
  3. 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 会依次完成:

  1. 读取字符串中的类名;
  2. 创建对象结构并恢复属性;
  3. 发现入口类存在 __wakeup()
  4. 自动调用 EntryGadget::__wakeup()
  5. __wakeup() 读取 $relay,调用 forward()
  6. forward() 读取 $sink$message
  7. 最后调用 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()",而要拆成:

  1. $this->relay 必须是对象;
  2. 该对象必须存在 $sink
  3. $sink 必须存在 record()
  4. $message 必须满足参数要求;
  5. 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()
    ↓
自动触发入口
    ↓
读取对象属性
    ↓
调用下一个方法
    ↓
确认最终处理点

审计时始终回答这五个问题:

  1. 链从哪里开始?
  2. 每一步调用哪个方法?
  3. 下一跳由哪个属性决定?
  4. 哪些类型、版本或生命周期条件会阻断?
  5. 最终代码是否真的产生安全影响?

一句话总结:先画对象图,再追调用关系,最后判断终点影响。


免责声明

本文所有内容仅限授权靶场、本地私有实验环境和代码审计学习研究。任何未经授权对第三方系统开展测试、攻击或数据操作的行为都可能违反法律法规,使用者应自行承担相应责任。

上一篇

Burst Lab|PHP 审计 12|反序列化(二):魔术方法全解与隐式触发

下一篇

Burst Lab|PHP 审计 14|反序列化(四):字符串逃逸与版本特性差异

相关推荐
职场的momo1 小时前
11个后端与AI岗位同时开放:Java、网关、推理优化怎么匹配
java·开发语言·人工智能
蓝速科技1 小时前
蓝速科技丨新旧楼宇混杂场景下会议室预约门牌屏一体化改造方案
开发语言·科技·php
梦想的旅途22 小时前
Python实现企业微信文本消息发送
开发语言·python·企业微信
大鹏说大话3 小时前
美业微信会员系统哪个好
开发语言
程序员与背包客_CoderZ3 小时前
高性能分布式KV存储引擎RocksDB入门与C/C++编码实战
c语言·开发语言·数据库·c++·分布式·分布式数据库·rocksdb
钱栈up4 小时前
Mac 开发机一键发版不用切环境:我这样改造了团队的后端部署脚本Maven编译卡住20分钟?我靠两步定位到2处隐蔽编译错误
开发语言·python·macos
william_yangshun4 小时前
【大模型部署实战】kimi-k3-in-c 中文版:用 176KB 纯 C99 引擎在 CPU 上跑 Kimi K3 上手指南
c语言·开发语言
Sagittarius_A*4 小时前
Burst Lab | PHP 审计 14|反序列化(四):字符串逃逸与绕过技巧
开发语言·安全·php·web·反序列化
名字还没想好☜4 小时前
Go 用 slices/maps 标准库泛型函数:告别手写 Contains、Sort、去重(Go 1.21)
开发语言·后端·算法·golang·go