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时,希望你能清晰地想起整个分析流程,从容地拆解它。
