1. 项目概述:一次典型的CTF反序列化漏洞实战复盘
最近在复盘去年的NewStarCTF 2023公开赛题目时,遇到了一道让我印象深刻的Web题,它把反序列化漏洞的两个经典考点——私有属性访问和不可见字符处理——巧妙地结合在了一起。这道题不仅考察了对PHP反序列化机制的底层理解,更考验了在实际模糊、受限环境下的漏洞利用技巧。很多选手卡在了看似简单的payload构造上,其实就是对这两个“陷阱”的绕过姿势不够熟练。今天我就结合这道题,把反序列化漏洞中关于私有属性和不可见字符的绕过思路、原理以及我在实战中踩过的坑,系统地梳理一遍。无论你是正在备战CTF的新手,还是想深入理解PHP反序列化安全的研究者,这篇从实战出发的深度解析,应该都能给你带来一些启发。
2. 核心漏洞原理与题目环境拆解
2.1 反序列化漏洞的通用攻击面
在深入具体题目之前,我们得先统一认知:反序列化漏洞的本质是什么?简单说,就是程序在将序列化字符串(比如O:4:"User":2:{s:4:"name";s:5:"admin";s:5:"isAdmin";b:1;})还原成内存对象的过程中,如果开发者没有对反序列化的数据源进行严格控制,攻击者就可以构造恶意的序列化字符串,在对象还原时触发某些类方法(如__wakeup(),__destruct()),进而执行任意代码或改变程序逻辑。
PHP的反序列化之所以危险,核心在于其“自动化”的特性。一旦unserialize()函数被执行,PHP引擎会自动调用对象的魔术方法,并按照序列化字符串的指示去设置对象的属性值。如果攻击者能够控制输入点,他就能“教唆”PHP引擎去创建一个他设计好的对象,执行他预设的逻辑。常见的攻击链起点往往是__wakeup()(反序列化时自动调用)或__destruct()(对象销毁时自动调用),在这些方法内部如果存在危险操作(如文件操作、命令执行、属性赋值影响后续逻辑),就可能被利用。
2.2 题目场景还原与代码审计
回到NewStarCTF 2023的这道题。题目通常会给出一段或几段PHP源代码,核心逻辑是接收一个参数(比如data),经过某些过滤后,将其进行反序列化。我根据常见的出题模式和赛后Writeup,还原其核心代码逻辑如下:
<?php highlight_file(__FILE__); error_reporting(0); class MyClass { private $flag = "flag{test}"; public $publicVar; protected $protectedVar; public function __wakeup() { if ($this->publicVar === "SECRET_KEY") { include($this->protectedVar); } } public function __destruct() { // 一些清理操作,可能包含逻辑判断 if (isset($this->publicVar) && $this->publicVar != 'admin') { echo "Access Denied!"; } } } if(isset($_GET['data'])) { $input = $_GET['data']; // 关键过滤:移除不可见字符 $input = preg_replace('/[^\x20-\x7E]/', '', $input); $obj = unserialize($input); var_dump($obj); } ?>当然,实际赛题会更隐蔽,类名、属性名、关键字符串都会做混淆,但核心结构万变不离其宗。从这段模拟代码中,我们可以清晰地看到两个考点:
- 私有属性
$flag:目标类MyClass中定义了一个私有属性$flag,其值可能就是我们要获取的最终目标(真实flag)。在反序列化时,我们需要通过某种方式读取或影响这个私有属性。 - 不可见字符过滤:在反序列化前,程序使用正则表达式
preg_replace('/[^\x20-\x7E]/', '', $input)移除了所有非打印字符(ASCII码不在0x20到0x7E之间的字符)。这直接封杀了我们使用包含空字符、换行符等特殊字符的序列化字符串进行攻击的常见路径。
题目的目标很明确:绕过过滤,成功反序列化我们精心构造的对象,触发__wakeup()中的文件包含,或者利用__destruct()的逻辑绕过,最终读取到私有属性$flag的值。
3. 核心技术点深度解析:私有属性与字符编码
3.1 绕过私有属性访问限制
在PHP中,类属性的访问权限分为 public(公有)、protected(受保护)和 private(私有)。在序列化时,这三种属性的表示方式是不同的:
- Public (
$publicVar):直接序列化为属性名,如s:9:"publicVar";。 - Protected (
$protectedVar):属性名会被加上\x00*\x00前缀,序列化后看起来像s:13:"\x00*\x00protectedVar";。这里的\x00是空字符。 - Private (
$flag):属性名会被加上\x00类名\x00前缀。对于定义在MyClass中的私有属性$flag,其序列化后的名称是s:11:"\x00MyClass\x00flag";。
这里就是第一个关键点:当我们手动构造序列化字符串去匹配一个私有属性时,我们必须严格按照这个格式来写属性名。如果你直接在payload里写s:4:"flag";,PHP在反序列化时会认为这是一个新的、名为flag的public属性,而不是去覆盖已有的私有属性$flag。这会导致对象中存在两个同名但不同访问权限的属性,从而无法触发依赖$flag值的逻辑,或者无法读取到真正的flag值。
构造私有属性payload的实操要点:
- 确定类名:你必须知道定义该私有属性的完整类名。注意,如果类被定义在命名空间里,类名需要包含命名空间(如
\MyApp\MyClass)。 - 手动拼接字符串:在文本编辑器或脚本中构造payload时,你需要将空字符和类名插入到属性名字符串中。例如,要表示
\x00MyClass\x00flag,你实际写入payload的字符串就是“\x00MyClass\x00flag”。在PHP代码里,你可以用双引号字符串直接包含这些转义字符。 - 长度计算必须精确:序列化格式
s:length:"value";中的length是字符串value的字节长度,而不是字符数。对于包含空字符的字符串,你必须计算所有字节。“\x00MyClass\x00flag”的字节长度是:1(空) + 7(“MyClass”) + 1(空) + 4(“flag”) = 13。所以正确的部分应该是s:13:“\x00MyClass\x00flag”;s:9:“hacked_flag”;。
踩坑记录:我第一次做这类题时,经常在长度计算上出错。空字符
\x00是一个字节,在计算长度时务必算进去。一个快速验证的方法是,用PHP写个小脚本先序列化一个示例对象,看看生成的字符串格式,然后依葫芦画瓢。
3.2 绕过不可见字符过滤
题目中的过滤preg_replace('/[^\x20-\x7E]/', '', $input)意图非常明显:只保留可打印的ASCII字符(空格到波浪号),剔除所有控制字符、空字符、扩展ASCII字符等。这直接命中了我们构造私有/受保护属性payload的命门——因为那些属性名里必须包含空字符(\x00)。
绕过思路的核心在于:寻找PHP序列化语法中,那些“看起来”像特殊字符,但实际编码落在可打印ASCII范围内的表示方法。
这里主要涉及PHP序列化中对字符串的两种表示方式:
- 双引号字符串:就是我们最常见的
s:5:“hello”;。这里的字符串内容“hello”就是字面量。 - Unicode转义序列(PHP 7.0+):PHP支持用
\u{XXXX}的形式表示Unicode字符,例如s:5:“\u{0068}ello”;也表示“hello”。关键来了:在序列化字符串的值(value)部分,\u转义是会被解析的。但是,在序列化字符串的结构部分(如表示类型的O、i、s,以及表示长度的数字),PHP是不识别\u转义的。
然而,我们的突破口不在这里。更经典的绕过方式是利用PHP序列化中数字类型和字符串类型的模糊性,以及十六进制字符串表示法。
PHP序列化的十六进制字符串表示法: 除了s:length:“value”;,PHP还支持S:length:“value”;(注意是大写的S)。当使用大写的S时,字符串value部分可以包含十六进制转义序列\xXX,并且这些转义序列会在反序列化时被转换回对应的字节。
例如:
s:2:“\x00”;表示一个长度为2的字符串,内容是字符\、x、0、0。S:1:“\x00”;表示一个长度为1的字符串,内容是单个空字符(ASCII 0)。
我们的绕过武器就是大写的S。过滤正则[^\x20-\x7E]是针对原始输入字符串的字节值进行判断的。在字符串S:1:“\x00”;中,实际存储的字节是S、:、1、:、“、\、x、0、0、“、;。所有这些字节的ASCII值(S=83,\=92,x=120,0=48)都在可打印范围(0x20-0x7E)内!过滤函数会原样放过这个字符串。但当unserialize()处理到大写S时,它会识别这种格式,并将“\x00”解析为一个真正的空字符字节。
因此,绕过方案就是将私有属性名中必须包含的空字符(\x00),用大写S格式的十六进制表示法来编码。原本的s:13:“\x00MyClass\x00flag”;需要写成S:13:“\x00MyClass\x00flag”;。注意,这里外层的格式标识符变成了S,但里面表示空字符的\x00也需要用十六进制写法。实际上,整个属性名字符串“\x00MyClass\x00flag”作为值,其本身就包含了字面量的\x00,当使用S格式时,这些\x00会被正确解析。
重要提示:你不能写成
S:13:“\x00MyClass\x00flag”;就完了。你必须确保你传递给unserialize()的整个字符串里,代表空字符的就是\x00这四个字符(反斜杠、小写x、数字0、数字0),而不是一个真正的空字符字节。在URL传参或文本编辑时,你需要对反斜杠进行转义(通常需要双反斜杠\\x00),具体取决于上下文。
4. 完整漏洞利用链构造与Payload生成
理解了原理,我们来一步步构造最终能打通这道题的EXP。
4.1 第一步:分析利用链与确定目标
回顾模拟代码,有两个可能的入口:
__wakeup():如果$publicVar === "SECRET_KEY",则包含$protectedVar指向的文件。这可以用于文件包含读取源码或flag。但$protectedVar是受保护属性,其序列化名包含\x00*\x00。__destruct():输出“Access Denied”的逻辑可能只是干扰,或者结合其他类形成POP链。但题目更直接的目的是读取私有属性$flag。
通常,CTF中这类题目的最终目标是让反序列化后的对象,在某个时刻(比如被var_dump、被其他方法调用)能够输出或泄露私有属性$flag的值。有时,__wakeup()里的文件包含就是为了包含一个打印$this->flag的脚本。我们假设目标是触发__wakeup()进行文件包含。
4.2 第二步:构造恶意序列化字符串
我们需要创建一个MyClass对象,并设置其属性:
$publicVar="SECRET_KEY"(字符串,严格匹配)$protectedVar="php://filter/convert.base64-encode/resource=flag.php"(一个用于读取文件内容的PHP包装器)- (可选)为了覆盖原有的私有
$flag,我们也设置它,但这可能不是触发点所必须。
首先,构造一个未经处理的、标准的序列化字符串(用于理解结构):
<?php class MyClass { private $flag = "flag{test}"; public $publicVar; protected $protectedVar; } $obj = new MyClass(); $obj->publicVar = "SECRET_KEY"; $obj->protectedVar = "php://filter/convert.base64-encode/resource=flag.php"; // 尝试覆盖私有属性,注意需要使用Reflection或定义在相同作用域 // 这里我们先序列化看看结构 echo serialize($obj); ?>上述代码在相同作用域下运行,输出可能类似于(类名长度不同):O:7:"MyClass":3:{s:11:"\x00MyClass\x00flag";s:9:"flag{test}";s:9:"publicVar";s:10:"SECRET_KEY";s:16:"\x00*\x00protectedVar";s:52:"php://filter/convert.base64-encode/resource=flag.php";}
这里我们看到私有属性和受保护属性的原始序列化格式。
4.3 第三步:应用绕过技术生成最终Payload
现在,我们需要将上述字符串中涉及空字符的部分,进行大写S格式转换,以绕过不可见字符过滤。
原始部分:
- 私有属性名:
s:11:"\x00MyClass\x00flag" - 受保护属性名:
s:16:"\x00*\x00protectedVar"
转换后:
- 私有属性名:
S:11:"\x00MyClass\x00flag"(注意,这里的\x00是四个字符) - 受保护属性名:
S:16:"\x00*\x00protectedVar"
构造最终Payload: 我们需要手动拼接这个字符串。确保整个字符串中,所有空字符都以\x00这四个字符的形式出现。
O:7:"MyClass":3:{S:11:"\x00MyClass\x00flag";s:9:"hacked!!!";s:9:"publicVar";s:10:"SECRET_KEY";S:16:"\x00*\x00protectedVar";s:52:"php://filter/convert.base64-encode/resource=flag.php";}解释:
O:7:"MyClass":表示一个对象,类名MyClass长度7。:3::表示该对象有3个属性。{...}:内部是属性列表。S:11:"\x00MyClass\x00flag";s:9:"hacked!!!";:第一个属性。属性名用大写S格式,值为一个普通字符串“hacked!!!”(这里我们尝试覆盖原私有属性值)。注意,属性名字符串“\x00MyClass\x00flag”的长度是11个字节(1空+7+1空+4),计算正确。s:9:"publicVar";s:10:"SECRET_KEY";:第二个属性。公有属性,无需特殊处理。S:16:"\x00*\x00protectedVar";s:52:"php://filter/convert.base64-encode/resource=flag.php";:第三个属性。属性名用大写S格式,值是我们想要包含的文件路径。
4.4 第四步:Payload的URL编码与传输
由于我们要通过GET参数data传递,需要对一些特殊字符进行URL编码,尤其是花括号{}、双引号"和反斜杠\。
{编码为%7B}编码为%7D"编码为%22\编码为%5C(这是关键!我们必须确保反斜杠作为字符传输)
所以,我们的Payload需要进一步处理。将所有的\x00替换为%5Cx00,将双引号和花括号编码。
最终提交的URL可能类似于:http://target.com/vuln.php?data=O:7:%22MyClass%22:3:%7BS:11:%22%5Cx00MyClass%5Cx00flag%22;s:9:%22hacked!!!%22;s:9:%22publicVar%22;s:10:%22SECRET_KEY%22;S:16:%22%5Cx00*%5Cx00protectedVar%22;s:52:%22php://filter/convert.base64-encode/resource=flag.php%22;%7D
当服务器接收到这个data参数后,$_GET[‘data’]会先进行URL解码,还原出包含\x00字面量的字符串。然后经过preg_replace过滤,由于\、x、0、0都是可打印字符,所以字符串完好无损。最后unserialize()函数会识别S:格式,将\x00解析为真正的空字节,从而成功构造出包含受保护属性protectedVar的对象,并触发__wakeup()中的文件包含漏洞。
5. 实战调试与常见问题排查
在实际操作中,即使Payload构造正确,也可能因为各种原因失败。以下是我在实战和教学中总结的排查清单。
5.1 问题1:Payload提交后毫无反应或报错
- 检查点1:URL编码是否正确。最容易出错的就是反斜杠
\的编码。如果你在Burp Suite或浏览器地址栏直接粘贴,确保\被正确编码为%5C。许多在线URL编码工具可能默认不会编码反斜杠,需要手动处理或选择“编码所有特殊字符”选项。 - 检查点2:字符串长度计算。这是最经典的错误。
S:11:“\x00MyClass\x00flag”中的长度11必须精确等于后面字符串的字节数。\x00在作为字面量时是4个字符,但在S格式解析后,它代表1个字节。然而,长度字段指的是S格式下,解析前字符串的字符数吗?这里容易混淆。实际上,对于S格式,length指的是解析后字符串的字节数。也就是说,S:11:“\x00MyClass\x00flag”表示解析后的字符串是11个字节。而字面量“\x00MyClass\x00flag”有13个字符(\,x,0,0,M,y,C,l,a,s,s,\,x,0,0,f,l,a,g)。等等,这里我故意说错了,为了引出关键点。- 正确理解:在
S:“value”中,value是字符串字面量。PHP会先解析这个字面量中的\xXX转义序列,将其转换为对应的字节,然后得到一个字节序列。length必须是这个最终字节序列的长度。 - 对于
“\x00MyClass\x00flag”:- 字面量包含:
\x00(4字符),M,y,C,l,a,s,s(7字符),\x00(4字符),f,l,a,g(4字符)。总共4+7+4+4=19个字符。 - 但PHP解析转义后:
\x00-> 1字节(0x00),MyClass-> 7字节,\x00-> 1字节(0x00),flag-> 4字节。总共1+7+1+4=13字节。
- 字面量包含:
- 所以,正确的格式应该是
S:13:“\x00MyClass\x00flag”;。我之前示例中的S:11是错误的。你必须以解析后的字节数为准。这也是最容易出错的地方。最佳实践是先用PHP脚本生成一个包含目标属性的标准对象,查看其序列化字符串中该属性名的原始长度(例如s:13:“...”),然后将开头的s改为S即可,长度值保持不变。
- 正确理解:在
5.2 问题2:触发了文件包含但读不到flag
- 检查点1:文件路径是否正确。
php://filter包装器是读取服务器本地文件的。resource=flag.php假设flag在当前目录的flag.php文件中。有时flag可能在根目录/flag、/flag.txt或数据库里。需要结合题目描述或信息收集尝试。 - 检查点2:Base64解码。使用
convert.base64-encode过滤器后,包含的文件内容会以Base64格式输出。你需要对返回的结果进行Base64解码才能看到明文。 - 检查点3:代码执行与属性打印。如果
__wakeup()里是include($this->protectedVar);,而你包含的是一个纯文本flag文件,可能会成功。但如果包含的是一个PHP文件,它会被执行。如果这个PHP文件里只是定义了$flag变量而没有输出,那么你仍然看不到。这时,你的Payload可能需要让$protectedVar包含一个你自己写的、能打印对象属性的Webshell,或者利用PHP伪协议的其他过滤器直接读取源码:php://filter/read=convert.base64-encode/resource=./index.php。
5.3 问题3:私有属性覆盖成功但未触发预期逻辑
- 检查点:作用域问题。私有属性只能在定义该属性的类内部访问。即使你通过序列化强行设置了一个同名私有属性,如果后续读取该属性的代码不在同一个类定义上下文中(例如,在另一个类的方法里,或者直接在全局空间
var_dump一个私有属性),PHP会因为访问权限而无法读取你设置的值,或者会触发PHP警告。CTF题目通常会在类内部提供一个“出口”,比如一个getFlag()方法会返回$this->flag。你需要确保你的利用链最终能调用到这样的方法。
5.4 调试技巧与工具
- 本地模拟:在本地PHP环境中搭建一个类似的目标代码。用你的Payload进行测试,打开
error_reporting(E_ALL);和ini_set(‘display_errors’, ‘on’);,查看所有警告和错误信息,这是最直接的调试方式。 - 分步验证:先构造一个最简单的Payload,只包含一个公有属性,测试反序列化是否基本功能正常。然后逐步添加受保护属性(用
S格式),测试过滤是否被绕过。最后再处理私有属性。 - 使用序列化工具:编写小的PHP脚本帮助你生成和修改序列化字符串。比如,先正常序列化一个对象,得到标准字符串,然后用字符串替换函数将其中的
s:...:“\x00替换为S:...:“\x00,并确保长度正确。 - Burp Suite 的 Hackvertor 扩展:这是一个强大的编码/解码工具。你可以将你的Payload粘贴进去,使用
URL编码,它会自动处理特殊字符。对于\x00这种,你可能需要先确保它在Payload里是字面量。
6. 防御思路与安全编程建议
从这道题目反推,作为开发者,如何避免此类反序列化漏洞呢?
- 根本方法:避免反序列化不可信数据。这是最彻底的安全措施。如果业务上必须使用序列化,考虑使用JSON等更安全的格式进行数据交换。
- 严格输入验证与白名单:如果必须使用
unserialize(),应对输入数据进行严格校验。但注意,仅过滤不可见字符是远远不够的(正如本题所演示的)。更可靠的方法是使用白名单机制,只允许反序列化预期的、有限的类。在PHP中,可以通过unserialize()的第二个参数[‘allowed_classes’ => [‘MySafeClass1’, ‘MySafeClass2’]]来实现。这是PHP 7.0+引入的重要安全特性。 - 魔术方法的安全实现:在
__wakeup()和__destruct()等魔术方法中,避免执行敏感操作,或者在执行前进行严格的权限和参数检查。不要相信反序列化得到的对象属性值。 - 使用签名或加密:对序列化后的字符串进行签名(如HMAC)或加密,确保其在传输过程中未被篡改。在反序列化前先验证签名或解密。
- 代码审计时重点关注:在审计代码时,凡是看到
unserialize()函数,都要将其视为一个潜在的高危点,仔细审查其参数来源、过滤方式以及相关类的魔术方法实现。
这道NewStarCTF 2023的题目,虽然融合了两个知识点,但其核心仍然是考察对PHP序列化内部机制的熟悉程度。私有属性的序列化格式、大写S对十六进制字符的解析,这些都不是“偏门”知识,而是PHP手册中有明确说明的内容。在安全研究中,对底层细节的掌握程度,往往决定了你在面对各种过滤和限制时,能否找到那条唯一的、正确的绕过路径。多动手实验,多阅读官方文档,比死记硬背Payload要有效得多。