发布于周一 19:403天前 背景 最近在做一个 Java Web 项目的代码审计时,遇到一类比较常见但容易被低估的问题:文件预览/下载接口只做了简单字符串拼接,导致路径穿越。这个问题本身不新,但在实际项目里经常出现在附件中心、日志下载、模板预览、导出文件读取等功能中。 这篇文章不讨论武器化利用,只从代码审计和修复角度梳理一个可复现的路径穿越案例,重点放在如何定位、如何验证、如何修。 技术分析 典型风险点是后端接收一个文件名或相对路径参数,然后直接与业务目录拼接: String baseDir = "/data/app/upload/"; File file = new File(baseDir + fileName); 如果 fileName 可控,并且没有做规范化校验,攻击者可以构造类似: ../../../../etc/passwd ..%2f..%2f..%2f..%2fetc%2fpasswd ..\..\..\windows\win.ini 在 Linux 和 Windows 下表现略有不同,但根因一致:服务端没有确认最终访问的文件是否仍然位于允许的根目录下。 审计时可以重点搜索以下代码特征: new File(baseDir + param) Paths.get(baseDir, param) 但未做 normalize 和边界判断 FileInputStream、Files.readAllBytes、Resource 直接读取用户参数 下载接口中使用 filename、path、file、url 等参数 一个有问题的示例 下面是一个简化后的文件下载接口,问题比较典型: @RestController @RequestMapping("/file") public class FileController { private static final String BASE_DIR = "/data/app/upload/"; @GetMapping("/download") public ResponseEntity<byte[]> download(@RequestParam String name) throws IOException { File file = new File(BASE_DIR + name); if (!file.exists()) { return ResponseEntity.notFound().build(); } byte[] body = Files.readAllBytes(file.toPath()); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + file.getName() + "\"") .body(body); } } 这里有几个问题: name 完全由用户控制; 使用字符串拼接构造路径; 只判断文件是否存在,没有判断是否在允许目录内; 没有处理符号链接、编码绕过、Windows 路径分隔符等情况。 本地复现步骤 假设应用运行在 Linux 环境,基础目录为: /data/app/upload/ 正常请求: curl " 如果接口存在路径穿越,下面的请求可能读取到系统文件: curl " 实际测试时还需要关注 URL 编码场景: curl " 如果前面有网关、WAF、Nginx 或 Spring 自身的参数解码行为,可能会出现一次解码、二次解码差异。审计时不能只看浏览器里直观的字符串,要看后端最终拿到的参数值。 安全修复方式 比较稳妥的修复思路是: 基础目录使用绝对路径; 用户参数只允许相对路径或文件名; 使用 resolve 拼接路径; 对最终路径做 normalize; 确认最终路径仍然以基础目录开头; 必要时拒绝符号链接。 示例修复代码: @RestController @RequestMapping("/file") public class SafeFileController { private static final Path BASE_DIR = Paths.get("/data/app/upload") .toAbsolutePath() .normalize(); @GetMapping("/download") public ResponseEntity<byte[]> download(@RequestParam String name) throws IOException { if (name == null || name.isBlank()) { return ResponseEntity.badRequest().build(); } // 可选:如果业务只允许下载单个文件名,不允许子目录,建议直接限制分隔符 if (name.contains("/") || name.contains("\\")) { return ResponseEntity.badRequest().build(); } Path target = BASE_DIR.resolve(name).normalize(); // 核心校验:规范化后的路径必须仍然在 BASE_DIR 下 if (!target.startsWith(BASE_DIR)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } // 防止目录读取 if (!Files.exists(target) || !Files.isRegularFile(target)) { return ResponseEntity.notFound().build(); } // 如果业务场景不需要符号链接,建议拒绝 if (Files.isSymbolicLink(target)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } byte[] body = Files.readAllBytes(target); String safeFileName = target.getFileName().toString(); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + safeFileName + "\"") .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(body); } } 关于 startsWith 校验的细节 这里需要注意,不能直接对字符串做 startsWith: // 不推荐 String base = "/data/app/upload"; String target = "/data/app/upload_bak/secret.txt"; System.out.println(target.startsWith(base)); // true 这种字符串判断会把 /data/app/upload_bak 误认为在 /data/app/upload 下。建议使用 Path 对象的 startsWith,并且两边都要先转为绝对路径并规范化。 更严格的白名单方案 如果业务上文件是由系统生成并记录在数据库中的,最好不要让用户直接传路径。更推荐传文件 ID: GET /file/download?id=17823 后端根据 ID 查询数据库中的文件记录: id: 17823 owner_id: 10001 storage_name: 2024/12/8f3a2c9d7e1.pdf origin_name: report.pdf 这样可以同时做权限判断和路径控制: FileRecord record = fileMapper.findById(id); if (record == null) { return ResponseEntity.notFound().build(); } if (!record.getOwnerId().equals(currentUserId)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } Path target = BASE_DIR.resolve(record.getStorageName()).normalize(); if (!target.startsWith(BASE_DIR)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } 这个方案比直接暴露路径参数更容易控制权限,也更方便做审计日志。 审计时容易忽略的点 日志下载接口:很多后台系统会提供 logName 参数下载日志,权限通常只做了登录校验。 图片预览接口:看起来只是返回图片,但如果读取逻辑通用,也可能读取任意文件。 压缩包解压:上传 zip 后解压时要防 Zip Slip,不能信任压缩包内文件名。 符号链接:即使路径本身在上传目录下,符号链接可能指向外部敏感文件。 反向代理差异:Nginx、Tomcat、Spring 对编码和路径规范化的处理可能不一致。 Zip Slip 补充示例 如果项目里有压缩包导入功能,也要检查解压逻辑。错误示例通常是直接把 entry name 拼到目标目录: ZipEntry entry; while ((entry = zipInputStream.getNextEntry()) != null) { File outFile = new File(destDir, entry.getName()); // 如果 entry.getName() 是 ../../shell.jsp,就会写出目标目录 } 修复方式同样是规范化后判断边界: Path dest = Paths.get("/data/app/import").toAbsolutePath().normalize(); ZipEntry entry; while ((entry = zis.getNextEntry()) != null) { Path out = dest.resolve(entry.getName()).normalize(); if (!out.startsWith(dest)) { throw new SecurityException("Invalid zip entry: " + entry.getName()); } if (entry.isDirectory()) { Files.createDirectories(out); } else { Files.createDirectories(out.getParent()); Files.copy(zis, out, StandardCopyOption.REPLACE_EXISTING); } } 注意事项 不要只过滤 ../,这类黑名单很容易被编码、反斜杠、重复分隔符绕过。 不要把文件真实路径返回给前端,避免泄露服务器目录结构。 下载接口要同时做认证和授权,尤其是多租户系统。 文件名进入响应头时要处理特殊字符,避免响应头注入问题。 生产环境建议记录被拦截的非法路径,但日志里不要直接打印过长原始输入。 总结 路径穿越问题的修复关键不是简单替换 ../,而是确认“最终访问的真实路径”是否仍在业务允许的根目录内。实际代码审计时,可以围绕文件读取、文件下载、图片预览、日志导出、压缩包解压几个入口快速排查。 比较推荐的落地方案是:前端传文件 ID,后端查数据库记录;路径拼接使用 Path.resolve;规范化后使用 Path.startsWith 做边界校验;必要时禁用符号链接。这样比单纯做字符串过滤稳定很多。
6小时前6小时 可以从“路径拼接点”和“文件最终打开点”两头查,Spring Boot 里这类问题经常不是出在 Controller 明面上的 `../`,而是中间有一层 service/helper 做了二次拼接或 URL decode。 建议重点看这几类代码: new File(baseDir + "/" + filename) Paths.get(baseDir, filename) ResourceUtils.getFile(...) new ClassPathResource(path) response.getOutputStream() + FileInputStream Files.readAllBytes / Files.copy 排查时不要只搜 `../`,可以直接按文件读取 API 搜: grep -R "FileInputStream\|Files.read\|Files.copy\|getInputStream\|ResourceUtils\|ClassPathResource\|Paths.get\|new File" -n src/main/java 如果接口参数类似: /download?file=xxx /read?path=xxx /preview?name=xxx 需要确认是否存在下面几种绕过: ../application.yml ..%2f..%2fapplication.yml %2e%2e%2f%2e%2e%2fapplication.yml ....//....//application.yml subdir/../../application.yml 代码层面建议不要用字符串判断 `contains("..")`,这类过滤很容易被编码、分隔符、重复 decode 绕过。比较稳的写法是:先确定允许访问的根目录,然后 `normalize()` 后检查最终路径是否仍在根目录下。 示例: private final Path basePath = Paths.get("/data/uploads").toAbsolutePath().normalize(); public ResponseEntity<Resource> download(String filename) throws IOException { if (filename == null || filename.isBlank()) { return ResponseEntity.badRequest().build(); } Path target = basePath.resolve(filename).normalize(); if (!target.startsWith(basePath)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } if (!Files.isRegularFile(target)) { return ResponseEntity.notFound().build(); } Resource resource = new UrlResource(target.toUri()); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + target.getFileName().toString() + "\"") .body(resource); } 如果是 Windows 环境,还要注意反斜杠和盘符问题,比如: ..\..\application.yml C:\Windows\win.ini %5c..%5c..%5capplication.yml 测试时建议同时观察日志里“用户输入值”和“最终读取路径”,很多问题一眼就能看出来: log.info("download input={}, resolved={}", filename, target); 另外,审计 Spring Boot 项目时可以顺手看一下是否暴露了配置文件、日志文件、上传目录外的文件,例如: application.yml application.properties logback-spring.xml logs/app.log BOOT-INF/classes/application.yml 如果业务只允许下载数据库里登记过的文件,最好不要直接信任前端传来的文件名,而是传 `fileId`,后端从数据库取真实存储路径,再做一次 `basePath.resolve(...).normalize().startsWith(basePath)` 校验。这样比单纯过滤参数可靠很多。
创建帐户或登录后发表意见