跳转到帖子

背景

任意文件读取是 Java Web 项目里比较常见、也容易被低估的一类问题。它通常不需要复杂利用链,只要下载、预览、导出、日志查看等接口对路径处理不严,就可能把配置文件、密钥、源码片段或运行日志暴露出去。

这篇文章整理的是一次常规代码审计中的复盘。场景比较典型:业务系统提供附件下载和日志预览能力,开发侧认为已经做了“目录限制”,但实际只做了字符串拼接和简单前缀判断,存在路径穿越风险。文章重点放在审计方法、漏洞成因、修复策略和验证方式,不涉及对真实目标的攻击操作。

问题场景

系统中有一个文件下载接口,大致功能是根据前端传入的文件名,从固定目录读取附件并返回给用户。简化后的代码如下:

GET /file/download?name=report.pdf
String baseDir = "/data/app/upload/";
String name = request.getParameter("name");
File file = new File(baseDir + name);

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

response.setHeader("Content-Disposition", "attachment; filename=" + file.getName());
Files.copy(file.toPath(), response.getOutputStream());

从功能角度看,这段代码能正常完成下载。但从安全角度看,问题也很明显:用户输入被直接拼接到服务端路径中,没有进行规范化校验,也没有确认最终文件是否仍位于允许访问的目录内。

技术分析

1. 漏洞成因

这类问题的核心不在于是否使用了 File 或 Path,而在于是否把“不可信路径片段”转换成了“可信文件路径”。常见风险点包括:

  • 直接拼接用户输入和服务端目录。
  • 仅过滤 ../,忽略编码、分隔符差异、重复解码等情况。
  • 使用 startsWith 做字符串前缀判断,而不是基于规范化后的路径对象判断。
  • 下载接口没有绑定业务对象权限,只要知道路径或文件名就能访问。
  • 日志查看、模板预览、图片缩略图等“非下载接口”也可能间接读取文件。

例如下面这种修复方式看似加了限制,实际上仍然不可靠:

String path = baseDir + name;
if (!path.startsWith(baseDir)) {
    throw new SecurityException("invalid path");
}

原因是这里判断的是字符串,而不是文件系统语义。路径里可能包含相对路径片段,也可能存在符号链接、大小写差异、分隔符转换等问题。正确做法应当先解析、规范化,再判断最终路径是否位于允许目录下。

2. 审计关注点

在 Java Web 项目中,可以优先搜索以下关键词,定位潜在文件读取点:

new File(
Paths.get(
FileInputStream
Files.readAllBytes
Files.copy
ResourceUtils.getFile
ClassPathResource
ServletContext.getResourceAsStream
response.getOutputStream
Content-Disposition

搜索到结果后,不要只看一行代码,需要向上追踪参数来源,确认是否来自:

  • HTTP 参数:query、form、JSON 字段、header。
  • 数据库字段:如果字段可被用户间接写入,也属于不可信输入。
  • 消息队列或任务参数:后台任务同样可能触发文件读取。
  • 模板变量或导出配置:常被误认为是内部参数。

再向下确认文件内容如何返回或使用:

  • 直接通过响应流返回给用户。
  • 打包成 ZIP、Excel、PDF 后返回。
  • 写入日志、错误信息或异步任务结果。
  • 被模板引擎、解析器、图片处理库进一步处理。

关键步骤

1. 确认输入是否可控

首先确认文件名、路径、目录名是否能被普通用户控制。如果接口传入的是附件 ID,也不能直接认为安全,需要看 ID 对应的数据库字段是否存储了用户上传时提交的原始文件名或相对路径。

比较安全的设计是:用户只提交附件 ID,服务端根据 ID 查询文件元数据,并且文件真实存储名由服务端生成,例如 UUID 或哈希值,而不是使用用户原始文件名作为物理路径。

2. 确认路径是否规范化

推荐使用 java.nio.file.Path 处理路径,避免手工拼接字符串。示例修复如下:

Path basePath = Paths.get("/data/app/upload").toRealPath(LinkOption.NOFOLLOW_LINKS);
String name = request.getParameter("name");

if (name == null || name.isBlank()) {
    response.setStatus(400);
    return;
}

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

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

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

Files.copy(targetPath, response.getOutputStream());

这里有几个点需要注意:

  • resolve 用于把用户传入的相对路径拼到基准目录下。
  • normalize 用于消除路径中的 . 和 .. 片段。
  • startsWith 是 Path 对象层面的判断,不是字符串判断。
  • isRegularFile 用于避免目录、设备文件等非普通文件被读取。

3. 处理符号链接问题

如果上传目录中允许存在符号链接,仅靠 normalize 不一定够。符号链接可能把路径指向基准目录之外。更严格的做法是在文件存在后使用 toRealPath 获取真实路径,再判断是否仍在基准目录内:

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

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

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

Path realTarget = targetPath.toRealPath();
if (!realTarget.startsWith(basePath)) {
    response.setStatus(403);
    return;
}

对于大多数业务上传目录,建议从部署层面禁止应用用户在该目录创建符号链接,或者在文件写入时统一使用服务端生成的文件名,降低后续处理复杂度。

4. 限制文件访问范围

文件读取接口不应该只依赖路径校验,还要做业务权限校验。比如附件属于某个工单、合同或用户资料,下载时应确认当前登录用户是否具备访问该业务对象的权限。

推荐的数据模型可以类似这样:

attachment_id | owner_type | owner_id | storage_name | original_name | created_by
--------------|------------|----------|--------------|---------------|-----------
10001         | ticket     | 9001     | a8f3...pdf   | report.pdf    | 501

下载流程应为:

  • 根据附件 ID 查询元数据。
  • 校验当前用户是否有权限访问 owner_type 和 owner_id。
  • 使用服务端生成的 storage_name 定位文件。
  • 返回时仅把 original_name 用作下载文件名展示。

5. 避免错误信息泄露

文件读取失败时,不建议把服务端绝对路径、异常堆栈直接返回给前端。错误信息可以记录在服务端日志里,但对用户返回统一的状态码和简短提示。

try {
    // file read logic
} catch (IOException e) {
    log.warn("download failed, attachmentId={}, userId={}", attachmentId, currentUserId, e);
    response.setStatus(404);
}

这里日志里记录的是附件 ID 和用户 ID,而不是直接记录用户提交的完整路径。这样便于排查,同时减少日志污染和敏感路径扩散。

验证方式

修复后建议补充单元测试和接口测试,覆盖正常文件、路径穿越、绝对路径、空值、目录、符号链接等情况。示例测试思路如下:

@Test
void shouldRejectPathTraversal() {
    Path base = Paths.get("/tmp/upload").toAbsolutePath().normalize();
    Path target = base.resolve("../config/application.yml").normalize();

    assertFalse(target.startsWith(base));
}

如果项目里有统一的下载组件,可以把路径校验逻辑收敛到一个方法中,避免各业务模块重复实现:

public Path safeResolve(Path baseDir, String userInput) throws IOException {
    if (userInput == null || userInput.isBlank()) {
        throw new IllegalArgumentException("empty path");
    }

    Path base = baseDir.toRealPath();
    Path target = base.resolve(userInput).normalize();

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

    if (!Files.exists(target) || !Files.isRegularFile(target)) {
        throw new FileNotFoundException("file not found");
    }

    Path realTarget = target.toRealPath();
    if (!realTarget.startsWith(base)) {
        throw new SecurityException("real path out of base directory");
    }

    return realTarget;
}

注意点

  • 不要只过滤 ../。黑名单过滤很容易遗漏编码、分隔符和平台差异。
  • 不要使用用户原始文件名作为物理存储名。原始文件名只适合展示。
  • 不要把权限校验和路径校验混为一谈。路径合法不代表用户有权访问。
  • 不要在响应中返回绝对路径、堆栈信息或详细文件系统错误。
  • 不要忽略 Windows 和 Linux 路径差异。跨平台项目尤其要统一使用 Path API。
  • 对历史文件数据要做清理。如果库里已经保存了带路径片段的文件名,修复代码后也应迁移或隔离。

排查清单

检查项风险表现建议处理
路径拼接使用字符串拼接用户输入改用 Path.resolve 并规范化
目录限制使用字符串 startsWith使用规范化后的 Path.startsWith
文件名来源使用用户上传原始文件名存储服务端生成存储名,原始名仅展示
符号链接可能指向基准目录外使用 toRealPath 二次校验
业务权限知道文件 ID 即可下载绑定业务对象并校验访问权限
异常处理返回绝对路径或堆栈前端统一错误,后端记录审计日志

总结

任意文件读取漏洞通常不是复杂代码导致的,而是边界意识不足:把用户输入当成了文件系统路径的一部分。实际审计时,重点关注下载、预览、导出、日志查看、模板加载、图片处理等入口,沿着“输入来源、路径构造、权限校验、内容输出”这条线追踪,基本能覆盖大多数风险点。

修复上建议遵循三点:路径使用标准 API 规范化并限制在基准目录内;业务上使用附件 ID 和服务端生成文件名,不暴露物理路径;权限校验必须绑定具体业务对象。这样处理后,即使后续新增下载场景,也能通过统一组件降低重复踩坑的概率。

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

0篇意见

推荐意见

没有意见。

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