
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.0PHP支持用\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值S83,\92,x120,048都在可打印范围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对象并设置其属性$publicVarSECRET_KEY(字符串严格匹配)$protectedVarphp://filter/convert.base64-encode/resourceflag.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/resourceflag.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/resourceflag.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/resourceflag.php;}解释O:7:MyClass表示一个对象类名MyClass长度7。:3:表示该对象有3个属性。{...}内部是属性列表。S:11:\x00MyClass\x00flag;s:9:hacked!!!;第一个属性。属性名用大写S格式值为一个普通字符串“hacked!!!”这里我们尝试覆盖原私有属性值。注意属性名字符串“\x00MyClass\x00flag”的长度是11个字节1空71空4计算正确。s:9:publicVar;s:10:SECRET_KEY;第二个属性。公有属性无需特殊处理。S:16:\x00*\x00protectedVar;s:52:php://filter/convert.base64-encode/resourceflag.php;第三个属性。属性名用大写S格式值是我们想要包含的文件路径。4.4 第四步Payload的URL编码与传输由于我们要通过GET参数data传递需要对一些特殊字符进行URL编码尤其是花括号{}、双引号和反斜杠\。{编码为%7B}编码为%7D编码为%22\编码为%5C这是关键我们必须确保反斜杠作为字符传输所以我们的Payload需要进一步处理。将所有的\x00替换为%5Cx00将双引号和花括号编码。最终提交的URL可能类似于http://target.com/vuln.php?dataO: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/resourceflag.php%22;%7D当服务器接收到这个data参数后$_GET[‘data’]会先进行URL解码还原出包含\x00字面量的字符串。然后经过preg_replace过滤由于\、x、0、0都是可打印字符所以字符串完好无损。最后unserialize()函数会识别S:格式将\x00解析为真正的空字节从而成功构造出包含受保护属性protectedVar的对象并触发__wakeup()中的文件包含漏洞。5. 实战调试与常见问题排查在实际操作中即使Payload构造正确也可能因为各种原因失败。以下是我在实战和教学中总结的排查清单。5.1 问题1Payload提交后毫无反应或报错检查点1URL编码是否正确。最容易出错的就是反斜杠\的编码。如果你在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字符)。总共474419个字符。但PHP解析转义后\x00- 1字节(0x00)MyClass- 7字节\x00- 1字节(0x00)flag- 4字节。总共171413字节。所以正确的格式应该是S:13:“\x00MyClass\x00flag”;。我之前示例中的S:11是错误的。你必须以解析后的字节数为准。这也是最容易出错的地方。最佳实践是先用PHP脚本生成一个包含目标属性的标准对象查看其序列化字符串中该属性名的原始长度例如s:13:“...”然后将开头的s改为S即可长度值保持不变。5.2 问题2触发了文件包含但读不到flag检查点1文件路径是否正确。php://filter包装器是读取服务器本地文件的。resourceflag.php假设flag在当前目录的flag.php文件中。有时flag可能在根目录/flag、/flag.txt或数据库里。需要结合题目描述或信息收集尝试。检查点2Base64解码。使用convert.base64-encode过滤器后包含的文件内容会以Base64格式输出。你需要对返回的结果进行Base64解码才能看到明文。检查点3代码执行与属性打印。如果__wakeup()里是include($this-protectedVar);而你包含的是一个纯文本flag文件可能会成功。但如果包含的是一个PHP文件它会被执行。如果这个PHP文件里只是定义了$flag变量而没有输出那么你仍然看不到。这时你的Payload可能需要让$protectedVar包含一个你自己写的、能打印对象属性的Webshell或者利用PHP伪协议的其他过滤器直接读取源码php://filter/readconvert.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要有效得多。