跳转到帖子

背景

任意文件读取是 Java Web 项目里比较常见、也比较容易被低估的一类问题。它不一定直接导致系统被控制,但如果能读取到配置文件、密钥、日志、源码或中间件敏感文件,往往会成为后续攻击链的起点。

这类问题在代码审计中经常出现在下载接口、模板预览、日志查看、附件读取、图片代理、静态资源转发等功能里。表面看只是一个文件路径参数,实际风险取决于路径拼接、权限校验、目录限制和响应处理是否严谨。

本文整理一套偏实战的审计与复现思路,重点放在如何识别风险、如何安全验证、如何修复。示例均为本地靶场或脱敏代码,不涉及真实目标攻击。

典型场景

先看一个在业务系统中很常见的下载接口:

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

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

这段代码的问题不在于使用了文件下载功能,而在于它直接把用户输入拼接进了文件路径,没有做目录约束。攻击者如果传入类似目录穿越的路径,就可能访问上传目录之外的文件。

在真实项目里,这种代码可能更隐蔽,例如:

  • 路径参数名不是 file,而是 path、name、url、resource、template、logName。
  • 下载逻辑封装在工具类里,Controller 只传了一个字符串。
  • 使用 Nginx、对象存储、NAS、本地磁盘混合存储,导致路径来源复杂。
  • 开发者做了简单 replace,但没有处理编码、符号链接、路径规范化等问题。

风险形成原因

从代码角度看,任意文件读取通常由以下几个条件叠加形成:

  • 用户可控输入进入文件路径。
  • 服务端使用字符串拼接构造文件路径。
  • 缺少固定根目录限制,或者限制方式不可靠。
  • 读取前没有做权限判断和文件类型判断。
  • 错误信息或下载响应暴露了文件是否存在、大小、内容等信息。

其中最核心的是“路径规范化前后不一致”。很多修复只做了字符串层面的黑名单过滤,比如禁止出现 ../,但文件系统解析路径时会处理分隔符、软链接、重复斜杠、URL 编码等情况,导致过滤看起来存在,实际仍可能被绕过。

代码审计关注点

审计 Java Web 任意文件读取时,我一般按数据流来找,不会只搜一个关键词。比较有效的入口包括:

// Spring MVC 常见入口
@GetMapping
@PostMapping
@RequestMapping
@RequestParam
@PathVariable

// Servlet 入口
request.getParameter
request.getInputStream
request.getHeader

// 文件读取相关 API
new File
Paths.get
Files.readAllBytes
Files.copy
FileInputStream
ResourceUtils.getFile
ClassPathResource
ServletContext.getResourceAsStream

发现用户输入后,需要继续追踪它是否进入以下位置:

  • 本地文件路径:new File(userInput)、Paths.get(userInput)。
  • 相对路径拼接:baseDir + userInput。
  • 日志读取:按日期或文件名读取日志。
  • 模板加载:根据参数选择模板文件。
  • 静态资源代理:把参数映射到服务器文件。
  • 压缩包预览或解压后读取:ZIP 内路径也可能存在穿越问题。

值得注意的是,有些项目表面上不直接返回文件内容,而是先读入字符串再做预览、解析或缓存。这种也需要纳入审计范围。

安全复现方法

复现时建议只在本地环境、测试环境或明确授权范围内进行。为了避免触碰敏感数据,可以先在临时目录准备一个测试文件,然后验证接口是否能越过预期目录读取它。

例如本地准备目录结构:

/tmp/demo-app/
├── uploads/
│   └── public.txt
└── secret.txt

启动应用时将下载根目录设置为:

/tmp/demo-app/uploads/

正常请求只应读取 uploads 目录下的文件:

GET /download?file=public.txt

如果通过相对路径能够读取到 uploads 之外的测试文件,就说明目录约束失败:

GET /download?file=../secret.txt

这里不建议使用系统敏感文件作为验证目标。用自己创建的测试文件即可证明漏洞存在,也便于写报告和复现记录。

常见但不可靠的修复方式

一些代码看似做了限制,但仍然不够安全。例如:

if (file.contains("../")) {
    throw new RuntimeException("invalid path");
}

这类黑名单方式的问题是只覆盖了某一种表现形式。实际路径可能包含不同分隔符、重复编码、绝对路径、软链接或平台差异。还有一些代码只判断后缀:

if (!file.endsWith(".jpg")) {
    throw new RuntimeException("invalid file type");
}

后缀判断可以作为业务限制的一部分,但不能替代目录限制。尤其是当服务端实际读取路径已经逃逸到根目录外时,后缀限制并不能保证安全。

推荐修复方案

比较稳妥的修复思路是:固定根目录、路径规范化、校验规范化后的真实路径仍在根目录下,并尽量使用文件 ID 映射而不是直接暴露文件名。

public void safeDownload(String fileName, HttpServletResponse response) throws IOException {
    Path basePath = Paths.get("/data/app/uploads").toRealPath(LinkOption.NOFOLLOW_LINKS);
    Path targetPath = basePath.resolve(fileName).normalize();

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

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

    String safeName = targetPath.getFileName().toString();
    response.setHeader("Content-Disposition", "attachment; filename=\"" + safeName + "\"");
    response.setContentType("application/octet-stream");
    Files.copy(targetPath, response.getOutputStream());
}

这段代码有几个关键点:

  • basePath 使用 toRealPath 获取真实路径。
  • 用户输入通过 resolve 拼接,不直接字符串相加。
  • normalize 消除路径中的 . 和 ..。
  • 使用 startsWith 判断最终路径是否仍在根目录下。
  • 用 Files.isRegularFile 限制只能读取普通文件。

如果业务允许,最好进一步改造成基于文件 ID 的访问方式。数据库里保存文件 ID、真实路径、所属用户、状态、哈希等信息,接口只接收 ID,然后在服务端查询路径并做权限校验。

GET /download?id=12345

这种方式能明显降低路径参数直接暴露带来的风险,也方便做审计、鉴权和生命周期管理。

权限控制不能省

很多任意文件读取漏洞的根源不只是路径问题,还包括“知道文件名就能下载”。例如用户 A 上传的附件,用户 B 只要猜到路径或 ID 就能读取。

因此下载接口至少应检查:

  • 当前用户是否登录。
  • 文件是否属于当前用户或当前业务对象。
  • 当前业务对象状态是否允许下载。
  • 管理员、普通用户、外部访客是否有不同权限边界。
  • 临时链接是否有过期时间和签名校验。

路径安全解决的是“能不能读到目录外文件”,权限控制解决的是“能不能读到不属于自己的文件”。两者不能互相替代。

日志与告警建议

这类漏洞在修复后,建议补充必要的安全日志。日志不需要记录完整敏感路径,但应能帮助定位异常行为。

log.warn("invalid download path, userId={}, input={}, ip={}",
        userId,
        sanitizeForLog(fileName),
        request.getRemoteAddr());

可以关注以下异常特征:

  • 路径参数中出现 ..、反斜杠、URL 编码特征。
  • 短时间内大量请求不存在的文件。
  • 访问大量不同文件名或不同日期日志。
  • 下载接口返回 4xx/5xx 突增。

需要注意,日志里不要直接写入过长的原始用户输入,避免日志注入和敏感信息落盘。可以截断、转义后再记录。

代码审计清单

检查项说明
路径是否用户可控包括参数、请求头、JSON 字段、数据库中可被用户影响的字段
是否字符串拼接路径重点关注 baseDir + name 这类写法
是否规范化后校验校验应发生在 resolve、normalize、realPath 之后
是否限制根目录最终路径必须位于允许目录内
是否处理软链接必要时使用 NOFOLLOW_LINKS,避免链接逃逸
是否有权限判断不能只依赖文件名或路径
是否暴露详细错误避免返回服务器绝对路径和堆栈信息
是否记录异常访问便于后续排查和告警

注意点

  • Windows 和 Linux 的路径分隔符不同,跨平台项目要同时考虑 / 和 \。
  • URL 解码可能发生在框架层,也可能在业务层重复发生,审计时要确认最终进入文件 API 的字符串。
  • 如果上传目录允许用户创建压缩包或软链接,要额外关注解压和链接处理。
  • 不要把“文件名后缀白名单”当成唯一安全措施。
  • 异常响应应统一处理,不要把绝对路径、环境变量、堆栈直接返回给前端。

总结

任意文件读取漏洞看起来是一个简单的路径问题,实际往往牵涉输入来源、路径解析、权限模型、存储架构和日志响应。审计时不要只看是否过滤了 ../,而要看最终文件系统访问点是否被可靠地限制在预期目录内。

比较推荐的落地方案是:业务层使用文件 ID,服务端查询真实路径;文件层使用 Path.resolve、normalize、toRealPath 做目录约束;权限层按用户和业务对象校验访问资格;运维层记录异常下载行为。这样即使某一层出现疏漏,也不至于直接扩大成高风险漏洞。

复现和验证建议始终放在本地或授权测试环境中完成。用自建测试文件证明问题即可,没必要读取真实系统敏感文件。
代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

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