PHP反序列化漏洞实战:从CVE-2016-7124绕过__wakeup到CTF解题
1. 项目概述:从一道CTF题看PHP反序列化的攻防博弈
最近在带新人入门Web安全,发现很多朋友对PHP反序列化漏洞的理解还停留在“知道有这么回事”的层面,一到实战就无从下手。正好,攻防世界(CTFHub)里那道经典的 unserialize3 题目,完美地浓缩了这类漏洞的一个关键考点——如何绕过 __wakeup 魔术方法。这不仅仅是解一道题拿个flag那么简单,它背后折射的是对PHP对象生命周期和序列化机制的深度理解。今天,我就以这道题为蓝本,手把手带你拆解整个漏洞利用过程,从原理分析、代码审计到最终Payload构造,让你不仅知其然,更知其所以然。无论你是刚接触安全的新手,还是想巩固反序列化知识的老兵,这篇实战笔记都能给你带来直接的收获。我们最终的目标很明确:绕过 __wakeup 的“防御”,拿到藏在服务器里的flag。
2. 漏洞原理深度解析:序列化、反序列化与魔术方法
要绕过 __wakeup ,首先得彻底明白它在整个流程中扮演什么角色。这得从PHP序列化(serialize)和反序列化(unserialize)这两个基础操作说起。
2.1 序列化与反序列化:数据的“打包”与“解包”
你可以把PHP的序列化想象成给一个复杂的、活生生的对象“拍一张快照”。这个对象可能有各种属性(数据),还定义了一系列方法(行为)。 serialize() 函数的作用,就是把这个对象当前的状态(主要是属性值)转换成一个字符串格式的“字节流”。这个字符串包含了重建该对象所需的最少信息,比如对象的类名、属性名和属性值。这样做的好处是方便存储(比如存到数据库或文件里)或传输(比如通过网络发送)。
而 unserialize() 函数则相反,它是个“复活”过程。它读取这个序列化字符串,并根据其中的信息,在内存中重新创建出一个和原来状态几乎一模一样的对象实例。注意,这里说的是“几乎”,因为序列化主要保存的是对象的数据(属性),而不是其逻辑(方法的代码)。方法的逻辑是由类的定义决定的。
2.2 魔术方法:对象生命周期的“事件监听器”
PHP提供了一系列以双下划线 __ 开头的魔术方法(Magic Methods),它们会在对象的特定生命周期节点被自动调用。在反序列化漏洞的利用中,以下几个尤为关键:
__construct(): 构造函数,在对象被创建(new)时调用。__destruct(): 析构函数,在对象被销毁(如脚本执行结束、被unset)时调用。 这是反序列化漏洞中最常见的“攻击入口”之一 ,因为反序列化出来的对象在脚本结束时总会触发析构。__wakeup(): 在对象被unserialize()反序列化之后、自动调用之前 执行。它的设计初衷是用于反序列化后,重新初始化一些可能丢失的资源(比如数据库连接、文件句柄)。
这里就是 unserialize3 题目的核心考点: __wakeup 方法的存在,常常被开发者用作一种“安全措施” 。他们可能会在 __wakeup 里重置对象状态、进行一些安全检查,甚至直接销毁对象或退出流程,从而阻断我们利用 __destruct 或 __toString 等后续魔术方法执行恶意代码的企图。
2.3 __wakeup 绕过原理(CVE-2016-7124)
那么, __wakeup 就真的无法逾越吗?并非如此。PHP历史上存在一个著名的特性,在特定版本下可以被利用来绕过 __wakeup 的调用。这个特性与序列化字符串中表示对象属性数量的值有关。
一个标准的序列化字符串格式如下: O:<类名长度>:"<类名>":<属性数量>:{<属性序列化>...}
例如,一个 TestClass 类,有一个属性 $a ,值为1,序列化后可能是: O:10:"TestClass":1:{s:1:"a";i:1;}
这里的 1 就表示这个对象有1个属性。
绕过关键点 :在PHP 5.6.25之前和PHP 7.0.10之前的版本中,如果我们在序列化字符串中, 将对象属性数量的值,修改为比实际属性数量更大的数字 ,那么在反序列化时, __wakeup 方法将 不会被调用 ,但对象依然会被成功反序列化,并且后续的 __destruct 方法会正常执行。
这就是我们绕过 __wakeup 的武器。对于上面的例子,我们将字符串改为: O:10:"TestClass":2:{s:1:"a";i:1;} (注意,属性数量从 1 改成了 2 ,但后面属性的定义并没有增加)
当这个字符串被 unserialize() 处理时,PHP会尝试读取2个属性,但实际数据只定义了1个,这会导致解析异常。然而,在受影响版本的PHP中,这种异常恰好阻止了 __wakeup 的执行,却不妨碍对象被创建以及最终 __destruct 的触发。
注意 :这个绕过方法有严格的版本限制。在解题或实战中,首要步骤就是判断目标PHP版本是否在受影响范围内。攻防世界的
unserialize3题目环境通常就是搭建在存在此漏洞的PHP版本上,为我们创造了条件。
3. 靶场环境分析与代码审计思路
明确了原理,我们就要开始实战了。面对 unserialize3 这样的题目,标准的解题流程是:获取源码 -> 代码审计 -> 寻找漏洞点 -> 构造Payload。
3.1 获取题目源码
CTF题目,尤其是Web题,经常通过留备份文件、 .git 泄露、注释提示等方式提供源码。对于 unserialize3 ,常见的方法是访问 index.php 的备份文件,如 index.php.bak 、 index.php~ ,或者尝试 www.zip 、 source.zip 等压缩包。有时候直接查看网页源代码也能找到线索。这里我们假设通过常规扫描,获取到了核心的PHP源码文件。
3.2 核心漏洞代码审计
假设我们拿到了如下简化后的源码( class.php ):
<?php
class xctf{
public $flag = '111';
public function __wakeup(){
exit('bad requests');
}
public function __destruct(){
// 我们假设,在理想情况下,这里会输出或操作$flag
// 例如:echo $this->flag;
// 但题目可能把真正的flag放在服务器文件里,这里只是示意
// 真正的目标可能是触发这里,从而读取flag文件
if (isset($this->flag)) {
// 一些关键操作...
}
}
}
?>
以及一个入口文件( index.php ):
<?php
require_once('class.php');
$str = $_GET['code'];
if (isset($str)) {
$data = unserialize($str);
echo "Welcome!";
} else {
highlight_file(__FILE__);
}
?>
审计过程:
- 定位反序列化入口 :
index.php中,通过$_GET['code']获取参数,并直接传递给unserialize()函数。这是一个明显的、用户输入可控的反序列化点。 - 分析可利用的类 :代码中只定义了一个类
xctf。 - 寻找魔术方法 :
__wakeup(): 该方法直接执行exit('bad requests')。这意味着,只要__wakeup被调用,程序会立即终止,打印“bad requests”,我们后续的任何企图都会落空。 这是我们必须绕过的障碍。__destruct(): 析构函数。这里虽然看起来只是判断$flag是否存在,但在真实的题目场景中,__destruct内部可能包含文件读取、命令执行等关键代码,或者是触发其他链式调用的起点。我们的目标就是让程序执行到这里。
- 分析属性 :类有一个公共属性
$flag。在反序列化时,我们可以通过序列化字符串控制这个属性的值。
解题思路链 :我们需要向 code 参数传递一个精心构造的序列化字符串。这个字符串要能:
- 成功反序列化出一个
xctf对象。 - 绕过
__wakeup方法 ,防止程序退出。 - 让对象正常走到生命周期结束,从而自动调用
__destruct方法,执行其中的关键代码(在真实题目中,这可能是获取flag的关键)。
3.3 确定利用链与攻击面
对于这道题,利用链非常直接: 可控输入($_GET[‘code’]) -> unserialize() -> 绕过__wakeup -> 对象销毁 -> 触发__destruct 。
攻击面就在于我们能否控制序列化字符串,使其在反序列化时触发漏洞。结合第2.3节的知识,我们确定使用 修改属性数量 的方法来尝试绕过 __wakeup 。
4. 手把手构造绕过Payload
理论结合实践,现在我们一步步构造出能绕过 __wakeup 的Payload。
4.1 步骤一:创建正常对象并序列化
我们先写一个本地脚本,模拟创建对象并生成标准的序列化字符串。
<?php
class xctf{
public $flag = '111'; // 初始值不重要,我们可以覆盖它
}
$obj = new xctf();
// 我们可以修改$flag的值,比如指向一个假想的flag文件
// $obj->flag = '/path/to/real/flag';
echo serialize($obj);
?>
运行这段代码,会得到类似以下的输出: O:4:"xctf":1:{s:4:"flag";s:3:"111";}
字符串解析 :
O: 表示对象(Object)。4: 类名xctf的长度。"xctf": 类名。1: 对象属性的数量(本例中只有$flag一个属性)。{s:4:"flag";s:3:"111";}: 这是属性的序列化。s:4:"flag"表示一个长度为4的字符串属性名flag;s:3:"111"表示一个长度为3的字符串属性值111。
4.2 步骤二:应用 __wakeup 绕过技巧
根据CVE-2016-7124,我们需要将属性数量 1 修改为一个大于实际属性数量的数字,比如 2 或 100 。同时,为了增加利用成功率,我们可能还需要修改 $flag 属性的值。在真实题目中, __destruct 方法里可能会用 $this->flag 去做文件读取(例如 file_get_contents($this->flag) ),那么我们就需要将 $flag 的值设置为服务器上存储flag的真实路径(这通常需要结合其他信息泄露或路径遍历漏洞来获取,有时题目会直接给提示)。
假设我们通过信息收集,知道flag文件在 /flag 。那么,我们先构造一个属性值被修改的序列化字符串: O:4:"xctf":1:{s:4:"flag";s:5:"/flag";}
然后,应用绕过技巧,将属性数量 1 改为 2 : O:4:"xctf":2:{s:4:"flag";s:5:"/flag";}
这就是我们的核心Payload。
4.3 步骤三:进行URL编码与传输
由于Payload需要通过GET请求的 code 参数传递,而序列化字符串中包含花括号 {} 、引号 " 等特殊字符,在URL中可能会被错误解析或截断。因此,我们需要对其进行URL编码。
可以使用在线工具或编程语言函数(如PHP的 urlencode )进行编码。上述Payload编码后大致如下: O%3A4%3A%22xctf%22%3A2%3A%7Bs%3A4%3A%22flag%22%3Bs%3A5%3A%22%2Fflag%22%3B%7D
4.4 步骤四:发起攻击并获取结果
在浏览器中访问靶场地址,并附上我们的Payload: http://靶场地址/?code=O%3A4%3A%22xctf%22%3A2%3A%7Bs%3A4%3A%22flag%22%3Bs%3A5%3A%22%2Fflag%22%3B%7D
如果一切顺利:
- 服务器接收到
code参数。 unserialize()函数开始解析我们提供的字符串。- 由于属性数量(2)大于实际定义的数量(1),在存在漏洞的PHP版本中,
__wakeup方法被跳过。 - 一个
xctf对象被成功创建,其$flag属性值为/flag。 - 脚本执行到末尾,该对象被销毁,触发
__destruct()方法。 - 在
__destruct()方法中(根据题目实际代码),可能会读取/flag文件的内容并将其输出到页面,或者作为响应的一部分返回。这样,flag就出现在我们眼前了。
5. 实战中的疑难排查与技巧进阶
在实际操作中,事情往往不会一帆风顺。下面分享几个我踩过的坑和对应的排查思路。
5.1 常见问题排查表
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
| 页面返回“bad requests” | __wakeup 方法未被成功绕过 |
1. 确认PHP版本 :这是最常见的原因。靶场环境可能使用了已修复该漏洞的PHP版本(>=5.6.25或>=7.0.10)。尝试寻找其他入口或利用链。 2. 检查Payload格式 :仔细核对序列化字符串的语法,确保花括号、分号、引号配对正确,属性名长度与实际字符串长度严格一致。一个字符的错误都会导致解析失败,从而可能走正常流程触发 __wakeup 。 |
| 页面空白或报错(非“bad requests”) | 反序列化过程出错 | 1. URL编码问题 :确保Payload已正确进行URL编码。可以先用 urldecode 函数验证一下编码后的字符串是否与原始Payload一致。 2. 属性名修饰符 :如果类属性是 private 或 protected ,其序列化后的格式不同。私有属性会在属性名前加上 %00类名%00 ,保护属性前加 %00*%00 。需要根据源码中的属性定义来调整Payload。本题中 $flag 是 public ,所以最简单。 3. 开启错误显示 :如果可能,在本地测试时开启 display_errors ,查看具体的PHP错误或警告信息。 |
| 返回“Welcome!”但无flag | __destruct 逻辑未按预期执行 |
1. 分析 __destruct 真实逻辑 :我们之前审计的代码是简化的。真实题目的 __destruct 可能不是直接输出 $flag ,而是进行其他操作,比如调用其他对象的方法(POP链的起点)。需要更仔细地审计全部源码。 2. $flag 属性值不对 :可能flag文件的路径不是 /flag ,而是 ./flag 、 flag.txt 或位于其他目录。需要结合题目描述、注释、其他接口进行路径猜测或遍历。 3. 输出被过滤或重定向 : __destruct 中的输出可能被 ob_start 缓存,或者被后续代码覆盖。可以尝试将 $flag 的值设置为一个Web可访问的URL,让服务器发起请求(SSRF思路),或者写入一个文件。 |
| Payload被WAF拦截 | 存在安全防护 | 1. 混淆Payload :对序列化字符串进行多次编码(如Base64+URL编码)、添加无关字符(利用PHP反序列化特性,字符串长度后的冒号后可以有空格)、拆分参数等。 2. 更改请求方式 :尝试将Payload放在POST Body中传递。 3. 寻找其他入口点 :也许 code 参数不是唯一的反序列化点。 |
5.2 高级技巧与扩展思考
- 利用
__destruct与__toString构建POP链 :在更复杂的场景中,一个类的__destruct可能会调用另一个对象的某个方法,如果那个方法又触发了__toString或其他魔术方法,就可能形成一条“属性导向编程(POP)”链。审计时需要全局搜索所有类的魔术方法,寻找可以连接起来的“跳板”。 - 字符串逃逸与字符数量利用 :这是另一种高级利用技巧。当序列化字符串在反序列化前经过了某些过滤函数(如
str_replace)时,可能会因为字符数量的变化,导致序列化字符串的边界被“撑开”或“压缩”,从而使得后续部分被解析为新的属性,实现对象注入。这需要对序列化格式有极其精准的把握。 - Phar反序列化 :一种更隐蔽的反序列化入口。如果网站存在文件上传功能,且可以上传Phar文件(或能通过修改文件头将其他文件伪装成Phar),并且有文件操作函数(如
file_get_contents、include等)的参数可控,就可能触发Phar包中元数据(metadata)的反序列化。这是一种将反序列化与文件上传结合的综合利用方式。 - 关注PHP内置类 :一些PHP内置类(如
SimpleXMLElement、SoapClient、ArrayObject等)的魔术方法在某些情况下可以被利用来发起SSRF、发起请求或进行其他操作。在找不到自定义类利用链时,可以研究一下内置类。
5.3 防御措施建议(开发者视角)
既然我们作为攻击者研究了利用,那么从防御者角度,该如何避免此类漏洞呢?
- 首要原则:不要反序列化不可信数据 。这是最根本的。如果业务必须使用序列化,考虑使用JSON等更安全的格式。
- 升级PHP版本 :及时升级到已修复CVE-2016-7124的PHP版本。
- 使用安全的白名单机制 :如果必须使用
unserialize,可以配合allowed_classes参数(PHP 7.0+),将其设置为false或一个明确的可信类名数组,只允许反序列化基础的、无害的类。 - 对象签名与校验 :在序列化数据中加入签名(HMAC),在反序列化前先验证数据的完整性和来源合法性。
- 避免在魔术方法中放入关键逻辑 :尤其是
__wakeup、__destruct、__toString等,尽量不要在这些方法中执行文件操作、数据库查询、命令执行等敏感操作。如果必须,要严格检查对象属性的来源和有效性。 - 代码审计与漏洞扫描 :定期对代码进行安全审计,使用自动化工具扫描潜在的反序列化漏洞点。
回过头看 unserialize3 这道题,它像是一个精致的教学模型,把PHP反序列化漏洞中最经典的一个绕过场景单独提炼出来让我们练习。通过它,我们不仅学会了一个具体的绕过技巧(CVE-2016-7124),更重要的是建立起一套面对此类漏洞的通用分析方法:找入口、审代码、寻链子、构载荷、试绕过。在实际的渗透测试或CTF比赛中,情况会复杂得多,可能需要综合运用信息收集、代码审计、链式构造等多种能力。但万变不离其宗,对语言特性(这里是PHP魔术方法和序列化协议)的深刻理解,永远是解开这些谜题最可靠的钥匙。下次当你再遇到 unserialize 时,希望你能清晰地想起整个分析流程,从容地拆解它。
更多推荐
所有评论(0)