背景
任意文件读取是 Java Web 项目里比较常见、也比较容易被低估的一类问题。它不一定直接导致系统被控制,但如果能读取到配置文件、密钥、日志、源码或中间件敏感文件,往往会成为后续攻击链的起点。
这类问题在代码审计中经常出现在下载接口、模板预览、日志查看、附件读取、图片代理、静态资源转发等功能里。表面看只是一个文件路径参数,实际风险取决于路径拼接、权限校验、目录限制和响应处理是否严谨。
本文整理一套偏实战的审计与复现思路,重点放在如何识别风险、如何安全验证、如何修复。示例均为本地靶场或脱敏代码,不涉及真实目标攻击。
典型场景
先看一个在业务系统中很常见的下载接口:
@GetMapping("/download")
public void download(String file, HttpServletResponse response) throws IOException {
String baseDir = "/data/app/uploads/";
File target = new File(baseDir + file);
response.setHeader("Content-Disposition", "attachment; filename=" + target.getName());
Files.copy(target.toPath(), response.getOutputStream());
}这段代码的问题不在于使用了文件下载功能,而在于它直接把用户输入拼接进了文件路径,没有做目录约束。攻击者如果传入类似目录穿越的路径,就可能访问上传目录之外的文件。
在真实项目里,这种代码可能更隐蔽,例如:
- 路径参数名不是 file,而是 path、name、url、resource、template、logName。
- 下载逻辑封装在工具类里,Controller 只传了一个字符串。
- 使用 Nginx、对象存储、NAS、本地磁盘混合存储,导致路径来源复杂。
- 开发者做了简单 replace,但没有处理编码、符号链接、路径规范化等问题。
风险形成原因
从代码角度看,任意文件读取通常由以下几个条件叠加形成:
- 用户可控输入进入文件路径。
- 服务端使用字符串拼接构造文件路径。
- 缺少固定根目录限制,或者限制方式不可靠。
- 读取前没有做权限判断和文件类型判断。
- 错误信息或下载响应暴露了文件是否存在、大小、内容等信息。
其中最核心的是“路径规范化前后不一致”。很多修复只做了字符串层面的黑名单过滤,比如禁止出现 ../,但文件系统解析路径时会处理分隔符、软链接、重复斜杠、URL 编码等情况,导致过滤看起来存在,实际仍可能被绕过。
代码审计关注点
审计 Java Web 任意文件读取时,我一般按数据流来找,不会只搜一个关键词。比较有效的入口包括:
// Spring MVC 常见入口
@GetMapping
@PostMapping
@RequestMapping
@RequestParam
@PathVariable
// Servlet 入口
request.getParameter
request.getInputStream
request.getHeader
// 文件读取相关 API
new File
Paths.get
Files.readAllBytes
Files.copy
FileInputStream
ResourceUtils.getFile
ClassPathResource
ServletContext.getResourceAsStream发现用户输入后,需要继续追踪它是否进入以下位置:
- 本地文件路径:
new File(userInput)、Paths.get(userInput)。 - 相对路径拼接:
baseDir + userInput。 - 日志读取:按日期或文件名读取日志。
- 模板加载:根据参数选择模板文件。
- 静态资源代理:把参数映射到服务器文件。
- 压缩包预览或解压后读取:ZIP 内路径也可能存在穿越问题。
值得注意的是,有些项目表面上不直接返回文件内容,而是先读入字符串再做预览、解析或缓存。这种也需要纳入审计范围。
安全复现方法
复现时建议只在本地环境、测试环境或明确授权范围内进行。为了避免触碰敏感数据,可以先在临时目录准备一个测试文件,然后验证接口是否能越过预期目录读取它。
例如本地准备目录结构:
/tmp/demo-app/
├── uploads/
│ └── public.txt
└── secret.txt启动应用时将下载根目录设置为:
/tmp/demo-app/uploads/正常请求只应读取 uploads 目录下的文件:
GET /download?file=public.txt如果通过相对路径能够读取到 uploads 之外的测试文件,就说明目录约束失败:
GET /download?file=../secret.txt这里不建议使用系统敏感文件作为验证目标。用自己创建的测试文件即可证明漏洞存在,也便于写报告和复现记录。
常见但不可靠的修复方式
一些代码看似做了限制,但仍然不够安全。例如:
if (file.contains("../")) {
throw new RuntimeException("invalid path");
}这类黑名单方式的问题是只覆盖了某一种表现形式。实际路径可能包含不同分隔符、重复编码、绝对路径、软链接或平台差异。还有一些代码只判断后缀:
if (!file.endsWith(".jpg")) {
throw new RuntimeException("invalid file type");
}后缀判断可以作为业务限制的一部分,但不能替代目录限制。尤其是当服务端实际读取路径已经逃逸到根目录外时,后缀限制并不能保证安全。
推荐修复方案
比较稳妥的修复思路是:固定根目录、路径规范化、校验规范化后的真实路径仍在根目录下,并尽量使用文件 ID 映射而不是直接暴露文件名。
public void safeDownload(String fileName, HttpServletResponse response) throws IOException {
Path basePath = Paths.get("/data/app/uploads").toRealPath(LinkOption.NOFOLLOW_LINKS);
Path targetPath = basePath.resolve(fileName).normalize();
if (!targetPath.startsWith(basePath)) {
throw new SecurityException("invalid file path");
}
if (!Files.isRegularFile(targetPath, LinkOption.NOFOLLOW_LINKS)) {
throw new FileNotFoundException("file not found");
}
String safeName = targetPath.getFileName().toString();
response.setHeader("Content-Disposition", "attachment; filename=\"" + safeName + "\"");
response.setContentType("application/octet-stream");
Files.copy(targetPath, response.getOutputStream());
}这段代码有几个关键点:
basePath使用toRealPath获取真实路径。- 用户输入通过
resolve拼接,不直接字符串相加。 normalize消除路径中的.和..。- 使用
startsWith判断最终路径是否仍在根目录下。 - 用
Files.isRegularFile限制只能读取普通文件。
如果业务允许,最好进一步改造成基于文件 ID 的访问方式。数据库里保存文件 ID、真实路径、所属用户、状态、哈希等信息,接口只接收 ID,然后在服务端查询路径并做权限校验。
GET /download?id=12345这种方式能明显降低路径参数直接暴露带来的风险,也方便做审计、鉴权和生命周期管理。
权限控制不能省
很多任意文件读取漏洞的根源不只是路径问题,还包括“知道文件名就能下载”。例如用户 A 上传的附件,用户 B 只要猜到路径或 ID 就能读取。
因此下载接口至少应检查:
- 当前用户是否登录。
- 文件是否属于当前用户或当前业务对象。
- 当前业务对象状态是否允许下载。
- 管理员、普通用户、外部访客是否有不同权限边界。
- 临时链接是否有过期时间和签名校验。
路径安全解决的是“能不能读到目录外文件”,权限控制解决的是“能不能读到不属于自己的文件”。两者不能互相替代。
日志与告警建议
这类漏洞在修复后,建议补充必要的安全日志。日志不需要记录完整敏感路径,但应能帮助定位异常行为。
log.warn("invalid download path, userId={}, input={}, ip={}",
userId,
sanitizeForLog(fileName),
request.getRemoteAddr());可以关注以下异常特征:
- 路径参数中出现
..、反斜杠、URL 编码特征。 - 短时间内大量请求不存在的文件。
- 访问大量不同文件名或不同日期日志。
- 下载接口返回 4xx/5xx 突增。
需要注意,日志里不要直接写入过长的原始用户输入,避免日志注入和敏感信息落盘。可以截断、转义后再记录。
代码审计清单
| 检查项 | 说明 |
| 路径是否用户可控 | 包括参数、请求头、JSON 字段、数据库中可被用户影响的字段 |
| 是否字符串拼接路径 | 重点关注 baseDir + name 这类写法 |
| 是否规范化后校验 | 校验应发生在 resolve、normalize、realPath 之后 |
| 是否限制根目录 | 最终路径必须位于允许目录内 |
| 是否处理软链接 | 必要时使用 NOFOLLOW_LINKS,避免链接逃逸 |
| 是否有权限判断 | 不能只依赖文件名或路径 |
| 是否暴露详细错误 | 避免返回服务器绝对路径和堆栈信息 |
| 是否记录异常访问 | 便于后续排查和告警 |
注意点
- Windows 和 Linux 的路径分隔符不同,跨平台项目要同时考虑
/和\。 - URL 解码可能发生在框架层,也可能在业务层重复发生,审计时要确认最终进入文件 API 的字符串。
- 如果上传目录允许用户创建压缩包或软链接,要额外关注解压和链接处理。
- 不要把“文件名后缀白名单”当成唯一安全措施。
- 异常响应应统一处理,不要把绝对路径、环境变量、堆栈直接返回给前端。
总结
任意文件读取漏洞看起来是一个简单的路径问题,实际往往牵涉输入来源、路径解析、权限模型、存储架构和日志响应。审计时不要只看是否过滤了 ../,而要看最终文件系统访问点是否被可靠地限制在预期目录内。
比较推荐的落地方案是:业务层使用文件 ID,服务端查询真实路径;文件层使用 Path.resolve、normalize、toRealPath 做目录约束;权限层按用户和业务对象校验访问资格;运维层记录异常下载行为。这样即使某一层出现疏漏,也不至于直接扩大成高风险漏洞。
复现和验证建议始终放在本地或授权测试环境中完成。用自建测试文件证明问题即可,没必要读取真实系统敏感文件。
推荐意见