PHP反序列化字符逃逸漏洞:从CTF题目到实战利用 1. 项目概述最近在复盘一些经典的CTF题目特别是Web安全方向的发现很多朋友对PHP反序列化漏洞的理解还停留在“知道有这么个东西”的层面一旦遇到题目里加了各种过滤和限制就不知道从何下手了。今天我就拿一道非常经典的题目——0CTF 2016的“piapiapia”来做个深度拆解。这道题之所以经典是因为它完美地展示了如何在一个看似严密的过滤机制下利用PHP反序列化过程中的“字符逃逸”特性最终实现任意文件读取。很多人在学习反序列化时只记住了__wakeup、__destruct这些魔术方法却忽略了序列化字符串本身的结构特性这道题正好补上了这一课。通过这道题我们不仅能学会一种绕过过滤的技巧更能深入理解PHP序列化/反序列化的底层机制这对于审计真实世界的代码、挖掘更深层次的漏洞至关重要。2. 核心漏洞原理PHP序列化字符串的结构与“字符逃逸”在深入题目之前我们必须把基础打牢。PHP的serialize()函数将对象、数组等复杂数据类型转换成一种特定格式的字符串而unserialize()则负责将这个字符串还原。这个字符串的格式就是一切魔法发生的地方。2.1 序列化字符串格式详解一个最简单的序列化字符串看起来是这样的O:7:Example:1:{s:4:name;s:5:Alice;}。我们来拆解一下O:7:Example: 表示这是一个对象Object类名长度为7类名是Example。:1:: 表示这个对象有1个属性。{ ... }: 花括号内是属性的具体内容。s:4:name;: 表示一个字符串string类型的属性名长度4内容是name。s:5:Alice;: 表示该属性对应的值也是一个字符串长度5内容是Alice。关键在于长度声明s:5:Alice中的5明确告诉反序列化引擎“接下来我要读取5个字符作为这个字符串的值”。反序列化器会严格根据这个长度来读取数据直到遇到下一个分号;来结束当前值的定义或者遇到右花括号}来结束整个结构。2.2 什么是“字符逃逸”“字符逃逸”漏洞本质上是一种“数据与元数据”的混淆攻击。在上述格式中s:5:Alice里的5和Alice是“元数据”与“数据”的关系。如果我们在数据部分即Alice这个位置注入了一些特殊字符比如闭合引号或者分号;但没有同步修改前面的长度声明反序列化过程就会出错因为读取的字符数对不上可能提前终止或读取错误。但是如果应用程序在序列化之后、反序列化之前对序列化字符串进行了一次字符串替换操作情况就变得有趣了。假设它把所有的where替换成了hacker。where是5个字符hacker是6个字符。替换后整个字符串的总长度增加了但原来序列化字符串中记录各个部分长度的数字比如s:5:where里的那个5并没有改变。这就导致了“元数据”长度与“数据”实际内容的不一致。反序列化引擎仍然会傻傻地按照原来的长度去读取数据。如果精心构造输入就可以让引擎“读错位”把原本属于A字符串值的一部分数据错误地解析为B属性的名称或者把注入的恶意数据“逃逸”出原本的字符串值范围成为新的、可控的属性。这就是“字符逃逸”的核心。正向逃逸字符变多过滤操作使字符串变长如where-hacker。我们需要利用多出来的字符将后续我们想要“逃逸”出去的恶意payload“顶”到正确的位置使其被解析为新的属性。反向逃逸字符变少过滤操作使字符串变短如删除flag。我们可以利用缩短后空出来的“位置”通过精心构造让后续的正常数据被“吞并”进前一个属性从而使得更后面的、我们可控的数据被解析为新属性。0CTF这道题利用的就是“正向逃逸”。3. 0CTF 2016 “piapiapia” 真题深度剖析现在我们进入正题看看这道题是如何具体运用上述原理的。3.1 题目环境与代码审计通常我们拿到题目首先进行信息搜集。扫描目录发现www.zip源码泄露这给了我们审计代码的机会。这是实战和CTF中非常重要的第一步——尽可能获取源码。核心代码逻辑分布在几个文件update.php: 处理用户更新个人资料包括昵称、头像等。profile.php: 展示用户个人资料。class.php: 定义了一个user类及其方法。config.php: 存放数据库配置和敏感的flag。漏洞的触发链条是用户在update.php提交资料 - 资料被序列化后存储 - 在profile.php被反序列化并展示。我们的目标是读取config.php。在update.php中关键代码如下已简化$profile[phone] $_POST[phone]; $profile[email] $_POST[email]; $profile[nickname] $_POST[nickname]; $profile[photo] upload/ . md5($file[name]); $profile serialize($profile); // 进行过滤操作 $profile str_replace(where, hacker, $profile); // 将$profile更新到数据库的用户记录中可以看到程序将用户提交的profile数组序列化后执行了一个替换操作将所有where替换为hacker。然后存入数据库。在profile.php中关键代码如下// 从数据库读取序列化后的profile字符串 $profile unserialize($profile); $profile[photo] base64_encode(file_get_contents($profile[photo])); // ... 然后将$profile内容显示在页面上这里存在一个任意文件读取漏洞file_get_contents($profile[photo])的参数$profile[photo]来自于反序列化后的数组。如果我们能控制photo这个键的值将其指向config.php就能读取到flag。3.2 漏洞利用链构造我们的攻击目标是让最终反序列化得到的数组$profile中photo键的值是config.php。但这里有个障碍在update.php中photo的值是程序自动生成的upload/ . md5($file[name])我们无法直接控制。然而nickname是我们可以完全控制的。并且在序列化后的字符串中nickname字段排在photo字段之前。序列化后的结构大致是a:4:{s:5:phone;s:...;s:5:email;s:...;s:8:nickname;s:...我们控制的长度...;s:5:photo;s:...程序生成的...;}利用思路我们通过注入超长的nickname并利用where-hacker的替换使字符串变长从而改变反序列化引擎读取的“边界”。我们的目标是让引擎在解析完nickname的值后恰好将我们精心构造的一段恶意数据识别为photo属性的键名和键值。具体来说我们要“伪造”一个photo属性。在序列化字符串中一个属性是这样表示的s:5:photo;s:10:config.php;。我们需要让这段字符串“逃逸”出nickname值的范围成为独立的一部分。构造Payload我们期望的最终序列化结构是a:4:{s:5:phone;s:...;s:5:email;s:...;s:8:nickname;s:【长度L】:“【一堆where】”;}s:5:“photo”;s:10:“config.php”;”;s:5:“photo”;s:...程序生成的...;}注意这里出现了两个photo。根据反序列化规则当键名重复时后出现的值会覆盖先出现的值。所以我们注入的photo值config.php会覆盖掉程序生成的photo值。我们需要计算需要多少个where。逃逸的目标字符串是“;}s:5:“photo”;s:10:“config.php”;。注意这里开头的“;}用于闭合当前nickname的字符串值并结束整个nickname属性。这段字符串的长度是34计算字符数注意引号和分号。每将一个where替换为hacker字符串长度增加1。因此我们需要34个where这样替换后会多出34个字符正好将我们后面拼接的34个字符的恶意payload“顶”出去使其成为独立属性。所以nickname的Payload为wherewherewhere...where(一共34个)”;}s:5:“photo”;s:10:“config.php”;3.3 完整攻击流程与细节注册并登录一个账户。进入资料修改页面在nickname栏位填入我们的Payload34个连续的where后面紧跟“;}s:5:“photo”;s:10:“config.php”;。注意这里有一个关键点。在update.php中nickname可能通过mysql_real_escape_string等函数处理但通常CTF环境为了简化可能只是trim或直接使用。我们的Payload中包含引号和分号需要确保它们能原样进入序列化字符串。在实际测试中如果遇到过滤可能需要尝试编码或其它绕过方式。本题中可以直接提交。提交表单。后端逻辑如下构建数组$profile[‘nickname’] ‘wherewhere...where”;}s:5:“photo”;s:10:“config.php”;‘序列化假设其他字段如phone、email为空字符串序列化结果可能类似a:4:{s:5:“phone”;s:0:“”;s:5:“email”;s:0:“”;s:8:“nickname”;s:106:“wherewhere...where”;}s:5:“photo”;s:10:“config.php”;“;s:5:“photo”;s:...“;}注意原始nickname值的长度是 34*5 34 204个字符这里需要精确计算。实际上34个where是170字符加上后面34字符的payload总共204字符。序列化后s:204:“...“执行替换将所有where替换为hacker。34个where被替换字符串总长度增加34。存储到数据库。访问profile.php。该页面从数据库读取这个被修改过的序列化字符串并进行反序列化。反序列化引擎开始解析读取到nickname的长度声明是204。它开始读取204个字符作为nickname的值。由于where被替换为hacker实际读取的内容变成了hackerhacker...hacker”;}s:5:“photo”;s:10:“config.php”;。关键点来了引擎读满204个字符后认为nickname的值读取完毕。它接下来看到的是“;}s:5:“photo”;s:10:“config.php”;。这正好是一个完整的属性结束符“;}加上一个新的属性定义s:5:“photo”;s:10:“config.php”;。于是引擎将“;}解析为结束nickname字符串和当前键值对然后继续解析成功创建了一个新的键值对photoconfig.php。之后它还会读到程序原本生成的photo键值对但会被我们注入的这个覆盖。反序列化完成后$profile[‘photo’]的值就变成了config.php。profile.php执行file_get_contents(‘config.php’)读取到文件内容然后经过base64_encode输出在页面上通常是在头像的src属性里形式为data:image/jpeg;base64,...但里面其实是config.php的base64编码。我们将这个base64字符串解码即可获得config.php的源代码从中找到flag。3.4 实操中的注意事项与技巧长度计算的精确性这是成功的关键。必须精确计算需要逃逸的payload长度和需要填充的where数量。一个字符算错就会导致反序列化失败报错或得到错误结果。在本地可以用PHP脚本先模拟计算和生成payload。引号的处理Payload中的引号是字符串边界的一部分。在序列化字符串中字符串值是被引号包裹的。我们的Payload“;}s:5:“photo”...中的引号必须确保能成为序列化字符串语法的一部分而不是被当作普通字符转义。如果题目对输入做了addslashes处理会在引号前加反斜杠这会破坏我们的Payload。本题中没有这个过滤。数组键名覆盖顺序理解PHP反序列化时后出现的相同键名会覆盖先出现的这一点对于利用两个photo键至关重要。使用脚本辅助手动构造34个where容易出错。最好编写一个简单的PHP脚本来自动生成Payload并模拟替换和序列化过程验证Payload的有效性。?php $escape_payload ;}s:5:photo;s:10:config.php;; $len_escape strlen($escape_payload); // 34 $num_where $len_escape; // 因为一次替换增加1字符 $nickname str_repeat(where, $num_where) . $escape_payload; $profile array(phone, email, nickname$nickname, photoupload/abc.jpg); $serialized serialize($profile); echo 原始序列化:\n . $serialized . \n\n; $filtered str_replace(where, hacker, $serialized); echo 过滤后序列化:\n . $filtered . \n\n; // 尝试反序列化看看结果 $result unserialize($filtered); if ($result) { echo 反序列化成功:\n; var_dump($result); echo \nPhoto值为: . $result[photo]; } else { echo 反序列化失败; } ?关注输出点成功反序列化后file_get_contents读取的文件内容通常会被编码如base64后输出到页面的某个地方。需要仔细查看页面源代码寻找类似data:image/jpeg;base64,这样的长字符串将其解码获取原文。4. 漏洞的扩展与高级利用场景通过这道题我们掌握了“字符增多”型逃逸。与之相对的“字符减少”型逃逸同样重要例如题目将flag替换为空字符串。其原理是我们提交flagflagflag序列化后长度信息是12过滤后内容变成空但长度还是12。反序列化时引擎会从空字符串之后继续读取12个字符作为该键的值这就有可能“吞掉”后面一部分正常的数据从而使更后面的、我们注入的数据被解析为新的键值对。这要求我们对序列化字符串的结构有更深的理解。4.1 结合其他反序列化触发点本题的触发点是profile.php中显式调用了unserialize()。但在实际漏洞利用中反序列化可能发生在更隐蔽的地方Phar反序列化这是近年来非常流行的一种利用方式。phar://协议在解析phar文件元数据metadata时会自动反序列化。如果存在文件操作函数如file_get_contents、file_exists、is_dir等且参数部分可控就可以通过phar://协议触发反序列化无需显式的unserialize()调用。这大大拓宽了反序列化漏洞的利用面。Session反序列化当session.serialize_handler设置不一致如存储用php_serialize读取用php时可能造成Session反序列化漏洞。攻击者可以通过构造特殊的Session数据注入序列化字符串。其他魔术方法的利用链POP Chain单一的__destruct或__wakeup可能不足以直接执行代码。但如果一个类的__toString方法调用了另一个类的方法而该方法又调用了危险函数就可以将多个类的魔术方法像“乐高”一样拼接起来形成一条从入口点到危险函数调用的完整链条这就是POP链。挖掘POP链需要对代码逻辑有深入的分析。4.2 防御与修复建议从开发者和安全审计的角度如何避免此类漏洞根本方法不要反序列化不可信数据。这是OWASP的首要建议。如果必须这么做考虑使用安全的替代方案如JSON。严格输入校验如果无法避免必须在反序列化之前对输入数据进行严格的白名单校验。例如只允许反序列化预期的、有限的类列表。PHP提供了unserialize()的第二个参数$allowed_classes来实现这一点。// 只允许反序列化MyClass1和MyClass2 $data unserialize($input, [allowed_classes [MyClass1, MyClass2]]);避免在序列化数据上执行字符串操作如本题所示在序列化字符串上进行查找替换str_replace、正则匹配等操作极其危险极易破坏其结构完整性。任何过滤都应在序列化之前对原始数据进行。使用数字签名或HMAC确保序列化数据的完整性和来源可信。在存储或传输前对序列化字符串计算签名在反序列化前验证签名。更新PHP版本及时修复已知的反序列化相关漏洞如经典的__wakeup绕过CVE-2016-7124。5. 实战渗透中的思考在真实的渗透测试或代码审计中遇到反序列化漏洞的利用往往比CTF题目更复杂。寻找反序列化入口点全局搜索unserialize(、phar://、以及session_start()配合特定的session.serialize_handler。关注所有用户输入点是否能最终到达这些函数。分析可利用的类POP链挖掘一旦找到入口需要审计代码中所有可用的类特别是那些包含魔术方法__destruct,__wakeup,__toString,__call,__get,__set的类。画出类与方法之间的调用关系图寻找从魔术方法到危险函数如eval,system,file_put_contents的路径。绕过限制就像本题的字符逃逸一样真实环境可能有更复杂的过滤。需要灵活运用各种技巧如利用PHP特性类型混淆、弱类型比较、原生类SoapClient用于SSRFError/Exception用于XSS、以及字符编码变换等。利用链的稳定性构造的Payload需要考虑不同PHP版本、扩展配置如某些类是否需要特定扩展的影响尽量使用通用性高的链。这道0CTF的“piapiapia”就像一把钥匙为我们打开了PHP反序列化漏洞中“字符逃逸”这扇门。它告诉我们安全漏洞的挖掘不仅在于功能点的寻找更在于对数据流动和协议/格式本质的深刻理解。下次当你再看到serialize和unserialize时不妨多想一层数据在这里经历了怎样的变形我们能否控制这种变形的结果