跳转到帖子

背景

任意文件读取在 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 和真实路径校验。这样处理后,既能覆盖大部分路径穿越问题,也便于后续维护和审计。

代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

游客
抱歉,你的帖子内容包括我们不允许的字词。请编辑你的帖子,删除下面高亮的屏蔽字。
添加意见…

SiteMap HACKHAT © 2026 All rights reserved.