背景
任意文件读取是 Java Web 项目里比较常见、但容易被低估的一类问题。它不一定直接导致代码执行,但在真实环境中,经常会被用来读取配置文件、密钥、数据库连接信息、日志、源码片段,进而扩大影响面。
这篇文章整理一个偏通用的审计案例:某业务系统提供“附件预览 / 日志下载 / 模板读取”能力,后端根据前端传入的文件名或路径读取服务器本地文件并返回。代码本身看起来只是普通文件下载接口,但如果路径处理不严谨,就可能形成任意文件读取。
文章重点放在审计思路、漏洞成因、复现方法和修复要点,不涉及非法利用场景。
问题场景
很多系统会有类似接口:
- 下载用户上传的附件
- 预览导入模板
- 读取某个目录下的日志文件
- 根据文件名返回静态资源
常见的后端写法是从请求参数中取出文件名,然后拼接到固定目录下:
String fileName = request.getParameter("fileName");
File file = new File(baseDir + "/" + fileName);
try (InputStream in = new FileInputStream(file)) {
IOUtils.copy(in, response.getOutputStream());
}开发者通常假设 fileName 只是类似 report.pdf、template.xlsx 这样的普通文件名。但如果攻击者传入包含路径穿越语义的参数,后端又没有做边界校验,就可能读取 baseDir 之外的文件。
漏洞成因分析
这类问题的核心不是“能下载文件”,而是“用户输入影响了最终文件路径”。常见风险点包括:
- 直接使用用户传入的相对路径或绝对路径
- 只做简单字符串替换,例如替换 ../,但没有规范化路径
- 使用黑名单过滤,忽略编码、分隔符差异和平台差异
- 校验发生在路径拼接前,最终读取路径与校验对象不一致
- 允许用户传入完整文件路径,而不是文件 ID
例如下面这类过滤方式就比较脆弱:
fileName = fileName.replace("../", "");
File file = new File(baseDir + "/" + fileName);原因在于路径可能存在多种变体:不同操作系统的分隔符、URL 编码、重复编码、混合路径、符号链接等。如果只是对字符串做替换,很难覆盖所有情况。
代码审计关注点
审计 Java Web 项目时,可以优先搜索以下关键字:
FileInputStream
FileOutputStream
new File(
Paths.get(
Files.readAllBytes
Files.copy
ResourceUtils.getFile
getResourceAsStream
response.getOutputStream如果命中下载、预览、导出、日志查看等接口,需要继续确认几个问题:
- 文件路径是否由请求参数直接或间接决定
- 是否存在固定的允许访问目录
- 是否使用 canonical path 或 normalize 后的绝对路径做边界校验
- 是否允许读取符号链接指向的外部文件
- 是否根据文件 ID 查询数据库,而不是信任前端传入路径
- 异常信息是否会返回真实服务器路径
一个典型的问题代码如下:
@GetMapping("/download")
public void download(String path, HttpServletResponse response) throws Exception {
File file = new File("/data/upload/" + path);
response.setHeader("Content-Disposition", "attachment; filename=" + file.getName());
try (InputStream in = new FileInputStream(file)) {
IOUtils.copy(in, response.getOutputStream());
}
}这里 path 完全来自用户输入,最终拼接成文件系统路径。即便前面有登录态校验,也不能说明接口安全,因为授权用户仍可能越权读取不属于其权限范围的文件。
安全复现方式
在授权测试环境中,建议不要直接读取敏感文件,而是准备一个明确的测试文件,用来验证是否存在路径越界。
例如测试目录结构如下:
/tmp/app/upload/allowed.txt
/tmp/app/secret-test.txt业务预期只能读取 /tmp/app/upload 目录下的文件。如果通过下载接口能够读取到 /tmp/app/secret-test.txt,就说明存在路径穿越问题。
可以使用如下方式构造本地测试请求:
curl "
curl " secret-test.txt 的内容,就可以确认读取边界失效。注意:真实项目测试时应只使用授权环境和测试文件,避免读取系统敏感文件或业务数据。
修复方案一:使用文件 ID 映射
更推荐的设计是前端不传真实文件路径,只传文件 ID。后端根据 ID 查询数据库中记录的存储路径,并结合当前用户权限判断是否允许下载。
@GetMapping("/download")
public void download(Long fileId, HttpServletResponse response) throws Exception {
FileRecord record = fileService.getById(fileId);
if (record == null) {
throw new RuntimeException("file not found");
}
if (!permissionService.canDownload(getCurrentUser(), record)) {
throw new RuntimeException("permission denied");
}
Path filePath = Paths.get(record.getStoragePath());
try (InputStream in = Files.newInputStream(filePath)) {
IOUtils.copy(in, response.getOutputStream());
}
}
这种方式可以减少用户输入直接影响文件路径的机会,也更便于做权限控制和审计。
修复方案二:规范化路径并做目录边界校验
如果业务确实需要传文件名,至少要把可访问范围限制在固定目录内。关键是先得到规范化后的绝对路径,再判断它是否仍位于允许目录下。
private static final Path BASE_DIR = Paths.get("/data/upload").toAbsolutePath().normalize();
@GetMapping("/download")
public void download(String fileName, HttpServletResponse response) throws Exception {
if (fileName == null || fileName.isBlank()) {
throw new IllegalArgumentException("invalid file name");
}
Path target = BASE_DIR.resolve(fileName).normalize();
if (!target.startsWith(BASE_DIR)) {
throw new SecurityException("path traversal detected");
}
if (!Files.isRegularFile(target)) {
throw new RuntimeException("file not found");
}
try (InputStream in = Files.newInputStream(target)) {
IOUtils.copy(in, response.getOutputStream());
}
}
这里有几个细节:
- BASE_DIR 使用绝对路径并 normalize
- 用户输入通过 resolve 拼接,而不是字符串相加
- target normalize 后再判断 startsWith(BASE_DIR)
- 读取前确认是普通文件,避免目录读取等异常情况
符号链接问题
在 Linux 环境中,还要额外关注符号链接。如果 upload 目录下存在一个软链接,指向目录外部文件,那么简单的 normalize 和 startsWith 可能仍不足够。
更严格的方式是使用 real path 校验:
Path baseRealPath = BASE_DIR.toRealPath();
Path targetRealPath = BASE_DIR.resolve(fileName).normalize().toRealPath();
if (!targetRealPath.startsWith(baseRealPath)) {
throw new SecurityException("invalid file path");
}
toRealPath 会解析符号链接,并要求目标文件实际存在。对于下载已存在文件的场景比较适合。如果是上传写入场景,则要另行处理父目录校验,避免先检查后写入时产生竞态问题。
文件名与响应头处理
下载接口除了路径读取,还要注意响应头。不要把用户输入原样放入 Content-Disposition,否则可能引入响应头注入或文件名异常问题。
建议只使用经过处理的文件名:
String safeName = target.getFileName().toString();
safeName = safeName.replaceAll("[\\r\\n]", "");
response.setHeader("Content-Disposition", "attachment; filename=\"" + safeName + "\"");
如果需要兼容中文文件名,可以使用 RFC 5987 格式编码,但同样要避免 CRLF 字符进入响应头。
常见误区
误区 问题 只过滤 ../ 无法覆盖编码、混合分隔符、嵌套路径等情况 只判断是否包含敏感关键字 黑名单容易遗漏,且不同系统敏感路径不同 登录后即可下载 认证不等于授权,仍需判断用户是否有权访问该文件 前端限制文件名 前端校验可以被绕过,服务端必须校验 异常直接返回 可能泄露服务器真实路径和框架信息
测试建议
上线前可以加入以下安全测试用例:
- 正常文件名应能下载
- 包含 ../ 的路径应被拒绝
- 包含子目录跳转的路径应被拒绝
- 绝对路径应被拒绝
- 不存在的文件不应暴露真实路径
- 符号链接指向目录外部文件时应被拒绝
- 无权限用户访问他人文件应被拒绝
单元测试示例:
@Test
void shouldRejectPathTraversal() {
Path base = Paths.get("/tmp/app/upload").toAbsolutePath().normalize();
Path target = base.resolve("../secret-test.txt").normalize();
assertFalse(target.startsWith(base));
}
如果项目里文件处理逻辑比较多,建议把路径解析和边界校验封装成公共方法,避免每个接口重复实现导致规则不一致。
日志与监控
对于被拒绝的异常路径,可以记录安全日志,但不要记录过多敏感内容。比较合适的字段包括:
- 用户 ID
- 接口路径
- 客户端 IP
- 原始文件名参数的截断值
- 拒绝原因
如果短时间内出现大量路径穿越探测请求,通常说明接口已被扫描器命中,应及时检查访问来源、接口暴露范围和历史日志。
总结
任意文件读取的根因通常很简单:服务端信任了用户传入的路径。修复时不要依赖黑名单替换,而应从设计上减少路径暴露,并在服务端做规范化后的目录边界校验。
实际落地时建议遵循三点:
- 优先使用文件 ID,不让前端直接控制真实路径
- 必须使用路径参数时,使用 Path.resolve、normalize、toRealPath 做边界校验
- 文件下载同时做好权限判断、响应头处理、异常收敛和安全日志
这类问题代码审计成本不高,但影响面经常被低估。尤其是配置文件、日志文件和上传目录在同一台机器上的系统,更值得重点排查。

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