1. 文件包含漏洞:一行 include 引发的血案
1.1 PHP 文件包含机制,先把它盘明白
文件包含漏洞听起来玄乎,但它的大本营其实特别朴素——就是 PHP 里那几个最基础的函数。include、require、include_once、require_once,这些函数在 PHP 里的作用就是把一个文件的内容拉进当前脚本里执行。正常的开发场景下,你写一套模板系统,把公共头、公共尾部抽出来,然后靠变量动态选择包含哪个模板文件,这是再常见不过的代码组织形式。
问题恰恰出在"动态"这两个字上。如果包含的文件名完全由外部输入决定,而且没有做任何校验,那这个输入点就成了一个口子。举个例子,开发者为了方便写了一个页面分发逻辑:
<?php $page = $_GET['page']; include($page . '.php');这段代码本意是让index.php?page=about去包含about.php,实现一个最简的"路由"功能。但实际上,攻击者传入的page值根本不受限制,他完全可以传一个绝对路径,或者一个协议流地址。文件包含漏洞,本质上是外部输入未经校验就进入文件系统寻址路径,导致包含目标被操纵。
这里要先区分两个概念:本地文件包含(LFI)和远程文件包含(RFI)。LFI 是把服务器本地文件拉进来,RFI 是从远程服务器拉文件进本地执行。伪协议这个东西,恰好把这两类场景都覆盖了,它让你既能拿本地文件内容,也能通过数据流的方式实现远程代码执行的效果。这也是为什么它在 CTF 和授权渗透测试里出镜率这么高。
1.2 漏洞是怎么长出来的
文件包含漏洞的根子,不在于函数本身,而在于三个环节的叠加。第一个环节是开发者图省事,用参数拼接路径形成伪路由;第二个环节是参数值可以被用户直接控制;第三个环节是最关键的——PHP 的文件包含函数本身太"宽容"了。
include的宽容体现在两个地方。一是它不介意传入的是普通路径还是协议封装流,只要allow_url_include配置允许,它甚至可以跨协议拉取内容。二是它对路径的处理非常"人性化",它会自动补全路径、自动尝试多种解析方式,这些特性反而成了攻击者的帮手。你在代码里写include($_GET['page']),PHP 会老老实实地按照传进来的字符串去找文件、去解析流,完全不问这个东西该不该被包含。
再加上 PHP 的配置项鱼龙混杂,生产环境里总能看到allow_url_include = On这种危险配置。这个开关本来是给开发者图方便用的,但在文件包含漏洞面前,它就是一道大闸门。闸门一开,漏洞的破坏力直接从一个"读文件"问题升级成一个"远程代码执行"问题。我在后面讲php://input和data://的时候会反复提到这个配置项,因为它是两个 RCE 场景成立的前提。
1.3 先立规矩:本文所有操作都在什么前提下展开
动手之前必须把边界讲清楚。文件包含伪协议的利用方法,在我这里分两种用途:一种是安全研究、CTF 赛题、自建靶场的测试演练,另一种是在获得书面授权的渗透测试项目中对目标系统做验证。如果你没有授权,这些东西绝对不能往任何线上系统上试,任何一次探测都可能构成越权甚至破坏行为,这是底线问题,没有商量的余地。
本文所有实验都基于本地容器搭建的最小化环境,代码示例是刻意构造的漏洞代码,配置项也是故意放宽的。这篇文章更核心的目标,不是教你怎么拿一台服务器,而是讲清楚这些协议为什么能生效、在什么条件下生效、触发之后会产生什么痕迹,以及防守方应该怎么堵住这些渠道。只有把原理吃透了,你以后不管是做红队还是做蓝队,看到page=php://filter这种请求时才能一眼判断出背后发生了什么。
2. 三大经典伪协议逐个拆解
2.1 php://filter:不落地读源码的标配
php://filter在我的实际测试里是出场率最高的一个伪协议,因为它解决了文件包含漏洞利用的一个核心痛点:你包含一个 PHP 文件,PHP 引擎会直接执行它,你看到的只是一个执行结果,根本看不到源代码。但很多时候,攻击者需要的恰恰是源码本身,拿到源码才能继续审计、找更多的利用点。
php://filter的用法是给 PHP 的流加一层过滤器,其中read=段指定读取时应用的过滤器,resource=段指定要读取的目标。最经典的组合是:
php://filter/read=convert.base64-encode/resource=index.phpphp://filter/read=convert.base64-encode/resource=config.php为什么要套一层 base64 编码?这是我常给新人解释的一个点。因为include在读取目标文件时会解析其中的 PHP 代码,如果你直接把源码include进来,代码先被执行了,你看到的只是 echo 出来的结果或者一片空白。而经过convert.base64-encode过滤器之后,文件内容在被解析之前就变成了一段 base64 字符串,PHP 引擎对它没有解析兴趣,只会把它当作普通文本输出到响应里。攻击者拿到这串 base64,在本地解码,源码就到手了。
而且 base64 编码还有一个额外的好处:对于二进制文件或者包含特殊字符的文件,直接输出可能会导致响应包格式错乱、内容截断,编码之后传输就稳定得多。所以我经常说,php://filter不仅仅是"可以读源码",它是"可以稳定地把任意文件内容搬出来"。实际测试中,这个方法在 Linux 和 Windows 环境都能用,只要当前 PHP 进程对目标文件有读权限。
除了convert.base64-encode这个最常见的过滤器,还有convert.iconv.UTF-8.UTF-7之类的字符集转换过滤器可以选,目的都是把内容转换成不会触发 PHP 解析的形式。不同的过滤器组合还能派生出一些绕过 WAF 的玩法,这一块放到 4.3 节再展开。
这里要补一个细节:php://filter读取同文件时,路径不一定是相对路径。实际利用中经常配合./、../这类路径拼接来穿越目录,绕过一些简单的关键字拦截。比如resource=config被过滤的时候,可以试试resource=./config或者resource=config%2ephp。这些变形本质上没有改变 URL 指向的目标,但有时能骗过只做精确匹配的防护规则。
2.2 php://input:请求体秒变 PHP 代码
php://input是一个很有意思的封装协议,它把 HTTP 请求的原始 body 当作一个可读流。正常开发中,开发者可以用file_get_contents('php://input')来获取 POST 提交的原始 JSON 数据。但在文件包含的场景里,这个流一旦被include引入,脚本引擎会直接尝试解析流内容——也就是说,请求体里放的任何 PHP 代码,都会被当作服务端代码执行。
举个最直观的测试场景。目标存在这样的文件包含点:
<?php include($_POST['file']);攻击者可以 POST 这样的数据:
file=php://input然后在请求体里附带:
<?php echo md5('test'); ?>当脚本执行到include('php://input')时,PHP 读取请求体里的字符串,发现里面带有<?php ... ?>标签,于是触发解析执行,返回结果。
这里有一个必须强调的前置条件:php://input生效需要allow_url_include = On。原因在于 PHP 把php://input归类为"URL 远程封装"性质的协议,它和http://这类远程流一样,被include使用时受allow_url_include控制。默认安装的 PHP 这个配置是Off的,所以我经常说,一个文件包含漏洞如果想升级成代码执行,很多时候要看运气——运气就是管理员有没有开这个配置。
使用php://input还有两个实操中的注意点。第一,内容里的引号、特殊字符要小心处理,最好在构造请求时就明确用 URL 编码,避免被传输层截断。第二,这种利用方式必须能把请求体送到目标服务器,所以通常更适合 POST 型的包含点,如果漏洞参数在 GET 里,那就得看看服务端会不会读 body。可以先用无害的php://input配合输出测试确认 body 内容确实被读进来了,再决定下一步。
另外很多人忽略的是,php://input并不只能执行代码。如果你单纯想验证包含点是否可控 body 内容,可以用一个<?php echo "input_ok"; ?>的探针。如果响应里出现input_ok,说明请求体不仅被读入,还被解析。这比盲打要稳妥得多,也是我在授权测试里最喜欢用的探测方式——先用无害标记确认解析链路,再构造实际验证载荷。
2.3 data://:把 payload 打包成一串 URL
data://这个协议允许你把数据直接嵌入 URL 里,格式是data://媒体类型,数据内容。因为数据本身就在 URL 上,所以它特别适合 GET 型的包含点,不需要另外传 body。
经典用法是这样:
data://text/plain,<?php echo md5('test'); ?>但这串 payload 里有逗号、空格、尖括号,很多 WAF 和中间件会拦截。所以实际利用中更常用的变体是先对 PHP 代码做 base64 编码,再用base64,关键字拼接:
data://text/plain;base64,PD9waHAgZWNobyBtZDUoJ3Rlc3QnKTs/Pg==PD9waHAgZWNobyBtZDUoJ3Rlc3QnKTs/Pg==解码出来就是<?php echo md5('test'); ?>。这种写法的好处是 URL 里不出现尖括号和空格,可传递性极大提升,而且不会被常见的"尖括号黑名单"规则命中。编码这一步在本地直接用base64命令就能完成:
echo -n '<?php echo md5("test"); ?>' | base64data://生效需要两个配置同时满足:allow_url_include = On和allow_url_fopen = On。第一个负责放行include层面的远程数据流,第二个负责让 PHP 的 URL 包装层能正常解析data://这种协议头。相比php://input,data://的要求其实更苛刻一点,因为它不仅依赖include层的开关,还依赖流的全局 URL 支持。
我用一个对比表把这几个协议的关键差异整理一下:
| 协议 | 核心用途 | 关键配置要求 | payload 放置位置 |
|---|---|---|---|
| php://filter | 读取文件源码 | 无需额外配置 | URL 参数内 |
| php://input | 执行代码 | allow_url_include = On | 请求 body 内 |
| data:// | 执行代码 | allow_url_include + allow_url_fopen | URL 参数内 |
这个表也是我在实际分析中判断一个包含点能不能利用、能利用到哪一步的依据。先看参数里有没有机会注入协议头,再看响应特征,最后反推服务端配置。
3. 零成本本地复现:把靶场搭起来
3.1 用容器起一个最小实验环境
实验环境越干净越好,我强烈推荐用容器而不是直接在物理机上装 PHP。容器的好处是快、隔离、删了重建不心疼。推荐直接用php:7.4-apache这种现成镜像,一条命令就能把环境拉起来。
docker run -d -p 8080:80 -v /tmp/lfi-lab:/var/www/html --name lfi-php php:7.4-apache注意这里我把本地目录/tmp/lfi-lab挂载到容器里的站点根目录,方便直接改 PHP 文件。启动容器之后,到/tmp/lfi-lab下面创建一个测试文件index.php,内容就是最经典的漏洞代码。实验环境我们需要把配置的坑也模拟出来,所以还要进容器改一下 PHP 配置,把allow_url_include打开:
docker exec -it lfi-php bash # 在容器内执行 docker-php-ext-install 什么都不用装,只需要改配置实际操作时,我习惯直接挂载一个自定义的php.ini到容器里,保证每次重建配置一致:
allow_url_include = On allow_url_fopen = On display_errors = On挂载方式是在docker run命令里加一个-v /tmp/php.ini:/usr/local/etc/php/php.ini。这样环境就是可复现的,折腾坏了也不怕。
3.2 手写一个文件包含点
实验用的漏洞代码越简单越好,太复杂的逻辑会干扰你对漏洞本身的理解。我建了两个文件,一个index.php作为包含入口,一个flag.php作为"目标机密文件"。
index.php是这样:
<?php // 文件包含漏洞演示环境,仅限本地测试 $file = $_GET['file']; include($file); ?>flag.php是:
<?php $flag = "local_test_flag_2024"; echo "文件包含成功,打算读取的内容在注释里"; // flag: local_test_flag_2024 ?>然后访问http://127.0.0.1:8080/index.php?file=flag.php,不出意外页面会正常显示 flag.php 的内容。这说明包含链路已经打通。接下来我们开始验证三大伪协议。
3.3 三大协议完整验证记录
先验证php://filter。访问:
http://127.0.0.1:8080/index.php?file=php://filter/read=convert.base64-encode/resource=index.php浏览器返回的东西里会有一段明显的 base64 字符,类似PD9waHAgLy8g5paH5Lu2...。把这段内容放到本地解码:
echo "PD9waHAgLy8g5paH5Lu2..." | base64 -d解码结果就是index.php的完整源码。这一步意味着:即使我不知道任何源码,只要包含点存在,任何人都可以把整个 Web 应用的核心代码搬走,后续的进一步审计只是时间问题。
再验证php://input。对包含点发一个 POST 请求:
POST /index.php?file=php://input HTTP/1.1 Host: 127.0.0.1:8080 Content-Type: application/x-www-form-urlencoded <?php echo "input_works"; ?>注意这里 body 前后不要有多余空格,否则可能导致解析异常。我在本地测试时第一次就栽在换行符上,body 开头莫名多了一个空行,PHP 把空行原样输出了,然后再解析代码,虽然最终能出结果,但响应内容不干净。
如果环境没问题,响应里会出现input_works。稳定复现后,再测试data://:
http://127.0.0.1:8080/index.php?file=data://text/plain;base64,PD9waHAgZWNobyAibmV3X3Rlc3QiOz8+这里PD9waHAgZWNobyAibmV3X3Rlc3QiOz8+解码是<?php echo "new_test"; ?>。访问后页面输出new_test,说明data://场景成立。我故意把三条验证分开做,因为每次验证都能帮你确认当前环境的配置到底开了哪个口子。只测出一个不代表安全,三个都测过,你才对自己的实验环境有底。
4. 授权测试中的排查技巧与进阶玩法
4.1 如何快速锁定可疑包含点
做授权测试时,发现文件包含点靠的是参数名字和输入特征的双重命中。最典型的参数名就那么几个:file、page、path、include、inc、dir、template、view,凡是看到这些名字出现在 URL 里,并且值像一个文件名(比如about.php,include/header),就要多留个心眼。
确定了可疑参数之后,不要直接上伪协议,先用最无害的方式确认目标确实存在包含行为。我习惯先尝试加载一个确定的本地文件,比如传file=/etc/passwd,只有几十个字节,响应特征非常容易识别。如果目标对绝对路径做了限制,就试试相对路径穿越,file=../../../../../etc/passwd。这一步如果成功,说明包含点不仅可控,而且可以跨目录寻址,那php://filter也大概率能直接用。
测试过程中要时刻留意三种信号:响应内容的长度突变、返回了文件内容特征、以及报错信息。PHP 在include失败时会抛出Warning: include(): Failed opening之类的信息,这个信息能直接暴露脚本绝对路径和包含逻辑的细节,对后续构造 payload 帮助很大。就算页面设置了display_errors = Off,有时候Warning也会被部分中间件以不同状态码体现出来,要注意观察响应码的变化。
4.2 前置条件清单:什么情况下哪些协议能用
经验多了以后,我判断一个文件包含点能用到什么程度,基本就是套一个条件清单:
php://filter:只要包含点成立,无额外条件。它可以读取任何 PHP 进程有权限读的文件。这里唯一要注意的是 PHP 版本带来的过滤器差异,比如某些版本对convert.iconv过滤器链的支持程度不同,多数情况下用convert.base64-encode就够了。php://input:需要allow_url_include = On,并且包含点要能接收请求体。GET 型包含点通常也能用,但构造起来麻烦一点,需要用 POST 同时带上参数和 body。data://:需要allow_url_include和allow_url_fopen都是 On,并且 WAF 不拦截协议关键字。在 GET 型包含点上它是最顺手的。
我在判断目标配置时,一般会用一条探测请求同时测试多个条件。比如传file=/etc/passwd确认包含;传file=php://filter/read=convert.base64-encode/resource=/etc/passwd确认过滤器是否可用;传file=data://text/plain;base64,PD9waHAgZWNobyAidGVzdCI7Pz4=确认远程数据流是否放行。三条请求下来,目标的配置底细基本就摸清了。
4.3 过滤器链和编码绕过的思路
绕过 WAF 是文件包含利用里绕不开的话题,也是我踩坑最多的方向。最常见的情况是 WAF 拦截了 URL 里的php://、data://、resource=等敏感关键字。这时代的思路不是绕着走,而是换一条路继续走。
一个比较实用的思路是利用 URL 编码和路径变形。resource=index.php可以写成resource=index%2ephp,部分中间件在解码前不会识别出.php后缀;协议关键字php://filter也可以尝试大小写混写,比如Php://Filter,要看目标环境的协议解析是否大小写敏感。这个完全依赖具体的 PHP 版本和配置,所以测试时要随手记录成功与失败的情况,形成自己的小字典。
另一个进阶思路是过滤器链,这也是php://filter最灵活的地方。比如组合read=convert.iconv.UTF-8.UTF-7|convert.base64-encode/resource=xxx,先把文件内容做个字符集转换,再加上 base64 编码,这样在数据流动的早期阶段就避开了对base64-encode单词的精确匹配。对于 Laravel 框架里出现过的那类利用字符集转换触发 PHP 代码执行问题的场景,过滤器链还能用于构造无写文件的执行链。但这里要说明白:这种玩法需要大量测试和较强的前置条件,实战中更多是做防御研究的思路延伸,我建议新手先掌握filter和data://的组合效果,过滤器链作为一个进阶方向去了解即可。
5. 修复与加固:防御视角才是长久之计
5.1 入口白名单:最有效的第一道闸
文件包含漏洞的修复方案里,效果最直接、成本最低的就是白名单。既然你本来就有固定的几个包含目标,那就不要把整个文件系统开放给用户选。把允许包含的文件名列一个数组,只接受数组里的值,其他一律拒绝,伪协议就无从谈起。
<?php $allowed = ['home' => 'home.php', 'about' => 'about.php', 'contact' => 'contact.php']; $page = $_GET['page']; if (isset($allowed[$page])) { include($allowed[$page]); } else { http_response_code(404); exit('Page not found'); }有人觉得白名单要求改动业务逻辑,工作量太大,那至少也要做一层路径校验:用realpath()把传入路径转成真实绝对路径,再判断这个路径有没有落在站点根目录的允许范围内。这个方案的思路是"不信任任何外部输入,服务端必须重新验证后才能使用"。实现时要注意realpath()对不存在的文件会返回false,要处理好这个分支,避免引起空值导致的问题。
5.2 PHP 配置层面怎么兜底
代码层面的修复可能漏掉某个犄角旮旯的调用点,所以配置层的兜底必不可少。第一个必查项就是allow_url_include,生产环境必须设为Off。这个开关每开一分钟,php://input和data://就多一分钟的生存空间。很多遗留系统能搞出 RCE,追根溯源都是这个配置没有收口。
第二个必查项是open_basedir。它能把 PHP 能够访问的文件限制在指定目录内,即使攻击者找到了包含点,也没法直接读取/etc/passwd或者网站目录之外的文件。open_basedir不能单独解决文件包含,但它能把攻击者的视野压缩到很小,大幅提升后续利用难度。
第三个是disable_functions。如果你确定某个目录存在动态包含需求,且实在无法改成白名单,至少可以禁用掉一批危险的执行函数。但这只是一个降级方案,真正的安全还是要靠入口校验。配置层的意义在于"纵深防御",而不是"靠它解决问题"。我把常用配置整理一下:
allow_url_include = Off allow_url_fopen = On open_basedir = /var/www/html display_errors = Off log_errors = On error_log = /var/log/php_errors.logdisplay_errors设为 Off 这一点经常被忽略。包含失败时那一条 Warning 里包含脚本路径和包含逻辑,这些信息对攻击者来说全是情报。线上环境把错误显示关掉,错误写进日志文件,既能保护信息,又方便开发者排查问题。
5.3 日志与监控的检测特征
从蓝队视角看,文件包含漏洞不是没法发现的。Web 访问日志和 WAF 日志里,伪协议利用有明显的特征可循。我每次做日志审计时都会重点捞几类关键字:php://、file://、data://、expect://,以及convert.base64-encode、resource=之类的子串。如果看到 URL 里同时出现这些关键字和一个文件路径,那基本可以判定有人正在试图利用包含点。
另一个检测维度是响应特征。php://filter的响应里通常会出现很长的 base64 字符串,而且内容高度随机。如果 WAF 或者日志分析系统能对响应体做语义检测,这类流量是可以被识别出来的。更细的检测点在于:正常业务请求的 URL 参数值通常是about.php这种固定文件名,而利用请求的参数值会夹杂..路径穿越、绝对路径或者协议名,结构差异非常明显。
我还建议把异常请求自动化收集起来。规则不用太复杂,一条正则就够了:如果请求参数里出现协议名或者路径穿越符号,就记录到独立日志里并告警。规则过严可能误报,但文件包含的核心特征非常明确,宁可多记几条再人工复核,也不要漏掉真正的问题。
6. 常见问题与踩坑速查
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| filter 读取后内容为空 | 目标文件无权限,或路径有误 | 先确认浏览器能直接访问到该文件,再核对路径 |
| base64 解码后不是源码 | 包含了 css/js 等非 PHP 文件,或资源路径错了 | 换一个明确的 PHP 文件测试,比如直接读 index.php |
| php://input 页面空白 | allow_url_include 未开启 | 检查配置,开不了就换 filter 读取源码 |
| data:// 报 500 | allow_url_fopen 关闭,或 payload base64 有误 | 重新 base64 编码,确认没有多余换行 |
| WAF 拦截关键字 | 防护规则命中了协议特征 | 尝试 URL 编码变形或过滤器链,但多半说明防护存在 |
| 日志里频繁出现包含点报错 | 有人正在批量扫描 | 立即查访问日志来源,并收紧入口校验 |
还有一个我特别想说的高频坑:很多人忽略 PHP 处理相对路径时的差异性。你在本地实验环境用file=index.php成功,到了目标环境却失效,不一定是对面修了漏洞,可能是目标脚本的当前工作目录和你预想的不一样。遇到这种情况,先试绝对路径和./前缀,再试相对路径穿越,逐步缩小范围。
实操层面,我给自己定了一条纪律:每次构造 payload 之前,先在本地把 payload 用php -r或者浏览器验证一遍能不能跑通,再往目标上放。这个习惯帮我省了无数时间,很多问题其实不是目标环境的问题,而是 payload 本身编码错了。
最后再分享一个小技巧。如果你手头有一个 PHP 环境的 shell 权限,可以直接用命令行查看当前所有配置项,比如php -i | grep allow_url,几秒钟就知道服务和配置的底细。这个命令在本地排查环境问题时非常好用,比翻文档快得多。
文件包含漏洞看似简单,但它是很多真实安全事故的入口。我在实际测试里遇到的案例,十有八九都是开发顺手拼接、运维放开了配置、安全观察不到位这几个环节叠加出来的结果。把伪协议的原理、检测特征和修复手段串起来看,你会发现防御并没有想象中那么复杂,关键是把每个环节都堵死,而不是寄希望于某一层防护能包住所有问题。