文件包含漏洞实战:从路径回退到代码执行

文件包含漏洞在老站里几乎没有绝迹过。原因很简单:早期为了省事,很多项目把「页面切换」写成了 include($_GET['page'] . '.php'),模板引擎又把「片段路径」交给用户传参。这类代码在正常访问时看不出任何问题,但只要有人把参数换成一个路径,整个服务器的内容都可能被读出去。这篇把本地文件包含(LFI)的读取手法、日志投毒到代码执行、以及一套可落地的防护顺序完整讲一遍,最后给一个能贴进代码库的封装函数。

一、先分清 LFI 和 RFI 的区别

两者都发生在「文件路径来自用户输入」的地方,区别在于包含源是本机还是远端。

  • 本地文件包含 LFI:参数能改到本机的其它文件,例如 /etc/passwd、站点的配置文件、日志文件、会话文件。危害从「信息泄露」起步,运气好可以升级为代码执行。
  • 远端文件包含 RFI:参数里能塞一个完整 URL,PHP 去远端抓回来执行。这需要 allow_url_include=On,默认是关闭的,现代 PHP 上很少见,但一旦打开就是「一句话接管」。

判断环境时先看这一项:php -i | grep allow_url_include。如果是 On,优先把它关掉,这是本单位最便宜的一刀。

二、四种常见的参数形态

接手别人的代码时,先按这四种形态去搜,覆盖九成以上的出口。

  1. 直接的 include:include $_GET['page'];,或带后缀拼接 include $_GET['page'] . '.php';。后者可以用空字节或路径回退绕过,见下一节。
  2. 目录前缀 + 变量:include 'pages/' . $_GET['p'];。这种比纯变量更难逃出目录,但仍然可以用足够多的 ../ 逐级上翻。
  3. 模板名:$smarty->display($_GET['tpl']);、require "views/{$tpl}.php";。模板引擎的「缓存名」常见这类写法。
  4. 语言文件与配置合并:require 'lang/' . $lang . '.php';,多见于多语言站点,出现在 URL 里往往是 ?lang=。

用 grep 一次性盘点,别靠肉眼翻文件:

grep -rn --include=*.php -E "(include|include_once|require|require_once)\s*[\(\s]" . \
  | grep -E "\\$_(GET|POST|REQUEST|COOKIE)|\\$_(GET|POST|REQUEST)\[" \
  | grep -v vendor/

命中的每一行都要人工确认一遍:是「常量路径」还是「带变量的路径」,是「白名单选择」还是「直接拼用户输入」。这一步做完,通常能在半小时内把风险清单列出来。

三、路径回退:../ 为什么拦不住

很多老代码的防护思路是「把 ../ 删掉」,例如 str_replace('../', '', $page)。这种写法可以被三层套娃破掉:....//....//etc/passwd 删掉中间的 ../ 之后又还原成 ../../etc/passwd。同理 %2e%2e%2f、..%252f(双重 URL 编码)也能绕过只看一层解码的实现。

正确做法不是「滤黑名单」,而是归一化之后再做前缀比对:

$base = realpath(__DIR__ . '/pages');
$file = realpath($base . '/' . $userInput . '.php');
if ($file === false || strpos($file, $base . DIRECTORY_SEPARATOR) !== 0) {
    http_response_code(404);
    exit;
}
include $file;

三个细节不能省:realpath() 会解开 ../、符号链接和多重编码;返回 false 说明文件不存在,必须当成失败处理(不能继续往下 include);前缀比对要带上末尾的目录分隔符,否则 /var/www/pages2/x.php 会被误判成在 /var/www/pages 里面。这个 pages2 的前缀混淆是非常常见的疏漏。

四、读哪些文件有实际价值

只读得到 /etc/passwd 的时候危害有限,但下面几类文件必须按「一旦泄露就是事故」对待:

  • config/database.php、.env:数据库账号密码、第三方 API Key,泄露后攻击者直接进库。
  • 应用日志:/var/log/nginx/access.log、框架的 runtime/log。这既是信息泄露,也是下一步「日志投毒」的落点。
  • 会话文件:PHP 默认 /tmp/sess_<PHPSESSID>。知道别人的 session id 就能直接冒用身份,等于绕过登录。
  • /proc/self/environ、/proc/self/cmdline:环境变量里常常放着数据库密码和密钥,一些容器部署更明显。
  • /root/.ssh/id_rsa、/home/www/.bash_history:前者是直接拿到服务器,后者常常能翻出运维手工执行的命令历史。

需要强调的是会话文件这条线:即使你的代码没有任何日志功能,只要站点允许 LFI 读取 /tmp,攻击者也可以先构造一个带 PHP 代码的 session 值(例如在昵称里塞 payload),再用包含读 /tmp/sess_xxx,代码就执行了。这条路径完全不依赖日志,所以「我没开日志所以安全」的判断是错的。

五、从 LFI 到代码执行的三条路

LFI 单独存在时是信息泄露,配合下面任意一条就升级为 RCE。

路径一:日志投毒

请求一个不存在的页面,把 PHP 代码写进 User-Agent 或 URL,让它落在 access.log 里;然后 include 这个日志文件。默认 Nginx 日志里会原样记录 UA 和请求行,代码就被写进去了。防御上要对 UA 和 URI 做长度与字符限制,但更根本的是不让用户输入进文件路径。

路径二:上传 + 包含

允许上传图片的站点,往往把文件存到 uploads/,同时校验扩展名和 MIME。攻击者可以上传一张「图片马」——文件头是合法 JPEG,尾部追加 PHP 代码。如果上传目录可执行、且 LFI 能指到那里,图片就被当脚本执行了。所以上传目录必须禁止 PHP 执行,这是两条独立的防线,缺一不可。

路径三:PHP 包装器

PHP 内置了一批 php://、data://、phar:// 包装器,其中 php://filter 尤其危险:它可以在 include 时对文件内容做 base64 编码后输出,从而把任意源码打印出来。phar:// 更麻烦——只要代码里有一次 file_exists() 之类的文件函数碰到 phar:// 路径,就会触发 PHAR 反序列化,等于把反序列化漏洞也带进来了。

一条直接有效的处置:

; php.ini
allow_url_include = Off
allow_url_fopen = Off
open_basedir = /home/wwwroot/site:/tmp

; 阻断包装器直链
; Nginx
location ~ ^/(.*):// { return 400; }

open_basedir 不是万能的(它能被绕过、也会影响正常运行),但在中小站点上,配合目录白名单能挡住绝大多数路径回退。改之前务必先在测试环境跑一遍,它会连带影响文件上传、临时目录、缓存写入。

六、防护顺序:从架构到细节

很多团队一上来就写过滤函数,结果漏掉一个入口就全盘失效。按下面的顺序做,每一步都独立有效,后面的步骤只是兜底。

第一步:消灭「路径由用户决定」这件事

把页面切换改成白名单映射,参数只作为「键」而不是路径:

$routes = [
    'about'   => 'pages/about.php',
    'contact' => 'pages/contact.php',
    'faq'     => 'pages/faq.php',
];
$key = $_GET['page'] ?? 'about';
if (!isset($routes[$key])) { http_response_code(404); exit; }
include __DIR__ . '/' . $routes[$key];

这是最彻底的修法:参数里不再有斜杠、点、协议,任何路径手法都失效。模板引擎同理,把模板名映射成编号或常量名。

第二步:统一封装包含函数

老项目改造成本高时,至少把所有 include 收口到一个函数里,函数内部做归一化与前缀校验,并记录被拒绝的请求:

function safe_include(string $name, string $base = __DIR__ . '/pages'): bool
{
    if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $name)) {
        error_log('include rejected: ' . $name . ' ip=' . ($_SERVER['REMOTE_ADDR'] ?? '-'));
        return false;
    }
    $base = realpath($base);
    $file = realpath($base . '/' . $name . '.php');
    if ($file === false || strpos($file, $base . DIRECTORY_SEPARATOR) !== 0) {
        error_log('include escape: ' . $name);
        return false;
    }
    include $file;
    return true;
}

要点是「先做字符白名单,再做路径归一化,最后比对前缀」,三层叠加。日志一定要记,后面排查是否已经被打过一眼就能看出来。

第三步:PHP 层面收紧

disable_functions 里加上 system,exec,shell_exec,passthru,popen,proc_open,putenv;open_basedir 限定到站点目录;allow_url_include 关掉。注意 disable_functions 在 PHP-FPM 下才是常态,CLI 里通常不生效,验证时要走一个 Web 请求去测。

第四步:Nginx 兜住上传目录与敏感路径

location ^~ /uploads/ {
    location ~ \.(php|php5|phtml|phar)$ { return 403; }
}
location ~ /\.(git|env|svn) { return 404; }
location = /composer.lock { return 404; }

^~ 前缀匹配的优先级高于正则,所以要先把 ^~ /uploads/ 整块占住,再在里面做正则拒绝,否则会被外层的 \.php$ 转发给 PHP。这个顺序写反导致「上传目录照样能执行」的事故非常常见。

第五步:WAF 与监控

在 Nginx 层用一个 map 标记可疑参数,命中就把请求日志打上标记,方便事后筛查:

map $request_uri $lfi_suspect {
    default 0;
    ~*"(\.\./|%2e%2e|php://|phar://|data://|\.\.%2f)" 1;
}
log_format main '$remote_addr "$request" $status $lfi_suspect';

注意这只是观测手段,不是防护。攻击者可以用三重编码绕过正则,真正拦住请求的是第一步的白名单。

七、怎么验证防护真的生效

不要只靠「我看代码没问题」。用三条命令做回归,每次改完都跑一遍:

  1. 找一个已知入口,把参数换成 ../../etc/passwd,返回应当是 404 或你定义的错误页,而不是文件内容。若返回内容里出现 root:x:0:0,就是没拦住。
  2. 用 php://filter/convert.base64-encode/resource=index 试一次,返回内容不应该是 base64 串。
  3. 看一遍 error_log 里有没有 include rejected 的记录,确认新加的日志路径确实跑到了。

线上验证要挑低峰期,且只测自己的站点。验证完把测试请求在日志里标注一下,避免后续审计时把自己当成攻击者。

八、一个真实事故的复盘形态

常见的事故不是「写了一个明显有洞的 include」,而是补丁只补了一半:有人把 ?page= 修好了,却漏了 ?lang= 和后台导出功能里的模板路径。前半段被修,后半段还在,扫描器换个参数就进来了。所以处置顺序应该是:先全站 grep 出所有带变量的 include,列成表,逐个标记修复状态,最后再复查一次表;而不是看到报障点就只改那一处。

九、上线检查清单

  • 全站 grep 带变量的 include/require,结果为零或全部收口到 safe_include()。
  • allow_url_include、allow_url_fopen 均为 Off。
  • open_basedir 已限定,且上传、缓存、临时目录都验证可写。
  • 所有上传目录的 PHP 执行已拒绝,实测上传一个 .php 返回 403。
  • .env、.git、composer.lock 直链返回 404。
  • Nginx 记录了可疑参数的标记字段,能在日志里筛出来。
  • 回归三条命令通过,日志里有 rejected 记录。

十、小结

文件包含的危险不在「读文件」这一步,而在于它把「用户输入」和「代码执行路径」连在了一起。防御上最有效的动作永远是同一个:不要让用户输入决定文件路径,用白名单把参数降级成键,剩下的 realpath 归一化、前缀比对、open_basedir、目录执行拒绝都是兜底。老项目改造时不要追求一次改完,先把入口盘点清楚、全部收口到一个函数里,再逐步把高频入口换成白名单映射,这个顺序既能控风险,也不会一次性把代码改出更多问题。

Last modification:November 1st, 2026 at 09:26 pm

Leave a Comment