1. 从“极客大挑战”到代码审计一次完整的PHP反序列化漏洞实战复盘最近在整理CTFCapture The Flag的解题笔记翻到了“[极客大挑战 2019]PHP”这道经典题目。这道题在网上有各种解法但很多教程要么过于简略要么默认读者已经具备了一定的安全基础对于刚入门Web安全或者PHP代码审计的新手来说理解起来还是有些门槛。今天我就以“纯小白也能看懂”为目标从头到尾、掰开揉碎地复盘一遍这道题的解题过程。我们不仅要知道怎么构造Payload更要理解每一步背后的原理比如为什么__wakeup方法会成为我们的“拦路虎”而__destruct方法又是如何成为我们的“突破口”。这个过程本质上是一次针对PHP反序列化漏洞的微型代码审计实战。对于完全没接触过的新手你可以把这道题想象成一个上了锁的保险箱网站后台或敏感功能。题目给了我们一把形状奇怪的钥匙胚一段可控的输入旁边还散落着一些制作钥匙的图纸网站的源代码。我们的任务就是研究图纸搞清楚保险箱锁芯的内部结构程序逻辑然后用钥匙胚打磨出一把能开锁的万能钥匙Payload。而“反序列化”就是那个将我们的钥匙胚数据转换成锁芯能识别的形状对象的关键机制。搞懂它你就能打开很多类似的“锁”。2. 环境搭建与初步信息收集看见题目全貌在开始解题之前我们首先得把题目“跑起来”看到它最原始的样子。很多线上靶场已经关闭所以我们最好在本地搭建环境。推荐使用Docker配合CTFd或者Vulhub这类集成环境对于这道题一个简单的PHP环境就够了。假设我们在本地用PHP内置服务器运行php -S 127.0.0.1:8080然后把题目文件放在根目录。访问题目地址我们首先看到的很可能是一个极其简单的页面甚至可能只有一句提示比如“你能拿到flag吗”。作为黑客这里指白帽子安全研究员的第一步习惯就是查看源代码。按F12打开开发者工具在Elements或源代码标签页里我们仔细搜寻。经验告诉我出题人经常会把关键信息藏在HTML注释里。果然我们可能会发现类似这样的注释!-- $user new Welcome(); $user-name $_GET[name]; echo $user-name; --这段注释就是我们的“第一张图纸”。它透露了几个关键信息题目使用了PHP。代码中有一个名为Welcome的类。程序通过$_GET[name]获取一个参数并将其赋值给$user-name属性。最后回显了这个属性。这看起来人畜无害就是一个简单的对象创建和属性赋值。但这里有一个至关重要的细节$_GET[name]是用户完全可控的输入。在安全领域“用户可控输入”永远是我们需要重点关注的攻击面。它就像一扇门我们需要检查这扇门是否上锁以及锁是否牢固。仅仅有前端注释还不够我们通常需要看到服务器端的完整源代码。在CTF中常见的考点就是源代码泄露。我们会尝试一些常见的源码备份文件名比如index.php.bak、index.php.swpvim备份文件、www.zip、robots.txt等。用浏览器访问http://题目地址/index.php.bak如果运气好服务器配置不当我们就能直接下载到备份的源代码文件。对于这道题我们假设通过访问www.zip成功下载到了完整的源码包。解压后我们找到了核心文件index.php其内容远比注释丰富。这才是完整的“保险箱图纸”。3. 核心代码深度审计定位危险函数与魔法方法现在我们拿到了完整的index.php。让我们静下心来像侦探一样逐行分析这段代码。真正的漏洞往往藏在细节之中。?php include flag.php; class Welcome { public $name; public function __construct() { $this-name Guest; } public function __wakeup() { echo Welcome back, . $this-name . !br; $this-name Guest; } public function __destruct() { if ($this-name admin) { global $flag; echo Hello admin! Here is your flag: . $flag; } } } $str $_GET[str] ?? ; if (empty($str)) { show_source(__FILE__); } else { if (unserialize($str)) { echo Have fun!; } } ?这段代码虽然不长但信息量巨大。我们来拆解每一个部分第一行include flag.php;这行代码引入了一个外部文件flag.php。在CTF题目中这通常就是存放flag通关凭证的文件。我们的最终目标就是让程序打印出$flag变量的值。类定义class Welcome定义了一个名为Welcome的类它有三个成员一个公共属性$name。一个构造方法__construct()当使用new Welcome()创建对象时自动调用将$name初始化为Guest。一个魔术方法__wakeup()这是本次漏洞利用的第一个关键点也是最大的障碍。当unserialize()函数反序列化一个字符串并重建对象时如果该对象的类中定义了__wakeup()方法那么该方法会在反序列化完成后、对象被使用之前自动调用。在这里__wakeup()做了两件事打印欢迎信息然后强行将$name属性重置为Guest。这意味着无论我们通过反序列化传入的$name是什么在__destruct()执行前它都会被改回Guest。一个魔术方法__destruct()这是本次漏洞利用的最终目标。当对象被销毁时例如脚本执行结束、unset()被调用此方法自动执行。它判断如果当前对象的$name属性等于字符串admin就输出全局变量$flag。主程序逻辑$str $_GET[str] ?? ;从URL的str参数获取用户输入这是我们的攻击入口点。if (empty($str)) { ... }如果str参数为空就使用show_source(__FILE__)高亮显示当前文件源代码。这就是我们第一步能看到代码的原因。else { if (unserialize($str)) { ... } }如果str参数不为空则尝试对其内容进行反序列化。如果反序列化成功返回一个非false的值就打印Have fun!。漏洞链条梳理攻击路径已经清晰我们通过str参数传入一个序列化字符串 - 程序用unserialize()将其还原成一个Welcome对象 - 触发__wakeup()方法重置$name- 脚本结束对象销毁 - 触发__destruct()方法检查$name是否为admin以决定是否输出flag。核心矛盾__wakeup()会在__destruct()之前执行并且它把$name强制改成了Guest。这导致__destruct()中的判断条件($this-name admin)永远无法成立。我们的挑战就是如何绕过__wakeup()对$name的篡改让__destruct()执行时$name的值依然是我们设定的admin。4. 构造Payload的关键利用CVE-2016-7124绕过__wakeup面对__wakeup()的阻挠我们似乎走进了死胡同。但PHP历史上有一个著名的漏洞编号CVE-2016-7124给了我们一线生机。这个漏洞存在于PHP 5.6.25之前和PHP 7.0.10之前的版本中。虽然题目环境可能已修复但CTF题目为了考察这个知识点通常会特意设置在存在此漏洞的PHP版本上。CVE-2016-7124漏洞原理当unserialize()反序列化一个字符串时字符串中包含了对象的属性数量信息。正常情况下这个数量应该与对象类中实际被序列化的属性数量一致。该漏洞在于如果我们在序列化字符串中故意将声明的对象属性数量大于实际数量那么__wakeup()方法将不会被调用。为什么是“大于”因为PHP在反序列化时会按照字符串中声明的数量去分配内存并读取属性。如果我们声明的数量比实际的多PHP在读取完所有实际属性后会发现还有“多余”的属性需要读取但这个数据可能不存在或者格式错误这种不一致性导致内部状态异常从而阻止了__wakeup()的执行。这是一个非常经典的“异常条件导致安全机制失效”的案例。如何构造利用的序列化字符串首先我们需要一个Welcome类的对象并且将其$name属性设置为admin。?php class Welcome { public $name; } $obj new Welcome(); $obj-name admin; echo serialize($obj); ?运行这段代码我们会得到标准的序列化字符串O:7:Welcome:1:{s:4:name;s:5:admin;}我们来解析这个字符串O:7:Welcome表示一个对象Object类名长度为7类名是Welcome。:1:表示这个对象有1个属性。{s:4:name;s:5:admin;}这是属性的具体内容。它是一个序列。s:4:name表示一个字符串类型的属性名长度为4值是names:5:admin表示该属性的值是一个长度为5的字符串admin。根据CVE-2016-7124我们需要修改属性数量字段。将其从1修改为一个大于1的数比如2。修改后的Payload为O:7:Welcome:2:{s:4:name;s:5:admin;}注意我们只修改了属性数量:1:-:2:并没有在后面的花括号{}里增加新的属性定义。这就是触发漏洞的关键声明了2个属性但只提供了1个属性的数据。5. 完整攻击流程演示与结果验证现在我们将理论付诸实践。假设我们的靶机地址是http://127.0.0.1:8080。第一步访问初始页面确认环境。打开浏览器访问http://127.0.0.1:8080/。页面显示源代码因为str参数为空我们确认代码与我们分析的一致。第二步构造并发送Payload。我们将上面构造的恶意序列化字符串作为URL参数str的值进行传递。需要对Payload进行一次URL编码因为其中包含特殊字符如冒号、引号、花括号等。可以直接使用浏览器的地址栏或者用curl命令。使用浏览器访问Payload已URL编码http://127.0.0.1:8080/?strO%3A7%3A%22Welcome%22%3A2%3A%7Bs%3A4%3A%22name%22%3Bs%3A5%3A%22admin%22%3B%7D使用curl命令更清晰curl http://127.0.0.1:8080/?strO:7:\Welcome\:2:{s:4:\name\;s:5:\admin\;}第三步分析响应结果。如果攻击成功我们可能会看到如下输出Have fun!Hello admin! Here is your flag: flag{this_is_your_flag_here}输出解读Have fun!来自主程序if (unserialize($str))判断成功后的回显。Hello admin! Here is your flag: ...来自__destruct()方法的成功执行。这说明我们的Payload成功反序列化。__wakeup()方法没有被调用因为如果被调用我们会先看到Welcome back, admin!并且$name会被重置导致__destruct()无法输出flag。在对象销毁时$name的值依然是我们设定的admin成功通过判断打印出flag。如果__wakeup()被正常调用我们看到的将是Welcome back, admin! Have fun!并且不会出现flag。6. 举一反三PHP反序列化的其他攻击面与防御通过这道题我们掌握了利用属性数量不一致绕过__wakeup()的技巧。但这只是PHP反序列化漏洞的冰山一角。一个完整的反序列化漏洞利用链POP Chain可能非常复杂它通过调用一系列类的魔术方法如__construct,__destruct,__wakeup,__toString,__call等最终达到执行任意代码RCE的目的。常见的危险魔术方法__destruct() 对象销毁时触发。常用于最终的攻击步骤如执行系统命令。__wakeup() 反序列化时触发。常被用来初始化资源或像本题一样被尝试绕过。__toString() 对象被当作字符串使用时触发如echo $obj。可以在此方法中触发其他敏感操作。__call() 调用对象中不存在的方法时触发。可以用于动态调用危险函数。__get()/__set() 访问不存在的属性时触发。可以用于操纵内部属性。一个简单的POP链例子假设有ClassA和ClassB。ClassA的__destruct()会调用$this-internal-action()。ClassB的__call($func, $args)方法会执行system($func)。 如果我们能通过反序列化让ClassA对象的internal属性指向一个ClassB对象那么当ClassA销毁时就会调用ClassB中不存在的action方法从而触发__call()执行system(action)。这就构成了一条从反序列化到命令执行的链路。如何防御PHP反序列化漏洞根本方法不要反序列化不可信数据。这是最有效的一条。如果业务必须使用请使用安全的替代方案如JSON。版本升级及时升级PHP版本修复已知的类似CVE-2016-7124的底层漏洞。魔术方法审查在代码审计时严格检查所有__wakeup、__destruct等魔术方法的逻辑确保它们不会执行危险操作或能被外部输入控制。使用白名单如果必须反序列化可以只允许反序列化特定的、预定义的类。PHP 7.0 的unserialize()函数提供了第二个参数$allowed_classes来实现白名单控制unserialize($data, [allowed_classes [SafeClass1, SafeClass2]])。数字签名/校验对序列化后的字符串进行签名如HMAC在反序列化前先验证其完整性和来源可靠性。回过头看这道题如果开发者想修复最简单的办法是升级PHP版本至不受CVE-2016-7124影响的版本。但更健壮的做法是重新审视__destruct中的逻辑是否必要或者对输入进行严格的类白名单过滤。7. 从CTF到实战代码审计的思维模式解完这道题我们收获的不仅仅是一个flag更是一套面对黑盒/白盒系统的初级审计思维。在实际的渗透测试或代码审计中流程是相似的信息收集尽可能收集一切信息包括前端注释、JS文件、备份文件、目录列表、报错信息等。就像我们找index.php.bak一样。定位入口点寻找所有用户可控的输入点如$_GET、$_POST、$_COOKIE、$_REQUEST、文件上传、反序列化函数unserialize、危险函数eval,assert,system等。理解业务逻辑像读故事一样阅读代码搞清楚数据从哪里来经过哪些处理最后到哪里去。画出简单的数据流图对理解复杂逻辑非常有帮助。寻找漏洞点将“用户可控输入”与“危险函数/敏感操作”进行关联。检查在这条路径上数据是否被充分过滤。反序列化就是一个典型的“用户输入直接进入危险函数”的场景。构造利用链如果漏洞点直接利用如本题的__destruct被阻断就要寻找是否存在其他可控的类属性和方法将它们像拼图一样连接起来形成一条完整的利用链POP Chain。验证与利用在测试环境构造Payload进行验证确认漏洞存在及其影响范围。这道“[极客大挑战 2019]PHP”题目完美地浓缩了步骤1、2、4、5。它告诉我们一个看似简单的属性重置操作__wakeup因为底层引擎的一个历史漏洞CVE使得整个安全机制形同虚设。这提醒我们安全是一个整体任何一环的薄弱都可能导致全盘崩溃。对于开发者而言除了关注自身业务逻辑也必须了解所依赖语言和环境的常见安全问题。对于安全研究者而言则需要这种抽丝剥茧、在限制条件下寻找突破口的思维能力。