本文仅用于授权环境下的安全测试与学习,请勿对未授权系统进行测试。
一、任意文件读取:漏洞概述
1.1 什么是任意文件读取
定义:Web 应用在处理用户请求时,直接把用户输入的文件路径拿去读文件,既没做校验也没做过滤,导致攻击者可以读取服务器上的任意文件。
本质:用户输入被拼进了文件读取函数,例如:
file_get_contents($_GET['file']);
危害:泄露源码、数据库配置、系统用户信息、SSH 私钥;拿到源码和密钥之后,往往能进一步打通 RCE。
1.2 常见触发点(代码审计关键词)
| 代码写法 | 说明 |
|---|---|
file_get_contents($_GET['file']) | 最常见的读取入口 |
readfile($_POST['filename']) | 直接把内容输出到响应体 |
fopen($path, 'r') | 先开句柄再读 |
include($_REQUEST['page']) | 这已经属于文件包含,能执行代码 |
1.3 先分清:文件读取 vs 文件包含
这两个漏洞经常被混为一谈,但危害差一个量级。
| 漏洞类型 | 核心操作 | 危害 |
|---|---|---|
| 文件读取 | 读取文件内容并返回 | 泄露源码、配置、敏感数据 |
| 文件包含 | 把文件当作代码执行 | 直接 RCE |
判断方法就是看代码:
include($_GET['file']) —— 你可以传一个含 PHP 代码的文件让服务器执行它;
file_get_contents($_GET['file']) —— 你只能看到文件内容,代码不会被执行。
记住一条:只有「读取」这一层时,伪协议里真正能用的基本只有 php://filter(php://input、data:// 要配合 include 才有意义)。
1.4 常见敏感文件清单
| 系统 | 文件路径 | 作用 |
|---|---|---|
| Linux | /etc/passwd | 用户账户信息 |
| Linux | /etc/shadow | 用户密码哈希(需 root 权限) |
| Linux | /root/.ssh/id_rsa | SSH 私钥(可直接登录服务器) |
| Linux | /proc/self/environ | 环境变量(可能含 Flag) |
| Linux | /proc/self/cmdline | 当前进程的启动命令 |
| Linux | /proc/self/cwd | 当前工作目录(探测部署路径很有用) |
| Web | /var/www/html/config.php | 数据库配置文件 |
| Web | /var/www/html/.git/config | Git 泄露(可还原源码) |
| Web | /var/www/html/index.php | 网站首页源码 |
二、靶场一:无过滤,绝对路径一步到位
2.1 靶场信息
-
目标:读取服务器上的
/tmp/flag.txt -
页面特征:一个输入框,提示「请读取你想要读取的文件」
2.2 通关过程
信息收集阶段页面就直接给了提示,Flag 位于 /tmp/flag.txt。
在输入框里填绝对路径:
/tmp/flag.txt
点击「读取文件」,服务器把文件内容原样返回。
2.3 原理小结
后端代码对路径没有任何过滤,用户输入什么就读什么。这种情况下不需要任何绕过技巧,一个绝对路径就结束了。
这一关的意义不在于难,而在于让你先建立「输入 → 拼接 → 读文件」这条最基本的链路认知——后面三关全都是在这条链路上加过滤。
三、靶场二:相对路径穿越 + URL 编码
3.1 靶场信息
-
靶场名称:路径穿越(Path Traversal)
-
难度:1 级
-
核心考点:路径穿越原理、相对路径与绝对路径、URL 编码
-
目标:读取
/tmp/flag.txt
3.2 这次绝对路径为什么不管用
直接输入 /tmp/flag.txt,服务器返回「文件不存在」或「无权限」。
原因:后端对路径做了拼接限制,实际读取的路径是 upload/ + 你的输入,也就是:
upload//tmp/flag.txt
这个路径当然不存在。所以我们要换思路——从这个限制目录里跳出去。
3.3 用 ../ 跳出限制目录
../ 表示「上一级目录」。在 upload/ 后面不断向上跳,就能跳出限制目录、抵达系统根目录,再进入 /tmp/:
../../../../tmp/flag.txt
跳的次数给多了也没关系——一旦到了根目录 /,再往上一级还是 /。
3.4 URL 编码:为什么要编码
../ 里的 . 和 / 在 URL 中属于保留字符,可能被浏览器、服务器或 WAF 过滤、转义。把它们换成百分号编码就能避开字符串层面的黑名单:
| 字符 | 编码 |
|---|---|
. | %2e |
/ | %2f |
.. | %2e%2e |
../ | %2e%2e%2f |
在 Burp Suite 里把 ../ 替换成 %2e%2e%2f,再发包。
3.5 通关 Payload
%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2ftmp%2fflag.txt
同一关里,绝对路径失败、相对路径成功,这个对比很关键:绝对路径能不能用,完全取决于后端有没有强制拼接目录。后面两关你会看到同一个关卡里结论又反过来——别死记结论,要看过滤逻辑。
四、靶场三:双重 URL 编码(浏览器与 Burp 的差异)
4.1 靶场信息
-
靶场名称:任意文件读取的简单绕过 1
-
难度:1 级
-
核心考点:路径穿越、黑名单过滤绕过、URL 编码、双重 URL 编码(关键)
-
目标:读取
/tmp/flag.txt
4.2 第一回合:单次编码被拦
先直接输入 ../../../../tmp/flag.txt,服务器直接报错:
禁止使用../尝试目录遍历!
说明后端拿到参数后会对 ../ 做关键字匹配,命中就拦。
那就换成上一关的招数,在 Burp Suite 里把 ../ 替换为 %2e%2e%2f——结果还是失败。
4.3 第二回合:改从网页提交却成了
同样的 %2e%2e%2f,这次不改包,直接填进网页的输入框提交——成功读取到 Flag。
同一串字符,同一个服务器,只因为提交方式不同,一个失败一个成功。差异出在「实际上发出去的字节」上。
4.4 关键原理:编码几层,就要解码几层
| 提交方式 | 你输入的内容 | 实际发出去的 HTTP 请求 | 应用层第一次解码后 | 结果 |
|---|---|---|---|---|
| Burp 直接改包 | %2e%2e%2f | %2e%2e%2f | ../ | 被拦截 |
| 浏览器网页提交 | %2e%2e%2f | %252e%252e%252f(浏览器自动编码) | %2e%2e%2f | 成功绕过 |
链条是这样走的:
-
浏览器:你在输入框里写了
%,浏览器按表单编码规则把它编成%25,于是%2e%2e%2f变成%252e%252e%252f发出去。Burp 不会帮你做这一步,你让它发什么它就发什么。 -
应用层第一次解码:PHP 解析查询串时把
%252e%252e%252f还原成%2e%2e%2f。此时黑名单里找的是字面量../,找不到,放行。 -
应用层第二次解码:这段代码又对已经解码过的参数调用了一次
urldecode(),%2e%2e%2f这才变成../,最终交给文件函数去读。
⚠️ 最容易讲错的一点
网上很多教程会把这第二次解码说成「底层文件系统又解码了一次」——这是错的。
file_get_contents()、fopen()和操作系统从不做 URL 解码。你给它%2e%2e%2f,它就老老实实去找一个名字叫%2e%2e%2f的文件,然后报「文件不存在」。第二次解码一定发生在应用代码里。PHP 官方手册在
urldecode()页面明确警告过:超全局变量
$_GET和$_REQUEST已经被解码了。对$_GET或$_REQUEST里的元素使用urldecode()将会导致不可预计和危险的结果。这个靶场踩的正是这个坑——它在已经解码过的
$_GET上又解码了一次。所谓「双重编码绕过」,本质就是让载荷的编码层数刚好等于系统的解码次数:编码 1 层,会在第一次解码时暴露../被拦;编码 2 层,第一次解码后伪装成普通字符蒙混过关,第二次解码才现出真身。
4.5 为什么浏览器行、Burp 不行
-
浏览器:会自动把
%编码成%25,等于免费帮你做了一层双写; -
Burp Suite:完全忠实转发,你不手写
%252e,它就原样发%2e。
所以在 Burp 里手工构造时,必须自己把百分号也编码掉。
4.6 通关 Payload
%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252ftmp%252fflag.txt
4.7 通俗类比:只检查一次的门卫
把服务器想象成敌方总部,门口坐着一位极其死板的门卫大爷(应用层的过滤代码),他手里拿着黑名单,只要看到信里写着 ../(意思是「往回走,去别的房间」)就把你轰出去。
-
硬闯:你直接递上
../../../../tmp/flag.txt。门卫一看有../,大喊「禁止使用../尝试目录遍历」,把你拦在门外。 -
一次伪装:你把
../写成暗号%2e%2e%2f。门卫拿到信,习惯性地先翻译一遍暗号(URL 解码),一翻译发现意思还是../,再次把你轰走。 -
二次伪装:你把暗号又加了一层,写成
%252e%252e%252f。门卫解码一层,看到的是%2e——不是../,心想「这是普通字符,安全」,放行。信到了档案室管理员手里,管理员又解码了一次,路径这才变成../,顺利取到档案。
一句话记住:门卫只检查一次,但整个流程会解码两次。
而「为什么浏览器行、Burp 不行」也就好理解了——浏览器是那个会自动帮你多封一层信封的小弟,Burp 是老实的传令兵,要你自己动手封。
⚠️ 这个类比方便记忆,但别把「管理员」也当成门外汉——在真实系统里,第二次解码的仍是应用代码,不是操作系统。
五、靶场四:双写变形绕过(....//)
5.1 靶场信息
-
靶场名称:任意文件读取的简单绕过 2
-
难度:1 级
-
核心考点:变形路径穿越绕过、黑名单过滤绕过
-
目标:读取
/tmp/flag.txt
5.2 第一回合:php://filter 被拒
先试伪协议:
php://filter/convert.base64-encode/resource=/tmp/flag.txt
返回「文件不存在或无法读取」。
页面回显泄露了原因——它把实际处理的路径打了出来:
您当前读取的文件是:upload/php://filter/convert.base64-encode/resource=/tmp/flag.txt
也就是说后端在做 "upload/" . $filename 的拼接。伪协议有一个硬性要求:必须出现在路径的最开头。被拼到 upload/ 后面之后,它退化成了普通字符串,自然读不到东西。
另外也可能服务器直接把 php:// 拉进了黑名单。两种情况都会导致失败。
5.3 第二回合:绝对路径被拒
既然有 upload/ 前缀,那 /tmp/flag.txt 也会被拼成 upload//tmp/flag.txt,同样不存在。
前两关的招数这一关都用不上,说明黑名单升级了。
5.4 第三回合:....// 双写成功
用 ....// 代替 ../:
....//....//....//....//tmp/flag.txt
服务器成功返回 Flag。
原因在于后端大概率用了这类只做一遍的替换逻辑:
$safe = str_replace('../', '', $filename);
str_replace 会把所有不重叠的 ../ 删掉,但它不会回头检查「删完之后是不是又拼出了新的 ../」。而 ....// 恰好就是这个盲点。
5.5 逐字符拆解:....// 是怎么变成 ../ 的
先看单个 ....//(4 个点 + 2 条斜杠):
| 位置 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| 字符 | . | . | . | . | / | / |
替换引擎从左往右扫,在位置 2 匹配到 ../(位置 2、3、4),把它删掉。剩下的是位置 0、1、5:
. . /
也就是 ../——删掉一个 ../,反而造出了一个新的 ../。
再看完整 Payload:
输入: ....//....//....//....//tmp/flag.txt
↓ str_replace 删掉 4 处不重叠的 ../
输出: ../../../../tmp/flag.txt
后端检查时看到的是「没有 ../ 了,安全」,但真正交给文件系统的路径已经变成了标准的四次上跳。
5.6 为什么「删一遍」的黑名单挡不住
这里有一个常见的表述错误值得澄清:str_replace('../', '', $x) 不是「只删一个」,而是一次调用删掉全部不重叠的出现。
它之所以被绕过,不是因为「删得少」,而是因为删除操作会把残余字符重新拼接起来,拼出的新字符串里又出现了 ../。这是「删除型过滤」的通用缺陷——凡是「先删危险子串、再拿结果去用」的逻辑,都要考虑删完之后会不会重新组合成危险串。
这个坑在文件上传那一篇里也出现过:php 双写成 pphphp 之后,过滤器删掉一遍反而还原出了 php。
正确修法:循环替换直到字符串不再变化。
while (strpos($filename, '../') !== false) {
$filename = str_replace('../', '', $filename);
}
但更稳妥的仍然是下面第七章的白名单方案——黑名单永远是在和攻击者比谁想得多。
5.7 通俗类比:聪明反被聪明误的保安
这一关的保安(后端过滤代码)学聪明了,他升级了防御系统,还学会了删关键字。但他有个致命毛病:大脑很简单,只按一遍规则办事。
-
传送门(PHP 伪协议):你掏出
php://filter说「我不走大门,我直接传送过去」。保安翻了个白眼:「总部禁止传送门,而且所有路线图都必须从大厅upload/出发,你的传送门放错位置了。」失败。 -
暴力硬闯(绝对路径):你直接报档案室的绝对坐标
/tmp/flag.txt。保安冷笑:「系统规定所有人从大厅出发,你的路线图前面会被强行加上upload/,实际要去的是upload//tmp/flag.txt,那儿什么都没有。」失败。 -
终极伪装(双写变形):你发现保安看到
../就会把它删掉,而且删完就不再看第二眼。于是你写下....//....//....//....//tmp/flag.txt。保安扫描到一个藏在....//里的../,果断删掉——删完之后路线图上剩的字符重新拼起来,恰好变成了../../../../tmp/flag.txt。保安一看「没有../了,安全」,放行。档案室管理员照着这份路线图,向上跳了四次,把文件交了出来。
一句话总结:保安以为把 ../ 删掉就万事大吉,却不知道攻击者故意多写了几个点,让他越删越错,最后反倒亲手拼出了正确的攻击路径。
以后只要看到后端用 str_replace 这类「删一遍」的过滤逻辑,第一反应就该是:能不能让它自己把载荷重新拼出来?
六、绕过手法速查
6.1 防御方式 → 绕过方式对照表
| 防御方式 | 绕过思路 | 示例 |
|---|---|---|
直接过滤 ../ | 双写变形 | ....//....//....//....//tmp/flag.txt |
| 过滤前先做 URL 解码 | 双重 URL 编码 | %252e%252e%252f%252e%252e%252ftmp%252fflag.txt |
| 限制只能读特定目录 | PHP 伪协议 | php://filter/convert.base64-encode/resource=/tmp/flag.txt |
| 强制拼接目录前缀 | 绝对路径 / 路径穿越 | /tmp/flag.txt 或 ../../../../tmp/flag.txt |
| 正则严格匹配 | 特殊字符变形 | ..//..//..//tmp/flag.txt |
6.2 常用 Payload 清单
/tmp/flag.txt
../../../../tmp/flag.txt
%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2ftmp%2fflag.txt
%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252ftmp%252fflag.txt
....//....//....//....//tmp/flag.txt
..//..//..//..//tmp/flag.txt
php://filter/convert.base64-encode/resource=/tmp/flag.txt
php://filter 那一条建议加上 convert.base64-encode:一方面能绕开某些字符过滤,另一方面 Base64 输出不会因为遇到二进制字节而截断。
6.3 %00 截断:为什么在输入框里发不出去
老版本 PHP(5.3.4 之前)存在空字节截断:
../../../etc/passwd%00.png 会被截成 ../../../etc/passwd
但直接把这个 payload 填进输入框是发不出空字节的——表单和 URL 会对 % 再编码一次,你写的 %00 到了服务端会被还原成字面的三字符 %00,而不是一个 0x00 字节。想真发空字节,得用 Burp Suite 的 Hex 模式手动把那个位置的字节改成 00。
顺便一提,这一类「编码层数」的坑和第四章的双重编码是同一个根源。
七、防御总览(开发者视角)
7.1 七条防线
① 白名单校验(最稳)
只允许读取指定文件,其余一律拒绝。白名单是唯一不依赖「想得比攻击者全」的方案。
② 用文件 ID 映射,不让用户碰路径
// ❌ 危险:用户直接控制路径
$file = 'upload/' . $_GET['file'];
// ✅ 安全:用户只能选编号,路径由服务端查表得出
$map = ['1' => 'a.png', '2' => 'b.png'];
$name = $map[$_GET['id']] ?? null;
if ($name === null) { exit('非法请求'); }
$file = 'upload/' . $name;
③ realpath() 规范化 + 目录前缀比对
$base = realpath('upload');
$path = realpath($base . '/' . $_GET['file']);
// 注意:路径不存在时 realpath() 返回 false,必须显式处理
if ($path === false || strpos($path, $base . DIRECTORY_SEPARATOR) !== 0) {
exit('非法请求');
}
⚠️
realpath()在路径不存在或逐级目录缺少可执行权限时返回false(个别 BSD 平台是例外)。如果代码漏判了false,这个校验可能形同虚设——一定要先判false再比前缀。
④ 用 basename() 剥掉目录成分
最直接的一招。basename('../../etc/passwd') 的结果是 passwd,上跳符号直接被砍掉。
⑤ 配置 open_basedir
在 php.ini 或应用配置里限制 PHP 可访问的目录树:
open_basedir = /var/www/html:/tmp
这样即使路径穿越成功,也只能在指定目录里打转,读不到 /etc/passwd。需要注意它并非绝对可靠——/proc/self/cwd、/proc/self/root 这类特殊路径有时能把限制绕回来,所以仍要和白名单配合使用。
⑥ 权限最小化
Web 服务运行用户的权限应该只够读 Web 目录,不该有读系统敏感文件的权限。前面所有代码层的绕过都建立在「进程有能力读到那个文件」之上——把权限砍掉,漏洞的收益就没了。
⑦ 统一错误信息
不要把详细报错回显给用户。这一关里 您当前读取的文件是:upload/... 这种回显,等于直接把后端的拼接逻辑和部署路径白送给了攻击者。
7.2 澄清:php://filter 根本无法通过配置「禁用」
网上不少文章把「在配置中禁用 php://filter 等危险的伪协议」列为一条防御建议,这条是不成立的。
php://filter 是一个本地流包装器,它既不受 allow_url_fopen 约束,也不受 allow_url_include 约束——没有哪个 php.ini 开关能把它关掉。这两个开关管的是 http://、ftp://、data:// 这类涉及远程的包装器。
所以:
data://、http:// 做文件包含 → 受 allow_url_include 控制,关掉确实有用;
php://filter 读本地文件 → 关配置没用,只能靠白名单和 open_basedir。
把「禁用伪协议」当防线,会产生一种已经安全了的错觉,这比不设防更危险。
7.3 防御速查表
| 防线 | 挡住的主要手法 | 备注 |
|---|---|---|
| 白名单 / 文件 ID 映射 | 全部 | 首选,不依赖黑名单完备性 |
basename() | 路径穿越、绝对路径 | 一次性砍掉所有目录成分 |
realpath() + 前缀比对 + 判 false | 路径穿越、符号链接 | 别漏判返回值 |
open_basedir | 路径穿越读系统文件 | 非绝对可靠,需配合白名单 |
| 循环替换关键字 | 双写变形 | 黑名单兜底,永远不如白名单 |
| 权限最小化 | 降低全部漏洞收益 | 系统层兜底 |
| 统一错误信息 | 信息泄露 | 别把后端逻辑回显出去 |
八、实战利用链
发现文件读取点
↓
读取 /etc/passwd 确认漏洞存在
↓
读取 Web 源码,寻找数据库配置与隐藏接口
↓
遇到过滤就走 php://filter / 编码 / 双写变形
↓
结合文件包含漏洞(LFI)实现 RCE
↓
结合 SSRF 攻击内网服务
九、总结
这四关串起来是一条完整的升级线:
| 关卡 | 后端过滤逻辑 | 破解手法 | 关键认知 |
|---|---|---|---|
| 靶场一 | 无 | 绝对路径 | 输入直达文件函数 |
| 靶场二 | 拼接 upload/ 前缀 | ../ + URL 编码 | 相对路径可以跳出限制目录 |
| 靶场三 | 解码后再匹配 ../ | 双重 URL 编码 | 编码层数要对齐解码次数 |
| 靶场四 | 一遍 str_replace 删 ../ | ....// 双写变形 | 删除操作会重新拼出危险串 |
三句话收束:
-
同一份 Payload 在不同关卡结论可能相反——绝对路径在靶场一是最快的解法,在靶场三、四却完全无效。判断依据永远是后端的过滤逻辑,不是记忆里的结论。
-
任何「先解码/删除,再检查」的过滤都是可疑的。检查必须发生在规范化之后,而不是之前或者一半。
-
黑名单是和攻击者比拼想象力,白名单才是终局。这也正是第七章把白名单放在第一条的原因。
144




被折叠的 条评论
为什么被折叠?



