1. 先把“反序列化漏洞”这件事掰开揉碎
看到我这个标题点进来的朋友,估计有两类人:一类是刚接触Web安全没多久,正被“反序列化”这个概念劝退;另一类是已经刷过不少靶场,但总觉得这块知识是“背网上的payload”,换个场景就不会了。这篇文章就是冲着这两类人写的。
先说结论:反序列化漏洞没那么玄乎,它本质上就是一个“数据还原过程”被攻击者滥用的问题。程序要存储或传输数据,就得把对象变成可保存的字符串,这个叫序列化;用的时候再把字符串还原成对象,这个叫反序列化。问题出在哪?出在还原的过程中,程序会不自觉地去执行对象内部的一些方法,而这些方法可能是带“副作用”的。攻击者只要精心构造一个恶意对象,让这些方法帮自己干活,漏洞就发生了。
我为什么选pikachu这个靶场来讲?两个原因:一是它设计得够直白,每个漏洞模块都有独立的交互界面和详细的说明,特别适合零基础的人建立“漏洞长什么样”的直觉;二是国内很多安全资料网站过期快、配置复杂,而pikachu只需要本地启动一个PHP环境就能跑,几乎没有门槛,今天这篇文章我会把从环境搭建到漏洞利用的完整链路都讲清楚。
这篇文章适合谁?适合刚学完HTTP协议和PHP基础语法、准备进入漏洞挖掘阶段的人,也适合想系统梳理反序列化原理的在职安全工程师。我不会只给payload,我会把payload为什么这样写、每一步在干什么、程序内部发生了什么全部拆开讲。
2. 序列化与反序列化的基础认知
2.1 用生活化的例子理解对象持久化
在讲漏洞之前,我们必须先把“序列化”这个底层概念吃透。想想这样一个场景:你在游戏里打到一个稀有装备,下线前储存了游戏进度,第二天上线还能继续玩。这个“储存进度”的动作,就是把你的角色状态、背包数据变成一段可以写入文件的数据,这就是序列化;第二天登录时程序把这段数据重新读出来,还原成你的角色对象,这就是反序列化。
程序里的对象比游戏角色更复杂,一个对象内部可能包含属性(变量)和方法(函数)。序列化只保存属性和类名,不保存方法本身,因为这些方法是在类的定义里写死的,只要类还在,方法就在。想象一下你把自己的购物清单写在纸上(序列化),然后把纸交给另一个人,对方照着清单把东西买回来(反序列化)。如果这张纸上的内容被恶意篡改,对方就可能买了你根本没想买的东西。
PHP里序列化一个对象,用的是serialize()函数,输出的是这种格式的字符串:
O:4:"User":3:{s:4:"name";s:5:"admin";s:3:"age";i:18;s:6:"isVip";b:1;}我来一行行拆解这段字符串的含义:
O:4:"User":表示这是一个对象(Object),类名长度是4,类名是User3:表示该对象有3个属性s:4:"name":第一个属性名是字符串,长度4,值为names:5:"admin":第一个属性的值是字符串,长度5,值为admin- 后面以此类推,
i代表整数类型(Integer),b代表布尔类型(Boolean)
理解了格式之后,你就能明白一个关键点:序列化字符串里的属性值和数量是可控的。攻击者完全可以不通过正常代码逻辑,直接伪造一个序列化字符串,强行指定属性的值。这是反序列化漏洞的起点——数据不再可信。
2.2 为什么“还原对象”会变成漏洞
问题来了:反序列化的过程,代码只是在根据字符串格式创建对象并赋值,漏洞是怎么产生的?
关键在于PHP的“魔术方法”。所谓魔术方法,就是不需要你手动调用、在特定时机自动触发的方法,方法名固定以两个下划线开头。反序列化过程中,最常被攻击者盯上的魔术方法有以下这组:
| 方法名 | 触发时机 | 常见滥用场景 |
|---|---|---|
__construct() | 对象被创建时自动调用 | 但反序列化时不调用此方法,容易把人带偏 |
__destruct() | 对象被销毁时自动调用 | 在方法里写入文件、执行命令等 |
__wakeup() | 反序列化开始时自动调用 | 常用于重置属性或连接数据库 |
__toString() | 对象被当作字符串使用 | 拼接字符串时自动触发,可读取文件 |
__call() | 调用对象不存在的方法时触发 | 配合构造链调用任意方法 |
__get() | 访问对象不存在的属性时触发 | 读取文件、调用敏感方法 |
那攻击者为什么能控制这些方法的触发?因为反序列化字符串是攻击者自己构造的,攻击者可以指定任何类名和任何属性值。当程序反序列化这个字符串时,会创建一个指定类的对象,并将指定的属性值赋给它。这个对象在后续代码逻辑中被销毁、被拼接、被调用时,魔术方法就会触发,而方法内部如果使用了攻击者控制的属性值,就可能导致写入恶意文件、执行系统命令、发起网络请求等等。
我把整个过程串成一条逻辑链,你记住这条链就能理解所有反序列化漏洞的套路:
攻击者构造恶意序列化字符串 → 程序反序列化创建恶意对象 → 对象满足某个触发条件(销毁、拼接、异常) → 魔术方法执行 → 魔术方法中使用了攻击者控制的属性 → 造成危害
2.3 一个简单的代码示例:感受漏洞是怎么发生的
先看一段存在问题的代码,这是某个后台系统的用户管理功能,假设它会把用户对象存到日志文件里:
<?php class User { public $name; public $profile; public function __construct($name) { $this->name = $name; $this->profile = 'normal'; } public function __destruct() { // 模拟写日志操作 $log = $this->profile; file_put_contents('./logs/user_log.txt', $log); } } // 假设代码从用户输入中读取序列化数据,然后反序列化 $data = $_POST['user_data']; $user = unserialize($data); ?>攻击者构造的payload是这样的:
<?php // 攻击者构造恶意对象 class User { public $name; public $profile; } $evil = new User(); $evil->name = 'attacker'; $evil->profile = '<?php phpinfo(); ?>'; echo serialize($evil); ?>当服务端反序列化这个恶意对象时,$profile属性会被设置为恶意PHP代码。当脚本运行结束,$user对象被销毁时,__destruct()方法触发,把$profile里的内容写入日志文件。如果生成的日志文件可以被直接访问且以PHP解析,攻击者就拿到了webshell。
你看,攻击者根本没有调用任何函数,只是提交了一个精心构造的序列化字符串。这就是反序列化漏洞的杀伤力:看似只用了一个普通的输入点,实际却完成了一次任意代码写入。
3. pikachu靶场:环境搭建与漏洞模块初探
3.1 本地环境准备
pikachu靶场是某安全技术社区开源的一套Web漏洞练习平台,集合了暴力破解、XSS、SQL注入、越权、CSRF、RCE、反序列化等多个漏洞模块。我推荐在本地搭建,不要在公网部署,避免被他人扫描利用。
需要准备的环境:
- PHP 5.4及以上版本(推荐PHP 5.6或7.x,版本太高某些旧模块可能报兼容性问题)
- MySQL 5.x(用于靶场自身的数据存储)
- Apache/Nginx(需要支持PHP)
- pikachu源码包(网上搜“pikachu靶场源码”即可找到开源地址)
因为我用的是Windows环境做演示,就直接说Windows下的搭建步骤,Mac/Linux用户把路径替换一下逻辑一样:
- 安装集成环境,比如PHPStudy或者小皮面板,一键启动Apache和MySQL;
- 把pikachu源码包解压到Web目录下,比如
C:\phpstudy_pro\WWW\pikachu; - 浏览器访问
http://localhost/pikachu,自动跳转到安装页面; - 按提示配置数据库,默认配置是
root用户、密码也是root,输入后点击“安装”即可; - 安装完成进入主页,能看到左侧一排漏洞模块列表,找到“RCE”或“反序列化”模块。
安装过程中最容易出问题的就是PHP版本。pikachu有些旧代码用了非常老的语法写法,PHP 8.0以上的版本可能会抛出“Deprecated”甚至“Fatal Error”。我个人建议直接用PHP 7.4,这是兼容性和稳定性最平衡的版本。
3.2 pikachu反序列化模块的功能拆解
进入pikachu的反序列化模块,你会看到一个输入框,要求输入一个序列化的字符串。靶场将后端处理逻辑简化到了极致,核心代码大致是:
<?php class S { public $test; public function __construct() { $this->test = 'hello'; } public function __destruct() { echo $this->test; } } $data = $_POST['payload']; $obj = unserialize($data); ?>乍一看,这个模块非常简陋,但它正好给你展示了一个完整漏洞所需的最小要素:一个类、一个可控制的输入点、一个会执行动作的魔术方法。靶场里界面上有提示,它甚至把“需要构造payload”这个目标都直接告诉你了。
我第一次拿这个靶场练习时,犯了个典型错误——构建了正常的序列化字符串就提交,比如输入O:1:"S":1:{s:4:"test";s:5:"hello";},结果页面只输出“hello”。后来才明白,靶场是在等你构造恶意输入,让你亲手体会“明明看起来数据格式完全正常,但内容被篡改后会产生完全不同的结果”。
实际上,模块的核心考察点就是__destruct()方法中的echo $this->test;。如果我们把test属性设置为一个可执行代码的文件路径,再利用另一个方法来读取执行,就可以完成RCE。但靶场设计得比较温和,一般只需演示出通过反序列化改变程序行为即可。
3.3 自己动手生成第一个payload
为了让读者能完整体验,我给出一个符合靶场逻辑的payload构造过程。在本地写一个PHP脚本,模拟生成攻击字符串:
<?php class S { public $test; } $attack = new S(); $attack->test = '<script>alert("Pikachu_RCE")</script>'; echo serialize($attack); ?>运行后得到:
O:1:"S":1:{s:4:"test";s:34:"<script>alert("Pikachu_RCE")</script>";}把这段字符串粘贴到靶场输入框,点击提交,你会看到页面弹窗。如果靶场将test属性直接当作HTML/JS输出,那就相当于完成了一次XSS;如果把test属性设置为phpinfo()的代码再配合文件包含,就是RCE。
这里我想强调一个非常重要的认知:反序列化漏洞的“利用方式”完全取决于目标代码上下文中存在哪些“危险操作”。同一个输入点,如果后续代码把属性值拼接到SQL语句里,它就是SQL注入;如果拼接到HTML里,它就是XSS;如果写入文件,它就是文件写入;如果能触发代码执行函数,它就是RCE。这就是为什么反序列化漏洞的危害等级通常都很高——攻击者可以在没有代码细节的情况下,通过黑盒测试逐步探测可利用的类和方法。
4. 深层原理:POP链与魔术方法的组合艺术
4.1 什么是POP链
光靠单个魔术方法直接执行危险操作的情况,在实际漏洞中反而少见。更常见的是:目标对象自身的魔术方法并没有直接执行命令,但它调用了其他方法,或者它内部有多个属性之间可以联动,攻击者通过串联多个类和方法,形成一条调用链,最终到达危险函数。这条调用链,安全圈叫它POP链(Property-Oriented Programming,面向属性编程)。
拿一个真实业务场景举例:某CMS系统有一个FileManager类,它的__destruct()方法里有个删除文件的操作;还有一个Cache类,它的__toString()方法里有个读取文件操作;另有一个Logger类,它的__call()方法可以动态调用任何方法。攻击者为了让代码执行system()函数,需要找一条“从反序列化开始,经过一系列自动触发,最终调用system”的路径。
POP链之所以难,是因为你需要先摸清目标程序里有哪些类、每个类有哪些魔术方法、方法之间如何互相调用。这就像玩一个“多米诺骨牌”游戏,你只能推倒第一块(反序列化创建的对象),但必须确保每块骨牌的位置和方向都正确,才能推到终点。
4.2 几个关键魔术方法的实战利用模式
为了让大家看得更清楚,我列出几个常见魔术方法的利用模式和构造技巧,这些内容在实际漏洞挖掘中会反复遇到。
__destruct()的利用反序列化对象在脚本结束或显式unset()时,必定触发__destruct()。这意味着即使你忘了提交某些条件,它也会执行。构造payload时,可以把攻击属性全部塞进对象里,等待程序收尾时自动执行。
__wakeup()的绕过技巧(CVE-2016-7124)__wakeup()是在反序列化时首先被调用的方法,很多开发者会在这个方法里做属性校验,比如检查用户权限、重置敏感状态。攻击者可以通过修改序列化字符串中的“属性数量”来绕过它。举个实例:
O:4:"User":2:{s:4:"name";s:5:"admin";s:10:"isAdmin";b:1;}正常字符串声明了2个属性,如果把这个数字改成3或更大,比如:
O:4:"User":3:{s:4:"name";s:5:"admin";s:10:"isAdmin";b:1;}PHP某些版本(5.6.25以下、7.0.10以下)在反序列化时,会因为“属性数量不匹配”而直接跳过执行__wakeup(),但后续功能不受影响。这是一个经典的版本绕过漏洞,直到现在很多老系统依然存在。
__toString()与字符串拼接__toString()在对象被当作字符串使用的时候触发。比如代码中有echo $obj;或'Hello ' . $obj;。利用这个特性,攻击者可以构造一个对象,让它在拼接过程中读取文件内容并输出。结合文件包含漏洞,可以拿到服务器上的敏感文件。
__call()与动态方法调用当程序调用了一个不存在的方法时,__call()会被触发,并把方法名和参数传给它。如果__call()内部把方法名作为可变函数来调用,那就是一个“万能调用器”,攻击者可以直接向任何不存在的类方法发起调用。
4.3 一步一步构造一条简单的POP链
为了让你真正理解,我构造一个最简单的POP链演示,不涉及真实框架,但逻辑与真实漏洞完全一致。
假设目标代码里有这样两个类:
<?php class A { public $cmd; public function __destruct() { // 注意这里,把属性当函数名来调用 $func = $this->cmd; $func(); } } class B { public $arg; public function __toString() { // 危险函数,直接把参数当系统命令执行 system($this->arg); return ''; } } ?>注意,两个类之间本身没有直接联系。攻击者想利用的是:如果把A的cmd属性设置为B的实例,那么当A的__destruct()执行时,$func()实际就是执行B对象的__toString()方法,方法内部会执行system($this->arg)。于是攻击者只需要构造:
<?php class A { public $cmd; } class B { public $arg; } $b = new B(); $b->arg = 'whoami'; $a = new A(); $a->cmd = $b; echo serialize($a); ?>序列化结果是:
O:1:"A":1:{s:3:"cmd";O:1:"B":1:{s:3:"arg";s:6:"whoami";};}程序反序列化后,生成一个A对象,A的cmd属性是B对象。脚本结束时A对象销毁,触发A::__destruct(),它执行$func(),其实是调用B对象,因为B对象没有__invoke方法,但对象作为函数被调用时会尝试触发__invoke,示例中我用了__toString()是为了在拼接场景触发,实际这个链还需要调整。实战中,链的设计必须严格匹配目标代码中的自动触发时机。
从这条链你可以看出:链的本质是“属性值之间的嵌套与控制”。攻击者把一个对象塞进另一个对象的属性里,利用程序自身的函数调用逻辑,把原本不相关的类串联起来。这要求攻击者对代码执行流程有精确的理解,也是为什么反序列化漏洞利用门槛较高的原因。
5. pikachu靶场实际操作记录与避坑指南
5.1 完整操作步骤实录
为了照顾零基础读者,我把在pikachu靶场的实际操作完整记录下来,照做即可复现。
第一步:进入靶场模块
浏览器访问http://localhost/pikachu,在左侧菜单找到“反序列化漏洞”并点击。页面会出现一个输入框,标题写着“请提交一个序列化字符串”。
第二步:查看源代码(本地靶场合法操作)
右键查看源代码,找到PHP代码位置,或者直接找到靶场源码目录下的对应PHP文件。通过阅读源码,我们能看到一个名为S的类和它的__destruct()方法。
<?php class S { public $test; public function __construct() { $this->test = 'hello'; } public function __destruct() { echo $this->test; } } $data = $_POST['payload']; $obj = unserialize($data); ?>第三步:制作payload
在本地创建一个make_payload.php文件,内容如下:
<?php class S { public $test; } $obj = new S(); $obj->test = '<script>alert(1)</script>'; echo serialize($obj); ?>浏览器访问这个文件,屏幕输出序列化字符串。
第四步:提交payload
将序列化字符串复制,粘贴到靶场输入框中,点击提交。页面弹出alert(1),说明我们通过反序列化修改了类的属性,并使__destruct()方法输出了恶意代码。
第五步:扩大危害演示
把test属性改为:
<?php $obj->test = 'phpinfo();'; // 但此模块的__destruct只做echo输出,无法执行代码 // 这里展示思路:如果方法里用了include或eval,就能RCE靶场本身不提供代码执行环境,但它足够让你明白,如果将echo换成include或eval,同样思路就能打RCE。整套流程的关键在于:先看代码(审计),再构造对象(payload),最后观察触发结果(验证)。
5.2 我在实操中踩过的3个坑
坑一:提交后页面报错“Class not found”
出现这个错误是因为攻击者的payload中指定了目标环境中不存在的类。靶场代码中有一个S类,但你构造payload时把类名写成了别的,反序列化时PHP无法找到对应类定义,就会报错。解决办法:严格使用源码中定义的类名。这个“坑”其实是一个重要启示——反序列化漏洞利用的前提是目标环境中必须存在可用的类,这就是为什么在真实漏洞挖掘中,我们需要先收集目标程序使用了哪些框架、引入了哪些库,因为这些类都是潜在的攻击面。
坑二:属性数量与实际不匹配导致行为异常
刚开始尝试手写序列化字符串,经常漏写一个属性或者弄错属性类型,导致反序列化结果为false或产生Notice错误。这背后其实是PHP反序列化机制的严格性:字符串格式必须完全符合规范,比如字符串长度计算错误,会导致截断或越界。实战构造payload时,我强烈建议通过脚本自动生成,不要手写字符串,人写容易出错,脚本生成则稳如老狗。
坑三:忽略了魔术方法的触发条件
我在本地测试时,构造了一个__wakeup()主导的payload,但提交后一直没触发,后来才意识到,目标模块的代码根本没有让反序列化对象走unserialize()之后再使用它的逻辑,只是直接销毁。不同魔术方法有不同的触发时机,不是所有方法在反序列化后都会执行。构造payload前,一定要仔细阅读源码中哪些地方调用了对象、哪些地方销毁了对象、哪些地方把对象当作字符串用了。
5.3 从靶场到实战:真实漏洞场景的差异
很多人练完靶场后有个疑惑:“靶场这么简单,为什么真实系统那么难打?”答案是:靶场把“类”和“调用链”直接摆在明面上,而真实系统里你需要自己从成千上万个类中找出一条可利用的链。
真实场景下的反序列化攻击,通常逃不开这几个阶段:
- 信息收集阶段:获取目标技术栈,确定其使用的框架(如某些国内常用开发框架)或软件版本;
- 类库分析阶段:根据技术栈获取对应版本的开源代码,搜索所有的魔术方法及其中的危险函数;
- 链构造阶段:寻找一个从
__wakeup或__destruct出发,能够抵达危险函数的方法调用序列; - payload生成阶段:将调用链实现为嵌套的序列化字符串;
- 验证利用阶段:在本地搭建相同版本环境测试,确保不出意外再打目标。
这一套流程,说到底还是考验代码审计能力和对PHP底层机制的理解。靶场是帮助你建立“用手摸到漏洞”的直觉,但真正的分水岭,是你能不能在没有提示代码的情况下自己找到那条链。
6. 常见疑问与自查清单
6.1 关键问题速查表
| 疑问 | 答复 |
|---|---|
反序列化漏洞一定需要__wakeup()吗? | 不是,__destruct()、__toString()、__call()等任一带危险操作的方法都可能被利用 |
| 没有源码能挖反序列化漏洞吗? | 可以,但难度极高。黑盒环境下可以通过报错信息、信息泄露猜测类名,配合常用框架已知链尝试触发 |
| 反序列化漏洞与XXE、SSRF是什么关系? | 反序列化是一种“攻击入口”,入口后的危害取决于后续代码能干什么,它往往表现为RCE、文件读取、SSRF等具体危害 |
| PHP版本对利用有影响吗? | 影响很大,不同版本对魔术方法执行顺序、报错处理、属性数量校验的规则有差异,同一个payload在不同版本下可能完全失效 |
| 所有PHP框架都受反序列化漏洞影响吗? | 理论上只要使用unserialize()且数据来自用户输入,就有风险。但框架如果对输入做了严格过滤,或者没有可用的魔术方法链,漏洞就无法形成 |
6.2 学习自查清单:你能独立完成这些吗?
在结束正文前,我列出几个关键任务,你能独立完成,才说明这块知识点真正掌握了。
- 能手动解释一段序列化字符串中每个字符的含义;
- 能不看资料写出一个触发
__destruct()中file_put_contents的payload; - 能说出
__wakeup()绕过漏洞的原理和影响版本; - 能设计一个A类调用B类方法的微型POP链;
- 能在pikachu靶场中通过读源码的方式构造payload并成功利用;
- 知道真实环境中寻找反序列化利用链的基本步骤和思路。
6.3 防御视角:开发时如何避免反序列化漏洞
学了攻击,也得知道怎么防。很多开发者在写代码时根本没有“用户输入不可信”的意识,这才是漏洞泛滥的根源。
- 永远不要对用户提交的序列化数据直接
unserialize(),无论它是否经过加密或签名; - 如果必须保存复杂数据结构,优先使用JSON格式,JSON不存在自动触发方法的问题;
- 如果架构里必须用到PHP反序列化(比如处理会话、缓存),可以启用
allowed_classes参数限制反序列化时允许创建的类白名单; - 及时更新PHP版本和相关框架版本,很多反序列化链已经被公开并修补;
- 代码审计时,重点关注
unserialize()附近是否使用了危险函数(如system()、eval()、include()等)。
说实话,有时候最靠谱的思路是从源头杜绝,而不是事后打补丁。一个后端接口如果压根不接收序列化字符串,这类攻击就打不进来。
最后再分享一点个人经验
反序列化漏洞这块内容,我前前后后刷了不止三遍才真正感觉自己开窍。第一遍看教程,觉得“哦原来就是构造一个字符串嘛”,真到自己写payload的时候完全懵掉——类名写错、属性类型不对、魔术方法触发时机理解错,各种问题轮着来。后来我自己搭了一套“代码审计+payload构造+本地验证”的闭环流程,每个细节都亲手敲一遍,才算真正把这些知识点焊死在脑子里。
如果你正卡在某一步,我的建议特别简单:不要光看,一定要动手。从pikachu这个最基础的靶场开始,一行代码一行代码地看,一个payload一个payload地试。不要急着去啃复杂的框架反序列化链,先把“字符串怎么解析”“魔术方法什么时候触发”这两个底层问题彻底搞懂。底层通了,后面那些“看似高深”的链构造,不过是在这个基础上加了不少绕路的花活而已。
我个人还有一个习惯:把构造好的payload做版本记录,同一个漏洞分别用PHP 5.6和PHP 7.4跑一遍,看看结果差异。这个习惯帮我避了不少雷,也让我对PHP不同版本的特性有了深刻的记忆。你如果也想在这条路上走得稳一点,建议早点建立自己的测试环境矩阵。多做实验,多独立思考,这类漏洞在你眼里就不会再“神秘”了。