PHP反序列化绕过实战:__wakeup与正则过滤的攻防博弈

发布时间:2026/10/13 10:51:39
PHP反序列化绕过实战:__wakeup与正则过滤的攻防博弈 我第一次在攻防世界遇到web_php_unserialize这道题时说实话挺懵的。标题里明晃晃写着unserialize知道是 PHP 反序列化但打开环境一看源码就卡住了类里有个__wakeup方法直接exit默认的序列化字符串还会被正则拦下来。我试了几次要么页面弹bad requests要么直接回hacker完全找不到突破口。后来我耐着性子把序列化格式手工拆了一遍又单独验证两个绕过点才真正想明白这道题一点都不难难的是没人把为什么讲透。这篇文章就当一次胎教级复盘从读源码开始把__wakeup绕过、正则绕过、payload 构造、本地复现全部说清楚保证你能完整跑通。1. 读题先看看这道题到底想让我们干什么1.1 题目源码还原攻防世界这道题的源码网上流传的版本大同小异核心下来是这样的?php class xctf{ public $flag 111; public function __wakeup(){ exit(bad requests); } } $temp isset($_GET[user]) ? $_GET[user] : a:2:{s:5:xctf;O:4:xctf:1:{s:4:flag;s:3:111;}}; if(preg_match(/[oc]:\d:/i, $temp)) { die(hacker); } else { $temp1 unserialize($temp); echo $temp1[xctf]-flag; }有的环境里isset写法会被替换成 PHP 7 的??不影响后面的分析。第一眼看到这段代码别急着找工具乱试先把结构拆出来定义了一个xctf类里面只有一个 public 属性$flag默认值是111。__wakeup()是魔术方法只要反序列化出来的是这个类的实例它就自动执行执行内容就是exit(bad requests)。页面接收user参数如果没传就用一个默认的序列化字符串。反序列化之前先过一道正则/[oc]:\d:/i匹配到就输出hacker并结束。最后unserialize的结果被当成数组取[xctf]键对应的对象再打印它的-flag。所以目标非常明确让unserialize成功执行、不触发__wakeup最后让echo把flag属性的值打出来。这一步想清楚后面所有构造都围绕它展开。1.2 三个关键结论读到这里可以顺手总结出三个结论。第一入口参数是GET参数user。我们不需要依赖平台上任何表单或按钮直接构造请求就行用浏览器、Burp 或者 Python 脚本都可以。第二最大的拦路虎是__wakeup。因为只要反序列化对象成功它就会执行然后退出所以我们必须让这个对象不正常醒来。怎么不正常醒这是后面第 3 章的重点。第三正则/[oc]:\d:/i专门盯着序列化串里的对象和类标记。典型的O:4:这种写法一进去就被打回。所以 payload 里还得绕过这个正则。再注意默认的序列化字符串a:2:{s:5:xctf;O:4:xctf:1:{s:4:flag;s:3:111;}}它最外层是一个数组数组中声明了 2 个元素但里面实际上只有一个键值对键是字符串xctf值是xctf对象。这个a:2在老版本 PHP 里能被容忍但新版本对数组长度不匹配会直接报错。这也是为什么网上有些 wp 直接抄默认值结构抄到本地新环境就失效。关于flag属性我还要多说一句在线环境的题目里类中的$flag初始值往往只是占位符平台运行时会把它替换成这道题真正的 flag 串。所以我们不是去猜 flag 值而是要让程序执行到echo把类里已经写好的真实 flag 打出来。这也是我后面推荐某些 payload 变体的原因——你如果在序列化串里把flag属性写死输出就会是你写死的那个值而不是题目真正的 flag。2. 反序列化基础不懂这些payload根本写不出来2.1 序列化格式速记表在动手构造前先把 PHP 序列化格式过一遍。serialize()把一个对象变成字符串unserialize()再把字符串还原成对象。这个字符串是纯文本每种数据类型都有固定表达方式。类型序列化格式示例布尔b:值b:1整数i:值i:123字符串s:长度:内容s:5:admin数组a:元素个数:{键;值;...}a:1:{s:1:a;i:1;}对象O:类名长度:类名:属性个数:{属性}O:4:xctf:1:{...}引用R:整数 或 r:整数较少用到了解即可对象格式里每个属性的结构是属性名 属性值比如O:4:xctf:1:{s:4:flag;s:3:111;}意思是类名xctf长度是 4对象里有 1 个属性属性名是字符串flag长度 4属性值是字符串111长度 3。很多人第一次手工构造就栽在长度上。比如把s:4:flag里的 4 写成 3PHP 解析时就会错位轻则解析失败重则把后面的内容读成乱码。所以后面我给的 payload 里凡是字符串都要反复核对长度。2.2 __wakeup到底在多早执行__wakeup是 PHP 反序列化对象的魔术方法之一。它的触发时机是unserialize()解析到对象格式时先把对象实例化出来填充完属性然后立刻调用__wakeup()。也就是说不管你怎么构造属性只要这个类有__wakeup它就会在这个时机执行。拿本题来说__wakeup里直接exit于是整个脚本终止后面的echo $temp1[xctf]-flag永远不会执行。对于这种摆明了不让你反序列化的守卫逻辑CTF 里最常见的办法就是利用它的一个历史绕过。注意__wakeup和__construct不一样。普通new对象时走的是构造函数而unserialize()创建对象时不一定走构造函数它更常调用__wakeup。可以理解成__wakeup是对象从字符串里醒过来时的初始化动作。2.3 public属性的序列化细节public属性在序列化时就是普通字符串键值对没有任何额外修饰。比如class User { public $name admin; }序列化结果是O:4:User:1:{s:4:name;s:5:admin;}如果属性是protected键名会变成\0*\0name这种带空字符的形式长度和肉眼看到的完全不同如果是private键名会带类名前缀。很多新手在这一步吃亏是因为直接拿public的写法去套protected属性长度填错了还不知道。本题的$flag是public所以我们只需要处理普通的键值对形式。这省了不少事。3. 绕过点拆解__wakeup和正则一个都不能少3.1 CVE-2016-7124为什么把属性个数改大就能跳过__wakeup这道题的关键漏洞是 CVE-2016-7124官方描述是在某些 PHP 版本中当反序列化时对象属性个数大于真实属性个数__wakeup()不会被执行。受影响的版本主要是 PHP 5.6.25 之前、PHP 7.0.10 之前的版本。修复之后这种数量伪造就不再生效了。攻防世界的这道老题在线环境一般用的还是存在漏洞的 PHP 版本所以我们可以放心利用。为什么数量不一致就能跳过__wakeup原理可以这样理解PHP 反序列化对象时会先读取序列化串里声称的属性个数然后按这个数量去逐个读属性。正常情况下序列化串里写几个属性就有几个属性。如果解析器读到对象声明的属性个数位置时发现后面已经遇到右大括号}结束它就会提前停止。这个提前停止的行为使得后面的__wakeup调用被跳过了。举个例子。下面的对象声明有 2 个属性但实际只写了 1 个属性O:4:xctf:2:{s:4:flag;s:3:111;}解析流程是读类名xctf读属性个数 2开始读属性读到第一个属性flag111再准备读第二个属性时发现字符串已经结束了于是停止属性解析。这种情况下PHP 在某些版本里就不会继续走到__wakeup反而直接把这个对象返回。这个技巧对这个题的启示很直接把对象属性个数从 1 改成 2也就是让声明个数大于实际个数就能绕过__wakeup里的exit。3.2 正则/[oc]:\d:/i与O:4的博弈绕过__wakeup之后还有一道关卡preg_match(/[oc]:\d:/i, $temp)。正则拆开看[oc]匹配字母o或c不区分大小写:匹配冒号\d匹配一个或多个数字:再匹配一个冒号。它专门匹配O:4:这种对象序列化片段。为什么还要管c因为 PHP 里还有一种类序列化格式是C:类名长度:类名:通常用于实现了Serializable接口的类。最常见的绕过方法是在数字前面加一个把O:4:写成O:4:。原因也很简单正则里的\d要求数字紧跟冒号而不是数字所以O:4:不匹配正则。但 PHP 的unserialize在解析数字时本身就把4当作有符号整数 4完全能正常读出来。我见过有人试着用O:04:绕过这个思路不行因为04同样是数字正则照样匹配。还有人试大小写更没用正则带了/i。所以在本题里最干净利落的绕过方式就是O:4:。3.3 两层组合后的构造思路现在思路很清楚我们需要构造一个xctf对象同时做到三件事序列化格式能被 PHP 正常解析属性个数声明大于实际属性个数骗过__wakeup用O:4:骗过正则。一开始可以先写标准格式O:4:xctf:2:{s:4:flag;s:3:111;}再改成O:4:xctf:2:{s:4:flag;s:3:111;}这个版本是目前网上绝大多数 wp 的写法。但我实测中发现它有个隐藏的坑它把flag属性直接写成111并放进序列化串里反序列化后flag属性会被覆盖成111。如果在线环境里题目真正的 flag 是通过类属性默认值注入的你最终输出会是111而不是 flag。这一点很少有人在 wp 里说明。更稳妥的做法是把属性名写成一个类里不存在的名字比如fla让它去当挡箭牌占用属性个数真正的flag属性保留类默认值。具体的 payload 我放在下一章一步步构造。4. 手把手构造payload推荐带假属性名的版本4.1 先写对象部分我们最终要传给user的是一段可被unserialize识别的字符串。先写内层对象O:4:xctf:2:{s:4:fla;s:3:111;}逐段解释O:表示这是一个对象4类名长度为 4用于绕过正则xctf类名2声明属性个数为 2大于实际属性个数 1用于绕过__wakeup{s:4:fla;s:3:111;}实际只写了一个属性属性名叫fla属性值是111。这里为什么用fla而不是flag因为xctf类里没有fla这个属性PHP 反序列化时会把它当成动态属性创建出来类里真正的flag属性不会被覆盖。而2大于1满足 CVE-2016-7124 的绕过条件。这样既绕过了__wakeup又保住了真实flag属性。如果你只是想先在本地验证流程把fla换成flag也可以只是输出会变成111你看不到真实 flag。两种写法建议都试一遍你会对属性覆盖有更直观的理解。4.2 包一层数组题目最后一行是echo $temp1[xctf]-flag;$temp1[xctf]这种写法说明$temp1必须是数组数组里xctf键对应一个对象。所以内层对象外面还要包一层数组a:1:{s:5:xctf;O:4:xctf:2:{s:4:fla;s:3:111;}}结构拆开a:1数组1 个键值对s:5:xctf第一个键字符串xctf长度 5O:4:xctf:...第一个值也就是xctf对象}数组结束。这里我用a:1而不是a:2。原因有两个第一符合数组真实元素个数最规范第二新版 PHP 对数组长度不匹配比较敏感a:2在 PHP 7 以上可能会解析报错。老环境下a:2也许能容忍但没有必要去赌。4.3 URL编码与提交user参数是放在 URL 里的所以必须做 URL 编码。尤其是在 URL 里会被当成空格必须编码成%2B。双引号、冒号、花括号这些字符最好也一起编码避免被浏览器或 WAF 处理掉。完整请求可以是GET /?usera%3A1%3A%7Bs%3A5%3A%22xctf%22%3BO%3A%2B4%3A%22xctf%22%3A2%3A%7Bs%3A4%3A%22fla%22%3Bs%3A3%3A%22111%22%3B%7D%7D如果你用 Burp 的 Repeater直接把这个 URL 编码后的字符串粘到请求头后面就行。浏览器地址栏也可以访问但注意有些浏览器会自作聪明地解码或转义引号能用 Burp 就用 Burp。4.4 常见的翻车现场我总结了几个容易踩的坑没编码成%2B。这是最多的翻车原因因为 URL 里表示空格PHP 拿到的参数会变成O: 4:反序列化直接失败。属性个数写成了1。那样等于没绕过__wakeup页面会回bad requests。外层写成了对象。如果user传的是O:4:xctf:...而不是数组$temp1[xctf]这一步会报错因为对象不能像数组一样按下标访问。字符串长度写错。比如s:4:flafla明明只有 3 个字符长度必须写 3。如果长度和实际不符PHP 解析会错位。把fla写成flag后输出变成111误以为题目有问题。其实是你覆盖了真实 flag 属性。如果上面都没问题但页面还是没有输出那可能和 PHP 版本有关我建议直接跳到第 5 章在本地用老版本复现一下。5. 本地环境验证用PHP 5.6老版本跑一遍5.1 为什么是PHP 5.6.25之前CVE-2016-7124 的修复版本分界大致在 PHP 5.6.25 和 PHP 7.0.10。如果你现在系统里装的是 PHP 7.4 或 PHP 8.x直接拿这个 payload 去测__wakeup还是会被正常调用页面还是会退出。这不是 payload 写错而是漏洞已经修掉了。本地验证时不要偷懒直接在官网下载老版本源码自己编译或者用现成集成环境切到 PHP 5.5 / 5.6 早期版本。注意不是所有PHP 5.6都有漏洞5.6.25 之后的版本已经修复。所以版本号一定要看仔细。5.2 快速起一个测试环境最简单的办法是用 Docker 跑一个老版本 PHP。比如docker run -d -p 8080:80 --name php56 php:5.5-apache然后把题目源码放到容器里的站点目录docker cp index.php php56:/var/www/html/如果官方php:5.5-apache镜像版本不在漏洞范围内可以找更早的 tag。也可以直接用 phpstudy 这类集成环境在 Windows 上切换 PHP 版本非常方便。本地源码可以简化成下面这样只保留核心逻辑?php class xctf{ public $flag 111; public function __wakeup(){ exit(bad requests); } } $temp isset($_GET[user]) ? $_GET[user] : ; if(preg_match(/[oc]:\d:/i, $temp)) { die(hacker); } else { $temp1 unserialize($temp); echo $temp1[xctf]-flag; }5.3 命令行和浏览器双验证命令行验证最直接把序列化字符串放在php -r里跑php -r class xctf{public $flag111;public function __wakeup(){exit(bad requests);}} $aunserialize(O:4:\xctf\:2:{s:4:\fla\;s:3:\111\;}); echo $a-flag;如果你用的是有漏洞的 PHP 版本这条命令会输出111。如果没有漏洞这里不会输出任何东西因为exit会先执行。浏览器验证就是把题目源码放到 Web 目录后访问http://127.0.0.1:8080/?usera%3A1%3A%7Bs%3A5%3A%22xctf%22%3BO%3A%2B4%3A%22xctf%22%3A2%3A%7Bs%3A4%3A%22fla%22%3Bs%3A3%3A%22111%22%3B%7D%7D正常情况下页面会输出111因为没有平台注入真实 flag。在线题目环境里这个位置输出的就是真 flag。5.4 对照组实验你可以再跑一个对照组确认__wakeup确实在执行php -r class xctf{public $flag111;public function __wakeup(){exit(bad requests);}} $aunserialize(O:4:\xctf\:1:{s:4:\fla\;s:3:\111\;}); echo $a-flag;这串里属性个数写的是1和实际属性个数一致所以__wakeup会被触发页面直接输出bad requests。两个命令一对比你就能直观感受到属性个数多写一个到底改变了什么。如果本地没有老版本 PHP还可以用在线 PHP 沙盒去验证。不过要注意很多在线沙盒跑的也是新版本 PHP漏洞可能已经修复。这时候就不能用__wakeup绕过作为唯一验证点可以只看正则绕过是否生效。6. 从一道题到一类题PHP反序列化的通用解题模型6.1 四个固定步骤刷多了你会发现PHP 反序列化的题目基本都能用一套固定流程解第一步找到unserialize入口。题目里出现unserialize($_GET[xxx])或unserialize($_POST[xxx])就是信号。输入的点通常就在反序列化函数参数上。第二步找魔术方法。__wakeup、__destruct、__toString、__call这些都可能被反序列化触发。本题是__wakeup直接exit所以重点在绕过它。第三步想清楚目标数据流。本题最终是打印$temp1[xctf]-flag所以反序列化的结果必须是一个数组、数组里有一个xctf对象。第四步处理过滤。正则、关键字替换、长度限制都要逐个绕过。本题的正则过滤比较典型属于一眼能看出来在过滤对象标记的题型。6.2 如果环境没有正则限制这道题如果再简单一点把正则那行去掉payload 直接就是a:1:{s:5:xctf;O:4:xctf:2:{s:4:fla;s:3:111;}}连 URL 编码也能少操心很多——只要把非 URL 字符编码一下就行。实际上很多类似的入门题目就是这样只考__wakeup绕过不考正则。所以建议先把这套不带正则的 payload 原理吃透再去看O:4这种花活。正则过滤本身也是一个信号出题人知道这个漏洞所以加了层障碍。遇到这种题先判断能不能用号、空格、十六进制、大小写变化绕过再考虑更复杂的编码技巧。6.3 个人经验别背payload手写序列化格式最后分享一个我自己的体会。很多新手遇到反序列化题第一反应是去网上搜现成的 payload拿过来改吧改吧就提交。这样能过题但换个环境、换个类名、换个属性结构立刻就懵了。我建议花一个小时自己在本地把serialize()的结果打印出来对着格式表拆解一遍再手工构造一个最简单的对象用unserialize()验证。这个过程走完后面的反序列化题基本都能自己推出来。至于这道题本身真正有价值的不是记住O:4:xctf:2:{...}这几个字符而是理解序列化格式里的长度、个数、符号每一样微小的变化都可能改变 PHP 的解析行为。把这些行为摸透了再看到新的过滤规则你也能很快找到对应的绕过思路。把这个过程在本地跑通一遍从手写序列化串到拿到输出比看十篇 wp 都管用。