☰
深入php://filter:文件包含漏洞的原理、过滤器链与防护
2026/10/21 23:08:16 网站建设 项目流程

各位做安全的同行应该都清楚,文件包含漏洞(LFI/RFI)在Web漏洞里的地位有多高。它不像SQL注入那样“输入即得”,但一旦遇上有代码审计缺陷的PHP站点,利用php://filter伪协议往往能直接撕开整个业务系统的口子。这篇文章不绕弯子,直接从底层原理讲到实操利用,再补上过滤器链、日志包含这类进阶玩法,最后聊防护。无论你是刚入行的信安新人,还是在职的运维开发,只要接触Web安全测试,这版内容值得花十几分钟通读一遍。

我自己早期也属于那种“模板背得熟、原理说不清”的状态,遇到php://filter只知道照抄payload:php://filter/convert.base64-encode/resource=index.php。直到有一次在某授权测试项目里,目标环境的include点过滤了一堆常见伪协议,常规思路全部失效,最后靠一组iconv过滤器链绕了过去。那次之后我才真正把这套管道的机制吃透,也才下定决心把经验沉淀成一篇完整文档。接下来按我的实际操作顺序,从原理开始逐步展开。

1. 内容整体设计与思路拆解

打开这篇文章的读者,大概率已经在实际测试中碰到过类似场景:一个URL参数直接拼进了include、require或readfile,上传点、日志文件或会话文件有可控内容。此时php://filter的价值,不只是“读源码”这么简单。

1.1 php://filter到底是拿来做什么的

php://filter是PHP内置的流封装器(stream wrapper),官方名字叫“过滤器”。它在文件读写过程中插入一层数据流管道,允许在数据被读取或写入之前,先经过一个或多个过滤器处理。最常见的用法是base64_encode和base64_decode,但完整能力远不止前面提到的这两个。

用一个生活化类比来解释:你把文件系统想象成自来水厂,include就是家里水管的水龙头。php://filter则像在水龙头上加装了一个净水器,水流出来之前可以在里面先过一遍“处理工序”。净水器里装几层滤芯,水流过就按顺序被处理几次。PHP文件流同理,php://filter/过滤器1/过滤器2/resource=目标文件,数据会先经过过滤器1,再经过过滤器2,最后到达处理函数。

1.2 文件包含漏洞本身在Web安全里的位置

PHP文件包含漏洞的根因是:开发者在调用include或require时,直接把用户传入的参数作为文件名使用,且没做严格过滤。最典型的是以下几个场景:

<?php // 场景1:未加任何过滤 include $_GET['page']; // 场景2:简单后缀拼接,但可以被空字节或路径截断绕过 include $_GET['page'] . '.php'; // 场景3:通过文件包含读取模板内容 $template = $_GET['template']; include('templates/' . $template);

在这些代码中,用户传入/etc/passwd、php://filter/convert.base64-encode/resource=config.php、data://text/plain;base64,...等值,都会让服务器尝试读取并处理这个“外部资源”。其中php://filter的优势在于:它不仅能读取任意文件内容,还能通过过滤器对读取结果进行编码处理,从而绕过PHP解析器的拦截,把源代码原样“带出来”。

1.3 为什么不用file://或直接路径读取

有朋友会问:读文件不直接用file://或绝对路径不就行了?这里有一个PHP包含机制的细节:include一个PHP文件时,目标文件会先被当成PHP代码解析执行,再返回执行后的输出。如果你直接用绝对路径读取一个包含<?php标签的文件,大概率什么也看不到——PHP解析器会试图执行里面的代码,而非返回源代码。除非你有办法让文件内容在解析前先被改变,php://filter正好能完成这种“预先编码”的动作。

convert.base64-encode把文件内容转成base64字符串,代码中原本的<?php、变量、函数等全部变成普通字符,PHP解析器不再把它们当代码执行,而是作为输出内容返回给客户端。你在浏览器或Burp Suite里得到一坨base64串,解码就是完整源码,这就是它在文件包含漏洞里经久不衰的核心原因。

2. 核心细节解析与实操要点

原理清楚了,接下来聊聊这些过滤器具体怎么组合、直接套用为什么有时候会翻车。这一节里的内容,都是我在本地环境和实际项目中反复验证过的。

2.1 过滤器类型及适用场景

php://filter支持多种过滤器,常用的按功能分为三类:

过滤器类型标志性写法典型用途注意事项
字符串过滤器string.rot13将内容ROT13编码,可绕过敏感字符过滤编码后可读性差,适合短文本探测
转换过滤器convert.base64-encode/convert.base64-decode读取源码、绕过PHP解析、传输数据最常用,编码后体积膨胀约33%
转换过滤器convert.iconv.utf-8.utf-16修改字符编码,常用于XSS绕过或FTP被动模式利用部分PHP环境扩展未启用时会报错
资源过滤器zlib.deflate或zlib.inflate压缩/解压文件流依赖zlib扩展,需确认环境支持
自定义过滤器由用户注册业务自定义处理逻辑少见,审计时可以留意

用的时候需要区分“读过滤器”和“写过滤器”。include、file_get_contents这类读取操作,使用read方向上的过滤器;如果写入文件时想“先处理再落盘”,就使用write方向。php中的默认规则:php://filter/过滤器类型/过滤器名/resource=目标,如果省略过滤器类型(如read=),则该过滤器同时作用于读写两个方向。

2.2 过滤器链的执行顺序与“坑”

每次你在php://filter/后写多个过滤器,PHP会按从左到右的顺序依次处理数据。过滤器之间用/分隔。以这个payload为例:

php://filter/convert.base64-decode/convert.base64-encode/resource=index.php

处理顺序是:先让文件内容经过base64解码,再经过base64编码。对于本来就是PHP源码的普通文本,base64解码大概率会失败(因为解码期望的输入是纯base64字符集),这种链路经常导致输出为空或报错。反过来:

php://filter/convert.base64-encode/resource=index.php

文件内容先被base64编码,编码结果再进入include。因为include会把整段内容当作PHP代码解析,这段base64串并没有PHP标签,会被当作HTML/纯文本输出。于是源码就以编码状态呈现在响应里。

这里埋着第一个经验之谈:过滤器顺序不要瞎拼。并不是过滤器越多越好,很多payload直接在线上环境报错“Unable to allocate buffer”或“Invalid parameter”,往往就是过滤器顺序不对或类型不匹配。我习惯的做法是,先在本地搭一个最小复现环境测试一遍,再上真实目标。

2.3 编码链路的副作用与体积换算

使用base64编码时有个必然的副作用:文件大小膨胀。base64编码用4个字符表示3个字节的原始数据,体积增加约33%。如果目标站有输出长度限制或WAF检查响应包大小,体积膨胀可能触发告警。这种情况下,可以优先尝试string.rot13——字符数量不膨胀,但输出的可读性较差。

string.rot13处理后的结果长得很像乱码,但它的好处是:改动前后字符一样多,且对于不区分大小写的敏感词匹配有天然绕过优势。想快速查看文件内容时,我经常先用rot13探测是否存在读取漏洞,再上base64进行完整获取。本地一个简单测试:

// 本地测试文件 test.php <?php echo "hello world"; ?>

用php://filter/string.rot13/resource=test.php读取,输出是<?cuc rpub "uryyb jbeyq";?>。虽然不带栅栏也能猜出原文,但确认“读取链路通不通”这个目标已经达成。接着再用convert.base64-encode/resource=test.php,拿完整源码。

2.4 遇到PHP版本差异怎么办

不同PHP版本对php://filter的支持程度有细节差异。老版本PHP 5.x对非法过滤器的容错性更高,有时候过滤器名写错也只会warning,不会直接fatal。而PHP 7.x / 8.x更加严格,部分不支持的过滤器会直接抛Fatal error。另外convert.iconv.*在PHP 5.4之后默认启用,但在PHP 8.0之后部分编码组合会报Unsupported encoding。

注意:实战中如果payload语法完全正确却毫无输出,优先怀疑版本兼容性问题。可以先在响应页面中开启display_errors观察报错信息,或直接换用data://、php://input等替代协议。

3. 实操过程与核心环节实现

光说原理不行,下面我以一次完整测试过程为例,演示如何从探测到利用、再到进阶的完整链路。所有环境均为我本地搭建并已获得授权测试,请务必在合规前提下操作。

3.1 搭建一个最小测试环境

本地实验我推荐直接用现成的容器。用Docker跑一个带PHP环境的镜像,里面放入以下两个文件:

<?php // vuln.php 漏洞入口 $file = $_GET['file']; include($file); ?>
<?php // config.php 目标文件 $db_host = "127.0.0.1"; $db_user = "appuser"; $db_pass = "S3cretPass!"; $db_name = "appdb"; ?>

启动容器后,访问http://127.0.0.1/vuln.php?file=config.php,页面会显示config.php执行后的结果,但由于config.php没有任何输出,页面基本是空白的。此时用file=php://filter/convert.base64-encode/resource=config.php再访问一次,页面就会返回一长串base64字符。解码后得到config.php源码,数据库账号口令直接暴露。这是我用来演示的第一课,说得直白一点,任何用到用户可控参数的include点,在没有过滤的情况下都等于把源码装进快递盒送人。

3.2 三步判断文件包含点是否可利用

面对真实目标,我不会上来就塞payload,而是先做三个基础探测:

  1. 传一个不存在的文件,观察报错信息中是否暴露了绝对路径,这能帮助确认当前目录和框架结构。
  2. 传一个确定的本地文件(比如index.php自己),观察页面变化,确认包含行为是否生效。
  3. 传php://filter/convert.base64-encode/resource=index.php,如果返回一段base64字符串,基本可以确认存在无过滤的LFI。

这三步看下来,漏洞已经实锤。接下来才需要根据业务场景选择利用方向:是读取源码进行进一步审计,还是直接尝试RCE。

3.3 通过php://input实现无文件代码执行

在某些开启了allow_url_include的环境中,可以用php://input向请求体中放入PHP代码,达到无文件RCE的效果。严格来说这不再属于php://filter的范畴,但它们是同级的伪协议,放在一起理解更完整。

# 请求 GET /vuln.php?file=php://input HTTP/1.1 Host: target.example <?php system('id'); ?>

前提条件是allow_url_include=On。如果目标环境的php.ini里这项是Off,请求会失败。这也是很多“LFI秒变RCE”做法失效的直接原因。相比php://input,php://filter常常不需要开启allow_url_include,因为在PHP设计里filter属于“本地流包装器”,默认允许使用。这是它相对data://、http://等协议的核心优势。

3.4 更稳的文件读取链路:多段组合

有时目标代码包含了一层过滤,比如正则删除了base64字符串或禁用了resource=关键字。这时可以尝试多段组合绕过:

php://filter/convert.base64-encode/resource=../config.php php://filter/read=convert.base64-encode/resource=config.php php://filter/convert.base64-encode|convert.base64-encode/resource=config.php

第三种写法里,|在部分PHP版本中能替代/作为过滤器分隔符,从而绕过“只删/”的简单防护。不过这种方式在PHP 7.0之后逐渐失效,需要实测。为了尽量稳定,我更推荐配合路径转换技巧,比如使用resource=./config.php、resource=config%2ephp、绝对路径,甚至配合..目录穿越来判断过滤规则。

3.5 进阶技巧:convert.iconv过滤器链实现任意代码执行

这一节是整个实操部分含金量最高的地方。当目标环境虽然能包含文件,却无法通过常规方式写入文件、也不允许data://时,过滤器链依然是可行的出路。

原理一句话概括:convert.iconv支持把一种字符编码转换成另一种,例如UCS-4LE到UCS-4BE会做字节序反转。通过精心挑选一组编码转换配合string.strip_tags、convert.base64-decode,可以让输入数据在逐级过滤器处理中“变形”成一段合法PHP代码。这条链路的本质相当于把过滤器当成“汇编指令”,用滤镜组合手工拼接出一段代码执行序列。

举个例子,一个常被提及的利用形态是这样的:

php://filter/convert.iconv.UTF8.CSISO2022KR|convert.base64-decode|convert.iconv.UTF8.UTF7|.../resource=php://temp

php://temp本身是一个临时内存流,不需要目标服务器存在实际文件。整条链利用php://temp作为数据起点,经过多次iconv和base64解码的交替处理,最后在管道末端生成一段有实际攻击负载的PHP代码。这种用法曾经在多个CTF赛题里出现,也是现代LFI利用技巧中少见的“无文件”形态。

那么问题来了:怎么确定链条中每个过滤器的作用?

以convert.iconv.UCS-4LE.UCS-4BE为例,它会把输入数据的内部字节序进行反转。而convert.base64-decode只会把输入中合法base64字符保留下来并解码,其他字符被丢弃。把这两种不同“语义”的过滤器交错排列,就能对原始输入进行有选择的保留、丢弃、反转操作。把这些操作组合起来,理论上可以生成任意字节序列。

注意:组装过滤器链是个拼耐心的活。实际开发中很少有人手工硬算,一般都借助现成的脚本来搜索过滤器组合。某个管理工具里常见的自动链生成脚本,就是基于这类搜索策略来工作。我建议初学者先把单步过滤器行为摸清楚,再逐步组合两条、三条链。

3.6 配合日志文件包含实现RCE

过滤器链的门槛偏高,真实渗透中更常见的思路是“日志投毒+文件包含”。例如,当Nginx或Apache把请求日志写入access.log时,你可以把PHP代码放进User-Agent头里发送给服务器。日志文件会原样记录这段内容,接着再用LFI漏洞去包含这个日志文件,PHP代码得以执行。

实操步骤大概是这样:

# 第一步:向目标发送带有恶意 UA 的请求 curl -A "<?php system('id'); ?>" http://127.0.0.1/vuln.php?file=config.php # 第二步:通过LFI包含日志文件 curl "http://127.0.0.1/vuln.php?file=/var/log/nginx/access.log"

第二步执行后,响应内容里会包含完整日志,其中<?php system('id');?>被PHP解析并执行,输出uid=33(www-data) gid=33(www-data)。这个思路胜在简单直接,唯一的限制是你需要知道日志文件的绝对路径。常见探测路径包括:

  • /var/log/nginx/access.log
  • /var/log/apache2/access.log
  • /opt/lampp/logs/access_log
  • /var/log/apache/access.log

日志投毒的变种很多。例如Apache把POST body写在error.log,或者在Referer头中插入代码后访问一个不存在的路径,也可以让部分中间件把这段内容记录进日志。不论哪种方式,核心都落到两个问题上:日志里能否写进任意字符?LFI能否包含到日志文件本身?

3.7 会话文件包含

如果目标站使用PHP原生Session功能,且session文件保存在服务器固定路径下,你可以通过向Session中写入可控内容,再利用LFI包含Session文件来实现代码执行。

Session文件默认路径通常是/var/lib/php/sessions/sess_加上你的会话ID。当你登录或产生会话数据时,PHP会把$_SESSION中的内容序列化写入该文件。如果能控制Session中的某个字段值(例如昵称、主题偏好),就可以在Session文件里预置PHP代码。然后通过LFI读取会话文件,触发代码执行。

这个思路要求的目标场景比较窄,但实战中很有用。很多后台系统允许用户修改资料里的“默认模板”或“个性签名”,这些内容如果直接存入Session,就是天然的投毒点。

4. 常见问题与排查技巧实录

“为什么我的payload在本地能用,到目标上就失效?”这是我在技术交流群里被问得最多的一句话。下面把高频问题汇总成表格,省得大家一个个试错。

问题现象可能原因排查/解法
返回空白convert.base64-encode被WAF删除尝试string.rot13或双重base64编码绕过
返回403目标有URL协议拦截,如禁止php://尝试路径穿越+绝对路径包含,或过滤器链
页面显示源码但被截断输出长度限制或代理响应处理改用string.rot13(体积不膨胀)或分片读取
代码执行失败allow_url_include=Off换用php://filter读取源码,放弃直接RCE
过滤器报错过滤器名在当前PHP版本不可用用phpinfo()确认可用过滤器列表,或切换版本
包含日志文件却无效果日志注入的代码被转义尝试用User-Agent、Referer、POST body多种位置
链失效过滤器顺序、iconv编码组合在当前版本不可用本地同版本环境重放调试

4.1 我自己踩过的坑

第一个坑是过度依赖模板。早期我在某个授权项目里,发现参数?file=存在包含点。我没多想,直接塞php://filter/convert.base64-encode/resource=index.php,结果响应一直是500。仔细排查后发现代码里用了htmlspecialchars对文件名做了编码转换,尖括号和引号被处理掉,而php://filter/中的//变成了\/\/,最终include路径变了。这个案例启示我,绕过过滤器的重点不只是“换payload”,而是先摸清过滤规则到底动了哪些字符。用Burp的Intruder发一组payload刷特征,远比盲目试更高效。

第二个坑是忽略PHP版本差异。某个目标服务器用的PHP 7.4,我拿一个在网上找的过滤链payload跑,提示Unable to allocate buffer。后来换本地PHP 7.4容器重放,发现问题出在convert.iconv的编码组合在该版本被移除了。所以说,遇到过滤器链失效,先别怀疑思路,检查版本兼容性更靠谱。

第三个坑是会话路径猜错。有一次目标环境session文件不在默认的/var/lib/php/sessions目录,而在/tmp下配置了自定义路径。我盲猜默认路径自然是白费功夫。正确的做法是先通过报错信息或配置文件拿到准确的session.save_path,再决定是否用会话包含。

4.2 日志包含失败的排查顺序

日志包含失败时,我一般按下面顺序排查:

  1. 先确认日志文件存在且可读。访问一个带?file=../../../../var/log/nginx/access.log的路径,看响应里是否出现日志原文。
  2. 确认PHP代码是否被中间件转义。Nginx默认会原样记录UA,但有些代理或WAF会在写入日志前过滤特殊字符。
  3. 确认包含时PHP代码是否被截断。如果日志行很长,Nginx的log_format配置可能做了截断,导致闭合标签不完整。
  4. 确认文件路径是否可穿越。虚拟主机环境下日志路径往往包含站点目录,需要动态拼接路径。

4.3 配置探测与信息收集技巧

在真实渗透中,我倾向于先通过phpinfo()页面(如果存在)或php.ini泄露来获取以下关键配置:

  • allow_url_include:是否为On,直接决定能否使用data://、php://input
  • disable_functions:哪些危险函数被禁用,决定RCE后的命令执行方式
  • session.save_path:会话文件的存储路径
  • open_basedir:限制文件读取的目录范围,避免盲目路径穿越
  • 扩展列表:确认是否有iconv、zlib等过滤器依赖扩展

拿open_basedir来说,如果目标设定了只允许读取/var/www/html内的文件,那你读/etc/passwd自然失败。但php://filter并不会绕过这个限制。要识别这类限制,报错信息里常见的提示是open_basedir restriction in effect。

5. 从攻到防:修复方案与服务端加固

讲完利用,必须花篇幅讲讲怎么防。任何漏洞分析,如果只停留在打打杀杀,价值就少了一半。真正能吃透漏洞的从业者,一定也清楚如何在一线业务里加固和止损。

5.1 代码层的正确写法

最直接也最有效的方案:不要使用用户可控参数直接拼接文件路径。需要动态加载文件时,从白名单里取。

<?php $allowedPages = [ 'home' => '/var/www/html/pages/home.php', 'about' => '/var/www/html/pages/about.php', 'contact' => '/var/www/html/pages/contact.php', ]; $page = $_GET['page'] ?? 'home'; if (isset($allowedPages[$page])) { include($allowedPages[$page]); } else { include($allowedPages['home']); } ?>

这种写法的优势在于,用户无论传入什么值,最后都只能落到白名单数组的键名上,彻底切断了路径控制能力。如果业务上确实需要灵活加载路径,至少要做到:禁止协议前缀(php://、data://等)、对路径做realpath()解析并校验前缀、禁止..穿越、使用basename()提取文件名。

5.2 PHP配置层面的加固项

在php.ini里,以下几项是必须检查的:

allow_url_include = Off allow_url_fopen = Off disable_functions = system, exec, proc_open, popen, shell_exec, passthru, pcntl_exec open_basedir = /var/www/html/:/tmp/websession

allow_url_include=Off后,data://和php://input直接失效,相当于堵死了一条RCE路径。disable_functions里的危险函数禁用,即使包含点被利用,攻击者也会发现命令执行处处受限。至于open_basedir,它把可读文件范围缩到业务目录内,让/etc/passwd这种敏感文件读取变成不可能。

但要注意,配置加固并不能替代代码修复。LFI的核心问题始终是“用户影响文件路径”,只要这个逻辑不堵上,攻击者总能找到绕过配置的新思路。

5.3 中间件与运行环境的额外考量

除了在代码和PHP层修复,中间件侧也建议同步调整:

  • Nginx/Apache访问日志不要和业务代码存放在同一可写目录。日志目录单独分区,且降低服务账户权限。
  • 对日志内容做字符过滤写入。虽然这会带来一定运维成本,但可以封死日志投毒这类后门入口。
  • 在WAF规则里增加对php://filter、convert.base64-encode、include参数拼接等关键字组合的检测。这里的组合检测很重要,单纯拦php://filter过于粗暴,容易误伤业务正常请求。

5.4 修复验证的自查清单

加固完成后,建议用下面的流程快速回归:

  1. 直接访问?file=php://filter/convert.base64-encode/resource=config.php,确认不再返回base64串。
  2. 访问?file=../../../../etc/passwd,确认返回404或空白。
  3. 构造恶意UA访问站点,再尝试包含日志文件,确认日志内容无法触发代码执行。
  4. 检查错误日志中是否出现open_basedir restriction in effect或类似告警,如出现则说明路径穿越被正确拦截。

6. 写在最后的一点实际经验

文章到这里,技术链路已经全部串起来了。最后再分享一次我在实际入侵检测中印象很深的经历。某次处理一个被入侵的站点,排查后确认攻击者正是通过一个后台模板编辑功能的LFI漏洞读到了数据库配置源码,然后拿着硬编码的数据库口令拖了一批数据。整个攻击链并不复杂,但影响面非常大。事后复盘,那个漏洞点就是一行include($_GET['template']),整个后台没有任何路径白名单。

个人使用php://filter时最有价值的体会,是它不只是“读文件”的工具。你把它放入include点,等同于获得了目标应用的代码面;再配合日志投毒和会话写入,又能把这个代码面转化为执行面。这是LFI漏洞从“信息泄露”逐步升级为“RCE”的关键路径。作为攻击者要懂链路,作为防御方更要懂链路,否则你连日志里多出来的一行UA都看不出猫腻。

最后送大家一个小习惯:无论做攻还是防,永远在本地搭一个和线上版本一致的PHP环境。低版本和高版本之间的过滤器行为差异比你想象中大得多。一套顺手的环境,能省掉大量在真实目标上“猜来猜去”的时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询