跳转到帖子

背景

任意文件读取是 Java Web 项目里比较常见、也比较容易被低估的一类问题。它不像远程命令执行那样直观,但在真实环境中,一旦能读取配置文件、日志、密钥文件或源码片段,后续风险会明显放大。

这篇文章整理的是一次代码审计中遇到的文件下载接口问题。内容重点放在审计思路、漏洞成因、复现边界和修复方式上,不涉及对第三方系统的攻击利用。类似问题在 Spring MVC、Servlet 老项目、管理后台附件下载模块里都比较常见。

问题场景

项目中有一个附件下载接口,业务设计是根据前端传入的文件名,从服务器指定目录下读取文件并返回给用户。简化后的代码如下:

@GetMapping("/download")
public void download(@RequestParam("file") String file,
                     HttpServletResponse response) throws IOException {
    String baseDir = "/data/app/upload/";
    File target = new File(baseDir + file);

    response.setHeader("Content-Disposition", "attachment; filename=" + file);
    response.setContentType("application/octet-stream");

    try (InputStream in = new FileInputStream(target);
         OutputStream out = response.getOutputStream()) {
        byte[] buffer = new byte[4096];
        int len;
        while ((len = in.read(buffer)) != -1) {
            out.write(buffer, 0, len);
        }
    }
}

从表面看,这段代码只是下载上传目录中的文件。但它把用户输入直接拼接进本地路径,没有做路径归一化、目录边界校验、文件白名单校验,因此存在越权读取本地文件的风险。

技术分析

任意文件读取通常不是因为“读取文件”这个动作本身有问题,而是因为服务端没有限制用户可控路径最终落在哪个目录下。这里主要有几个风险点。

1. 路径拼接导致目录穿越

如果直接使用字符串拼接路径,用户输入中的相对路径片段可能改变最终文件位置。例如:

baseDir = /data/app/upload/
file    = ../../config/application.yml
result  = /data/app/upload/../../config/application.yml

文件系统在解析路径时会处理 ..,最终访问的位置不再局限于上传目录。即使代码里判断了字符串是否以 /data/app/upload/ 开头,也不可靠,因为未归一化路径和真实路径不是一回事。

2. URL 编码与多层解码问题

有些接口会尝试过滤 ../,例如:

file = file.replace("../", "");

这种处理方式很脆弱。原因包括:

  • 容器或框架可能已经做过一次 URL 解码;
  • 网关、应用、业务代码之间可能存在多层解码;
  • 不同操作系统路径分隔符不同,例如 Windows 下还有 \;
  • 简单替换可能被变形路径绕过。

实际审计时,不建议把安全性建立在“替换危险字符”上,而应该基于最终规范路径做目录边界校验。

3. 文件名响应头未处理

示例代码还存在一个容易被忽略的问题:直接把用户输入放进 Content-Disposition 响应头。虽然这里主要讨论文件读取,但响应头拼接不当可能引入响应头污染、下载文件名异常等问题。至少应该对文件名做安全编码,并避免直接使用原始输入。

关键审计步骤

审计这类问题时,我一般按下面几个点走,效率会高一些。

1. 定位文件操作入口

优先搜索这些 API 或关键字:

FileInputStream
Files.readAllBytes
Files.copy
new File(
Paths.get
ResourceUtils.getFile
ServletContext.getResourceAsStream
getRealPath
download
export
preview
readFile

重点关注下载、预览、导出、模板读取、日志查看、图片访问等功能。管理后台里的“日志下载”“附件预览”也经常出问题。

2. 判断路径是否用户可控

需要确认文件路径中的哪些部分来自请求参数、数据库字段、请求头、Cookie 或可被用户间接控制的配置。很多时候代码里没有直接使用请求参数,而是先根据 ID 查数据库,再取数据库中的文件路径。如果普通用户能写入这个路径字段,同样需要按用户可控处理。

3. 检查是否做了规范化和边界校验

安全的核心不是简单判断字符串,而是确认最终解析后的真实路径仍在允许目录内。推荐使用 Path 相关 API:

Path basePath = Paths.get("/data/app/upload").toRealPath();
Path targetPath = basePath.resolve(file).normalize();

if (!targetPath.startsWith(basePath)) {
    throw new SecurityException("invalid file path");
}

注意:如果目标文件必须存在,可以进一步使用 toRealPath() 获取真实路径,这样可以处理符号链接带来的逃逸问题。

4. 关注符号链接问题

Linux 环境下,如果上传目录内存在符号链接指向外部敏感目录,仅做 normalize() 不一定足够。比如上传目录中有一个软链接:

/data/app/upload/link -> /etc

访问 link/passwd 时,字符串路径仍像是在上传目录下,但真实文件已经指向外部目录。因此对高风险接口,建议在文件存在后使用 toRealPath() 再做一次边界校验。

安全修复方案

比较稳妥的修复方式是:固定基础目录、规范化路径、校验真实路径、限制文件类型、避免直接暴露物理文件名。示例代码如下:

@GetMapping("/download")
public void download(@RequestParam("file") String file,
                     HttpServletResponse response) throws IOException {
    Path basePath = Paths.get("/data/app/upload").toRealPath();

    String safeName = Paths.get(file).getFileName().toString();
    Path targetPath = basePath.resolve(safeName).normalize();

    if (!targetPath.startsWith(basePath)) {
        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
        return;
    }

    if (!Files.exists(targetPath) || !Files.isRegularFile(targetPath)) {
        response.sendError(HttpServletResponse.SC_NOT_FOUND);
        return;
    }

    Path realTarget = targetPath.toRealPath();
    if (!realTarget.startsWith(basePath)) {
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
        return;
    }

    String downloadName = realTarget.getFileName().toString();
    response.setContentType("application/octet-stream");
    response.setHeader("Content-Disposition",
            "attachment; filename=\"" + downloadName.replace("\"", "") + "\"");

    Files.copy(realTarget, response.getOutputStream());
}

上面的代码为了说明思路,使用了 getFileName() 丢弃目录部分,适合业务只允许按文件名下载的场景。如果业务确实需要子目录,可以不要直接取 getFileName(),但必须保留 resolve + normalize + startsWith + toRealPath 这一组校验。

更推荐的业务设计

从长期维护角度看,不建议让前端传物理文件名或相对路径。更稳的方案是使用文件 ID:

  • 上传文件后生成随机文件名,避免用户可预测路径;
  • 数据库记录文件 ID、真实存储名、原始文件名、所属用户、文件类型、存储目录;
  • 下载时前端只传文件 ID;
  • 服务端根据登录用户和权限查询文件记录;
  • 由服务端拼接受控路径,不接收用户传入的路径。

这种方式不仅能降低路径穿越风险,也方便做权限控制、审计日志和文件生命周期管理。

测试与验证建议

修复后建议补充单元测试或集成测试,覆盖正常路径、非法路径、编码路径、符号链接等情况。可以设计类似下面的测试用例:

测试点预期结果
正常文件名允许下载
包含 ../ 的路径拒绝访问
绝对路径拒绝访问
不存在的文件返回 404
目录路径拒绝访问
软链接指向外部目录拒绝访问

在 Linux 环境下,可以用一个临时目录模拟上传目录,再创建测试文件和软链接进行验证:

mkdir -p /tmp/upload-test
printf 'hello' > /tmp/upload-test/a.txt
ln -s /etc /tmp/upload-test/etc-link

验证时只在本地测试环境操作,不要对未授权系统做路径探测。

日志与监控

这类漏洞即使修复了,也建议保留必要的安全日志。日志里可以记录用户 ID、文件 ID、客户端 IP、请求时间、拒绝原因,但不要记录完整敏感路径或文件内容。

log.warn("blocked file download, userId={}, file={}, reason={}",
        userId, safeLogValue(file), "path traversal detected");

如果短时间内出现大量包含 ../、绝对路径、系统配置文件名的请求,可以作为安全告警信号。生产环境里这类日志对溯源和确认影响面很有价值。

注意点

  • 不要只依赖黑名单过滤,路径问题应基于规范路径和真实路径判断。
  • 不要把用户传入的文件名直接放进响应头。
  • 不要让普通用户控制数据库中的物理文件路径字段。
  • 如果使用对象存储,也要关注对象 key 的访问权限和越权下载问题。
  • 容器、网关、框架的解码行为不同,测试环境应尽量贴近生产链路。

总结

任意文件读取问题的根因通常很简单:用户输入影响了服务端文件路径,而服务端没有确认最终文件是否仍在允许范围内。修复时不要停留在替换字符串或过滤关键字,而要从路径解析规则、符号链接、权限控制和业务设计几个层面一起处理。

实际项目里,最推荐的方式还是用文件 ID 替代文件路径,把路径拼接留在服务端,并配合真实路径校验和权限校验。这样既能解决当前漏洞,也能减少后续同类问题反复出现。

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

0篇意见

推荐意见

没有意见。

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