PHP反序列化字符逃逸漏洞原理与实战:以0CTF 2016 piapiapia为例

PHP反序列化字符逃逸漏洞原理与实战:以0CTF 2016 piapiapia为例 1. 项目概述一次经典的PHP反序列化“字符逃逸”实战最近在复盘CTF题目时又遇到了那道经典的[0CTF 2016] piapiapia。这道题可以说是PHP反序列化漏洞中“字符串逃逸”类型的教科书级案例它完美地展示了当程序对用户输入进行不安全的过滤和替换时如何通过精心构造的序列化字符串让一部分数据“逃逸”出原有的结构定义从而篡改整个反序列化的结果最终实现任意文件读取。很多朋友在初次接触这个概念时会觉得有些抽象尤其是“字符变多”和“字符变少”两种场景的区别。今天我就结合这道题把整个漏洞的原理、审计思路、利用链构造和最终的Payload生成掰开揉碎了讲清楚。无论你是正在入门Web安全的新手还是想巩固反序列化知识的老兵相信这篇实战解析都能让你有所收获。简单来说这道题的核心就是一个用户信息更新功能。网站将用户的个人资料如昵称、电话等序列化后存储。为了安全它会对序列化前的数组值进行过滤将敏感词如whereselect等替换成hacker。问题就出在这个“替换”操作上替换后的字符串长度hacker是6个字符比原敏感词如where是5个字符要长。当序列化字符串生成后其结构是固定的例如s:5:where表示一个长度为5的字符串“where”。如果过滤发生在序列化之后这个结构描述的长度5并不会因为内容被替换成更长的hacker而自动更新。于是在反序列化时PHP解释器会严格按照s:5去读取接下来的5个字符作为值但实际内容已经变成了6个字符的hacker这就导致多出来的那个字符r会“吃掉”后面原本属于序列化结构的一部分破坏了整个字符串的语法。而我们正是要利用这种“破坏”精心设计输入让“逃逸”出来的字符恰好能闭合前面的结构并拼接上我们恶意构造的新序列化数据从而在反序列化后控制原本我们无法控制的属性值比如读取flag文件的路径。2. 漏洞原理深度拆解为什么“字符变多”会导致逃逸在深入题目之前我们必须把PHP反序列化字符串逃逸特别是“字符变多”场景下的底层逻辑彻底搞明白。这比单纯记忆Payload重要得多。2.1 PHP序列化字符串的结构与解析规则PHP的serialize()函数会将数组或对象转换成一种特定格式的字符串。对于数组其基本结构是a:{元素个数}:{key1}{value1}{key2}{value2}...}。其中每个键值对如果是字符串类型则表示为s:长度:值。关键点在于反序列化函数unserialize()在解析这个字符串时是**“流式”且“语法驱动”**的。它从左到右读取根据遇到的类型标识符如s,a,i和其后跟的长度数字、冒号、引号等定界符来明确知道接下来要读取多少字节作为该元素的值。举个例子一个简单的数组序列化后可能是这样的$a array(name admin, role user); echo serialize($a); // 输出a:2:{s:4:name;s:5:admin;s:4:role;s:4:user;}解析器的工作流程是读到a:知道这是一个数组。读到2:知道数组有2个元素。读到{开始解析元素。读到s:4:知道接下来是一个长度为4的字符串键。读到name确认这是键名然后遇到;结束键的解析。读到s:5:知道接下来是一个长度为5的字符串值。读到admin确认这是键name对应的值遇到;结束。如此循环直到遇到最后的}表示数组结束。整个过程的基石是“长度声明”与“实际内容”的严格匹配。一旦不匹配解析就会出错。2.2 “过滤导致字符变多”如何破坏匹配现在我们引入一个常见的“安全”操作过滤敏感词。假设代码逻辑是这样的$data[name] $_POST[name]; $data[role] $_POST[role]; // 先序列化 $serialized_data serialize($data); // 再对序列化后的字符串进行过滤把admin替换成superadmin $filtered_data str_replace(admin, superadmin, $serialized_data); // 最后反序列化过滤后的字符串 $result unserialize($filtered_data);这个过程存在一个致命的顺序错误过滤发生在序列化之后。假设用户输入nameadmin。那么序列化字符串是a:1:{s:4:name;s:5:admin;}过滤操作将admin替换为superadmin。过滤后的字符串变为a:1:{s:4:name;s:5:superadmin;}看到了吗结构描述s:5:声明后面的值长度是5但实际值superadmin有10个字符。当unserialize()解析时它读取s:5:然后开始寻找起始双引号接着准备读取5个字符作为值。它读取了super这5个字符后按照规则它认为值已经读取完毕下一个字符应该是结束双引号。但实际下一个字符是asuperadmin的第6个字符。解析器发现不是期待的语法错误于是反序列化失败通常返回false并可能产生一个警告。这不仅仅是导致失败更是一种“可控的破坏”。因为我们输入的内容admin影响了序列化字符串而过滤规则替换为更长的字符串导致了结构错位。我们的目标就是从“导致失败”进阶到“利用错位”。2.3 从结构破坏到可控逃逸构造Payload的核心思想漏洞利用的精髓在于我们不仅要引发错位还要让错位后的结果能被解析器“误认为”是一个合法的、新的序列化字符串并且这个新字符串包含了我们想要执行的恶意操作。继续上面的例子假设我们想让反序列化后的role值变成hacker但程序并没有给我们直接输入role的地方。我们只能控制name。我们的攻击思路是目标让最终的序列化字符串在解析完我们设计的name值后能够“吞掉”后面原有的role定义并“拼接”上我们自定义的新role定义。手段利用过滤使字符变多让多出来的字符恰好覆盖掉原有结构的一部分。计算这是最关键的一步。我们需要精确计算需要“覆盖”或“逃逸”掉多少个字符。假设原始的安全序列化字符串我们输入namexxx后未经过滤的是a:2:{s:4:name;s:N:[我们的输入];s:4:role;s:6:user;}(N是我们输入的长度)过滤后我们的输入中的admin被替换为superadmin长度增加了5。这会导致s:N:声明的长度比实际值短5个字符。解析器在读取name的值时会少读5个字符吗不正好相反。解析器会忠实地按照s:N:读取N个字符作为name的值。但由于实际内容变长了这N个字符只是我们输入值的前N个字符。多出来的那部分字符就留在了序列化字符串流中等待着被解析为后续的内容。所以我们构造输入时要让这“多出来的部分”正好是合法的序列化语法并且能覆盖掉我们不想要的role定义接上我们想要的。 例如我们想让最终结构变成a:2:{s:4:name;s:100:[精心构造的前N个字符];s:4:role;s:6:hacker;}那么我们需要“逃逸”出来的字符就是;s:4:role;s:6:hacker;}。我们需要计算输入多少个admin其产生的额外字符长度总和正好等于这个逃逸字符串的长度。注意这里有一个巨大的思维误区很多初学者会直接计算“逃逸字符串”的长度然后除以单个敏感词替换产生的额外长度。这是不准确的因为你必须考虑你的输入本身也是序列化字符串值的一部分。你需要确保在过滤替换后你的整个输入值可能包含很多admin和其他字符的前N个字符N是序列化时声明的长度在过滤替换后其末尾恰好是逃逸字符串的开头。这通常需要通过闭合字符串、提前结束name字段等方式来精确控制。在piapiapia这道题里我们会看到具体的计算方法。3. 题目环境审计与代码分析理解了原理我们来看[0CTF 2016] piapiapia这道题的具体实现。通常我们需要进行源代码审计题目通常会给出源码或通过其他漏洞获取。3.1 功能点梳理题目是一个简单的用户管理系统主要功能包括注册/登录常规操作。查看个人信息展示用户名、电话等。更新个人信息可以修改昵称、电话、邮箱、头像图片等信息。漏洞就发生在这个更新功能的处理逻辑中。3.2 关键漏洞代码定位在update.php或类似的逻辑文件中我们会找到类似以下的核心代码这里根据常见考点还原// update.php 关键部分 class user { public $username; public $password; public $profile; // 这是一个序列化后的字符串存储了phone, email, nickname等信息 // ... 其他方法 } // 假设从表单接收数据 $new_phone $_POST[phone]; $new_email $_POST[email]; $new_nickname $_POST[nickname]; // 将新信息组装成一个数组 $profile_array array(phone$new_phone, email$new_email, nickname$new_nickname); // 关键步骤1序列化 $profile_serialized serialize($profile_array); // 关键步骤2过滤在序列化之后 function filter($serialized_str) { $blacklist array(where, select, insert, update, delete, union, into, load_file, outfile, sleep); $filtered_str str_replace($blacklist, hacker, $serialized_str); return $filtered_str; } $filtered_profile filter($profile_serialized); // 关键步骤3将过滤后的序列化字符串更新到数据库的user.profile字段 $sql UPDATE users SET profile $filtered_profile WHERE username {$_SESSION[username]}; // ... 执行SQL而在显示个人资料的页面如profile.php中代码会从数据库读取这个profile字段然后直接进行反序列化并将数组内容展示出来// profile.php $user get_user_from_db($_SESSION[username]); $profile_data unserialize($user[profile]); echo Phone: . $profile_data[phone]; echo Email: . $profile_data[email]; echo Nickname: . $profile_data[nickname];3.3 漏洞链形成漏洞链条非常清晰输入点用户可控的phone,email,nickname字段。危险操作先序列化(serialize)后过滤(str_replace)且过滤使字符变多where(5) -hacker(6)。存储过滤后的、结构可能已被破坏的序列化字符串被存入数据库。触发点在展示页面程序无条件信任并反序列化(unserialize)数据库中的这个字符串。利用目标通过构造输入破坏原有profile数组的结构在反序列化时注入一个新的键值对例如photo /flag。因为展示页面可能只展示了phone,email,nickname但反序列化后的数组如果包含photo这个键它就会被成功解析并存储在$profile_data[photo]中。如果网站其他地方或者通过其他技巧能够读取或输出这个photo值我们就能读到flag路径。审计时还需要注意一个细节过滤函数filter可能被调用多次或者以递归方式调用确保替换完全。这会影响我们计算替换次数和最终增长的长度。4. 利用链构造与Payload计算这是最考验逻辑和细心的一步。我们假设要注入一个photo键其值为flag的路径比如/flag。4.1 确定目标序列化结构我们希望最终被成功反序列化的字符串即经过过滤后存储在数据库里的字符串是这样的a:4:{s:5:phone;s:11:12345678901;s:5:email;s:15:testexample.com;s:8:nickname;s:3:aaa;s:5:photo;s:5:/flag;}注意数组元素变成了4个我们多注入了一个photo字段。但我们的输入接口只有phone,email,nickname三个。所以我们必须通过“逃逸”让解析器在解析完前三个字段后认为后面跟着的是我们注入的第四个字段。4.2 分析原始结构与逃逸点假设我们正常输入phone 12345678901(长度11)email testexample.com(长度15)nickname aaa(长度3)未经过滤的序列化字符串是a:3:{s:5:phone;s:11:12345678901;s:5:email;s:15:testexample.com;s:8:nickname;s:3:aaa;}我们计划在nickname字段上做文章。因为nickname是最后一个我们可控的字段它之后就是序列化字符串的结束符}。我们要让nickname值的“溢出”部分覆盖掉结尾的}并拼接上我们注入的photo字段。所以我们的逃逸字符串是;s:5:photo;s:5:/flag;}。这个字符串需要被“挤出”nickname的值域并被解析器当作新的序列化结构来解析。计算逃逸字符串长度;(2) s:5:photo(10) ;(1) s:5:/flag(9) ;(1) }(1) 24个字符。注意开头的;至关重要。它的作用是闭合nickname原本的字符串值。因为解析器在读取nickname值时期望读取一个被双引号包裹的字符串。我们用;提前结束这个字符串后面的s:5:photo...就会被解析为新的键值对。4.3 计算所需触发过滤的次数过滤规则将where替换为hacker。每次替换增长1个字符where(5) -hacker(6)。我们需要通过替换让nickname字段序列化描述的长度s:X:中的X比过滤后的实际值长度短24个字符。这样解析器在读取nickname值时就会少读24个字符而这24个字符正好是我们精心构造的逃逸字符串。假设我们在nickname里填入了N个where。那么原始输入长度替换前L_raw N * 5(每个where5字符)过滤后长度替换后L_filtered N * 6(每个where变成hacker6字符)长度增长Delta L_filtered - L_raw N * 1 N我们需要Delta 24所以N 24。但是这是理想情况且是错误的因为我们忽略了序列化结构本身。我们的nickname输入是被包裹在s:X:...;里的。X是程序根据我们输入的原始内容计算的长度。如果我们输入24个where程序计算的长度X 24 * 5 120。过滤后实际内容变成了24个hacker长度120 24 144。解析器按照s:120:去读取120个字符作为nickname的值它会读取前120个字符的hackerhacker...。那么多出来的24个字符第121到144个字符就留在了后面。这多出来的24个字符是什么是我们输入的第25到第48个字符吗不我们只输入了24个where。实际上多出来的就是因为我们输入被“拉长”而额外产生的24个字符每个where变hacker多一个r。这24个r字符并不是我们想要的逃逸字符串;s:5:photo...}。所以直接填充敏感词让长度差等于逃逸字符串长度是行不通的。因为“溢出的内容”是不可控的全是替换后多出来的那个字符如r。4.4 正确的构造方法利用闭合与占位正确的思路是我们需要让“逃逸字符串”成为我们输入值的一部分并且使其位于“被截断的边界”之后。我们构造的nickname输入应该由三部分组成[大量敏感词] [逃逸字符串] [一些填充字符]大量敏感词用于触发过滤产生“长度增长”。假设用了M个where。逃逸字符串即我们想要注入的;s:5:photo;s:5:/flag;}。填充字符用于确保我们输入的总长度在过滤替换后其“前X个字符”X是序列化时声明的长度刚好以逃逸字符串结尾。我们需要满足一个等式序列化时声明的长度X 过滤后前X个字符的末尾正好是逃逸字符串的结束。设M:where的个数L_escape 24: 逃逸字符串长度L_padding: 填充字符长度非敏感词不会被替换长度不变输入总原始长度L_raw M*5 L_escape L_padding过滤后总长度L_filtered M*6 L_escape L_padding长度声明X L_raw过滤后前X个字符的内容是前M个hacker共M*6字符 逃逸字符串的前(X - M*6)个字符。我们希望这前X个字符的结尾恰好是完整的逃逸字符串。即X - M*6 L_escape代入X M*5 L_escape L_padding(M*5 L_escape L_padding) - M*6 L_escape-M L_padding 0所以L_padding M结论填充字符的长度需要等于敏感词where的个数M。并且填充字符必须不是敏感词否则又会被替换打乱计算。通常用任意字母数字比如a。那么M是多少它可以是任何正整数但为了最小化Payload我们取M L_escape 24。这样L_padding 24。4.5 最终Payload构造因此nickname的值为wherewherewhere...24个where ;s:5:photo;s:5:/flag;} aaaaaaaaaaaaaaaaaaaaaaaa24个a总长度 24*5 24 24 168。序列化时会声明s:168:...。过滤后where变成hacker总长度变为 24*6 24 24 192。反序列化时解析器读取前168个字符作为nickname的值。这168个字符是前24个hacker共144个字符。逃逸字符串;s:5:photo;s:5:/flag;}24个字符。 144 24 168正好于是解析器认为nickname的值就是这168个字符。接下来的字符是24个a和原本的}。但解析器在读取完nickname值后遇到了;然后它期待的是下一个键值对或者结束符}。它接下来看到的是aaaaaaaaaaaaaaaaaaaaaaaa}这不符合序列化语法因此会被忽略PHP的unserialize()在成功解析完有效数据后对后面的多余字符是容忍的。而我们注入的photo字段已经被成功地作为nickname值的一部分“逃逸”出来并被解析为数组的一个新元素。所以最终反序列化得到的数组是array( phone 12345678901, email testexample.com, nickname hackerhacker...hacker;s:5:photo;s:5:/flag;}, photo /flag )注意nickname的值看起来乱码了但没关系我们关心的是photo被成功注入。4.6 实操中的注意事项URL编码在通过GET/POST提交时需要对Payload进行URL编码。特别是双引号和分号;。过滤函数细节务必确认过滤是str_replace还是preg_replace是否区分大小写是否递归过滤即替换后的hacker里如果又包含where是否会再次被替换。piapiapia这道题通常是递归过滤这意味着我们的计算需要更复杂因为一个where被替换成hacker后hacker里的er可能又会被匹配实际上where和hacker没有重叠部分递归替换不影响结果。但如果替换是select-selselectect这种会产生递归增长的计算就完全不同了。转义问题检查代码在序列化前是否对输入用了addslashes等函数。如果有我们的Payload中的双引号会被转义成\这会破坏我们的闭合。在这种情况下我们需要将转义也考虑进去例如用\”来闭合计算长度时需要把反斜杠也算进去。数组键值确认我们要注入的键名如photo是否在反序列化后的对象/数组中有特殊用途。有时需要覆盖已存在的键有时需要注入新键。在这道题里通常是注入一个新键其值指向一个包含flag的文件路径。5. 完整利用流程与问题排查5.1 逐步利用流程注册并登录一个账号。进入个人信息更新页面如update.php。构造Payload在nickname字段填入计算好的字符串。例如wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere;s:5:photo;s:5:/flag;}aaaaaaaaaaaaaaaaaaaaaaaaphone和email可以填任意合法值。提交更新。访问个人信息展示页面如profile.php。此时服务端会从数据库读取我们更新后的、包含畸形序列化字符串的profile字段并进行反序列化。photo键已被注入。触发文件读取通常flag不会直接显示在个人信息页。我们需要寻找输出photo的地方。可能的方法有查看网页源代码看是否有隐藏的输出。如果网站有头像上传/显示功能可能会读取photo字段作为头像路径。我们可以尝试访问这个路径如/flag。有时题目会直接给出提示或者通过其他接口如API泄露反序列化后的整个数组。需要结合题目其他部分进行信息收集。5.2 常见问题与排查技巧问题1Payload提交后更新失败或页面报错。可能原因Payload长度超限或被其他WAF/过滤拦截。排查尝试简化Payload先测试少量where和简单的逃逸字符串如;}看是否能触发反序列化错误。通过错误信息判断执行到哪一步。问题2更新成功但访问profile页面没有变化或photo没有被解析。可能原因1长度计算错误。这是最常见的原因。检查敏感词长度、替换词长度、逃逸字符串长度是否计算准确。特别注意URL编码后是否改变了长度通常不会因为参数在PHP中被解码后再处理。可能原因2过滤逻辑与预期不符。确认过滤是发生在序列化之后还是之前是否是全局过滤是否过滤了其他字符如、:、;查看源代码确认。可能原因3反序列化目标不是数组。可能profile字段反序列化后是一个对象。那么我们的逃逸字符串需要符合对象的序列化格式O:类名长度:...。需要根据具体类来调整。排查尝试将Payload中的/flag换成简单的test并在profile页面查看网页源码搜索test看是否存在。如果可能想办法让服务端打印出过滤前和过滤后的序列化字符串例如通过报错信息这是最直接的调试方式。在CTF中有时可以通过报错信息、文件包含等方式读到源码。问题3知道注入了photo但不知道flag路径。解决方法常见的flag路径有/flag/flag.txt/var/www/html/flag../../../flag等。可以尝试目录遍历或者利用PHP反序列化本身的其他漏洞进行目录扫描。如果photo的值被当作文件包含如include()甚至可以尝试使用PHP伪协议。问题4在计算时对“逃逸字符串”到底要不要包含最后的}感到困惑。解释需要包含。因为我们的目的是让注入的photo字段成为数组的一部分。原序列化字符串的最后一个}是用来闭合整个数组的。我们的逃逸字符串;s:5:photo;s:5:/flag;}做了两件事1.;闭合了前一个字符串值2.s:5:photo;s:5:/flag;定义了一个新字段3.}闭合了整个数组。这样后面原本的}就成为多余字符被忽略。如果不加这个}数组无法正常闭合会导致反序列化失败。5.3 一个实用的调试技巧在本地搭建类似环境进行测试是无价之宝。你可以写一个简化的PHP脚本模拟题目的序列化、过滤、反序列化流程?php // 模拟filter函数 function filter($str) { $blacklist array(where, select, insert, update, delete, union, into, load_file, outfile, sleep); foreach($blacklist as $b) { $str str_replace($b, hacker, $str); } return $str; } // 模拟用户输入 $profile array( phone 123, email aaaa.com, nickname 你的Payload在这里 ); echo 原始数组:\n; print_r($profile); echo \n\n原始序列化字符串:\n; $ser serialize($profile); echo $ser; echo \n\n长度声明nickname部分: ; // 这里可以用正则提取出nickname的长度声明辅助计算 preg_match(/s:8:nickname;s:(\d):/, $ser, $matches); echo $matches[1] ?? Not found; echo \n\n过滤后的序列化字符串:\n; $filtered filter($ser); echo $filtered; echo \n\n尝试反序列化结果:\n; $result unserialize($filtered); if ($result false) { echo 反序列化失败; echo \n错误信息: . json_encode(error_get_last()); } else { print_r($result); } ?通过这个脚本你可以反复调整Payload观察中间每一步的字符串变化直观地理解逃逸是如何发生的极大提升构造成功率。6. 漏洞修复与安全编程建议通过这个案例我们可以总结出几条至关重要的安全开发准则过滤的顺序至关重要永远在序列化之前对数据进行过滤和验证。确保存入序列化字符串的数据是“干净”的。或者更优的做法是不要将序列化字符串作为过滤对象而是过滤原始的输入数据。避免序列化不可信数据如果可能尽量避免使用serialize/unserialize来存储和读取复杂的用户数据。使用JSON (json_encode/json_decode) 是更安全的选择因为JSON没有PHP序列化那种复杂的类型标识和长度声明结构更简单不易被注入。数据库的每一列存储一个属性是更规范的做法。使用安全的反序列化方法如果必须使用PHP反序列化可以考虑使用php://filter进行序列化字符串的净化吗不这治标不治本。根本在于控制输入。对于对象反序列化可以使用实现了__wakeup()或__unserialize()魔术方法的类在这些方法中进行严格的属性检查和过滤。输入验证与白名单对所有用户输入进行严格的验证。对于像昵称、电话、邮箱这样的字段使用白名单正则表达式限制允许的字符集如电话只允许数字和-昵称只允许中英文数字常见符号从根本上杜绝注入特殊字符的可能性。最小化反序列化入口不要在任何用户可控的地方直接使用unserialize()。如果必须确保反序列化的数据来源可信例如来自加密且签名的Cookie或者来自服务器端安全生成的数据而不是前端直接提交。回过头看piapiapia这道题修复方案非常简单将filter函数的调用移到serialize之前即可。先对用户输入的数组$profile_array中的每一个值进行过滤清理然后再序列化存储。这样序列化字符串的结构就永远不会被破坏。字符逃逸漏洞是PHP反序列化中非常巧妙且危险的一类问题它考验的是开发者对数据流处理顺序和安全边界的理解。希望这篇从原理到实战的详细解析能帮你彻底掌握这个知识点。在安全测试中遇到序列化操作不妨多问一句“过滤和序列化谁先谁后”