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

PHP反序列化漏洞实战:从原理到POP链构造与防御

PHP反序列化漏洞实战:从原理到POP链构造与防御 ★ FEATURED ARTICLE
刚开始接触PHP反序列化的时候我一度以为它就是个简单的“把对象存起来再读出来”的过程直到在一次代码审计里踩了坑才明白这玩意儿水有多深。它本质上是PHP对象状态的序列化表示与恢复机制但一旦和用户输入挂钩、和魔术方法串联就成了攻击者手里的“瑞士军刀”。这篇文章想把PHP反序列化的学习路径、利用手法和防御思路讲透帮助正在学Web安全或做代码审计的朋友避开我踩过的那些坑。说实话这个知识点对新手并不友好因为你需要同时理解PHP面向对象编程、魔术方法触发时机、语言底层的内存结构和各种框架的自动加载机制。但反过来只要你能完整构造出一条可用的反序列化利用链对于理解整个Web应用的数据流和控制流都是一种降维打击。文章里我会从原理讲起逐步到本地环境搭建、POP链构造、真实场景分析最后是修复方案全程用我自己的实操记录说话保证你照着能跑通。1. 反序列化机制拆解它到底在做什么1.1 序列化与反序列化的数据格式PHP里序列化一个对象通常用serialize()反序列化用unserialize()。序列化后的字符串并不是二进制乱码而是一段有规则的结构化文本。举个例子一个简单的用户类?php class User { public $name tester; public $is_admin false; private $token abc123; } $u new User(); echo serialize($u);输出长这样O:4:User:3:{s:4:name;s:6:tester;s:8:is_admin;b:0;s:11:token;s:6:abc123;}这个格式看着像天书拆开就懂了O:4:User表示一个对象Object类名长度是4类名叫User。后面花括号里是三组属性s:4:name表示字符串string长度为4值是name接着;s:6:tester表示字符串长度为6值是tester。b:0是布尔值false。注意private属性在序列化字符串里的键名会包含空字节和类名实际显示为s:11:\0User\0token。反序列化就是解析这段字符串在内存中重新创建出对应的对象。这里有一个关键点容易被忽略反序列化过程中只要类存在于当前命名空间或者被自动加载PHP并不会检查这个类之前有没有被实例化过也不会检查属性值是否合法它直接按照字符串里的定义把对象造出来。这意味着什么意味着如果你能控制整个字符串的内容就等于能控制对象的类名、属性名、属性个数、属性类型和值。攻击面一下子就打开了。1.2 魔术方法在反序列化中的触发时机对象被序列化或反序列化时PHP会在特定时机调用一组魔术方法。搞清楚这些方法什么时候被调用是构造利用链的基石。我列了一张表方便对照魔术方法触发时机典型用途__wakeup()反序列化恢复对象时重新初始化资源、恢复连接__sleep()序列化对象之前筛选需要序列化的属性__destruct()对象被销毁时例如脚本结束、unset释放资源、写日志__toString()对象被当作字符串使用返回标识字符串__call()调用不可访问的方法时动态处理调用__get()读取不可访问的属性时动态返回属性值__set()给不可访问的属性赋值时动态设置属性__isset()对不可访问属性调用isset()时辅助判断__unset()对不可访问属性调用unset()时辅助移除在反序列化攻击里最常被利用的是__wakeup()和__destruct()。前者在对象刚被恢复时立刻执行如果方法里面有危险操作比如写文件、执行命令那攻击者只要控制属性值就能触发。后者在对象生命周期结束时执行脚本结束、缓存回收等时机都会触发所以哪怕反序列化的结果没有被赋给任何变量只要对象被构造出来最终被销毁时__destruct()一样会跑。我见过很多新手在测试时把unserialize()的返回值赋给了变量却忽略了在函数作用域里对象销毁造成的“收割效应”。2. 漏洞本质为什么一个简单的函数能变成灾难2.1 入口不可信的输入进入了unserialize()反序列化漏洞要成立必须有一个关键的“源码”前提unserialize()函数的参数是用户可控的。最常见的入口是Cookie或请求参数里直接存放序列化字符串比如购物车数据、用户偏好设置程序把序列化后的数据存入数据库或缓存读取时再反序列化文件上传或导入功能处理了包含序列化数据的文件。如果开发者图省事把对象直接序列化后塞进Cookie这就是教科书级的“一键送命”。比如$data $_COOKIE[user]; $user unserialize($data);攻击者可以手动构造一个Cookie值内容是一串精心设计的序列化对象。只要目标环境里存在某个类它的魔术方法能够产生危险行为比如__destruct里调用system()攻击者就能直接控制命令参数。2.2 利用链的核心POP链的构造思路如果只是简单利用单个类里的危险魔术方法那太理想化了。实际上大部分安全项目里不存在“反序列化直接执行命令”这种裸奔类。于是攻击者需要串联多个类的方法调用从某个入口方法出发逐步调用其他对象的可控方法最终到达危险函数比如eval、system、file_put_contents。这就是所谓的POP链Property-Oriented Programming。可以把POP链理解成一个接力赛第一棒是__wakeup()或__destruct()它会调用某个类的方法这个方法里又因为属性可控调用了另一个类的方法……每一棒都要求被调用的方法名、参数类型和返回值能够被控制。整个链条的终点是危险函数中间可能经过几十个类。构造POP链需要对目标代码库有足够的熟悉度。我常用的思路是先列出所有魔术方法找出其中会调用其他方法、或者包含敏感函数调用的地方然后从危险函数倒推一层一层向前找“谁调用了它”。这个过程很像解谜需要耐心也需要用工具辅助。2.3 为什么说“魔术方法只是触发器不是全部”很多人误以为只有魔术方法才能被利用其实普通方法同样可以成为链上的一环。只要你通过属性控制能让某个对象调用任意类的方法哪怕这个方法名字是常规的getData()只要它内部又调用了另一个对象的方法就能作为中转。所以真正要关注的是“可控的对象属性在方法中如何使用”。举个例子某个类的__wakeup()里有这样的代码public function __wakeup() { $this-logger-log($this-message); }这里$this-logger和$this-message都是攻击者可控的属性。攻击者可以让$this-logger指向另一个类只要那个类有log()方法就能调用到它。如果那个类的log()方法里又调用了$this-handler-execute($this-data)那继续替换$this-handler。这就是一环扣一环的POP链。所以审计的时候不只看魔术方法体还要看它调用的方法的完整实现路径。3. 本地实验环境搭建与POP链手工构造3.1 搭建一个可调试的靶场环境要学反序列化最怕的就是“纸上谈兵”。我建议你在本地装一套PHP环境版本选PHP 7.x官方已停止支持但很多老项目还在用漏洞生态最丰富再用Xdebug辅助调试。可以在虚拟机或者Docker里跑避免影响宿主机。我平时习惯用Docker写一个简单的docker-compose.ymlversion: 3 services: php: image: php:7.4-apache ports: - 8080:80 volumes: - ./www:/var/www/html然后在www目录下放一个测试入口文件test.php专门用来接收参数并反序列化?php class Logger { public $message ; public function __destruct() { file_put_contents(/tmp/log.txt, $this-message); } } $data $_GET[data] ?? ; if ($data) { unserialize($data); } ?这就是一个最简单的可攻击入口。注意unserialize后我没有把结果赋给变量这意味着对象会立即被销毁不对这里是函数调用结束后临时对象就会销毁所以__destruct会触发。如果赋给了变量则会等到脚本结束才销毁。现在这种写法相当于主动加速触发。3.2 从零开始构造一条可用POP链假设我们的目标是让上述Logger类的__destruct()写入文件。但正常情况下攻击者只能控制message属性没办法控制写入路径。所以我们需要找到另一个类它可以控制文件路径。假设源码里还有一个FileHandler类class FileHandler { public $filename default.txt; public $content ; public function write() { file_put_contents($this-filename, $this-content); } }我们的目标变成让Logger::__destruct()能够触发FileHandler::write()而不是直接写文件。如果Logger类里加一个属性$handler并且__destruct变成class Logger { public $message ; public $handler; public function __destruct() { $this-handler-write(); } }这时攻击者就可以这样构造序列化字符串$handler指向一个FileHandler对象$filename设为/var/www/html/shell.php$content设为?php phpinfo(); ?。对象销毁时Logger::__destruct()调用$handler-write()而$handler是FileHandler对象所以就会执行file_put_contents(/var/www/html/shell.php, ?php phpinfo(); ?)。因为代码里handler属性没有类型限制所以我们可以放任意对象。这就是一个最简单的两条链。真实项目里往往需要更多的中转类才能到达危险函数但原理一模一样。3.3 手工生成序列化Payload的几个技巧手工构造的时候最烦的是类名长度、属性名长度都要精确。我后来写了个小脚本帮助生成但初学阶段强烈建议手写两遍能加深理解。比如我们要构造上面那个链可以先在本地写一个生成器?php class FileHandler { public $filename /var/www/html/shell.php; public $content ?php phpinfo(); ?; } class Logger { public $handler; } $payload new Logger(); $payload-handler new FileHandler(); echo serialize($payload);输出可能是O:6:Logger:1:{s:7:handler;O:11:FileHandler:2:{s:8:filename;s:22:/var/www/html/shell.php;s:7:content;s:18:?php phpinfo(); ?;}}注意属性顺序和serialize的顺序一致我们把Logger的handler放在前面没问题。实际上PHP序列化不要求属性按定义顺序排列但解析时会根据字符串顺序赋值。构造好之后URL编码传参http://localhost:8080/test.php?dataO%3A6%3A%22Logger%22%3A1%3A%7Bs%3A7%3A%22handler%22%3BO%3A11%3A%22FileHandler%22%3A2%3A%7Bs%3A8%3A%22filename%22%3Bs%3A22%3A%22%2Fvar%2Fwww%2Fhtml%2Fshell.php%22%3Bs%3A7%3A%22content%22%3Bs%3A18%3A%22%3C%3Fphpphpinfo%28%29%3B%3F%3E%22%3B%7D%7D然后访问生成的shell.php看到phpinfo就说明利用成功。3.4 关于PHP 7.4与PHP 8.x在序列化上的差异PHP 8.0之后序列化字符串的解析更严格了。比如__wakeup被移除没有仍然存在。但有一个常见绕过手法是“CVE-2016-7124”当序列化字符串中表示属性个数的值大于实际属性个数时__wakeup会被跳过。这个在PHP 7.4之前有效PHP 7.4已经修复。我们学习时不要只盯着老技巧要理解为什么能绕过——因为旧版在反序列化时没有严格校验属性数组大小先跳过__wakeup再逐个填充属性导致数量不一致时逻辑错乱。另一个差异是PHP 8中如果类不存在反序列化会产生一个__PHP_Incomplete_Class对象并且在脚本结束时触发什么不会触发魔术方法。这会影响利用构造因为你不能加载一个不存在的类。所以在真实攻击中必须依赖目标环境中已经加载或可自动加载的类。4. 实战场景复盘从审计到利用的完整链条4.1 场景设定一个带缓存功能的内容管理系统假设我在审计一个自研的内容管理系统CMS代码不算复杂但有些“经典问题”。它的用户模块在登录成功后会生成一个包含用户信息的对象然后序列化后存入Cookie来维持会话。更可怕的是它在后续请求中会直接反序列化Cookie值来恢复用户状态。代码大致如下class UserSession { public $user; public $logged_in false; public function __wakeup() { if ($this-user ! null) { $this-logged_in true; } } public function __destruct() { if ($this-logged_in) { $this-user-updateLastLogin(); } } }开发者的意图是每次请求结束前如果用户已登录就更新最后登录时间。这个逻辑看起来没什么问题但问题出在$user属性完全可控。攻击者可以构造一个序列化字符串让$user指向一个不相关的类只要这个类存在updateLastLogin()方法即可。如果某个其他类的updateLastLogin()方法内部恰好调用了$this-cache-save($this-data)且$cache和$data都可控那么漏洞就显现了。在实际审计中我会先顺着这些调用关系画一条“方法调用图”。这里不能使用mermaid但可以手动记录成列表入口UserSession::__destruct()第一跳$this-user-updateLastLogin()第二跳$someClass-updateLastLogin()内调用$this-cache-save($this-data)第三跳$cache指向CacheFileHandler其save()内执行file_put_contents($this-path, $this-content)终点文件写入只要找到这些类就能串起来。这种利用链依赖的是代码里天然存在的调用顺序攻击者的核心工作就是“找方法”。4.2 如何找到可利用的类类文件扫描技巧面对一个庞大的框架人工扫描所有类是很低效的。我一般会先用正则跑一遍代码库找到包含危险函数eval、system、exec、file_put_contents、unlink等的类方法再看这些方法所在的类是否被其他类的魔术方法直接或间接调用。这里有个实操技巧使用grep -r function __destruct或grep -r __wakeup列出所有魔术方法然后对每个魔术方法方法体进行人工审计。我把它叫做“触发器清单”。有了触发器清单后再针对每个触发器内部调用的$this-xxx-yyy()追踪属性类型如果属性类型不是严格的类约束就标记为可疑。另一个技巧是看框架的自动加载规则。比如某个主流PHP框架的vendor/目录下有成百上千个类但大部分是第三方包。攻击者只需要找到一个可用的类即可。可以通过composer.json里的autoload配置了解类的命名空间和目录映射方便快速定位。4.3 从反序列化到RCE的真实利用记录回到上面那个CMS我最后构造了一条完整的利用链。因为代码里有一个用于文件上传的类UploadManager它的__destruct()会在临时文件没有被移动时执行清理操作使用unlink($this-tmp_path)。如果我能控制tmp_path就可以删除任意文件这是一个反序列化删除文件漏洞。但要实现RCE还得找可写文件的地方。最终我发现一个日志类LogWriter它的write()方法会执行file_put_contents($this-log_file, $this-log_entry, FILE_APPEND)。如果把UserSession的$user指向LogWriter并且让updateLastLogin()方法与write()方法重名不对LogWriter没有updateLastLogin()方法。所以还需要一个中间类这个中间类有一个updateLastLogin()方法内部恰好调用了LogWriter-write()。这种类在真实项目中很常见比如某些“用户行为追踪”类会记录用户动作。我的最终链子是UserSession::__destruct()→UserTracker::updateLastLogin()→LogWriter::write()→file_put_contents()写入Web目录下的webshell。整个利用过程只构造了一串序列化字符串通过Cookie提交即可。4.4 工具化利用与自动化探索手工构造太费时所以我开始使用一些自动化辅助工具。比如某个知名的PHP反序列化利用工具说的是工具概念不提具体名它可以在给定代码库后自动扫描POP链。但工具不是万能的很多复杂的链子需要人工调整。我的建议是先用工具扫描候选链再用手工验证和修正。在本地调试时我还会配合Xdebug的断点功能观察每一步的变量值。具体来说在unserialize()处打断点然后单步跟踪可以看到对象被恢复后有哪些属性被覆盖了在__destruct()处打断点可以看到调用栈确定每一步的调用来源。这个调试过程比看代码快多了。5. 防御与修复别让序列化数据变成突破口5.1 最直接的修复永远不要反序列化不可信数据这句话虽然是老生常谈但真正落实到项目里却很难。很多项目会把序列化对象直接存Redis或数据库如果这些存储被污染一样会导致问题。所以防御的第一原则是反序列化的数据来源必须是可信的并且要对数据进行完整性校验。如果一定要通过Cookie或参数传递中间数据建议把序列化字符串改用JSON或别的格式并且只保留必要字段。比如用户会话信息只用user_id、expire_time这些标量数据就好不要整个对象都塞进去。5.2 使用白名单限制允许反序列化的类如果业务逻辑确实需要反序列化PHP提供了自定义反序列化行为的方法实现Serializable接口或使用spl_autoload_call的类加载限制。实际上更实用的做法是使用unserialize()的第二个参数即允许的类白名单。比如$data unserialize($cookie_value, [allowed_classes [UserSession]]);这样PHP只会反序列化UserSession类其他类都会被替换为__PHP_Incomplete_Class对象魔术方法也不会被触发。这个参数在PHP 7.0以后可用。注意如果业务里要反序列化的对象包含多个类就得把白名单都列出来虽然麻烦但安全了。5.3 序列化数据加签与加密一个非常实用的方案是在序列化字符串上附加一个HMAC签名。发送时计算签名接收时先校验签名再反序列化。这样攻击者无法修改数据因为他们没有密钥。代码示例如下$secret some-long-random-key; function sign_serialized($data, $secret) { return $data . . . hash_hmac(sha256, $data, $secret); } function verify_sign($payload, $secret) { $pos strrpos($payload, .); if ($pos false) return false; $data substr($payload, 0, $pos); $sig substr($payload, $pos 1); return hash_equals(hash_hmac(sha256, $data, $secret), $sig); }注意用hash_equals做比较可以防时序攻击。密钥要保存在服务端不能混进代码库。5.4 对魔术方法进行加固最小化危险操作即使有上面的防御开发者仍然应该在魔术方法里保持克制。__wakeup()和__destruct()里不要执行任何和对象属性直接相关的敏感操作尤其是文件操作、命令执行、数据库写入等。可以把这些操作挪到显式调用的方法里。同时所有从外部可能进入对象的属性都要做类型校验和值校验。比如public function __wakeup() { if (!is_string($this-message) || strlen($this-message) 1024) { $this-message ; } if (!$this-handler instanceof FileHandler) { $this-handler null; } }这样即使攻击者构造了恶意的属性也因为类型不正确而无法进入危险分支。5.5 框架层面的安全机制现在许多主流PHP框架对反序列化已经加了防护。比如有的框架使用独立的会话管理不会让开发者直接暴露序列化数据有的框架使用allowed_classes参数。但这些机制需要正确配置才能生效。我见过不少项目框架已经提供了安全方法但开发者为了“方便”又自己写了一套裸奔实现。所以审计时不要只看框架还要看业务代码。6. 常见问题与排查技巧实录6.1 反序列化后魔术方法不触发的几种情况我遇到最多的问题是构造好的Payload打过去目标没有任何反应。排查顺序应该是确认类是否被正确加载。如果反序列化字符串中的类名不存在PHP会创建__PHP_Incomplete_Class对象魔术方法不会触发。这时去看目标环境源码里有没有这个类有没有开启自动加载。确认PHP版本。有些魔术方法触发行为在不同版本有差异。比如__wakeup()在老版本里可以被属性个数绕过新版本修复了。如果你的实验环境是PHP 8.0就不能用那个旧手法。确认反序列化入口是否真的被调用。有些代码虽然存在unserialize()但入口文件被禁用或被防火墙拦截。需要用本地代理或修改Host确认请求真的到达了目标脚本。确认对象生命周期。如果你把反序列化的结果赋给一个全局变量脚本结束后才触发__destruct如果在函数内函数结束时就触发。利用时要算准时机。6.2 序列化字符串中的特殊字符编码问题序列化字符串可能包含空字节\0、不可见字符和单引号。在通过HTTP传输时必须进行URL编码。否则空字节会被截断导致解析失败。我在实战中习惯用在线工具或脚本把Payload编码成%00形式。另外用curl发送时也要注意--data-urlencode。6.3 如何调试一次失败的利用尝试调试时我建议在目标脚本里加一些临时日志比如在unserialize()前记录收到的数据在魔术方法里记录触发标志。如果无法改代码就用Xdebug远程调试或者启用PHP错误日志。错误日志里如果出现“Class XXX not found”就说明类加载有问题出现“unserialize(): Error at offset ...”说明字符串格式不对。还有一个技巧在本机安装和目标完全一致的PHP版本和框架源码把目标环境复制下来。然后直接在本地用同一个Payload打用Xdebug单步跟踪看到底哪一步断掉了。这个方法虽然是“笨办法”但最有效。6.4 排查中的“假阳性”提醒有时你找到了一个看起来能利用的类方法确实调用了危险函数但传入的参数并不受属性控制而是固定写死。这种情况下要仔细看参数来源别急着提交漏洞报告。我犯过很多次这样的错误找到一条从魔术方法到file_put_contents的调用路径以为搞定了结果$this-path被代码硬编码成了临时文件路径攻击者根本改不了。所以总结下来一条真正可用的POP链必须保证每一个关键节点的参数都是攻击者可控的。6.5 给学习者的梯度建议如果你想系统掌握PHP反序列化我建议按这个顺序来先把serialize和unserialize的数据格式背熟能手写简单对象的序列化字符串。把所有常用魔术方法的触发条件验证一遍自己写脚本输出日志。在自己搭的靶场里构造一个包含两个类的POP链。去审计一个开源CMS找一个真实的入口点尝试构造链子。学习工具化利用但要能手工复现工具的结果。最后再分享一个我个人的习惯每次审计反序列化漏洞时我都会把用到的源码片段和调用链记录在一个文档里标注出哪些属性是可控的哪些方法是入口哪些是终点。时间长了这些记录就成了自己的“武器库”。遇到类似项目时直接对照旧链子改一改就能用。这个习惯帮我节省了大量重复探索的时间也希望对你有所帮助。
阅读完成 · 觉得有帮助?
咨询建站