背景
任意文件读取在 Java Web 项目里并不少见,尤其是下载接口、日志查看接口、静态资源代理接口、模板预览接口这几类功能。它的危害取决于部署环境和可访问文件范围:轻则泄露配置文件、日志和源码片段,重则暴露数据库口令、密钥、Token 或内部服务地址。
这篇文章整理一个偏通用的审计思路,场景来自日常代码审计中反复遇到的一类问题:业务希望通过参数传入文件名,然后由后端读取指定目录下的文件并返回给用户。问题往往出在“看起来做了目录限制”,但限制方式并不可靠。
本文只讨论代码审计、漏洞复现验证和修复方式,不涉及对真实第三方目标的攻击利用。示例代码均为简化后的本地靶场代码。
典型场景
一个常见的下载接口大概长这样:
@GetMapping("/download")
public void download(@RequestParam String file,
HttpServletResponse response) throws IOException {
String baseDir = "/data/app/upload/";
File target = new File(baseDir + file);
response.setHeader("Content-Disposition", "attachment; filename=" + target.getName());
Files.copy(target.toPath(), response.getOutputStream());
}开发者的本意是只允许下载 /data/app/upload/ 目录下的文件,但这里直接拼接用户输入,攻击者可以通过路径穿越访问上级目录。例如在本地测试环境中,如果请求参数传入:
../../../../etc/hosts实际读取路径就可能变成:
/data/app/upload/../../../../etc/hosts文件系统解析后会落到 /etc/hosts。在 Windows 环境下,也可能出现类似 ..\..\、盘符路径、UNC 路径等差异。
问题不只在 ../
很多修复方案会简单做字符串过滤,例如禁止 ../:
if (file.contains("../")) {
throw new IllegalArgumentException("invalid file");
}这种方式并不稳。原因有几个:
- 不同系统路径分隔符不同,Windows 下
..\同样有效。 - 输入可能经过 URL 编码、多次编码、Unicode 变体等处理。
- 路径中可能包含重复分隔符、当前目录
./、符号链接等情况。 - 只看字符串不看规范化后的真实路径,容易出现判断与实际访问不一致。
更可靠的方式是:先把用户输入解析成路径,再做规范化,最后确认规范化后的目标路径仍然位于允许的基础目录内。
审计时关注的代码特征
实际审计时,我通常先全局搜索以下关键词:
FileInputStream
Files.readAllBytes
Files.copy
new File
Paths.get
ResourceUtils.getFile
ClassPathResource
ServletOutputStream
Content-Disposition
/download
/export
/preview
/log找到文件读取点后,再沿着参数来源反查:参数是否来自 HTTP 请求、Header、Cookie、数据库字段、消息队列或配置中心。如果最终可被低权限用户影响,就需要继续判断路径约束是否可靠。
常见危险写法包括:
// 直接拼接
new File(baseDir + filename)
// 只判断前缀字符串
if (!path.startsWith(baseDir)) {
throw new RuntimeException("invalid path");
}
// 只过滤少数字符
filename = filename.replace("../", "");
// 使用原始路径判断,使用另一个路径读取
if (filename.endsWith(".pdf")) {
Files.copy(Paths.get(baseDir, filename), out);
}其中“判断路径”和“实际读取路径”不一致的问题比较隐蔽。例如判断时用了相对路径,读取时又经过别的工具类处理,可能导致审计时误判。
本地复现方法
可以用一个最小 Spring Boot 接口验证问题。示例只用于本地复现:
@RestController
public class FileController {
private static final String BASE_DIR = "/tmp/upload/";
@GetMapping("/file")
public ResponseEntity<byte[]> file(@RequestParam String name) throws IOException {
File f = new File(BASE_DIR + name);
byte[] body = Files.readAllBytes(f.toPath());
return ResponseEntity.ok(body);
}
}准备测试文件:
mkdir -p /tmp/upload
echo "normal file" > /tmp/upload/a.txt
echo "secret value" > /tmp/secret.txt正常访问:
curl ' ' secret value,说明接口存在路径穿越导致的任意文件读取风险。推荐修复方式
修复时建议使用 Path 做规范化,并确保目标路径位于基础目录内。一个相对稳妥的写法如下:
@GetMapping("/safe-file")
public ResponseEntity<byte[]> safeFile(@RequestParam String name) throws IOException {
Path base = Paths.get("/tmp/upload").toRealPath(LinkOption.NOFOLLOW_LINKS);
Path target = base.resolve(name).normalize();
if (!target.startsWith(base)) {
return ResponseEntity.status(400).build();
}
if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) {
return ResponseEntity.status(404).build();
}
byte[] body = Files.readAllBytes(target);
return ResponseEntity.ok(body);
}
这里有几个关键点:
base.resolve(name):不要手工字符串拼接路径。normalize():消除 .、.. 等路径片段。target.startsWith(base):确认规范化后的路径仍在允许目录下。Files.isRegularFile:避免读取目录、设备文件等特殊对象。NOFOLLOW_LINKS:降低符号链接带来的绕出目录风险。
如果业务允许,最好进一步改成白名单下载:前端传文件 ID,后端从数据库查真实文件路径,不直接接受用户传入的文件名或路径。
@GetMapping("/download/{id}")
public ResponseEntity<byte[]> download(@PathVariable Long id) throws IOException {
FileRecord record = fileService.getById(id);
if (record == null) {
return ResponseEntity.notFound().build();
}
Path base = Paths.get("/tmp/upload").toRealPath(LinkOption.NOFOLLOW_LINKS);
Path target = base.resolve(record.getStoredName()).normalize();
if (!target.startsWith(base) || !Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) {
return ResponseEntity.status(400).build();
}
return ResponseEntity.ok(Files.readAllBytes(target));
}
文件名和响应头也要处理
下载接口除了路径问题,还容易忽略响应头注入和文件名编码问题。不要直接把用户输入拼进 Content-Disposition,至少要去除换行符,并对文件名做安全处理。
private String safeDownloadName(String name) {
String cleaned = name.replaceAll("[\\r\\n]", "");
cleaned = cleaned.replaceAll("[\\\\/:*?\"<>|]", "_");
if (cleaned.length() > 100) {
cleaned = cleaned.substring(0, 100);
}
return cleaned;
}
如果文件名来自数据库,也不能默认可信。很多漏洞并不是单个接口直接暴露,而是“上传时可控文件名,下载时信任文件名”,最终形成跨接口利用链。
符号链接的坑
在 Linux 环境里,符号链接是任意文件读取修复中经常被忽视的一点。例如上传目录中存在一个指向外部敏感文件的软链接:
ln -s /etc/hosts /tmp/upload/link.txt
如果代码只判断 /tmp/upload/link.txt 是否在基础目录内,然后直接读取,就可能跟随软链接读到外部文件。是否需要允许软链接,要根据业务决定。大多数上传下载场景不需要软链接,建议使用 NOFOLLOW_LINKS 并在部署层面限制上传目录权限。
更严格的检查可以在读取前确认目标文件的真实路径:
Path base = Paths.get("/tmp/upload").toRealPath(LinkOption.NOFOLLOW_LINKS);
Path target = base.resolve(name).normalize();
if (!target.startsWith(base)) {
throw new SecurityException("invalid path");
}
Path realTarget = target.toRealPath(LinkOption.NOFOLLOW_LINKS);
if (!realTarget.startsWith(base)) {
throw new SecurityException("invalid real path");
}
需要注意:toRealPath 要求文件存在,因此更适合下载、预览这类读取已存在文件的场景。
审计清单
检查点 说明 是否直接拼接路径 如 baseDir + filename,优先标记为风险点 是否只做字符串过滤 过滤 ../、替换关键字通常不可靠 是否规范化后校验 应使用 normalize 后确认仍在基础目录内 是否处理符号链接 关注 NOFOLLOW_LINKS 和真实路径校验 是否有权限控制 下载文件是否与当前用户、租户、项目绑定 是否暴露敏感目录 日志目录、配置目录、备份目录不应通过 Web 直接读取 响应头是否安全 避免文件名导致 Header 注入或异常解析
部署层面的缓解
代码修复是核心,但部署侧也能降低影响面:
- 应用进程使用低权限账号运行,不要使用 root。
- 上传目录和配置目录分离,避免同一用户可读过多敏感文件。
- 敏感配置优先使用环境变量、密钥管理服务或只读挂载,并限制文件权限。
- 下载接口增加访问控制、审计日志和异常频率告警。
- 容器环境中避免把宿主机敏感路径挂载到应用可读范围。
如果系统里有“查看日志”功能,也建议限制可读日志类型和时间范围,不要允许用户传绝对路径。日志查看接口在内网系统中很常见,实际风险不低。
总结
任意文件读取的本质问题是:用户可控输入影响了服务端文件路径,而代码没有可靠地把访问范围限制在预期目录内。审计时不要只看有没有过滤 ../,更要看最终读取的路径是否经过规范化、是否校验基础目录、是否处理符号链接、是否有业务权限绑定。
比较推荐的修复原则是:前端传文件 ID,不传路径;后端使用白名单记录定位文件;路径处理使用 Path.resolve、normalize、startsWith;必要时结合 NOFOLLOW_LINKS 和真实路径校验。这样处理后,既能覆盖大部分路径穿越问题,也便于后续维护和审计。

代码审计、调用链与关键函数定位示意
推荐意见