
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;写这段代码的人当时的想法很朴素我要做一个模块化加载传moduser就加载user.php传modorder就加载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 业务里最常出现的五个位置跑过几十个项目之后我发现包含类问题高度集中在几类业务形态上多语言/多主题切换?langzh-cn加载lang/zh-cn.php这是经典中的经典几乎没有过滤。模板与视图渲染?tpldetail加载对应模板尤其是自研的简易模板引擎通常连目录限制都没有。模块化/插件化加载?modxxxactyyyCMS 类系统里极其常见。文件下载与预览?filereport.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之类的读取函数。这一步决定了后续用哪种手法。第二步判断路径拼接方式。传?modtest看警告里的完整路径。警告会告诉你程序实际找的是/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读取时后缀拼接的影响会被过滤器处理掉。实际测试中?modphp://filter/readconvert.base64-encode/resourceindex这类载荷往往能直接读到源码。第四步确认执行能力。读源码只是信息泄露要确认能否执行得找一个内容里含 PHP 代码的文件来包含。最直接的办法是看能不能控制某个文件的写入这就进入下一节的三条路径。4.3 php://filter 读源码的实操细节这是使用频率最高的手法细节不少。?modphp://filter/convert.base64-encode/resourceconfig返回的是一串 base64复制出来解码即可。有几个坑要提前知道第一resource后面的路径不要带.php后缀因为程序会自动补。如果非要带就写成resourceconfig.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/passwdisset判断直接失败连目录穿越的机会都没有。我在实际项目里推这套方案时遇到的阻力通常是模块太多映射表维护麻烦。解决办法是把映射表做成自动扫描启动时扫一遍模块目录用文件名作为键生成映射代码里不写死。这样既保留了白名单的安全性又不需要人工维护。要注意扫描时只允许特定后缀且目录要写死不能从配置或请求里取。白名单的另一个变体是枚举校验参数必须是纯字母数字长度不超过某个值不符合就拒绝。这个方案比映射表弱一些因为如果目录下恰好存在一个名字合法的恶意文件比如上传目录里的abc.php还是能被包含。所以枚举校验适合作为补充不能作为唯一防线。5.2 路径规范化realpath 用对了才有用很多人知道要用realpath但用法经常是错的// 错误写法先拼接再判断判断条件形同虚设 $path realpath(__DIR__ . /modules/ . $_GET[mod] . .php); if (strpos($path, __DIR__ . /modules/) 0) { include $path; }这段代码的问题在于realpath在文件不存在时返回falsestrpos(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 几个让我印象深刻的排查场景场景一模块名带下划线误判成了修复成功。某次审计一个自研框架参数过滤里有一条规则是必须以字母开头看起来能挡穿越。我传?moda/../../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 状态码来间接判断多试几条不同深度的路径看哪一条的响应特征发生变化往往能定位到真实的基础目录。