背景
最近在做一批 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、路径规范化、权限校验和运行权限收敛一起修。这样给出的结论更容易被开发接受,也能减少“修了字符串过滤但问题仍在”的反复。
安全测试建议始终限定在授权范围内,复现时优先使用测试文件和测试环境。漏洞价值判断应结合可读文件范围、接口权限、部署权限和业务数据敏感度综合评估。
推荐意见