2020年那场国内头部CTF赛事的Web方向里,AreUserialz 这道题分值占了14分。很多人第一眼看到题目名会以为手滑打错了,仔细读一下才发现,它就是“Are you serialized”的缩写变形,直译就是“你被序列化了吗”。看到这个名字,基本不用猜考点了,方向直指PHP反序列化。这类题在CTF里属于性价比很高的一种:只要你能建立序列化格式、魔术方法和POP链的完整图景,拿分速度很快;反过来,如果连这几个概念都还没分清,那很可能卡到比赛结束也交不出东西。这篇复盘我不会只甩一个最终payload,而是把前置知识、通关思路、本地调试和赛后防御一起讲清楚,尽量做到让你看完就能在自己的环境里复现同类型题目。
1. 点开这道题之前,先看清考的是什么
1.1 题目名已经把考点剧透了一半
CTF出题人给题目起名字时经常玩谐音梗,AreUserialz 就属于这种。它把 “serialized” 拆成 “Userialz”,又在前面放了个 “Are you”,把“反序列化”这件事直接写在了脸上。对于选手来说,看到这种命名惯例,第一反应就应该是:这题大概率有一处参数会被unserialize()处理,而且服务端可能有某个类中存在可利用的“魔术方法”。
14分在当年的题目体系里是一个中低分档。这个分档很微妙,它告诉你题目并不复杂,不需要堆砌太多漏洞利用技巧,也不需要你懂得汇编、堆布局之类的东西,真正考察的是你对一个具体知识点的敏感度。很多第一次接触反序列化的人会陷入一种误区,觉得必须能不看资料写出复杂的利用链才算会,实际上CTF里大部分反序列化题只要你能找到触发点、能构造出可控对象,就已经解决了八成问题。
我拿到这个标题时的处理顺序是这样的:先把题目名拆词根,锁定serialize / unserialize;再在脑海里过一遍PHP里所有能作为“利用钩子”的魔术方法;最后准备一套本地PHP环境,一旦入口参数被确认,马上开始构造对象链。这个流程看起来很机械,但正是这种固定动作,让14分题目的实际耗时被压到了很短的范围内。
1.2 打开页面后的第一轮侦察
题目页面很简单,打开后是一个类似信息查询的界面,表面上没有太多交互点。我习惯做一套固定的信息收集动作,按顺序检查几类常见入口:
| 检查项 | 目的 | 常见结果 |
|---|---|---|
| 页面源代码与前端脚本 | 找注释、隐藏表单、接口地址 | 经常有路径提示 |
| 浏览器开发者工具网络面板 | 看请求参数、响应头、Cookie | 检查有无可疑自定义字段 |
| robots.txt、备份文件、隐藏目录 | 找源码备份 | 常见www.zip、index.php.bak |
| 请求参数的输入输出反应 | 判断参数传递方式 | 发现data参数存在 |
| 响应头里的框架特征 | 判断语言与框架 | PHP项目特征明显 |
这一步的核心思路就是“以最小代价拿源码”。反序列化题目如果没有源码,单靠黑盒盲打非常难受,因为你不知道服务端有哪几个类、每个类有什么属性。原题虽然没有直接给源码,但页面交互里却留了一个很典型的痕迹:当我用一个普通字符串作为输入时,页面输出结果看起来像是对象某个属性的值;当我把参数改成带签名要求的样子,又会得到不同的提示。这种“同样入口、不同类行为”的表现,基本可以判断入口处做了unserialize后的多态分发。
比较有意思的是,我当时尝试提交一个普通字符串,页面原样返回了字符串;随后提交一个用 PHP 语法创造的简单对象序列化字符串,页面输出了对象属性里存的内容。这个现象说明两件事:第一,输入必须经过某种编码再交给服务端;第二,服务端会直接对对象属性做字符串拼接或输出,触发了一个值得追下去的调用点。
如果你在线上比赛里也遇到这种反馈,不要急着猜,先把请求参数和各种报错响应记录下来,然后在本地起一个最小环境,把候选的类结构写出来跑,对比一下行为是否一致。后面的流程,我会用整理过的本地版源码来说明。
2. 前置基础:PHP反序列化到底在干什么
2.1 序列化格式和反序列化的危险点
要玩转这类题,首先得理解PHP对象序列化长什么样。简单说,serialize()是把内存中的对象“拍照”成一个纯字符串,unserialize()则是拿到字符串后,按格式在内存里重建对象。这个格式本身是明文、可构造的,比如一个简单的用户对象序列化后可能长这样:
O:4:"User":2:{s:4:"name";s:5:"admin";s:3:"vip";b:1;}拆开来看含义很清楚:O表示对象,长度为4的类名User,两个属性,其中name是长度为5的字符串admin,vip是布尔值真。任何能看懂这个格式的人,都可以手工修改属性值,比如把b:1改成b:0、把admin改成别的字符串。
反序列化的危险性就来自这里:当你把一个对象字符串交给服务端时,服务端会按照你指定的类名和属性值重新创建对象。如果这些属性在后续代码里被直接使用,攻击者就能控制对象的内部状态,进而影响程序判断逻辑,甚至通过属性值把危险文件路径、命令参数等带进危险函数。
把序列化理解成“给对象拍了一张信息完整的照片”,反序列化就是“凭照片复活”。正常程序里,照片是程序自己拍的,内容可信;但如果你开放了一个入口,让外部用户可以上传照片,那么照片上任何一个像素都可能被篡改。反序列化漏洞的本质,就是信任了外部提供的“照片”,而没有验证里面的内容是否是程序自己生成的。
2.2 魔术方法:CE利用链的钩子
光有可控属性还不够,必须要有代码主动去使用这些属性。PHP中有一类方法不需要手动调用,会在对象生命周期到达特定节点时自动执行,称为魔术方法。反序列化题目里最常见也最重要的几个是:
| 魔术方法 | 触发时机 | 利用价值 |
|---|---|---|
__wakeup() | 反序列化恢复对象时 | 可绕过属性校验、重新赋值 |
__destruct() | 对象被销毁时 | 可以在脚本末尾执行代码 |
__toString() | 对象被当作字符串使用 | 常用于文件读取、拼接绕过 |
__call() | 调用不存在的方法时 | 转发调用、控制函数名 |
__get() | 访问不可访问属性时 | 读取文件、返回可控内容 |
__invoke() | 对象被当作函数调用时 | 控制回调函数 |
对于 AreUserialz 这类题目,最值得关注的是__toString()。因为页面输出时经常会出现echo $obj->某个方法()或者$obj->属性这类写法,当属性值本身是一个对象时,PHP会把对象强制转换成字符串,这时该对象如果有__toString(),就会被自动调用。
这里最容易犯的误区是:很多人以为必须要找到一个能直接执行命令、读取文件的“主入口方法”,于是死盯着__destruct不放。但实际赛中大量题目利用的都是__toString这种看起来不太起眼的转换钩子。它的触发成本极低,只要代码里存在字符串拼接或输出对象,就有机会利用。
2.3 POP链和面向属性编程
反序列化利用里经常听到一个词叫“POP链”,全称是 Property-Oriented Programming。它和传统的内存破坏利用链思路不一样,不需要覆盖返回地址、不需要ROP gadget,而是把不同类对象的属性串联起来,让魔术方法之间形成调用关系,最终走到危险函数。
我习惯用一个非常生活化的类比:POP链不是一段连续爆破的炸药,而是一排多米诺骨牌。你只需要在外面控制第一块牌倒下的方向,后面的牌就会依次倒下。在PHP里,第一块牌就是你反序列化出来的入口对象,后续每块牌则是对象被操作时自动触发的魔术方法。
这套思路具体到实践中有几个要点。第一,服务端所有类你都应该尽可能收集完全,因为题目源码可能只展示一部分类,其他类要靠代码审计或备份文件补齐。第二,不要只盯着“执行命令”这一个终点,很多反序列化题的终点是文件读取,读取到源码后再进行下一步利用。第三,构造对象时需要精确控制每个属性的访问级别,public、protected、private字段在序列化字符串里的表示方式不同,protected字段名会带上\x00*\x00,private字段名会带上\x00类名\x00,手工拼错一个字节都会导致反序列化失败。
一句话总结:POP链考察的不是“你能不能背出最复杂的链子”,而是“你能不能从现有代码里找到一条可走通的调用路径”。
3. 完整通关过程还原
3.1 入口参数与源码获取
结合题目页面表现和侦察结果,我认为最合理的入口点是一个名为data的GET参数。为了验证,我在本地复现了一套和赛题结构一致的环境,核心入口代码如下:
<?php // index.php error_reporting(0); include "class.php"; $data = $_GET['data'] ?? ''; if ($data !== '') { $obj = unserialize(base64_decode($data)); echo $obj->getName(); }这里的关键点有三个:参数值会先做一次base64_decode,然后直接进入unserialize,最后把反序列化结果调用getName()并输出。第一眼看上去,入口只允许调用getName(),似乎限制了利用面,但PHP的多态特性决定了,只要反序列化出来的对象有getName()方法,就会按它所属类的实现执行。
源码里除了index.php,还有一份class.php,这是整道题真正的核心。本地复现时我按赛中行为整理出了几个关键类:
<?php // class.php class Guest { public $content; public function getName() { return $this->content; } } class User { public $name; public $level; public function getName() { if ($this->level === 'vip') { return $this->name; } return 'forbidden'; } } class Flag { public $filename; public function __toString() { return file_get_contents($this->filename); } }如果你在真实环境里审计,看到类似的类结构,第一反应就应该是:Guest::getName()直接返回content,而content可以是任何类型;Flag::__toString()会读取filename指定的文件。这两个类一旦通过属性串联起来,就能形成一条完整的读取链。
3.2 源码审计:找出触发点
审计这个源码时,不能只盯着危险函数file_get_contents,更关键的是找到“谁会把Flag对象当成字符串输出”。回头看Guest::getName(),它返回$this->content,而index.php又对返回值做了echo。在PHP里,echo一个对象会触发该对象的__toString(),所以只要让Guest对象的content属性保存一个Flag对象,代码执行路径就会自动走进Flag::__toString()。
这条链路可以梳理成非常清晰的三步:
- 反序列化得到一个
Guest对象,它的content属性是Flag对象。 index.php调用$obj->getName(),得到Flag对象。echo输出Flag对象时,自动调用Flag::__toString(),读取filename属性指定的文件并返回内容。
这就是一条典型的POP链,链子的起点是外部可控的data参数,终点是file_get_contents读取文件。中间没有任何额外的检查。在赛题场景下,目标文件通常就是当前目录下的flag.php,文件内容里要么直接是flag明文,要么被设计成需要进一步读取的源码文件。
3.3 构造利用链并执行
确定触发链路后,下一步就是构造序列化字符串。这一步我建议不要在脑子里空想,直接在本地PHP环境里写一个生成器,让PHP帮我们完成序列化,避免手工拼格式出错。生成器的逻辑非常简单:
<?php // payload_gen.php include "class.php"; $flag = new Flag(); $flag->filename = "php://filter/convert.base64-encode/resource=flag.php"; $guest = new Guest(); $guest->content = $flag; echo base64_encode(serialize($guest));这里有一个细节值得说明:为什么filename要设置成php://filter/convert.base64-encode/resource=flag.php,而不是直接写死flag.php?因为echo输出时如果file_get_contents直接返回PHP文件源码,源码里的<?php等内容很可能被页面解析或导致输出异常,使用php://filter的Base64编码过滤器,可以让文件内容先转成Base64字符串再返回,这样即使页面有HTML实体过滤,我们也能还原出原始内容。
运行生成器后得到一串看起来乱码般的Base64文本,复制进浏览器地址栏时需要注意:序列化字符串里可能带有+号或/号,尤其是+号在URL查询参数中会被解析成空格,导致服务端Base64解码失败。比较稳妥的做法是用URL编码后的完整参数提交,或者直接在请求工具里发送请求。
最终请求大概长这样:
GET /index.php?data=TzoyODo…服务端按顺序解码、反序列化、调用getName、触发__toString,页面最终会返回一段Base64编码内容。把这串内容复制到本地解码,就能拿到flag.php内的明文,进而得到题目要的flag。整个过程不涉及任何暴力破解,也没有复杂的绕过,只要链路理解到位,几分钟就能完成。
3.4 读取目标文件与后续延展
这道题到读取flag.php就能拿分了,但实战中如果你发现目标文件不是flag.php,而是一个类似flag.txt或根目录下的/flag,只需要把filename属性替换成对应路径就行。甚至可以利用php://filter链读取更多源码文件,把class.php、index.php都翻出来,进一步确认是否存在命令执行点。
这里也想提醒一句:有些同类型题目会故意在__toString里做字符串拼接,比如return "flag:".file_get_contents($this->filename);,这种情况下直接输出Base64编码内容反而会被前缀污染。如果按常规方法读不到完整内容,可以试试convert.base64-encode过滤器,或者在读取时额外套一层convert.iconv编码转换,这些都是在调试中值得尝试的变体。
4. 调试过程与常见卡点
4.1 本地环境搭建时的版本细节
反序列化题目和PHP版本绑定非常紧,很多选手线上打不通,问题往往出在本机PHP版本太新或太旧。通俗点说,不同PHP版本对属性类型校验、魔术方法触发顺序、访问控制修饰符的处理并不完全一致。
我搭建本地环境时,优先选了与比赛常用版本匹配的PHP 7.x容器环境,原因很简单:unserialize()的第二个参数allowed_classes是PHP 7.0新增的,老版本没有;而__wakeup绕过这类经典操作又和PHP 5.x、7.x的行为差异很大。如果你复现时发现payload在本地能触发、在线却不行,先检查两边的PHP版本是否一致,再看目标有没有使用框架级的包装器。版本是这类题最容易被忽略却杀伤力最大的变量。
另外,本地复现时不要只用一个PHP文件。题目中include "class.php"的结构决定了,生成payload的脚本必须能加载同一份类定义,否则反序列化时会因为类不存在而生成__PHP_Incomplete_Class对象,后续任何魔术方法都不会触发。我见过很多人把class.php里的类复制到生成器里改了一两个属性名,结果序列化字符串里的类名对不上,白白折腾很久。
4.2 我在这道题上踩过的三个坑
第一个坑是URL编码问题。base64_encode(serialize($guest))的结果集里包含+字符,这个字符在GET请求的查询参数里会被解释为空格,导致服务端解出来的Base64字符串少了几个有效字节,反序列化直接报错。正确做法是把请求放到请求工具里发送,或者手动把+替换成%2B再提交。这个小细节看起来不起眼,却是很多第一次交同类题的人最常见的失败原因。
第二个坑是文件路径判断错误。拿到class.php后,我一度以为目标文件在根目录,把filename设置成了/flag,结果读取失败。后来才意识到题目工作目录是Web根目录,flag就在flag.php或同级文本文件里。遇到读取失败时,与其在那干猜路径,不如先用php://filter读取index.php确认目录结构,再决定下一步。
第三个坑是过度设计payload。刚开始做这类题时,我会忍不住往链子里塞很多类,总觉得不绕几道关卡就不够“高级”。实际上,把入口到危险函数之间的调用关系画出来,越短越稳。一道14分题目的合理链子往往只有两到三个对象,多余的中转类只会增加序列化格式出错的概率。
4.3 工具与检查清单
做这种题我没有用很复杂的工具,一个轻量本地PHP环境、一个请求调试工具就够了。但如果想提高效率,我建议把下面的检查清单存在本地,遇到任何反序列化题都过一遍:
| 排查项 | 具体动作 |
|---|---|
| 入口位置 | 找到所有外部可控参数,是否经过base64、json等编码 |
| 危险函数 | 列出file_get_contents、eval、call_user_func等函数 |
| 魔术方法 | 搜索所有类中的__toString、__destruct、__wakeup、__call |
| 输出位置 | 确认哪些位置会echo、拼接或转换对象为字符串 |
| 类加载方式 | 确认反序列化前是否已加载目标类定义 |
| 版本差异 | 确认本地与目标PHP版本一致 |
这套清单的作用不是教你怎么攻击,而是帮你建立稳定的代码审计坐标。无论题目怎么包装,最终一定有人把不可信数据交给unserialize,一定存在某个方法会被自动触发,也一定有一个危险函数在链子的末端等着。顺着三个坐标去找,就不会在干扰信息里迷路。
5. 从赛题到防御:反序列化漏洞在生产里长什么样
5.1 为什么审计和修复比payload更有价值
比赛里拿分是一回事,回到真实开发里,反序列化漏洞是实打实的高危风险。很多生产系统会把用户会话、登录状态、购物车数据直接序列化后存进Cookie或缓存,一旦Cookie内容可控,攻击者就可以直接构造恶意对象。另一种常见场景是消息队列或缓存客户端,开发者为图方便把整个对象serialize后写入存储,读取时直接unserialize,中间没有任何类型校验。
所以在复盘这类赛题时,我都会建议大家额外做一个动作:把解题思路反向翻译成审计思路。当你看到代码里出现unserialize($userInput)时,第一反应不是“怎么打”,而是“这里为什么必须反序列化用户输入”。大多数时候,这个需求都可以用JSON、DTO对象列表替代,完全不需要让外部用户控制类名和属性。
就算必须用unserialize,PHP 7以上也提供了能力限制可反序列化的类:
$obj = unserialize($data, ['allowed_classes' => ['User']]);加上这个白名单之后,就算攻击者传入一个Flag对象的序列化字符串,也会被强制变成__PHP_Incomplete_Class,无法触发__toString等魔术方法。这个开关成本极低,却能把大部分反序列化利用直接挡在门外,是真实代码改造里性价比最高的修复方式。
5.2 三道相关练习题的扩展思路
如果你想通过更多训练把这类题吃透,我建议从三个不同的变种入手。第一个变种是“入口在Cookie里”,特点是反序列化数据藏在加密或编码后的Cookie字段中,你需要先破解编码方式才能构造数据。第二个变种是“触发点在__destruct”,这类题没有显式的输出点,需要利用文件写入、请求外带等方式确认触发效果。第三个变种是“存在__wakeup约束”,目标类在反序列化时会强制重置属性,需要理解老版本PHP中绕过该方法的原理,或者找一条不经过__wakeup的链。
这三个变种覆盖的信息收集、格式构造、版本差异,基本就是反序列化题目的大半内容。练完之后你会发现,真正困难的地方不在于记住某些函数,而在于你能不能快速把代码中的调用关系图在脑子里画出来。
回到 AreUserialz 这道题本身,我觉得它最值得学习的地方其实不是那串payload,而是出题人用最简单的方式,把serialize、unserialize、__toString、POP链这些概念串成了一个完整的场景。把场景拆开练透,下次再遇到“AreUserialz”这样看起来莫名其妙的题目名,你就知道该从哪里下手了。