跳转到帖子

背景

任意文件读取属于 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 化、权限校验、路径规范化、最小权限目录、异常处理和测试用例几个方面一起收口。

这类漏洞看似基础,但在实际项目里非常高频。把下载接口当作一个需要完整安全设计的能力,而不是简单的流复制代码,能减少很多后续风险。

代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

游客
抱歉,你的帖子内容包括我们不允许的字词。请编辑你的帖子,删除下面高亮的屏蔽字。
添加意见…