背景
任意文件读取是 Java Web 项目里比较常见、也比较容易被低估的一类问题。它不像远程命令执行那样直观,但在真实环境中,一旦能读取配置文件、日志、密钥文件或源码片段,后续风险会明显放大。
这篇文章整理的是一次代码审计中遇到的文件下载接口问题。内容重点放在审计思路、漏洞成因、复现边界和修复方式上,不涉及对第三方系统的攻击利用。类似问题在 Spring MVC、Servlet 老项目、管理后台附件下载模块里都比较常见。
问题场景
项目中有一个附件下载接口,业务设计是根据前端传入的文件名,从服务器指定目录下读取文件并返回给用户。简化后的代码如下:
@GetMapping("/download")
public void download(@RequestParam("file") String file,
HttpServletResponse response) throws IOException {
String baseDir = "/data/app/upload/";
File target = new File(baseDir + file);
response.setHeader("Content-Disposition", "attachment; filename=" + file);
response.setContentType("application/octet-stream");
try (InputStream in = new FileInputStream(target);
OutputStream out = response.getOutputStream()) {
byte[] buffer = new byte[4096];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}
}从表面看,这段代码只是下载上传目录中的文件。但它把用户输入直接拼接进本地路径,没有做路径归一化、目录边界校验、文件白名单校验,因此存在越权读取本地文件的风险。
技术分析
任意文件读取通常不是因为“读取文件”这个动作本身有问题,而是因为服务端没有限制用户可控路径最终落在哪个目录下。这里主要有几个风险点。
1. 路径拼接导致目录穿越
如果直接使用字符串拼接路径,用户输入中的相对路径片段可能改变最终文件位置。例如:
baseDir = /data/app/upload/
file = ../../config/application.yml
result = /data/app/upload/../../config/application.yml文件系统在解析路径时会处理 ..,最终访问的位置不再局限于上传目录。即使代码里判断了字符串是否以 /data/app/upload/ 开头,也不可靠,因为未归一化路径和真实路径不是一回事。
2. URL 编码与多层解码问题
有些接口会尝试过滤 ../,例如:
file = file.replace("../", "");这种处理方式很脆弱。原因包括:
- 容器或框架可能已经做过一次 URL 解码;
- 网关、应用、业务代码之间可能存在多层解码;
- 不同操作系统路径分隔符不同,例如 Windows 下还有
\; - 简单替换可能被变形路径绕过。
实际审计时,不建议把安全性建立在“替换危险字符”上,而应该基于最终规范路径做目录边界校验。
3. 文件名响应头未处理
示例代码还存在一个容易被忽略的问题:直接把用户输入放进 Content-Disposition 响应头。虽然这里主要讨论文件读取,但响应头拼接不当可能引入响应头污染、下载文件名异常等问题。至少应该对文件名做安全编码,并避免直接使用原始输入。
关键审计步骤
审计这类问题时,我一般按下面几个点走,效率会高一些。
1. 定位文件操作入口
优先搜索这些 API 或关键字:
FileInputStream
Files.readAllBytes
Files.copy
new File(
Paths.get
ResourceUtils.getFile
ServletContext.getResourceAsStream
getRealPath
download
export
preview
readFile重点关注下载、预览、导出、模板读取、日志查看、图片访问等功能。管理后台里的“日志下载”“附件预览”也经常出问题。
2. 判断路径是否用户可控
需要确认文件路径中的哪些部分来自请求参数、数据库字段、请求头、Cookie 或可被用户间接控制的配置。很多时候代码里没有直接使用请求参数,而是先根据 ID 查数据库,再取数据库中的文件路径。如果普通用户能写入这个路径字段,同样需要按用户可控处理。
3. 检查是否做了规范化和边界校验
安全的核心不是简单判断字符串,而是确认最终解析后的真实路径仍在允许目录内。推荐使用 Path 相关 API:
Path basePath = Paths.get("/data/app/upload").toRealPath();
Path targetPath = basePath.resolve(file).normalize();
if (!targetPath.startsWith(basePath)) {
throw new SecurityException("invalid file path");
}注意:如果目标文件必须存在,可以进一步使用 toRealPath() 获取真实路径,这样可以处理符号链接带来的逃逸问题。
4. 关注符号链接问题
Linux 环境下,如果上传目录内存在符号链接指向外部敏感目录,仅做 normalize() 不一定足够。比如上传目录中有一个软链接:
/data/app/upload/link -> /etc访问 link/passwd 时,字符串路径仍像是在上传目录下,但真实文件已经指向外部目录。因此对高风险接口,建议在文件存在后使用 toRealPath() 再做一次边界校验。
安全修复方案
比较稳妥的修复方式是:固定基础目录、规范化路径、校验真实路径、限制文件类型、避免直接暴露物理文件名。示例代码如下:
@GetMapping("/download")
public void download(@RequestParam("file") String file,
HttpServletResponse response) throws IOException {
Path basePath = Paths.get("/data/app/upload").toRealPath();
String safeName = Paths.get(file).getFileName().toString();
Path targetPath = basePath.resolve(safeName).normalize();
if (!targetPath.startsWith(basePath)) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST);
return;
}
if (!Files.exists(targetPath) || !Files.isRegularFile(targetPath)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
Path realTarget = targetPath.toRealPath();
if (!realTarget.startsWith(basePath)) {
response.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}
String downloadName = realTarget.getFileName().toString();
response.setContentType("application/octet-stream");
response.setHeader("Content-Disposition",
"attachment; filename=\"" + downloadName.replace("\"", "") + "\"");
Files.copy(realTarget, response.getOutputStream());
}上面的代码为了说明思路,使用了 getFileName() 丢弃目录部分,适合业务只允许按文件名下载的场景。如果业务确实需要子目录,可以不要直接取 getFileName(),但必须保留 resolve + normalize + startsWith + toRealPath 这一组校验。
更推荐的业务设计
从长期维护角度看,不建议让前端传物理文件名或相对路径。更稳的方案是使用文件 ID:
- 上传文件后生成随机文件名,避免用户可预测路径;
- 数据库记录文件 ID、真实存储名、原始文件名、所属用户、文件类型、存储目录;
- 下载时前端只传文件 ID;
- 服务端根据登录用户和权限查询文件记录;
- 由服务端拼接受控路径,不接收用户传入的路径。
这种方式不仅能降低路径穿越风险,也方便做权限控制、审计日志和文件生命周期管理。
测试与验证建议
修复后建议补充单元测试或集成测试,覆盖正常路径、非法路径、编码路径、符号链接等情况。可以设计类似下面的测试用例:
| 测试点 | 预期结果 |
|---|---|
| 正常文件名 | 允许下载 |
| 包含 ../ 的路径 | 拒绝访问 |
| 绝对路径 | 拒绝访问 |
| 不存在的文件 | 返回 404 |
| 目录路径 | 拒绝访问 |
| 软链接指向外部目录 | 拒绝访问 |
在 Linux 环境下,可以用一个临时目录模拟上传目录,再创建测试文件和软链接进行验证:
mkdir -p /tmp/upload-test
printf 'hello' > /tmp/upload-test/a.txt
ln -s /etc /tmp/upload-test/etc-link验证时只在本地测试环境操作,不要对未授权系统做路径探测。
日志与监控
这类漏洞即使修复了,也建议保留必要的安全日志。日志里可以记录用户 ID、文件 ID、客户端 IP、请求时间、拒绝原因,但不要记录完整敏感路径或文件内容。
log.warn("blocked file download, userId={}, file={}, reason={}",
userId, safeLogValue(file), "path traversal detected");如果短时间内出现大量包含 ../、绝对路径、系统配置文件名的请求,可以作为安全告警信号。生产环境里这类日志对溯源和确认影响面很有价值。
注意点
- 不要只依赖黑名单过滤,路径问题应基于规范路径和真实路径判断。
- 不要把用户传入的文件名直接放进响应头。
- 不要让普通用户控制数据库中的物理文件路径字段。
- 如果使用对象存储,也要关注对象 key 的访问权限和越权下载问题。
- 容器、网关、框架的解码行为不同,测试环境应尽量贴近生产链路。
总结
任意文件读取问题的根因通常很简单:用户输入影响了服务端文件路径,而服务端没有确认最终文件是否仍在允许范围内。修复时不要停留在替换字符串或过滤关键字,而要从路径解析规则、符号链接、权限控制和业务设计几个层面一起处理。
实际项目里,最推荐的方式还是用文件 ID 替代文件路径,把路径拼接留在服务端,并配合真实路径校验和权限校验。这样既能解决当前漏洞,也能减少后续同类问题反复出现。
推荐意见