背景
文件读取类漏洞在 Java Web 项目里并不稀奇,但实际排查时经常被低估。很多业务代码本意只是提供“下载日志”“导出附件”“预览模板”等功能,参数看起来也只是一个文件名,一旦路径拼接和权限校验处理不严,就可能变成任意文件读取。
这篇文章整理一次相对典型的代码审计和复盘过程。内容只讨论本地测试环境中的漏洞成因、验证方式和修复思路,不涉及真实目标攻击,也不提供越权利用指导。重点放在:如何在代码审计中识别风险点,如何构造安全的复现环境,以及如何把问题修干净。
场景描述
某个内部 Java Web 系统提供附件下载接口,接口大致功能如下:
- 用户传入文件名参数。
- 服务端从固定目录读取文件。
- 以流的形式返回给浏览器下载。
开发侧认为文件都存放在固定目录下,因此没有额外做权限检查。问题出在“固定目录 + 用户输入”这种组合上,如果直接字符串拼接路径,攻击者可能通过路径穿越读取目录外文件。
问题代码示例
下面是经过简化的示例代码,便于说明问题。实际项目里类似逻辑可能散落在 Controller、Service 或工具类中。
String baseDir = "/data/app/uploads/";
String fileName = request.getParameter("file");
File file = new File(baseDir + fileName);
if (!file.exists()) {
response.setStatus(404);
return;
}
try (InputStream in = new FileInputStream(file);
OutputStream out = response.getOutputStream()) {
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}这段代码的问题比较直接:
- 用户输入直接参与文件路径拼接。
- 没有做路径规范化处理。
- 没有校验最终文件是否仍位于允许目录下。
- 接口层缺少业务权限判断,例如文件是否属于当前用户或当前租户。
漏洞成因分析
文件读取漏洞的核心并不只是“出现了 ../”,而是服务端没有把“用户可控文件标识”和“真实文件系统路径”隔离开。常见成因有几类。
1. 直接拼接路径
这是最常见的问题。开发者把用户传入的文件名直接拼到基础目录后面,认为基础目录已经限制了访问范围。但文件系统会解析路径中的相对目录符号,最终可能跳出预期目录。
File file = new File(baseDir + userInput);即使对参数做了简单黑名单过滤,例如替换 ../,也容易被编码、不同分隔符、重复编码或平台差异影响。单纯靠字符串替换不是稳定的安全边界。
2. 只判断文件是否存在
不少代码只检查 exists() 或 isFile(),但这些判断只能说明路径对应的文件存在,不能说明这个文件是否应该被当前用户访问。
if (file.exists() && file.isFile()) {
// 返回文件
}安全判断应该包含两个层面:路径边界校验和业务授权校验。前者解决“能不能出目录”,后者解决“是不是你的文件”。
3. 使用原始文件名作为下载标识
如果数据库中只保存原始文件名,下载接口又允许客户端直接传 fileName,会带来两个问题:
- 文件名可预测,容易枚举。
- 文件名和路径边界耦合,容易出现路径穿越。
更稳妥的做法是使用文件 ID 或随机存储名作为引用,由服务端根据数据库记录映射到真实路径。
本地复现思路
复现建议只在本机或授权测试环境完成。可以用一个临时目录模拟上传目录,不需要读取系统敏感文件。下面以 Linux/macOS 环境为例,准备目录结构:
mkdir -p /tmp/demo-upload
mkdir -p /tmp/demo-secret
echo "normal attachment" > /tmp/demo-upload/report.txt
echo "should not be downloadable" > /tmp/demo-secret/internal.txt假设下载接口的 baseDir 指向 /tmp/demo-upload/,正常访问 report.txt 应该成功,而 /tmp/demo-secret/internal.txt 不应该被读取。测试时只需要验证接口是否可能越过 /tmp/demo-upload 访问上级目录中的测试文件即可。
如果漏洞存在,服务端日志里通常能看到最终被打开的路径并不在预期目录下。建议在本地调试时打印 canonical path,便于观察路径解析结果。
System.out.println("request fileName = " + fileName);
System.out.println("absolute path = " + file.getAbsolutePath());
System.out.println("canonical path = " + file.getCanonicalPath());这里重点关注 canonical path,因为它会解析相对路径、符号链接等情况,比 getAbsolutePath 更接近真实访问路径。
安全修复方案
修复文件读取漏洞时,不建议只做参数黑名单。更可靠的方案是“服务端映射 + 路径规范化 + 目录边界校验 + 业务授权”。
1. 使用文件 ID 映射真实路径
下载接口尽量不要接受任意文件名,而是接受业务文件 ID。服务端根据 ID 查询数据库,确认文件归属和存储路径。
GET /download?id=12345数据库记录中建议至少包含:
- 文件 ID。
- 上传用户或所属业务对象。
- 存储目录或对象存储 key。
- 原始文件名,仅用于展示和下载头。
- 文件大小、哈希、创建时间等审计字段。
这样客户端不能自由决定服务端读取哪个路径,风险会低很多。
2. 规范化路径并校验目录边界
如果确实需要根据相对路径读取文件,必须在读取前做规范化,并确认最终路径仍在允许目录内。
Path basePath = Paths.get("/data/app/uploads").toRealPath();
String fileName = request.getParameter("file");
Path targetPath = basePath.resolve(fileName).normalize();
if (!targetPath.startsWith(basePath)) {
response.setStatus(403);
return;
}
if (!Files.isRegularFile(targetPath)) {
response.setStatus(404);
return;
}
try (InputStream in = Files.newInputStream(targetPath);
OutputStream out = response.getOutputStream()) {
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}这里有几个细节:
- basePath 使用 toRealPath() 获取真实路径。
- targetPath 使用 resolve() 和 normalize() 处理相对路径。
- 通过 startsWith(basePath) 判断是否仍在允许目录内。
- 读取前使用 Files.isRegularFile(),避免目录、设备文件等异常对象。
3. 注意符号链接问题
符号链接是文件读取修复中容易遗漏的点。如果上传目录内存在指向外部目录的软链接,单纯 normalize() 不一定能覆盖所有情况。更严格的做法是在最终读取前对目标文件调用 toRealPath(),再判断边界。
Path basePath = Paths.get("/data/app/uploads").toRealPath();
Path targetPath = basePath.resolve(fileName).normalize();
if (!Files.exists(targetPath)) {
response.setStatus(404);
return;
}
Path realTargetPath = targetPath.toRealPath();
if (!realTargetPath.startsWith(basePath)) {
response.setStatus(403);
return;
}如果业务不需要软链接,部署层面也建议禁止在上传目录中创建软链接,或者在文件处理流程中显式拒绝。
4. 增加业务权限校验
路径边界校验只能证明文件在上传目录里,不能证明当前用户有权下载。多租户系统、工单系统、网盘类系统尤其要注意横向越权。
推荐的校验流程:
- 根据文件 ID 查询文件记录。
- 判断文件是否存在且状态正常。
- 判断当前用户是否属于该文件的拥有者、协作者或有对应业务对象权限。
- 通过权限校验后,再读取对应存储路径。
Attachment attachment = attachmentDao.findById(fileId);
if (attachment == null) {
response.setStatus(404);
return;
}
if (!permissionService.canDownload(currentUser, attachment)) {
response.setStatus(403);
return;
}审计时的关注点
做代码审计时,可以优先搜索以下关键词和调用点:
new File(
FileInputStream
Files.newInputStream
Files.readAllBytes
getResourceAsStream
ResourceUtils.getFile
response.getOutputStream
Content-Disposition同时关注参数名:
file
fileName
path
download
template
log
report
attach
url
key一些高风险业务接口包括:
- 附件下载、图片预览、报表导出。
- 日志查看、日志下载。
- 模板下载、配置文件预览。
- 插件包、升级包、备份文件下载。
- 临时文件读取接口。
审计时不要只看 Controller。很多项目会在工具类里封装 downloadFile、writeFileToResponse、preview 等方法,多个接口复用同一段危险逻辑。
测试用例建议
修复后建议补充单元测试或集成测试,覆盖正常路径、越界路径、编码路径、软链接等场景。示例用例可以包括:
- 正常下载上传目录中的文件。
- 访问不存在文件时返回 404。
- 访问目录时拒绝。
- 相对路径跳出基础目录时返回 403。
- 软链接指向外部目录时拒绝。
- 无权限用户访问他人文件时返回 403。
简单的 JUnit 思路如下:
@Test
void shouldRejectPathTraversal() throws Exception {
Path base = Files.createTempDirectory("upload");
Path outside = Files.createTempFile("outside", ".txt");
Files.writeString(outside, "outside content");
String input = "../" + outside.getFileName();
boolean allowed = FilePathChecker.isSafe(base, input);
assertFalse(allowed);
}路径安全判断可以单独抽成工具方法,便于复用和测试。
public static boolean isSafe(Path baseDir, String userInput) throws IOException {
Path basePath = baseDir.toRealPath();
Path targetPath = basePath.resolve(userInput).normalize();
if (!Files.exists(targetPath)) {
return false;
}
Path realTargetPath = targetPath.toRealPath();
return realTargetPath.startsWith(basePath) && Files.isRegularFile(realTargetPath);
}日志与监控
文件读取类问题修复后,建议增加必要的审计日志。日志不需要记录完整文件内容,但应记录关键上下文,便于后续排查:
- 当前用户 ID。
- 文件 ID 或业务对象 ID。
- 请求来源 IP。
- 规范化后的目标路径或存储 key。
- 拒绝原因,例如越界、无权限、不存在。
对明显异常的路径访问可以做告警,例如参数中出现连续的路径跳转符号、异常编码、访问大量不存在文件等。但监控只能作为辅助,不能替代代码层面的边界校验。
容易踩的坑
- 只过滤 ../,没有处理 Windows 分隔符或编码差异。
- 只判断 absolute path,没有判断 canonical/real path。
- 修了下载接口,遗漏了预览接口。
- 上传目录允许创建软链接,但读取时没有处理。
- 只做路径校验,没有做文件归属权限校验。
- 下载头直接使用用户输入文件名,导致响应头异常或兼容性问题。
下载文件名建议从数据库中的原始文件名读取,并进行安全处理,避免换行符等特殊字符影响响应头。
String safeName = originalName.replaceAll("[\\r\\n]", "_");
response.setHeader("Content-Disposition", "attachment; filename=\"" + safeName + "\"");总结
文件读取漏洞看起来简单,但在真实项目里经常和权限设计、存储设计、历史接口复用混在一起。比较稳妥的治理方式不是临时加几个字符串过滤,而是把文件访问抽象成统一能力:客户端只传文件 ID,服务端负责权限判断、路径映射和边界校验。
如果只能先做短期修复,至少要做到三点:规范化路径、校验最终真实路径仍在允许目录内、确认当前用户有业务访问权限。后续再逐步把直接传路径的接口改造成文件 ID 访问模式,减少同类问题反复出现。
推荐意见