背景
任意文件读取在 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,后端基于数据库记录和权限模型决定是否允许下载;确实需要处理路径时,必须使用规范化后的真实路径与基准目录做校验。对历史系统来说,建议把下载、预览、导出、日志查看等接口统一梳理一遍,这类问题往往不是单点存在。
推荐意见