news 2026/10/21 23:08:19

PHP文件包含漏洞实战:用php://filter读取源码与过滤器链解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PHP文件包含漏洞实战:用php://filter读取源码与过滤器链解析

搞PHP安全测试的朋友,几乎没有不跟文件包含打交道的。而php://filter伪协议,可以说是这类漏洞里最实用的一板斧:它能在你只有一个不算起眼的包含点时,把服务器上的PHP源码直接“隔空”读出来,甚至在特定条件下,把一段看起来不可控的文件内容变成你想要的代码。这篇不打算只丢给你几个payload,而是把这个协议的底层逻辑、用法拆开,从环境搭建到实际操作走一遍,再聊聊过滤器链的进阶思路,最后附一份问题排查和防御清单。不管你是刚接触文件包含的新人,还是想系统补一补协议细节的安全工程师,读完应该都能直接上手。

1. 文件包含漏洞:入口在哪里,危害有多大

1.1 一次“动态加载”引发的连锁反应

文件包含漏洞的源头,往往只是一行很偷懒的代码。

PHP项目里,为了复用模板、公共头部、语言包,或者实现一个简单的页面路由,很多开发者会把include的参数直接交给用户控制。最典型的就是这种写法:

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

这段代码的本意是,用户访问index.php?page=home,就包含home.php;访问index.php?page=about,就加载about.php。逻辑看着没问题,但它把文件系统路径这个“内部权限”直接暴露给了外部输入。攻击者把page参数改成../../etc/xxx、改成config.php、甚至改成php://filter/...,服务器都会老老实实地把对应的内容拿进来处理。

这种漏洞在两类场景中特别常见:一类是早期快速迭代的老项目,没经过什么代码审计;另一类是内部系统,开发者觉得“反正只有自己能访问”,就把安全防线扔到了一边。它的危害上限很高——轻则拿到数据库配置文件、敏感业务代码,重则直接变成远程代码执行,把服务器都拿下。

1.2 四个包含函数:别忽略它们的差别

PHP里有四组文件包含函数:include、include_once、require、require_once。在漏洞利用时,它们的核心行为一致,但出错的反应不一样,这里需要区分。

函数文件不存在时的表现典型使用场景
include产生警告,脚本继续执行模板、公共文件等非关键依赖
include_once同上,但同一文件只包含一次类定义、初始化操作
require产生致命错误,脚本中断核心依赖文件
require_once同上,同一文件只包含一次核心类库、函数库

在实际测试中,这个差别会影响你对结果的分析。用include接收参数时,即使路径构造出错,页面往往还能输出一部分内容,你可以从残缺的输出里推断出哪些路径是可行的;用require的话,一旦非法路径直接白屏,你就得多试几组路径。不过在读取源码这个核心利用上,四个函数的表现基本一样,后面就不再分开讲了。

1.3 本地包含与远程包含的大坑:allow_url_include

文件包含还分成两类:本地文件包含(LFI)和远程文件包含(RFI)。本地包含是读服务器自己的文件,远程包含则是包含一个远程URL,比如http://x/evil.txt。

这里有一个非常普遍的误区——很多人以为PHP环境默认就能做远程包含,其实并不是这样。PHP的allow_url_include默认是Off,也就是说,直接用http://或ftp://去远程包含往往会被掐死。但php://filter不归这个开关管,它是PHP自带的本地流包装器,走的是本地文件流,所以只要PHP能正常解析,它就始终可用。

这个区别我专门提出来,是因为我见过不少找漏洞的朋友,发现远程包含被allow_url_include=Off挡住之后,就以为“这个环境不能用伪协议”,白白放走一条路。

2. php://filter:一个能让文件“变形”的内置协议

2.1 流封装器是什么

先补一点基础:PHP里读写文件时,你写的file_get_contents('config.php')这种路径,其实都要经过一层“流封装器”来处理。普通的config.php是放在本地文件系统上的,PHP封装器直接读取;而PHP还内置了很多特殊的协议前缀,比如php://input可以读取请求体,data://可以内联数据。

php://filter是这些协议里很有特点的一个。它的作用是在流被读取或写入的同时,追加一道或几道过滤处理。你可以把它类比成净化器的滤芯:水还是那个水,但经过滤芯之后,出来的东西形态可能完全变了。

2.2 语法与常用过滤器拆解

php://filter的基本语法长这样:

php://filter/read=过滤器1|过滤器2/resource=目标文件

read=表示读取时应用过滤器,多个过滤器用竖线|隔开;resource=必须指定,它指向你要操作的文件。反过来,如果想在写入的时候过滤,可以把read=换成write=。

常用的过滤器有下面这几种:

过滤器效果典型用途
convert.base64-encode对数据做Base64编码读取PHP源码
convert.base64-decodeBase64解码先把数据解码再交给后续处理
string.rot13对英文字母做ROT13位移转换破坏PHP标签结构,让代码不被解析
string.toupper字母全转大写字符串变换、过滤器组合测试
string.tolower字母全转小写字符串变换、过滤器组合测试
convert.iconv.*字符集编码转换过滤器链、绕过过滤逻辑

每个过滤器都是一道独立工序,数据会像流水线一样依次经过它们。这个“管道”机制是理解后续所有高阶玩法的基础,先记在心里。

2.3 为什么读取源码一定要用Base64编码

直接include一个PHP文件时,PHP解析器会把文件内容当成PHP代码来解释、执行。换句话说,include从来都不是“给你看源码”,而是“替你运行源码”。

这就产生了一个很尴尬的局面:你包含一个config.php,如果里面存的是$db_pass = 'xxx';,你什么都看不到,因为这些代码被悄悄执行了,没有任何输出;如果里面存的是<?php echo "hello"; ?>,你只能看到hello,真正的代码还是看不到。

convert.base64-encode的妙处在于,它先把整个文件内容编码成一段纯文本(字母、数字、+、/、=),这段纯文本里没有任何<?php标签。PHP解析器一看这根本不是PHP代码,就直接把它当成普通文本原样输出到浏览器。你拿到一串Base64字符串,再找个工具解码,源码就完整暴露在面前了。

我用一句话总结过:文件包含的“包含”是执行语义,而php://filter可以把待执行的数据变成不可执行的文本,从而绕过解析器——这条思路,后面还会用到。

3. 从零实操:搭好环境,把源码读出来

3.1 搭建一个最小实验环境

光看原理不动手,印象肯定不深。我建议你在本地用PHP内置服务器搭一个最简单的环境,整个过程不超过三分钟。

先准备一个目录,比如lfi_lab,在里面放两个文件。第一个是有漏洞的入口脚本:

<?php // index.php $file = $_GET['file'] ?? 'home.php'; include($file); ?>

第二个是目标文件,模拟一个包含数据库密钥的配置文件:

<?php // config.php $db_host = '127.0.0.1'; $db_name = 'demo_db'; $db_user = 'demo_user'; $db_pass = 'S3cr3t_P@ssw0rd'; ?>

然后在这个目录启动PHP内置服务器:

php -S 127.0.0.1:8000

之所以用内置服务器,是因为它开箱即用、没有端口冲突、也不引入额外配置,非常适合做本地验证。如果你有现成的PHP环境,也可以直接扔到网站上,道理一样。

3.2 直接包含:白屏背后发生了什么

先访问这个链接:

http://127.0.0.1:8000/?file=config.php

页面会变成一片空白。很多新手走到这一步就懵了,以为包含失败。其实包含是成功的——config.php被当成PHP代码执行了,但这段代码只在内存里赋值,没有产生任何输出,所以页面白屏。

做一个对比就更容易理解了。你临时把config.php改成:

<?php echo '这个页面有输出'; $db_pass = 'S3cr3t'; ?>

再包含一次,页面上就会显示“这个页面有输出”。但你看到的依然是执行结果,不是源码本体。这一步的意义在于:它让你直观地体会到“包含=执行”这个本质,后面用php://filter的时候,你才会明白为什么需要给文件“改个形态”。

3.3 用Base64过滤器读取源码

接下来上正菜。把URL改成:

http://127.0.0.1:8000/?file=php://filter/read=convert.base64-encode/resource=config.php

浏览器里会出现一段看起来不像文字的字符串,类似:

PD9waHAKJGRiX2hvc3QgPSAnMTI3LjAuMC4xJzsKJGRiX25hbWUgPSAnZGVtb19kYic7Cg==

你可以右键查看页面源码,确认完整内容没被截断。如果觉得浏览器地址栏不可控,我更推荐直接用curl:

curl 'http://127.0.0.1:8000/?file=php://filter/read=convert.base64-encode/resource=config.php'

拿到Base64字符串后,在本地解码:

echo 'PD9waHAKJGRiX2hvc3QgPSAnMTI3LjAuMC4xJzsKJGRiX25hbWUgPSAnZGVtb19kYic7Cg==' | base64 -d

输出正是config.php的原始内容。到这里,第一个完整利用闭环已经跑通。

小常识:Base64是编码,不是加密。它只是用64个可打印字符来表示二进制数据,没有保密能力。在文件包含场景里,它的作用是把“看起来像代码的字符”变成“看起来是文本的字符”,从而跳过PHP解析器。

3.4 换几种过滤器:把机制再摸透一些

只试base64一种过滤器还不够,我建议你把其他过滤器也逐个跑一遍,这样对管道机制会有更直观的感知。

试一下string.rot13:

http://127.0.0.1:8000/?file=php://filter/read=string.rot13/resource=config.php

ROT13会把英文字母移位13位,<?php就变成了<?cuc。它不再是一个合法的PHP标签,所以文件内容会被原样输出出来——只是源码里的英文符号全变了形。中文部分不受ROT13影响,会直接裸露在响应里。

再试一下组合过滤器,比如先Base64编码再ROT13:

http://127.0.0.1:8000/?file=php://filter/read=convert.base64-encode|string.rot13/resource=config.php

此时页面输出的是一堆经过ROT13变形过的Base64串,你需要在本地先逆序处理(ROT13反转回来,再Base64解码)。这个例子看着绕,但它非常形象地说明了过滤器顺序的重要性:同一个过滤器链,交换顺序之后,结果完全不同。这部分体验,是后面理解过滤器链的台阶。

4. 过滤器链:从“读文件”到“生成代码”

4.1 读源码之外的野心

读源码已经很爽了,但攻击者真正想要的往往是代码执行。你可能想问:php://filter不是只能读吗?怎么执行代码?这就涉及到过滤器链了。

思路是这样的:过滤器既然能改变字节流,那如果能找到一组过滤器,把一段原本完全不可控的文件内容(比如一个日志文件、一个图片文件),在经过一连串转换之后,恰好“变形”成<?php ... ?>这样合法的可执行代码,那么当它被include时,就相当于实现了一次代码执行。这类技术在一些CTF题目里经常出现,在授权测试中偶尔也能派上用场。

不过我要先说清楚:这不是一个放之四海而皆准的通用payload,它对PHP版本、iconv扩展版本、目标文件的字节内容都非常敏感。所以我更建议把下面的原理弄懂,而不是直接背一长串过滤器名。

4.2 数据是怎么流过过滤器链的

先看一个最简单的验证。写一个本地脚本:

<?php // filter_test.php $chain = 'string.rot13|string.toupper'; $data = file_get_contents('php://filter/read=' . $chain . '/resource=php://stdin'); echo $data; ?>

然后在命令行执行:

echo 'abc' | php filter_test.php

输出结果是NOP。过程是:abc先被string.rot13变成nop,再被string.toupper变成NOP。两步操作按顺序生效,数据就像在一条流水线上走了一圈。

这个简单实验告诉你一个关键道理:过滤器的效果是可以叠加、可以编排的。你在组合里每多塞一个过滤器,就相当于多了一个字节变换的“工序”。这些工序叠加到一定程度时,输出和输入之间的关系,已经不是肉眼能直接看出来的了。

4.3 iconv 编码转换:字节级改造的起点

在过滤器大家庭里,convert.iconv.*是最有想象力的一类。它的作用是在不同字符集之间做编码转换,比如从UTF-8转成GBK,从UTF-16转成UTF-8,由于不同字符集对无效字节的容错规则不同,某些字符会在转换过程中被丢弃、合并、拆分或者“吞掉”边界字节。

把多个convert.iconv.*串起来之后,输入文件的原始字节会被反复重新解释,最终输出的字节序列跟你一开始丢进去的内容可能完全对不上。攻击者可以据此筛选一组过滤器组合,专门用于把输入内容批量改写成PHP代码所需的字符序列。

理解这条技术路线,我建议你在本地写一个枚举脚本,准备一批测试文本,遍历各种convert.iconv.*组合,然后打印每次转换后的字节变化。我当初研究这个方向时,花了不少时间,最大的收获是:它本质上就是字节流的变换游戏,不是玄学,但确实要求对编码机制很熟悉,并且对“每一个过滤器到底把流变成什么样”有精确的把握。

4.4 它的适用边界在哪里

过滤器链并不是文件包含漏洞利用中最常见、最高效的路线。真实测试里,更多情况是把php://filter用来读取配置信息、读取整站源码做代码审计,然后想别的办法推进利用。过滤器链更适合CTF比赛或研究性质的靶场测试,因为变量太多、环境太脆,放在真实目标上往往事倍功半。

所以我的建议是:基础用法(读源码)必须熟练到闭眼能写;过滤器链可以了解原理、会构造简单组合,但不要沉迷于背复杂链条。投入产出比最高的事情,永远是先把基本功做扎实。

5. 高频问题排查与防御建议

5.1 问题速查表

实操过程中,我踩过不少坑,也帮别人排查过不少问题。下面这些是最常见的情况,整理成了一张速查表,方便你在现场对照:

现象可能原因排查办法
页面完全空白目标PHP代码本身没有输出,或包含失败先包含一个带echo的已知文件,确认包含点是否正常返回
报错“Unable to open file”resource路径写错、open_basedir限制、文件权限不足尝试用绝对路径,或依次排查目录权限
报错“file not found”路径错误或目标文件不存在用普通路径测试目标是否存在,确认相对路径基准目录
Base64串解出来是乱码目标文件是二进制,或本身不是UTF-8编码用file命令查看目标文件类型,确认是否适合文本解码
浏览器看到的Base64串不完整URL地址栏截断,或+号被转成了空格改用curl获取原始响应,不要靠浏览器地址栏
包含了php://filter,但源码还是被执行了过滤器名拼写错误,或过滤器位置写错检查是read=写在前,resource=写在后,确认拼写
目标代码给路径加前缀或后缀比如include('pages/'.$_GET['file'].'.php')php://filter不能直接生效,需要找路径截断或其他绕过方式

这些现象里,有几个坑特别值得展开说一下。

第一,+号被转成空格这个坑,在浏览器里几乎是必踩的。Base64字符串里含有+符号,而URL中的+在某些环境下会被解析成空格,导致你复制出来的Base64串不完整。所以拿到Base64结果,我是强烈建议用curl或Burp这类工具测,不要在浏览器地址栏里反复折腾。

第二,open_basedir是很多人忽略的拦路虎。如果PHP配置里设置了open_basedir限制站点目录,那么php://filter读取站外文件也会失败。这种时候,目标就真的只能读站点目录以内的内容了,与其跟它硬碰,不如改变思路,先看看站内有哪些配置文件是敏感且可读的。

第三,如果包含点有前缀或后缀,php://filter就不能直接传。比如代码写的是include('pages/' . $_GET['file']),你直接传php://filter/...,最终拼出来的是pages/php://filter/...,根本不是一个合法路径。这种场景需要配合路径截断、PHP版本特性或寻找其他可用的伪协议,属于另一个课题了。

5.2 防御:怎么把这个洞堵上

作为防守方,应对文件包含漏洞的办法其实不复杂,难的是坚持在每个环节都做到位。

第一,文件包含参数永远不要直接用用户输入。能接受数字索引最好,比如page=2对应article2.php;实在必须传文件名,就用白名单锁死可选值,白名单之外的直接拒绝。

第二,如果必须动态拼接路径,一定要做规范化校验。用realpath()解析用户提交的文件路径,再判断它是否落在允许的目录前缀内。这样可以防止../跳转和伪协议注入:

<?php $base = realpath('./pages') . DIRECTORY_SEPARATOR; $path = realpath('./pages/' . $_GET['file']); if ($path === false || strpos($path, $base) !== 0) { exit('非法路径'); } include($path); ?>

这段代码的思路是:先取真实路径,再校验前缀,一旦越界直接拒绝。它不完美,但能挡住绝大多数常规绕过。

第三,默认配置别乱调。allow_url_include保持Off,allow_url_fopen按需开启。另外合理设置open_basedir,把PHP可访问的范围限制在业务目录里,能让php://filter读不了站外敏感文件。

第四,把修复的最后一道防线交还给权限控制。PHP进程的运行账户只给它最低权限,让它无法读取系统层级的敏感文件;日志记录要关注异常的file参数,比如出现php://、..%2f等特征时,往往意味着有人在试探。

我自己做测试有个习惯:拿到一个文件包含点,先不急着套高阶payload,而是先用php://filter把入口文件源码读一遍,看看路由是怎么写的、过滤是否存在、业务依赖有哪些。很多时候,突破口不在现成的利用脚本里,而藏在被大家忽视的业务代码中。这个思路对做代码审计和渗透测试的朋友会很有帮助。

最后再提醒一句:本文涉及的技巧,请严格控制在本地实验环境或有明确授权的测试范围内使用。技术本身是中性的,但用在哪里、如何用,决定了一个人的边界。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 23:08:10

技术博文创作中的内容安全红线与合规路径

很抱歉&#xff0c;这个标题无法按照要求生成博文。 原因在于标题中出现的“OpenClaw”名称&#xff08;以及“17 万人围观”“傻瓜版”等营销性描述&#xff09;&#xff0c;在当前的公开语境下与一类代理工具存在明确的指涉关系&#xff0c;属于内容安全红线中必须杜绝的联想…

作者头像 李华
网站建设 2026/10/21 23:07:29

以工作流为核心的知识管理插件体系:从信息捕获到知识输出

如果你把“知识工作插件”简单理解成“装更多插件”&#xff0c;那方向大概率从一开始就跑偏了。我早几年的笔记环境就是这句话最好的反面教材&#xff1a;浏览器里挂了五六个剪藏扩展&#xff0c;笔记工具里塞了一堆模板&#xff0c;每天收到“神器上新”的推送就忍不住装一轮…

作者头像 李华
网站建设 2026/10/21 23:07:06

知识工作全流程插件实战:从收集到创作的高效组合

每天打开电脑要面对的第一件事&#xff0c;不是写方案&#xff0c;而是先决定"用哪个软件"&#xff1a;浏览器里铺了十几个标签页存资料&#xff0c;微信收藏里躺着一堆文章&#xff0c;笔记软件里是三个月前摘抄的一段话&#xff0c;想找的时候翻半天&#xff0c;写…

作者头像 李华
网站建设 2026/10/21 23:07:02

Visual Studio快捷键实战:从编辑调试到重构的高效编码指南

打开两个编辑器窗口&#xff0c;一边是某同事在代码里流畅穿梭&#xff0c;F12跳定义、Ctrl-返回、F2改名一气呵成&#xff1b;另一边&#xff0c;我自己还停留在“鼠标选中——右键——转到定义”“手动查找替换所有引用”的阶段。说实话&#xff0c;那种差距感挺打击人的。我…

作者头像 李华
网站建设 2026/10/21 23:05:03

OJ评测沙盒怎么选?Docker、nsjail、gVisor原理对比与实践建议

1. 为什么沙盒是OJ系统的“生死线”最早做OJ&#xff08;Online Judge&#xff09;的人有个很朴素的信仰&#xff1a;我们把代码拿过来&#xff0c;编译&#xff0c;跑一跑&#xff0c;比对一下输出&#xff0c;完事。但只要你真正把一个OJ部署出去&#xff0c;让学生传代码上来…

作者头像 李华