看到这个标题,懂的人应该立刻能联想到那几年被各种include支配的恐惧。文件包含漏洞,在 PHP 的漏洞排行里从没跌出一线,它不像 SQL 注入那样直来直去,也不像反序列化那样需要复杂利用链,但它的危险程度极高——因为一旦配合上 PHP 伪协议,读写文件、执行代码、甚至直接getshell,往往就是几行代码的事。这篇文章不打算堆砌理论,我想从一个从业者的视角,把这个漏洞从原理到利用、从踩坑到防御,完整地拆开给你看。无论你是刚入行的安全新人,还是写了多年 PHP 想回头审视自己代码的老开发,这篇文章应该都能给你一些启发。
1. 漏洞从哪来:文件包含漏洞形成的底层逻辑
1.1 include 函数家族的日常行为
PHP 里有一组用于代码复用的函数:include、include_once、require、require_once。它们的设计初衷非常简单——把一个文件里的代码引入到当前脚本中执行。这种机制在传统 PHP 项目中非常常见,开发者习惯把公共配置、数据库连接、页面头部底部等抽离成单独文件,然后按需引入。
问题恰恰出在这个"按需引入"上。当一个开发者写下的代码是这样时:
<?php $page = $_GET['page']; include($page . '.php'); ?>从功能角度看,这段代码支持了index.php?page=about这样的 URL 来加载不同页面,很灵活。但从安全角度看,$page直接来自用户输入且没有任何过滤,攻击者完全可以把page参数改成任意值,让服务器去包含一个攻击者想要的文件。
文件包含漏洞的核心成因不是函数本身有缺陷,而是用户输入变量被直接传入文件包含函数且未经过有效的校验。这就像你家门锁没问题,但钥匙孔的位置被别有用心的人掌握并利用了。
1.2 本地包含与远程包含的差异
文件包含漏洞按包含文件的来源分为两类:
- LFI(Local File Inclusion,本地文件包含):包含服务器上已存在的文件。攻击者可以读取系统中的敏感文件,比如
/etc/passwd、数据库配置文件、日志文件等。 - RFI(Remote File Inclusion,远程文件包含):包含远程服务器上的文件。如果包含的文件内容是 PHP 代码,那么攻击者就等于直接在目标服务器上执行了任意代码,危害要严重得多。
两者的利用难度不同。RFI 之所以"容易"getshell,因为它天然自带代码执行能力——攻击者在自己服务器上放一个写好的恶意 PHP 文件,目标服务器包含进去执行就完事了。但 PHP 从低版本开始就在配置上对远程包含做了限制,allow_url_include默认为关闭状态,这让 RFI 在大多数新环境里跑不通。
LFI 的利用则更依赖服务器上已有的文件。它虽然默认不受allow_url_include的影响,但要实现代码执行,往往需要和其他技术配合,比如日志注入、session 文件写入,或者借助 PHP 伪协议。
1.3 为什么开发者会写出这类问题
我做代码审计这些年,见过太多类似的代码。最常见的场景是模板引擎、多语言切换、主题加载。开发者为了实现"灵活"的功能,把文件名作为参数传递,却忽略了参数的合法性验证。
更深层的原因在于,很多开发者对"用户输入"的边界认识不足。很多人以为只有表单提交的数据才算用户输入,其实 URL 参数、Cookie、HTTP 请求头,全都可能是攻击者可控的。一段代码只要存在"变量可控 + 危险函数"的组合,就可能被利用。
注意:文件包含漏洞并不仅限于 PHP 语言。JSP 的 include、ASP.NET 的 Response.WriteFile 等都有类似的包含逻辑问题,但 PHP 因其伪协议机制的丰富性,利用手段最多、研究最深入,所以业界讨论时一般以 PHP 为典型代表。
2. PHP 伪协议:不只是 include 的玩具
2.1 伪协议家族成员一览
在正式拆解利用方式前,我们先要理解什么是 PHP 伪协议。PHP 提供了很多内置的 URL 风格的封装协议(stream wrapper),它们以php://、data://、phar://、expect://等前缀出现,可以让 PHP 以统一的方式处理各种数据源。这些协议中,有一部分在文件包含漏洞利用中扮演了关键角色。
| 伪协议 | 作用 | 是否受allow_url_include限制 | 典型用法 |
|---|---|---|---|
php://filter | 读取文件内容并可进行编码转换 | 否 | 读取 PHP 源码 |
php://input | 访问请求的原始报文主体 | 是 | 直接注入 PHP 代码执行 |
data:// | 将数据流嵌入到 URL 中 | 是 | 以 base64编码数据传入代码 |
phar:// | 读取 PHP 归档文件内部内容 | 否 | 配合反序列化触发链 |
expect:// | 执行系统命令 | 取决于扩展 | 直接命令执行 |
zip:// | 读取压缩包内文件 | 否 | 配合文件上传绕过 |
这里面,php://filter和php://input是出现频率最高的两个,我用一个比喻来帮助你理解:它们就像是给"文件包含"这扇门配的两把万能钥匙。php://filter能在无法直接查看源码的时候偷偷把源码读出来,php://input则是直接把代码塞进服务器的"喉咙"里让它执行。
2.2php://filter的读文件玩法
php://filter最经典的能力是对文件内容做流处理。它本身不直接读取文件,而是定义了一个过滤器链,让目标文件先经过 filter 处理再输出。最常用的就是 base64 编码过滤器:
php://filter/read=convert.base64-encode/resource=config.php当包含点尝试包含这个路径时,PHP 会读取config.php的原始内容,先做 base64 编码,再把编码后的字符串输出到页面里。因为输出的是编码后的内容,不会在页面中显示成 PHP 代码的语法结构,攻击者拿到 base64 字符串后自己解码就能看到源码。
为什么要编码?因为如果直接包含一个 PHP 文件,PHP 解释器会把里面的代码当作 PHP 语法执行,你在页面上看到的只是执行后的结果,而不是源码本身。比如config.php里定义了数据库密码变量,直接包含它可能什么都看不到,但用php://filter读出来的就是完整源码,密码等敏感信息一览无余。
php://filter还支持多重过滤器,比如配合字符串旋转过滤器做混淆。常见的利用路径包括读取/etc/passwd、读取同目录下的配置文件、读取数据库密码文件等。它不受allow_url_include限制,所以几乎在所有 PHP 环境里都能用,这也是它成为首选的原因。
2.3data://和php://input的限制与用法
data://协议可以把数据直接嵌入到 URL 字符串中,比如:
data://text/plain;base64,PD9waHAgcGhwaW5mbygpOz8+其中PD9waHAgcGhwaW5mbygpOz8+是<?php phpinfo();?>的 base64 编码。当包含这个路径时,PHP 会把它当作一个"远程"文件来处理,而内容就是传入的代码,因此可以直接执行。但这个协议的使用前提是 PHP 配置了allow_url_include=On,而在默认配置里这个选项是关闭的。
php://input类似,它用于读取 HTTP POST 请求体的原始内容。攻击者在 POST 报文中写入 PHP 代码,然后通过包含php://input让这段代码被当作 PHP 文件执行。它同样依赖allow_url_include=On。
从我的测试经验来看,现在绝大多数新版本的 PHP 默认关闭了allow_url_include,所以data://和php://input在真实环境里成功率并不高。但千万别因此忽视它们——很多老项目、运维偷懒的服务器,或者某些框架在特殊场景下开了这个配置的情况依然大量存在。我在一次授权测试中就碰到过一台allow_url_include=On的机器,攻击路径瞬间变得非常直接。
实操心得:拿到一个包含点后,第一件事不是急着打,而是先判断 PHP 版本和配置。你可以通过
phpinfo()页面、报错信息、响应头X-Powered-By等多个维度去确认。判断allow_url_include开没开其实很简单——直接看phpinfo()里的allow_url_include那一项,或者用一个小探测 payload 试一下data://能不能通。
3. 利用链路完整拆解:从发现包含点到 getshell
3.1 第一步:定位包含点并判断可控参数
文件包含漏洞的第一步是"找到那个入口"。做代码审计时,重点搜这几类模式:
$_GET、$_POST、$_REQUEST、$_COOKIE中的变量直接传给include、require、include_once、require_once- 变量经过拼接后再传给包含函数,比如
include('pages/' . $_GET['page']) - 模板引擎中动态加载模板变量的场景
- 语言切换、主题切换中文件名参数化的场景
在黑盒测试中,你可以通过修改参数值为一个不存在的路径来观察报错。如果页面出现了文件路径相关的 warning,说明包含行为真实存在。更常见的做法是直接尝试包含一个确定存在的文件,比如/etc/passwd,如果页面上出现了root:x:0:0:root:/root:/bin/bash的内容,那基本就实锤了。
3.2 第二步:根据限制条件选择利用方案
包含点的代码写法五花八门,最常见的限制有两种:
后缀拼接限制
如果代码是include($_GET['page'] . '.php')这种形式,你直接传/etc/passwd是无效的,因为会变成/etc/passwd.php。绕过思路包括:
- 使用
%00截断(Null Byte):老版本 PHP 支持在字符串末尾用%00截断后续内容,/etc/passwd%00.php会被当作/etc/passwd。PHP 5.3.4 之后的版本已经修复了这个问题,但老环境仍然有效。 - 使用路径结尾的
?或#:/etc/passwd?.php在某些配置下?后面的内容会被当作查询字符串,从而绕过。#则可以把后面的内容当作锚点忽略掉(注意 URL 编码为%23)。 - 使用
php://filter时可以不用管后面的.php,比如php://filter/convert.base64-encode/resource=/etc/passwd配合某些解析器的宽容特性。
目录限制
如果代码强制拼接了固定的目录前缀,比如include('./pages/' . $_GET['page']),那么你可以用../目录穿越回到任意路径。层层嵌套的../配合 URL 编码可以绕过不严谨的过滤。
过滤规则限制
很多程序员会写str_replace('../', '', $page)这种代码来防目录穿越,但过滤不严谨时可以用....//绕过——因为str_replace只替换一次,....//中间的../被替换后,剩下的../又拼接出来了。也可以用 URL 编码后的%2e%2e%2f替代../,很多过滤规则只处理明文形式。
3.3 第三步:从包含到代码执行的多条路径
当你已经确认一个包含点可以读取任意文件后,接下来的目标往往是升级为代码执行。从 LFI 到 RCE 的常用路径有这么几条:
路径一:日志注入
Web 服务器会记录访问日志,如果你能把 PHP 代码写进日志,再把这个日志文件包含进来,代码就会执行。比如 Apache 的 access.log,攻击者构造一个包含<?php phpinfo();?>的 User-Agent 头,日志文件就会记录下这行内容。之后通过包含日志文件路径(例如默认的/var/log/apache2/access.log),PHP 代码就会得到执行。
这条路的问题在于日志路径在不同系统上差别很大,且权限设置各有不同。很多日志目录权限是 600 或者放在日志轮转后的压缩包里,直接包含可能会失败。另一个坑是,日志量大的时候,你注入的代码可能被拆到日志的不同块中,导致语法错误。
路径二:Session 文件
PHP 会把 Session 数据写入文件系统,默认路径是/tmp/sess_[session_id]。如果你的网站应用把 Session 中的某个字段(比如用户名)渲染到了页面上,你可以先构造一个恶意 Session(比如把用户名设为<?php system($_GET['cmd']);?>),再包含对应的 Session 文件。这个方法在 PHP 5 时代很流行,但在 PHP 7 之后 Session 文件包含的成功率因为配置变化而不太稳定,不过依然是值得尝试的一条路。
路径三:文件上传配合
很多应用有文件上传功能,但上传目录通常是不可执行的(比如所有上传文件按MIME类型重命名后存到uploads/目录)。这时如果 LFI 存在,你可以上传一个内容包含恶意 PHP 代码的普通图片文件(比如在 EXIF 信息里塞代码),然后通过包含uploads/xxx.jpg让里面的 PHP 代码执行。这就是经典的"图片马"思路。
路径四:php://input直接注入
这需要allow_url_include=On,前面已经说过,是最高效的一条路径。构造一个 POST 请求,Body 里直接写<?php system($_GET['cmd']);?>,同时把page参数设为php://input,一行代码就能执行命令。如果无法新建文件、无法控制日志内容,这条路就是最接近"一键 getshell"的。
3.4 伪协议的进阶玩法:phar://与反序列化
phar://协议值得单独拿出来讲讲,因为它打通了文件包含和反序列化漏洞之间的桥梁。
phar://可以访问 Phar 归档文件内部的内容,比如phar://uploads/test.png/shell.php就能读取uploads/test.png(这个文件看起来是图片,实际是恶意构造的 Phar 归档)内部的shell.php内容。
利用phar://的经典攻击链是:上传一个精心构造的 Phar 文件(可以通过任何反序列化入口触发),然后通过文件包含点触发phar://的元数据反序列化。如果代码中存在一些危险的反序列化 gadget(例如有__destruct、__wakeup等魔法方法且内部有危险操作),就能形成一条完整的 getshell 链。
这个利用方式的前置条件比较多——需要目标环境支持 phar 协议、需要代码里有可利用的 gadget——但现实是,很多主流框架都曾被发现过可利用的反序列化 gadget。phar://的利用也因此成为 LFI 漏洞利用中的"高级玩法"。
4. 实战现场:我在授权测试中踩过的坑
4.1 过滤规则绕过的几个真实案例
有一次在授权测试中,目标代码如下:
<?php $page = str_replace(array('../', '..\\'), '', $_GET['page']); include '/var/www/html/pages/' . $page;单看代码,../被替换掉了,似乎目录穿越被堵死了。但我直接构造了....//....//....//etc/passwd,第一次替换把中间的../删掉,剩下的是../+../+/etc/passwd,等于两个路径穿越拼接在了一块。结果就是成功读到了/etc/passwd。
还有一次是过滤了php://前缀。没法直接用php://filter,但我发现php://的大小写绕过在某些老版本 PHP 里有效,比如PHP://filter。虽然这个场景现在很少见了,但提醒你一点:过滤规则往往只过滤了精确匹配的小写字符串,而 PHP 协议前缀是不区分大小写的。
4.2 伪协议失效的原因排查
实际利用中最容易翻车的情况是allow_url_include=Off,导致data://和php://input直接无效。但是我发现很多人连php://filter都还没测就放弃了,这是最大的误区。
排查失效问题的步骤,我建议这么做:
- 先排除 PHP 版本问题。
php://filter在 PHP 5.0 以上都可用;data://在 PHP 5.2.0 引入。 - 确认
allow_url_fopen状态。php://filter依赖的是allow_url_fopen,虽然它默认开启,但如果被显式关闭,php://filter同样失效。 - 测试包含的目标路径是否真实存在。有时候不是协议的问题,而是包含的路径错了,尤其是使用绝对路径还是相对路径需要区分清楚。如果页面有报错显示
file not found之类,先确认这个。 - 观察输出位置。
php://filter输出的是 base64 编码后的内容,如果页面没有输出或者输出被框架模板吃掉了,需要在 URL 里找到编码串的位置——有时它藏在 HTML 源码中的注释里,需要看页面的源码而不仅仅是可见渲染效果。
4.3 自动化工具的误报与手动确认
现在很多扫描器都支持检测文件包含漏洞,但自动化工具能发现的往往只是最浅层的存在性问题,比如page=/etc/passwd返回了文件内容。但真正的利用是否可行、能否升级为 RCE,还需要手动确认。
我见过太多人跑完扫描器看到"存在 LFI"就当自己已经跑通了漏洞,把报告提交上去后复测时却什么也复现不出来,原因无外乎是:
- 扫描器使用了默认路径
/etc/passwd,但目标环境是 Windows 服务器,没有这个文件 - 扫描器用了
data://协议,但allow_url_include是关闭的 - 扫描器直接把包含点参数替换成自己的恶意值,但因为代码里有强制后缀拼接而失败
所以我有一条铁律:扫描器的结果只能作为"线索",真正验证必须要亲自动手走一遍利用链。手动构造php://filter读取源码、用日志注入尝试包含日志文件、观察每个请求在服务器日志中的真实记录——这些动作是任何工具都无法替代的经验判断。
安全提醒:以上所有利用方式的讨论,仅适用于你在授权范围内进行的安全测试、漏洞研究或学习实验。任何人利用这些技术对未授权的系统发起攻击,后果自负。这条底线,我希望每个人都记在心里。
5. 防护不是堵洞而是修路
5.1 代码层的修复模板
修复文件包含漏洞的方式有很多,但最根本的原则永远是:永远不要直接使用用户可控的变量作为文件路径。如果必须用,那就一定要做严格的校验。
方案一:白名单映射
<?php $pages = array( 'home' => 'home.php', 'about' => 'about.php', 'contact'=> 'contact.php' ); $page = $_GET['page'] ?? 'home'; if (isset($pages[$page])) { include __DIR__ . '/pages/' . $pages[$page]; } else { include __DIR__ . '/pages/home.php'; } ?>把参数限定在一个可控的映射表内,从根本上杜绝了任意文件包含。这样即使传入../../etc/passwd,也只会落入else分支加载默认页。
方案二:路径规范化校验
如果业务确实需要动态文件名,那就要在包含前做路径校验:
<?php $basePath = realpath(__DIR__ . '/pages/'); $filePath = realpath(__DIR__ . '/pages/' . $_GET['page']); if ($filePath === false || strpos($filePath, $basePath) !== 0) { die('Invalid path.'); } include $filePath; ?>这里的关键是realpath()会把包含../的路径解析成真实的绝对路径,然后校验它是否位于允许的基目录之下。注意:为什么不能只用strpos($filePath, $basePath) === 0?因为如果$basePath是/var/www/html/pages,而$filePath是/var/www/html/pages_evil/file.php,这个判断会误放行。判断时要带上目录分隔符,或者用更严格的方式确认路径精确匹配。
5.2 配置层面的兜底
在代码修复之外,PHP 配置和服务器配置也可以提供纵深防御:
- 关闭
allow_url_include:在php.ini中设置为Off,这是阻断data://、php://input这类直接代码注入的最有效手段。 - 设置
open_basedir:这个配置可以限制 PHP 文件操作可以访问的目录范围。即使包含点被绕过,open_basedir也能限制攻击者能包含的文件范围。 - 禁用危险函数:其实
include没法禁用,但你可以在生产环境禁掉不常用的函数来降低风险,比如exec、system、passthru等命令执行函数,用以削平包含升级为 RCE 后的破坏力。 - Web 服务器禁止访问危险路径:在 Nginx 中可以配置
location ~ \.(include|inc)$ { deny all; }防止这些文件被直接请求。
这些配置不是万能的,但每多一层限制,攻击者的路径就多断一节,需要组合的资源就更多一分。
5.3 一套可落地的自查清单
收个尾,给你一份我在日常开发和审计中最常使用的自查清单,每半年或者大版本发布前跑一遍:
| 序号 | 检查项 | 结论标准 |
|---|---|---|
| 1 | 全局搜索include、require等函数 | 确认所有调用处传入的变量非用户可控 |
| 2 | 用户可控变量是否被$_GET、$_POST直接引入 | 若存在,检查是否经过白名单或路径校验 |
| 3 | php.ini中的allow_url_include状态 | 必须为Off |
| 4 | 文件上传目录的权限设置 | 上传目录不允许执行脚本文件 |
| 5 | 日志文件、Session 文件是否会被包含 | 检查应用是否把include扩展到了日志/session 目录 |
| 6 | 是否部署了 WAF 规则拦截目录穿越、伪协议关键字 | WAF 规则不能只过滤../,还要覆盖php://、data://等协议前缀 |
自查的过程其实也是梳理自己代码的过程。我一直觉得,安全建设不一定非得是采购多贵的设备、部署多复杂的系统,但一定要理解代码里最基础的那些风险点在哪里。把文件包含漏洞这个点吃透,你在面对其他类似的"变量可控 + 危险函数"组合时,会有同样的判断力。
我自己在这些年测试中最深的体会是:漏洞本身不可怕,可怕的是对自己系统里每一个可控入口都一无所知。漏洞的发现往往就是一趟"顺着数据流找出口"的旅程,把这条路走顺了,你会发现这类问题其实有规律可循。希望这篇文章能让你在遇到include时多留一个心眼,不要再用那些把性命交给用户输入参数的写法了。