跳转到帖子

背景

最近在做一批内部系统的安全巡检时,遇到一个比较典型的告警:某个 Java Web 服务的下载接口存在任意文件读取风险。这个问题本身不算新,但在真实业务里仍然很常见,尤其是历史系统、报表下载、附件预览、日志导出这类功能。

这篇文章不写攻击利用细节,主要从代码审计和修复角度整理一下:这类问题为什么出现、怎么判断风险边界、如何做可靠修复,以及在排查类似问题时哪些点容易漏。

问题场景

业务里常见的文件下载接口大概是这样的逻辑:前端传入文件名或相对路径,后端拼接到固定目录下,然后读取文件并返回给浏览器。

GET /download?file=report/2024-01.xlsx

如果后端只做了简单字符串拼接,没有对路径做规范化和边界校验,就可能导致用户读取到预期目录之外的文件。

一个简化后的问题代码如下:

String baseDir = "/data/app/files/";
String file = request.getParameter("file");
File target = new File(baseDir + file);

if (!target.exists()) {
    response.setStatus(404);
    return;
}

try (InputStream in = new FileInputStream(target);
     OutputStream out = response.getOutputStream()) {
    byte[] buffer = new byte[8192];
    int len;
    while ((len = in.read(buffer)) != -1) {
        out.write(buffer, 0, len);
    }
}

这段代码的问题并不在于 FileInputStream 本身,而是“用户输入直接参与文件路径拼接”。只要路径没有被限制在业务允许的目录内,风险就会扩大。

技术分析

任意文件读取的核心风险点通常有几个:

  • 用户可控参数进入文件路径。
  • 后端使用字符串拼接构造路径。
  • 没有做 canonical path 或 real path 校验。
  • 仅依赖黑名单过滤特殊字符。
  • 下载目录中存在软链接,导致边界被绕出。
  • 错误信息过于详细,泄露本地路径结构。

很多修复会停留在过滤 ../ 这一步,但这通常不够。不同操作系统、不同框架、不同编码处理方式下,单纯过滤字符串很容易出现遗漏。更稳妥的方式是:先解析真实路径,再判断它是否仍然位于允许的根目录内。

例如 Java 中可以使用 Path 和 normalize 做第一层规范化,再结合 toRealPath 判断最终路径。

Path basePath = Paths.get("/data/app/files").toRealPath();
String file = request.getParameter("file");

Path targetPath = basePath.resolve(file).normalize();

if (!targetPath.startsWith(basePath)) {
    response.setStatus(403);
    return;
}

if (!Files.exists(targetPath) || !Files.isRegularFile(targetPath)) {
    response.setStatus(404);
    return;
}

这里有两个点需要注意:

  • normalize() 只是处理路径中的冗余部分,不会访问文件系统。
  • toRealPath() 会解析真实路径,包括符号链接,因此更适合做最终边界判断。

如果系统允许下载目录中存在软链接,仅使用 normalize() 可能仍然不够。更严格的写法可以在目标文件存在后,对目标路径也取真实路径再判断。

Path basePath = Paths.get("/data/app/files").toRealPath();
String file = request.getParameter("file");

Path candidate = basePath.resolve(file).normalize();

if (!Files.exists(candidate) || !Files.isRegularFile(candidate)) {
    response.setStatus(404);
    return;
}

Path realTarget = candidate.toRealPath();

if (!realTarget.startsWith(basePath)) {
    response.setStatus(403);
    return;
}

代码审计关注点

在审计这类问题时,我一般会先从接口功能入手,而不是一开始就全局搜危险函数。下面这些功能点值得重点看:

  • 附件下载、图片预览、报表导出。
  • 日志查看、日志下载、错误文件导出。
  • 模板读取、静态资源代理。
  • 文件上传后的回显、预览、转码。
  • 运维后台中的备份文件下载。

然后再结合代码特征做搜索:

new FileInputStream(
Files.readAllBytes(
Files.newInputStream(
ResourceUtils.getFile(
ServletContext.getResourceAsStream(
response.getOutputStream()

搜索时不要只看读取函数,还要向上追溯参数来源。重点判断以下几个问题:

  • 文件名是否来自请求参数、请求头、Cookie 或数据库字段。
  • 数据库里的路径是否可能由普通用户写入。
  • 路径拼接前是否做了白名单映射。
  • 是否存在“先校验后替换”的顺序问题。
  • 是否对压缩包解压后的文件路径做了边界检查。

更推荐的设计方式

如果业务允许,最推荐的设计不是让前端传路径,而是传文件 ID。后端根据文件 ID 到数据库查找存储位置,并且存储位置由服务端生成。

方案风险建议
前端传完整路径高不建议
前端传相对路径中必须做真实路径边界校验
前端传文件名中限制字符集并映射目录
前端传文件 ID低推荐

文件 ID 方案的后端逻辑大致如下:

String fileId = request.getParameter("id");
FileMeta meta = fileService.getById(fileId);

if (meta == null || !permissionService.canDownload(user, meta)) {
    response.setStatus(403);
    return;
}

Path basePath = Paths.get("/data/app/files").toRealPath();
Path target = basePath.resolve(meta.getStorageName()).normalize().toRealPath();

if (!target.startsWith(basePath)) {
    response.setStatus(403);
    return;
}

这里还可以顺手解决权限问题。很多文件读取漏洞并不是单点路径问题,而是“路径可控 + 缺少鉴权 + 文件命名可预测”叠加出来的。

修复时容易踩的坑

  • 只过滤 ../:容易漏掉编码、路径分隔符差异、重复规范化等问题。
  • 只判断字符串前缀:例如 /data/app/files_bak 也可能以 /data/app/files 开头,建议使用 Path 的 startsWith。
  • 忽略软链接:上传目录或共享目录中如果存在软链接,需要使用真实路径判断。
  • 错误信息过细:不要把服务器绝对路径、异常堆栈直接返回给前端。
  • 缺少权限校验:即使路径安全,也要确认当前用户是否有权下载该文件。
  • 下载文件名未处理:响应头中的文件名要避免换行等特殊字符影响 Header。

响应头里的文件名也建议做最小化处理。例如只允许常见字符,或者使用服务端保存的安全展示名。

String displayName = meta.getDisplayName();
displayName = displayName.replaceAll("[\\r\\n]", "_");
response.setHeader("Content-Disposition", "attachment; filename=\"" + displayName + "\"");

验证与回归测试

修复后建议补充单元测试或接口测试。测试重点不是证明某个特定 payload 可用,而是验证边界策略是否稳定。

@Test
void shouldRejectPathOutsideBaseDir() throws Exception {
    Path base = Paths.get("/tmp/app/files").toRealPath();
    Path target = base.resolve("../secret.txt").normalize();

    assertFalse(target.startsWith(base));
}

在实际项目里,可以补充以下测试用例:

  • 正常文件下载成功。
  • 不存在的文件返回 404。
  • 目录路径不能被下载。
  • 跨目录路径被拒绝。
  • 软链接指向外部目录时被拒绝。
  • 无权限用户无法下载他人文件。

如果系统使用 Nginx 或对象存储做文件分发,也要检查代理层是否引入新的路径穿越问题。例如使用 alias 时要特别注意 location 末尾斜杠和实际路径的对应关系。

location /files/ {
    alias /data/app/files/;
}

这类配置本身没问题,但如果上游把用户输入直接拼进内部跳转地址,仍然需要做同样的边界控制。

日志与监控建议

文件读取类问题在修复前后都建议加一些审计日志,方便发现异常访问趋势。日志不需要记录完整敏感路径,但至少应包含:

  • 用户 ID 或会话标识。
  • 请求的文件 ID 或相对文件名。
  • 校验结果。
  • 客户端 IP 和 User-Agent。
  • 失败原因分类,例如越权、文件不存在、路径非法。

注意不要把敏感文件内容、完整系统路径写入日志。日志本身也是数据资产,很多二次泄露都来自过度记录。

总结

任意文件读取问题看起来是老问题,但在业务代码里依然高频出现。根因通常不是开发不知道风险,而是早期实现为了方便直接把用户输入当路径使用,后续功能不断叠加,风险边界逐渐模糊。

比较稳妥的处理思路是:前端尽量传文件 ID,不传路径;后端统一做权限校验;文件路径使用真实路径边界判断;错误信息保持克制;修复后补充覆盖边界场景的测试。这样处理后,不只是修一个漏洞,也能把同类下载、预览、导出功能的风险整体降下来。

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

0篇意见

推荐意见

没有意见。

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