资讯动态

PHP反序列化字符串逃逸:从CTF piapiapia看长度边界漏洞

发布时间:2026/10/20 16:36:35 来源:尧图企业网站定制
最近复盘了一道挺经典的 CTF 题目代号叫 piapiapia。第一眼看上去平平无奇注册、登录、改资料、看资料典型 Web 题套路。但真正卡住人的不是 SQL 注入也不是 XSS而是一个听起来很拗口的东西——PHP 反序列化字符串逃逸。说白了就是利用一个不起眼的字符串替换函数让序列化字符串的长度和内容对不上号从而篡改反序列化后的对象结构。这篇文章我把这道题的底层原理、payload 构造过程、踩坑点全部拆开讲一遍。不管你是准备 CTF 的选手还是在搞代码审计的开发者只要搞懂这一招反序列化类的题目至少能少走一半弯路。这个漏洞的核心点非常集中代码里一定存在一个unserialize()并且传入的数据在序列化之后、反序列化之前被某个preg_replace()改过。听起来条件很苛刻但现实中很多过滤和转义函数就是这么用的。当年我第一次做这题时盯着代码看了半天也没想明白where被替换成hacker到底有什么用。直到我把序列化字符串的字节长度一个个数清楚才意识到这不是一个简单的过滤缺陷而是一种精巧的边界失控。下面我就从这个角度一步步带你把整条链路走通。1. 项目概况与攻击面分析1.1 这道题到底在考什么piapiapia 这道题给了一个比较完整的 Web 应用用户可以注册、登录、上传个人信息然后查看自己的资料。表面上看功能很正常但如果你拿到了源码审计下来会发现两件关键的事第一应用把用户资料序列化后存储读取时直接unserialize序列化字符串里包含了用户输入的用户名、手机号、自我介绍等字段。第二代码里有一个统一的过滤函数里面用了preg_replace()对用户输入或者最终生成的字符串做了替换。具体替换规则因不同版本略有差异但核心思路是把某个短字符串替换成一个更长的字符串比如把where替换成hacker或者反过来把长字符串替换成短字符串。这两点加在一起就构成了字符串逃逸的充分条件。如果替换发生在序列化之前那只是普通的字符串内容变化但如果替换发生在序列化之后、反序列化之前序列化结构中的长度声明是旧的内容却已经被改了长度差就会导致解析器读错边界。这个题目典型的解法是通过逃逸往对象里注入一个额外的字段最终把某段文件路径覆盖成目标文件配合后面的file_get_contents把读取结果输出到页面上。整个过程不需要猜 flag也不需要打数据库纯粹靠字符串层面的逻辑漏洞。1.2 攻击链路的整体视角把整道题的攻击链拆开看大概是这样的找到所有进入unserialize()的入口追踪这个参数是从哪里组装出来的。确认在组装成序列化字符串之后有没有经过任何会改变字符串长度的操作尤其是preg_replace。计算每个替换单元造成的长度差算出需要多少个替换单元来让溢出的字节数刚好等于恶意结构体的长度。构造输入让溢出部分恰好成为一个完整的序列化字段比如s:5:phone;s:5:config;。反序列化成功多出的字段被解析后续代码在这个字段上做了敏感操作最终拿到目标文件内容。这个链路里最容易被忽略的其实是第 3 步。很多人看到替换规则后第一反应是想当然地在输入里塞一个完整的序列化 payload结果反序列化直接报错。原因很简单你塞进去的字符串大部分被当成了字符串值本身根本没有机会参与到语法解析。只有长度补偿之后多出来的那部分字节才会被 PHP 当作结构字符去解析。所以构造 payload 前第一件事就是把字节数算得清清楚楚。1.3 为什么叫“逃逸”“逃逸”这个词用在这里特别形象。正常情况下一个字符串字段内部的内容无论是什么都会被双引号包裹由前面的长度声明限制外部语法和内部内容井水不犯河水。但现在因为长度声明失效内容区的末尾多出了几个字节这几个字节里如果恰好带着双引号、分号、字母 s就会被 PHP 误认为新的结构字符相当于从字符串的“数据区”逃逸到了“语法区”。理解了这一点再看网上那些 writeup 里的妖魔鬼怪 payload就不会觉得神奇了。它们本质上都是在数据区里埋了一段伪语法然后通过长度差让这段伪语法在解析时恰好从数据区里“漏”出来成为真正的代码结构。2. 反序列化字符串逃逸的原理拆解2.1 先回顾一下序列化字符串的结构在讲逃逸之前必须把 PHP 序列化的基本格式吃透。一个简单的数组序列化后长这样a:2:{s:8:username;s:5:admin;s:5:phone;s:3:123;}这段结构拆开来看a:2表示这是一个包含 2 个元素的数组{和}是数组的边界s:8:username表示一个长度为 8 的字符串内容是username;是字段之间的分隔符每个数组元素由键和值组成键是s:8:username值是s:5:admin。注意这里的数字都是按字节算的不是按字符个数。如果是中文、emoji 这类多字节字符长度计算特别容易出错后面我会专门讲。PHP 的unserialize()在解析s:5:admin;时流程是先读到s表示字符串再读到5表示长度然后跳过引号连续读取 5 个字节读完后再读一个双引号作为字符串结束标记接着读分号作为当前字段结束。问题就出在这个“连续读取 N 个字节”上——如果内容里混入了超级长的字节或者内容比声明的长度短PHP 根本不会关心它只按长度去读。2.2 变长逃逸内容变长了长度声明没变这是 piapiapia 这类题目最常用的手法。假设过滤函数做了这么一件事$str preg_replace(/hi/, hello, $str);hi长度是 2hello长度是 5每次替换会让字符串增加 3 个字节。再看这段序列化字符串a:2:{s:8:username;s:2:hi;s:5:phone;s:3:123;}如果hi被替换成hello最终变成a:2:{s:8:username;s:2:hello;s:5:phone;s:3:123;}现在s:2告诉你 username 的长度是 2但实际上内容变成了 5 个字节。反序列化读取时PHP 只会读he作为值然后期待下一个字符是双引号。结果下一个字符是l直接报错。那怎么利用呢我们可以通过控制输入中hi的数量让多出来的字节数刚好等于一段完整的序列化字段。比如我想注入一个admin字段内容是yes这段恶意结构就是;s:5:admin;s:3:yes;注意它开头是;这是因为在字符串值读完之后PHP 还需要读一个双引号和分号来结束当前字段。我们故意让多出来的字节从这里开始PHP 读到的第一个字符就是它会认为是 username 的结束引号接着读;结束当前键值对然后继续解析后面的s:5:admin;s:3:yes;这样就成功注入了一个新字段。2.3 减少逃逸内容变短长度声明没变与变长逃逸对应的是“减少逃逸”也就是替换函数把长字符串替换成短字符串比如hello变成hi。这时实际内容比声明长度短PHP 读取完内容后会多读后面的字符来凑够声明的长度这些被多读进来的字符会被吞进当前字符串的值里导致原本的结构边界发生偏移。减少逃逸的构造思路是刻意让当前字段的声明长度大于实际内容让 PHP 把后面的;、字段名、字段值等原有结构的一部分吞掉从而改变接下来的解析结果。减少逃逸往往能直接吞掉不需要的字段实现“删字段”或者“改字段”的效果但它对长度精度的要求更苛刻差一个字节整个序列化就会崩。实战中变长逃逸更常见因为它的溢出部分可以直接放新结构不用去猜原有尾部的边界。两种逃逸的对比可以看这个表类型替换方向长度差解析表现典型用法变长逃逸短变长实际内容比声明长多出的字节漏到语法区注入新字段减少逃逸长变短实际内容比声明短后续字符被吞进值吞掉旧结构覆盖字段不管哪种共同的前提都是替换发生在序列化之后。如果替换发生在反序列化之前长度和内容会同步变化不会产生边界错位。3. 从零构造一次字符串逃逸攻击3.1 搭建一个可复现的最小示例为了把原理讲透我写了一个非常小的模拟环境完全靠变量拼接不做任何黑盒干扰。这个示例就是 piapiapia 的缩小版保留了最核心的两个点序列化后替换、反序列化利用。假设后端代码长这样?php $username $_POST[username]; $phone 123; $profile [ username $username, phone $phone ]; $data serialize($profile); // 过滤函数把所有 hi 替换成 hello $data preg_replace(/hi/, hello, $data); $profile unserialize($data); echo pre; var_dump($profile);这段代码的漏洞点非常明确serialize()之后调用了preg_replace()然后直接unserialize()。我们的目标是让反序列化出来的数组多一个admin字段内容为yes也就是让最终数组变成[ username 这里是什么不重要, admin yes, phone 123 ]3.2 计算溢出长度和 payload每个hi会变成hello长度从 2 变成 5增加 3 字节。如果我们在用户名里放 n 个hi替换后就会多出 3n 个字节。我们要注入的结构是;s:5:admin;s:3:yes;数一下这个结构的字节数注意所有引号、分号、冒号都要算进去;2 字节s:5:4 字节admin7 字节;1 字节s:3:4 字节yes5 字节;1 字节总共 24 字节。所以我们需要 3n 24也就是 n 8 个hi才能刚好让溢出部分等于这个恶意结构。接下来是输入的用户名怎么组装。直接写hihihi...加恶意结构行不行我们看序列化后的样子。serialize()时用户名是按原始输入的字节长度来声明长度的。如果输入是 8 个hi加上恶意结构原始长度 L 16 24 40。替换后8 个hi变成 8 个hello长度 40加上恶意结构 24总共 64。反序列化时按 L 40 读取用户名内容读到的就是替换后的前 40 字节也就是 8 个hello正好 40 字节。读完这 40 字节后接下来的 24 字节恰好是恶意结构PHP 会先读一个双引号闭合当前字符串再读分号结束当前字段然后解析后面的s:5:admin;s:3:yes;新字段就这么诞生了。这里其实有个小技巧我们可以不先从8个hi恶意结构开头数而是在恶意结构前面加一段任意填充字符因为这些填充字符会算进 L 里被读走。所以更通用的构造方式是username (hi 重复 n 次) 任意填充 恶意结构只要满足替换后多出的 3n 个字节正好等于恶意结构的长度并且恶意结构刚好位于溢出区开头就能成功。为了保险我用 8 个hi不加额外填充。那么用户名输入就是hihihihihihihihi;s:5:admin;s:3:yes;注意hi是英文字母 h 和 i别跟中文的“嗨”搞混。3.3 编写 payload 生成脚本手工数 24 字节容易数错实战里我都是写一个小脚本自动拼。这里给出一个 PHP 生成脚本直接拿来用就行?php // 构造恶意结构 $malicious ;s:5:admin;s:3:yes;; // 计算恶意结构长度 $mal_len strlen($malicious); echo 恶意结构长度: $mal_len\n; // 每个 hi 替换为 hello 增加 3 字节 $inc 3; $n $mal_len / $inc; echo 需要 hi 的数量: $n\n; // 生成用户名 $username str_repeat(hi, $n) . $malicious; // 演示整个流程 $profile [username $username, phone 123]; $data serialize($profile); $data preg_replace(/hi/, hello, $data); echo 替换后的序列化字符串:\n$data\n\n; $result unserialize($data); var_dump($result);跑一遍这个脚本输出里应该能看到admin字段成功出现。这个脚本的价值在于以后遇到任何替换规则只需要改$inc和$malicious几秒钟就能生成一个新的可用 payload不用每次手数。3.4 攻击成功的标志演示结果里unserialize不再报错而且 var_dump 出来的数组有三个元素。username字段的值会是一大串hellohello...这很正常因为 PHP 按照声明长度读走了前 40 字节而这段内容恰好是替换后的 8 个 hello。真正起作用的是后面的admin字段。在实际的 piapiapia 题目里注入的字段可能不是admin而是某个后续会参与文件读取的路径字段比如把手机号字段覆盖成config.php或者加入一个file字段让程序读取 flag 文件。原理完全一样只是最后读取文件的那段代码决定了你要覆盖哪个字段名。3.5 从最小示例回到现实题目真实题目里替换规则往往是where变成hacker这两者的长度差是多少where5 字节hacker6 字节增加 1 字节。如果恶意结构长度是 24就需要 24 个where。所以你在网上看到的 writeup 里用户名经常是一长串wherewherewhere...这就是在凑长度。有些变体里替换规则是preg_replace(/\/, \\\, ...)转义单引号会让长度增加也能达到同样效果。还有一些题目会用 base64、urlencode 等编码函数代替preg_replace本质都一样只要序列化之后的内容和序列化之前的长度声明不一致就有逃逸的可能。做题时我一般先定位过滤函数看它处理的是原始输入还是序列化后的字符串。如果是后者再数一下替换前后的长度差剩下的就是套公式。4. 常见问题与排错心得4.1 长度计算到底哪里容易翻车这个漏洞的所有成败都系于长度计算。我自己踩过的坑主要有三个第一把英文字符长度按 1 算是对的但一旦混入中文、emojiPHP 里strlen()和肉眼看到的字符个数完全不同。比如中文在 UTF-8 下是 3 个字节如果恶意结构里包含中文必须用strlen()实测不能用眼睛数。第二preg_replace的替换次数可能受正则影响。如果替换模式是/hi/i之类的不区分大小写那HI、Hi也会被替换长度差就不再是单纯的strlen(hello) - strlen(hi)了。做题前用一段小程序先测一下实际替换结果永远比在脑子里硬算靠谱。第三拼接 placehodler 时忘了算结尾。比如恶意字段s:5:admins:3:yes;看着人畜无害但如果少了开头的;反序列化时字符串闭合引号找不到直接报Error at offset。构造出的 payload 最好先在本地环境跑一遍再放到题目里试。4.2 数组闭合和字段覆盖问题前面我没有在恶意结构里加}因为一旦加上}PHP 会认为整个数组在这个位置结束了后面原有的phone字段会变成多余内容导致反序列化失败。所以我们的恶意结构通常是注入一个新字段但不提前闭合数组。如果想覆盖已有字段比如把phone改成指定值光靠注入同名字段还不够因为 PHP 反序列化遇到重复键时后面的值会覆盖前面的值。如果你注入的phone在原来的phone之前最终生效的仍然是原来的phone。这种情况下有两种思路一是把恶意结构中注入的字段放在原有字段后面让它成为同一个键的第二次出现二是构造一个长度极大的字段值把原有尾部的phone字段直接吞进这个值里再从被吞的内容后面重新组装出想要的结构。第二种方法更接近减少逃逸的思路难度也更大。我在复现原题时最常用的技巧还是加一个新字段然后通过代码逻辑里的判断条件去触发敏感行为。比如后端原本用photo字段作为头像路径我注入一个新的photo字段并且在它前面保留原phone的结构最终读取头像路径时就会用到我注入的这个值。具体覆盖哪个字段完全取决于目标代码读的是哪个键。4.3 过滤位置不同结果完全不同这是很多新手最容易忽略的。同样一段preg_replace放在serialize()之前还是之后是两种完全不同的情况。如果放在serialize()之前比如先$username preg_replace(/hi/, hello, $username)再塞进数组做serialize()那序列化里面的长度值也是替换后的长度长度和内容是一致的根本不会产生逃逸。反序列化也不会出错只是用户输入的值被改写了而已。如果放在serialize()之后比如题目代码里那样先serialize($profile)然后对整个序列化字符串做替换这时长度值已经写死才可能出现逃逸。所以拿到代码第一件事画出数据流用户输入 → 过滤 → 序列化 → 存储/传输 → 反序列化。只要过滤函数在序列化之后、反序列化之前的某个环节执行了就可以考虑字符串逃逸。如果过滤只在最外层做了一次但后面序列化时又把过滤后的结果组装了进去也可能产生二次逃逸那种题目会更绕。4.4 防御端有哪些值得注意的做法站在漏洞挖掘角度讲完利用反过来看防御其实这个问题一点也不难防。最直接的办法就是永远不要对不可信数据直接unserialize()。如果业务必须存数组优先用 JSON 序列化json_encode()和json_decode()不存在长度声明就没有逃逸的土壤。如果一定要用 PHP 序列化至少要做到对反序列化的数据加一层校验反序列化之前用hash_equals比对签名签名使用的密钥只在服务器端保存如果只需要对象数据可以限制unserialize允许的类列表用allowed_classes参数过滤掉不认识的类对所有输入做长度一致性校验序列化后和序列化前对比长度一旦发现异常直接丢弃过滤函数尽量不要放在序列化之后的链路上。你以为的“统一过滤”很可能帮你把漏洞喂给了反序列化器。这些措施成本都不高但很多项目里unserialize往往隐蔽在缓存组件、session 处理、RPC 请求解析等地方审计时要格外注意。最后再分享一个实操习惯。做这类题目我通常先把目标环境的 PHP 版本确认好不同版本的unserialize对非法结构的报错容忍度有差异。本地验证时我习惯直接写一个error_reporting(E_ALL); ini_set(display_errors, 1);这样一旦长度算错报错 offset 能直接告诉我错在第几个字节。把 payload 生成脚本和验证脚本放到同一个目录下每次调完$inc和$malicious立刻跑一遍比手动在脑子里模拟快得多。字符串逃逸这东西看着玄乎实际就是一层窗户纸。你把长度差算明白把序列化结构背熟再用脚本自动生成 payload很快就能形成肌肉记忆。以后再遇到反序列化题先查过滤位置再算长度差大概率能少踩很多坑。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑