PHP反序列化漏洞实战:从CVE-2016-7124绕过__wakeup到代码审计
2026/8/3 2:54:41 网站建设 项目流程

1. 从“极客大挑战”到代码审计:一次完整的PHP反序列化漏洞实战复盘

最近在整理CTF(Capture 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; -->

这段注释就是我们的“第一张图纸”。它透露了几个关键信息:

  1. 题目使用了PHP。
  2. 代码中有一个名为Welcome的类。
  3. 程序通过$_GET['name']获取一个参数,并将其赋值给$user->name属性。
  4. 最后回显了这个属性。

这看起来人畜无害,就是一个简单的对象创建和属性赋值。但这里有一个至关重要的细节:$_GET['name']是用户完全可控的输入。在安全领域,“用户可控输入”永远是我们需要重点关注的攻击面。它就像一扇门,我们需要检查这扇门是否上锁,以及锁是否牢固。

仅仅有前端注释还不够,我们通常需要看到服务器端的完整源代码。在CTF中,常见的考点就是源代码泄露。我们会尝试一些常见的源码备份文件名,比如index.php.bakindex.php.swp(vim备份文件)、www.ziprobots.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的类,它有三个成员:

  1. 一个公共属性$name
  2. 一个构造方法__construct():当使用new Welcome()创建对象时自动调用,将$name初始化为'Guest'
  3. 一个魔术方法__wakeup():这是本次漏洞利用的第一个关键点,也是最大的障碍。当unserialize()函数反序列化一个字符串并重建对象时,如果该对象的类中定义了__wakeup()方法,那么该方法会在反序列化完成后、对象被使用之前自动调用。在这里,__wakeup()做了两件事:打印欢迎信息,然后强行将$name属性重置为'Guest'。这意味着,无论我们通过反序列化传入的$name是什么,在__destruct()执行前,它都会被改回'Guest'
  4. 一个魔术方法__destruct():这是本次漏洞利用的最终目标。当对象被销毁时(例如脚本执行结束、unset()被调用),此方法自动执行。它判断:如果当前对象的$name属性等于字符串'admin',就输出全局变量$flag

主程序逻辑:

  1. $str = $_GET['str'] ?? '';:从URL的str参数获取用户输入,这是我们的攻击入口点
  2. if (empty($str)) { ... }:如果str参数为空,就使用show_source(__FILE__)高亮显示当前文件源代码。这就是我们第一步能看到代码的原因。
  3. 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/?str=O%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/?str=O:7:\"Welcome\":2:{s:4:\"name\";s:5:\"admin\";}"

第三步:分析响应结果。如果攻击成功,我们可能会看到如下输出:

Have fun!Hello admin! Here is your flag: flag{this_is_your_flag_here}

输出解读:

  1. Have fun!:来自主程序if (unserialize($str))判断成功后的回显。
  2. 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链例子:假设有ClassAClassBClassA__destruct()会调用$this->internal->action()ClassB__call($func, $args)方法会执行system($func)。 如果我们能通过反序列化,让ClassA对象的internal属性指向一个ClassB对象,那么当ClassA销毁时,就会调用ClassB中不存在的action方法,从而触发__call(),执行system('action')。这就构成了一条从反序列化到命令执行的链路。

如何防御PHP反序列化漏洞?

  1. 根本方法:不要反序列化不可信数据。这是最有效的一条。如果业务必须使用,请使用安全的替代方案,如JSON。
  2. 版本升级:及时升级PHP版本,修复已知的类似CVE-2016-7124的底层漏洞。
  3. 魔术方法审查:在代码审计时,严格检查所有__wakeup__destruct等魔术方法的逻辑,确保它们不会执行危险操作或能被外部输入控制。
  4. 使用白名单:如果必须反序列化,可以只允许反序列化特定的、预定义的类。PHP 7.0+ 的unserialize()函数提供了第二个参数$allowed_classes来实现白名单控制:unserialize($data, ['allowed_classes' => ['SafeClass1', 'SafeClass2']])
  5. 数字签名/校验:对序列化后的字符串进行签名(如HMAC),在反序列化前先验证其完整性和来源可靠性。

回过头看这道题,如果开发者想修复,最简单的办法是升级PHP版本至不受CVE-2016-7124影响的版本。但更健壮的做法是,重新审视__destruct中的逻辑是否必要,或者对输入进行严格的类白名单过滤。

7. 从CTF到实战:代码审计的思维模式

解完这道题,我们收获的不仅仅是一个flag,更是一套面对黑盒/白盒系统的初级审计思维。在实际的渗透测试或代码审计中,流程是相似的:

  1. 信息收集:尽可能收集一切信息,包括前端注释、JS文件、备份文件、目录列表、报错信息等。就像我们找index.php.bak一样。
  2. 定位入口点:寻找所有用户可控的输入点,如$_GET$_POST$_COOKIE$_REQUEST、文件上传、反序列化函数(unserialize)、危险函数(eval,assert,system等)。
  3. 理解业务逻辑:像读故事一样阅读代码,搞清楚数据从哪里来,经过哪些处理,最后到哪里去。画出简单的数据流图对理解复杂逻辑非常有帮助。
  4. 寻找漏洞点:将“用户可控输入”与“危险函数/敏感操作”进行关联。检查在这条路径上,数据是否被充分过滤。反序列化就是一个典型的“用户输入直接进入危险函数”的场景。
  5. 构造利用链:如果漏洞点直接利用(如本题的__destruct)被阻断,就要寻找是否存在其他可控的类属性和方法,将它们像拼图一样连接起来,形成一条完整的利用链(POP Chain)。
  6. 验证与利用:在测试环境构造Payload进行验证,确认漏洞存在及其影响范围。

这道“[极客大挑战 2019]PHP”题目,完美地浓缩了步骤1、2、4、5。它告诉我们,一个看似简单的属性重置操作(__wakeup),因为底层引擎的一个历史漏洞(CVE),使得整个安全机制形同虚设。这提醒我们,安全是一个整体,任何一环的薄弱都可能导致全盘崩溃。对于开发者而言,除了关注自身业务逻辑,也必须了解所依赖语言和环境的常见安全问题。对于安全研究者而言,则需要这种抽丝剥茧、在限制条件下寻找突破口的思维能力。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询