php反序列化
在 Web 安全的语境下,反序列化漏洞的本质是 “越权的数据控制了代码的执行流” 。
开发者原本的意图只是单纯地保存和恢复一个对象的数据(属性),但由于 PHP 引擎在销毁对象或处理特定上下文时会自动调用一系列“魔术方法”,攻击者就可以通过精心构造序列化字符串中的属性值,人为地制造出一条方法调用的链条。这个链条就是 POP(Property-Oriented Programming)链。
构造 POP 链,实际上就是一个“找积木”和“搭桥”的过程。我们需要找到一个必定会执行的入口(起点),一个包含高危函数的出口(终点),以及连接它们的无数个跳板。
1 自动触发机制
反序列化发生后,有几个魔术方法是不需要任何外部干预,纯靠 PHP 自身的生命周期管理就会自动触发的。它们是所有 POP 链的绝对入口。
1.1 生命周期终点:__destruct()
任何对象在内存中都有生命周期。当 PHP 脚本执行到末尾(?>),或者对象被显式地 unset() 时,垃圾回收机制就会介入,此时 __destruct() 会被无条件调用。在反序列化漏洞中,由于我们通过 unserialize() 凭空在内存中捏造了一个对象,这个对象在脚本结束时必然面临销毁,因此 __destruct() 是最完美的 POP 链起点。
1 | <?php |
在实战审计中,看到 __destruct 内部存在对类属性的方法调用(如 $this->obj->action())或函数调用,立刻就要将其标记为潜在的链条入口。
1.2 唤醒的瞬间:__wakeup()
当 unserialize() 执行时,PHP 会在重组对象属性之后,立刻检查该类是否存在 __wakeup() 方法。如果存在,就会优先执行它。开发者通常用它来重新建立数据库连接或进行输入过滤。
这也意味着,__wakeup() 经常扮演“拦路虎”的角色。
1 | <?php |
在上述代码中,如果没有防御机制,__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
1 | <?php |
在上面的代码中,如果我们将 Entry 的 $obj 属性赋值为 StringBridge 的实例,一旦 Entry 被销毁执行拼接操作,代码流就会完美切入 StringBridge 的 __toString() 方法中。
__invoke():当对象被当作函数调用时触发。
在上面 StringBridge 的 __toString() 方法中,有一句非常惹眼的代码:($this->target)();。
在正常逻辑中,$this->target 应该是一个字符串形式的函数名(比如 'phpinfo')。但如果攻击者将 $this->target 赋值为一个对象呢?把对象加个括号当函数用,就会无缝触发该对象的 __invoke() 方法。
PHP
1 | <?php |
至此,一条微型 POP 链其实已经闭环了:Entry::__destruct -> 触发字符串拼接 -> StringBridge::__toString -> 触发函数调用 -> ExecuteBridge::__invoke -> RCE。
2.1.2 寻找知识盲区的拦截器:__get()、__call() 等
面向对象编程中,访问权限是核心。当你试图访问一个不存在的,或者权限不够(private/protected)的属性或方法时,PHP 并没有直接报错崩溃,而是提供了兜底的魔术方法。在 POP 链中,这成为了极佳的跨类跳板。
__get($name):读取不可访问的属性时触发。
PHP
1 | <?php |
通过将 PropertyReader 的 $data 赋值为 Interceptor 的对象,当反序列化触发析构函数读取 $data->secret_file 时,因为 Interceptor 中根本没有 secret_file 这个公开属性,执行流瞬间转移到了 Interceptor::__get() 中。
同理,__call($name, $arguments) 用于拦截对不存在的方法的调用。
PHP
1 | <?php |
2.2 第三阶段:实战演练,手搓高阶 POP 链
为了彻底巩固上述概念,我们不依赖任何已知框架,纯手工解析并构造一条包含多个跳板的复合 POP 链。
2.2.1 目标源码分析(假设这是 CTF 考题)
PHP
1 | <?php |
2.2.2 逆向推演(在脑海中画图)
第一步:找终点(Sink)
扫视全文,最危险的执行点在哪里?在 Writer 类的 __get 方法中:
PHP
1 | $func = $this->filepath; return $func(); |
如果我们将 $filepath 设置为 'phpinfo' 或自定义函数,这就是一个无参的函数执行点(如果配置得当,也能执行 system('cat /flag') 类的操作,这里为了演示简化为无参调用)。
第二步:找触发 __get 的跳板
我们需要找到代码中试图读取不存在属性的地方。
观察 Logger 类的 __call 方法:
PHP
1 | 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
1
2
3
4public 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
1
2
3
4
5
6
7class 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
1 | <?php |
当你将这串数据通过 POST 请求发送给服务器的 unserialize() 时,整个服务器内部的执行流完全按照你设定的轨道:从 __destruct 一路跳转到 phpinfo(),这就是 POP 链的艺术。
通过这种剥丝抽茧的方式,不仅能理解“是什么”,更能深刻体会“为什么”要在某个特定的属性上赋予特定类的实例。这是应对复杂 CTF 题目和实战代码审计的必经之路。



