背景
任意文件读取在 Java Web 系统里并不算新问题,但实际项目中仍然很常见。尤其是后台导出、附件下载、模板预览、日志查看这类功能,一旦路径拼接和权限控制做得粗糙,就容易把本地敏感文件暴露出去。
这篇文章整理一次内部测试中遇到的文件读取问题。内容会偏向漏洞成因、审计方法和修复建议,不涉及未授权利用或真实目标攻击。类似思路也适用于日常代码审计和安全测试自查。
问题场景
某 Java Web 系统提供了一个文件下载接口,用于下载用户上传的附件。接口大致逻辑是:前端传入文件名,后端拼接上传目录,然后通过流返回文件内容。
简化后的代码类似下面这样:
@GetMapping("/download")
public void download(@RequestParam String fileName, HttpServletResponse response) throws IOException {
String baseDir = "/data/app/upload/";
File file = new File(baseDir + fileName);
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);
}
}
}从功能上看,这段代码没什么复杂逻辑,但安全问题比较明显:用户输入的 fileName 直接参与了本地路径构造,没有做规范化校验,也没有限制最终访问路径必须位于上传目录下。
技术分析
这类问题的核心不是“能不能拼出某个路径”,而是后端是否把用户输入当作可信路径使用。
Java 里常见的风险点包括:
- 使用字符串拼接构造文件路径,例如
baseDir + fileName。 - 只过滤了简单的
../,没有处理编码、反斜杠、重复分隔符等情况。 - 校验发生在路径规范化之前,导致检查对象和实际访问对象不一致。
- 仅依赖文件后缀判断是否允许下载,忽略了路径逃逸问题。
- 下载接口缺少资源归属校验,用户可读取其他用户上传文件。
在代码审计时,可以重点搜索下面几类 API:
new File(...)
new FileInputStream(...)
Files.readAllBytes(...)
Files.copy(...)
Paths.get(...)
ResourceUtils.getFile(...)
ServletContext.getResourceAsStream(...)如果这些 API 的参数中包含来自请求参数、Header、Cookie、数据库字段或可控配置的内容,就需要继续确认是否做了严格限制。
关键审计步骤
1. 确认输入来源
首先要看路径参数来自哪里。常见入口包括:
@RequestParam:如 fileName、path、url、template、logName。@PathVariable:如/download/{name}。- JSON 请求体:如导出任务、报表模板、文件预览参数。
- 数据库字段:例如附件表里保存的文件路径,如果该字段可被用户间接写入,也要视为不可信。
一个容易忽略的点是:很多项目会认为“文件路径来自数据库,所以可信”。但如果路径最初由上传接口、导入接口或第三方回调写入,仍然可能被污染。
2. 检查路径拼接方式
不推荐使用字符串直接拼接路径。即使使用 Paths.get(baseDir, fileName),也不代表天然安全,因为如果 fileName 中存在上级目录跳转,最终路径仍可能逃逸。
风险写法示例:
Path path = Paths.get(baseDir, fileName);
byte[] data = Files.readAllBytes(path);相对更合理的做法是先解析出目标路径,再做规范化和目录边界判断:
Path basePath = Paths.get("/data/app/upload").toRealPath();
Path targetPath = basePath.resolve(fileName).normalize();
if (!targetPath.startsWith(basePath)) {
throw new SecurityException("invalid file path");
}
if (!Files.isRegularFile(targetPath)) {
throw new FileNotFoundException("file not found");
}这里的关键是:最终使用的路径必须经过 normalize() 后再和可信根目录比较。生产环境中建议使用 toRealPath() 处理符号链接场景,但要注意文件不存在时该方法会抛异常,可以根据业务调整。
3. 关注符号链接问题
在 Linux 环境里,如果上传目录下存在符号链接,单纯判断 startsWith(basePath) 可能还不够。比如目标文件表面上在上传目录下,但实际指向目录外部。
对于高风险下载功能,建议在文件存在后再获取真实路径:
Path basePath = Paths.get("/data/app/upload").toRealPath();
Path targetPath = basePath.resolve(fileName).normalize();
if (!targetPath.startsWith(basePath)) {
throw new SecurityException("invalid file path");
}
Path realTarget = targetPath.toRealPath();
if (!realTarget.startsWith(basePath)) {
throw new SecurityException("symlink is not allowed");
}如果业务不需要符号链接,部署层面也应避免上传目录中出现软链接,并限制应用进程对敏感目录的读取权限。
4. 不要只做黑名单过滤
很多修复会写成这样:
if (fileName.contains("../") || fileName.contains("..\\")) {
throw new RuntimeException("invalid filename");
}这种方式维护成本高,也容易漏掉编码、重复分隔符、不同系统路径差异等情况。更推荐白名单策略:如果接口只允许下载附件,就只接收附件 ID,而不是接收任意文件名。
例如:
@GetMapping("/download")
public void download(@RequestParam Long fileId, Principal principal, HttpServletResponse response) {
Attachment attachment = attachmentService.findById(fileId);
if (attachment == null) {
throw new NotFoundException();
}
if (!attachmentService.canAccess(principal.getName(), attachment)) {
throw new AccessDeniedException("no permission");
}
Path basePath = Paths.get(uploadBaseDir).toRealPath();
Path targetPath = basePath.resolve(attachment.getStorageName()).normalize();
// 后续继续做 startsWith、isRegularFile、Content-Type 等校验
}这种方式把外部可控输入从“路径”降级为“资源标识”,安全边界会清晰很多。
5. 检查权限控制是否缺失
任意文件读取不一定只表现为读取系统文件,更多时候是越权读取业务文件。例如用户 A 通过修改 fileId 下载用户 B 的合同、报表或工单附件。
审计时建议把下载类接口分为两层检查:
- 路径安全:最终文件是否限制在指定目录内。
- 业务权限:当前用户是否有权访问这个文件对应的业务对象。
如果只修路径,不修权限,漏洞仍然可能以水平越权的形式存在。
复现与验证建议
在授权测试环境中,可以从以下角度验证修复是否有效:
- 传入正常文件名,确认功能可用。
- 传入包含上级目录跳转的路径,确认被拒绝。
- 传入绝对路径,确认不会被直接读取。
- 传入不存在文件,确认返回统一错误,不暴露本地路径。
- 构造其他用户的附件 ID,确认权限校验生效。
- 上传目录存在符号链接时,确认不会跟随到目录外。
接口层面可以用普通的 HTTP 客户端验证,不需要引入复杂工具。例如在本地测试环境中:
curl -i "
curl -i " ID、IP、请求接口。原始文件参数或资源 ID。 规范化后的相对路径。 拒绝原因,例如越权、路径逃逸、文件不存在。 可以对异常访问建立简单告警规则,例如短时间内大量请求下载接口、文件参数中频繁出现路径分隔符、连续访问不存在资源等。
修复建议汇总
问题点 建议 直接接收文件路径 改为接收文件 ID,通过数据库映射真实存储名 字符串拼接路径 使用 Path API,并进行 normalize 和边界校验 缺少目录限制 最终路径必须位于可信根目录内 忽略软链接 必要时使用 toRealPath 校验真实路径 只校验路径不校验权限 增加资源归属和业务权限判断 错误信息过细 对外返回统一错误,对内记录详细日志
注意点
- Windows 和 Linux 的路径分隔符不同,审计时不要只考虑一种环境。
- 不要依赖前端隐藏参数或下拉框限制,后端必须独立校验。
- 下载文件名用于响应头时,也要避免换行等特殊字符造成响应头污染。
- 上传目录、日志目录、配置目录应尽量分离,应用进程权限遵循最小化原则。
- 修复后要补充单元测试,覆盖正常下载、路径逃逸、越权访问、软链接等场景。
总结
任意文件读取的根因通常不是某个 API 危险,而是边界没有定义清楚:用户到底能访问哪些文件,后端是否把这个边界落实到了路径和权限校验上。
实际审计时,我一般按“输入来源、路径构造、规范化校验、真实路径、业务权限、错误回显”这条线排查。修复上优先把外部输入从路径改为资源 ID,再配合可信根目录校验和最小权限部署。这样比简单过滤字符串稳定得多,也更适合长期维护。

代码审计、调用链与关键函数定位示意
推荐意见