PHP反序列化漏洞原理与Payload构造实战指南
刚入行做代码审计或者打CTF的时候反序列化漏洞这块应该劝退过不少人。网上能搜到的文章大多是直接甩一个PoC告诉你“这样打就完了”至于为什么是这个格式、幂等方法是怎么串起来的、改个属性名怎么就打不通了基本没人细讲。我自己当年啃这块也踩了不少坑从对着Pikachu靶场一脸懵到后来能独立审计一套CMS的Gadget链中间花了很长时间去理解PHP的序列化格式本身。这篇就纯当一次个人项目复盘把我理解的PHP反序列化漏洞原理和Payload构造思路从头梳理一遍重点放在“为什么要这么构造”上而不是纯粹贴Payload。1. 反序列化到底是怎么出问题的1.1 序列化的本质就是状态持久化要搞清楚漏洞先得把序列化这玩意儿本身弄明白。序列化就是把内存里的对象状态转换成可存储、可传输的字符串格式反序列化就是把字符串再还原成对象。PHP里面典型的场景就是存Session、存Cookie、缓存复杂数据、消息队列传递、RPC调用传参这些场景里对象没法直接塞进数据库或者Redis只能先变成字符串用的时候再还原。这个设计本身没问题问题出在“还原”这一步。如果反序列化的数据来源不可控攻击者就能通过精心构造的字符串控制还原出来的对象里每个属性的值甚至指定对象的类名。当这个对象后续被程序逻辑使用触发到某些特殊方法的时候攻击就发生了。这里有句话我一直跟团队里的新人强调反序列化漏洞的本质是“不可信数据流入了对象还原过程”而不是“序列化格式本身有漏洞”。理解这个定位很重要它决定了你后续审计时的思路——你不是去找格式问题而是去找“哪些操作会处理用户可控的反序列化输入”。1.2 为什么说这是PHP独有的常见问题Java也有反序列化漏洞Python、Ruby也有但PHP的反序列化漏洞在Web领域占比特别高。原因其实很直白PHP写起来太随意了。一个unserialize($_POST[data])就敢写在业务代码里甚至很多框架为了“方便”把整个请求对象序列化了存Session攻击面一下就铺开了。再补一点PHP是解释型语言改完代码立刻生效不用编译这就导致很多小团队的项目里“接口返回对象”和“下一接口接收对象”接不上干脆全塞serialize里走一遍。这种大量使用序列化的代码习惯等于给攻击者敞开了大门。还一个关键点是PHP对象的方法调用太灵活了。魔术方法、动态调用、回调函数满天飞攻击者只要找到一个起点魔术方法和一条路径调用链就能把危害滚雪球一样滚到命令执行。这在Java里需要找反序列化Gadget在PHP里同样存在而且因为语法的松散性Gadget链经常长得很离谱。2. PHP序列化的格式就是Payload的语法基础2.1 手写一个序列化字符串到底长什么样学Payload构造第一步就是把序列化格式背熟——不是死记硬背而是理解它的语法规则。PHP的serialize()函数输出有固定的类型前缀N; // NULL b:1; // 布尔值 true i:99; // 整数 99 d:3.14; // 浮点数 s:4:test; // 字符串 test a:2:{...} // 数组2个元素 O:4:User:2:{...} // User对象2个属性拿一个简单的User类举例class User { public $username; public $isAdmin; } $u new User(); $u-username admin; $u-isAdmin false; echo serialize($u);输出结果O:4:User:2:{s:8:username;s:5:admin;s:7:isAdmin;b:0;}逐段拆一下O:4:User对象Object类名长度4类名User。2两个属性。s:8:username属性名字符串类型长度8。s:5:admin属性值字符串长度5。b:0属性值布尔假。这里有一个关键的坑s:8:username里面的8是属性名字符串的字节数不是字符数。如果属性名里有中文或者特殊字符长度是按字节算的。举个例子一个叫用户名的属性UTF-8下是9个字节你得写成s:9:用户名写成s:3:用户名直接报错反序列化会失败甚至触发报错信息泄露。2.2 访问修饰符对序列化格式的隐藏影响上面是public属性的情况。如果属性是protected或者private序列化的时候属性名格式会变这是新手最容易懵的地方也是手写Payload时最常见的卡点之一。先看代码class User { public $username admin; protected $role user; private $secret xxx; } echo serialize(new User());输出O:4:User:3:{s:8:username;s:5:admin;s:7:*role;s:4:user;s:13:Usersecret;s:3:xxx;}注意看差异public属性属性名就是username。protected属性属性名变成了*role也就是在原始属性名前加了一个星号。private属性属性名变成了Usersecret也就是“类名 原始属性名”。这里有个致命细节我们在手写Payload的时候protected属性的*前面以及private属性的类名前面都必须有一个空字节也就是\0。上面的输出为了显示方便用*role代替实际字节是这样的s:7:\0*\0role s:13:\0User\0secret也就是说protected属性的实际格式是\0*\0属性名private属性是\0类名\0属性名。这也是为什么网上很多Payload看起来乱糟糟的里面全是%00。你直接把序列化字符串写进URL或者POST请求里空字节会被编码成%00到了PHP端反序列化它读到了空字节才算对得上。如果你手写Payload时漏掉了这个空字节长度对不上反序列化直接失败。实操里我喜欢这样处理先写一个正常的PHP脚本把目标类实例化后serialize()一下拿到原始格式再手工替换属性值。这样做能避免自己推导空字节和长度算错尤其是面对几十个属性的复杂类时手写纯属找虐。3. 魔术方法和POP链Payload的魂3.1 魔术方法不是随便就触发的PHP的魔术方法Magic Methods是一批有固定名称的方法不需要显式调用满足了某个条件后PHP引擎会自动调。和反序列化相关度最高的有这么几个__construct()对象被创建new时调用。__destruct()对象被销毁/脚本结束时调用。__wakeup()反序列化生成对象时调用。__toString()对象被当作字符串使用时调用。__call()调用不可访问的方法时触发。__get()读取不可访问的属性时触发。__set()给不可访问的属性赋值时触发。__invoke()把对象当作函数调用时触发。__isset()、__unset()、__sleep()、__serialize()、__unserialize()等。注意__construct和__destruct的区别unserialize()还原对象时并不会调用对象的__construct()只在对象生命周期结束时调用__destruct()。这是一个反直觉但非常重要的事实。很多刚入门的朋友写PoC时习惯在__construct里做恶意操作结果发现unserialize()根本不弹这个操作一脸懵。我在审计时第一步就是梳理目标类库里的__destruct、__wakeup、__toString这三个才是反序列化后最先可能被触发的点。__wakeup和__destruct都在反序列化流程中被调用但顺序不同__wakeup在对象属性填充完之后立刻调用__destruct则发生在对象生命周期结束时。攻击者一般首选__wakeup做文章因为可控性更强——只要反序列化执行就触发__destruct则受限于对象什么时候被销毁。3.2 POP链是“属性驱动”的调用链不依赖魔术方法直接getshell的话就得靠POP链Property-Oriented Programming。这个名字本身就是在说攻击者控制的是对象的属性值通过属性值去驱动一系列方法调用最终到达危险函数。想象这样一个场景类A有一个属性$callback并且它的__destruct方法里写了call_user_func($this-callback)那你只要把$callback设成system再把另外的属性设成要执行的命令反序列化后__destruct一触发命令就执行了。这就是最简单的POP链。真实的POP链长得多可能从某个类的__toString触发然后在方法里调用了另一个对象的某个方法那个方法里又调用了第三方对象的方法……一环扣一环最终落到eval、system、file_put_contents这类危险函数上。构造POP链的审计思路可以这么干先看魔术方法特别是__destruct、__wakeup、__toString找到入口点。在入口点的方法体里追踪每一个函数调用和表达式取值看哪些是可以通过属性控制的。如果某个调用是$obj-method()格式想办法找一个类它的同名方法能带来更危险的操作这就是“跳板”。一路追下去直到找到可以执行系统命令、写文件、读文件、SSRF这类敏感操作的点。这里面有一个非常重要的实操经验别一上来就盯框架的核心类库优先看业务代码和公共组件。框架核心库的代码质量高审计的人也多很难找出Gadget反而是封装了文件上传、图片处理、日志写入这些功能的公共组件经常在不经意间埋了雷。我曾经在一套老PHP系统里找Gadget最后是在它的“图片水印”功能里找到的一个Image类里把图片路径参数直接丢进了shell_exec去调用ImageMagick命令这条链转发下来非常丝滑。3.3 一个完整的迷你POP链Demo写一个能看懂的示例。假设目标代码里有这三个类class Logger { public $logFile; public $content; public function __destruct() { file_put_contents($this-logFile, $this-content); } } class FileReader { public $path; public function read() { return file_get_contents($this-path); } } class ShellProxy { public $command; public function read() { return shell_exec($this-command); } }再来一段业务逻辑反序列化后某个地方把对象当字符串拼接了$obj unserialize($_POST[data]); // 业务代码中某处 echo DEBUG: . $obj; // 实际上会触发 __toString但示例里没有先忽略现在重新设计一下让Logger的__destruct去调用FileReader的read先把Logger的内容参数设计成它会调用可执行方法。伪代码不好演示换个更贴切的例子。我们给Logger加一个方法class Logger { public $logFile; public $reader; public function __destruct() { $data $this-reader-read(); file_put_contents($this-logFile, $data); } }此时攻击者可以构造$proxy new ShellProxy(); $proxy-command id /tmp/pwned; $logger new Logger(); $logger-logFile /tmp/log.txt; $logger-reader $proxy; echo serialize($logger);然后把这个序列化后的字符串POST到unserialize($_POST[data])的接口上。反序列化一执行Logger::__destruct()被调用它读取$this-reader-read()而这个reader被我们替换成了ShellProxy对象于是ShellProxy::read()被调用执行了id /tmp/pwned写入/tmp/log.txt。文件内容又被file_put_contents写到日志里。命令确实执行了。这里面的核心思想就一句话我不能直接调用危险方法但我可以控制对象的属性让本该调用A方法的地方实际调用到B方法。这跟面向对象设计里的多态特性完美结合所以叫“面向属性编程”。4. Payload构造实操全流程4.1 入口定位找到unserialize还挺不容易写Payload之前先得知道目标哪里调用了unserialize()。这就是入口定位。有些系统比较明显直接unserialize($_COOKIE[user])有些藏在Session处理器、缓存读取、消息队列消费里不仔细找根本发现不了。我用过的实战定位思路按效率排序全局搜索unserialize(一个不漏地看上下文。配合grep或者IDE全局检索很快能圈出候选目标。搜__wakeup、__destruct反着找。如果你的目标是要触发某个类的魔术方法那就搜那些魔术方法可能被谁调用。搜serialize(找到所有做序列化的地方再回看反序列化的入口。很多系统成对出现存的时候用了serialize取的时候大概率用unserialize。定位到unserialize还不够你还要确认用户的输入能不能精准到达这里。比如$data base64_decode($_POST[payload]); $obj unserialize($data);那你构造的Payload就要先序列化、再base64编码。常见处理还有urldecode、json_decode之后再反序列化、先反序列化一次得到一个字符串再反序列化一次这属于二次反序列化每一种都需要专门适配。4.2 利用Pikachu靶场把基础链路跑通Pikachu这个靶场里的反序列化漏洞是很多人的启蒙老师省略搭建细节只说它的利用思路。靶场页面会给一段序列化字符串提交后会反序列化并显示结果。一开始是让你理解序列化格式后面会让你尝试攻击。实际去打的时候先正常提交一次观察输出变化再尝试构造一个包含恶意属性值的序列化字符串提交看能不能触发目标类的魔术方法。Pikachu这里的场景相对简单不会涉及POP链但用来练格式和属性覆盖性价比很高。4.3 PHAR反序列化没有unserialize也能打值得单独说的一个点是PHAR反序列化。这和phar协议有关不展开讨论协议细节只说利用思路如果你能上传一个PHAR文件本质是特殊的归档文件并且目标代码中存在file_exists()、file_get_contents()这类文件操作函数且使用了phar://伪协议那么即使代码里完全没有unserialize()也能触发反序列化。原理就是PHAR文件里嵌有一段序列化字符串的元数据metadata当PHP通过phar://协议读取PHAR文件时会自动反序列化这段元数据。攻击者把恶意对象的序列化字符串写进PHAR的metadata里上传PHAR文件只要一个文件操作触发了phar协议解析反序列化就悄悄执行了。这个变种攻击在真实世界里特别值得注意因为它把攻击面从“必须有反序列化入口”扩大到了“任何能读取可控路径文件的地方”。利用时的几个要点生成PHAR文件时需要PHP环境允许phar.readonly0。PHAR文件有文件头通常是?php __HALT_COMPILER(); ?后面跟着二进制内容。Metadata可以通过$phar-setMetadata($obj)设置对象会被序列化后存进去。触发点常见的函数file_exists()、file_get_contents()、file()、fopen()、is_file()、is_dir()、stat()、filesize()等等本质上凡是能接受phar://协议的函数都可能中招。我试过的利用链里最刁钻的一次是目标把上传的图片经过缩放后再存上传接口只校验了文件扩展名和MIME。我用一个合法的PNG文件头拼接PHAR内容文件名改成.png上传成功后在另一个存在file_exists()的地方用路径拼接的姿势触发了phar反序列化。这个思路就是经典的“图片马 phar触发”实测稳定有效。4.4 __wakeup绕过这个经典操作__wakeup在某些版本里有个著名的绕过条件当序列化字符串中声明的属性数量大于实际给出的属性数量时PHP引擎会跳过__wakeup()方法直接生成对象。这个漏洞编号是CVE-2016-7124影响PHP 5.6.25以下、PHP 7.0.10以下的版本。假设目标类class Evil { public $cmd; public function __wakeup() { // 一些检查逻辑 if ($this-cmd safe) { // do something } } public function __destruct() { system($this-cmd); } }如果__wakeup里做了限制比如禁止执行某些命令、或者强制命令白名单使得Payload打不通那么改成绕过__wakeup就能直接进入__destruct绕过前置约束。构造方式很简单正常序列化结果O:4:Evil:1:{s:3:cmd;s:2:id;}把属性数量从1改成2O:4:Evil:2:{s:3:cmd;s:2:id;}属性数量声明大于实际数量PHP在部分版本里就会跳过__wakeup直接进入__destruct。我在做代码审计时如果看到目标的PHP版本比较老这个绕过点基本必试。4.5 Payload可用性自测清单写好了Payload不是扔上去就能用得按一套清单自测属性名是否正确public/protected/private的空字节处理对了吗字符串长度属性对不对中文按字节数算。反序列化前有没有过滤函数如果过滤了O:可以考虑改成C:__serialize相关的格式或者用数组包装绕过。如果是在URL里传递空字节和特殊字符的URL编码处理好了吗类在命名空间里的话类名长度算的是完整命名空间名的长度吗目标PHP版本是否满足触发条件__wakeup绕过、phar可用版本这串问题我在本地起环境测试时就会过一遍正式提交Payload前再跑一遍能省很多来回调试的时间。5. 常见报错长什么样怎么排查5.1 直接报错“unserialize(): Error at offset”这个是我见过最多的问题。反序列化字符串格式不对PHP解析到第几个字节时发现不对报错信息会给出offset位置。排查方法分两步第一步拿serialize()生成一个合法字符串比对格式差异。很多时候是因为手写时字符串长度算错了尤其是属性名里的特殊字符或中文。第二步如果格式看起来没问题极大概率是空字节问题。检查protected属性是否写成了\0*\0private属性是否写成了\0类名\0。我一般会在本地用bin2hex()把拼接的字符串打印出来肉眼确认空字节是否存在。5.2 没报错但命令不执行还有一种情况反序列化成功对象生成了但你想触发的方法没被调用。排查思路先确认类是否被目标代码正确加载。如果目标类在namespace下你的序列化字符串里类名必须写成完整的命名空间名比如O:16:App\Services\Test:1:{...}类名长度16是App\Services\Test的字节数。再确认触发条件。是不是必须依赖__toString或者__get这些触发点和你提交的Payload路径对不对得上。特别注意的是如果目标代码里没有把对象当字符串使用__toString一辈子也不会触发。你要找的是代码流里真正用到了该对象的位置。然后确认__wakeup是不是被跳过了。有些场景里你希望触发__wakeup结果因为属性数量不匹配被PHP跳过反而很困惑。如果怀疑是版本问题打印phpversion()看一眼在本地复现环境把版本对齐。5.3 一些看起来毫无关联的诡异失败有一种情况容易被忽略反序列化后程序逻辑里调用了对象的某个方法但那个方法里又依赖其他属性或外部资源导致报错。此时报错信息可能五花八门从“Call to a member function on null”到“Trying to access array offset on value of type bool”都有。这类问题本质是你构造的对象状态不完整。解决方案很简单把目标方法链读一遍把每一步用到的属性都补齐。还有一种情况跟类加载有关。目标环境的autoload机制可能不会加载你的类导致反序列化出来的对象实际上是__PHP_Incomplete_Class任何方法都调不动。这在用Composer的项目里偶尔会遇到排查时先确认目标类是否在自动加载范围里。6. 别忘了还有原生类和内置对象6.1 原生类Gadget的两大金矿真实环境里不是所有目标都有你能利用的自定义类。有些系统很精简整个代码库就几个类翻遍了也找不到一条危险链路。这时候原生类PHP内置类就派上用场了。两个在反序列化利用里高频出现的原生类是Error和Exception。它们俩都有一个特点__toString()方法会返回异常信息而且这个信息里包含了传入构造函数的message参数。更妙的是它们内部会有文件路径、代码行号等信息拼接。在某些环境下可以利用这个特性实现XSS如果异常信息被拼进HTML或者信息泄露通过报错页面泄露数据库密码、源码片段等。另外还有SoapClient这玩意儿是SSRF的一个好帮手。它的__call魔术方法会在调用不存在的方法时发起一个SOAP请求如果能控制请求的URL就能实现SSRF。在反序列化链里如果目标逻辑里调用了对象的不存在方法SoapClient就可以被用来发起任意HTTP请求。6.2 巧用SimpleXMLElement构造XXE链PHP原生类SimpleXMLElement也值得记一笔。它的构造函数可以解析XML字符串如果LIBXML外部实体没有禁用就能触发XXE。场景是这样的目标反序列化后某处代码把对象转成了字符串或调用了某些XML相关操作你构造一个SimpleXMLElement对象传给它一个包含外部实体引用payload的XML字符串。当它被解析时外部实体就被加载文件内容就被读取出来了。构造方式大概是$xml ?xml version1.0? !DOCTYPE root [!ENTITY xxe SYSTEM file:///etc/passwd] rootcontentxxe;/content/root; $obj new SimpleXMLElement($xml);然后配合目标环境里把它输出或者落库的行为就能把文件内容带出来。利用这类原生类有个好处你不需要理解目标系统里有哪些类只要知道目标反序列化入口和后续处理逻辑就能打。6.3 利用phar协议和原生类组合前面的PHAR反序列化和原生类还能再做一次组合。如果目标代码里有file_exists()且参数可控你上传一个metadata里塞了SimpleXMLElement对象的PHAR文件触发后就会生成一个解析XML的对象再配合后续的对象使用方法可能连续触发XXE。这类组合链看着复杂但审计的思维是完全一致的——找入口找跳板找终点。7. 防御和修复思路7.1 最硬的一条别反序列化不可信数据这条说起来简单做起来最难。很多业务场景拿反序列化当万能胶水用缓存、Session、接口参数传递全都用它。防御的第一位就是重新审视这些场景里数据来源是否可信。不可信入口必须彻底移除unserialize调用。可以改成JSON序列化方案虽然功能弱一些但安全性完全不一样。7.2 白名单和allowed_classes如果确实没法移除反序列化PHP官方给了allowed_classes参数unserialize($data, [allowed_classes false]);这里的false意思是一个类也不允许还原所有对象都会变成__PHP_Incomplete_Class。有些场景需要部分类可以传白名单数组unserialize($data, [allowed_classes [User, Order]]);这样即使来了恶意类也被打回原形。我自己的审计建议是这个参数必须当成强制要求写进代码规范不能靠自觉。7.3 加固扩展边界除了反序列化入口本身还要把边界上的问题堵住。针对PHAR反序列化在所有文件操作函数前校验路径是否允许使用phar://协议或者禁用phar流包装器。上传文件要严格校验内容格式不能只看扩展名和MIME。对文件操作函数能不做就不要直接拼接用户输入。文件路径参数必须经过白名单校验或至少加一层realpath判断。7.4 代码审计时的自查清单给审计的同行们一份自查清单每次测反序列化相关项目我都按这个来过全局搜unserialize逐一确认数据来源和过滤情况。搜file_exists、file_get_contents、fopen等函数检查是否可能处理可控的phar://路径。梳理所有魔术方法特别是__destruct、__wakeup、__toString。确认框架里是否有已知的公开Gadget链比如ThinkPHP、Laravel的相关历史链。测试目标PHP版本确认是否有__wakeup绕过、phar可利用版本特性。检查allowed_classes是否被正确设置。防御的本质不是某个函数、某个参数而是一整套“不信任用户输入”的编码习惯。反序列化漏洞能反复出现很大程度上是因为业务代码觉得“用户没这么聪明”但现实往往打脸。8. 写在最后的一点个人体会反序列化漏洞的Payload构造说到底是一个双层的活底层是吃透PHP序列化语法顶层是吃透目标代码的调用链。前者背一背格式、踩一踩空字节的坑就行后者才是真正拉开水平差距的地方。我自己的成长路径是先把Pikachu靶场每个漏洞场景打到吐然后在本地搭一套PHP框架代码审计练手翻出每一次“明明没有反序列化”却因为PHAR机制被打穿的真实案例再回去读官方文档把协议层的原理补齐。走完一轮之后再看网上那些直接甩PoC的文章一眼就知道作者是抄的还是自己写过的。如果你刚开始接触这块别急着背那些又长又复杂的Gadget链先自己写几个小类手动拼一次序列化字符串亲手感受一次因为长度算错导致格式坏掉的痛苦。等你能不看文档写出O:4:User:2:{...}这种基本Payload时反序列化的大门才真正朝你打开。后面再遇到POP链、原生类、PHAR都有一种“我懂它为什么这么走”的从容感。最后分享一个小习惯我本地常年备着一个payload_generator.php脚本把序列化格式、空字节注入、长度计算封装好输入类名和属性数组脚本自动生成Payload字符串。遇到复杂对象直接用serialize()生成再改值。这样既不烧脑又能最大程度减少低级错误。这个习惯救了我很多次也推荐给你。