我第一次在攻防世界遇到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 写成 3,PHP 解析时就会错位,轻则解析失败,重则把后面的内容读成乱码。所以后面我给的 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,开始读属性;读到第一个属性flag=111,再准备读第二个属性时,发现字符串已经结束了,于是停止属性解析。这种情况下,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,长度 5;O:+4:"xctf":...:第一个值,也就是xctf对象;}:数组结束。
这里我用a:1而不是a:2。原因有两个:第一,符合数组真实元素个数,最规范;第二,新版 PHP 对数组长度不匹配比较敏感,a:2在 PHP 7 以上可能会解析报错。老环境下a:2也许能容忍,但没有必要去赌。
4.3 URL编码与提交
user参数是放在 URL 里的,所以必须做 URL 编码。尤其是+,在 URL 里会被当成空格,必须编码成%2B。双引号、冒号、花括号这些字符最好也一起编码,避免被浏览器或 WAF 处理掉。
完整请求可以是:
GET /?user=a%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:"fla",fla明明只有 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 $flag="111";public function __wakeup(){exit("bad requests");}} $a=unserialize("O:+4:\"xctf\":2:{s:4:\"fla\";s:3:\"111\";}"); echo $a->flag;'如果你用的是有漏洞的 PHP 版本,这条命令会输出111。如果没有漏洞,这里不会输出任何东西,因为exit会先执行。
浏览器验证就是把题目源码放到 Web 目录后,访问:
http://127.0.0.1:8080/?user=a%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 $flag="111";public function __wakeup(){exit("bad requests");}} $a=unserialize("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 都管用。