
看到这个标题懂的人应该立刻能联想到那几年被各种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?pageabout这样的 URL 来加载不同页面很灵活。但从安全角度看$page直接来自用户输入且没有任何过滤攻击者完全可以把page参数改成任意值让服务器去包含一个攻击者想要的文件。文件包含漏洞的核心成因不是函数本身有缺陷而是用户输入变量被直接传入文件包含函数且未经过有效的校验。这就像你家门锁没问题但钥匙孔的位置被别有用心的人掌握并利用了。1.2 本地包含与远程包含的差异文件包含漏洞按包含文件的来源分为两类LFILocal File Inclusion本地文件包含包含服务器上已存在的文件。攻击者可以读取系统中的敏感文件比如/etc/passwd、数据库配置文件、日志文件等。RFIRemote 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/readconvert.base64-encode/resourceconfig.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_includeOn而在默认配置里这个选项是关闭的。php://input类似它用于读取 HTTP POST 请求体的原始内容。攻击者在 POST 报文中写入 PHP 代码然后通过包含php://input让这段代码被当作 PHP 文件执行。它同样依赖allow_url_includeOn。从我的测试经验来看现在绝大多数新版本的 PHP 默认关闭了allow_url_include所以data://和php://input在真实环境里成功率并不高。但千万别因此忽视它们——很多老项目、运维偷懒的服务器或者某些框架在特殊场景下开了这个配置的情况依然大量存在。我在一次授权测试中就碰到过一台allow_url_includeOn的机器攻击路径瞬间变得非常直接。实操心得拿到一个包含点后第一件事不是急着打而是先判断 PHP 版本和配置。你可以通过phpinfo()页面、报错信息、响应头X-Powered-By等多个维度去确认。判断allow_url_include开没开其实很简单——直接看phpinfo()里的allow_url_include那一项或者用一个小探测 payload 试一下data://能不能通。3. 利用链路完整拆解从发现包含点到 getshell3.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.logPHP 代码就会得到执行。这条路的问题在于日志路径在不同系统上差别很大且权限设置各有不同。很多日志目录权限是 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_includeOn前面已经说过是最高效的一条路径。构造一个 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_includeOff导致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直接引入若存在检查是否经过白名单或路径校验3php.ini中的allow_url_include状态必须为Off4文件上传目录的权限设置上传目录不允许执行脚本文件5日志文件、Session 文件是否会被包含检查应用是否把include扩展到了日志/session 目录6是否部署了 WAF 规则拦截目录穿越、伪协议关键字WAF 规则不能只过滤../还要覆盖php://、data://等协议前缀自查的过程其实也是梳理自己代码的过程。我一直觉得安全建设不一定非得是采购多贵的设备、部署多复杂的系统但一定要理解代码里最基础的那些风险点在哪里。把文件包含漏洞这个点吃透你在面对其他类似的变量可控 危险函数组合时会有同样的判断力。我自己在这些年测试中最深的体会是漏洞本身不可怕可怕的是对自己系统里每一个可控入口都一无所知。漏洞的发现往往就是一趟顺着数据流找出口的旅程把这条路走顺了你会发现这类问题其实有规律可循。希望这篇文章能让你在遇到include时多留一个心眼不要再用那些把性命交给用户输入参数的写法了。