聊反序列化题目绕不开2016年前后那批经典题。piapiapia是我刷题过程中印象很深的一道它没有复杂的CVE利用也没有花哨的pop链考的是基础中的基础你会不会读源码、能不能看懂序列化数据是怎么组装和解析的、知不知道private属性序列化后长什么样。把这些小知识点串起来就是一条完整的攻击链。这篇文章会把我当时完整过一遍的思路写下来从信息收集、源码审计、攻击面定位、绕过过滤到最终构造利用链每一步都给出理由和判断依据而不是直接丢一个payload让你去抄。适合刚开始碰PHP反序列化的选手也适合想整理同类攻击模型的人做参考。1. 开局一个登录页背后的源码获取链路1.1 信息收集不是漫无目的的扫描拿到题目很多人第一反应是开目录扫描、扫备份文件。这不叫错但效率很低。这类赛题的地图其实非常小通常就几个PHP文件的事与其去撞目录不如先把页面本身点一遍再用手头的工具读源码这样拿到的是确定性的信息。piapiapia的入口页面就是一个普通的注册登录界面。把页面源码看完会发现所有跳转都集中在一个入口文件里跳转参数也没什么过滤的迹象。此时有几个明显的关注点页面里有没有直接暴露出来、可被参数控制包含路径的地方。Cookie里有没有可疑的、像是base64编码或经过序列化的数据。有没有看起来像是错误信息会泄露文件路径的地方。逐个确认后我把目标放在登录后产生的Cookie上。它的编码特征非常明显是一长串base64内容解码后不是普通的JSON而是带长度计数的PHP序列化格式。第一反应是“这题要打反序列化”但先不急着构造先把服务端代码拿到手再说。1.2 伪协议读源码老手法为什么好用这类题目读源码有一个非常顺手的姿势利用PHP伪协议常见的就是php://filter、data://、file://。其中php://filter又最常用因为它能把目标文件按指定格式输出最典型的是base64编码避免PHP文件被执行后只剩运行结果。为什么不是直接读文件因为你要读的是.php源码如果直接用file_get_contents这类函数去读得到的是服务器执行后的结果你看到的只是空白或者页面输出真正的代码逻辑一点都看不到。套一层base64过滤器就可以把原始源码搬出来再手动解码还原。几个关键点php://filter/readconvert.base64-encode/resourceindex.php最终读出来的是一段base64编码文本需要自己解码。如果题目里有文件读取能力优先用它验证当前可读的路径和文件列表形成一个目录感知。我当时在某个输出点试了这个payload运气不错直接把入口文件内容读了出来。这一步的意义不只是拿到一个文件而是为后面摸清整个应用的数据流打下了基础。1.3 从入口文件摸清逻辑主干读到的入口文件逻辑并不复杂典型的路由分发结构根据一个参数决定包含哪个模块模块包括注册、登录、个人主页、更新资料等。几个文件之间的协作关系大致是入口文件做基本的请求分发和Session初始化。class.php里定义了核心的用户类负责处理用户对象的序列化与反序列化。profile.php读取当前用户的序列化信息渲染个人主页。update_profile.php接收个人资料更新请求重新组装序列化数据。到这里题目的攻击面已经从“整个网站”缩小到了“序列化数据被创建、存储、再次解析的这几个节点”。这也是我复盘CTF题目的一贯做法不要一上来就看漏洞点而是先把数据流画出来知道哪个环节产生了攻击者可控的数据哪个环节把这些数据还原成了对象漏洞一定藏在这条链路里。2. 攻击面从哪来序列化数据的完整生命周期2.1 用户对象是怎么被序列化进Cookie的注册成功之后服务端做了一件很典型但也很危险的事情把用户对象直接序列化然后base64编码写入Cookie。这个Cookie在后续每次请求中都会带回来服务端拿到后解码、反序列化得到用户对象。大致等价逻辑是class User { public $username; public $password; private $profile; // 其他方法 } $user new User($username, $password); $cookie base64_encode(serialize($user)); setcookie(user, $cookie);这里有一个容易被忽略的点整个用户对象内容都是可以被攻击者看到的因为Cookie是发给客户端的。攻击者不仅能看还能改唯一的门槛在于服务端反序列化时会不会对这个对象做严格的校验。实际测试中我把这个Cookie拿下来解码能看到User类对象完整的属性名、属性类型、属性值甚至能看到一个字段里包含的是一整段序列化后的个人资料数据。这说明个人资料也是以序列化字符串形式存放在对象属性里的数据库里存的大概率也是这个序列化结果。看到这个结构第一个念头就是如果我能改掉个人资料那一段序列化字符串是不是就能影响服务端解析出来的数据2.2 页面如何消费这些数据接着看个人主页相关的处理逻辑。服务端在展示个人主页时大概做了这么几件事从Cookie中拿到序列化数据unserialize成User对象。从对象里取出个人资料字段通常又是一个序列化字符串。对这段资料字符串再做一次unserialize得到一个关联数组$profile。从数组里取出昵称、手机号、邮箱、头像路径等字段拼到页面上展示。问题就出在第三步到第四步之间。如果某个字段的值被直接用于文件读取那页面的正常功能就变成了一个隐藏的文件读取入口。个人资料数组里有一个字段专门存放头像路径正常路径被限制为类似upload/xxx.jpg的形式。在展示头像时有的代码会先判断路径格式是否符合要求不符合就直接用file_get_contents读取并输出。这意味着只要我能控制这个头像路径字段的值把它从upload/xxx.jpg改成config.php或者/flag这类路径页面就会直接把文件内容读出来渲染给我。攻击目标到此已经很清晰了不是要去打什么远程命令不需要RCE只需要想办法篡改$profile[photo]这个字段的值。2.3 危险调用链能让你任意读文件的三个关键文件把整条链整理一下三个文件构成了一条调用链update_profile.php接收攻击者提交的资料处理后生成新的序列化资料字符串。class.php负责把资料字符串存到用户对象的属性里并负责解码。profile.php反序列化用户对象和资料字符串提取头像路径并读取文件。这条链的每一步都有序可循但如果更新资料时对数据的过滤不够严格攻击者就能借着“更新个人资料”这个正常功能把恶意数据注入到序列化字符串中最终污染头像路径。我当时看到这里基本已经把解题思路锁定成两步第一步找到更新资料时可以被利用的输入点。第二步找到过滤规则的漏洞让正常输入变成序列化字符串的一部分。2.4 攻击目标定下来了改photo字段反序列化题的乐趣就在于你最终控制的是对象结构而不只是一两个变量值。在这道题里我要的不是简单地把某个POST参数变成../flag而是要让整个序列化字符串在解析后photo字段被替换成目标文件路径。需要注意的是photo字段本身由服务端固定拼接生成类似$profile[photo] upload/ . md5(time() . rand(1, 10000)) . .jpg;攻击者直接传photo参数是没有用的因为更新接口不会让你覆盖这个字段。所以必须找到一个间接手段利用其他字段的字符串拼接特性在前面的字段里“注入”出新的序列化结构把原本的photo字段覆盖掉。这正是PHP反序列化题目里最常见的套路之一字符串注入 序列化逃逸。接下来的重点就是研究过滤函数到底过滤了什么以及它的弱点在哪里。3. 绕过过滤的两把钥匙数组参数与字符串注入3.1 filter函数的过滤粒度是解题关键更新资料接口收到的数据会过一个统一的过滤函数再拼进$profile数组最后序列化存储。过滤函数不可能对每个字段单独写一套逻辑通常就是一个通用函数遍历数组里的所有值做处理。题目里过滤函数长什么样我在解题时只关心两点过滤函数是对字符串做替换还是对数据类型做判断替换/判断的粒度能不能被数组绕过去。常见的过滤写法有两种function filter($str) { $str str_replace(select, , $str); $str str_replace(where, , $str); return $str; }或者function filter($str) { if (preg_match(/^[a-zA-Z0-9_]$/, $str)) { return $str; } return ; }第一种属于“黑名单替换”第二种属于“白名单校验”。两者的绕过思路完全不同。前者要看替换后能不能重新拼出恶意字符串后者要看校验函数对非字符串类型的行为。这道题里两种思路都用得上但真正让我打开突破口的是第二种preg_match遇到数组时行为会变得很有意思。3.2 preg_match遇上数组返回类型决定生死PHP里函数调用一旦传入数组很多预期的逻辑都会失效关键是返回值和警告信息的组合。preg_match的签名要求第一个参数是正则第二个参数是字符串。当你把数组传进去时PHP 7以下版本会产生一个警告并返回false某些情况下还返回null。如果过滤逻辑是校验通过才继续处理数据那false意味着校验失败数据会被丢弃貌似安全如果过滤逻辑是校验不通过就跳过过滤直接保留原值那false反而等于“没法过滤放行”。题目过滤函数的问题在于调用preg_match之后用了类似下面的逻辑if (!preg_match(/^[a-f0-9]{32}$/, $value)) { return $value; }当$value是数组时preg_match返回false!false为true于是直接返回原始数组。也就是说只要把参数设成数组过滤函数就直接跳过原封不动地返回整个数组。这是很多PHP题里面最简单的绕过手法原理不复杂但代码审计时非常容易看漏。我之前做其他题目时也踩过这个坑所以这次拿到过滤代码后专门把所有调用了preg_match的地方都检查了一遍很快锁定了一个可以被数组绕过的点。3.3 序列化字符串注入的具体手法知道某个字段可以被数组绕过之后还得想清楚怎么通过数组往序列化字符串里注入内容。更新资料时会做类似这样的拼接操作$profile [ phone $phone, email $email, nickname $nickname, photo upload/ . md5(time()) . .jpg, ];随后$profile_str serialize($profile); $user-profile $profile_str;如果某个字段传入的不是普通字符串而是数组那么在serialize的时候该字段的值会按数组序列化而不是普通字符串。这时数组里的内容完全可以被构造成一段“伪装序列化字符串”的结构达到覆盖后续字段的效果。举个例子正常情况下a:4:{s:5:phone;...s:5:email;...s:8:nickname;...s:5:photo;s:32:upload/xxx.jpg;}攻击者想让photo字段的值变成config.php但photo字段不是他能直接控制的。于是他在email字段里塞入一个精心构造的数组$email [ aaa;s:5:photo;s:10:config.php;} ];当这个数组被serialize后数组内部的字符串元素会被转义成对应长度的字符串。最关键的是数组元素里包含的和}字符不会被二次转义在某些过滤缺失的情况下这些字符直接成为外层序列化字符串的一部分从而“截断”原有序列化结构重新定义后面的字段。最终解析出来的$profile[photo]就不再是upload/xxx.jpg而是攻击者写入的config.php。这本质上就是反序列化里的字符串注入和SQL注入把注入到SQL语句里是一个思路只是语法从SQL变成了PHP序列化格式。3.4 private属性序列化后的\x00修饰符问题这里还有一个非常容易卡住的细节也是piapiapia取名很形象的来源PHP对象序列化时private属性和public属性的输出格式完全不同。如果一个属性是public序列化结果是s:8:username;s:5:admin;如果一个属性是private序列化结果是s:9:\x00User\x00username;s:5:admin;private属性会在属性名前面加上\0也就是字节0PHP里显示为\x00、类名、再一个\0。在序列化字符串里敲出来就是三个字符段控制字符、类名、控制字符。这个\x00有两个麻烦你直接在payload里写\x00可能被中间处理函数吃掉或变成普通字符。你手动拼序列化字符串时长度计算很容易出错因为一个\x00占一个字符长度但在文本里几乎看不见。当时我在构造payload时不只一次栽在长度上。比如s:9:\x00User\x00username很多新手会以为\x00是4个字符——反斜杠、x、0、0于是把长度数成11或者12导致整个序列化字符串解析失败目标对象变不回来页面直接报错或者数据显示为空。正确的处理方式是在代码里直接用真正的字节0拼进字符串而不是用\x00字面量。比如在Python里用chr(0)拼接或者写payload时明确标注这是单个字节。这个细节不只是这道题的知识点它属于PHP反序列化里最基础的“语法规则”。你连private属性序列化后是什么样都不清楚后面所有的手工构造都会变成玄学纯粹靠试错碰运气。4. 完整利用链的构造与验证4.1 分步构造HTTP请求整个利用过程按顺序分几步走第一步注册一个普通用户拿到正常的用户Cookie。这一步是为了获得合法身份同时拿到一个正常的序列化用户对象作为参考模板。第二步从Cookie中解码出序列化对象观察对象内部结构尤其是个人资料字段的拼接方式。第三步访问更新资料接口准备提交恶意参数。关键输入点是email和nickname这类的文本字段我把其中一个改成数组格式数组元素里带上构造好的序列化注入字符串。第四步更新成功后重新获取Cookie解码并查看个人资料数据是否已经被污染。如果注入成功序列化字符串里会出现攻击者写入的photo字段。第五步带着被污染的Cookie访问个人主页页面会尝试读取photo字段指向的文件也就是目标flag文件最终flager直接显示在页面上。这里有一个经验每次提交修改之后都重新抓一次完整HTTP响应里的Set-Cookie而不是只盯着页面的展示结果。因为有些时候序列化是成功注入了但页面模板因为文件不存在或者路径不对会报错你以为失败了其实Cookie里已经躺着一份带毒数据只需要修正路径就能出结果。4.2 序列化串长度的精确计算构造注入字符串时长度的计算是整个Payload能不能生效的核心。拿一个简单场景举例。假设你要写入的payload元素是;s:5:photo;s:10:config.php;}在PHP序列化数组时这个字符串会被表示成s:34:\;s:5:\photo\;s:10:\config.php\;};注意这里的长度是根据字符串本身的字符个数来的双引号需要正常计数。实际题目里因为注入发生在序列化前的数组元素中内部字符串不需要也不应该加反斜杠转义——那些是要在最终序列化结果里“露出来”去截断外层结构用的。所以构造时不能偷懒复制粘贴要自己写一个小脚本计算每个字符串的精确长度s ;s:5:photo;s:10:config.php;} print(len(s))脚本输出的数字就是s:后面需要填写的长度。如果长度差一个整个序列化结构就会错位解析出来的数组字段全乱掉。这种错误还不是报“解析错误”而是字段名变成乱码目标值读不出来排查起来特别费劲。我当时的标准做法先构造一个最小样例能在本地环境里跑通确认长度计算逻辑没问题再套到题目上。毕竟同一套代码在不同PHP版本下的序列化输出可能有细微差异。4.3 从脏cookie到flag的验证过程注入成功后个人主页的数据流大致是服务端从Cookie拿到序列化用户对象。unserialize得到User实例。取出对象里保存的个人资料字符串。再unserialize得到$profile数组。读取$profile[photo]。如果路径不符合upload/xxx的格式就执行file_get_contents。因为此时photo的值已经被污染成config.php第6步就会把服务端配置文件的内容读出来。配置文件一般定义了一堆全局变量有时候数据库密码、加密用的盐、甚至flag都放在里面。我在题目里第一次拿到目标文件时用的payload并不复杂就是通过数组绕过一个字段的白名单校验塞入一段截断字符串。拿到配置文件之后确认了flag存放位置再改一下路径直接把最终flag抓了出来。还有个小技巧值得说file_get_contents不仅能读文件还能配合PHP伪协议。也就是说如果目标文件在某个目录下不好猜路径可以尝试php://filter把文件内容以base64形式输出。只要这个调用点允许传入伪协议路径基本上目标文件内容逃不掉。4.4 常见失败点与调试方法我自己在做这类题目时最容易翻车的几个点集中在长度数错。尤其含有不可见字符的时候用编辑器肉眼数长度基本必错一定要用代码算。数组参数被服务端强制转成字符串。有些接口在接收POST数据时会自动trim或者implode数组绕过的前提就不存在了需要换一个输入点。序列化字符串注入后尾部的}失效。因为序列化字符串的闭合花括号位置可能和你预想的不一样导致后面字段解析错乱。解决办法是把注入点尽量放在靠后的字段这样需要截断的部分就越少。Cookie里的base64数据被URL编码或者加号被处理。在URL里会被解析成空格导致解码失败。抓包发送时要确保原始base64不被二次编码。如果页面一直报错不要盲目改payload。先做一次“最小化验证”只改一个字段确认注入是否影响了序列化字符串的前缀部分再改第二个字段看能否控制后续字段。分步推进比一次憋一个大payload更可靠。5. 从piapiapia学到的东西5.1 反序列化题目的通用解题框架做完这道题我最大的收获是总结出一套反序列化题的排查思路后面遇到类似题目基本可以按这个框架走第一步找出所有unserialize调用点确认数据来源是否可控。数据来源包括Cookie、数据库字段、上传文件、用户提交的隐藏参数。第二步画出数据流从可控输入到unserialize中间经历了哪些过滤、编码、拼接操作。第三步列出所有可被攻击者影响的对象属性区分public、protected、private尤其是它们的序列化格式差异。第四步检查过滤函数的逻辑看是拒绝还是放行特别注意数组、空字符串、null这些“类型边界情况”。第五步构造最小payload先用本地环境验证序列化格式和解码效果再应用到题目。第六步利用反序列化结果触发的副作用比如文件读取、方法调用、变量覆盖完成最终目标。这套框架不依赖具体题目核心是理解攻击者到底能控制哪些字节、这些字节会流向哪个unserialize消费点。5.2 同类型题目的三个变体piapiapia属于比较基础的字符串注入型反序列化题。同类型的变体我后来也遇到过不少区别主要在于注入入口和利用方式字符逃逸型过滤函数把某个关键词替换成空字符串导致序列化字符串长度比预期短后面的内容向前移动出现逃逸字符覆盖后续字段。这种题比直接注入更隐蔽考的是对过滤替换行为造成长度变化的敏感度。PHAR反序列化利用file_exists、file_get_contents这类文件操作函数对phar://协议的解析触发PHAR文件内的序列化数据反序列化。不需要在业务代码里找到unserialize调用点只要存在文件操作函数和可控文件路径就能打。原生类利用不自己构造pop链而是用PHP内置类自带的一些方法比如利用某个内置类在反序列化时的__destruct或__toString方法读文件、写文件、执行命令。这类题对底层类库的熟悉程度要求更高。piapiapia没有涉及这些变体的复杂度但理解它的字符串注入原理对后面理解字符逃逸非常有帮助。因为字符逃逸本质上也是“改变序列化字符串的结构”只是触发方式从直接拼接变成了长度变化。5.3 顺着漏洞反推安全写法做CTF题不只是为了解出题目反过来想一下服务端应该怎么写才能避免这类问题也是很有价值的部分。针对这道题暴露的问题安全写法至少有三条第一不要序列化整个用户对象放进Cookie。用户状态应该用无状态的Session服务端存储Cookie里只放SessionID。序列化对象的二进制格式、类结构、数据内容都不该暴露给客户端。第二反序列化之前做严格的类型校验。unserialize接收的数据应明确限定为指定类PHP里可以先用is_string校验再用allowed_classes参数限制允许反序列化的类。第三所有字段过滤应该在“结构化数据”层面做而不是在“字符串拼接”层面做。如果数据以数组形式存储就逐个字段校验、逐个字段转义不要只处理一遍拼接后的字符串。否则序列化字符串注入总是能找到缝隙。代码审计时看到unserialize加file_get_contents的组合基本上可以立刻判定这是一个高优先级漏洞点。业务逻辑里如果确实需要输出文件内容至少也要做一次严格的白名单校验确保路径落在指定目录下。最后再分享一个我做这类题的习惯拿到源码后先别急着找漏洞先写一段脚本把序列化、反序列化、过滤这几个关键函数按题目逻辑在本地跑通。本地环境模拟一遍你对长度计算和注入效果的判断会准很多。等本地payload稳定了再拿到题目环境里做一次性验证。这个习惯帮我省了很多无效尝试也让我在正式比赛中碰到类似题目时不会因为手忙脚乱而丢掉不该丢的分。