聊到 Web 安全靶场,pikachu 基本是绕不开的一站。里面漏洞类型挺全,从 SQL 注入、XSS 到文件上传都有,而反序列化漏洞这个模块,看起来只是让你往输入框里提交一段奇怪的字符串,但很多人在这一步卡住——不知道这段字符串是怎么来的,更不知道为什么要这样构造。这篇就用靶场实战的视角,把 pikachu 里的反序列化漏洞从原理、代码执行流程到 payload 构造、踩坑排错一根线捋完,看完你就能自己上手复现,也能顺带看明白同类题目。
反序列化漏洞属于那种“原理一听就懂,实操一踩就废”的类型。它跟 SQL 注入、XSS 不一样,没有一个固定的“万能 payload”,攻击能否成功取决于目标代码里有哪些类、哪些方法、哪些属性可控。pikachu 的好处恰恰在于把这类漏洞做成了一个小而完整的样例,分析完它,你再去碰其它框架的反序列化链,思路是通用的。
这篇适合刚刷完注入和 XSS、想往更底层漏洞走的新手,也适合老是搞不清 POP 链、魔法方法触发时机的人。我会把序列化格式一点点拆开,配合靶场源码和实际构造过程讲,最后再给一份实战问题排查表,基本覆盖我当年在这上面浪费过时间的所有坑。
1. 先把反序列化漏洞的来龙去脉整明白
1.1 序列化与反序列化到底在干嘛
做 Web 开发的都知道,程序里跑的是对象和数组,但存进数据库、写进缓存、传到另一个服务时,这些内存里的结构没法直接传输。序列化就是把对象转成一段“字符串”,反序列化就是把字符串还原成对象。PHP 里的serialize()和unserialize()就是干这个的。
用生活里的例子类比:序列化相当于你把一箱子杂物写进一张快递单,反序列化就是快递员按单子把箱子原样装好再交给你。快递单上写着“箱子里有哪些东西,分别是什么材质、多大尺寸”,PHP 的序列化字符串也一样,写着“这个对象是什么类、有几个属性、每个属性的类型和值是什么”。
<?php class User { public $name = 'pika'; public $isAdmin = false; } $user = new User(); echo serialize($user);上面这段代码输出:
O:4:"User":2:{s:4:"name";s:4:"pika";s:7:"isAdmin";b:0;}这段字符串的意思翻译过来就是:这是一个名为User的对象,有 2 个属性,属性name是长度为 4 的字符串pika,属性isAdmin是布尔值假。如果对方调用unserialize(),就能把这串字符恢复成User对象。
在正常业务里,这类字符串通常是程序自己生成、自己解析,数据源头可信。但问题就出在:如果某个接口直接接收用户提交的数据,不做任何校验就扔给unserialize(),攻击者就能完全控制这个字符串里描述的内容——类名、属性名、属性值、属性数量全都可以伪造。
1.2 漏洞的本质与攻击面
反序列化漏洞的核心一句话概括:不可信数据进入了unserialize(),导致对象被篡改,甚至代码执行逻辑被劫持。
很多人有个误区,觉得反序列化漏洞就是“改一下对象属性,把 isAdmin 改成 true”那么简单。这确实是一种利用方式,叫“属性篡改”,但真正危险的是另一条路——反序列化过程中触发魔法方法。
PHP 里有一批名字以双下划线开头的方法,会在特定时机被自动调用。比如:
- 对象被创建时触发
__construct() - 对象被销毁时触发
__destruct() - 反序列化恢复对象时触发
__wakeup() - 对象被当作字符串使用(比如
echo)时触发__toString()
攻击者不需要调用这些方法,只要构造出包含某个类对象的序列化字符串,服务端一执行unserialize(),对象在恢复、使用、销毁的某个环节中,魔法方法自己就跑了。如果某个魔法方法内部调用了危险函数,比如eval()、system()、file_put_contents(),而且参数来自对象属性,那后果可想而知。
这就是反序列化漏洞比普通逻辑漏洞难搞的原因:你不仅要看入口函数,还得把整个应用里所有可能被反序列化触发的类翻一遍,找出一条从魔法方法到危险函数的调用链。这个过程在圈内叫 POP 链挖掘,跟栈溢出里找 ROP 链一个思路。
1.3 为什么选 pikachu 靶场练手
pikachu 靶场在设计上有一点很好:它把每个漏洞都放在独立、最小化的代码片段里,不掺杂复杂的框架逻辑。反序列化模块也不例外,它直接给你一个带有魔法方法的类,然后接收输入并反序列化。没有路由分发、没有中间件、没有 Service 层,你能一眼看清数据从头到尾的流动过程。
这点对初学者极其友好。很多人在真实框架里学反序列化,被 Composer 自动加载、命名空间、抽象类继承绕得晕头转向,连类文件在哪个目录都找半天。在 pikachu 里不存在这个问题,源码就是三五行的 class,加上一行unserialize(),你花五分钟就能把整条调用链在脑子里画出来。
另一个好处是它自带交互界面。你提交一个序列化字符串,页面上直接能看到执行结果,反馈非常直观。调试 payload 时不用反复看日志,效率高很多。等你在靶场里把这个最简单的模型吃透了,再回头去看那些框架级漏洞,就会发现底层的原理完全一样。
2. 核心原理:PHP 序列化格式与魔法方法拆解
2.1 序列化字符串的语法解剖
要把 payload 写得又准又稳,首先必须能肉眼解析序列化字符串。PHP 的序列化格式并不复杂,记住一套固定规则就行:每个数据单元由“类型标识 + 长度 + 内容”组成。
常见的数据类型标识如下:
| 类型标识 | 含义 | 格式示例 | 说明 |
|---|---|---|---|
| s | 字符串 | s:4:"pika"; | 长度按字节数算 |
| i | 整数 | i:123; | 直接写数值 |
| b | 布尔值 | b:1; | 0 或 1 |
| a | 数组 | a:2:{...} | 先写元素个数,再写键值对 |
| O | 对象 | O:4:"User":2:{...} | 类名长度 + 类名 + 属性个数 |
| N | NULL | N; | 直接结束 |
对象序列化的完整格式长这样:
O:<类名长度>:"<类名>":<属性个数>:{<属性名><属性值>...}以开头那个User为例:
O:4:"User":2:{s:4:"name";s:4:"pika";s:7:"isAdmin";b:0;}注意一个细节:属性名前后的“类型”决定属性值是字符串还是其它类型,而属性名本身的可见性不同,序列化出来的名字也会有差异。PHP 5 及以后,public 属性直接写s:4:"name",但 protected 属性会变成s:8:"\0*\0name",private 属性会变成s:12:"\0ClassName\0name"。这里\0是 ASCII 码为 0 的空字节,肉眼看不见,但它确实占一个字节,所以长度要把它算进去。
这个细节是后面 payload 最容易翻车的地方。你在本地用脚本生成序列化字符串时不会出错,但一旦你拿文本编辑器手写 payload 去改属性名,很容易忽略这些不可见字符。我后面排查部分会专门讲这个。
2.2 魔法方法是漏洞的“点火开关”
前面提过几个魔法方法,这里展开讲触发条件,因为能不能弹个窗、写个文件、执行个命令,全靠踩对触发时机。
__construct():对象创建时触发。但注意,反序列化恢复对象时不会触发__construct(),这是新手最容易懵的点。你序列化的时候类构造函数跑没跑,跟反序列化没关系。__destruct():对象被销毁时触发。脚本结束、对象引用计数归零、unset 对象时都会触发。这是反序列化利用中出现频率最高的方法,因为无论你构造什么对象,反序列化完成后它终归会销毁。__wakeup():反序列化恢复对象时立刻触发。这个方法是利用者重点关注的第二个点。在某些利用场景里,如果__wakeup()会重置属性值或者阻断利用链,攻击者甚至会想办法绕过它。__toString():对象被当作字符串使用时触发,比如echo $obj、字符串拼接、strcmp($obj, 'xxx')等。
pikachu 靶场里的示例类是这样设计的:
class S{ var $test = "pikachu"; function __destruct(){ echo $this->test; } }反序列化后,S对象创建出来;脚本结束或变量被释放时,__destruct()被自动调用,它做的事情是把$this->test直接输出。那如果test属性被我们控制为一段 JavaScript 代码,输出到页面上会发生什么?对,XSS。如果test属性是某个恶意对象,而某个类的__toString()里又碰巧有危险操作,那就形成了一条 POP 链。
2.3 从 XSS 到 RCE 的利用思路
反序列化漏洞的最终效果完全取决于“目标环境里有什么”。同样的洞,放在一个只有无害输出类的应用里,最多打个 XSS;放在一个能触发system()的类存在的应用里,就能直接命令执行。
利用思路按复杂度可以分三个层次:
第一层:修改对象属性,改变业务逻辑。典型场景就是购物网站序列化存购物车,反序列化时直接恢复,攻击者把数量、单价、vip 等级改成对自己有利的值。
第二层:触发魔法方法,造成输出或文件操作。pikachu 的示例属于这一层——通过控制__destruct()中的属性来实现注入。很多 CMS、开发框架的历史漏洞也属于这一类。
第三层:构造 POP 链,实现命令执行。这个需要目标代码里存在一条调用链,例如某个类的__wakeup()调用了另一个类的方法,那个方法又调用了call_user_func(),参数可控。层层嵌套,像搭积木一样把不可信的属性值最终传导到危险函数上。这条链在源码里可能散布在多个文件,看得人头皮发麻,但拆到最后依然是“找魔法方法 + 找危险函数 + 找属性可控路径”三个动作。
pikachu 因为示例简单,通常练到第二层就能完整理解原理了。但我会在实操部分加一个第三层的小例子,演示一下“用eval类做命令执行”的构造思路,能帮你把链式思维立起来。
3. 在 pikachu 里完整跑通一次利用
3.1 定位漏洞模块并阅读源码
在 pikachu 靶场的导航里找到反序列化漏洞模块,页面一般是一个输入框,旁边有提示让你输入序列化字符串。你随便输入hello提交,页面大概率什么也不显示,或者报一串错误。这就是典型的“后端unserialize()解析失败”表现——它不是不处理你的输入,而是你给的字符串不符合序列化格式。
要彻底看明白,直接去看靶场对应功能点的源码。pikachu 的代码结构很清晰,找到反序列化模块对应的 PHP 文件,核心代码基本长这样:
class S{ var $test = "pikachu"; function __destruct(){ echo $this->test; } } $html = $_POST['data']; unserialize($html);关键就两行:接收 POST 参数data,扔给unserialize()。S类里没有构造函数,却有__destruct(),而且里面直接echo属性值。这意味着只要我们能构造一个S对象,并且把test属性设置成我们想要的字符串,反序列化结束后它就会把内容打回页面上。
有人会问:页面上不是有输入框吗,直接输入pikachu不就行?不行。因为unserialize()不认裸字符串,它只认序列化格式。你要提交的是“一个 S 对象的序列化表示”,而不是“一个字符串”。
3.2 第一步:用本地脚本生成合法序列化数据
手工写序列化字符串最大的问题就是长度容易算错。比如s:29:"..."里这个 29,多一个字符少一个字符都会导致解析失败,而且 PHP 报错信息往往比较隐晦。最靠谱的做法是:本地写一个 PHP 脚本,用serialize()自动生成。
我这里模拟靶场里的类,写了一小段生成脚本:
<?php class S{ var $test = "pikachu"; } $payload = new S(); $payload->test = '<script>alert("xss")</script>'; echo serialize($payload);把这段脚本放到本地 PHP 环境里跑一下,会输出:
O:1:"S":1:{s:4:"test";s:35:"<script>alert("xss")</script>";}然后把这个字符串原样复制,作为data参数提交。
这里有个细节:<script>中如果用了双引号,在序列化字符串里不会转义,但如果你后续把 payload 拼到 HTML 里,页面可能会解析出错。所以测试时建议把 alert 里的引号统一用单引号,或者直接在 Payload 里写成alert(/xss/)这种不需要引号的形式,省得被引号问题干扰判断。
不过说实话,更实用的生成方式是在本地写一个带输出功能的完整脚本,直接输出最终要提交的 URL 编码形态。因为你在靶场提交时,尤其是用 Burp Suite 重发的时候,很多特殊字符会干扰传输。标准做法是给字符串里的关键字符做 URL 编码。
3.3 第二步:构造 PoC 提交验证
打开浏览器,在靶场输入框里提交刚才生成的 payload。如果一切正常,页面会弹出xss的警告框,或者直接在页面上渲染出一段 JavaScript 代码。这就是反序列化漏洞被成功利用的直接证据。
用 Burp Suite 会更清晰。把data=O:1:"S":1:{s:4:"test";s:35:"<script>alert(/xss/)</script>";}作为 POST body 发过去,然后看响应里出现了什么。你会发现服务端把test属性的值原封不动地反射到了响应体中。这说明整个链路是通的:你控制属性值 -> 反序列化创建对象 -> 对象销毁触发__destruct()-> 危险操作执行。
这一步成功之后,你可以顺手做个小实验:把test属性改成正常字符串,比如hello world,提交后页面上就会输出hello world。这能验证一个关键事实——服务端确实在输出对象的属性值,而不是碰巧对提交的字符串做回显。区分“反射型输出”和“反序列化对象输出”很重要,后者才证明漏洞根本原因在unserialize()上。
3.4 第三步:向 RCE 方向延伸
XSS 是反序列化漏洞最轻的利用方式。如果你的目标环境里存在一个更危险的类,比如:
class R{ public $cmd = 'whoami'; function __destruct(){ system($this->cmd); } }那只需构造对应的R对象:
O:1:"R":1:{s:3:"cmd";s:6:"whoami";}反序列化结束后,__destruct()会调用system('whoami'),直接把命令执行结果输出到页面。这就算简单 RCE 了。
再进一步,如果目标类不存在system()这种现成的危险函数,但存在call_user_func()、preg_replace()带/e、文件写入函数这类“间接危险调用”,就需要构造多级对象的 POP 链。构造 POP 链的核心方法就三步:
- 从反序列化入口出发,找到第一个会被触发的魔法方法。
- 看这个方法的代码,找到它调用了什么其它方法或函数。
- 如果调用的目标函数的参数来自某个属性,就往这个属性里塞一个“能够利用的对象”,然后递归分析这个对象的类里有没有可利用的魔法方法。
这个方法说明起来有点抽象,但你在 pikachu 环境里可以自己加一个带链式调用的类来练。比如 A 类的__destruct()里调用了B类对象的某个方法,B 类的那个方法里又调用了C类的某个方法,最后 C 的方法里有一个eval()接收属性值。你只需要构造一个A对象,它的属性里装一个B对象,B 对象的属性里装一个C对象,C 的属性里装上命令字符串,反序列化一次,整条链自动滚起来。
这个“装”的过程,在序列化字符串上表现出来就是层层嵌套的花括号。如果你手工构造嵌套对象,很容易数错括号,所以还是那句话,能用脚本生成就不要手写。
4. 实战中的常见坑与排查技巧
4.1 最常见问题速查表
反序列化 payload 提交之后不生效,原因通常比较集中。我整理了一张排查表,基本覆盖了我在靶场和真实环境里踩过的坑:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 提交后页面空白 | 序列化格式不合法,unserialize()直接返回 false | 用serialize()生成 payload,别手写 |
| 页面报“Class not found” | 目标类名写错或环境里没有这个类 | 检查源码,确认类名大小写完全一致 |
| 属性没生效 | 属性名/长度算错,尤其 protected/private 的空字节没处理 | 用脚本生成,别在编辑器里手动改 |
| 提交后没弹窗 | 浏览器把<script>过滤了 | 用 Burp 看响应原文,确认是否反射在页面中 |
| 反序列化对象后什么都不执行 | 魔法方法名拼写错误,或方法里没有危险操作 | 检查源码里是__destruct还是__wakeup |
| 修改了序列化字符串导致解析错误 | 长度计数没更新,最常见 | PHP 里serialize()生成后再改值 |
4.2 三个我踩过的高频坑
第一个坑是手改序列化长度。早年我习惯从网上粘一段现成的序列化字符串,然后把里面的值改成自己的,比如把字符串里的s:4:"test"改成s:5:"test1"。长度改了,值也改了,但花括号或结尾分号漏改了一个。PHP 的unserialize()遇到这种格式错误不会报明显异常,而是默默返回 false,整个页面看起来像什么都没发生。排查了半小时才发现是结尾少了个分号。
第二个坑是可见性字符\0。PHP 5 以后,protected 和 private 属性在序列化时,属性名里会带不可见字符。比如 protected 属性cmd会序列化成s:6:"\0*\0cmd"。这里长度为 3 个\0加3个可见字符一共 6。如果你用文本编辑器看到了*但没看到前面的空字节,直接复制粘到 HTTP 请求里,空字节丢了,反序列化就会失败。这种错最恶心——payload 肉眼看着完全正确,就是不生效。唯一稳妥的办法就是让serialize()自己生成。
第三个坑是环境差异。本地生成的序列化字符串和目标环境 PHP 版本不一致,可能会导致某些魔法方法的调用行为不一样。比如 PHP 7.4 和 PHP 8.x 对某些特殊属性处理略有差异。靶场环境通常固定,但如果你把靶场里的攻击思路搬到别的系统,别忘了先确认对方 PHP 版本和相关类源码。
4.3 防御思路:怎么封住这条链路
搞渗透的不能只会打不会防,看完漏洞利用,还是要聊聊怎么修。反序列化漏洞的防御说起来很简单,做起来全是细节。
最直接的方案是:不接收用户输入反序列化数据。但现实业务里很多老系统就在用,改不动,那就做以下几件事:
第一,白名单校验类名。PHP 的unserialize()支持第二个参数,限定只允许反序列化某个白名单内的类,其它类一概拒绝:
$data = unserialize($input, ['allowed_classes' => ['User', 'Order']]);这个用法能把大部分攻击类直接挡在门外。
第二,数据完整性校验。在序列化内容里加签名,比如 HMAC,读取时先验签再反序列化。这样攻击者哪怕能控制传入数据,没有密钥也生成不了合法签名。
第三,业务层替代方案。很多场景下序列化并不是唯一选择,换成json_encode+json_decode,虽然不能完全免疫逻辑漏洞,但至少不会触发魔法方法,不存在 POP 链问题。
如果你是在做代码审计,重点是全局搜索unserialize(调用,逐个检查入参是否可控。凡是$_POST、$_GET、$_COOKIE、请求头等外部数据直接进入unserialize()的,基本就是高危点。再顺着这些入口往类定义和魔法方法里追,很快就能画出风险面。
4.4 如果继续深入,下一步可以练什么
pikachu 里的反序列化模块是入门模型,吃完之后建议按这个顺序继续进阶:
第一站:把 PHP 内置的SoapClient、Phar等反序列化 gadget 了解一遍。Phar 反序列化尤其有意思,它不需要直接调用unserialize(),只要目标代码对 Phar 文件执行file_exists()这类文件操作,就会自动触发反序列化。这个思路打破了很多人的惯性——原来入口不是明面上的unserialize()。
第二站:找一个真实框架的历史反序列化漏洞来分析,跟着公开分析文章把利用链画出来,对比一下“框架自动加载机制”在链构造里起的作用。你会发现类名能不能被自动加载、命名空间怎么处理、属性类型怎么限制,这些都会影响最终 payload 的形态。
第三站:花点时间研究一下 Java 的反序列化,虽然语言不同,但 gadget 链的挖掘思路跟 PHP 如出一辙。到时候你回头看 pikachu 这个示例,会感到它小归小,但把反序列化漏洞最底层的逻辑讲得非常透。
我自己在练习过程中最大的体会是:反序列化漏洞不要背 payload,要背“触发链”。你现在能把这个S类的输出链条从unserialize()一路讲到__destruct()再到页面反射,那之后遇到任何复杂的反序列化洞,你只需要重复“找入口 -> 找魔法方法 -> 找危险函数 -> 构造属性链”这套流程。另外再提醒一句:所有攻击验证仅限本地靶场和授权环境,把原理吃透,比提交出一个弹窗有用得多。