最近复盘了一道挺经典的 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长度是 2,hello长度是 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 字节"admin"7 字节;1 字节s:3:4 字节"yes"5 字节;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 算是对的,但一旦混入中文、emoji,PHP 里strlen()和肉眼看到的字符个数完全不同。比如中文在 UTF-8 下是 3 个字节,如果恶意结构里包含中文,必须用strlen()实测,不能用眼睛数。第二,preg_replace的替换次数可能受正则影响。如果替换模式是/hi/i之类的不区分大小写,那HI、Hi也会被替换,长度差就不再是单纯的strlen('hello') - strlen('hi')了。做题前用一段小程序先测一下实际替换结果,永远比在脑子里硬算靠谱。第三,拼接 placehodler 时忘了算结尾。比如恶意字段s:5:"admin"s: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,很快就能形成肌肉记忆。以后再遇到反序列化题,先查过滤位置,再算长度差,大概率能少踩很多坑。
最近复盘了一道挺经典的 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长度是 2,hello长度是 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 字节"admin"7 字节;1 字节s:3:4 字节"yes"5 字节;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 算是对的,但一旦混入中文、emoji,PHP 里strlen()和肉眼看到的字符个数完全不同。比如中文在 UTF-8 下是 3 个字节,如果恶意结构里包含中文,必须用strlen()实测,不能用眼睛数。第二,preg_replace的替换次数可能受正则影响。如果替换模式是/hi/i之类的不区分大小写,那HI、Hi也会被替换,长度差就不再是单纯的strlen('hello') - strlen('hi')了。做题前用一段小程序先测一下实际替换结果,永远比在脑子里硬算靠谱。第三,拼接 placehodler 时忘了算结尾。比如恶意字段s:5:"admin"s: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,很快就能形成肌肉记忆。以后再遇到反序列化题,先查过滤位置,再算长度差,大概率能少踩很多坑。