资讯动态

PHP反序列化漏洞深度解析:从魔术方法到POP链利用与防御

发布时间:2026/10/17 19:25:24 来源:尧图企业网站定制
1. 从一个限制登录框开始讲起搞渗透测试这些年见过太多开发者在写登录功能时踩同一个坑用户输入看起来正常参数名也简单后端逻辑也就那么几行代码结果就是被一条精心构造的字符串直接打穿内网。今天要聊的unserialize反序列化漏洞在PHP应用里出现频率极高很多看似人畜无害的功能点比如Cookie存登录态、购物车数据序列化、缓存文件持久化都可能变成攻击者的突破口。简单说序列化是把对象变成字符串存起来反序列化是把字符串还原成对象。本身设计是为了方便传值和存储但问题在于如果反序列化操作直接使用了用户可控的输入而程序又恰好存在某些特定方法PHP里叫魔术方法会在对象还原时被自动触发那攻击者就可以控制还原什么样的对象进而触发危险函数最终实现任意代码执行、文件读写、SQL注入甚至拿下整台服务器。适合看这篇文章的人有三类刚入门想搞懂漏洞原理的渗透测试新手在写PHP代码但对安全边界模糊的开发同学以及准备面试时被问到“反序列化漏洞怎么利用”需要系统性答案的求职者。我会从原理讲到实操再讲绕过手法和修复方案最后附上我踩过的一些坑。2. 序列化与反序列化的底层逻辑2.1 PHP里对象是怎么被“打包”的在聊漏洞之前得先搞清楚PHP序列化的数据格式长什么样。下面是一段最普通的PHP类定义和它的序列化结果。class User { public $name admin; public $is_admin false; public $age 28; } echo serialize(new User()); // 输出: O:4:User:3:{s:4:name;s:5:admin;s:8:is_admin;b:0;s:3:age;i:28;}我来逐段拆解这个字符串的结构你会看得很清楚O:4:User表示这是一个对象Object类名长度是4个字节类名是User3表示有3个属性s:4:name表示属性名是字符串类型长度4值是names:5:admin表示属性值字符串长度5值是adminb:0是布尔值falsei:28是整数28除了上面用到的类型标志PHP序列化还支持这些a代表数组d代表浮点数N代表NULLO代表对象C代表自定义序列化对象R代表引用。攻击者最爱的就是O类型因为只有对象才会触发类的魔术方法。这种格式本身没有任何加密或者完整性校验意味着谁拿到这段字符串谁就能按照同样格式拼一个任意对象的序列化数据出来。如果程序在某个地方直接对用户输入做unserialize那用户就控了“还原成什么对象”这个选项。2.2 魔术方法为什么是漏洞的催化剂PHP对象在生命周期里有很多特殊方法不需要手动调用满足条件就会自动触发它们叫魔术方法。和反序列化漏洞直接相关的有这么几个魔术方法触发时机危害场景利用难度__wakeup()unserialize()时被调用执行任意代码、连接恶意服务器低__destruct()对象销毁时被调用写文件、删文件低__toString()对象被当作字符串使用时触发文件读取、SQL拼接中__call()调用不存在的方法时触发链式调用跳板中__get()访问不存在属性时触发链式调用跳板中__invoke()对象被当作函数调用时触发链式调用跳板中这就引出了两个利用层面。第一层是“单一魔术方法直接getshell”比如某个类的__destruct()里恰好有一条file_put_contents()攻击者只要构造这个类的对象序列化数据反序列化时类被还原脚本结束对象被销毁恶意代码就被写进文件了。这种利用方式叫做“一条链打到底”。第二层是“多个类串联成POP链”。真实业务代码里单独的魔术方法很少直接执行危险操作通常是调用其他类的方法。这时攻击者就需要在当前代码环境中寻找一个类的方法A会调用另一个类的方法BB又调用C最终落到一个敏感函数上。这种由多个类方法调用拼接起来的利用链就叫POP链Property-Oriented Programming属性导向编程。我打个比方魔术方法就像是一堆搭好的积木单块积木用处不大但攻击者可以按图纸把它们拼成一座能发射的弩。图纸就是POP链积木就是代码里已有的类和方法。3. 漏洞出现的高频场景与根本成因3.1 哪个环节最容易出问题根据我实际做渗透测试的经验反序列化漏洞最常出现在下面几类功能点里第一类是Cookie直接存序列化数据。很多早期PHP框架为了在无会话环境下维持登录状态会把用户信息序列化后塞进Cookie。看起来省事但Cookie完全由客户端控制把Cookie里的序列化字符串拿出来改一改再放回去服务端解开时就被注入恶意对象了。第二类是参数名直接进入unserialize。有些接口为了兼容旧逻辑直接接收data、param这类参数值做反序列化。如果参数是GET或POST直接传的那漏洞判定就是“无认证可利用”风险会立刻拉满。第三类是缓存系统和Session。把业务对象序列化后存到文件或Redis里读取时再反序列化。缓存内容如果用用户输入拼了文件名或者Session文件能被控制同样会造成反序列化入口。判断一个反序列化点是否可利用有两个关键条件输入点是否可控、输出点是否有危害。输入可控是前提比如unserialize($_GET[data])这种写出了一百遍的典型写法不爆都难。危害可证明是第二步需要找到代码里能构成利用链的类。3.2 为什么会普遍存在认知偏差说了这么多技术细节我想专门花点篇幅聊聊开发者的认知问题。很多同学觉得“我的类又没有危险函数怕什么反序列化”这是最常见的误区。漏洞利用的价值并不取决于你写了什么危险函数而取决于你的代码里有没有足够的类、方法、函数能够被攻击者串联起来。换句话说一个本身没有执行命令功能的类也可能成为另一条POP链的关键跳板。攻击者不需要当前类直接执行代码只要某个类的方法可以被当作跳板链接到下一个类就行。这意味着框架里即使只有几个“看起来无害”的基础类也可能被拼成完整利用链。所以我在做代码审计时有个习惯先把整个项目里的类全部拿出来标注每个方法能干的“危险动作”比如读写文件、执行系统命令、发起网络请求、操作数据库等然后再去找这些方法之间的调用关系。这种方式比单纯看入口直白得多。3.3 一个最简示例的完整还原为了让你对“利用”有具象认知我给你模拟一个极简场景。假设某个测试环境里有这样一段代码class Log { public $filename; public $data; public function __destruct() { file_put_contents($this-filename, $this-data, FILE_APPEND); } } // 入口 if (isset($_COOKIE[user_prefs])) { $prefs unserialize(base64_decode($_COOKIE[user_prefs])); }这段代码的问题清晰可见Cookie可控传入内容被base64解码后直接反序列化最终对象销毁时触发__destruct()写文件。攻击者构造一个Log对象把filename设成webshell.phpdata设成?php eval($_POST[x]); ?序列化后base64编码放进Cookie访问一次网站根目录就会多一个一句话木马。我用笔算一下这个过程第一步打开终端生成序列化payloadPHP脚本写$exp new Log; $exp-filename ./shell.php; $exp-data demo; echo serialize($exp);执行拿到O:3:Log:2:{s:8:filename;s:11:./shell.php;s:4:data;s:4:demo;}。第二步base64编码后塞进Cookie模拟器。第三步请求目标页面服务端反序列化后创建Log对象脚本结束对象销毁file_put_contents被调用。第四步访问shell.php确认写入成功。攻击流程就这么简单真正打起来比SQL注入还直观因为它完全合乎PHP的“设计逻辑”。4. 实操过程构造利用链的完整思路与方法4.1 第一步确定反序列化数据入口实操第一步永远是找入口而不是急着构造payload。我拿到目标代码后通常会先全局搜索这些关键函数一个都别放过unserialize(这是直接入口优先级最高serialize(先找出哪些数据被序列化了再逆推到哪一步解出来的__wakeup()和一个__destruct()找出可触发的魔术方法所在类__toString()、__call()找类的自动调用关系file_put_contents、assert、call_user_func找危险函数作为链条终点搜完后把每个入口对应输入参数追踪一下参数是从$_GET、$_POST、$_COOKIE、$_REQUEST来的还是从文件、数据库、缓存里读取的。如果是从数据库读的利用难度会高一些因为可能还需要先拿到数据库权限如果是直接前端传的那不用犹豫漏洞确认。我实际做测试时还会在Burp Suite里加一个“全请求抓包检查”把每个POST参数、每个Cookie字段都尝试在值前后分别出现O:串和a:串的地方标红重点观察是否有参数名带data、param、cache、session之类的接口。4.2 第二步穷举可用的类与方法确定入口后要开始收集“装备”也就是代码里所有可用的类。这一步我用两种方式加快速度。一种是静态利用代码审计工具比如RIPS、PHPStan这类能识别类和函数调用关系的工具。另一种更接地气的办法是写一小段PHP脚本扫描整个项目目录提取所有类的类名、属性、方法名再人工叠加关键词过滤找哪个方法触发了敏感函数。下面是我经常在目标后端环境直接执行的一小段资产收集脚本?php // 扫描代码库中的危险函数调用 $dangerFuncs [file_put_contents,eval,assert,system,exec,shell_exec,call_user_func,unserialize,include,require,fwrite,curl_exec]; $targetDir /var/www/html/app; $rii new RecursiveIteratorIterator(new RecursiveDirectoryIterator($targetDir)); foreach ($rii as $file) { if ($file-isFile() in_array($file-getExtension(), [php])) { $content file_get_contents($file-getPathname()); foreach ($dangerFuncs as $func) { if (stripos($content, $func) ! false) { echo $file-getPathname() . . $func . \n; } } } } ?这个脚本帮我节省了大量翻代码的时间。它会把所有涉及危险函数的文件打出来之后我再逐个去读上下文看危险函数附近是否有魔术方法、是否可能被反序列化触发。这里有个技巧不只看直接调用了危险函数的类更要看“谁调用了这个类的方法”。因为反序列化利用往往是间接的__destruct里一行$this-logger-log($this-msg)看起来安全但logger属性可控攻击者把它指向另外一个有危险方法的类调用链就被续上了。4.3 第三步拼接POP链的核心方法论POP链拼接是整场利用中最考验耐心的环节。我把它拆成三步走按照这个方法一步步推基本不会迷路。第一步选终点。先确定一个想要执行的最终动作比如读取/etc/passwd或者向当前目录写一个PHP文件。然后全项目搜哪个类的方法能直接完成这个动作。比如我要写文件会重点找file_put_contents、fwrite、rename这些调用所在的类。第二步找跳板。选好终点后往前推假设终点类方法是B那我要找谁触发B。通常寻找逻辑是这样找某个类的某个方法里出现了$this-xxx-yyy()这种调用而这个xxx属性可控、它的类型又指向终点类。这时候把那个类也纳入链条作为新的“起点”。第三步定起点。一直往前推直到找到由魔术方法触发的那一刻。常见起点是__destruct()或者__wakeup()。因为这两个方法不需要我们额外操作反序列化本身就会触发。我总结了一个特别实用的表格式“链条检查清单”环节检查内容确认结果入口用户输入直接进入unserialize确认起点存在自动触发的魔术方法确认跳板魔术方法里调用了可控属性的方法确认衔接每个跳板方法调用的下一个对象也可控确认终点末位方法执行了敏感操作确认依赖类已加载且构造函数无阻断确认只要上面六个环节都确认这条链基本就可以打。实际利用时攻击者要生成本身序列化的payload并不需要真的一步步在内存里还原对象。只需要在代码里new出这些类、设好属性然后serialize()出来整个利用重放就完成了。这也是PHP反序列化利用工具的原理。4.4 第四步绕过与限制的现实情况搞渗透测试的人都知道真实项目里不可能老是遇到“一把梭”的完美代码总会碰到各种限制。最常见的几个限制就是老生常谈的__wakeup()绕过、属性数量绕过、引用绕过等等。__wakeup()绕过这个经典技术在CVE-2016-7124里描述得很清楚处理的版本是PHP 5.6.25之前和7.0.10之前的版本在反序列化时如果对象属性总数大于真实属性数量__wakeup()就不会被调用。我自己实测时把这个技巧用于一个类里__wakeup()存在反制逻辑比如重置恶意属性的情况修改序列化串里表示属性数量的那个数字比如真实有2个属性把它改成3或更大直接跳过wakeup方法然后后续魔术方法以及恶意属性就得以保留。PHP 7.4之后官方修复了这个行为现在__wakeup()无论如何都会被调用。这个老技巧只能在老版本环境里玩不过老旧系统存量巨大不该丢掉这个知识。另一个常见的绕过点是小写类名和大小写敏感问题。PHP 8之前的类名不区分大小写序列化时类名不一致会导致反序列化失败但在PHP 5里O:4:User和O:4:user都能被识别。早期利用时有人用大小写变异绕过某些对类名做了硬匹配的WAF规则。还有一个利用方式值得注意就是引用绕过。某些情况下不能直接给属性赋我们想要的值因为反序列化之后某些属性会被覆盖掉但利用R类型引用可以制造“两个属性指向同一块内存”的效果一个属性负责触发另一个属性负责存储值。比如a:2:{i:0;R:1;i:1;s:5:admin;}这种构造可以让一个属性引用另一个属性的值达到绕过安全赋值的目地。4.5 实战里的“垃圾代码”排查思路上面说的都挺清晰但实战里还有一个特别容易卡壳的点你以为找到了一条完美链条结果反序列化的时候直接报错或触发了别的代码。原因多半出在构造函数上。PHP反序列化时会不会调用构造函数答案是__construct正常情况下不会调因为对象不是通过new创建的。但很多类的属性在构造函数里被初始化或者被赋予默认值如果链条上某个环节的方法依赖构造函数里设置的属性而反序列化生成的属性覆盖后数值变成空调用时就会直接抛异常。所以我的经验是在构造payload前先把链条上涉及的类都实例化一遍观察构造函数是否有副作用、是否设置了一些必须存在的辅助属性。如果构造函数里有die()/exit()这种直接中断的代码那么这个类就不能用作起点得换跳板绕过它。另外反序列化时PHP是“懒加载”的只有当反序列化遇到引用到类时才会尝试加载类文件。如果类不在当前已加载的类列表里而又没有自动加载机制处理的话就会抛“Class not found”错误。这种情况我一般会在payload头部附近随手include目标类文件或者在代码库里找有没有现成的autoload机制可以利用。5. 进阶利用手法与真实战场案例5.1 从反序列化到RCE的完整路径利用链一旦打通最终执行什么命令、往哪里写文件就完全看攻击者的目的了。我按危害从低到高排列一下常见利用方式文件读取可利用__toString配合文件读取类读取配置、源码、数据库账号密码文件写入利用__destruct配合文件写入类写一句话木马或者WebShellSSRF利用类的网络请求方法探测内网、访问云元数据接口命令执行利用exec、system等调用的类方法直接执行系统命令数据库操作利用PDO、MySQL连接类读库、拖取数据拿命令执行来说最常见的终点函数是call_user_func和assert。这两个函数的特点是都可以接受字符串当代码执行。如果类里有一个方法把某个属性作为参数传给call_user_func那我只需要让这个属性等于我们想要执行的函数名就能完成一次调用。class Command { public $callback; public $param; public function run() { call_user_func($this-callback, $this-param); } }序列化成O:7:Command:2:{s:8:callback;s:6:system;s:5:param;s:11:id;whoami;}反序列化后run()被触发等价于执行了system(id;whoami)。这个还是最基础的版本真实利用链往往更长终点也不一定是Command这个类但思路完全一致。5.2 真实场景复盘某业务系统登录接口有一次我在做一个内部模拟测试目标是一个自研后台管理系统的登录接口。接口本身没有直接的sql注入但登录成功后会把用户信息序列化放进Cookie。这属于我前面说的高危场景所以重点盯了这个点。我把Cookie里那段base64字符串解出来看到O:8:stdClass:3:{...}的结构说明服务端确实在用PHP序列化。顺着这个入口我把系统代码里的类和危险函数全扫了一遍发现有个Cache类__destruct()里会调用$this-store-save($this-key, $this-value)而这个store属性完全可控。继续追踪save方法找到FileCacheStore类它的save()最后调用file_put_contents。链条清晰了起点Cache::__destruct()跳板Cache::$store指向FileCacheStore对象终点FileCacheStore::save()写入$this-path指定的文件最后构造的payload把Cache::$key设为shell.php$value设为?php phpinfo();?$store属性填一个FileCacheStore对象对象里的path属性设为网站根目录。反序列化执行后shell.php被成功写入。整个过程没遇到登录拦截入口就在Cookie里危害从“无认证写文件”直接升级到了“RCE”。这类案例我碰得不少都是同一套思维找入口找链条构造对象。5.3 利用工具的选择与限制搞渗透测试不可能每次都是手工造payload效率太低。真实测试中我常用两类工具来辅助PHP反序列化利用。第一类是现成框架的漏洞利用工具比如针对某知名PHP框架的历史漏洞打出的exp脚本。这类工具开箱即用但局限于已知漏洞对自研系统毫无作用。第二类就是PHPGGC这款工具收集了大量框架和CMS的现成POP链用命令直接生成payload。比如生成一个针对Laravel的RCE链命令大概长这样phpggc Laravel/RCE4 system id它会自动输出一个序列化好的payload字符串直接复制到请求里就能用。注意PHPGGC不生产利用链它只是把你的目标框架内已知可利用的POP链以工具化形式固定下来。真遇到一个架构独特的自研系统PHPGGC就帮不上忙了只能回到手工拼链的思路。我的习惯是框架类目标先用PHPGGC快速试一遍不行再手工分析。自研系统从头到尾手工分析这条流程帮我节省了太多时间。6. 常见问题速查与漏洞修复方案6.1 我在测试中踩过哪些坑第一次自己拼POP链的时候我花了一整个下午最后发现payload一提交就报错原因让我哭笑不得序列化串里的属性顺序和代码里属性声明顺序不一致导致某些属性能被赋值某些则因为被反序列化时的赋值顺序问题被意外覆盖。后来我才明白PHP反序列化后的对象属性值完全以字符串中给定的为准顺序并不影响赋值但会影响某些逻辑的判断。如果代码某个方法检查属性A不为空才执行关键操作而序列化串里A排在B后面恰好被后面的逻辑覆盖成空链条就断了。真实的经验是构造序列化串时要逐个属性确认最终值千万别图省事直接serialize()一个构造好的对象就完事有时候还得手动改改字符串里的值让两条路径都能走通。第二个坑是属性可见性。PHP序列化对private和protected属性的格式有严格要求private属性名会带上类名前缀还包含空字节。直接手写序列化串时稍有差错就导致反序列化失败。我在实战中一般用脚本生成避免手写污染。第三个坑是编码问题。序列化串里有中文或者特殊字符时长度计算会出错。比如echo strlen(你好)在PHP 7以上是6但在某些老版本是4如果序列化串里的长度是按老版本算的到新版本就会解不开。这个坑在跨版本环境中尤其致命解决方式是全部用ASCII字符构造最优解目标敏感值也用十六进制或者base64包一层再放进去。6.2 常见问题速查表很多刚接触反序列化的同学遇到问题总在线上问东问西我干脆把高频问题整理成一个速查表省得大家踩重复的坑。问题现象排查思路解决方案反序列化报错Class not found类未被加载检查目标环境是否存在该类类文件路径是否正确在payload中提前include关键类文件__wakeup()被阻断恶意属性被重置确认PHP版本是否低于7.4使用CVE-2016-7124绕过或更换起点属性值为空赋值后出现null检查属性名大小写和可见性格式用脚本生成序列化串长度错误base64解码后乱码检查序列化串长度标记全ASCII构造特殊值做二次编码链条走不通某方法返回false检查前置条件判断语句回看方法源码补齐必要条件WAF拦截请求被403序列化串特征太明显拆分payload、变形类名、混淆编码命令执行无回显shell_exec不回显目标环境可能过滤了stdout用到写文件的方式反弹输出或使用时间盲打版本兼容问题本地成功远端失败本地PHP版本与目标不一致在多个PHP版本下测试payload6.3 防御方该怎么修别再看漏了讲完攻击思路得说说怎么修不然这篇文章只教了破坏不教防御干我们这行的都知道防御思路才是渗透测试真正要让客户明白的核心价值。第一条也是最彻底的一条永远不要对用户输入直接调用unserialize()。如果必须反序列化不可信数据那就不应该用PHP原生unserialize()而是改用json_decode()处理数组和标量数据或者对字符串做一次严格的签名校验。比如存储时hash_hmac(sha256, $data, $key)取值时先校验签名一致再unserialize确保数据没有被篡改。第二条是用safe_mode或allowed_classes参数限制反序列化白名单。PHP 7.0之后unserialize()支持第二个参数可以指定允许反序列化的类列表。配置方式是unserialize($data, [allowed_classes [User, CartItem]]);这样即使攻击者把恶意类序列化串塞进来反序列化时遇到白名单之外的类会直接转为__PHP_Incomplete_Class魔术方法根本不会被触发。这个方法对修复老代码特别实用改动量极小。第三条是对类做“消毒”。以__destruct()为例如果业务上根本不需要它写文件那就把写文件逻辑挪出去或者加严格类型校验。尽量让每个魔术方法内部只做无副作用的基础操作别有危险函调。第四条是属性访问控制。攻击者拼接POP链时依赖的就是属性可控。如果关键属性设置成private或者readonly序列化时属性名会被带上类名前缀而且外部代码无法通过$this-xxx方式来恶意赋值利用链条会断得很难看。最后一条是上线前的自动化审计。把PHP代码扔进支持自定义规则的静态扫描工具里规则写上“unserialize的入参直接来自超全局变量”就算一条高危告警。如果研发流程能卡住这种代码上线反序列化漏洞的数量会骤降一大截这比我写十篇文章都管用。7. 最后分享一点个人经验做渗透测试这么多年反序列化漏洞是我觉得“性价比”最高的入门漏洞类型。SQL注入需要懂数据库语法XSS需要懂前端上下文而反序列化漏洞最核心的就是面向对象的思维只要你能理解对象、方法、调用关系这三件事它就不难。很多新手一听说POP链就头大其实拆开了就是“有个方法会调用别的方法”这个常识。我个人在实操中体会最深的一条是不要把反序列化利用想成黑客炫技它本质上是编程逻辑缺陷。攻击者只是比开发者更认真翻了一遍代码把“属性可控”这个事实用到了极致。所以反过来防御的本质也不是堆WAF而是收紧可控数据进入反序列化函数的路径。每次拿到代码我都先问自己三个问题这段数据从哪来进到哪里去中间有哪一层校验这三个问题想清楚了漏洞基本不会扎堆出现。练手的话我建议大家先在自己本地搭一个PHP环境把文章里那个极简Log类例子亲手过一遍然后尝试把类数量扩展成三个自己拼一条小型POP链。这个过程会帮你彻底打通“构造payload”的任督二脉。真实系统里所有复杂利用链本质上都是在这个练习上加了更多跳板而已。如果你手头有旧项目代码可以顺手全局搜一遍unserialize看看有没有类型白名单有没有签名校验没有的话那大概率存在隐患。趁早修别等被打穿了才回头看这篇总结。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑