背景
最近在做一批内部系统的安全巡检时,遇到一个比较典型的告警:某个 Java Web 服务的下载接口存在任意文件读取风险。这个问题本身不算新,但在真实业务里仍然很常见,尤其是历史系统、报表下载、附件预览、日志导出这类功能。
这篇文章不写攻击利用细节,主要从代码审计和修复角度整理一下:这类问题为什么出现、怎么判断风险边界、如何做可靠修复,以及在排查类似问题时哪些点容易漏。
问题场景
业务里常见的文件下载接口大概是这样的逻辑:前端传入文件名或相对路径,后端拼接到固定目录下,然后读取文件并返回给浏览器。
GET /download?file=report/2024-01.xlsx如果后端只做了简单字符串拼接,没有对路径做规范化和边界校验,就可能导致用户读取到预期目录之外的文件。
一个简化后的问题代码如下:
String baseDir = "/data/app/files/";
String file = request.getParameter("file");
File target = new File(baseDir + file);
if (!target.exists()) {
response.setStatus(404);
return;
}
try (InputStream in = new FileInputStream(target);
OutputStream out = response.getOutputStream()) {
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}这段代码的问题并不在于 FileInputStream 本身,而是“用户输入直接参与文件路径拼接”。只要路径没有被限制在业务允许的目录内,风险就会扩大。
技术分析
任意文件读取的核心风险点通常有几个:
- 用户可控参数进入文件路径。
- 后端使用字符串拼接构造路径。
- 没有做 canonical path 或 real path 校验。
- 仅依赖黑名单过滤特殊字符。
- 下载目录中存在软链接,导致边界被绕出。
- 错误信息过于详细,泄露本地路径结构。
很多修复会停留在过滤 ../ 这一步,但这通常不够。不同操作系统、不同框架、不同编码处理方式下,单纯过滤字符串很容易出现遗漏。更稳妥的方式是:先解析真实路径,再判断它是否仍然位于允许的根目录内。
例如 Java 中可以使用 Path 和 normalize 做第一层规范化,再结合 toRealPath 判断最终路径。
Path basePath = Paths.get("/data/app/files").toRealPath();
String file = request.getParameter("file");
Path targetPath = basePath.resolve(file).normalize();
if (!targetPath.startsWith(basePath)) {
response.setStatus(403);
return;
}
if (!Files.exists(targetPath) || !Files.isRegularFile(targetPath)) {
response.setStatus(404);
return;
}这里有两个点需要注意:
normalize()只是处理路径中的冗余部分,不会访问文件系统。toRealPath()会解析真实路径,包括符号链接,因此更适合做最终边界判断。
如果系统允许下载目录中存在软链接,仅使用 normalize() 可能仍然不够。更严格的写法可以在目标文件存在后,对目标路径也取真实路径再判断。
Path basePath = Paths.get("/data/app/files").toRealPath();
String file = request.getParameter("file");
Path candidate = basePath.resolve(file).normalize();
if (!Files.exists(candidate) || !Files.isRegularFile(candidate)) {
response.setStatus(404);
return;
}
Path realTarget = candidate.toRealPath();
if (!realTarget.startsWith(basePath)) {
response.setStatus(403);
return;
}代码审计关注点
在审计这类问题时,我一般会先从接口功能入手,而不是一开始就全局搜危险函数。下面这些功能点值得重点看:
- 附件下载、图片预览、报表导出。
- 日志查看、日志下载、错误文件导出。
- 模板读取、静态资源代理。
- 文件上传后的回显、预览、转码。
- 运维后台中的备份文件下载。
然后再结合代码特征做搜索:
new FileInputStream(
Files.readAllBytes(
Files.newInputStream(
ResourceUtils.getFile(
ServletContext.getResourceAsStream(
response.getOutputStream()搜索时不要只看读取函数,还要向上追溯参数来源。重点判断以下几个问题:
- 文件名是否来自请求参数、请求头、Cookie 或数据库字段。
- 数据库里的路径是否可能由普通用户写入。
- 路径拼接前是否做了白名单映射。
- 是否存在“先校验后替换”的顺序问题。
- 是否对压缩包解压后的文件路径做了边界检查。
更推荐的设计方式
如果业务允许,最推荐的设计不是让前端传路径,而是传文件 ID。后端根据文件 ID 到数据库查找存储位置,并且存储位置由服务端生成。
| 方案 | 风险 | 建议 |
|---|---|---|
| 前端传完整路径 | 高 | 不建议 |
| 前端传相对路径 | 中 | 必须做真实路径边界校验 |
| 前端传文件名 | 中 | 限制字符集并映射目录 |
| 前端传文件 ID | 低 | 推荐 |
文件 ID 方案的后端逻辑大致如下:
String fileId = request.getParameter("id");
FileMeta meta = fileService.getById(fileId);
if (meta == null || !permissionService.canDownload(user, meta)) {
response.setStatus(403);
return;
}
Path basePath = Paths.get("/data/app/files").toRealPath();
Path target = basePath.resolve(meta.getStorageName()).normalize().toRealPath();
if (!target.startsWith(basePath)) {
response.setStatus(403);
return;
}这里还可以顺手解决权限问题。很多文件读取漏洞并不是单点路径问题,而是“路径可控 + 缺少鉴权 + 文件命名可预测”叠加出来的。
修复时容易踩的坑
- 只过滤 ../:容易漏掉编码、路径分隔符差异、重复规范化等问题。
- 只判断字符串前缀:例如
/data/app/files_bak也可能以/data/app/files开头,建议使用 Path 的 startsWith。 - 忽略软链接:上传目录或共享目录中如果存在软链接,需要使用真实路径判断。
- 错误信息过细:不要把服务器绝对路径、异常堆栈直接返回给前端。
- 缺少权限校验:即使路径安全,也要确认当前用户是否有权下载该文件。
- 下载文件名未处理:响应头中的文件名要避免换行等特殊字符影响 Header。
响应头里的文件名也建议做最小化处理。例如只允许常见字符,或者使用服务端保存的安全展示名。
String displayName = meta.getDisplayName();
displayName = displayName.replaceAll("[\\r\\n]", "_");
response.setHeader("Content-Disposition", "attachment; filename=\"" + displayName + "\"");验证与回归测试
修复后建议补充单元测试或接口测试。测试重点不是证明某个特定 payload 可用,而是验证边界策略是否稳定。
@Test
void shouldRejectPathOutsideBaseDir() throws Exception {
Path base = Paths.get("/tmp/app/files").toRealPath();
Path target = base.resolve("../secret.txt").normalize();
assertFalse(target.startsWith(base));
}在实际项目里,可以补充以下测试用例:
- 正常文件下载成功。
- 不存在的文件返回 404。
- 目录路径不能被下载。
- 跨目录路径被拒绝。
- 软链接指向外部目录时被拒绝。
- 无权限用户无法下载他人文件。
如果系统使用 Nginx 或对象存储做文件分发,也要检查代理层是否引入新的路径穿越问题。例如使用 alias 时要特别注意 location 末尾斜杠和实际路径的对应关系。
location /files/ {
alias /data/app/files/;
}这类配置本身没问题,但如果上游把用户输入直接拼进内部跳转地址,仍然需要做同样的边界控制。
日志与监控建议
文件读取类问题在修复前后都建议加一些审计日志,方便发现异常访问趋势。日志不需要记录完整敏感路径,但至少应包含:
- 用户 ID 或会话标识。
- 请求的文件 ID 或相对文件名。
- 校验结果。
- 客户端 IP 和 User-Agent。
- 失败原因分类,例如越权、文件不存在、路径非法。
注意不要把敏感文件内容、完整系统路径写入日志。日志本身也是数据资产,很多二次泄露都来自过度记录。
总结
任意文件读取问题看起来是老问题,但在业务代码里依然高频出现。根因通常不是开发不知道风险,而是早期实现为了方便直接把用户输入当路径使用,后续功能不断叠加,风险边界逐渐模糊。
比较稳妥的处理思路是:前端尽量传文件 ID,不传路径;后端统一做权限校验;文件路径使用真实路径边界判断;错误信息保持克制;修复后补充覆盖边界场景的测试。这样处理后,不只是修一个漏洞,也能把同类下载、预览、导出功能的风险整体降下来。
推荐意见