最近在攻防世界刷题的时候,看到一道叫 unserialize3 的 PHP 反序列化题。光看名称就知道考点很直接,就是unserialize函数配合魔术方法做文章。很多教程把这道题一笔带过,直接甩 payload 让你复制粘贴,但对为什么能绕过、为什么要改那个数字、本地怎么复现,基本不讲。这篇文章我把它的原理、构造过程、坑点,以及常见的排查思路从头到尾过一遍。你在攻防世界练题也好,想理解 PHP 反序列化漏洞也好,这篇内容基本都能对上号。
1. 题目分析与核心考点
1.1 这道题到底在考什么
unserialize3 的题目给了一段极简的 PHP 代码,肉眼就能看完。核心部分大概是这样一个类:
class xctf{ public $flag = 'hi'; public function __wakeup(){ if($this->flag != 'hi'){ $this->flag = 'hi'; } } } if(isset($_GET['flag'])){ unserialize($_GET['flag']); }else{ highlight_file(__FILE__); }入口是一个 GET 参数flag,直接把参数值丢给unserialize()处理。类xctf里面有一个 public 属性$flag,默认值是'hi';另一个重点是__wakeup()魔术方法,它会在反序列化完成之后被自动调用,方法体内的逻辑是:如果当前$flag的值不等于'hi',就强制把它改回'hi'。
也就是说,正常构造一个序列化字符串,只要设置$flag为别的值,反序列化一执行,__wakeup()就会把它打回原形。要想让注入的值保留下来,唯一的思路就是让__wakeup()不执行。这道题的核心考点就落到了__wakeup()的绕过上,也就是安全圈常说的 CVE-2016-7124。
很多新手到这里会卡住:明明按照序列化格式传了值,反序列化也确实执行了,但结果就是被还原。原因很简单,这类题需要的不是“传参技巧”,而是对 PHP 反序列化机制的底层理解。你得清楚__wakeup()在什么时机触发,以及它存在什么样的历史缺陷。
1.2 前置知识准备
动手之前,建议先把几个基础点过一遍,不然容易飘:
- 知道 PHP 序列化字符串的基本格式,比如
O:4:"xctf":1:{...}每个字段代表什么。 - 知道
__wakeup()、__destruct()、__construct()这几个常见魔术方法的触发条件。 - 会起一个本地 PHP 环境,或者能用在线靶场,方便自己改 payload 做实验。
- 了解一个常识:
unserialize()接收外部输入本身就是危险信号,后面会详细说原理。
如果你这几个点都能对上,那下面内容看起来不会累;如果对不上,也没关系,第 2 章会把这些机制重新揉碎了讲。
2. 反序列化与魔术方法原理拆解
2.1 序列化与反序列化机制
序列化本质上是把对象状态转成一段可存储、可传输的字符串,PHP 用serialize()完成这个动作。看个最简单例子:
$x = new xctf(); echo serialize($x); // 输出:O:4:"xctf":1:{s:4:"flag";s:2:"hi";}把这段输出拆开解释:
O表示对象类型,后面跟类名。4是类名字符串xctf的长度。1是对象属性的个数,这里只有一个flag。- 大括号里面逐个列出属性,
s:4:"flag"表示属性名是长度为 4 的字符串flag,s:2:"hi"表示属性值hi。
反序列化则是把这种字符串还原成对象,unserialize()负责这个工作。理解这段格式很重要,后面构造 payload 时,所有长度、个数都必须严格匹配。比如你写s:5:"flag",PHP 解析器会取字符串前五个字节来当属性名,flag只有四个字节,解析就会错乱,最终属性对不上,检查也会失败。
我习惯把这个过程类比成发送一份快递单:对象是包裹,序列化字符串是快递单,类名、属性名、长度这些信息就是单号上的每一项。任一项写错,快递员都找不到正确的包裹,最后要么拒收,要么送到错误的地方。很多反序列化漏洞利用失败,不是思路错了,而是“单号”没填对。
2.2 __wakeup 魔术方法的触发时机
__wakeup()是 PHP 魔术方法之一,官方定义是:在unserialize()执行过程中,如果反序列化的对象所属类存在这个方法,就会自动调用。它的设计初衷是让开发者有机会在对象恢复之后重新初始化资源,比如重新打开数据库连接、重置临时状态。这也是为什么__wakeup()很像“对象的二次构造函数”。
在上面的题目代码里,__wakeup()做的事情不是资源恢复,而是做了一层“属性修正”:一旦发现$flag被改过,就强制还原为'hi'。从设计角度讲,这类校验写在这里是合理的,因为反序列化入口可能不可信,需要防止攻击者把对象状态改成预期外的值。但问题在于,PHP 底层的反序列化流程是先恢复属性,再调用__wakeup(),两者并非一个不可分割的整体。
有人可能会问:既然__wakeup()一定会被调用,那题目还怎么做?答案是:历史上有很长一段时间,这个“一定会被调用”的假设不成立。这里就引出了 CVE-2016-7124。
2.3 CVE-2016-7124:属性个数与 __wakeup 的关系
CVE-2016-7124 描述的问题是:在 PHP 5.6.25 和 PHP 7.0.10 之前的版本里,当反序列化字符串中声明的对象属性个数大于真实属性个数时,__wakeup()会被跳过。
回到题目,xctf类只有一个公开属性$flag,所以它的“真实属性个数”是 1。正常序列化字符串写的是O:4:"xctf":1:{...},如果我强行写成:
O:4:"xctf":2:{s:4:"flag";s:3:"abc";}声明的属性个数是 2,但大括号里实际只给了一个属性。PHP 解析器因为版本缺陷,没有检查这个数量是否真实,直接认为“类里的属性很多,模式异常”,于是跳过了__wakeup()的调用。结果就是:$flag被设置成abc之后,没有人再把它改回hi。
这个缺陷本质上是解析逻辑与执行逻辑的不一致。序列化字符串是用户可控的输入,属性个数声明是我们可以任意改的,而 PHP 对“属性个数”与“实际属性内容”之间的校验存在漏洞。利用这个不一致,就能让原本负责安全校验的__wakeup()形同虚设。
这里要格外注意版本问题。PHP 官方后来修复了这个问题,在新版本里,如果属性个数对不上,解开会直接抛出一个警告或错误,大概率也会阻止异常状态进入对象。因此,现在想在本地新版本的 PHP 环境里复现这个绕过,大概率是不行的。做 CTF 题能成功,通常是因为题目平台运行在存在该缺陷的旧版本 PHP 环境下。
3. 本地复现与利用过程实录
3.1 搭建最小靶机环境
虽然攻防世界有在线环境可以直接做题,但我强烈建议在本地也搭一套一模一样的靶机,这样你可以反复改 payload,随时观察输出和报错,比在网页上盲试舒服得多。
第一步,准备一个 PHP 环境。最简单的方式是用系统自带的 PHP CLI 和内置 web 服务器。如果你系统装了 PHP,进到工作目录直接执行:
php -S 127.0.0.1:8080第二步,新建一个test.php文件,内容参考题目逻辑。我为了能在本地直观看到绕过效果,在保留原题类结构的基础上,额外加了一段输出逻辑:
<?php class xctf{ public $flag = 'hi'; public function __wakeup(){ if($this->flag != 'hi'){ $this->flag = 'hi'; } } } if(isset($_GET['flag'])){ $obj = unserialize($_GET['flag']); if($obj->flag === 'hi'){ echo "wakeup executed, flag reset to hi"; }else{ echo "bypass success: your flag => " . $obj->flag; } }else{ highlight_file(__FILE__); } ?>原题环境的最终判定逻辑可能藏在题目后台,但原理是一样的:如果$flag被还原成hi,说明__wakeup()正常执行了;如果$flag保留了我们注入的值,说明绕过成功。
第三步,访问http://127.0.0.1:8080/test.php,确认能看到源码高亮页面,环境就准备好了。这里用内置服务器主要是省事,你换成 Nginx、Apache、宝塔环境都行,核心是 PHP 版本要足够旧,否则后续验证会受版本限制。
3.2 正常序列化测试与漏洞观测
先用正常思路测一遍。在同一个目录下建一个gen.php脚本,输出xctf对象的正常序列化字符串:
<?php class xctf{ public $flag = 'hi'; } $x = new xctf(); echo serialize($x); ?>浏览器访问http://127.0.0.1:8080/gen.php,得到:
O:4:"xctf":1:{s:4:"flag";s:2:"hi";}这是不带攻击性的正常数据。接下来,我手动修改属性值,把hi换成任意字符串,比如abc,得到:
O:4:"xctf":1:{s:4:"flag";s:3:"abc";}注意这里s:3:"abc",长度必须是 3,不能写 5。然后把这段字符串作为flag参数提交:
http://127.0.0.1:8080/test.php?flag=O:4:"xctf":1:{s:4:"flag";s:3:"abc";}页面上输出的是wakeup executed, flag reset to hi。这正好验证了__wakeup()把abc改成hi,属性被还原。攻击者的第一次尝试是失败的,因为没有任何东西能阻止__wakeup()执行。
这个正常测试的意义在于确认两点:一是我们构造的序列化字符串格式正确,能被反序列化器识别;二是__wakeup()确实在起作用。有了这个基准,后面改属性个数才有对照。
3.3 构造绕过 payload 的完整步骤
核心操作来了。把序列化字符串里的属性个数从 1 改成 2,其余部分保持不变:
O:4:"xctf":2:{s:4:"flag";s:3:"abc";}这里说清楚为什么是 2:xctf类只有一个真实属性$flag,我们声明 2 个属性,已经造成“声明的属性个数 > 真实属性个数”的条件。只要大于实际数量就能触发绕过,写 3、写 5 也一样,但没必要,写 2 最干净。写太大反而可能在极端情况下触发内存申请问题,属于给自己找麻烦。
由于 GET 请求中双引号、花括号等字符需要做 URL 编码,最终提交的完整地址是:
http://127.0.0.1:8080/test.php?flag=O:4:%22xctf%22:2:{s:4:%22flag%22;s:3:%22abc%22;}也可以写一个小工具来生成并发送:
import requests payload = 'O:4:"xctf":2:{s:4:"flag";s:3:"abc";}' url = "http://127.0.0.1:8080/test.php" resp = requests.get(url, params={"flag": payload}) print(resp.text)requests 库会帮你做参数编码,输出里出现bypass success: your flag => abc,说明__wakeup()被成功绕过。此时对象中$flag不再等于hi,而是保留了攻击者注入的值。在攻防世界原题场景下,这个“非 hi 状态”就是触发 flag 回显的条件,拿到回显内容即解题成功。
3.4 解题脚本与自动化尝试
如果题目流程需要反复测试不同值,手工拼 URL 效率太低。我习惯用一个通用脚本,既能生成序列化字符串,也能直接发送请求:
import requests class_name = "xctf" prop_count_real = 1 prop_count_claim = 2 payload = 'O:{len}:{name}:{count}:{{s:{len_flag}:"flag";s:{len_val}:"{value}";}}'.format( len=len(class_name), name=class_name, count=prop_count_claim, len_flag=len("flag"), len_val=len("abc"), value="abc" ) resp = requests.get("http://127.0.0.1:8080/test.php", params={"flag": payload}) print(payload) print(resp.text)脚本里的prop_count_real这个变量其实没被直接用到,写它是为了提醒自己:真实属性个数是判断“是否超过”的基准。做题时我们把它硬编码成 2 就行,但想深度理解漏洞,这个对比一定得放在脑子里。
自动化尝试结束之后,再看一眼返回结果。如果页面上出现了意外的Warning或Notice,别急着忽略,很多 CTF 题把提示信息藏在报错里,下一步的路径可能就在里面。
4. 常见问题与排查技巧
4.1 为什么我改了属性个数却仍然失败
这是最高频的问题。本地测试时,如果你使用的是 PHP 7.0.10 以上版本,或者在 2020 年之后安装的 PHP 8,CVE-2016-7124 的绕过几乎不会成功。新版 PHP 在反序列化对象时,如果发现声明的属性个数和实际提供的不一致,会直接报错,__wakeup()甚至可能不会被当作安全校验逻辑来执行。
解决办法有两个:一是换一个旧版本 PHP 环境做实验,比如 PHP 5.5、PHP 5.6 低版本;二是使用 Docker 装一个对应版本的镜像,把靶机代码跑在里面。很多练手环境本身就是基于旧版本镜像,所以在线能做,本地新版做不了,这不奇怪。
另一个容易被忽略的原因是代码路径不对。有些人把测试环境搭在 PHP 新版,却以为漏洞仍然存在,反复调整属性个数的数值,从 2 试到 9999,自然没有效果。做题目之前,先确定环境版本到底受不受 CVE 影响,这是最基本的排查顺序。
4.2 序列化字符串的长度坑
长度字段是反序列化利用的重灾区。字符串类型用s:长度:内容表示,长度必须严格按照字节数计算,不能按照字符数量猜。对 ASCII 字符来说,一个字母就是 1 个字节,所以flag是 4,abc是 3。但如果属性值换成中文、特殊符号,情况就不同了,一个中文在 UTF-8 编码下通常占 3 个字节,比如字符串测试应该写成s:6:"测试",写成s:2:"测试"就会解析失败。
属性名的问题也类似。如果属性是protected,PHP 序列化之后属性名会带有不可见字符\0*\0,private 属性会带上\0类名\0。这类属性在做反序列化注入时要额外构造,直接用题目里看到的属性名往往匹配不上。不过这道题里flag是 public,正好避开了这个麻烦。
看到这里你可能明白了:序列化格式不只是一串能看懂的字符,它的每一个数字都代表解释器的某个操作。长度写错,后面的解析全部偏移,结果就是“我明明传了 payload,却什么都没发生”。
4.3 解题速查表
平时做题遇到类似问题,可以对照这张表排查:
| 现象 | 可能原因 | 处理思路 |
|---|---|---|
| 属性总是被还原成 hi | __wakeup() 正常触发 | 检查属性个数是否大于实际属性数 |
| 属性个数改了仍失败 | PHP 版本过高,CVE 已修复 | 换旧版 PHP 环境或 Docker 镜像 |
| 提交 payload 后页面空白 | 参数被截断或序列化格式错误 | 检查引号、花括号是否 URL 编码 |
| 反序列化报错 | 属性长度、类型写错 | 核对 s 类型字符串的字节长度 |
| 用单引号包属性名失败 | PHP 序列化只认双引号字符串 | 全部改成双引号并转义 |
| 本地能绕过但线上不行 | 在线环境版本不同 | 按线上返回信息重新确认版本 |
这张表也能套用到不少同类题目上。反序列化题目的表面形式千变万化,底层就是格式、版本、魔术方法这几个关键变量在来回组合。
5. 安全加固与延伸思考
5.1 如何修复这种反序列化问题
从防御者角度看,unserialize3 暴露的问题非常典型:反序列化外来输入,又依赖__wakeup()做状态校验,结果一个历史缺陷让校验直接失效。真正的修复不是把__wakeup()里多写几行判断,而是要卡住源头。
第一道防线是不要对不可信输入直接调用unserialize()。HTTP 请求参数、Cookie、外部接口传进来的字符串,都属于不可信输入。非用不可时,至少加上allowed_classes限制,例如:
unserialize($data, ['allowed_classes' => ['xctf']]);这样即使攻击者构造其他类的序列化字符串,反序列化器也不会实例化它们,能挡住一大部分利用面。
第二道防线是升级 PHP 版本,把 CVE-2016-7124 这类历史缺陷从根上排除。版本升级不能只盯漏洞编号,还得回归测试业务逻辑,防止__wakeup()行为变化影响正常流程。
第三道防线是给序列化数据加完整性校验。最简单的方式是用 HMAC 对序列化字符串签名,服务端接收后再验签,攻击者没有密钥就无法篡改对象状态。做过电商、登录态开发的人应该熟悉这种思路,本质上就是“别信任客户端任何可伪造的东西”。
5.2 从单点题目延伸:POP 链与更复杂的反序列化威胁
unserialize3 只是反序列化漏洞的入门题,它只涉及单个类、单个魔术方法。现实世界里的反序列化漏洞,更多是利用多个类之间魔术方法的连锁反应,比如__wakeup()触发__destruct(),__destruct()又调用某个危险函数,最终构成一条 POP 链。
我建议做这道题时不要止步于绕过__wakeup(),可以主动想几个问题:如果目标代码里没有把$flag还原的校验,而是直接把属性值拼接到文件路径里,能不能配合__destruct()做文件删除?如果存在一个类,它的__toString()方法会执行 SQL 查询,反序列化后把对象放进字符串拼接,是不是另一种风险?这些问题想清楚,你才算真正拥有了反序列化的直觉。
再往后还可以研究phar://反序列化。PHP 的 phar 文件元数据在解析时也会触发反序列化,哪怕代码里没有显式调用unserialize(),只要存在file_exists()、fopen()等文件操作并允许用户控制路径,就可能被恶意利用。这类攻击链比普通 GET 参数触发更隐蔽,也更贴近真实漏洞场景。
5.3 几个值得养成的实操习惯
最后说几个我刷题和做代码审计时保持的习惯。第一,拿到题目先看 PHP 版本,再看入口参数,最后才构造 payload,顺序反了容易浪费时间。第二,每次构造完序列化字符串,先在本地用serialize()生成一遍标准数据,再手动改需要改的字段,能显著减少低级错误。第三,遇到绕过不生效,不要死磕同一个思路,先把返回内容完整看一遍,很多答案会藏在报错和警告里。
这道题虽然简单,但它是一把很好的钥匙。理解__wakeup()的触发时机、理解 CVE-2016-7124 的成因、理解属性个数为什么能成为突破口,比背一百个 payload 都管用。后面无论遇到反序列化绕 PHP 版本限制的题目,还是框架漏洞分析,底层逻辑都是这些。