☰
PHP反序列化漏洞原理与POP链构造实战排查指南
2026/10/22 20:02:22 网站建设 项目流程

在PHP里,序列化和反序列化本来是一对再普通不过的工具。把对象变成字符串存起来,再把字符串还原成对象,开发者在做缓存、Session存储、RPC通信时几乎天天用。但就是这套机制,一旦碰到不可信的输入,就容易被改造成一条直达服务器内部逻辑的攻击路径。我见过不少业务团队,代码写得规规矩矩,SQL注入和XSS都防得住,最后却栽在一个看似无害的unserialize调用上。这篇总结就是想把PHP反序列化这条路从头到尾捋一遍,从原理、魔术方法、POP链构造到常见绕过和修复方案,尽量做得像一份能直接拿去看代码的检查清单。

这篇内容适合真正在写PHP业务代码的人,尤其是做接口、做后台、做框架维护的开发者。如果你正在审计一套老系统,或者接手了一个到处是$_COOKIE、$_REQUEST的祖传项目,那这篇文章可能帮你省下好几天的排查时间。新手也不用慌,我会从serialize()到底输出了什么讲起,保证你哪怕第一次接触反序列化漏洞,也能顺着思路跟下来。

1. 先搞清楚:反序列化到底在“反”什么

1.1 序列化就是把内存里的对象拍扁

PHP里的对象不是一个单纯的字典,它里面有属性、方法、访问权限,还带着类定义。网络传输和文件存储只能处理字符串,所以PHP提供了serialize(),把对象转成一种带有类型标记的结构化字符串。最常见的输出长这样:

O:8:"stdClass":1:{s:4:"name";s:5:"admin";}

拆开看很直观。O代表Object,8是类名长度,stdClass是类名,1表示有1个属性,后面大括号里是属性的键值对。s:4表示字符串长度是4。如果是private属性,属性名会带上类名和空字节,序列化字符串里看起来就是\0类名\0属性名。这个细节在后续绕过正则时很关键。

反序列化就是unserialize()把这段字符串还原成内存中的对象。它不只是恢复属性值,还会按照类定义重新构建对象,甚至触发类里一些特定的方法。问题就出在这些“特定方法”上。

1.2 为什么一段字符串能变成漏洞

如果反序列化的输入完全可控,攻击者等同于可以指定任意类名、任意属性名、任意属性值。程序里只要存在一个接收外部输入并直接unserialize()的点,攻击者就能伪造出一个对象,注入到业务逻辑里。

但光注入一个普通对象没什么用,对象本身不会自己执行危险操作。真正可利用的,是PHP在反序列化过程中或者对象被使用时会自动调用的“魔术方法”。只要代码库里存在某个类,它的魔术方法里做了危险动作,攻击者就有机会把属性值设置成危险参数,从而触发整条利用链。这也是为什么反序列化漏洞经常被称为“面向属性编程”,攻击者操作的不再是函数参数,而是一个个对象的属性状态。

我见过不少初学者以为必须找到eval、exec之类的危险函数才算数,实际并不是。很多时候利用链的终点是一个文件写入、一个数据库查询、一个缓存删除,甚至是一个__toString()里拼出来的SQL语句。只要能把攻击者可控的数据流进敏感操作,漏洞就成立了。

2. 反序列化漏洞的常见入口点

2.1 最直白的入口:外部参数直接传给unserialize

这是最容易发现也最容易利用的一类。代码类似:

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

如果$data完全可控,攻击者直接构造序列化字符串提交就行。真实系统里不会写得这么裸奔,但有的会从参数里取一段base64编码的数据,解码后反序列化。这种情况在多个老框架的RCE漏洞中出现过,比如某个API接口用base64_decode($_POST['payload'])去恢复缓存对象。这种写法一旦被审计到,基本就属于高危风险。

2.2 藏在Session里的反序列化

PHP的Session处理器默认会把$_SESSION数组序列化后存到文件里,读取时再反序列化还原。这里有两个攻击场景。

一种是session.upload_progress配合文件包含,网上讲得很多,本质是往Session文件里写入恶意内容,再通过LFI包含触发。严格来说它不完全是反序列化漏洞,但最终payload能进入Session文件并影响反序列化逻辑。

另一种更隐蔽:当应用自定义了Session处理器,自己在读写Session时调用了unserialize(),而Session ID可控,或者Session数据里存在可控键名,就可能被注入恶意对象。因为很多老代码在Session里存了用户对象、权限对象,反序列化这些数据时并不会做严格的类型校验。

2.3 Phar反序列化:不需要unserialize也能触发

Phar是PHP的打包格式,最值得警惕的是,只要用file_exists()、is_file()、getimagesize()这类文件操作函数去访问一个phar://协议的路径,Phar文件的元数据就会被自动反序列化。也就是说,即使代码里从头到尾没有出现unserialize(),只要某个文件路径参数可控,并且存在能加载Phar文件的函数,攻击者就能把恶意构造的Phar文件投放到服务器上,用文件操作触发反序列化。

这个攻击面特别适合绕过WAF。很多WAF会过滤unserialize(和php://,但对file_exists($_GET['file'])这种代码关注度不够。攻击者只需要上传一个内容伪造好的Phar文件,再把路径改成phar://uploads/evil.phar,就完成了触发。我在审计一个图片上传功能时就遇到过类似情况:上传接口只校验了文件头几个字节,但接受任意扩展名,配合后台一个file_exists()调用,整条链瞬间被打通。

2.4 更隐蔽的入口:反序列化数据被存进缓存和消息队列

很多现代PHP框架使用Redis、Memcached做缓存,存进去的是序列化后的对象。只要缓存键名可控,或者Redis未授权访问,攻击者可以向缓存里写入恶意序列化数据,等业务代码反序列化时自然触发。消息队列里的任务载荷如果有序列化对象,同样存在这个问题。

这类入口通常不会直接在Web层出现,但一旦前面有SSRF、未授权访问或者业务逻辑漏洞,就能借道完成利用。审计时不要只盯着HTTP参数,凡是能把字符串变成对象的边界,都需要当成攻击面看待。

3. 构造利用链的核心关键:魔术方法

3.1 序列化漏洞必知的魔术方法清单

PHP的魔术方法不需要显式调用,满足条件时会自动触发。和反序列化利用直接相关的有这几个:

__wakeup() // unserialize()时被调用,常用于重新连接数据库、资源初始化 __sleep() // serialize()时被调用,常用于清理资源 __destruct() // 对象被销毁时调用,通常在脚本结束或unset()时 __toString() // 对象被当作字符串使用时调用 __call() // 调用不存在的方法时触发 __get() // 读取不可访问属性时触发 __set() // 给不可访问属性赋值时触发 __isset() // 对不可访问属性调用isset()或者empty()时触发 __unset() // 对不可访问属性调用unset()时触发 __invoke() // 对象被当作函数调用时触发 __clone() // 克隆对象时触发 __debugInfo() // var_dump()对象时触发

其中__wakeup和__destruct在滥用频率上最高。__wakeup是反序列化一开始就触发,攻击者有充分的时间在属性值里塞入恶意参数。__destruct则在对象生命周期结束时触发,很多时候代码不直接引用反序列化出来的对象,但脚本一结束,对象被销毁,漏洞照样被触发。

3.2 每个魔术方法对应的触发场景

真正构造链子的时候,魔术方法之间是互相串联的。比如__toString()被触发,可能是因为代码里把反序列化出来的对象拼进了字符串;而__toString()内部可能又调用了$this->helper的某个方法,于是继续触发__call()。这种嵌套触发就是后来常说的POP链骨架。

我整理了一个简单的对照表,方便审计时快速判断某段代码里哪个方法可能是链子的承接点:

魔术方法触发时机常见利用方式
__wakeup()unserialize()时修改属性、调用危险方法
__destruct()对象销毁时写入文件、执行命令、删数据
__toString()被当字符串使用拼接SQL、拼接HTML、文件读取
__call($name, $args)调用不存在方法动态转发方法名,寻找可利用方法
__get($name)访问不可访问属性从任意来源取数据
__invoke()对象被当函数调用串联到闭包或危险方法
__set($name, $value)给不可访问属性赋值写入任意属性,污染对象状态

3.3 一个最小的__destruct利用示例

假设代码库里有这样一个类:

class FileLogger { public $filename; public $content; public function __destruct() { file_put_contents($this->filename, $this->content); } }

攻击者只要构造一个FileLogger对象,把filename设为Web目录下的恶意文件名,content设为恶意代码,提交给某个unserialize()入口。脚本运行结束后,对象被销毁,__destruct()自动执行,一个webshell就落盘了。

这就是最基础的反序列化利用。真实系统里类可能更复杂,但思路完全一致:找魔术方法,看它执行了什么操作,再看操作的参数能不能通过属性控制。

4. 从基础到POP:一条实际可利用链的构造思路

4.1 为什么单个魔术方法往往不够

现实代码里,专门写一个FileLogger还恰好能被反序列化入口触发的概率不高。大多数时候,魔术方法内部会调用其他对象的方法,而这些对象可能是通过属性传入的。攻击者需要拼接多个类,让执行流从入口一路走到危险函数,这就是POP链。POP链和栈溢出里的ROP链思路几乎一样,只是这里拼接的不是内存地址,而是对象的属性链。

我在审计某个老后台时遇到过一条经典链子。入口类是个CacheManager,它的__wakeup()会调用$this->driver->init()。driver属性可控,可以指向另一个类DatabaseBackup,而DatabaseBackup::init()里又调用了$this->cmd并执行了system()。从入口到执行命令,一共用了三层对象,这就是一条完整的POP链。

4.2 手工构造一条POP链的过程

为了讲清构造过程,我用一个简化版的模拟项目来演示。项目里有三个类:

class A { public $callback; public $data; public function __wakeup() { $fn = $this->callback; $fn($this->data); } } class B { public $method; public $obj; public function __toString() { return $this->obj->{$this->method}(); } } class C { public $cmd; public function run() { system($this->cmd); } }

入口是A的__wakeup(),它把callback当作函数直接调用。如果callback设置成system,data设置成命令,那就是最直接的RCE。

但真实代码里不会这么给面子。假设A的callback必须是一个方法名,实际执行的是$this->obj->{$this->callback}(),那就可以继续串联。如果某个类存在__toString(),并且代码里最终会把对象拼进字符串,可以以此为桥梁继续跳转。

构造序列化payload时,不需要手动拼字符串,直接写一段PHP脚本生成:

$a = new A(); $a->callback = 'system'; $a->data = 'id'; echo serialize($a);

如果目标环境callback被类型约束限制,就要换思路,找一个属性类型是接口的类,把攻击对象赋值进去,再通过该对象的方法调用跳转。类型约束检查的是对象是不是实现了某个接口或继承某个类,不符合预期的话,反序列化时会报错,所以构造时一定要对照源码仔细核对每个对象的类名和继承关系。

4.3 链子构造完之后的验证思路

构造完序列化数据,先在本地搭一个和线上PHP版本一致、依赖一致的环境做验证。重点确认三件事:第一,反序列化过程是否报错;第二,每个魔术方法的触发时机是否符合预期;第三,危险操作是否真的执行成功。

我习惯把payload写成base64格式,用命令行脚本模拟触发,方便调试。等本地链路完全稳定,再考虑目标环境的差异。PHP版本不同,一些魔术方法的行为会有变化,比如__wakeup的绕过方式在不同版本里就不一样,后面会单独说。

5. 绕过高危限制的常见姿势

5.1 绕过__wakeup()

PHP 7.4之前的版本有个著名的绕过方法:当序列化字符串里对象的属性数量大于实际声明的属性数量时,unserialize()会跳过__wakeup()的执行。这条漏洞在PHP 7.4中被修复,但不少老系统还在用7.x早期版本,审计时依然要测。

构造方式很简单,把对象属性个数的数值改大:

O:1:"A":2:{s:1:"x";s:4:"safe";}

原本只有1个属性,把2变成2个或更多,就能让__wakeup不被调用。这种绕过最大的价值在于:有些反序列化入口本身没有危险操作,危险操作在__wakeup里做了参数校验,绕过校验后攻击面就打开了。

现在的PHP版本主流都是8.x,__wakeup不再能被这种方式绕过,但老项目迁移不彻底的情况很常见。遇到这种环境,还是要优先看一下运行版本,再决定要不要测这一条。

5.2 利用引用绕过属性修改限制

PHP序列化支持引用。当代码在__wakeup()里做了类似$this->username = 'safe'的强制覆盖操作,攻击者可以把另一个属性设置成同一个值引用,让强制覆盖变成同时修改两个属性。

举个例子,假设__wakeup()会把username强制设为guest,但之后程序读取的是role。攻击者构造序列化时让role和username指向同一个值,__wakeup()修改username时,role也被改成guest,达不到攻击目的;反过来,如果源码里修改的是guest,而业务权限判断用的是is_admin,引用关系可以让一次赋值同时影响两个变量。

这类利用手法在CTF题里很常见,真实漏洞里也不少见。审计时如果看到反序列化之后有属性覆盖逻辑,不要简单认为覆盖了就安全,要想一想属性之间有没有引用关系可以利用。

5.3 利用C:类型绕过类名限制

序列化格式里除了O:表示对象,还有C:表示__construct构造的对象。一些过滤规则只拦截O:开头的字符串,不处理C:。但C:对类的要求更严格,且构造行为不同,不是所有类都能这么处理。实际利用时需要测试目标类是否支持这种格式,比如Exception类在某些场景下可以用C:顺利构造。

这个方法现在用得不多,因为主流的WAF规则已经开始同时过滤O:和C:。但做成通用规则意味着许多正常请求也会被误杀,所以总有一些边缘场景可以钻空子。审计时看到过滤规则只写了/O:\d+/这种正则,就应该试一下C:。

5.4 利用Phar协议绕过入口点限制

前面提到Phar可以在文件操作函数中触发反序列化,这本身就是一种绕过。很多基于框架的应用没有显式的unserialize()入口,但存在大量file_exists()、file_get_contents()调用。把恶意Phar文件上传到服务器或者通过URL包含远程文件,再用phar://路径就能触发。

绕过文件上传限制是这里的关键。图片上传通常只检查扩展名和文件头,Phar文件可以在GIF/JPG/PNG文件头之后嵌入序列化数据,生成一个内容合法的图片文件。上传接口不会报错,后台解析图片反而触发反序列化。

5.5 利用GC垃圾回收机制触发__destruct

有的反序列化场景会在对象析构前清空数组或销毁引用,导致__destruct()提前执行。PHP的垃圾回收机制在某些特定结构下会产生“对象被销毁”的假象,从而提前调用__destruct。这在CTF经常被用来解决“对象无法自然释放”或“__destruct触发太晚”的问题。

现实审计里,这种精细结构出现的概率更低,更多是本地构造payload时发现对象迟迟不析构,才考虑用数组元素解引用、嵌套对象引用让PHP GC提前回收。这个知识点可以作为进阶补充,不建议新手一上来就死磕。

6. 实战排查与修复要点

6.1 怎么快速定位代码里的反序列化入口

最土的办法就是全局搜索。搜索unserialize(、session_decode(、php_unserialize(这类函数调用,以及phar://字符串拼接。如果代码量大,用现代化的代码搜索工具按语法树搜索也可以。

搜索到调用点后,向上追踪数据流,确认参数是否来自$_GET、$_POST、$_COOKIE、$_FILES、请求头、数据库字段、缓存内容、消息队列消息。只要数据流里存在一层用户可控,就算可疑。把每个入口记下来,逐个验证。

6.2 优先检查危险函数和危险类

反序列化链的终点通常落在这些函数上:eval()、assert()、system()、exec()、shell_exec()、passthru()、popen()、proc_open()、file_put_contents()、fwrite()、unlink()、include()、require()。

在源码里搜索这些函数后,反查它们所在的类,看类里是否有魔术方法,再看魔术方法是否会被反序列化触发。如果某个类同时具备“魔术方法+危险函数+属性可控”,那基本可以确定存在一个可利用节点。

6.3 修复方案:从根上消除unserialize

最彻底的修复是放弃unserialize(),改用json_decode()加手动赋值的白名单映射方式。比如:

$data = json_decode($input, true); $obj = new User(); $obj->id = $data['id']; $obj->name = $data['name'];

这种方式不依赖魔术方法自动执行,天然免疫反序列化利用。即使输入包含恶意对象结构,JSON解析也只会得到数组,不会触发任何PHP对象生命周期操作。

如果没法立即重构,至少做到两点:一是禁止反序列化不可信数据,内部系统之间传递时也要加签名校验;二是对反序列化后的对象做严格类型检查,确保得到的对象是预期的类,而不是攻击者传入的任意类。

6.4 加固手段:允许类白名单与参数校验

PHP 7.0之后unserialize()支持第二个参数,可以指定允许反序列化的类名列表:

unserialize($data, ['allowed_classes' => ['User']]);

禁用所有类的写法是['allowed_classes' => false]。配合__wakeup()里的属性校验、Sanitizer过滤,可以在保留序列化能力的同时降低利用风险。

但白名单不是万能的,如果允许的类本身有可利用的魔术方法,或者该类的父类、接口存在可利用节点,依然可能被利用。所以白名单只能降低风险,不能替代入口数据过滤。

6.5 常见问题速查表

现象可能原因排查方向
反序列化后对象属性全部为null属性名带空字节,序列化格式被外部修改对比原对象serialize输出
__wakeup()不执行PHP版本超过7.4或属性数量被篡改确认PHP版本,检查属性数量
__destruct()反复触发对象被多次引用,GC回收次序异常检查数组引用,手动unset
Phar反序列化不触发目标函数不在支持列表换用file_exists、is_file测试
过滤了O:但没过滤C:WAF规则不完整尝试异常类构造

注意:unserialize()在新版PHP中遇到格式错误时不会抛出异常,而是返回false并产生E_NOTICE警告。如果你的代码没有对返回值做严格判断,攻击者可以通过畸形序列化字符串探测内部对象结构,所以日志里出现批量反序列化警告信息时,值得警惕。

7. 我在这条路上踩过的坑

7.1 别只盯着PHP版本,还要看扩展

PHP本身的行为是一回事,扩展的行为是另一回事。比如SimpleXML、DOM、PDO这些扩展在反序列化时可能会有额外的资源加载,某些扩展的版本漏洞还会影响反序列化利用成功率。我遇到过本地PHP 8.1环境一切正常,线上PHP 8.1带了一个老版本扩展就导致__destruct触发顺序完全不同的情况。审计时最好在真实环境里做一次快速验证,别迷信本地环境。

7.2 序列化数据里的小改动会产生大问题

手工修改序列化字符串时,最痛苦的坑是长度字段。比如a:3:{...}表示数组有3个元素,改成2个元素但没改长度,PHP会直接报错或者把解析流程带偏。还有字符串内容里的多字节字符,比如中文字符,长度是按字节数算的,按字符数填就错了。所以构造payload时,能用代码生成就不要手改,手改时务必用严格的字符统计工具对照。

7.3 很多时候漏洞不是找不到,是标签打得太粗了

我最初审计反序列化漏洞时,总盯着unserialize这个函数名,结果漏掉了大量通过session_decode、igbinary_unserialize、msgpack_unserialize执行反序列化的点。后来养成了一个习惯:先梳理项目里所有“数据格式转换”的边界,再单独排查转换后的对象是否进入敏感操作。这个习惯帮我找到了好几个原本会被忽略的风险点。

7.4 防御方视角值得多花时间

从攻击者视角看,反序列化利用讲究如何把属性值压到一个危险方法上;从防御者视角看,真正要做的是让“属性值可控”和“危险方法存在”这两件事永远无法相遇。控制点比控制函数重要,入口校验比输出过滤重要。如果你正在修复一个反序列化漏洞,先问自己能不能删掉这个unserialize调用,如果不能,再问自己能不能加白名单、加签名、加类型校验。一层一层往下走,比直接撸一个WAF规则要靠谱得多。

最后再分享一个小操作:我每次审计PHP项目,都会先把所有__wakeup、__destruct、__toString这几个方法的定义位置拉出来,列成一张表,然后逐个看它们内部调用了哪些函数,调用的参数距离属性赋值有多远。这个方法听起来很笨,但执行起来比漫无目的地翻代码高效得多。你可以把这张表当成自己的“反序列化漏洞雷达”,用熟了之后,看一眼类结构就能判断这条链子能不能走通。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询