背景
任意文件读取属于 Web 安全里比较常见但容易被低估的一类问题。它不像远程命令执行那样直观,但在真实环境中,一旦读取到配置文件、密钥、日志、源码包或部署脚本,往往会继续扩大影响面。
这篇文章整理一次典型 Java Web 下载接口的审计思路,重点放在如何发现问题、如何复现验证、如何评估影响以及如何修复。示例代码均为脱敏后的简化版本,不涉及真实目标,也不提供针对第三方系统的攻击操作。
问题场景
业务里常见文件下载功能,例如下载报表、导出附件、获取模板文件。很多项目会提供类似接口:
GET /file/download?name=report.xlsx如果服务端直接拼接用户传入的文件名,并从磁盘读取文件,就可能出现目录穿越问题。一个典型的风险代码如下:
String fileName = request.getParameter("name");
File file = new File("/data/app/upload/" + fileName);
response.setContentType("application/octet-stream");
response.setHeader("Content-Disposition", "attachment; filename=" + fileName);
try (InputStream in = new FileInputStream(file);
OutputStream out = response.getOutputStream()) {
byte[] buffer = new byte[4096];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}这里的问题不在于下载功能本身,而是服务端默认信任了用户输入。攻击者只要能够控制 name 参数,就可能尝试通过相对路径访问上传目录之外的文件。
技术分析
任意文件读取在 Java Web 中常见触发点包括:
- 下载接口:download、export、getFile、template 等。
- 预览接口:在线预览 PDF、图片、Office 转换等。
- 日志查看:后台运维页面读取日志文件。
- 压缩包处理:解压、打包下载时没有校验路径。
- 静态资源代理:根据参数读取本地文件并返回。
审计时可以重点关注以下代码模式:
new File(basePath + userInput)
new FileInputStream(userInput)
Paths.get(basePath, userInput)
ResourceUtils.getFile(userInput)
response.getOutputStream().write(...)
Files.readAllBytes(...)
Files.copy(..., response.getOutputStream())其中,单纯使用 Paths.get(basePath, userInput) 并不等于安全。如果后续没有做规范化路径校验,仍然可能被目录穿越绕出预期目录。
复现验证方式
在授权测试或本地环境中,可以用一些无敏感性的文件作为验证目标,例如应用自身创建的测试文件,而不是系统敏感文件。建议先准备一个目录结构:
/tmp/demo-upload/
report.txt
/tmp/demo-secret/
internal.txt存在风险的代码将下载根目录固定为 /tmp/demo-upload/。正常访问:
GET /file/download?name=report.txt如果接口没有限制路径,传入上级目录访问测试文件时,可能读取到预期目录之外的内容:
GET /file/download?name=../demo-secret/internal.txt这里验证的是路径控制缺陷,不建议在未授权系统上尝试读取任何系统文件、配置文件或业务数据。漏洞验证应以最小影响为原则,只证明边界可被突破即可。
关键审计步骤
1. 确认用户输入是否进入文件路径
先从 Controller 层入手,观察参数是否直接或间接传入文件读取逻辑。例如:
@GetMapping("/download")
public void download(@RequestParam String name, HttpServletResponse response) {
fileService.download(name, response);
}然后继续跟进 Service 层,看是否存在路径拼接、文件对象创建、流读取等操作。很多项目会把文件读取封装到工具类中,不能只看 Controller。
2. 确认是否存在路径规范化校验
安全的写法通常会对路径进行 canonical 或 normalize 处理,并确认最终路径仍位于允许目录下。仅做字符串替换并不可靠:
// 风险较高:容易遗漏编码、分隔符变体、双重处理等情况
fileName = fileName.replace("../", "");
fileName = fileName.replace("..\\", "");推荐使用规范化后的绝对路径做边界判断:
Path baseDir = Paths.get("/data/app/upload").toRealPath();
Path target = baseDir.resolve(fileName).normalize();
if (!target.startsWith(baseDir)) {
throw new SecurityException("非法文件路径");
}
if (!Files.isRegularFile(target)) {
throw new FileNotFoundException("文件不存在");
}如果业务允许符号链接,还需要额外评估链接指向。多数下载场景建议禁止跟随不受控符号链接,避免链接绕出根目录。
3. 检查文件名与业务权限是否绑定
很多漏洞的根因不是单一目录穿越,而是“用户知道文件名就能下载”。如果附件属于某个订单、工单或用户空间,下载时应通过文件 ID 查询数据库,再校验当前用户是否有权限访问。
更推荐的设计是:
- 前端传文件 ID,不直接传物理路径。
- 服务端从数据库查询存储路径。
- 校验文件所属业务对象与当前用户权限。
- 最终读取前再做路径边界校验。
FileMeta meta = fileRepository.findById(fileId);
if (meta == null) {
throw new FileNotFoundException("文件不存在");
}
if (!permissionService.canRead(currentUser, meta.getBizId())) {
throw new AccessDeniedException("无权限访问");
}
Path baseDir = Paths.get(uploadRoot).toRealPath();
Path target = baseDir.resolve(meta.getStorageName()).normalize();
if (!target.startsWith(baseDir)) {
throw new SecurityException("非法文件路径");
}4. 观察响应头处理是否存在附加风险
下载接口通常会设置 Content-Disposition。如果文件名直接进入响应头,可能引入响应头注入风险。建议对展示文件名做编码和过滤:
String safeName = URLEncoder.encode(displayName, StandardCharsets.UTF_8)
.replaceAll("\\+", "%20");
response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + safeName);同时不要把异常堆栈、绝对路径直接返回给前端,否则会增加信息泄露面。
修复方案
针对任意文件读取,建议从设计和代码两层同时修复。
设计层
- 不要让用户直接提交磁盘路径。
- 下载接口使用文件 ID 或业务 ID。
- 文件存储名与展示名分离,存储名可使用 UUID。
- 对每次下载做业务权限校验。
- 下载根目录使用独立低权限目录,不与配置、日志、源码目录混放。
代码层
- 使用
Path.resolve、normalize、toRealPath做路径规范化。 - 校验最终路径必须位于允许目录内。
- 限制可下载文件类型和大小。
- 确认目标是普通文件,不是目录、设备文件或异常链接。
- 统一处理异常,避免暴露本地路径。
一个相对完整的安全下载示例:
public void safeDownload(String storageName, String displayName, HttpServletResponse response) throws IOException {
Path baseDir = Paths.get("/data/app/upload").toRealPath();
Path target = baseDir.resolve(storageName).normalize();
if (!target.startsWith(baseDir)) {
response.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}
if (!Files.isRegularFile(target)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
long size = Files.size(target);
if (size > 100L * 1024 * 1024) {
response.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}
String safeName = URLEncoder.encode(displayName, StandardCharsets.UTF_8)
.replaceAll("\\+", "%20");
response.setContentType("application/octet-stream");
response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + safeName);
response.setContentLengthLong(size);
try (InputStream in = Files.newInputStream(target);
OutputStream out = response.getOutputStream()) {
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}
}测试用例建议
修复后建议补充自动化测试,避免后续迭代再次引入类似问题。可以覆盖以下场景:
| 测试场景 | 预期结果 |
| 下载合法文件 | 返回 200,文件内容正确 |
| 访问不存在文件 | 返回 404 |
| 包含 ../ 的路径 | 返回 403 |
| 绝对路径输入 | 返回 403 |
| 目录路径输入 | 返回 404 或 403 |
| 超大文件 | 按策略拒绝或走专用下载流程 |
JUnit 中可以针对路径校验逻辑单独写测试,不一定每次都启动完整 Web 容器:
@Test
void shouldRejectPathTraversal() throws Exception {
Path baseDir = tempDir.resolve("upload");
Files.createDirectories(baseDir);
Path target = baseDir.resolve("../secret.txt").normalize();
assertFalse(target.startsWith(baseDir));
}日志与监控
任意文件读取漏洞被探测时,日志里通常会出现一些异常路径特征,例如 ../、..\、URL 编码后的路径片段、异常的绝对路径格式等。防护时可以记录被拒绝的下载请求,但注意不要把完整敏感路径写入日志。
log.warn("Rejected suspicious download request, userId={}, fileId={}, reason={}",
currentUser.getId(), fileId, "path_outside_base_dir");如果短时间内出现大量非法路径请求,可以结合网关、WAF 或应用侧限流进行处理。这里的重点不是依赖拦截规则替代修复,而是把异常行为纳入可观测范围。
注意点
- 不要只用黑名单替换
../,路径编码和平台差异会让黑名单很脆弱。 - Windows 与 Linux 的路径分隔符不同,跨平台项目要统一测试。
- 对象存储下载同样要做权限校验,不要把可预测 Key 当作安全边界。
- 后台管理接口也需要防护,不能默认认为内部接口可信。
- 修复后要回归历史下载、预览、导出、打包功能,避免遗漏同类入口。
总结
任意文件读取的核心问题是“用户输入影响了服务端读取路径”。审计时不要只盯着接口名,而要沿着参数流向看它是否进入文件系统 API。修复时也不要停留在简单替换特殊字符,应该从文件 ID 化、权限校验、路径规范化、最小权限目录、异常处理和测试用例几个方面一起收口。
这类漏洞看似基础,但在实际项目里非常高频。把下载接口当作一个需要完整安全设计的能力,而不是简单的流复制代码,能减少很多后续风险。
推荐意见