背景
任意文件读取是 Web 应用里比较常见、也容易被低估的一类漏洞。它不像远程命令执行那样直接,但在真实事件里经常成为突破口:读取配置文件、源码、密钥、日志、备份文件后,攻击面会迅速扩大。
这篇文章整理一次 Java Web 项目代码审计中的典型问题。场景做了脱敏和简化,重点放在漏洞成因、审计方法、复现边界和修复思路上,避免涉及对真实系统的攻击利用。
问题场景
项目中有一个文件预览接口,用于给后台用户查看上传后的附件。接口大致逻辑是:前端传入文件名,后端拼接上传目录后读取文件内容并返回。
GET /admin/file/preview?name=report.pdf从业务上看,这类接口很常见,尤其是后台管理系统、工单系统、知识库系统中。但如果文件名参数没有做严格约束,就容易出现路径穿越,最终导致任意文件读取。
问题代码示例
审计时看到的代码结构大致如下:
String baseDir = uploadConfig.getBaseDir();
String name = request.getParameter("name");
File file = new File(baseDir + File.separator + name);
if (!file.exists()) {
response.setStatus(404);
return;
}
try (InputStream in = new FileInputStream(file)) {
IOUtils.copy(in, response.getOutputStream());
}这段代码的主要问题不在于使用了 FileInputStream,而在于直接信任了用户输入的 name 参数。只要参数中包含路径跳转符号,就可能脱离预期的上传目录。
技术分析
路径穿越漏洞的核心点是:应用以为自己访问的是某个受控目录下的文件,但操作系统在解析路径后,实际访问的可能是受控目录之外的文件。
例如,应用上传目录为:
/data/app/uploads如果代码直接拼接用户输入:
/data/app/uploads/../config/application.yml操作系统规范化路径后,实际访问路径可能变成:
/data/app/config/application.yml如果缺少边界校验,应用层很难意识到访问已经越界。
审计关注点
做代码审计时,我一般会优先检索以下几类代码:
new File(
FileInputStream
Files.readAllBytes
Files.copy
Paths.get
ResourceUtils.getFile
response.getOutputStream之后重点看这些 API 的路径参数来源。如果路径参数来自以下位置,就需要进一步确认是否做了安全处理:
- HTTP 请求参数,如
name、path、file、url。 - 请求头,如导出、下载、预览相关的自定义头。
- 数据库字段,尤其是用户可提交、可编辑的附件路径。
- 消息队列或异步任务参数。
- 压缩包解压后的文件名。
仅检查是否包含 ../ 并不可靠。不同系统、不同编码方式、不同路径分隔符都会带来绕过风险。更稳妥的方式是做路径规范化后再判断边界。
安全复现边界
在测试环境验证时,不建议去读取系统敏感文件。更合适的做法是在受控环境里放置一个标记文件,用于判断是否能越过上传目录。
例如测试目录结构如下:
/tmp/demo/
├── uploads/
│ └── normal.txt
└── marker.txt接口预期只能读取 /tmp/demo/uploads 下的文件。如果通过参数能读取到 /tmp/demo/marker.txt,就可以证明存在越权读取风险。
GET /admin/file/preview?name=../marker.txt这里的验证方式只针对本地或授权测试环境,不涉及真实业务系统的敏感文件,也便于后续写入测试报告和修复验证。
常见错误修复方式
这类漏洞的修复经常会出现一些看似有效、实际不稳的方案。
- 只用字符串判断
contains("../"),容易被编码、不同分隔符、重复规范化绕过。 - 只限制文件后缀,如只允许
.pdf,但路径仍可能越界。 - 只判断文件是否存在,不判断是否位于允许目录内。
- 将用户传入路径直接存入数据库,后续读取时默认可信。
- 使用黑名单过滤敏感目录,维护成本高且容易遗漏。
推荐修复思路
比较可靠的修复方式是:固定基准目录,对用户输入做白名单约束,并在路径规范化后判断是否仍在基准目录内。
Path basePath = Paths.get(uploadConfig.getBaseDir()).toRealPath();
String name = request.getParameter("name");
if (name == null || !name.matches("^[a-zA-Z0-9._-]{1,100}$")) {
response.setStatus(400);
return;
}
Path targetPath = basePath.resolve(name).normalize();
if (!targetPath.startsWith(basePath)) {
response.setStatus(403);
return;
}
if (!Files.isRegularFile(targetPath)) {
response.setStatus(404);
return;
}
try (InputStream in = Files.newInputStream(targetPath)) {
in.transferTo(response.getOutputStream());
}上面示例里有几个关键点:
basePath使用toRealPath()获取真实路径,减少符号链接等因素带来的误判。- 文件名使用白名单,只允许简单文件名,而不是任意路径。
resolve后调用normalize,再用startsWith判断是否仍在基准目录下。- 使用
Files.isRegularFile,避免读取目录、设备文件等非普通文件。
更稳的业务设计
如果业务允许,建议不要让前端直接传文件路径或文件名,而是传文件 ID。后端根据 ID 查询数据库中的附件记录,再拼接服务端保存的相对文件名。
GET /admin/file/preview?id=1024服务端流程可以调整为:
- 根据当前用户和文件 ID 查询附件记录。
- 确认该用户是否有权限访问该附件。
- 从数据库中取服务端生成的存储名,而不是使用用户提交的路径。
- 按固定目录读取文件,并进行路径边界检查。
这种方式能同时解决两个问题:路径穿越和水平越权。很多文件读取接口表面是路径问题,深入看其实还伴随权限边界不清。
日志与检测建议
修复之外,建议对异常路径访问做基础审计。比如出现以下特征时记录安全日志:
- 参数中包含
..、/、\等路径符号。 - 出现 URL 编码后的路径跳转字符。
- 访问不存在文件的频率明显异常。
- 同一账号短时间内访问大量不同文件名。
日志不要记录完整敏感路径和文件内容,重点记录用户、接口、参数摘要、客户端地址、拒绝原因即可。
log.warn("Blocked suspicious file access, userId={}, api={}, reason={}, nameHash={}",
userId, "/admin/file/preview", "path_boundary_check_failed", sha256(name));代码审计清单
| 检查项 | 说明 |
| 路径参数来源 | 确认文件路径是否来自用户输入、数据库或外部系统 |
| 是否使用白名单 | 优先允许简单文件名或文件 ID,避免任意路径 |
| 是否规范化路径 | 使用 normalize、toRealPath 等方式处理路径 |
| 是否校验目录边界 | 读取前判断目标路径是否仍在允许目录内 |
| 是否校验权限 | 确认当前用户是否有权访问目标文件 |
| 是否限制文件类型 | 结合业务限制后缀、MIME、大小等属性 |
| 是否记录异常访问 | 对疑似探测行为进行安全审计 |
注意点
- Windows 和 Linux 的路径分隔符不同,审计时不要只考虑一种系统。
- 压缩包解压也会遇到类似问题,俗称 Zip Slip,需要对压缩包内文件名做同样的边界检查。
- 如果上传目录中允许符号链接,需要特别谨慎,避免链接指向目录外。
- 下载、预览、导出、模板读取、日志查看接口都属于高频风险点。
- 修复后要补充单元测试,覆盖正常文件名、路径跳转、空值、超长文件名、特殊字符等情况。
总结
任意文件读取漏洞的根因通常不是某个 API 本身危险,而是路径边界没有定义清楚。只要接口允许用户影响文件路径,就应该默认存在风险,并通过白名单、路径规范化、目录边界校验和权限控制来收敛。
在代码审计中,这类问题值得长期关注。它复现成本低、影响面容易扩大,且经常隐藏在后台功能、运维功能和文件预览功能里。修复时不要停留在过滤字符串,应该从业务设计和路径模型上把边界收紧。
推荐意见