☰
PHP反序列化漏洞实战:从魔术方法到__wakeup绕过全解析
2026/10/19 19:28:45 网站建设 项目流程

1. 题目初识与核心考点拆解

最近我在攻防世界的WEB区刷题时,遇到了这道经典的老题——Web_php_unserialize。说实话,这类PHP反序列化题目在CTF里几乎属于“见面分”级别的存在,但里面的细节却一点也不简单,尤其是针对新手来说,光是那几个符号和URL编码就足够卡半天了。我今天把完整的分析过程和实操踩坑记录都整理出来,希望能让你少走弯路。

先说这题的基本信息:它属于WEB类型的题目,核心考查PHP反序列化漏洞,难度定级不高,但涉及的知识点非常集中——PHP魔术方法、反序列化逃逸、正则绕过、变量覆盖以及URL编码规则。这道题在各种CTF训练平台里反复出现,也被很多人拿来当作PHP反序列化的入门教学题,原因就是它的考点虽然基础,但每一环都恰好卡在常见的认知盲区上。

题目本身提供了一个PHP源码,你需要通过阅读这份源码,找到利用链,最终读取到flag文件的内容。它的考点说白了就三层:

  • 第一层是常规的反序列化POP链构造;
  • 第二层是绕过正则对O:开头的拦截;
  • 第三层是在构造payload时处理好URL编码与序列化字符串的对应关系。

很多人在第一层就卡住了,因为对PHP魔术方法的触发时机理解不到位;更多的则是死在了第二层的正则绕过,我见过不少朋友直接把O:4改成一个大写字母就提交,结果自然是浪费时间。下面我从头到尾,把整道题的画面完整还原一遍,你需要跟着思路走一遍,而不是只抄结果。

2. 反序列化漏洞的原理基础

2.1 为什么序列化数据会被攻击

在讲题目之前,我先把PHP序列化机制的关键点理一遍。PHP中的serialize()函数能够将对象或数组转换成一段可存储、可传输的字符串,反过来unserialize()则能将该字符串恢复成原有的数据结构。这种机制在开发中非常常见,比如Session存储、Cookie数据存取、缓存系统、以及一些需要跨请求保留状态的功能里都会用到。

问题在于:unserialize()本身是一种“不安全的反序列化”操作。如果反序列化的数据源不可控,比如直接取自用户输入、来自URL参数、来自Cookie,那么攻击者就可以构造一个恶意的序列化字符串。反序列化的时候PHP会创建对应的对象,而在创建和销毁对象的生命周期中,会触发一系列魔术方法。这些魔术方法的名称固定、触发条件明确,如果代码中恰好存在可利用的方法,攻击者就能实现任意代码执行、文件读取、命令注入等严重危害。

生活化地理解:序列化字符串就好比是一张提货单,上面写着“给我一个型号为X的对象”。unserialize()拿着这张单子去仓库提货——它不检查货品真假,只负责按照型号生产对象。如果提货单是攻击者伪造的,而且仓库里的某个对象初始化时会去执行一个危险动作,那问题就大了。

2.2 常用魔术方法的触发时机

PHP中与反序列化相关的魔术方法主要有:

方法名触发时机常见利用
__wakeup()反序列化恢复对象时自动调用重新初始化资源,修改属性值
__destruct()对象被销毁时自动调用执行清理操作,也常被利用来触发漏洞
__construct()对象被new创建时调用初始化属性
__toString()对象被当作字符串使用时调用读取敏感文件或返回特定内容
__get()访问不可访问属性时调用返回动态内容
__call()调用不可访问方法时调用路由转发、动态执行

在反序列化攻击中,__wakeup()和__destruct()属于高频入口。因为PHP在反序列化过程中会创建对象,当对象生命周期结束时__destruct()必然会被触发,攻击者只要控制对象属性,让__destruct()里的逻辑走向一个可被利用的方向,就能达到目的。

2.3 序列化字符串的结构解析

一个典型的序列化字符串长这样:

O:4:"User":2:{s:4:"name";s:5:"admin";s:8:"is_admin";b:1;}

我来拆解一下这里的每个字符的含义:

  • O:4:"User"表示这是一个对象(Object),类名长度为4,类名是User;
  • :2:表示该对象有2个属性;
  • 大括号内是属性列表;
  • s:4:"name"表示这是一个字符串类型的属性,属性名长度为4,属性名为name,后面跟分号和值;
  • b:1表示布尔类型的属性值,值为true。

如果你要手工构造payload,必须保证长度和格式一个字符都不能差,否则unserialize()会直接返回false,你的整个利用就失败了。这道题最终就是要求你手工构造这样一段序列化字符串,并通过URL参数传给目标服务器。

3. 题目源码分析:逐行读懂逻辑

3.1 代码全貌与文件结构

这道题在攻防世界里的源码结构大致是这样的:页面会直接展示一段PHP代码,你需要登录后台代码审计,找到一个输入点,在其中传入你的payload。代码如下(这里做脱敏和简化,核心逻辑保持一致):

<?php class Demo { public $file; public function __construct($file) { $this->file = $file; } public function __destruct() { echo @highlight_file($this->file . '.php', true); } public function __wakeup() { $this->file = 'index'; } } if (isset($_GET['var'])) { $var = preg_replace('/[oc]:\d+:/i', 'O:4:', $_GET['var']); unserialize($var); } ?>

这就是整道题的核心代码。咱们逐行拆解:

  • 定义了一个Demo类,包含一个公有属性$file;
  • __construct($file)在对象创建时给$file赋初值;
  • __destruct()在对象销毁时执行highlight_file($this->file . '.php', true),也就是说它会高亮显示$file属性指向的文件内容,并追加.php后缀;
  • __wakeup()则在反序列化时被触发,会将$file强行重置为index;
  • 页面接收GET参数var,先用正则/[oc]:\d+:/i过滤,再交给unserialize()处理。

3.2 关键逻辑链:从可控点到文件读取

咱们要利用的目标非常清晰:

  1. 通过unserialize()传入一个Demo类的对象;
  2. 让$file指向我们想读取的文件,比如flag;
  3. 对象被销毁时,__destruct()会执行highlight_file('flag.php', true),从而把flag文件的源码直接打印出来。

但这里有两个阻拦:

  • 第一道坎:__wakeup()会在反序列化时把$file强制改成index,我们必须绕开它;
  • 第二道坎:正则/[oc]:\d+:/i会将所有O:开头的对象声明以及C:开头的类声明都替换成固定的O:4:,这样我们的类名长度就和实际内容对不上了,反序列化直接失败。

3.3 正则替换的逻辑陷阱

从正则入手分析:/[oc]:\d+:/i匹配的是o:或c:开头、后面跟数字、再跟冒号的片段,i修饰符表示不区分大小写。替换成O:4:之后,原有的格式会被打乱。

举个例子,你原来的payload是:

O:4:"Demo":1:{s:4:"file";s:9:"flag.php";}

正则匹配到O:4:之后替换成O:4:,看起来没变化?不对,这里有个细节:正则的替换不是只替换一次,而是全局搜索匹配并替换。同时\d+会贪婪匹配尽可能长的数字。比如你写了一个属性个数是1,它也会匹配O:后面的数字直到遇到冒号。所以如果你序列化字符串中属性个数是两位数,也会被一并替换掉。

更复杂的是,\d+会匹配对象名长度后面的那个数字,但并不会去管类名的部分——也就是说O:4:"Demo"里的4会被匹配并替换成4,看起来没变化。但如果你的类名长度不是4,比如O:5:"Apple",那么替换后变成了O:4:"Apple",类名长度和实际内容就对不上了,unserialize()就会报错。

__wakeup()绕过的方法是利用一个已知的历史漏洞:当序列化字符串中的属性数量大于实际属性数量时,__wakeup()不会被调用。这在PHP 7.2版本之前是存在的(PHP 7.4开始对此行为进行了修正,但很多题目环境依旧跑在旧版本上)。所以我们只需要把Demo对象声明的属性数量改成大于实际属性数量即可。

3.4 构造payload前的关键知识点

综合以上分析,最终的payload构造思路非常清晰:

  1. 先正常构造一个Demo对象,序列化后得到含__wakeup()的字符串;
  2. 将属性数量改大,使__wakeup()不被触发;
  3. 让“对象名长度”满足正则替换后的格式要求,或者利用正则替换本身的特性实现等价绕过;
  4. URL编码后传入var参数。

第二个问题:为什么要设置$file = 'flag'而不是flag.php?因为__destruct()中已经有拼接.php的动作,你赋值flag,它就会读取flag.php。

第三个问题:highlight_file中加了一个true参数,这是让高亮结果直接返回而不是直接输出。但不管怎样,整个内容最终还是会被echo打印出来,所以我们能看到flag文件源码。

4. 实操过程:从手工构造到最终得到flag

4.1 本地验证环境的搭建

在正式打目标之前,我建议你在本地先搭一个环境,把payload调通,这样到实战环境时你只需要把URL编码替换一下即可省下大量时间。本地可以用php -S 127.0.0.1:8888快速起一个内置服务器,将题目源码保存为index.php,再在同目录下放一个假的flag文件flag.php,里面写<?php flag{test_flag}; ?>。

这样你就能在本地直接观察效果,也不用担心影响到远程服务。

4.2 标准的pop链构造过程

我来一步步手工生成payload:

第一步,我们用一段PHP脚本生成初始序列化字符串:

<?php class Demo { public $file; } $d = new Demo(); $d->file = 'flag'; echo serialize($d); ?>

输出结果为:

O:4:"Demo":1:{s:4:"file";s:4:"flag";}

这段序列化字符串的意思:

  • 一个Demo类的对象,类名长度为4;
  • 有1个属性;
  • 属性名为file,字符串长度为4,值为flag。

第二步,修改属性数量以绕过__wakeup,将1改为2:

O:4:"Demo":2:{s:4:"file";s:4:"flag";}

为什么改成2就能绕过?因为PHP在反序列化时会读取声明的属性数量,并根据这个数值去构造属性容器。但如果实际属性列表中少于声明的数量,在旧版本PHP的处理逻辑中会提前跳转到对象创建流程,不再执行__wakeup(),这就达到了我们的目的。

4.3 正则替换的绕过的正确姿势

现在面临正则替换的问题。我们最终要让unserialize()正确处理我们的payload,前提是正则在替换后不能破坏序列化结构。

当前的替换规则:

/[oc]:\d+:/i 替换为 O:4:

我们的payload开头是O:4,替换后还是O:4。看起来没有影响?但要注意,正则同时匹配c:开头的(类名),然而我们的payload并不是C:开头的。所以直接提交原始payload应该是可行的前提之下,我们需要额外处理另一个蛇皮问题。

这个蛇皮问题就出在:正则的i修饰符让它不区分大小写,而O:与o:都匹配。所以有人会想,那我把O改成小写o不就能让替换把o:4变成O:4吗?看起来好像没变化,但这是一种常见的“伪绕过”思路。

真正的关键卡点在于:如果我们的类名长度不是4,就会被替换破坏。所以有两个办法:

  • 办法一:让对象类名长度为4,那替换本身不会产生破坏——这在本题中天然成立,因为类名就是Demo,长度正好4;
  • 办法二:如果类名长度不是4,则需要利用正则替换的计数逻辑,比如将开头的O:4写成O:+4?但PHP序列化格式不允许加号,这条路走不通。

所以在这道题里,类名长度4天然满足条件,不必做额外操作。

4.4 URL编码的细节

确定了payload:

O:4:"Demo":2:{s:4:"file";s:4:"flag";}

接下来是URL编码。在URL中直接传输这些特殊字符(冒号、双引号、大括号、分号)会导致解析不完整,必须进行URL编码。关键字符的编码结果如下:

原始字符URL编码
:%3a
"%22
{%7b
}%7d
;%3b

所以最终的请求URL大致形式为:

http://target/?var=O%3a4%3a%22Demo%22%3a2%3a%7bs%3a4%3a%22file%22%3bs%3a4%3a%22flag%22%3b%7d

但你可能会问:为什么有的writeup里对大写字母O没有做编码?因为大写O在URL路径或查询参数中通常是安全的,可以保留原样。但为了保险,我建议你把所有特殊字符都编码,包括字母o在内也可以编码成%4f。不过需要小心,正则替换发生在PHP获取到请求参数之后,它拿到的是已经被URL解码后的原始字符串,所以你在URL编码之前必须是原始的序列化字符串,编码只是为了传输,服务器端的$_GET会自动解码回原始内容。

4.5 完整攻击流程命令

这里给你一个可以直接复制的完整流程:

第一步,构造payload文本:

O:4:"Demo":2:{s:4:"file";s:4:"flag";}

第二步,使用Python或在线工具进行URL编码。我推荐用Python一条命令完成:

import urllib.parse payload = 'O:4:"Demo":2:{s:4:"file";s:4:"flag";}' print(urllib.parse.quote(payload))

输出为:

O%3A4%3A%22Demo%22%3A2%3A%7Bs%3A4%3A%22file%22%3Bs%3A4%3A%22flag%22%3B%7D

第三步,将编码后的结果拼接到URL里发送请求:

curl "http://target/?var=O%3A4%3A%22Demo%22%3A2%3A%7Bs%3A4%3A%22file%22%3Bs%3A4%3A%22flag%22%3B%7D"

第四步,观察响应。正常情况页面会直接输出flag.php的源码内容,里面就是flag。

5. 常见问题与绕过细节调试记录

5.1 提交payload后页面空白或报错

这是最常见的失败状态。通常原因有:

  • 序列化字符串格式有误,比如属性数量、字符串长度计算错误;
  • URL编码不完整,导致unserialize()收到的内容不完整;
  • 服务器PHP版本过高,__wakeup()绕过已失效。

排查思路很简单:先把payload放到本地环境跑一遍,看var_dump(unserialize($payload))的输出结果。如果不能正确生成对象,说明payload本身有问题;如果能生成对象且文件读取正常,再考虑是不是编码问题。

5.2 __wakeup绕过失败

如果你在本地用PHP 7.4以上的环境测试,哪怕属性数量改成99也可能不生效。这是PHP官方在7.4版本修复的一个漏洞,具体原因是修改了对象恢复流程中属性数量与实际属性不一致时的处理方式。但在很多经典题目环境中,服务器仍旧使用旧版本PHP(常见的是5.x),因此这个绕过方式依然有效。

如果目标环境真的无法绕过,就需要另找思路了。题目一般不会出这种死局,你可以尝试看看是否存在其他魔术方法与反序列化链路。

5.3 正则替换把属性个数的数字也替换了

有些读者可能会遇到这种情况:把属性数量从1改成2后,正则的\d+把整个2匹配并替换成了4,得到的字符串变成了O:4:"Demo":4:,属性数量变成了4,仍未绕过__wakeup?

注意,preg_replace()的匹配是分别匹配的,它逐个查找[oc]:\d+:模式。你的payload中有多处符合相似模式的片段?我们来检查:s:4:"file"中的s是小写s,不在[oc]字符类里,所以不匹配。O:4匹配一次,替换一次。Demo内部的数字并没有形成字母:数字:冒号的结构,所以不会被匹配。因此属性数量2不会被替换。如果你测试环境中发现被改了,那可能是你用的正则不是题目的正则。

5.4 浏览器直接访问URL乱码或参数被截断

浏览器有时会对URL中的一些特殊字符做二次编码,导致服务器拿到的$_GET['var']内容变化。推荐直接用curl或HackBar这类工具发请求,完全控制编码内容。还有一点:不要用echo $flag之类的方式去猜flag内容,flag就在文件源码里。

5.5 读取其他文件的方式

如果flag不在flag.php中,你也可以尝试修改$file的值来读取其他文件。比如读取config、index等。但必须注意__destruct()会自动拼接.php后缀,所以你无法直接读取.txt等非php文件。除非你能通过其他方式控制$file属性,使其内容带路径穿越或封装协议前缀。

但在这道题中,并不需要这些花活。

6. 扩展:如果题目改一改,你还能打吗

我把这一类反序列化题的扩展思路也一并写出来,你可以借着这道题把知识点泛化,这比单刷一道题有用得多。

6.1 如果正则过滤更严格

一些变体题目会过滤O:开头,但不过滤C:开头,这时候可以使用C:来构造一个__construct类的对象,或者用PHP内置的Serializable接口对类做一种特殊的序列化格式。可以参考C:格式的手工构造,但这种方法要求类本身实现了Serializable接口,适用面较窄。

6.2 如果存在多个类可利用

真实环境里一般不会只有一个类。如果你在源码中看到多个类,就需要审计每个类的魔术方法,找出一条链——从反序列化入口到最终的危险函数。一个典型的链结构是:入口类控制__wakeup或__destruct,中间类打印消息或修改属性,终点类执行命令或读取文件。这就是所谓POP链(Property-Oriented Programming)。

6.3 如果目标是RCE而非文件读取

要想把文件读取升级为远程代码执行,可以通过控制$file属性为php://filter协议的方式读取任意文件内容,也可以替换为phar://触发反序列化,或者寻找eval、system这类危险函数的调用点。在这类题目中,highlight_file只是一个低危可利用点,但在真实代码审计中,你更可能遇到的是include、require、eval、assert等可以直接执行代码的函数。

6.4 对于真实开发的反序列化防御建议

看完攻击面之后,你也应当知道怎么防御。作为开发者,我有几条实在的建议:

  • 永远不要对用户输入直接调用unserialize(),这是最核心的一条;
  • 如果确实需要反序列化,请使用allowed_classes参数限制允许的类名列表;
  • 对session、cookie中存放的序列化数据进行签名和加密;
  • 升级到PHP 8.x版本,很多魔术方法触发漏洞已被修复;
  • 对包含敏感文件读取能力的类做严格的属性白名单校验。

7. 实操心得:一个踩坑的记录

最后分享一点我自己的实战体会。第一次遇到这道题的时候,我一度以为必须用一个非常复杂的payload,甚至尝试在$file里写php://filter/convert.base64-encode/resource=flag去读文件,结果一直被正则过滤。后来冷静下来重新读源码,才发现这道题的出题人把所有筹码都摆在明面上了:类名就是Demo,长度正好4,天然免疫正则替换;__destruct()自动拼接.php,所以只要传flag就能读flag文件。真正的重点只是绕过__wakeup这一件事,而绕过方式就是最经典的属性数量溢出。

另外一个小技巧是,在本地调试的时候一定要用和题目环境一致的phpversion()版本。我最初用最新的PHP 8.2环境测试,__wakeup()绕不过去,白忙活了大半个小时,后来换成旧版本PHP容器重测,一次就成功了。建议你本地准备一个PHP 5.6或7.0的Docker镜像,专门用来复现这类反序列化题目,会省下不少无意义的排查时间。

如果你也卡在了这道题,按照上面的流程从源码审计开始走一遍,你会发现整个攻击链非常通透:找到入口参数var、识别可利用的魔术方法__destruct、绕过__wakeup和正则干扰、URL编码发送。拿下flag,就只是时间问题了。

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

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

立即咨询