跳转至

文件包含漏洞:原理、实验分析与防御

本文面向授权 CTF、靶场和本地实验环境。不要对未获授权的系统读取文件、探测配置或尝试包含链。真实项目中的目标应是确认数据流、修复根因并验证修复,而不是收集敏感数据。

相关主题:

一、文件包含与路径穿越不是同一件事

两类问题经常使用相似的路径输入,但危险的“最终动作”不同:

问题 用户输入流向 主要结果
路径穿越 readFile、下载、删除、写入等文件 API 越过预期目录读写文件
本地文件包含(LFI) include、require 或模板加载器 本地文件被解释或拼入响应
远程文件包含(RFI) 支持 URL 的包含操作 远程资源被取回并作为包含目标处理

例如,下面两个片段都可能接收 ../,但影响不同:

// 路径穿越:读取字节并输出。
readfile($_GET['file']);

// 文件包含:由 PHP 的 include 语义处理目标。
include $_GET['page'];

路径穿越可以独立存在。LFI 通常也涉及路径控制,但还要考虑 PHP 解释、模板上下文、流封装器和被包含文件内容。审计时应沿着数据流确认最终使用的 API,不能看到 ../ 就直接把问题命名为 LFI。

二、PHP include 的关键语义

PHP 中常见的包含语句有:

  • include:失败时产生警告,脚本通常继续运行。
  • require:失败时抛出错误,当前脚本停止。
  • include_once 和 require_once:在前述语义上增加“同一文件只包含一次”的判断。

被包含文件会进入当前脚本的执行流程。文件中位于 PHP 标签内的代码会被解释,标签外的内容会作为普通文本输出。扩展名本身不是决定因素:如果 include 成功打开一个名为 notes.txt 的文件,其中的 PHP 标签仍可能进入解释流程。

<?php
$section = 'profile';
include __DIR__ . '/views/' . $section . '.php';

这段代码只有在 $section 完全由服务端决定时才稳妥。若 $section 来自请求,即使代码自动追加 .php,仍需评估目录跳转、编码、符号链接以及不同平台的路径规则。

相对路径解析还会受当前工作目录、脚本目录和 include_path 影响。诊断时可在本地记录 __DIR__、getcwd()、stream_get_wrappers() 和最终解析结果,不要仅凭浏览器中的报错猜测。

LFI 与 RFI 的条件

LFI 的目标位于应用可访问的本地文件系统或本地流中。RFI 则要求包含机制接受远程 URL,并且运行时配置允许这样做。在 PHP 中,远程 URL 包含通常受 allow_url_fopen、allow_url_include、所用封装器以及 PHP 版本共同约束。现代生产环境通常应关闭 allow_url_include;因此“任意 include 参数必然等于 RFI”是不准确的。

检查实验环境配置:

<?php
header('Content-Type: text/plain; charset=utf-8');
printf("PHP: %s\n", PHP_VERSION);
printf("allow_url_fopen: %s\n", ini_get('allow_url_fopen'));
printf("allow_url_include: %s\n", ini_get('allow_url_include'));
print_r(stream_get_wrappers());

该页面只应在本地临时使用,测试结束后删除,避免向外暴露运行时信息。

三、授权实验中的分析方法

先使用自己创建的无敏感内容文件验证数据流。例如在靶场目录创建:

lab/
|-- public/index.php
|-- views/home.php
`-- fixtures/marker.txt

marker.txt 只写入随机标记,如 LOCAL_LAB_MARKER_73A1。测试目标是回答以下问题:

  1. 参数是否到达文件或模板 API。
  2. 应用是否添加目录前缀、后缀或默认扩展名。
  3. 输入在代理、框架、路由和 PHP 之间被解码几次。
  4. 目标按文本读取,还是进入解释流程。
  5. 错误页面、响应长度、状态码和服务器日志是否能证明行为。

可以用安全的本地靶场代码观察差异:

<?php
$page = $_GET['page'] ?? 'home';
include __DIR__ . '/views/' . $page . '.php'; // 故意脆弱,仅用于隔离靶场。

记录原始请求、应用日志与最终路径。避免把“响应为空”直接当成“不存在漏洞”:目标可能不可读、被后缀限制、产生错误但关闭了显示、或内容没有可见输出。

常见可读目标的类别

在真实防守审计中,应检查应用身份是否能够读取不必要的文件。常见类别包括:

  • 应用配置、环境配置和部署清单。
  • 源代码、模板、备份副本和编辑器临时文件。
  • Web 服务器、反向代理和应用日志。
  • 会话文件、缓存文件和作业队列落盘数据。
  • 临时目录中的上传中间文件。
  • 进程环境、启动参数和运行时状态接口。
  • 操作系统的账户、主机和网络配置等非秘密系统信息。

不同操作系统、容器镜像、框架和部署方式的实际路径差异很大。教程中的固定路径不能替代资产清单与权限审计。生产验证应使用预先放置的无敏感标记文件,不应读取凭据或用户数据来“证明”问题。

四、PHP 流封装器:能力取决于环境

流封装器不是绕过清单,而是 PHP I/O 抽象。是否可用于包含取决于 PHP 版本、编译模块、INI 配置、目标代码以及参数是否被拼接后缀。

封装器 合法实验用途 关键前提与限制
php://filter 对本地测试文件施加读写过滤器,常用于确认“读取”和“解释”的差异 需要对应过滤器存在;通常不等同于远程 URL 包含,也不能绕过文件权限;具体过滤链和资源名必须被应用完整保留
data:// 用内联的无害文本验证 URL 流处理 需要 data 封装器;通常受 allow_url_fopen 影响,作为包含目标时还受 allow_url_include 影响;不同版本和配置必须实测
php://input 读取原始请求体,观察框架是否已消费请求体 请求方法、内容类型、enable_post_data_reading、SAPI 和 PHP 版本都会影响结果;multipart/form-data 的行为尤其不能按旧教程一概而论
phar:// 在本地读取自己创建的 PHAR 归档资源 需要 PHAR 支持、有效归档和可到达的文件操作;旧版 PHP 中与元数据反序列化有关的历史风险不代表现代版本仍会自动触发,必须按实际版本核对

一个安全的 php://filter 演示只读取自己创建的文本:

<?php
$text = file_get_contents(
    'php://filter/read=string.toupper/resource=' . __DIR__ . '/fixtures/marker.txt'
);
echo htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

不要把“封装器已注册”和“可通过当前包含点利用”等同起来。应用可能在参数前后拼接路径,open_basedir 或文件权限可能阻止访问,相关 INI 选项也可能在当前 SAPI 下不可修改。

五、包含链的概念

LFI 有时会与“可控内容被写入本地文件”组合。分析的核心是两个条件是否同时成立:

  1. 攻击者可影响某个本地文件的内容。
  2. 文件包含点可以稳定定位并读取该文件,且内容进入可执行上下文。

常被讨论的载体包括日志、文件会话、临时上传文件和应用缓存。它们都不是自动成立的技巧:

  • 日志链依赖日志格式、转义策略、落盘位置、轮转时机和应用用户的读取权限。
  • 会话链依赖会话存储驱动、序列化格式、会话标识控制、保存路径和清理周期。
  • 临时上传链依赖 SAPI 的上传实现、临时目录权限、请求生命周期和文件被删除前的时间窗口。
  • 缓存链依赖缓存是否落盘、键到路径的映射以及内容编码方式。

在本地实验中,用固定标记文本替代可执行内容,证明“写入”和“包含”两段数据流即可。防守修复应同时切断不可信内容落入可包含目录、限制读取权限,并消除动态包含点。

六、规范化与校验陷阱

路径安全的难点不只是查找字符串 ../。常见错误包括:

  • 校验前后发生不同次数的 URL 解码。
  • Windows 与类 Unix 系统对分隔符、盘符和大小写的处理不同。
  • 先检查字符串前缀,再由文件系统解析 .、.. 或符号链接。
  • 使用 realpath() 时没有处理目标不存在返回 false 的情况。
  • 用字符串前缀判断目录,误把 /srv/views-old 当成 /srv/views 的子目录。
  • 校验文件名后又追加、截断或替换扩展名。
  • 忽略 PHP 流方案,导致本应只接受文件名的输入仍可携带 scheme://。
  • 依赖空字节截断。该行为属于旧版本历史问题,现代 PHP 不应被描述为通用绕过。
  • 校验与打开之间存在竞态,文件或符号链接在两步之间被替换。

即使正确使用 realpath(),允许用户自由选择服务器文件仍会扩大风险。最稳妥的设计是让请求只携带业务标识,由服务端映射到固定模板。

七、首选防御:模板映射与允许列表

<?php
$templates = [
    'home' => __DIR__ . '/views/home.php',
    'profile' => __DIR__ . '/views/profile.php',
    'help' => __DIR__ . '/views/help.php',
];

$page = $_GET['page'] ?? 'home';
if (!array_key_exists($page, $templates)) {
    http_response_code(404);
    exit('Not found');
}

require $templates[$page];

这里的安全边界是“外部输入只能选择键”,而不是“尝试清洗一个路径”。进一步加固:

  • 不允许请求直接提供目录、扩展名、协议或完整 URL。
  • 模板目录只读,上传、日志、会话、缓存和临时目录与其物理隔离。
  • PHP 进程以最小权限运行,不读取与业务无关的配置和其他租户数据。
  • 关闭 allow_url_include;按业务需要评估 allow_url_fopen。
  • 生产环境关闭详细错误回显,将诊断写入访问受控的日志。
  • 容器或主机上进一步使用文件权限、只读挂载和强制访问控制缩小范围。

如果业务确实需要在基准目录下读取用户选择的普通文件,应同时做固定基准目录、严格文件名规则、realpath() 后的路径边界判断和普通文件检查:

<?php
$base = realpath(__DIR__ . '/public-docs');
$name = $_GET['name'] ?? '';

if ($base === false || !preg_match('/\A[a-z0-9][a-z0-9_-]*\.txt\z/i', $name)) {
    http_response_code(400);
    exit('Invalid document');
}

$candidate = realpath($base . DIRECTORY_SEPARATOR . $name);
$prefix = $base . DIRECTORY_SEPARATOR;

if ($candidate === false || !str_starts_with($candidate, $prefix) || !is_file($candidate)) {
    http_response_code(404);
    exit('Not found');
}

header('Content-Type: text/plain; charset=utf-8');
readfile($candidate); // 读取文档,不使用 include。

这段代码适用于已存在的普通文本文件。对于创建新文件、符号链接攻击或并发替换,还需要使用平台提供的安全打开方式并重新评估竞态。

八、审计与修复检查清单

分析

  • 已确认测试授权、目标范围和数据处理规则。
  • 已找到输入从路由到 include、模板或文件 API 的完整数据流。
  • 已区分路径穿越、LFI 与 RFI,而不是只依据输入形式命名。
  • 已记录 PHP 版本、SAPI、流封装器和相关 INI 配置。
  • 已用自建标记文件验证,未读取真实凭据或用户数据。
  • 已检查解码、规范化、符号链接、后缀拼接和错误处理。
  • 已确认所谓包含链的写入点、落盘时机、读取权限和执行上下文。

防御

  • 动态路径已替换为业务键到固定模板的映射。
  • 普通文件下载使用读取 API,不使用 include。
  • 上传、日志、会话、缓存和模板目录已经隔离。
  • PHP 与 Web 服务器账户遵循最小权限。
  • allow_url_include 已关闭,非必要 URL 封装能力已收缩。
  • 生产错误响应不泄露绝对路径和堆栈。
  • 自动化测试覆盖非法键、编码路径、不同分隔符和符号链接边界。

文件包含经常与上传落盘位置和解析配置形成组合风险。继续阅读文件上传漏洞和解析器与服务器配置差异,可以把这三条数据流放在同一张部署图中审计。