1. 文件包含漏洞到底在包含什么
第一次碰到文件包含漏洞,是在一次内部代码审计里。一个看似平平无奇的模块加载函数,参数直接取自请求,当时我只改了一个字符串,整个应用的源码就原样吐在了页面上。那一刻的感受很直接:这不是某个函数写错了,而是"文件路径"这件事从一开始就被当成了可以随便传的数据。
文件包含漏洞(File Inclusion Vulnerability)属于注入类问题的一个分支,它的根本成因只有一句话:程序把用户能控制的输入,当成文件路径交给了包含类函数去执行或读取。在 PHP 里是 include、include_once、require、require_once;在 JSP 里是<jsp:include>和<%@ include %>;在 ASP.NET 里是服务端包含指令;在 Node 生态里则表现为动态 require 或模板引擎的动态渲染。载体不同,本质一样。
它带来的后果分两个层级。轻的一层叫任意文件读取,攻击者能读到配置文件、源码、密钥、数据库连接串,信息泄露一步到位。重的一层叫代码执行,一旦被包含的文件内容里含有可被解释执行的代码片段,服务端就会真的跑起来。从"读"到"执行",中间只隔着一个"包含的文件内容里有没有代码"。
这篇文章适合三类人看:正在做代码审计、想知道从哪几行开始下手的同学;负责业务开发、想搞清楚自己写的加载逻辑有没有埋雷的同学;以及做安全建设、需要给团队定一套可落地修复规范的同学。我不打算只讲概念,重点放在"怎么判断、怎么验证、怎么修、修完怎么防回归"这条完整链路上,中间会穿插我在实际项目里踩过的坑和一些不太见于文档的细节。所有演示都在自建授权环境里完成,请勿对任何未授权的系统做测试。
2. 从一行 include 说起:漏洞的成因与分类
2.1 一段"看起来很合理"的代码
先看一段被无数项目写过、也被无数审计报告点过名的代码:
<?php $module = $_GET['mod']; include $module . '.php';写这段代码的人当时的想法很朴素:我要做一个模块化加载,传mod=user就加载user.php,传mod=order就加载order.php,多灵活。问题在于,$_GET['mod']是完全由外部决定的,它可以是user,也可以是../../etc/passwd,还可以是一个完整的远程地址。
这里有个很多人忽略的细节:include在找不到文件时只抛警告(Warning),脚本继续往下跑;require则会抛致命错误直接中断。所以在实战排查中,include的行为更像是"静默探测",你传一个不存在的路径,页面可能一点变化都没有,但传一个存在的路径,页面内容会明确变化。这个差异在判断阶段非常好用,后面会细讲。
还有一个更容易被忽视的点:包含语句本身不校验内容。它不会判断被包含的文件是.php还是.jpg,只要文件系统能打开、内容符合解释器的解析规则,就会被当作代码处理。图片马之所以能生效,靠的就是这一条。
2.2 本地包含与远程包含的分水岭
按包含目标的来源,业界通常分成两类:
| 类型 | 英文缩写 | 包含目标位置 | 典型利用价值 |
|---|---|---|---|
| 本地文件包含 | LFI | 目标服务器本地文件系统 | 读源码、读配置、借日志/会话/上传文件落地执行 |
| 远程文件包含 | RFI | 外部可访问的地址 | 直接指向外部资源,一步到位 |
这两个类型的差别不只是"文件在哪"。RFI 的利用门槛低得多,但它的生效有硬性前提:PHP 的allow_url_include必须开启。这个选项从 PHP 5.2 起默认就是关闭的,现代环境里基本见不到打开的情况,所以实际项目中 RFI 的比例已经很低了。但要注意,allow_url_fopen是另一回事,它默认开启,控制的是fopen、file_get_contents这类文件读取函数能不能读远程地址。很多"看起来像 RFI"的案例,实际上是文件读取漏洞而不是包含漏洞,报告里把这两个混为一谈是很常见的误判。
LFI 虽然不能直接指向外部,但它有另一条路:先想办法把代码写进服务器上的某个文件,再去包含它。日志文件、会话文件、上传的图片、临时文件、缓存文件,都是这条路的中转站。这也是 LFI 在现代环境下依然是高危漏洞的原因——它不依赖那个默认关闭的开关。
2.3 为什么"截断"和"二次拼接"值得单独说
老代码里常见这种写法:include $dir . '/' . $page . '.html';。表面上有前缀有后缀,看起来很安全,实际上前缀只是限制了起始目录,后缀只是限制了结尾字符。攻击者可以通过../一路往上跳,跳出去之后再接自己的路径。
早年 PHP 5.3.4 之前存在%00截断问题,可以在路径末尾注入空字节把后面的.html吃掉。现在这个特性已经被移除,但很多教程还在讲,容易误导新手。现代环境下更值得关注的是二次拼接问题:有些框架会把参数拼进模板路径,再交给模板引擎解析,两层拼接之间没有做规范化,导致../能穿透目录。这类问题在审计时不能只看包含语句那一行,要顺着参数一路往上追到入口。
我的经验是,审计包含类问题时优先搜这几个关键字:include、require、include_once、require_once、fopen、file_get_contents、readfile、file、show_source、highlight_file。搜完再对每个命中点往回追参数来源,判断是 GET、POST、COOKIE、HTTP 头还是从数据库里读出来的。数据库来源也不能放过,因为数据库里的值可能是用户写进去的。
3. 高频触发点:哪些业务写法最容易出事
3.1 语言差异带来的不同表现
不同技术栈的包含机制差异很大,审计时关注点也不一样。
PHP 是最"宽容"的,include接受相对路径、绝对路径、URL、各种包装器,还会按照include_path去搜索。这种宽容性正是漏洞高发的原因。JSP 的静态包含<%@ include file="..." %>是编译期行为,动态包含<jsp:include page="...">是运行期行为,后者参数可控时才有可能被利用,而且 JSP 一般会把参数解析成相对路径,../的穿透能力受容器配置影响。
ASP.NET 的用户控件注册、Server.Execute、Response.WriteFile在某些老项目里也存在路径可控的情况。Node 里最危险的是require(path.join(__dirname, userInput))这种写法,虽然require会缓存模块,但路径穿透依然成立;模板引擎如 EJS、Handlebars 的动态render更常见,某些版本还存在通过原型链污染间接控制渲染路径的玩法。
我的建议是不要按语言去背,要按"路径来源"去判断:只要一个字符串最终流向了文件系统调用,且中途没有经过白名单或规范化,就标记为可疑点。语言只是决定了利用手法的表达形式。
3.2 业务里最常出现的五个位置
跑过几十个项目之后,我发现包含类问题高度集中在几类业务形态上:
- 多语言/多主题切换:
?lang=zh-cn加载lang/zh-cn.php,这是经典中的经典,几乎没有过滤。 - 模板与视图渲染:
?tpl=detail加载对应模板,尤其是自研的简易模板引擎,通常连目录限制都没有。 - 模块化/插件化加载:
?mod=xxx&act=yyy,CMS 类系统里极其常见。 - 文件下载与预览:
?file=report.pdf,虽然严格说是任意文件读取,但和包含漏洞同源同修法。 - 缓存与日志查看:后台的"查看日志"功能,参数直接拼进路径。
这里面最危险的是第一类和第二类,因为它们往往在前台、未授权可访问,且返回内容直接渲染到页面上,攻击者能立刻看到效果。第三类通常需要登录,但如果存在越权或者本身就是前台模块,风险等级要往上提一档。
注意:不要因为"这个参数只有登录后才能控制"就把它降级处理。很多系统的注册接口、找回密码接口、分享链接接口都是未授权的,参数一样能流到包含逻辑里。
3.3 包装器:攻击面的放大器
PHP 提供了一系列流包装器,其中几个在包含场景里价值极高:
php://filter 读取并过滤文件内容,常用于读源码 php://input 读取请求体,配合 POST 可以间接落地代码 data:// 直接内联数据,需要 allow_url_include phar:// 触发反序列化,属于另一类问题的入口 zip:// 读取压缩包内文件php://filter是最常被用到的,因为它不需要任何特殊配置,默认可用。它的作用是把文件内容按指定过滤器处理后返回,最常用的组合是convert.base64-encode,把源码转成 base64 输出,避免 PHP 代码被直接执行导致页面空白。
data://和php://input依赖allow_url_include,现在基本用不上,但审计时如果发现目标环境是老旧版本,还是要测一下。
phar://稍微特殊,它本身不是包含漏洞,而是借包含语句触发的反序列化入口。这个点值得单独写一篇,这里只提醒一句:如果代码里存在file_exists、filesize、getimagesize这类函数且参数可控,同样可能触发 phar 反序列化。
4. 授权环境下的完整验证流程
4.1 先把调试开关打开
验证之前,环境要准备好。我习惯用容器起一个精简的 PHP 环境,把错误显示打开,这样包含失败时的警告信息能直接告诉我路径拼接的结果,省掉大量猜测。
; php.ini 关键项 display_errors = On error_reporting = E_ALL allow_url_include = Off ; 保持默认关闭,模拟真实环境 open_basedir = ; 先留空,方便验证,后面单独测限制效果 log_errors = On同时准备一个访问日志,Nginx 或 Apache 都可以,后面验证"日志包含"时要靠它。日志路径记下来,通常是/var/log/nginx/access.log或/var/log/httpd/access_log,容器里路径可能不同,用ps看进程启动参数最准。
提示:
open_basedir是 PHP 的限制项,它会把文件操作限制在指定目录内。验证阶段先关掉,是为了确认漏洞本身存在;修复阶段再打开,是为了测试纵深防御是否生效。这两个阶段不要混在一起做,否则你分不清是漏洞不存在还是被配置挡住了。
4.2 从可疑参数到确认包含
拿到一个可疑参数,我通常按这个顺序推进:
第一步,判断是包含还是读取。传一个不存在的文件名,看返回。如果页面出现Warning: include(...): failed to open stream这类字样,基本可以确定是包含语句;如果只是返回空内容或 404,可能是file_get_contents之类的读取函数。这一步决定了后续用哪种手法。
第二步,判断路径拼接方式。传?mod=test,看警告里的完整路径。警告会告诉你程序实际找的是/var/www/html/test.php还是/var/www/html/modules/test.php。知道前缀和后缀,才能算出要跳几级目录。
第三步,验证目录穿透。传?mod=../../etc/passwd,注意后缀会自动补上.php,所以真实路径是../../etc/passwd.php,找不到。这时候要用空字节(老版本)或者利用后缀特性。更实用的办法是找一个后缀本身无害的目标,或者用php://filter绕过后缀限制——php://filter后面的部分会被当作资源名,配合convert.base64-encode读取时,后缀拼接的影响会被过滤器处理掉。实际测试中,?mod=php://filter/read=convert.base64-encode/resource=index这类载荷往往能直接读到源码。
第四步,确认执行能力。读源码只是信息泄露,要确认能否执行,得找一个内容里含 PHP 代码的文件来包含。最直接的办法是看能不能控制某个文件的写入,这就进入下一节的三条路径。
4.3 php://filter 读源码的实操细节
这是使用频率最高的手法,细节不少。
?mod=php://filter/convert.base64-encode/resource=config返回的是一串 base64,复制出来解码即可。有几个坑要提前知道:
第一,resource=后面的路径不要带.php后缀,因为程序会自动补。如果非要带,就写成resource=config.php然后把程序补的后缀想办法吃掉,不太现实,所以按程序拼接规则来。
第二,过滤器可以叠加,比如convert.base64-encode|convert.base64-decode,但意义不大。更有用的是string.strip_tags配合,不过实际用起来容易把内容搞乱,我一般只用 base64。
第三,读大文件时 base64 输出会很长,浏览器可能截断。这时候用抓包工具看原始响应体,比看页面可靠。
第四,如果目标是二进制文件,base64 一样能读,只是解出来是乱码,这时候要判断有没有必要读——通常配置文件、源码、密钥文件才是重点。
注意:读到源码之后,重点是找数据库连接串、第三方服务的密钥、内网地址、上传目录配置。这些东西往往比"能执行代码"更有价值,因为它们直接打开了横向的路径。
4.4 日志、会话、上传三条落地路径
本地包含要变成代码执行,核心思路是"找一个我能写内容、又能被包含的文件"。常见的有三条路。
日志包含。请求日志会记录 URL、User-Agent、Referer 等字段。把一段 PHP 代码放进 User-Agent 里发一次请求,日志文件里就会出现这段代码。然后包含日志文件,代码就会被执行。这里的关键是日志路径要能猜到或者读到,常见位置是/var/log/nginx/access.log、/var/log/httpd/access_log、/usr/local/nginx/logs/access.log。如果读不到路径,可以先通过任意文件读取拿到 Nginx 配置,配置文件里写着access_log的路径。
会话包含。PHP 的会话文件默认存在/tmp下,文件名是sess_加会话 ID。会话 ID 通常在 Cookie 里,攻击者自己就能控制。只要往会话里写入一段内容(比如通过某个把用户输入存进 session 的功能),再去包含/tmp/sess_xxxx,就能执行。这条路不依赖日志路径,但需要目标存在"把可控内容写进 session"的功能点。
上传包含。上传一个图片,图片内容里插入 PHP 代码,然后包含这个图片文件。前提是上传目录的路径可预测,且上传没有做内容检测。这条路最经典,也最容易被 WAF 拦,实际成功率取决于目标的上传校验强度。
三条路的共同点是:先写后包含。所以审计时如果发现包含点,同时发现上传点或日志可写,要立刻把这两个点串起来评估,单独看每一个可能都是中危,串起来就是高危。
5. 代码层修复:白名单、规范化与配置兜底
5.1 白名单映射是最省心的解法
修复包含漏洞,最彻底的办法是不让用户输入参与路径构造。具体做法是建立一个映射表,用户传的是键,程序查表拿真实路径:
<?php $map = [ 'user' => __DIR__ . '/modules/user.php', 'order' => __DIR__ . '/modules/order.php', 'product' => __DIR__ . '/modules/product.php', ]; $key = $_GET['mod'] ?? ''; if (!isset($map[$key])) { http_response_code(400); exit('invalid module'); } include $map[$key];这段代码的好处是显而易见的:无论传什么,最终被包含的只可能是表里那三个文件。攻击者传../../etc/passwd,isset判断直接失败,连目录穿越的机会都没有。
我在实际项目里推这套方案时,遇到的阻力通常是"模块太多,映射表维护麻烦"。解决办法是把映射表做成自动扫描:启动时扫一遍模块目录,用文件名作为键生成映射,代码里不写死。这样既保留了白名单的安全性,又不需要人工维护。要注意扫描时只允许特定后缀,且目录要写死,不能从配置或请求里取。
白名单的另一个变体是枚举校验:参数必须是纯字母数字,长度不超过某个值,不符合就拒绝。这个方案比映射表弱一些,因为如果目录下恰好存在一个名字合法的恶意文件(比如上传目录里的abc.php),还是能被包含。所以枚举校验适合作为补充,不能作为唯一防线。
5.2 路径规范化:realpath 用对了才有用
很多人知道要用realpath,但用法经常是错的:
// 错误写法:先拼接再判断,判断条件形同虚设 $path = realpath(__DIR__ . '/modules/' . $_GET['mod'] . '.php'); if (strpos($path, __DIR__ . '/modules/') === 0) { include $path; }这段代码的问题在于:realpath在文件不存在时返回false,strpos(false, ...)会触发类型转换,行为不可预期;而且如果攻击者传的路径能跳出去并且文件真实存在,realpath会返回真实路径,strpos判断能挡住,但前提是前缀比较写得对——/var/www/html/modules_evil/也会以/var/www/html/modules开头,前缀比较必须带上结尾斜杠。
正确的写法应该是:
<?php $base = realpath(__DIR__ . '/modules'); $target = realpath($base . '/' . $_GET['mod'] . '.php'); if ($target === false || strncmp($target, $base . DIRECTORY_SEPARATOR, strlen($base) + 1) !== 0) { exit('forbidden'); } include $target;先算出基础目录的真实路径,再算目标文件的真实路径,然后做严格的前缀比较,并且比较时带上分隔符。这样../穿越之后realpath会解析出真实位置,前缀对不上就被拒绝。同时$target === false处理了文件不存在的情况,避免后续比较出错。
提示:
realpath有一个副作用,它会解析符号链接。如果部署环境里有软链接指向外部目录,可能出现意料之外的结果。生产环境建议配合open_basedir一起用,双保险。
5.3 运行配置层面的兜底
代码改完之后,配置层再加一道:
allow_url_include = Off allow_url_fopen = Off open_basedir = /var/www/html:/tmp disable_functions = exec,passthru,shell_exec,system,proc_open,popenallow_url_include关闭是默认值,但老环境升级上来可能被改成 On,要显式确认。allow_url_fopen关掉会影响一些正常业务(比如抓取外部接口),要评估后再动。open_basedir是最有效的兜底,它把文件操作限制在指定目录,即使代码里有穿越也出不去。disable_functions属于减少影响面,不是修复漏洞本身,但能显著降低被拿下之后的破坏力。
配置层要注意一个现实问题:很多团队的安全配置只在测试环境做过,生产环境的配置模板是另一套,上线时被覆盖。我见过不止一次"代码修了、配置没同步"的情况。解决办法是把安全配置写进基础镜像或者配置管理工具的模板里,让它成为默认值而不是可选项。
5.4 几个常见修复误区
修这类漏洞,我见过一些看起来有效、实际上没用的做法,值得单独列出来。
误区一:过滤../就算完事。攻击者可以用....//绕过简单的字符串替换,因为替换掉中间的../之后,剩下的部分又拼成了../。还可以用 URL 编码、双重编码。只做字符串替换的过滤,基本等于没做。
误区二:只允许.php后缀。后缀限制挡不住目录穿越,../../tmp/evil.php一样满足后缀要求。而且如果攻击者能往某个目录写.php文件(比如上传目录配置不当),后缀限制反而成了帮凶。
误区三:把参数 md5 一下再用。这个思路有时候被用来"防止注入",但 md5 之后路径就固定了,业务逻辑会失效,最后往往被迫改回原样。安全性不能靠这种自欺欺人的转换。
误区四:只修前台不修后台。后台的管理功能往往被认为"反正要登录",但后台账号可能是弱口令、可能是社工拿到的、可能通过越权访问。修漏洞要一视同仁。
误区五:修完不做回归测试。白名单加上去,某些合法模块可能加载失败,业务方验收时才发现,于是临时把校验放宽,漏洞又回来了。修复时要把所有模块名跑一遍,确认没有误杀。
6. 排查实录与常见问题速查
6.1 几个让我印象深刻的排查场景
场景一:模块名带下划线,误判成了修复成功。某次审计一个自研框架,参数过滤里有一条规则是"必须以字母开头",看起来能挡穿越。我传?mod=a/../../etc/passwd,被规则放行了,因为开头确实是字母。规则的漏洞在于它只校验了首字符,没管后面有没有斜杠。后来改成了"只允许字母数字下划线,且长度不超过 32",才算真修好。
场景二:open_basedir开了但还是读到了。某环境配了open_basedir = /var/www/html,但/var/www/html下面有一个上传目录,攻击者把文件传上去,再包含,限制完全没起作用。这说明open_basedir只是限制了"出不去",管不了"在里面乱来"。上传目录必须单独加固,最好放在 web 根目录之外,或者干脆禁止执行。
场景三:修复之后页面空白,怀疑被绕过。加了白名单,测试同学反馈某个功能打不开了。查了半天发现映射表里漏了一个模块,属于误杀。这个提醒我,任何校验上线前都要做全量回归,不能只测攻击用例,正常用例的覆盖同样重要。
场景四:日志包含的路径猜错。一直以为日志在/var/log/nginx/access.log,实际容器里是/var/log/nginx/host.access.log,试了十几次都没成功,最后是通过任意文件读取读了 Nginx 主配置才找到真实路径。这件事的教训是:信息收集比盲目尝试重要,能读文件就先去读配置,比自己猜路径高效得多。
6.2 常见问题速查表
| 现象 | 可能原因 | 排查动作 | 处理建议 |
|---|---|---|---|
| 包含后页面无变化 | 文件不存在,include 只报警告 | 打开 display_errors 看警告 | 确认路径拼接规则后再构造 |
| 读源码返回空白 | PHP 代码被执行了,输出为空 | 改用php://filter转 base64 | 用过滤器包装资源 |
php://filter报错 | 过滤器名写错或包装器被禁用 | 检查disable_functions和allow_url_include | 换用可用的过滤器组合 |
| 穿越路径被拦 | 存在open_basedir或路径规范化 | 尝试目标目录内的文件 | 说明纵深防御生效,需继续查其他点 |
| 上传后包含失败 | 上传目录改名、内容被重编码 | 检查上传返回路径和文件内容 | 确认内容未被压缩或转码 |
| 日志包含无输出 | 日志缓冲未刷新或路径不对 | 读 Web 服务配置确认路径 | 换一条请求路径再试 |
| 白名单上线后功能异常 | 映射表漏项或大小写不一致 | 对比所有合法请求参数 | 补齐映射并做全量回归 |
6.3 我在实际项目里的几点体会
第一,这类漏洞的修复成本其实很低,低到很多时候一个in_array就解决了,但它被拖很久的原因往往不是技术难,而是没人愿意承认自己写的加载逻辑有问题。推动修复时,拿一个能读到配置文件的演示比讲十页原理有用。
第二,审计时不要只盯着include。我在一个项目里发现真正的入口是file_get_contents,参数可控,能读任意文件,虽然不能执行,但泄露了数据库密码,危害同样严重。读取类和包含类要一起看,修法也基本一致。
第三,白名单一定要有默认拒绝。我见过一种写法:先判断参数是否在允许列表里,不在的话走"兼容模式"加载默认路径。这个"兼容模式"就是后门,攻击者只要传一个不在列表里的值,就能走到没有校验的分支。默认分支必须是拒绝,不能是放行。
第四,日志和会话这两个中转站,很多人修了包含点就以为完事,忽略了它们的可写性。比较稳妥的做法是:日志目录不对 web 进程开放读权限(或者干脆用独立的日志收集方案),会话文件目录同样收紧权限。这样即使包含点没修干净,也缺少可用的中转文件。
第五,验证修复效果时,我习惯准备一套固定的回归载荷,包括正常参数、../穿越、绝对路径、php://filter、data://、空字节、超长字符串、特殊字符。每次代码变更后跑一遍,形成习惯。这套载荷不复杂,但能挡住绝大多数"修了又破"的情况。
最后分享一个容易被忽略的细节:include和require的区别在排查时很有用。如果传不存在的路径,页面直接 500 或白屏,说明用的是require;如果只是警告但页面继续渲染,说明是include。这个判断能帮你快速确认代码结构,减少盲试次数。另外,某些框架会用@抑制错误,这时候警告看不到,就要靠对比响应长度、响应时间、HTTP 状态码来间接判断,多试几条不同深度的路径,看哪一条的响应特征发生变化,往往能定位到真实的基础目录。