最近在攻防世界刷题的时候看到一道叫 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 的字符串flags:2:hi表示属性值hi。反序列化则是把这种字符串还原成对象unserialize()负责这个工作。理解这段格式很重要后面构造 payload 时所有长度、个数都必须严格匹配。比如你写s:5:flagPHP 解析器会取字符串前五个字节来当属性名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?flagO: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;}这里说清楚为什么是 2xctf类只有一个真实属性$flag我们声明 2 个属性已经造成“声明的属性个数 真实属性个数”的条件。只要大于实际数量就能触发绕过写 3、写 5 也一样但没必要写 2 最干净。写太大反而可能在极端情况下触发内存申请问题属于给自己找麻烦。由于 GET 请求中双引号、花括号等字符需要做 URL 编码最终提交的完整地址是http://127.0.0.1:8080/test.php?flagO: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( lenlen(class_name), nameclass_name, countprop_count_claim, len_flaglen(flag), len_vallen(abc), valueabc ) 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 8CVE-2016-7124 的绕过几乎不会成功。新版 PHP 在反序列化对象时如果发现声明的属性个数和实际提供的不一致会直接报错__wakeup()甚至可能不会被当作安全校验逻辑来执行。解决办法有两个一是换一个旧版本 PHP 环境做实验比如 PHP 5.5、PHP 5.6 低版本二是使用 Docker 装一个对应版本的镜像把靶机代码跑在里面。很多练手环境本身就是基于旧版本镜像所以在线能做本地新版做不了这不奇怪。另一个容易被忽略的原因是代码路径不对。有些人把测试环境搭在 PHP 新版却以为漏洞仍然存在反复调整属性个数的数值从 2 试到 9999自然没有效果。做题目之前先确定环境版本到底受不受 CVE 影响这是最基本的排查顺序。4.2 序列化字符串的长度坑长度字段是反序列化利用的重灾区。字符串类型用s:长度:内容表示长度必须严格按照字节数计算不能按照字符数量猜。对 ASCII 字符来说一个字母就是 1 个字节所以flag是 4abc是 3。但如果属性值换成中文、特殊符号情况就不同了一个中文在 UTF-8 编码下通常占 3 个字节比如字符串测试应该写成s:6:测试写成s:2:测试就会解析失败。属性名的问题也类似。如果属性是protectedPHP 序列化之后属性名会带有不可见字符\0*\0private 属性会带上\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 版本限制的题目还是框架漏洞分析底层逻辑都是这些。
