背景
任意文件读取是 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,后端查元数据,真实路径由服务端生成和维护;再配合规范路径校验、权限校验、文件类型限制和审计日志。这样即使某个环节出现异常,也不至于直接扩大为高危问题。
推荐意见