路径遍历听起来不像 SQL 注入那么如雷贯耳,在漏洞排行榜里它经常被划到“低危/中危”区间。但真到了实战中,只要代码里出现?filename=、?path=、?download=这类参数,路径遍历往往能让你直接看到服务器上的敏感文件,比如配置文件、日志、密码表,甚至配合其他漏洞拿到代码执行权限。我最近在 PortSwigger 的 labs 上把路径遍历相关的实验过了一遍,前前后后试了../穿越、编码绕过、双写绕过、绝对路径穿越等思路,今天把这段实操过程整理出来,希望能给正准备刷题的人一点参考。
1. 路径遍历到底是什么:从一个“文件参数拼接”说起
1.1 漏洞根因:入口信任用户输入
要理解路径遍历,最直接的方式是看一段“残缺”代码。假设一个文件下载功能写成这样:
$file = $_GET['file']; $path = "/var/www/uploads/" . $file; readfile($path);开发者本意是只允许读取/var/www/uploads目录下的文件,所以拼接前缀是固定写死的。问题在于,用户传入的file参数完全可控,../会被当成普通字符串拼进去,然后交给文件系统解释。文件系统的规则是:..表示回到上一级目录,于是../../../../etc/passwd最终解析出来的路径就变成了/etc/passwd,服务器的文件就被读走了。
这个例子虽然简陋,但几乎是所有路径遍历漏洞的缩影:开发者把用户输入当成了文件路径的一部分,而没有考虑路径分隔符和相对路径符号在操作系统里的特殊含义。更准确地说,这不是编码错误,而是信任边界设计错误。入口参数一旦落到文件系统 API,它就拥有了“相对导航”能力,防御者如果只想着过滤,很容易出现漏网之鱼。
1.2 为什么都在读 /etc/passwd?——目标文件怎么选
在靶场或实战演示中,大家最常用的是/etc/passwd,因为几乎所有类 Unix 系统都有它,且文件是明文,内容通常是用户账号信息。关键点是,它不是真正的“密码文件”,但它能证明漏洞真实存在,也方便判断穿越的层数。如果你从应用当前目录出发,不知道到底需要多少层../才能回到根目录,一个笨办法是多试几层,比如../../../../etc/passwd,只要到根目录之后,多余的上跳会被系统忽略,依然能读到文件。
不过,读取敏感文件不是只有/etc/passwd一种选择。在真实攻防中,攻击者更关心应用配置文件、环境变量文件、备份文件、日志文件、数据库连接字符串等。比如常见的:
/proc/self/environ:可能泄露环境变量,里面有密钥、数据库地址/var/log/apache2/access.log:可能包含请求日志,甚至配合日志注入执行代码/etc/nginx/nginx.conf、.env:应用配置泄露/root/.ssh/id_rsa:若权限允许,直接拿到密钥
这些目标的选择会影响绕过策略。有些文件路径中包含空格、特殊字符,在 URL 中需要编码;有些文件是二进制格式,读取后回显可能是乱码,但这不代表漏洞不存在。我在实验中最开始的习惯是先读/etc/passwd,因为这个文件有明确的root:x:0:0:标志,一眼就能确定路径穿越成功。
2. PortSwigger 实验环境:从注册到跑通第一个请求
2.1 为什么选 PortSwigger 靶场而不是自己搭环境
很多新手学路径遍历会先自己写一个 demo,或者在本地搭个 Docker。自己搭有问题:一是环境太干净,不会遇到过滤规则和 WAF 类题目;二是没有现成的“目标路径”提示,你还需要去部署一个包含漏洞的 Web 应用,踩坑成本高。PortSwigger 的 labs 是官方提供的在线实验环境,好处有三点:
- 实验环境是真实隔离的临时实例,启动后给你一个独立域名,不用自己维护;
- 题目从简单到难做了严格分级,路径遍历这个主题下就有“简单文件路径遍历”“带过滤器的路径遍历”“双写绕过”“绝对路径绕过”等场景,非常适合循序渐进地学;
- 答题判定是打开一个带标志文件的页面,自动验证是否读取到指定内容,比你自己找漏洞更清晰。
对我来说,最有价值的一点是这些实验特意把“绕过方式”做成了不同题目,而不是混合在一个大杂烩里。路径遍历的原理并不深奥,但应用层过滤逻辑五花八门,比如前一层把../删掉、后一层又把字符串反转、再一层只做 URL 解码。如果没有这种题目设计,你可能只会一个../就以为懂了。
2.2 用 Burp Suite 联动:修改请求的正确姿势
我刷 PortSwigger labs 的习惯是全程开 Burp Suite。虽然浏览器直接改 URL 也能完成部分题目,但遇到需要绕过的场景,往往需要同时改路径参数和请求头,手动在地址栏里敲不如在 Burp 里重发请求方便。
具体操作是:先在 Burp 里配置好流量拦截,让它接管浏览器的 HTTP 请求。打开 lab 页面后,点击任意一个功能点,比如“查看商品图片”或“下载附件”,这时请求会停在 Burp 的拦截窗口。在拦截界面里你可以直接把原始请求里的参数改掉,再 Forward。更快的做法是把请求发到 Repeater,这样能反复测试,不用担心每次都经过浏览器。
例如,一个简单的文件下载请求:
GET /resources?file=product.jpg HTTP/1.1 Host: 0a1b2c3d4e5f6g7h8i9jlab.web-security-academy.net我会在 Repeater 里把请求改写成:
GET /resources?file=../../../etc/passwd HTTP/1.1发送后观察响应正文。如果返回的是root:x:0:0:,说明穿越成功。在浏览器直接访问时,路径里的../可能被浏览器标准化掉,而使用 Burp Repeater 可以原样发送,这一点是很多人没跑通实验的常见原因。
3. 四个典型绕过手法:从裸奔到 WAF 对抗
3.1 最基础的“一路返回上级目录”
第一个要掌握的是绝对路径穿越配合层级计算。假如应用拼接的目录是/var/www/images/,你传入的file参数是../../../etc/passwd,最终文件系统解析出的路径就是:
/var/www/images/../../../etc/passwd这个路径从语义上等于/etc/passwd,因为..会把当前目录不断上跳,超过根目录后多余的..会被忽略。所以既不需要精确知道应用当前目录在哪,也不需要数层数数得非常准;只要给的..数量大于等于当前目录到根目录的深度,就能到达根目录。
在 PortSwigger 的第一道路径遍历实验里,目标是一个商品图片加载功能,参数名可能叫filename,请求路径类似/image?filename=product.jpg。把参数改成../../../../etc/passwd,然后发送,响应就能看到文件内容。这个实验的目的是让你建立“目录穿越成功标志”的敏感度:不是页面出现 200 就成功,而是要看到响应体里出现/etc/passwd文件的内容。
3.2 过滤 ./ 与 ../ 时怎么办
第二道实验通常会在服务端加一层过滤:比如把../字符串直接删掉。这时候直接发送../../../../etc/passwd会被清理成/etc/passwd,反而读不了。因为清掉之后路径少了几层,无法跳出images目录。
这种过滤有一个典型绕过思路:双写。如果过滤是非递归的,也就是只删一次../,那可以构造....//....//....//etc/passwd,当把中间的../删掉后,剩下的字符串刚好拼接成../../../../etc/passwd。更形象地说,相当于把“小偷”藏进“袋子”,安全门只抓外面的“小偷”,不拆袋子。
具体构造方式:把..和/之间插入多写一层,比如....//。服务端如果只做精确匹配../,它看到字符串里没有连续的../,实际上....//在文件系统里会被解析为../,因为前两个点是..,后两个点加斜杠又是../,拼接后等于两层。这种方法在 PortSwigger 的“路径遍历序列被剥离”实验中很常见。除了双写,还可以尝试把../写成..;/,某些中间件会把它规范化成../,这是利用框架路径解析差异。
3.3 编码绕过与二次解码
很多应用会先对输入做 URL 解码,再把解码后的字符串传给文件系统函数。如果存在两次解码,攻击者就可以构造双重编码来绕过黑名单。常见的编码有:
- 单次 URL 编码:
../写成..%2f,服务端解码一次后变成../ - 双重 URL 编码:
%252e%252e%252f,第一次解码变成%2e%2e%2f,第二次解码变成../ - 十六进制编码:
%2e%2e%5c对应..\,适用于 Windows 路径 - Unicode 编码:
../这种全角点、全角斜杠在某些语言处理函数中会被转换成正常的../
原理在于,过滤规则通常对已经解码后的字符串做匹配,但操作系统或文件系统 API 可能还会再做一次规范化。你需要在 Burp 里精确控制编码方式,而不是依赖浏览器自动编码。我试过直接用 Burp Repeater 把 URL 里的%2f改成%252f,虽然浏览器地址栏看着很奇怪,但服务器在两次解码后最终拿到的是/,从而绕过了过滤。做这类实验时要注意:如果响应格式是 JSON,文件内容可能被转义,需要看原始响应体;如果是图片下载,文件内容会以二进制形式返回,可以从十六进制里找 ASCII 标志。
3.4 绝对路径与符号链接的旁门左道
如果开发者的过滤逻辑是“把参数开头的../或/都去掉”,那么提交绝对路径/etc/passwd时,开头的/可能被删掉,变成etc/passwd,然后在拼接后的目录下找不到。这引出一个知识点:路径穿越并不一定需要..,如果开发者拼接前缀时写得不严格,绝对路径会直接把前缀覆盖掉。
例如$path = "/var/www/images/" . $file;,当$file是/etc/passwd时,很多语言和函数会直接把拼接结果当作新路径/etc/passwd,前面那段前缀就“掉”了。这取决于具体 API 的行为,PHP 的readfile()基本是字符串拼接后交给系统,C 语言的fopen()也类似。所以在 PortSwigger 的实验中,有一道题专门考绝对路径绕过,不需要写任何..,直接把参数设置为/etc/passwd就行。
系统里的路径解析还包括符号链接、路径混淆层。即使应用把用户输入经过realpath()规范化,某些场景下通过符号链接指向的目录也可能绕过根目录限制。这类问题在真实文件服务里时有发生,比如上传一个软链接或者提前创建别名目录。不过在 PortSwigger 基础实验里很少遇到符号链接,属于进阶知识。
4. 实战复现:在 PortSwigger 上刷掉三套路径遍历实验
4.1 实验一:简单场景直接读文件
第一个实验我选择的是“File path traversal, simple case”。进入实验后,页面是一个网店,商品有图片,点击某个商品,图片加载请求是/image?filename=xxx.jpg。在 Burp 中拦截这个请求,把filename=product.jpg改成filename=../../../../etc/passwd,发送后会看到响应正文直接带着 passwd 文件内容。提交后在实验页面点“Solved”即可。
这里有一个新手容易忽略的点:浏览器可能自动把响应里的内容渲染成图片,导致看不到文本。我的习惯是直接在 Burp Repeater 里看响应,或者在浏览器开发者工具里查看“响应文本”。只要看到root:x:0:0:或daemon:x:1:1:这类内容,就算成功。
4.2 实验二:带过滤的路径穿越
第二类实验通常在服务端加了过滤。例如“File path traversal with traversal sequences blocked”,它会将../直接删掉。我开始的请求是:
GET /image?filename=....//....//....//etc/passwd HTTP/1.1这个 payload 在过滤逻辑里被删掉中间的../后变成../../../../etc/passwd,恰好完成穿越。如果过滤规则用的是正则\.\./,那么....//的前两个点和后两个点加斜杠,正则可能只匹配中段的../?需要详细解释:....//由四个点后跟两个斜杠组成,匹配../时,正则可能把第2-4点加斜杠匹配掉,等效为..+../?实际操作中我会测试不同数量。比如:
....//....//....//etc/passwd:消除后得到三层..././..././..././etc/passwd:利用./干扰匹配..;//..;//..;//etc/passwd:利用分号被某些库忽略
只要有一个成功,立刻可以在 Repeater 里看到文件内容。做这类实验不要在一个 payload 上死磕,把请求发给 Burp 的 Intruder,用简单的 payload 列表跑一遍,很快能找到可用的。不过 Intruder 免费版有速率限制,手动构造几个也够了。
4.3 实验三:混合使用编码和双写绕过
第三套实验常常把过滤器做成“先剥离常见../和%2e%2e%2f,再 URL 解码”,这样普通双写会被二次解码拦截,普通编码会被剥离拦截。我的思路是“两层绕过”:
- 第一层,用
%25编码%,使得解码后得到%2e%2e%2f; - 第二层,服务端再次解码
%2e%2e%2f为../。
完整 payload 可以是%252e%252e%252f%252e%252e%252f%252e%252e%252fetc/passwd。如果把过滤规则匹配的是解码一次后的%2e%2e%2f,双重编码可以躲过;如果过滤规则匹配的是../,双重编码也躲过,直到最后一次解码才露出真实路径。我在 PortSwigger 的“traversal sequences stripped non-recursively”这类实验中,用这种 payload 成功读取到了标志文件。要注意确认靶场环境是 Linux 还是 Windows。如果实验是 Windows 后端,斜杠要改成\,编码为%5c;如果响应返回的是 HTML 转义内容,需要先解码再判断。
5. 踩坑与排查:为什么我的路径遍历还没打通
5.1 请求包改了半天没回显
这是我在刷题时最常遇到的问题:Burp Repeater 里明明发送了../../../../etc/passwd,但响应和原来的图片响应一模一样,或者返回 500。常见原因有三个:一是应用可能把文件内容作为图片头识别,导致浏览器不显示,但响应字节里其实有文本;二是你改错了参数,有些接口的参数名是file,有些是filename,还有些在 POST body 里;三是路径层级数不够,比如应用目录在/var/www/html/images/,你从图片接口出发,可能需要四层才能到达根目录,只给三层就会被挡在/var/www/html/。
我的排查顺序是:先在响应中搜索root、uid、flag等关键词;然后打开开发者工具的“网络”标签,确认发出的请求参数没有再次被浏览器编码;最后用 Intruder 生成从../到../../../../../../的逐级列表,一次性看哪些层数有效。有时候不是漏洞不存在,只是层数不对。
5.2 编码被服务端二次解码的问题
编码绕过时,最让人迷惑的是“到底编码了几次”。如果你在 Burp 里发送%2e%2e%2f,服务端解码一次后就变成了../,如果过滤规则在解码前执行,它看到的输入是%2e%2e%2f,并在解码后丢弃,所以请求会被拦截。要绕过,就需要对%再做一次编码,形成%252e%252e%252f,服务端解码一次得到%2e%2e%2f,如果它没做二次解码就直接交给文件系统,操作系统不认识%2e,路径读取失败;如果它做了二次解码,返回的才是../。这里需要不断观察响应来调整编码层数。
一个实用技巧:用 Burp 的 Decoder 工具,把 payload 从../依次做 URL 编码,编码一次得到%2e%2e%2f,编码两次得到%252e%252e%252f。这样能快速生成不同层级的 payload,再逐个验证。
5.3 WAF 拦截了特征怎么办
PortSwigger 的部分实验模拟了 WAF 层,它会拦截包含../、/etc/passwd这类字符串的请求。这时候可以直接换一种读取目标,比如读取../../../../etc/hosts或../../../../var/www/html/admin/.env。WAF 的规则往往只覆盖了一部分关键字,/etc/passwd被重点防护,但其他文件不一定。
还可以用“协议差异”绕过:WAF 用字符串匹配识别../,但 Web 容器会对<,>,"等字符进行额外解码。比如..%c0%af利用 UTF-8 超长编码在特定容器里会被解析成/,或者%u2216被解析成\。这类畸形成功率和后端框架强相关,需要多试。我的经验是不要只盯着 URL 路径,也可以把 payload 放在 Cookie、Referer、X-Forwarded-For 里,很多应用会记录日志但不校验,如果日志文件能被读取,同样可以构造写入和读取。不过 PortSwigger labs 里主要还是看题目要求,别跑偏。
6. 防御侧清单:从代码层把洞堵死
6.1 输入校验与路径规范化(含代码示例)
前面讲了这么多绕过,不代表路径遍历防不住。核心不是“把黑名单做全”,而是不要信任用户输入中的路径语义。最有效的方式是:先获取用户传入的文件名,不拼接任何前缀,交给一个安全函数解析真实路径,最后检查解析后的路径是否在允许的根目录内。
以 Python 为例:
import os ALLOWED_DIR = "/var/www/uploads/" def safe_read(filename): # 只用文件名,不拼接外部路径 base = os.path.basename(filename) target = os.path.join(ALLOWED_DIR, base) real_path = os.path.realpath(target) if not real_path.startswith(ALLOWED_DIR): return None with open(real_path, "rb") as f: return f.read()关键点是os.path.basename()会去掉所有目录部分,os.path.realpath()会把..和符号链接都解析掉,然后判断真实路径是否落在允许目录内。这个“先规范化,再判断前缀”的顺序很重要,如果先判断再规范化,等于没判断。Java 里可以用Path.normalize()和startsWith(),PHP 里可以用realpath(),思路一致。
6.2 用文件映射表替代真实路径
比字符串校验更彻底的做法是,不给用户真实文件名,而是给一个 ID 或 token,后端通过 ID 查表拿到真实路径。这等于把“文件系统路径”这个高风险信息从用户视角完全移走。比如下载接口接收file_id=1024,后端映射表是:
{ "1024": "/var/www/uploads/product-spec-1024.pdf", "1025": "/var/www/uploads/promo-1025.pdf" }这样无论用户怎么传..或编码,都只能在预设的集合里选,不存在路径拼接的问题。现实中很多框架自带的静态文件服务就是这类机制,比如按哈希文件名存储、URL 中不带路径。虽然映射表方式要维护一份数据,但对于文件数量有限的应用来说,成本很低,安全性却提升一个量级。这是我在代码审计时最推荐的做法。
6.3 文件读取接口的最小化实践
最后是接口设计层面的防御。文件下载功能尽量只允许 GET 请求,但不要在 GET 参数里直接放文件名;如果业务必须支持动态文件,也要严格限制扩展名:例如只允许.jpg、.png、.pdf,然后在校验后加白名单。一些老系统会把 MIME 类型也直接取自路径后缀,这时攻击者可以让../etc/passwd%00.png,虽然现在的语言很少支持 null 字节截断,但旧版 Java/PHP 可能中招。
同时要保证 Web 进程权限最小化:即使出现路径遍历,Web 用户也只能读取它权限范围内的文件,无法读取/etc/shadow或 SSH 私钥。容器化部署里可以把上传目录单独挂载为只读卷,并禁止进程访问宿主机根目录。这些都是纵深防御,但它们在真实攻防里能明显提高攻击成本。
我个人在刷 PortSwigger 实验时最大的体会是:路径遍历的“技术门槛”其实不高,难的是对目标应用处理路径的方式保持敏感。同一个参数在不同的中间件、语言版本、过滤策略下,表现可能完全不同。与其死记硬背 payload,不如把 Burp 当作一个路径调试器,从响应的每一步变化里去猜后端在做什么。把这些实验做完之后,再回头看自己项目里的文件上传、下载、导出功能,很多接口真的是一眼就能看出问题。