跳转到帖子

背景

文件读取类漏洞在 Java Web 项目里并不稀奇,但实际排查时经常被低估。很多业务代码本意只是提供“下载日志”“导出附件”“预览模板”等功能,参数看起来也只是一个文件名,一旦路径拼接和权限校验处理不严,就可能变成任意文件读取。

这篇文章整理一次相对典型的代码审计和复盘过程。内容只讨论本地测试环境中的漏洞成因、验证方式和修复思路,不涉及真实目标攻击,也不提供越权利用指导。重点放在:如何在代码审计中识别风险点,如何构造安全的复现环境,以及如何把问题修干净。

场景描述

某个内部 Java Web 系统提供附件下载接口,接口大致功能如下:

  • 用户传入文件名参数。
  • 服务端从固定目录读取文件。
  • 以流的形式返回给浏览器下载。

开发侧认为文件都存放在固定目录下,因此没有额外做权限检查。问题出在“固定目录 + 用户输入”这种组合上,如果直接字符串拼接路径,攻击者可能通过路径穿越读取目录外文件。

问题代码示例

下面是经过简化的示例代码,便于说明问题。实际项目里类似逻辑可能散落在 Controller、Service 或工具类中。

String baseDir = "/data/app/uploads/";
String fileName = request.getParameter("file");
File file = new File(baseDir + fileName);

if (!file.exists()) {
    response.setStatus(404);
    return;
}

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

这段代码的问题比较直接:

  • 用户输入直接参与文件路径拼接。
  • 没有做路径规范化处理。
  • 没有校验最终文件是否仍位于允许目录下。
  • 接口层缺少业务权限判断,例如文件是否属于当前用户或当前租户。

漏洞成因分析

文件读取漏洞的核心并不只是“出现了 ../”,而是服务端没有把“用户可控文件标识”和“真实文件系统路径”隔离开。常见成因有几类。

1. 直接拼接路径

这是最常见的问题。开发者把用户传入的文件名直接拼到基础目录后面,认为基础目录已经限制了访问范围。但文件系统会解析路径中的相对目录符号,最终可能跳出预期目录。

File file = new File(baseDir + userInput);

即使对参数做了简单黑名单过滤,例如替换 ../,也容易被编码、不同分隔符、重复编码或平台差异影响。单纯靠字符串替换不是稳定的安全边界。

2. 只判断文件是否存在

不少代码只检查 exists() 或 isFile(),但这些判断只能说明路径对应的文件存在,不能说明这个文件是否应该被当前用户访问。

if (file.exists() && file.isFile()) {
    // 返回文件
}

安全判断应该包含两个层面:路径边界校验和业务授权校验。前者解决“能不能出目录”,后者解决“是不是你的文件”。

3. 使用原始文件名作为下载标识

如果数据库中只保存原始文件名,下载接口又允许客户端直接传 fileName,会带来两个问题:

  • 文件名可预测,容易枚举。
  • 文件名和路径边界耦合,容易出现路径穿越。

更稳妥的做法是使用文件 ID 或随机存储名作为引用,由服务端根据数据库记录映射到真实路径。

本地复现思路

复现建议只在本机或授权测试环境完成。可以用一个临时目录模拟上传目录,不需要读取系统敏感文件。下面以 Linux/macOS 环境为例,准备目录结构:

mkdir -p /tmp/demo-upload
mkdir -p /tmp/demo-secret
echo "normal attachment" > /tmp/demo-upload/report.txt
echo "should not be downloadable" > /tmp/demo-secret/internal.txt

假设下载接口的 baseDir 指向 /tmp/demo-upload/,正常访问 report.txt 应该成功,而 /tmp/demo-secret/internal.txt 不应该被读取。测试时只需要验证接口是否可能越过 /tmp/demo-upload 访问上级目录中的测试文件即可。

如果漏洞存在,服务端日志里通常能看到最终被打开的路径并不在预期目录下。建议在本地调试时打印 canonical path,便于观察路径解析结果。

System.out.println("request fileName = " + fileName);
System.out.println("absolute path = " + file.getAbsolutePath());
System.out.println("canonical path = " + file.getCanonicalPath());

这里重点关注 canonical path,因为它会解析相对路径、符号链接等情况,比 getAbsolutePath 更接近真实访问路径。

安全修复方案

修复文件读取漏洞时,不建议只做参数黑名单。更可靠的方案是“服务端映射 + 路径规范化 + 目录边界校验 + 业务授权”。

1. 使用文件 ID 映射真实路径

下载接口尽量不要接受任意文件名,而是接受业务文件 ID。服务端根据 ID 查询数据库,确认文件归属和存储路径。

GET /download?id=12345

数据库记录中建议至少包含:

  • 文件 ID。
  • 上传用户或所属业务对象。
  • 存储目录或对象存储 key。
  • 原始文件名,仅用于展示和下载头。
  • 文件大小、哈希、创建时间等审计字段。

这样客户端不能自由决定服务端读取哪个路径,风险会低很多。

2. 规范化路径并校验目录边界

如果确实需要根据相对路径读取文件,必须在读取前做规范化,并确认最终路径仍在允许目录内。

Path basePath = Paths.get("/data/app/uploads").toRealPath();
String fileName = request.getParameter("file");

Path targetPath = basePath.resolve(fileName).normalize();

if (!targetPath.startsWith(basePath)) {
    response.setStatus(403);
    return;
}

if (!Files.isRegularFile(targetPath)) {
    response.setStatus(404);
    return;
}

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

这里有几个细节:

  • basePath 使用 toRealPath() 获取真实路径。
  • targetPath 使用 resolve() 和 normalize() 处理相对路径。
  • 通过 startsWith(basePath) 判断是否仍在允许目录内。
  • 读取前使用 Files.isRegularFile(),避免目录、设备文件等异常对象。

3. 注意符号链接问题

符号链接是文件读取修复中容易遗漏的点。如果上传目录内存在指向外部目录的软链接,单纯 normalize() 不一定能覆盖所有情况。更严格的做法是在最终读取前对目标文件调用 toRealPath(),再判断边界。

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

if (!Files.exists(targetPath)) {
    response.setStatus(404);
    return;
}

Path realTargetPath = targetPath.toRealPath();

if (!realTargetPath.startsWith(basePath)) {
    response.setStatus(403);
    return;
}

如果业务不需要软链接,部署层面也建议禁止在上传目录中创建软链接,或者在文件处理流程中显式拒绝。

4. 增加业务权限校验

路径边界校验只能证明文件在上传目录里,不能证明当前用户有权下载。多租户系统、工单系统、网盘类系统尤其要注意横向越权。

推荐的校验流程:

  • 根据文件 ID 查询文件记录。
  • 判断文件是否存在且状态正常。
  • 判断当前用户是否属于该文件的拥有者、协作者或有对应业务对象权限。
  • 通过权限校验后,再读取对应存储路径。
Attachment attachment = attachmentDao.findById(fileId);
if (attachment == null) {
    response.setStatus(404);
    return;
}

if (!permissionService.canDownload(currentUser, attachment)) {
    response.setStatus(403);
    return;
}

审计时的关注点

做代码审计时,可以优先搜索以下关键词和调用点:

new File(
FileInputStream
Files.newInputStream
Files.readAllBytes
getResourceAsStream
ResourceUtils.getFile
response.getOutputStream
Content-Disposition

同时关注参数名:

file
fileName
path
download
template
log
report
attach
url
key

一些高风险业务接口包括:

  • 附件下载、图片预览、报表导出。
  • 日志查看、日志下载。
  • 模板下载、配置文件预览。
  • 插件包、升级包、备份文件下载。
  • 临时文件读取接口。

审计时不要只看 Controller。很多项目会在工具类里封装 downloadFile、writeFileToResponse、preview 等方法,多个接口复用同一段危险逻辑。

测试用例建议

修复后建议补充单元测试或集成测试,覆盖正常路径、越界路径、编码路径、软链接等场景。示例用例可以包括:

  • 正常下载上传目录中的文件。
  • 访问不存在文件时返回 404。
  • 访问目录时拒绝。
  • 相对路径跳出基础目录时返回 403。
  • 软链接指向外部目录时拒绝。
  • 无权限用户访问他人文件时返回 403。

简单的 JUnit 思路如下:

@Test
void shouldRejectPathTraversal() throws Exception {
    Path base = Files.createTempDirectory("upload");
    Path outside = Files.createTempFile("outside", ".txt");
    Files.writeString(outside, "outside content");

    String input = "../" + outside.getFileName();

    boolean allowed = FilePathChecker.isSafe(base, input);

    assertFalse(allowed);
}

路径安全判断可以单独抽成工具方法,便于复用和测试。

public static boolean isSafe(Path baseDir, String userInput) throws IOException {
    Path basePath = baseDir.toRealPath();
    Path targetPath = basePath.resolve(userInput).normalize();

    if (!Files.exists(targetPath)) {
        return false;
    }

    Path realTargetPath = targetPath.toRealPath();
    return realTargetPath.startsWith(basePath) && Files.isRegularFile(realTargetPath);
}

日志与监控

文件读取类问题修复后,建议增加必要的审计日志。日志不需要记录完整文件内容,但应记录关键上下文,便于后续排查:

  • 当前用户 ID。
  • 文件 ID 或业务对象 ID。
  • 请求来源 IP。
  • 规范化后的目标路径或存储 key。
  • 拒绝原因,例如越界、无权限、不存在。

对明显异常的路径访问可以做告警,例如参数中出现连续的路径跳转符号、异常编码、访问大量不存在文件等。但监控只能作为辅助,不能替代代码层面的边界校验。

容易踩的坑

  • 只过滤 ../,没有处理 Windows 分隔符或编码差异。
  • 只判断 absolute path,没有判断 canonical/real path。
  • 修了下载接口,遗漏了预览接口。
  • 上传目录允许创建软链接,但读取时没有处理。
  • 只做路径校验,没有做文件归属权限校验。
  • 下载头直接使用用户输入文件名,导致响应头异常或兼容性问题。

下载文件名建议从数据库中的原始文件名读取,并进行安全处理,避免换行符等特殊字符影响响应头。

String safeName = originalName.replaceAll("[\\r\\n]", "_");
response.setHeader("Content-Disposition", "attachment; filename=\"" + safeName + "\"");

总结

文件读取漏洞看起来简单,但在真实项目里经常和权限设计、存储设计、历史接口复用混在一起。比较稳妥的治理方式不是临时加几个字符串过滤,而是把文件访问抽象成统一能力:客户端只传文件 ID,服务端负责权限判断、路径映射和边界校验。

如果只能先做短期修复,至少要做到三点:规范化路径、校验最终真实路径仍在允许目录内、确认当前用户有业务访问权限。后续再逐步把直接传路径的接口改造成文件 ID 访问模式,减少同类问题反复出现。

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

0篇意见

推荐意见

没有意见。

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