背景
任意文件读取是 Java Web 项目里比较常见、但经常被低估的一类漏洞。它不像命令执行那样直观,也不一定能直接拿到权限,但在真实环境中,如果能读取配置文件、日志、密钥、源码或运行时信息,后续风险会被迅速放大。
这篇文章整理一次代码审计中的典型定位过程,重点放在漏洞成因、审计思路、复现边界和修复方式。内容只讨论授权测试和自查场景,不涉及未授权目标的攻击利用。
问题场景
某 Java Web 系统提供了文件预览和下载功能,接口大致用于读取服务器上的附件文件。业务侧最初的设计是:前端传入文件路径或文件名,后端读取对应文件并返回给浏览器。
常见接口形态类似下面几种:
GET /file/download?path=/upload/2024/report.pdf
GET /preview?fileName=report.pdf
POST /api/file/read {"file":"/upload/demo.txt"}如果后端直接信任用户输入,并把参数拼接到本地文件路径中,就容易出现目录穿越和任意文件读取问题。
漏洞成因分析
审计时看到的风险代码通常有几个明显特征:
- 用户可控参数参与文件路径拼接。
- 没有限制读取目录的根路径。
- 只做了简单字符串过滤,例如替换一次
../。 - 没有使用规范化路径校验。
- 文件下载接口缺少权限校验。
一个简化后的风险示例如下:
String filePath = request.getParameter("path");
File file = new File(filePath);
if (!file.exists()) {
response.setStatus(404);
return;
}
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);
}
}这类代码的问题不是单一的“没有过滤特殊字符”,而是整体信任边界错了:服务端把客户端传入的路径当成了可信路径。
审计定位思路
实际代码审计时,不建议只搜索某一个固定接口名,可以从文件操作 API 和响应输出点反向追踪。比较有效的搜索关键字包括:
new File(
FileInputStream
Files.readAllBytes
Files.newInputStream
ResourceUtils.getFile
getResourceAsStream
response.getOutputStream
setHeader("Content-Disposition"定位到文件读取代码后,重点看三件事:
- 路径来源:是否来自 request 参数、JSON 字段、Header、Cookie 或数据库中可被用户间接控制的字段。
- 路径处理:是否进行了 canonical path 或 normalize 后的边界校验。
- 权限模型:是否校验当前用户是否有权限访问该文件。
很多系统会把路径先存入数据库,然后下载接口根据文件 ID 查询路径。表面上看下载接口只接收 ID,风险较低,但如果上传、导入、同步任务等流程能写入异常路径,仍可能形成二次触发。
安全复现边界
在授权测试环境中,建议使用自建的临时目录和测试文件验证,不要读取系统敏感文件。这样既能证明漏洞存在,也能避免越界。
例如在测试环境准备目录:
/tmp/app-files/
public.txt
/tmp/app-secret/
internal.txt如果业务设计只允许读取 /tmp/app-files/ 下的文件,那么测试目标应该是验证是否能越过该目录读取 /tmp/app-secret/internal.txt 这类自建文件。
测试用例可以围绕以下方向设计:
- 正常文件读取:确认业务基线。
- 相对路径穿越:验证是否存在目录跳转。
- URL 编码路径:验证过滤是否只处理明文。
- 多层路径组合:验证 normalize 后是否仍在允许目录内。
- 符号链接场景:验证是否能通过软链接跳出根目录。
这里不建议把系统真实敏感路径作为验证目标。对企业内部测试来说,一份可控的测试文件已经足够证明风险。
常见错误修复方式
实际项目里经常看到一些“看起来修了,但没有真正解决”的写法。
1. 只替换一次 ../
String path = request.getParameter("path");
path = path.replace("../", "");这种方式很脆弱,编码、变形、路径分隔符差异、多次嵌套都可能绕过。更重要的是,它没有建立“允许访问范围”的概念。
2. 只判断是否包含敏感关键词
if (path.contains("passwd") || path.contains("shadow")) {
throw new SecurityException("invalid file");
}黑名单很难覆盖所有系统、所有部署环境、所有敏感文件类型。配置文件、日志、备份、密钥、临时文件都可能包含高价值信息。
3. 只限制后缀名
if (!path.endsWith(".pdf")) {
throw new SecurityException("invalid suffix");
}后缀限制可以作为辅助控制,但不能替代路径边界校验。特别是存在软链接、上传文件名可控、历史文件迁移等场景时,单纯后缀判断并不可靠。
推荐修复方案
比较稳妥的做法是:服务端维护固定的文件根目录,用户只能传文件 ID 或相对文件名;后端对路径进行规范化后,确认最终路径仍位于允许目录内。
Path baseDir = Paths.get("/opt/app/uploads").toRealPath();
String userInput = request.getParameter("fileName");
if (userInput == null || userInput.trim().isEmpty()) {
response.setStatus(400);
return;
}
Path target = baseDir.resolve(userInput).normalize();
if (!target.startsWith(baseDir)) {
response.setStatus(403);
return;
}
if (!Files.isRegularFile(target)) {
response.setStatus(404);
return;
}
try (InputStream in = Files.newInputStream(target);
OutputStream out = response.getOutputStream()) {
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}如果业务允许使用软链接,还需要谨慎处理。多数情况下,下载目录不建议放置指向外部敏感目录的软链接。如果要更严格,可以对目标路径使用 toRealPath() 后再判断:
Path baseDir = Paths.get("/opt/app/uploads").toRealPath();
Path target = baseDir.resolve(userInput).normalize();
if (!Files.exists(target)) {
response.setStatus(404);
return;
}
Path realTarget = target.toRealPath();
if (!realTarget.startsWith(baseDir)) {
response.setStatus(403);
return;
}更好的接口设计
从长期维护角度看,不建议让前端传真实路径。更推荐的设计是:上传文件后生成文件 ID,数据库记录真实存储路径,下载时只允许传 ID。
GET /file/download?id=8721后端流程可以是:
- 根据文件 ID 查询文件记录。
- 校验当前用户是否属于该文件的拥有者、项目成员或授权角色。
- 从数据库读取服务端保存的相对路径。
- 拼接固定根目录并做 normalize 校验。
- 返回文件流,避免暴露服务器真实路径。
即便使用文件 ID,也不能省略路径规范化。数据库中的路径同样可能因为导入脚本、历史数据、后台配置或其他漏洞变成不可信输入。
日志与告警建议
文件读取类漏洞适合做轻量级审计日志。建议记录以下字段:
- 用户 ID、角色、来源 IP。
- 请求接口和文件 ID。
- 最终解析后的相对路径。
- 拒绝原因,例如越界、文件不存在、权限不足。
- 请求时间和请求频率。
需要注意,日志中不要直接打印完整本地敏感路径,也不要把异常堆栈原样返回给前端。前端统一返回通用错误信息,详细原因保留在服务端安全日志即可。
代码审计检查清单
| 检查项 | 说明 |
|---|---|
| 用户输入是否进入文件 API | 重点关注 request 参数、JSON 字段、Header、数据库字段 |
| 是否固定根目录 | 所有下载、预览、导出接口都应限制在业务目录内 |
| 是否做规范化校验 | 使用 normalize、toRealPath 等方式确认最终路径边界 |
| 是否有权限校验 | 不能只依赖“知道文件名才能下载” |
| 是否处理软链接 | 确认软链接不会跳出允许目录 |
| 是否避免返回敏感异常 | 错误信息不要泄露本地路径、框架栈、部署结构 |
注意点
- Windows 和 Linux 路径分隔符不同,审计时要同时考虑
/和\。 - URL 编码、双重编码、Unicode 变体可能影响简单过滤逻辑。
- 文件预览、导出、日志下载、模板下载都属于高频风险点。
- Nginx、对象存储、应用本地磁盘混用时,要明确每一层的访问边界。
- 测试环境应使用自建文件验证,不要读取真实敏感文件。
总结
任意文件读取漏洞的核心不是某个特殊字符,而是服务端没有正确建立文件访问边界。修复时不要依赖黑名单过滤,应该从接口设计、路径规范化、权限校验和日志审计四个层面一起处理。
对代码审计来说,抓住“用户输入到文件 API”这条数据流,再结合固定根目录和权限模型检查,通常能比较高效地发现问题。对开发团队来说,最稳妥的方式是让前端只传文件 ID,真实路径始终由服务端控制,并在最终读取前做一次严格的路径边界校验。
推荐意见