跳转到帖子

背景

任意文件读取是 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 做边界校验
  • 文件下载同时做好权限判断、响应头处理、异常收敛和安全日志

这类问题代码审计成本不高,但影响面经常被低估。尤其是配置文件、日志文件和上传目录在同一台机器上的系统,更值得重点排查。

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

0篇意见

推荐意见

没有意见。

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

SiteMap HACKHAT © 2026 All rights reserved.