跳转到帖子

背景

最近在做一批 Java Web 系统的安全巡检时,遇到一类比较典型的告警:接口参数可控,后端根据参数拼接文件路径并返回文件内容。单看扫描器结论是“任意文件读取”,但实际风险需要结合代码路径、部署权限、过滤逻辑和业务场景判断。

这类问题在后台管理系统、报表下载、日志查看、模板预览、附件读取功能里比较常见。它不一定一上来就是高危,但如果能读取配置文件、密钥、日志、数据库连接串,往往会成为后续风险的入口。

本文整理一个偏通用的复现与审计思路,重点放在如何判断问题是否真实存在、如何定位代码根因,以及如何给出可落地的修复建议。内容仅用于授权测试和代码审计场景。

问题场景

业务系统中有一个文件预览接口,前端传入文件名,后端读取指定目录下的文件并返回内容。简化后的请求类似:

GET /api/file/preview?name=report-2024.txt HTTP/1.1
Host: example.local
Cookie: JSESSIONID=xxxx

正常情况下,它只应该读取业务目录中的报表文件,例如:

/data/app/reports/report-2024.txt

但如果后端只是简单拼接路径,而没有做规范化校验,攻击者可能通过路径穿越读取业务目录外的文件。安全测试中常见的风险点包括:

  • 参数直接参与文件路径拼接;
  • 只做了简单字符串替换,例如替换 ../,但没有处理编码、分隔符差异;
  • 校验发生在路径规范化之前;
  • 允许读取绝对路径;
  • 下载、预览、日志查看接口缺少权限校验;
  • 容器运行用户权限过大,导致可读范围过宽。

技术分析

一个存在风险的示例代码大致如下:

@GetMapping("/preview")
public ResponseEntity<String> preview(@RequestParam String name) throws IOException {
    String baseDir = "/data/app/reports/";
    File file = new File(baseDir + name);

    if (!file.exists()) {
        return ResponseEntity.notFound().build();
    }

    String content = Files.readString(file.toPath(), StandardCharsets.UTF_8);
    return ResponseEntity.ok(content);
}

这段代码的问题在于:它假设 name 一定是普通文件名,但实际请求参数完全可控。只要 name 中出现路径控制字符,最终 File 指向的路径就可能不再位于 /data/app/reports/ 下。

有些项目会尝试做简单过滤,例如:

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

这种处理并不可靠。原因包括:

  • 不同操作系统路径分隔符不同,例如 Windows 支持反斜杠;
  • URL 编码、双重编码可能导致过滤前后语义不一致;
  • 字符串替换不等于路径边界校验;
  • 过滤逻辑可能只覆盖部分入口,其他接口仍可触发。

更稳妥的判断方式是:先把用户输入解析为路径,再进行规范化,然后确认最终路径仍然落在允许访问的根目录下。

复现思路

授权测试时,不建议直接读取敏感文件。更稳妥的方式是在测试环境准备一个位于业务目录外、无敏感内容的标记文件,用于验证路径穿越是否成立。例如:

# 测试环境中准备验证文件
mkdir -p /tmp/security-test
echo "path traversal check" > /tmp/security-test/proof.txt

如果业务目录为 /data/app/reports/,可以构造只用于验证的相对路径访问该文件。具体层级需要根据实际目录深度调整:

GET /api/file/preview?name=../../../tmp/security-test/proof.txt HTTP/1.1
Host: example.local

如果接口返回 proof.txt 中的内容,就可以证明路径边界失效。测试时需要注意:

  • 只在授权环境验证,不读取系统敏感文件;
  • 优先使用自建标记文件或无敏感测试文件;
  • 记录请求、响应、服务端日志和代码位置,便于开发复核;
  • 确认是否需要登录态、角色权限、业务状态等前置条件;
  • 验证同类接口,例如下载、导出、模板、日志、图片预览等。

代码审计关键点

审计时可以从“文件读取 API + 参数来源”两个方向入手。Java 项目中常见的敏感方法包括:

new File(...)
Paths.get(...)
Files.readAllBytes(...)
Files.readString(...)
FileInputStream
ResourceUtils.getFile(...)
ServletContext.getResourceAsStream(...)
response.getOutputStream()

如果使用 grep、ripgrep 或 IDE 全局搜索,可以先找文件操作和响应输出相关代码:

rg "Files\.read|FileInputStream|new File|Paths\.get|getResourceAsStream|getOutputStream" src/main/java

命中后重点看几个问题:

  • 路径参数是否来自 request parameter、path variable、header、JSON body;
  • 是否使用白名单文件 ID,而不是直接使用文件名;
  • 是否存在 baseDir,但最终路径没有做 startsWith 校验;
  • 是否允许传入绝对路径;
  • 是否对符号链接有处理;
  • 异常信息是否会回显真实路径;
  • 接口权限是否足够细粒度。

一个比较常见的误区是只检查文件名是否包含 ..。实际修复时应该围绕“最终解析路径是否仍在允许目录内”来做,而不是围绕某几个危险字符串来做。

推荐修复方式

修复建议优先采用白名单 ID 映射。也就是说,前端传 fileId,后端根据数据库记录找到文件真实路径,而不是让用户直接传路径或文件名。

如果业务确实需要传文件名,可以参考以下思路:

private static final Path BASE_DIR = Paths.get("/data/app/reports").toAbsolutePath().normalize();

public Path resolveSafePath(String filename) throws IOException {
    if (filename == null || filename.isBlank()) {
        throw new IllegalArgumentException("empty filename");
    }

    // 可选:只允许普通文件名,不允许路径分隔符
    if (filename.contains("/") || filename.contains("\\")) {
        throw new IllegalArgumentException("invalid filename");
    }

    Path target = BASE_DIR.resolve(filename).normalize();

    if (!target.startsWith(BASE_DIR)) {
        throw new SecurityException("path out of base directory");
    }

    if (!Files.isRegularFile(target)) {
        throw new IllegalArgumentException("not a regular file");
    }

    return target;
}

如果允许子目录,则不能简单禁止 /,但仍然要保留 normalize 后的 startsWith 校验:

public Path resolveSafeSubPath(String relativePath) {
    Path target = BASE_DIR.resolve(relativePath).normalize();

    if (!target.startsWith(BASE_DIR)) {
        throw new SecurityException("path out of base directory");
    }

    return target;
}

另外,建议补充以下防护:

  • 文件访问接口必须做鉴权和业务权限校验;
  • 下载目录与系统目录、配置目录隔离;
  • 容器或进程使用低权限用户运行;
  • 限制可读取文件类型和大小;
  • 不要在错误响应中返回服务器绝对路径;
  • 对历史附件数据做一次路径合法性清理;
  • 增加单元测试覆盖路径穿越场景。

单元测试示例

修复后最好补充测试用例,避免后续重构时再次引入问题。示例:

@Test
void shouldRejectPathTraversal() {
    assertThrows(SecurityException.class, () -> {
        resolveSafeSubPath("../../tmp/proof.txt");
    });
}

@Test
void shouldAllowNormalFile() {
    Path path = resolveSafeSubPath("2024/report.txt");
    assertTrue(path.startsWith(BASE_DIR));
}

如果项目中存在多套文件处理逻辑,建议把安全路径解析封装成公共方法,避免每个接口各写一套过滤规则。

注意点

  • 不要只依赖黑名单过滤,路径问题更适合用规范化后的边界校验解决;
  • Windows 和 Linux 的路径规则不同,跨平台项目要同时考虑 / 和 \;
  • 反向代理、框架、容器可能会做一次 URL 解码,测试时要确认服务端实际收到的参数;
  • 符号链接可能让路径看起来在目录内,但实际指向目录外,敏感场景可结合 toRealPath 判断;
  • 日志查看接口尤其容易忽略权限,很多时候能读日志就能拿到 token、手机号、内部接口路径等信息;
  • 容器化部署不能替代代码修复,但可以通过只读文件系统、最小挂载和低权限用户降低影响面。

总结

任意文件读取类问题的核心不在于某个 payload 是否能命中,而在于用户输入是否参与了服务端文件路径决策,以及最终解析路径是否被限制在业务允许范围内。

实际排查时,我一般按三步走:先用无敏感标记文件确认风险是否成立,再从代码里定位路径拼接和读取点,最后推动白名单 ID、路径规范化、权限校验和运行权限收敛一起修。这样给出的结论更容易被开发接受,也能减少“修了字符串过滤但问题仍在”的反复。

安全测试建议始终限定在授权范围内,复现时优先使用测试文件和测试环境。漏洞价值判断应结合可读文件范围、接口权限、部署权限和业务数据敏感度综合评估。
代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

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