跳转到帖子

背景

任意文件读取是 Java Web 项目里比较常见、也容易被低估的一类问题。它不一定直接导致系统被控制,但一旦能够读取到配置文件、密钥、日志、备份包,后续风险会被明显放大,例如数据库凭据泄露、JWT 密钥泄露、云访问密钥泄露等。

这篇文章整理一个偏通用的审计思路:从一个文件下载接口入手,分析路径拼接、参数校验、目录限制和修复方式。内容以防护和代码审计为主,不涉及未授权入侵或真实目标攻击。

问题场景

项目中常见一个下载接口,前端传入文件名或相对路径,后端从指定目录读取文件并返回:

@GetMapping("/download")
public void download(@RequestParam String file, HttpServletResponse response) throws IOException {
    String baseDir = "/data/app/upload/";
    File target = new File(baseDir + file);

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

这类代码看起来很普通,但问题点也很明显:服务端直接信任了用户传入的 file 参数,并将其拼接为文件系统路径。只要路径规范化和目录边界控制缺失,就可能读取上传目录之外的文件。

技术分析

审计任意文件读取时,我一般会重点看四类代码模式。

1. 字符串拼接路径

最典型的风险点是直接使用字符串拼接:

new File(baseDir + fileName)
Paths.get(basePath + "/" + name)
ResourceUtils.getFile(path + param)

这种写法的问题在于:开发者以为 baseDir 固定,实际最终路径可能被用户输入影响。尤其是没有做 canonical path 校验时,简单过滤往往不可靠。

2. 只过滤关键字但不做规范化

有些代码会做类似处理:

if (file.contains("..")) {
    throw new IllegalArgumentException("invalid file");
}

这类过滤可以挡住一部分低级输入,但并不是完整防护。不同操作系统路径分隔符、URL 编码、重复解码、软链接等因素都可能让单纯字符串判断失效。更稳妥的方式是将路径解析为规范路径,再判断是否仍位于允许目录内。

3. 允许读取任意后缀文件

下载接口如果没有业务上的文件白名单,很容易扩大影响面。比如原本只应该下载用户上传的 PDF 或图片,却允许读取任意文件类型。审计时建议确认:

  • 接口是否需要登录和权限校验;
  • 文件是否与当前用户或当前业务对象绑定;
  • 是否限制后缀、MIME 类型和文件来源;
  • 是否允许读取临时目录、备份目录、日志目录。

4. 软链接导致目录逃逸

即使使用了相对路径限制,也要注意上传目录内存在软链接的情况。若程序允许用户影响服务器端文件结构,或者部署环境中 upload 目录本身存在链接,就需要额外关注真实路径校验。

关键审计步骤

下面是比较实用的一套排查流程,适合在代码审计或内部安全测试中使用。

步骤一:定位文件读取入口

可以先全局搜索以下关键词:

FileInputStream
Files.readAllBytes
Files.copy
new File(
Paths.get(
getResourceAsStream
InputStreamResource
ByteArrayResource
ResponseEntity<Resource>

重点关注 Controller、DownloadService、FileService、AttachmentService、ExportService 这类位置。很多漏洞并不在下载接口本身,而是在附件预览、日志查看、导出结果下载、模板读取等功能里。

步骤二:追踪参数来源

确认文件名、路径、ID 是否来自用户输入:

  • HTTP 参数:file、path、name、url、key、id;
  • 请求体 JSON 字段;
  • 数据库字段,但字段可能由用户提交写入;
  • 消息队列、回调参数、第三方同步数据。

不要只看接口方法参数。很多项目会先根据 id 查询数据库,然后使用数据库里的 path 字段读取文件。如果 path 可被用户间接控制,也属于风险链路。

步骤三:确认是否存在目录边界校验

推荐的写法是使用规范路径进行判断:

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

public Path resolveSafePath(String userInput) throws IOException {
    Path target = BASE_DIR.resolve(userInput).normalize();

    if (!target.startsWith(BASE_DIR)) {
        throw new SecurityException("invalid file path");
    }

    return target;
}

如果需要考虑软链接,可以进一步使用真实路径:

public Path resolveSafeRealPath(String userInput) throws IOException {
    Path baseReal = BASE_DIR.toRealPath();
    Path target = BASE_DIR.resolve(userInput).normalize();

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

    Path targetReal = target.toRealPath();
    if (!targetReal.startsWith(baseReal)) {
        throw new SecurityException("invalid file path");
    }

    return targetReal;
}

这里的核心点不是简单替换字符串,而是让操作系统语义下的路径解析结果接受边界检查。

步骤四:检查权限绑定

很多任意文件读取问题本质上是“文件对象未授权访问”。即使路径安全,也要验证当前用户是否有权读取该文件。更推荐的设计是:前端只传文件 ID,后端从数据库查询文件元数据,并校验业务归属。

public Attachment getReadableAttachment(Long fileId, Long currentUserId) {
    Attachment att = attachmentMapper.selectById(fileId);
    if (att == null) {
        throw new NotFoundException();
    }

    if (!att.getOwnerId().equals(currentUserId)) {
        throw new AccessDeniedException("no permission");
    }

    return att;
}

如果系统存在管理员、租户、部门、项目空间等概念,权限判断要按业务模型处理,不能只判断是否登录。

步骤五:收敛可读取范围

建议从设计上减少用户输入对真实路径的影响:

  • 文件落盘时使用服务端生成的随机文件名;
  • 数据库保存相对路径或对象存储 key,不保存用户传入的原始路径;
  • 下载时通过文件 ID 查元数据,不直接接收路径;
  • 限制允许下载的文件类型;
  • 敏感配置、日志、备份不要放在 Web 进程可读目录下。

一个相对稳妥的下载实现

下面是一个更接近生产可用的简化示例:

@GetMapping("/attachments/{id}/download")
public void download(@PathVariable Long id,
                     HttpServletResponse response,
                     Principal principal) throws IOException {
    Long currentUserId = userService.getUserId(principal);
    Attachment att = attachmentService.getReadableAttachment(id, currentUserId);

    Path baseDir = Paths.get("/data/app/upload").toAbsolutePath().normalize();
    Path target = baseDir.resolve(att.getStoragePath()).normalize();

    if (!target.startsWith(baseDir)) {
        throw new SecurityException("invalid path");
    }

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

    String safeName = URLEncoder.encode(att.getOriginalName(), StandardCharsets.UTF_8)
            .replaceAll("\\+", "%20");

    response.setContentType("application/octet-stream");
    response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + safeName);
    Files.copy(target, response.getOutputStream());
}

这个示例里有几个关键点:

  • 接口只接收文件 ID,不接收任意路径;
  • 先做用户权限校验;
  • 使用 normalize 后检查目录边界;
  • 确认目标是普通文件;
  • 下载文件名使用原始文件名,但不参与真实路径拼接。

测试与验证建议

在授权范围内做验证时,不建议直接读取系统敏感文件。可以在测试环境准备一个哨兵文件,用于确认目录边界是否被突破。例如:

/tmp/audit-base/
├── upload/
│   └── normal.txt
└── sentinel.txt

服务端配置下载目录为 /tmp/audit-base/upload,只允许读取 normal.txt。测试目标是确认接口无法读取 upload 目录外的 sentinel.txt。这样既能验证漏洞,又不会触碰真实敏感文件。

如果要写单元测试,可以覆盖以下用例:

  • 正常文件可下载;
  • 不存在的文件返回明确错误;
  • 越界路径被拒绝;
  • 软链接文件按预期处理;
  • 无权限用户无法读取他人文件;
  • 异常输入不会输出完整服务器路径。

日志与告警

文件读取类接口建议记录必要的审计日志,但不要把完整敏感路径和用户输入原样打到日志里。可以记录:

  • 用户 ID、租户 ID;
  • 文件 ID;
  • 业务对象 ID;
  • 请求来源 IP;
  • 拒绝原因的简要类型。

如果短时间内出现大量非法路径、异常文件 ID 或跨租户访问失败,可以作为告警信号。真实环境中,这类行为往往比单次错误请求更有价值。

常见误区

  • 只在前端限制文件名:前端校验不能作为安全边界。
  • 只过滤 ../:路径问题需要规范化和边界判断,不能依赖单个关键字。
  • 认为内网接口不需要权限:内网接口一旦被 SSRF、代理、低权限账号滥用,影响同样很大。
  • 下载接口只关注读取,不关注授权:越权下载在业务系统里更常见。
  • 把上传目录和配置、日志放在同一层级:目录规划不合理会放大风险。

修复优先级建议

问题类型风险建议优先级
直接接收路径并读取文件可能读取任意本地文件高
无权限校验的附件下载可能批量下载他人文件高
仅过滤关键字防护不稳定中高
错误信息泄露绝对路径辅助后续攻击面分析中
日志记录敏感路径增加二次泄露风险中

总结

任意文件读取的核心不在于某个特殊 payload,而在于后端是否把用户输入带入了文件系统语义,并且缺少边界控制。代码审计时可以沿着“入口定位、参数来源、路径解析、权限绑定、目录边界、异常处理”这条线排查。

从工程角度看,最稳妥的方案是减少路径暴露:前端传 ID,后端查元数据,真实路径由服务端生成和维护;再配合规范路径校验、权限校验、文件类型限制和审计日志。这样即使某个环节出现异常,也不至于直接扩大为高危问题。

服务器与云环境安全示意图
服务器、云资产与权限边界梳理示意

0篇意见

推荐意见

没有意见。

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