资讯详情

资讯详情

PHP反序列化漏洞实战:从魔术方法到Payload构造与绕过

拿到“攻防世界Web_php_unserialize”这道题的时候我的第一反应是终于遇到一道能把PHP反序列化这个知识点讲透的经典关卡了。如果你对CTF中的Web方向有过一点了解大概率听过“反序列化”这个词。它既不像SQL注入那样有固定语句可以套也不像文件上传那样靠猜路径就能搞定它考察的是你对PHP对象生命周期、魔术方法触发机制以及语言特性边界的理解。这道题恰恰把这些点都串在了一起非常适合作为PHP反序列化从入门到进阶的分水岭练习。我推荐所有正在刷Web方向、尤其是第一次接触PHP对象注入的读者来解这道题。它能帮你在实际题目里建立“代码审计 → 定位触发点 → 构造Payload → 绕过过滤 → 拿到Flag”的完整思路闭环。这篇文章我会从底层机制讲到实战Payload再把我踩过的坑和排错经验全部摊开全程不扯废话涉及关键原理的地方我会解释清楚“为什么能这样”而不是只丢给你一堆能抄的字符串。1. 挑战拆解与核心考点1.1 这是个什么样的关卡从题目名称就能看出来这是一道Web方向的PHP反序列化题目。通常在攻防环境里这类关卡会给你一个PHP源码文件或者直接让你访问一个Web页面页面上隐藏着包含反序列化调用点的代码逻辑。你的目标非常单一通过构造特殊的序列化数据让程序在反序列化时执行你期望的动作最终得到Flag。这道题的精妙之处在于它没有把反序列化点直接裸奔给你而是利用了一些很典型的过滤逻辑和魔术方法做干扰。你需要在代码审计阶段就识别出哪里调用了unserialize()、传入的数据怎么被加工过、有哪些魔术方法会在反序列化过程中被自动触发、这些魔术方法里有没有能帮你读取或输出Flag的通道。一个典型的题目模型大致长这样页面接收某个参数可能是Cookie、POST字段或GET参数这个参数经过弱过滤后被交给unserialize()处理。代码里定义了一个带有__wakeup()方法的类这个方法会在反序列化时自动调用可能把Flag字段给抹掉。而另一个魔术方法__destruct()则会输出某个属性内容。你要做的事就是绕开过滤构造一个序列化对象让它携带正确的属性值同时想办法让__wakeup()不把Flag清掉最后在对象销毁时把Flag打出来。这道题的考点覆盖得非常典型PHP魔术方法、序列化字符串格式、属性种类导致的字节差异、正则过滤绕过、CVE-2016-7124这类版本特性几乎一网打尽。1.2 前置知识清单我不建议零基础直接硬啃但也不是非要你先把PHP底层源码全部啃完才准上手。你只需要具备下面这四块知识解这道题就会顺手得多。第一熟悉PHP的面向对象基本语法至少要看得懂类、属性、方法、访问修饰符。第二知道什么是魔术方法尤其是__wakeup()、__destruct()、__toString()这几个高频触发点以及它们分别在什么时候被调。第三理解序列化字符串的存储结构也就是能在没有工具辅助的情况下手写或者微调一段O:4:flag:1:{...}这样的字符串。第四对反序列化漏洞的原理有基本的认知明白为什么攻击者能通过控制输入来劫持对象属性和方法流转。如果你前面这些还没完全掌握不要急我接下来从序列化本身讲起。这一块虽然看着像理论但其实和后面构造Payload强相关特别是私有属性和受保护属性在序列化字符串里的表示方式不看懂这里后面百分之百会踩坑。2. PHP反序列化的底层机制一次讲透2.1 序列化字符串的内部格式很多人第一次看到序列化字符串会觉得像天书O:4:flag:2:{s:4:flag;s:16:flag_here_123456;}这到底是什么意思拆开看其实很好理解。O代表Object对象4是类名的长度flag是类名2表示这个对象里有2个属性。后面花括号里就是属性的具体键值对。s表示字符串类型4是键名长度flag是属性名后面s:16:flag_here_123456则是属性值表示一个长度为16的字符串。这个格式是固定约定PHP内部就是靠这种自描述结构来还原对象的。你可以把它理解成一张快递面单收货人、地址、物品数量都被结构化成固定字段反序列化就是快递员照着面单把包裹还原出来。但有一个特别容易翻车的点就是属性种类不同键名的写法也不同。公有属性直接写属性名比如s:4:flag。受保护属性要在属性名前面加上\0*\0注意这里的\0是ASCII码为0的字符不是两个字符\和0。私有属性则要写成\0类名\0属性名。这也是为什么你经常会看到Payload里出现O:4:flag:1:{s:10:\0flag\0key;...}这种带一堆空字符的东西。手动构造的时候千万不能直接把私有属性键名写成s:4:key否则PHP反序列化时根本认不出那个属性它会被当成另一个不存在的公有属性。这个坑我当年排了好久看着明明是按网上模板改的就是不触发目标方法后来才发现是\0没写对。2.2 魔术方法触发规则魔术方法是PHP类中一些特殊命名的方法它们不需要你显式调用PHP会在特定时机自动触发。反序列化漏洞最常打交道的三个是__wakeup()、__destruct()和__toString()。__wakeup()会在unserialize()还原对象的过程中被调用通常用来重新初始化资源或者做校验。攻击者最怕的就是这个方法因为很多开发者会在里面做“消毒”操作比如把敏感属性重新赋值为空这就等于你费劲构造的Payload一落地就被废掉了。防御方靠它挡人进攻方就得想尽办法绕过它。__destruct()是析构方法当对象被销毁时触发。如果对象不再被引用、脚本结束、或者显式调用unset()它就会执行。很多题目把输出点藏在这里比如echo $this-flag;。对象销毁时机往往由PHP的垃圾回收机制决定这也是为什么有时候你Payload明明没问题却看不到输出因为对象可能在脚本结束前一直没被销毁或者销毁顺序和你预期的不一样。__toString()在对象被当作字符串使用的时候触发比如echo $obj;或者strlen($obj)。有些题目会把读取文件、查询数据库的操作放在这里。理解这三个方法的触发时机你才能真正明白“为什么构造这个类、触发哪个方法”的逻辑。很多人写Payload是纯背模板一旦题目稍微换了个输出点就完全不知道从哪下手根源就在于没搞懂方法触发链。2.3 从反序列化到代码执行的完整逻辑链反序列化漏洞的本质是什么一句话概括攻击者控制了一个对象的属性值进而劫持了对象方法的行为。举个最直白的例子。如果代码里有一个FileReader类它有个$filename属性__destruct()里面执行了file_get_contents($this-filename)并把内容输出。正常情况下这个类可能是被正常业务代码创建$filename是开发者设置的合法文件路径。但由于存在反序列化入口攻击者可以自己构造序列化字符串把$filename改成敏感文件路径再把这个字符串交给unserialize()。对象还原后$filename已经被篡改析构时自然就去读了你指定的文件。所以反序列化漏洞的利用核心不在于unserialize()本身有多少漏洞而在于它还原出来的对象属性完全由攻击者操控。这就像你把一个快递盒交付给自动拆包机盒子外面写着“充电器”实际上里面装了一块你根本不想收的板砖。反序列化机制只负责拆包不负责验证内容是否安全。在CTF题目里最终的目的很少是直接执行命令更多是读取某个文件的Flag或者通过类的联动构造一条输出链。但原理都是一样的找反序列化入口构造恶意对象让它的属性值指向攻击目标等待魔术方法触发。3. 代码审计靠什么线索锁定漏洞入口3.1 常见过滤逻辑拆解在这道题里入口代码通常不会太复杂但往往会加一个过滤函数让Payload不能直接过关。我遇到过最常见的一种过滤是这样写的$data $_GET[data]; $data preg_replace(/flag/i, , $data); unserialize(base64_decode($data));这段代码的逻辑是接收GET参数data先base64解码然后把内容里的flag字样全局替换成空字符串最后才交给unserialize()。为什么会有这样的过滤因为开发者知道类的名字、属性的名字大概率会和flag沾边你构造Payload时不可避免地要写上flag三个字母过滤掉这些字面量就能让你的序列化结构“缺胳膊少腿”PHP在反序列化时就会因为格式不合法而直接报错从而中断攻击。还有一种过滤是限制开头字符类似于if (preg_match(/^[Oa]:\d/, $data)) { die(hacker); }这步操作比上一种更狠它直接检查序列化字符串是不是以O:数字或者a:数字开头。O:代表对象起始a:代表数组起始这两种恰恰是构造Payload最常用的结构。只要检测到直接拒绝执行。3.2 过滤的软肋究竟在哪正则替换flag这种过滤方式软肋非常明显它是纯文本层面的替换不关心替换之后的结构是否仍然合法。这就给了我们逆向操作的空间。经典思路是利用替换次数不一致来构造畸形字符串。比如你原本想写s:3:abc你把abc替换掉就会变成s:3:这肯定不合法。但如果你的序列化结构里恰好存在一个长度值而这个长度值在过滤后变得和实际内容不匹配就可能产生一个长度欺骗的效果。回到flag替换这个场景。你可以在属性名里故意写成flagflag正则替换后变成flag而属性名长度那个数字还保留着替换前的值。原本s:8:flagflag经过替换变成s:8:flag长度明明是8实际内容却只有4个字符。PHP继续按照s:8去读取内容就会把后续字符串的字符也吃掉从而造成下一个字段的解析错位。这种错位有时候很致命但如果你能控制好长度差值反而可以利用它。比如你让替换后长度比实际内容多4个字节PHP就会把这多出的4个字节当成属性名的一部分然后从后续内容里凑够属性值。攻击者可以在后续内容里精心布局让这种“错位读取”恰好构成一个合法的属性键值对。不过这种方法实现起来比较复杂需要一遍遍验证溢出字节。更简单、更常被用的是改动序列化字符串中的结构标识。比如把O:4改成O:4。很多过滤正则写的是/^O:\d/它要求对象标识后面必须直接跟数字如果你在O:和4之间插入一个号变成O:4:flag:...正则就匹配不上了。而PHP在解析序列化字符串时却能兼容这种带号的写法。一个字符就绕过了开头检查这就是语言解析器的宽容性带来的攻击面。如果代码里只过滤了flag这个单词还有个更直接的思路把过滤目标本身变成Payload的一部分。比如类名就叫fl4g或者用大小写混写FLaG。如果正则没有开大小写不敏感修饰符/i那直接改大小写就能绕过。4. Payload构造全流程模拟4.1 手动构造序列化字符串这一节我们进入实战推演。以一道典型的类似题目为例假设源码长这样?php class flag { public $flag; public function __wakeup() { $this-flag bad flag; } public function __destruct() { echo $this-flag; } } $data base64_decode($_GET[data]); $data preg_replace(/flag/i, , $data); unserialize($data); ?我看到这个源码脑子里立刻就有两条信息flag类的属性$flag在__destruct()里被echo输出是我想利用的输出点__wakeup()会把这个属性改成bad flag是最大的阻碍。所以我的目标很明确构造一个flag类的反序列化对象让它的$flag属性等于真实Flag内容同时让__wakeup()不要执行或者执行了也不影响我的属性值。先来手写一个最基本的合法Payload。假设真实的Flag放在服务器某个文件里这里我们先构造一个假的字符串占位比如/flag_is_here原始序列化字符串应该是O:4:flag:1:{s:4:flag;s:14:/flag_is_here;}解读一下O:4:flag是对象头1表示只有一个属性花括号里s:4:flag是属性名长度为4的字符串flag后面是属性值长度14的字符串/flag_is_here。现在要做的是把它base64编码。在PHP命令行里跑一下echo -n O:4:flag:1:{s:4:flag;s:14:/flag_is_here;} | base64得到一串字符串。但重点来了这个Payload直接提交大概率失败原因就是__wakeup()会在反序列化时把$flag重置成bad flag。你辛辛苦苦塞进去的/flag_is_here在对象还原的瞬间就被覆盖了。4.2 绕过__wakeup与过滤的双重阻击这时候就需要动用CVE-2016-7124这个历史悠久的技巧了只要反序列化时声明的属性数量大于实际属性数量PHP就会跳过__wakeup()的执行。这是影响PHP 7.4之前多个版本的一个特性。理论上讲unserialize()本应先检查属性数量、再逐个还原属性、最后触发__wakeup()。但当声明的属性数量超过实际可还原的属性数量时处理逻辑出现了偏差属性还原不完__wakeup()直接被跳过。利用起来极其简单把属性数量改成比实际值大的数字就行O:4:flag:2:{s:4:flag;s:14:/flag_is_here;}这里我把1改成了2但花括号里仍然只填了一个属性。PHP处理时发现声明了两个属性实际数据不够于是跳过__wakeup()对象直接进入下一步生命周期。等到脚本结束或对象被注销时__destruct()被触发输出我们指定的属性值。还有一道坎横在眼前preg_replace(/flag/i, , $data)会把Payload里的所有flag字母组合删除。我的Payload里类名是flag属性名也是flag这俩都被点名了处处是雷。但我可以利用preg_replace替换后结构错位的原理来构造。我的目标是在过滤后服务器收到的字符串仍然是一个合法可解析的序列化字符串。因此我可以在过滤前故意多写字符让过滤后恰好形成我要的目标字符串。一个实用的思路是这样属性名本来需要是flag我在Payload里写成flflagag。经过preg_replace把中间的flag删掉剩下的就是左右两边的fl和ag拼接而成等等不对flflagag这个字符串里正则匹配到的flag是在第3到第6个字符替换为空后剩下的确实是flag前两个字符fl加后两个字符ag。聪明但还不够。别忘了属性名前面的长度值s:4表示的是4个字符这个长度值并不受过滤影响。攻击者提交的是flflagag长度8preg_replace后变成flag长度4但同时长度字段还是s:8。这就错位了PHP用s:8去读键名会从后续字节里吃掉额外的4个字符。我必须准确计算这些额外吃掉的内容让它们不影响属性值的正确读取。这个计算过程绕来绕去很容易出Bug。我实际更喜欢一个更暴力、更直白的思路反正代码是preg_replace(/flag/i, , $data)只删flag这个词那我干脆把类名和属性名换成不含flag的写法。但题目中类名就叫flag我没法改类名否则PHP对不上。另一条路直接利用O:4这种格式绕过开头检测的正则通常是只针对O:\d写的但本题过滤是删除flag不是管开头所以这里更匹配的绕过方式是替换后拼接法。我们来把这个更精细构造写出来。先确定目标Payload字符串不经过过滤的版本是O:4:flag:2:{s:4:flag;s:14:/flag_is_here;}为了经过preg_replace后得到它我提交的原始字符串必须在flag出现的位置上替换后恰好是目标字符串。一个可行的做法是把原始字符串中的每个flag都写成flflagag。因为替换时会把中间的flag抽掉剩下flag。但注意类名部分flag也要改。那我把原始Payload写成O:4:flflagag:2:{s:4:flflagag;s:14:/flag_is_here;}等一下这里有个致命问题类名长度。原始字符串中O:4表示类名长度是4但flflagag长度是8。preg_replace之后类名变成flag长度是4但序列化字符串里写的还是O:4恰好匹配这步可行。属性名同理原始flflagag替换后是flag长度字段s:4也恰好匹配。属性值里的/flag_is_here里面有个flag这是会出事的地方因为替换后它会变成/is_here长度从14变成10但s:14长度字段不匹配了。所以我不能把属性值写成/flag_is_here。我可以改用另一个不含flag的占位字符串比如/here_is_flag不行里面还是有flag。那就用/tmp/flag.txt有flag。干脆直接想一个不会在过滤中变形的字符串比如/etc/passwd或者/flag本身但如果属性值是/flag它也会被删掉。这里就需要换一个完全不含flag字母的字符串。我可以在真实场景里再替换但当前目标只是演示逻辑那我干脆让属性值用一个不含flag的占位符例如/data/secret.txt长度是16。目标Payload变成O:4:flflagag:2:{s:4:flflagag;s:16:/data/secret.txt;}手动验证替换流程输入字符串经过preg_replace把连续的flag抽走。我看到flflagag从左到右flflagag中间匹配到flag被删剩下flag。类名合法了。属性名同样合法。属性值里没有flag完好无损。最终得到O:4:flag:2:{s:4:flag;s:16:/data/secret.txt;}属性声明数量是2实际属性是1__wakeup()被跳过。把整段字符串base64编码后提交到data参数预期效果就是__destruct()输出/data/secret.txt。这一步验证了构造逻辑完全可行。4.3 验证与获取Flag的完整流程本地验证Payload可不可用最直观的方法是把整条链路在PHP环境里跑一遍。写一个和题面完全一致的测试文件然后自己提交请求观察输出。本地模拟时注意两点第一PHP版本尽量贴近题目环境别在PHP 8.2上验证CVE-2016-7124因为新版PHP大概率修掉了这个绕过手法。第二如果你本地没有PHP环境也可以用在线沙箱但要注意数据安全别把真实比赛中要用的关键思路代码直接贴到公共平台上。假设本地验证通过我实际提交时的URL大概长这样/?dataTzozOiJmbGZsYWdhZyI6Mjp7czo0OiJmbGZsYWdhZyI7czoxNjoiL2RhdGEvc2VjcmV0LnR4dCI7fQ上面那串base64是我凭手算编的不一定完全准确实际作业时直接用PHP函数生成即可不要手拼base64容易浪费大量时间。服务端收到数据后先base64解码再执行preg_replace把字符串处理成合法Payload接着unserialize()。如果一切顺利浏览器会直接把对象里的属性内容打印出来。看到输出后再顺着那串内容去找真实Flag的存放位置或者把属性值替换成实际已知的Flag路径就能拿到最终的结果。5. 踩坑记录与排错速查表5.1 序列化字符串被破坏的三种典型症状接着来聊在实战中我用血泪换出来的一些排查经验。第一种典型症状是提交Payload后页面一片空白或者直接报Fatal error: Uncaught Error之类的内容。多半是序列化字符串的结构不合法。最常见的坑包括属性数量不对、花括号不闭合、长度字段和内容实际字节数不匹配。排查方法也很原始把你最终打算提交的字符串先拿本地unserialize()试一遍如果本地报错那就先把字符串打印出来逐字节对着序列化格式规范检查。不要偷懒这一步没有任何捷径。第二种症状是页面有输出但内容不对输出的不是你要的Flag而是类似“bad flag”这样的默认值。这说明__wakeup()正常执行了你的绕过没生效。原因大概率是你用的PHP版本比较新CVE-2016-7124已经不可用了。此时要么换低版本环境去验证要么想别的办法绕过__wakeup()比如寻找其他入口类和POP链而不是死磕单个类。第三种症状是页面输出了一堆乱码有点像\0被直接打印出来了。这常见于你手写私有属性或受保护属性时把\0字符直接敲成了反斜杠加数字0。序列化字符串里必须有真正的ASCII空字符你在代码里拼接时得用双引号包裹的\0而不是单引号字符串\0。很多新手在这里卡了一整天怎么看都觉得自己的字符串是对的其实就是这个细节。5.2 和过滤正则死磕到底的思路过滤绕过不是每次都能靠改flag为flflagag来解决你首先得判断正则的匹配模式。preg_replace(/flag/i, , $data)这种是全局替换可以做拼接绕过。preg_match(/^O:\d/, $data)这种是前缀检测可以用O:4绕过。preg_match(/[0-9]/, $data)这类数字检测就更麻烦可能需要用数组或者直接换一种序列化格式。有个排错小技巧是给本地脚本加几行调试代码把每一步处理后的字符串都打印出来。比如$data base64_decode($_GET[data]); var_dump($data); $data preg_replace(/flag/i, , $data); var_dump($data); unserialize($data);这样你能看到过滤前的原始字符串和过滤后的最终字符串。如果是长度错位问题一眼就能看到var_dump输出的字符串到底哪里多了或少了几字节。调试完记得删掉这些输出否则会影响最终Flag页面的展示。5.3 版本差异的隐性坑PHP反序列化在不同版本之间的行为差异很大。__wakeup()绕过CVE-2016-7124在PHP 7.4之前的部分版本有效到了PHP 8.x基本被修复。而O:4这种加号绕过不同版本的解析器兼容性也不一样。所以解题前先确认题目运行环境如果题目页面没直接暴露PHP版本可以通过响应头、报错信息或者一些PHP探针页面判断。版本差异还体现在属性类型处理上。PHP 8.0之后对未定义属性的容忍度更低了可能直接抛异常而不会默默丢弃多余的属性声明。如果你在旧版本思路下构造的属性数和实际数不一致新版会直接报错而不是跳过__wakeup()。这就是为什么我建议你随时准备两套Payload方案一套用CVE-2016-7124另一套老老实实配合真实环境去触发正确的魔术方法。6. 回到现实如何修掉这些洞6.1 防御端不可回避的整改项刷完这道题如果只是爽了一波拿Flag那价值就太低了。更该做的是反推一下如果我是这套系统的开发者我应该怎么避免这个洞被别人打穿第一道防线是不要轻易对用户输入调用unserialize()。能JSON就JSONJSON格式不存在对象劫持问题更安全。如果业务上必须用PHP原生序列化那你至少要把允许被反序列化的类名放进白名单。利用unserialize($data, [allowed_classes [SafeClass]])这个第二参数可以限制只有白名单里的类才会被还原其他类一律转为__PHP_Incomplete_Class攻击面瞬间被砍掉一大截。第二道防线是给__wakeup()和__destruct()这些魔术方法加防御逻辑不要把敏感操作直接摊在明面上。比如在__wakeup()里校验对象属性是否合法如果属性值包含可疑路径或非法字符直接拒绝。当然这只能拦低水平的攻击者高手还是会找别的类来串联。第三道防线是定期的代码审计。反序列化漏洞隐蔽性很强静态扫描工具能帮上不少忙但最终还是要靠人去看逻辑。像这种在析构函数里输出文件内容、在__toString()里执行命令的写法审计时一经发现就必须返工。安全编码规范里应该明确列出一类高危API清单unserialize()绝对排在前几名。6.2 我个人的实战体会说句实在话我在第一次做反序列化题目时也被序列化字符串里的长度字段折磨得够呛。那时候我总觉得手动改字符串就能搞定结果改了半个小时越改越乱最后老老实实写了个PHP脚本帮我生成Payload。从那以后我养成了一个习惯凡是需要手工构造序列化数据的题目我第一件事就是在本地写好构造脚本把目标对象按正常PHP方式创建然后序列化它再根据过滤条件去调节字符串。这比整个人脑模拟解析器高效十倍。另外我建议你做这道题的时候不要急着找现成的WP。先自己把源码读明白把魔术方法的触发链路画在纸上然后手动推演一遍过滤前后的字符串变化。哪怕你最后没做出来这个推演过程本身就是在锻炼代码审计能力这是CTF里最值钱的技能。我见过很多选手刷题刷得挺多但一问到“这个点为什么能这样打”就只会说“别人这么写的”。这种刷法刷一百道题也长不出真正的渗透思维。反序列化这道题本质上是在考你“能不能当一个不讲武德的快递员”。PHP帮用户拆包裹用户要求包裹里装什么PHP就还原什么它不关心你的东西是来路不明的恶意对象。作为攻击者你只要把序列化字符串改得合法合规、又刚好能触发目标逻辑就能让程序乖乖交出Flag。回到现实中这个问题的关键从来都不是那一行unserialize()而是你代码里有没有可以被劫持的类、有没有能把属性值转化为危险操作的方法。看清这一点你玩这道题才算真正回本了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →