跳转到帖子

背景

任意文件读取在 Java Web 项目里并不少见,尤其是历史系统、内部管理后台、文件预览接口、日志下载接口中比较容易出现。它的危害取决于运行权限和部署环境:轻则读取应用配置、日志文件,重则泄露数据库账号、密钥、源码包,进一步扩大影响面。

这类问题看起来简单,常见成因也就是路径拼接不当、过滤不完整、下载接口缺少权限控制。但在真实代码审计中,漏洞往往不会以一个明显的 FileInputStream(request.getParameter()) 形式出现,而是混在业务封装、工具类、模板渲染、对象存储兼容逻辑里。本文整理一次比较典型的 Java Web 任意文件读取审计过程,重点放在定位方法、漏洞成因、验证边界和修复建议。

问题场景

某内部系统提供了附件下载能力,业务上允许用户下载自己上传的文档。接口大致功能如下:

  • 前端传入文件标识或文件路径;
  • 后端根据参数拼接本地存储目录;
  • 读取文件并写入响应流;
  • 下载行为仅做了登录校验,没有做文件归属校验。

在代码审计时,发现该系统存在多个类似接口,其中一处接口为了兼容历史数据,支持直接传入相对路径。这个兼容逻辑最终导致了目录穿越问题。

漏洞代码简化示例

下面是简化后的问题代码,和真实项目结构有所不同,但成因一致:

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

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

    response.setHeader("Content-Disposition", "attachment; filename=" + file.getName());
    try (InputStream in = new FileInputStream(file);
         OutputStream out = response.getOutputStream()) {
        byte[] buffer = new byte[4096];
        int len;
        while ((len = in.read(buffer)) != -1) {
            out.write(buffer, 0, len);
        }
    }
}

从表面看,代码限定了基础目录 /data/app/upload/,但实际上 filePath 可控,直接字符串拼接会导致路径穿越。例如传入包含 ../ 的相对路径后,最终文件解析结果可能离开上传目录。

这里不展开可用于真实攻击的具体敏感文件路径。做漏洞验证时,应在授权测试环境中使用自建测试文件确认边界,避免读取真实敏感配置。

技术分析

这个问题的关键不在于是否出现了 ../,而在于后端是否基于“规范化后的真实路径”做了边界校验。很多项目只做字符串过滤,例如:

if (filePath.contains("../")) {
    throw new IllegalArgumentException("invalid path");
}

这种做法并不可靠,原因包括:

  • 不同操作系统的路径分隔符不同,例如 Windows 下还有 \;
  • URL 编码、多次编码、Unicode 变体可能导致过滤和文件系统解析不一致;
  • 业务中间层可能先解码、再转义、再拼接,导致校验位置失效;
  • 符号链接可能让路径表面位于上传目录内,实际指向目录外。

Java 中 File 和 Path 的行为也需要注意。new File(baseDir + filePath) 只是构造路径对象,不会自动阻止越界。安全判断应该基于 normalize、toRealPath 之后的结果,并与允许访问的根目录做前缀关系校验。

审计定位思路

在审计这类问题时,我一般不会只搜 FileInputStream,而是按“输入点到文件操作”的链路去找。常用关键词包括:

FileInputStream
Files.newInputStream
Files.readAllBytes
FileUtils.readFileToByteArray
ResourceUtils.getFile
ServletContext.getResourceAsStream
response.getOutputStream
Content-Disposition
MultipartFile
uploadDir
download
preview
export
logFile
filePath
fileName

定位后需要继续确认几个问题:

  • 参数是否可由用户直接或间接控制;
  • 是否有登录、角色、数据归属校验;
  • 是否存在路径拼接、路径还原、历史兼容逻辑;
  • 是否做了规范化路径校验,而不是简单黑名单过滤;
  • 文件读取结果是否直接返回给用户;
  • 是否存在日志下载、模板预览、临时文件读取等非主业务入口。

实际项目中,经常会出现主下载接口修得比较好,但日志下载、PDF 预览、富文本附件接口还保留老逻辑的情况。

安全验证方法

验证时建议先在测试环境中创建一个明确的哨兵文件,例如:

/data/app/sentinel/read_check.txt

然后以授权账号访问下载接口,确认是否能够通过非预期路径读取到该文件。这样可以证明路径边界问题,又不会触碰生产敏感文件。

同时需要记录以下信息,方便开发复现和修复:

  • 存在问题的接口路径和请求方法;
  • 相关参数名;
  • 当前登录角色;
  • 预期允许访问目录;
  • 实际可读取的测试文件位置;
  • 后端关键代码调用链;
  • 是否可跨用户读取其他人的附件。

如果系统有反向代理、网关或对象存储代理,还要确认漏洞发生在应用层还是代理层,避免修错位置。

推荐修复方式

比较稳妥的修复方式是:不要让前端传真实文件路径,而是传文件 ID。后端根据文件 ID 查询数据库,确认当前用户有权限访问后,再读取服务端记录的存储路径。

如果短期内必须兼容路径参数,至少应使用 Path 做规范化和目录边界校验。示例代码如下:

public Path resolveSafePath(String baseDir, String userInput) throws IOException {
    Path basePath = Paths.get(baseDir).toRealPath(LinkOption.NOFOLLOW_LINKS);
    Path targetPath = basePath.resolve(userInput).normalize();

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

    if (!Files.isRegularFile(targetPath, LinkOption.NOFOLLOW_LINKS)) {
        throw new FileNotFoundException("not a regular file");
    }

    return targetPath;
}

下载接口中还应补充权限判断:

@GetMapping("/download")
public void download(Long fileId, HttpServletResponse response) throws IOException {
    Long currentUserId = getCurrentUserId();
    FileRecord record = fileService.findById(fileId);

    if (record == null || !fileService.canAccess(currentUserId, record)) {
        response.setStatus(403);
        return;
    }

    Path file = resolveSafePath("/data/app/upload/", record.getStoragePath());

    response.setContentType("application/octet-stream");
    response.setHeader("Content-Disposition", "attachment; filename=\"" + safeFileName(record.getOriginalName()) + "\"");

    try (InputStream in = Files.newInputStream(file);
         OutputStream out = response.getOutputStream()) {
        in.transferTo(out);
    }
}

文件名也要单独处理,不要直接把用户上传时的文件名拼进响应头。至少应去掉换行符、控制字符和路径分隔符,避免响应头异常或下载行为异常。

容易忽略的细节

  • 只过滤 ../ 不够:过滤黑名单很容易遗漏编码、分隔符和平台差异。
  • normalize 不等于安全:normalize 只是整理路径,还必须做 startsWith(basePath) 判断。
  • 注意符号链接:如果上传目录内允许创建或保留符号链接,要评估 toRealPath 和 NOFOLLOW_LINKS 的使用方式。
  • 权限校验不能省:路径不越界也可能存在越权下载,例如用户 A 读取用户 B 的附件。
  • 日志接口同样高风险:很多系统允许管理员下载日志,但日志中可能包含 token、手机号、SQL、异常堆栈等敏感信息,应限制角色和范围。
  • 错误信息要收敛:不要把服务端绝对路径、文件是否存在、完整异常堆栈直接返回前端。

加固清单

检查项建议
文件标识优先使用文件 ID,不让用户传真实路径
路径处理使用 Path 规范化,并校验真实路径位于基准目录下
访问控制校验登录态、角色、文件归属或业务授权关系
文件类型按业务限制可下载类型,避免任意读取服务端文件
响应头清理文件名中的控制字符和路径分隔符
日志记录记录下载行为,但避免记录敏感文件内容
异常处理前端返回统一错误,后端保留必要审计日志

总结

任意文件读取漏洞的本质,是把用户输入带入了服务端文件系统边界内,并且缺少可靠的路径归一化、边界判断和权限控制。修复时不要依赖简单字符串过滤,也不要只在前端限制参数。

比较长期有效的设计是:前端只传业务 ID,后端基于数据库记录和权限模型决定是否允许下载;确实需要处理路径时,必须使用规范化后的真实路径与基准目录做校验。对历史系统来说,建议把下载、预览、导出、日志查看等接口统一梳理一遍,这类问题往往不是单点存在。

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

0篇意见

推荐意见

没有意见。

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