背景
目录穿越漏洞属于 Web 安全里比较基础但长期高发的一类问题。它的触发条件通常并不复杂:后端根据用户传入的文件名或路径读取本地文件,但没有对路径进行可靠约束,导致攻击者可以通过 ../、编码变形或路径拼接问题访问预期目录之外的文件。
这类问题在文件下载、日志查看、模板预览、静态资源代理、附件读取等功能中比较常见。本文主要从防守和审计角度整理一次典型目录穿越问题的复现思路、代码审计要点和修复建议,不涉及未授权攻击目标。
建议所有复现都放在本地测试环境、靶场或已授权系统中进行。目录穿越虽然看起来简单,但一旦读取到配置文件、密钥、源码或日志,影响面通常会快速扩大。
问题场景
假设某系统提供一个文件下载接口,用于下载用户上传后的附件。接口大致如下:
GET /download?file=report.pdf后端逻辑是从固定目录读取文件,例如:
/data/app/uploads/report.pdf如果开发者直接把用户参数拼接到文件路径中,并交给文件读取函数处理,就可能出现目录穿越风险。
String baseDir = "/data/app/uploads/";
String file = request.getParameter("file");
File target = new File(baseDir + file);
return readFile(target);这段代码的问题不在于读取文件本身,而在于 file 完全由用户控制,且没有做路径归一化后的边界校验。
技术分析
目录穿越的核心是利用文件系统路径解析规则,跳出业务预期目录。例如在类 Unix 系统中,../ 表示上级目录。如果后端把参数直接拼接为文件路径,就可能得到类似下面的结果:
/data/app/uploads/../../config/application.yml经过文件系统解析后,实际访问路径可能变成:
/data/config/application.yml如果该文件存在且进程有读取权限,接口就可能把不该暴露的内容返回给用户。
实际审计时,需要注意目录穿越并不只表现为明文 ../。常见变形包括:
- URL 编码:
..%2f、%2e%2e%2f - 双重编码:
%252e%252e%252f - 混合分隔符:Windows 下的
..\ - 绝对路径输入:如
/etc/passwd或C:\Windows\win.ini - 路径中夹杂无效过滤绕过片段:如简单替换
../后产生新的路径关系
不过在实际修复中,不建议依赖黑名单过滤这些字符串。原因很简单:路径语义最终由操作系统和运行时解析,黑名单很难覆盖各种编码、符号链接、大小写和平台差异。
本地复现环境
为了便于说明,可以用一个最小化的 Python 示例模拟漏洞。以下代码只建议放在本地测试目录中运行。
from flask import Flask, request, send_file
import os
app = Flask(__name__)
BASE_DIR = "/tmp/demo/uploads"
@app.route("/download")
def download():
filename = request.args.get("file", "")
path = os.path.join(BASE_DIR, filename)
return send_file(path)
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000, debug=False)准备测试文件:
mkdir -p /tmp/demo/uploads
mkdir -p /tmp/demo/config
echo "normal file" > /tmp/demo/uploads/report.txt
echo "secret_key=local-test-only" > /tmp/demo/config/app.conf正常访问:
curl " " Flask 或 send_file 本身,而是传入的路径已经被用户控制且没有做安全边界检查。代码审计关注点
审计目录穿越漏洞时,我通常会先全局搜索文件读取相关函数,再反向追踪参数来源。不同语言的关键点如下。
语言/框架 常见关注函数或场景 Java new File()、FileInputStream、Files.readAllBytes、ResourceUtilsPHP file_get_contents、readfile、fopen、模板包含Python open、send_file、send_from_directory、日志读取接口Node.js fs.readFile、fs.createReadStream、静态资源代理Go os.Open、http.ServeFile、ioutil.ReadFile
重点看三个问题:
- 文件名或路径是否来自用户输入,例如 query、body、header、cookie、数据库中可被用户写入的字段。
- 是否直接拼接路径,例如
baseDir + filename 或 path.join(baseDir, filename) 后未校验。 - 是否只做了字符串黑名单过滤,而没有做规范化路径后的目录边界判断。
有些项目会误以为使用 path.join、Paths.get 就安全了。实际上这些函数只是帮助拼接或解析路径,不等于安全校验。真正关键的是:归一化后的最终路径必须仍然位于允许访问的根目录之下。
推荐修复方式
比较稳妥的修复思路是白名单优先,其次才是路径规范化加边界校验。
方式一:使用文件 ID 映射
最推荐的方式是不要让用户直接传文件路径,而是传业务层的文件 ID。后端根据 ID 查询数据库中的文件记录,再拼接服务端可信文件名。
GET /download?id=12345
数据库中保存类似信息:
id: 12345
owner_id: 10001
storage_name: 7f3a2d9c-9b11-4e1a.pdf
original_name: report.pdf
这样用户无法直接控制磁盘路径,同时还能做权限校验,例如文件归属、租户隔离、下载次数限制等。
方式二:规范化路径并校验边界
如果业务确实需要传文件名,也应只允许文件名,不允许路径分隔符。下面是一个 Python 修复示例:
from flask import Flask, request, abort, send_file
from pathlib import Path
app = Flask(__name__)
BASE_DIR = Path("/tmp/demo/uploads").resolve()
@app.route("/download")
def download():
filename = request.args.get("file", "")
# 只接受简单文件名,拒绝包含目录结构的输入
if filename != Path(filename).name:
abort(400)
target = (BASE_DIR / filename).resolve()
# 确认最终路径仍在 BASE_DIR 内
if BASE_DIR not in target.parents and target != BASE_DIR:
abort(403)
if not target.is_file():
abort(404)
return send_file(target)
Java 中可以使用 Path.normalize() 和 startsWith() 做类似处理:
Path baseDir = Paths.get("/data/app/uploads").toRealPath();
String filename = request.getParameter("file");
Path target = baseDir.resolve(filename).normalize();
if (!target.startsWith(baseDir)) {
throw new SecurityException("invalid path");
}
if (!Files.isRegularFile(target)) {
throw new FileNotFoundException();
}
byte[] data = Files.readAllBytes(target);
需要注意,存在符号链接时,单纯 normalize() 可能不够。对安全要求较高的场景,建议结合 toRealPath() 获取真实路径后再判断,避免符号链接把访问引到允许目录之外。
测试与验证
修复后建议至少覆盖以下测试用例:
- 正常文件名:
report.txt - 上级目录:
../config/app.conf - 多级上跳:
../../../../etc/passwd - URL 编码路径:
..%2fconfig%2fapp.conf - 反斜杠路径:
..\config\app.conf - 绝对路径:
/tmp/demo/config/app.conf - 不存在文件:
notfound.txt - 目录名而非文件:
.
可以用 curl 进行本地验证:
curl -i "
curl -i "
curl -i " 200,越界路径返回 400 或 403,不存在文件返回 404。错误响应中不要返回服务器真实路径,避免信息泄露。日志与应急排查
如果线上发现疑似目录穿越探测,可以从访问日志中检索以下特征:
../
..\
%2e%2e
%252e%252e
/etc/passwd
win.ini
application.yml
.env
Nginx 日志中可先做粗略检索:
grep -Ei "(\.\./|\.\.\\|%2e%2e|%252e%252e|/etc/passwd|win\.ini|\.env)" access.log
排查时建议关注:
- 是否有成功返回 200 的异常请求。
- 同一 IP 是否批量尝试不同敏感路径。
- 接口是否返回了配置、密钥、源码、日志等敏感内容。
- 应用进程权限是否过大,是否可读取非业务目录。
- 是否存在后续利用迹象,例如使用泄露配置访问数据库、对象存储或管理后台。
如果确认敏感文件被读取,应按泄露处理:轮换密钥、检查访问凭证、审计数据库和对象存储访问记录,而不是只修一个路径校验。
常见误区
- 只替换一次
../:容易被编码、嵌套或平台差异绕过。 - 只判断字符串是否包含某些敏感文件名:无法覆盖所有敏感路径。
- 以为上传目录不可执行就安全:目录穿越关注的是读取,不是执行。
- 忽略符号链接:允许目录内的软链接可能指向目录外。
- 错误信息返回绝对路径:会降低后续攻击成本,也影响资产暴露面。
总结
目录穿越漏洞的修复原则并不复杂:不要信任用户传入的路径,不要直接拼接文件系统路径,优先使用文件 ID 映射;确需使用文件名时,要做规范化后的边界校验,并结合权限控制和最小化进程权限。
从代码审计角度看,文件读取、文件下载、日志查看、模板预览和静态资源代理都是重点区域。实际项目里,这类漏洞往往不是单点问题,而是和配置泄露、凭证复用、权限过大组合产生更高风险。把路径处理收敛到统一工具函数,并配套单元测试,是比较可持续的治理方式。

代码审计、调用链与关键函数定位示意
推荐意见