ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

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

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