资讯详情

资讯详情

PHP反序列化漏洞实战:从魔术方法到__wakeup绕过全解析

1. 题目初识与核心考点拆解最近我在攻防世界的WEB区刷题时遇到了这道经典的老题——Web_php_unserialize。说实话这类PHP反序列化题目在CTF里几乎属于“见面分”级别的存在但里面的细节却一点也不简单尤其是针对新手来说光是那几个符号和URL编码就足够卡半天了。我今天把完整的分析过程和实操踩坑记录都整理出来希望能让你少走弯路。先说这题的基本信息它属于WEB类型的题目核心考查PHP反序列化漏洞难度定级不高但涉及的知识点非常集中——PHP魔术方法、反序列化逃逸、正则绕过、变量覆盖以及URL编码规则。这道题在各种CTF训练平台里反复出现也被很多人拿来当作PHP反序列化的入门教学题原因就是它的考点虽然基础但每一环都恰好卡在常见的认知盲区上。题目本身提供了一个PHP源码你需要通过阅读这份源码找到利用链最终读取到flag文件的内容。它的考点说白了就三层第一层是常规的反序列化POP链构造第二层是绕过正则对O:开头的拦截第三层是在构造payload时处理好URL编码与序列化字符串的对应关系。很多人在第一层就卡住了因为对PHP魔术方法的触发时机理解不到位更多的则是死在了第二层的正则绕过我见过不少朋友直接把O:4改成一个大写字母就提交结果自然是浪费时间。下面我从头到尾把整道题的画面完整还原一遍你需要跟着思路走一遍而不是只抄结果。2. 反序列化漏洞的原理基础2.1 为什么序列化数据会被攻击在讲题目之前我先把PHP序列化机制的关键点理一遍。PHP中的serialize()函数能够将对象或数组转换成一段可存储、可传输的字符串反过来unserialize()则能将该字符串恢复成原有的数据结构。这种机制在开发中非常常见比如Session存储、Cookie数据存取、缓存系统、以及一些需要跨请求保留状态的功能里都会用到。问题在于unserialize()本身是一种“不安全的反序列化”操作。如果反序列化的数据源不可控比如直接取自用户输入、来自URL参数、来自Cookie那么攻击者就可以构造一个恶意的序列化字符串。反序列化的时候PHP会创建对应的对象而在创建和销毁对象的生命周期中会触发一系列魔术方法。这些魔术方法的名称固定、触发条件明确如果代码中恰好存在可利用的方法攻击者就能实现任意代码执行、文件读取、命令注入等严重危害。生活化地理解序列化字符串就好比是一张提货单上面写着“给我一个型号为X的对象”。unserialize()拿着这张单子去仓库提货——它不检查货品真假只负责按照型号生产对象。如果提货单是攻击者伪造的而且仓库里的某个对象初始化时会去执行一个危险动作那问题就大了。2.2 常用魔术方法的触发时机PHP中与反序列化相关的魔术方法主要有方法名触发时机常见利用__wakeup()反序列化恢复对象时自动调用重新初始化资源修改属性值__destruct()对象被销毁时自动调用执行清理操作也常被利用来触发漏洞__construct()对象被new创建时调用初始化属性__toString()对象被当作字符串使用时调用读取敏感文件或返回特定内容__get()访问不可访问属性时调用返回动态内容__call()调用不可访问方法时调用路由转发、动态执行在反序列化攻击中__wakeup()和__destruct()属于高频入口。因为PHP在反序列化过程中会创建对象当对象生命周期结束时__destruct()必然会被触发攻击者只要控制对象属性让__destruct()里的逻辑走向一个可被利用的方向就能达到目的。2.3 序列化字符串的结构解析一个典型的序列化字符串长这样O:4:User:2:{s:4:name;s:5:admin;s:8:is_admin;b:1;}我来拆解一下这里的每个字符的含义O:4:User表示这是一个对象Object类名长度为4类名是User:2:表示该对象有2个属性大括号内是属性列表s:4:name表示这是一个字符串类型的属性属性名长度为4属性名为name后面跟分号和值b:1表示布尔类型的属性值值为true。如果你要手工构造payload必须保证长度和格式一个字符都不能差否则unserialize()会直接返回false你的整个利用就失败了。这道题最终就是要求你手工构造这样一段序列化字符串并通过URL参数传给目标服务器。3. 题目源码分析逐行读懂逻辑3.1 代码全貌与文件结构这道题在攻防世界里的源码结构大致是这样的页面会直接展示一段PHP代码你需要登录后台代码审计找到一个输入点在其中传入你的payload。代码如下这里做脱敏和简化核心逻辑保持一致?php class Demo { public $file; public function __construct($file) { $this-file $file; } public function __destruct() { echo highlight_file($this-file . .php, true); } public function __wakeup() { $this-file index; } } if (isset($_GET[var])) { $var preg_replace(/[oc]:\d:/i, O:4:, $_GET[var]); unserialize($var); } ?这就是整道题的核心代码。咱们逐行拆解定义了一个Demo类包含一个公有属性$file__construct($file)在对象创建时给$file赋初值__destruct()在对象销毁时执行highlight_file($this-file . .php, true)也就是说它会高亮显示$file属性指向的文件内容并追加.php后缀__wakeup()则在反序列化时被触发会将$file强行重置为index页面接收GET参数var先用正则/[oc]:\d:/i过滤再交给unserialize()处理。3.2 关键逻辑链从可控点到文件读取咱们要利用的目标非常清晰通过unserialize()传入一个Demo类的对象让$file指向我们想读取的文件比如flag对象被销毁时__destruct()会执行highlight_file(flag.php, true)从而把flag文件的源码直接打印出来。但这里有两个阻拦第一道坎__wakeup()会在反序列化时把$file强制改成index我们必须绕开它第二道坎正则/[oc]:\d:/i会将所有O:开头的对象声明以及C:开头的类声明都替换成固定的O:4:这样我们的类名长度就和实际内容对不上了反序列化直接失败。3.3 正则替换的逻辑陷阱从正则入手分析/[oc]:\d:/i匹配的是o:或c:开头、后面跟数字、再跟冒号的片段i修饰符表示不区分大小写。替换成O:4:之后原有的格式会被打乱。举个例子你原来的payload是O:4:Demo:1:{s:4:file;s:9:flag.php;}正则匹配到O:4:之后替换成O:4:看起来没变化不对这里有个细节正则的替换不是只替换一次而是全局搜索匹配并替换。同时\d会贪婪匹配尽可能长的数字。比如你写了一个属性个数是1它也会匹配O:后面的数字直到遇到冒号。所以如果你序列化字符串中属性个数是两位数也会被一并替换掉。更复杂的是\d会匹配对象名长度后面的那个数字但并不会去管类名的部分——也就是说O:4:Demo里的4会被匹配并替换成4看起来没变化。但如果你的类名长度不是4比如O:5:Apple那么替换后变成了O:4:Apple类名长度和实际内容就对不上了unserialize()就会报错。__wakeup()绕过的方法是利用一个已知的历史漏洞当序列化字符串中的属性数量大于实际属性数量时__wakeup()不会被调用。这在PHP 7.2版本之前是存在的PHP 7.4开始对此行为进行了修正但很多题目环境依旧跑在旧版本上。所以我们只需要把Demo对象声明的属性数量改成大于实际属性数量即可。3.4 构造payload前的关键知识点综合以上分析最终的payload构造思路非常清晰先正常构造一个Demo对象序列化后得到含__wakeup()的字符串将属性数量改大使__wakeup()不被触发让“对象名长度”满足正则替换后的格式要求或者利用正则替换本身的特性实现等价绕过URL编码后传入var参数。第二个问题为什么要设置$file flag而不是flag.php因为__destruct()中已经有拼接.php的动作你赋值flag它就会读取flag.php。第三个问题highlight_file中加了一个true参数这是让高亮结果直接返回而不是直接输出。但不管怎样整个内容最终还是会被echo打印出来所以我们能看到flag文件源码。4. 实操过程从手工构造到最终得到flag4.1 本地验证环境的搭建在正式打目标之前我建议你在本地先搭一个环境把payload调通这样到实战环境时你只需要把URL编码替换一下即可省下大量时间。本地可以用php -S 127.0.0.1:8888快速起一个内置服务器将题目源码保存为index.php再在同目录下放一个假的flag文件flag.php里面写?php flag{test_flag}; ?。这样你就能在本地直接观察效果也不用担心影响到远程服务。4.2 标准的pop链构造过程我来一步步手工生成payload第一步我们用一段PHP脚本生成初始序列化字符串?php class Demo { public $file; } $d new Demo(); $d-file flag; echo serialize($d); ?输出结果为O:4:Demo:1:{s:4:file;s:4:flag;}这段序列化字符串的意思一个Demo类的对象类名长度为4有1个属性属性名为file字符串长度为4值为flag。第二步修改属性数量以绕过__wakeup将1改为2O:4:Demo:2:{s:4:file;s:4:flag;}为什么改成2就能绕过因为PHP在反序列化时会读取声明的属性数量并根据这个数值去构造属性容器。但如果实际属性列表中少于声明的数量在旧版本PHP的处理逻辑中会提前跳转到对象创建流程不再执行__wakeup(),这就达到了我们的目的。4.3 正则替换的绕过的正确姿势现在面临正则替换的问题。我们最终要让unserialize()正确处理我们的payload前提是正则在替换后不能破坏序列化结构。当前的替换规则/[oc]:\d:/i 替换为 O:4:我们的payload开头是O:4替换后还是O:4。看起来没有影响但要注意正则同时匹配c:开头的类名然而我们的payload并不是C:开头的。所以直接提交原始payload应该是可行的前提之下我们需要额外处理另一个蛇皮问题。这个蛇皮问题就出在正则的i修饰符让它不区分大小写而O:与o:都匹配。所以有人会想那我把O改成小写o不就能让替换把o:4变成O:4吗看起来好像没变化但这是一种常见的“伪绕过”思路。真正的关键卡点在于如果我们的类名长度不是4就会被替换破坏。所以有两个办法办法一让对象类名长度为4那替换本身不会产生破坏——这在本题中天然成立因为类名就是Demo长度正好4办法二如果类名长度不是4则需要利用正则替换的计数逻辑比如将开头的O:4写成O:4但PHP序列化格式不允许加号这条路走不通。所以在这道题里类名长度4天然满足条件不必做额外操作。4.4 URL编码的细节确定了payloadO:4:Demo:2:{s:4:file;s:4:flag;}接下来是URL编码。在URL中直接传输这些特殊字符冒号、双引号、大括号、分号会导致解析不完整必须进行URL编码。关键字符的编码结果如下原始字符URL编码:%3a%22{%7b}%7d;%3b所以最终的请求URL大致形式为http://target/?varO%3a4%3a%22Demo%22%3a2%3a%7bs%3a4%3a%22file%22%3bs%3a4%3a%22flag%22%3b%7d但你可能会问为什么有的writeup里对大写字母O没有做编码因为大写O在URL路径或查询参数中通常是安全的可以保留原样。但为了保险我建议你把所有特殊字符都编码包括字母o在内也可以编码成%4f。不过需要小心正则替换发生在PHP获取到请求参数之后它拿到的是已经被URL解码后的原始字符串所以你在URL编码之前必须是原始的序列化字符串编码只是为了传输服务器端的$_GET会自动解码回原始内容。4.5 完整攻击流程命令这里给你一个可以直接复制的完整流程第一步构造payload文本O:4:Demo:2:{s:4:file;s:4:flag;}第二步使用Python或在线工具进行URL编码。我推荐用Python一条命令完成import urllib.parse payload O:4:Demo:2:{s:4:file;s:4:flag;} print(urllib.parse.quote(payload))输出为O%3A4%3A%22Demo%22%3A2%3A%7Bs%3A4%3A%22file%22%3Bs%3A4%3A%22flag%22%3B%7D第三步将编码后的结果拼接到URL里发送请求curl http://target/?varO%3A4%3A%22Demo%22%3A2%3A%7Bs%3A4%3A%22file%22%3Bs%3A4%3A%22flag%22%3B%7D第四步观察响应。正常情况页面会直接输出flag.php的源码内容里面就是flag。5. 常见问题与绕过细节调试记录5.1 提交payload后页面空白或报错这是最常见的失败状态。通常原因有序列化字符串格式有误比如属性数量、字符串长度计算错误URL编码不完整导致unserialize()收到的内容不完整服务器PHP版本过高__wakeup()绕过已失效。排查思路很简单先把payload放到本地环境跑一遍看var_dump(unserialize($payload))的输出结果。如果不能正确生成对象说明payload本身有问题如果能生成对象且文件读取正常再考虑是不是编码问题。5.2 __wakeup绕过失败如果你在本地用PHP 7.4以上的环境测试哪怕属性数量改成99也可能不生效。这是PHP官方在7.4版本修复的一个漏洞具体原因是修改了对象恢复流程中属性数量与实际属性不一致时的处理方式。但在很多经典题目环境中服务器仍旧使用旧版本PHP常见的是5.x因此这个绕过方式依然有效。如果目标环境真的无法绕过就需要另找思路了。题目一般不会出这种死局你可以尝试看看是否存在其他魔术方法与反序列化链路。5.3 正则替换把属性个数的数字也替换了有些读者可能会遇到这种情况把属性数量从1改成2后正则的\d把整个2匹配并替换成了4得到的字符串变成了O:4:Demo:4:属性数量变成了4仍未绕过__wakeup注意preg_replace()的匹配是分别匹配的它逐个查找[oc]:\d:模式。你的payload中有多处符合相似模式的片段我们来检查s:4:file中的s是小写s不在[oc]字符类里所以不匹配。O:4匹配一次替换一次。Demo内部的数字并没有形成字母:数字:冒号的结构所以不会被匹配。因此属性数量2不会被替换。如果你测试环境中发现被改了那可能是你用的正则不是题目的正则。5.4 浏览器直接访问URL乱码或参数被截断浏览器有时会对URL中的一些特殊字符做二次编码导致服务器拿到的$_GET[var]内容变化。推荐直接用curl或HackBar这类工具发请求完全控制编码内容。还有一点不要用echo $flag之类的方式去猜flag内容flag就在文件源码里。5.5 读取其他文件的方式如果flag不在flag.php中你也可以尝试修改$file的值来读取其他文件。比如读取config、index等。但必须注意__destruct()会自动拼接.php后缀所以你无法直接读取.txt等非php文件。除非你能通过其他方式控制$file属性使其内容带路径穿越或封装协议前缀。但在这道题中并不需要这些花活。6. 扩展如果题目改一改你还能打吗我把这一类反序列化题的扩展思路也一并写出来你可以借着这道题把知识点泛化这比单刷一道题有用得多。6.1 如果正则过滤更严格一些变体题目会过滤O:开头但不过滤C:开头这时候可以使用C:来构造一个__construct类的对象或者用PHP内置的Serializable接口对类做一种特殊的序列化格式。可以参考C:格式的手工构造但这种方法要求类本身实现了Serializable接口适用面较窄。6.2 如果存在多个类可利用真实环境里一般不会只有一个类。如果你在源码中看到多个类就需要审计每个类的魔术方法找出一条链——从反序列化入口到最终的危险函数。一个典型的链结构是入口类控制__wakeup或__destruct中间类打印消息或修改属性终点类执行命令或读取文件。这就是所谓POP链Property-Oriented Programming。6.3 如果目标是RCE而非文件读取要想把文件读取升级为远程代码执行可以通过控制$file属性为php://filter协议的方式读取任意文件内容也可以替换为phar://触发反序列化或者寻找eval、system这类危险函数的调用点。在这类题目中highlight_file只是一个低危可利用点但在真实代码审计中你更可能遇到的是include、require、eval、assert等可以直接执行代码的函数。6.4 对于真实开发的反序列化防御建议看完攻击面之后你也应当知道怎么防御。作为开发者我有几条实在的建议永远不要对用户输入直接调用unserialize()这是最核心的一条如果确实需要反序列化请使用allowed_classes参数限制允许的类名列表对session、cookie中存放的序列化数据进行签名和加密升级到PHP 8.x版本很多魔术方法触发漏洞已被修复对包含敏感文件读取能力的类做严格的属性白名单校验。7. 实操心得一个踩坑的记录最后分享一点我自己的实战体会。第一次遇到这道题的时候我一度以为必须用一个非常复杂的payload甚至尝试在$file里写php://filter/convert.base64-encode/resourceflag去读文件结果一直被正则过滤。后来冷静下来重新读源码才发现这道题的出题人把所有筹码都摆在明面上了类名就是Demo长度正好4天然免疫正则替换__destruct()自动拼接.php所以只要传flag就能读flag文件。真正的重点只是绕过__wakeup这一件事而绕过方式就是最经典的属性数量溢出。另外一个小技巧是在本地调试的时候一定要用和题目环境一致的phpversion()版本。我最初用最新的PHP 8.2环境测试__wakeup()绕不过去白忙活了大半个小时后来换成旧版本PHP容器重测一次就成功了。建议你本地准备一个PHP 5.6或7.0的Docker镜像专门用来复现这类反序列化题目会省下不少无意义的排查时间。如果你也卡在了这道题按照上面的流程从源码审计开始走一遍你会发现整个攻击链非常通透找到入口参数var、识别可利用的魔术方法__destruct、绕过__wakeup和正则干扰、URL编码发送。拿下flag就只是时间问题了。
觉得有用,分享给同行:

为您的企业打造数字门面

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

立即咨询 →