跳转到帖子

从代码审计角度排查 Spring Boot 文件读取接口的路径穿越问题

精选回复

发布于

背景

最近在做一个 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 自身的参数解码行为,可能会出现一次解码、二次解码差异。审计时不能只看浏览器里直观的字符串,要看后端最终拿到的参数值。

安全修复方式

比较稳妥的修复思路是:

  1. 基础目录使用绝对路径;
  2. 用户参数只允许相对路径或文件名;
  3. 使用 resolve 拼接路径;
  4. 对最终路径做 normalize;
  5. 确认最终路径仍然以基础目录开头;
  6. 必要时拒绝符号链接。

示例修复代码:

@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 做边界校验;必要时禁用符号链接。这样比单纯做字符串过滤稳定很多。

可以从“路径拼接点”和“文件最终打开点”两头查,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)` 校验。这样比单纯过滤参数可靠很多。

创建帐户或登录后发表意见

最近浏览 0

  • 没有会员查看此页面。

SiteMap HACKHAT © 2026 All rights reserved.