跳转到帖子

背景

文件上传是 Java Web 项目里很常见的功能,但也是代码审计中高频出问题的位置。很多漏洞并不是因为开发完全没有做校验,而是校验点放错了、信任了客户端参数,或者只处理了扩展名却忽略了存储路径、内容类型和访问控制。

这篇文章整理一个偏通用的审计思路,重点放在如何发现风险、如何验证风险边界,以及如何给出可落地的修复方案。内容不会涉及真实站点攻击,只讨论本地测试环境和代码层面的安全检查。

典型场景

常见业务包括头像上传、附件上传、富文本图片上传、工单附件、导入文件等。审计时我通常会先关注以下几个点:

  • 上传文件是否直接保存到 Web 可访问目录。
  • 文件名是否直接使用用户传入值。
  • 扩展名校验是否只依赖前端或 Content-Type。
  • 是否允许上传 HTML、SVG、JSP、jspx、jspx 等高风险类型。
  • 是否存在路径拼接导致的目录穿越。
  • 上传后的文件是否有独立访问鉴权。
  • 是否对文件大小、数量、解压内容做限制。

很多项目表面上做了白名单,但细看代码会发现只对原始文件名做了字符串判断,保存时又从其他参数取文件名,导致校验和落盘对象不是同一个。

问题代码示例

下面是一段简化后的示例代码,便于说明问题。不要直接套用到生产环境。

@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file,
                     @RequestParam("name") String name) throws IOException {
    String originalName = file.getOriginalFilename();
    if (originalName == null || !originalName.endsWith(".jpg")) {
        return "invalid file";
    }

    File dir = new File("/data/app/static/upload/");
    if (!dir.exists()) {
        dir.mkdirs();
    }

    File target = new File(dir, name);
    file.transferTo(target);
    return "/upload/" + name;
}

这段代码有几个明显风险:

  • 校验的是 originalName,但保存使用的是 name 参数,二者不一致。
  • 只用 endsWith 校验扩展名,大小写、特殊后缀、双扩展名等情况都容易漏掉。
  • 保存目录位于静态资源目录,上传后可直接访问。
  • name 未做规范化处理,存在路径穿越和覆盖已有文件的风险。
  • 没有校验真实文件类型,也没有限制文件大小。

审计时的分析方法

做代码审计时,不建议只搜 upload 或 MultipartFile 就结束。更稳妥的方式是从数据流角度看完整链路:

  • 入口:MultipartFile、ServletInputStream、Commons FileUpload、Base64 文件内容。
  • 校验:扩展名、MIME、魔数、大小、图片解析、业务类型。
  • 命名:是否使用用户输入、是否随机化、是否保留原始名称。
  • 存储:本地目录、对象存储、NFS、临时目录。
  • 访问:静态映射、下载接口、鉴权、Content-Disposition。
  • 后处理:图片压缩、文档解析、压缩包解压、异步扫描。

尤其要注意“校验对象”和“保存对象”是否一致。如果校验 file.getOriginalFilename(),但保存路径来自 request 参数、数据库字段或前端传入的 key,就要重点看是否能被用户控制。

关键验证点

在本地测试环境中,可以围绕以下几个方向验证风险,不需要对外部系统做任何操作。

1. 扩展名白名单是否严格

推荐使用服务端固定白名单,并统一转小写后判断。不要使用黑名单,因为遗漏成本很高。

private static final Set<String> ALLOWED_EXT = Set.of("jpg", "jpeg", "png", "gif", "pdf");

private boolean isAllowedExt(String filename) {
    if (filename == null) {
        return false;
    }
    String cleanName = Paths.get(filename).getFileName().toString();
    int idx = cleanName.lastIndexOf('.');
    if (idx <= 0 || idx == cleanName.length() - 1) {
        return false;
    }
    String ext = cleanName.substring(idx + 1).toLowerCase(Locale.ROOT);
    return ALLOWED_EXT.contains(ext);
}

这里的重点不是“能不能拦住所有情况”,而是先保证判断逻辑清晰,且只允许业务确实需要的类型。

2. 文件名是否可控

上传后的文件名建议由服务端生成,原始文件名只作为展示字段保存,不能参与实际路径拼接。

String ext = getSafeExt(file.getOriginalFilename());
String storedName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
Path baseDir = Paths.get("/data/app/uploads").toAbsolutePath().normalize();
Path target = baseDir.resolve(storedName).normalize();

if (!target.startsWith(baseDir)) {
    throw new SecurityException("invalid path");
}

Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);

即使文件名由服务端生成,也建议保留 normalize 和 startsWith 检查。这类防御看起来重复,但能减少后续维护人员改动代码时引入路径问题。

3. 是否校验文件内容

Content-Type 是客户端传入值,不能直接信任。对于图片类上传,可以结合魔数识别和图片解码。对于 PDF、Office 等文档,可以做基本类型识别,并配合杀毒或沙箱扫描。

private boolean isPng(byte[] header) {
    byte[] png = new byte[] {(byte)0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
    return header.length >= png.length && Arrays.equals(Arrays.copyOf(header, png.length), png);
}

如果业务只允许图片,建议上传后重新编码生成新图片,而不是原样保存。这样可以在一定程度上去掉多余内容和异常结构。

4. 上传目录是否可执行或可直接访问

文件尽量存储在 Web 根目录之外,通过受控下载接口访问。下载接口要做鉴权,并设置合适响应头,避免浏览器直接解析高风险内容。

response.setHeader("Content-Disposition", "attachment; filename=\"" + safeDownloadName + "\"");
response.setHeader("X-Content-Type-Options", "nosniff");
response.setContentType("application/octet-stream");

如果必须使用 Nginx 暴露静态文件,也要确保该目录没有脚本执行能力,并限制危险类型。

location /uploads/ {
    alias /data/app/uploads/;
    autoindex off;
    add_header X-Content-Type-Options nosniff;
    types { }
    default_type application/octet-stream;
}

压缩包上传的额外风险

很多系统允许上传 zip 包后自动解压,这类功能风险更高。除了文件类型校验,还要关注 Zip Slip、解压炸弹、文件数量过多、嵌套目录过深等问题。

安全解压时至少需要做以下限制:

  • 每个条目解压后的路径必须仍在目标目录内。
  • 限制压缩包总大小和解压后总大小。
  • 限制文件数量和目录层级。
  • 禁止符号链接、绝对路径和特殊设备文件。
  • 解压后的每个文件仍要按业务白名单检查。
Path destDir = Paths.get("/data/app/unzip").toAbsolutePath().normalize();
try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) {
    ZipEntry entry;
    while ((entry = zis.getNextEntry()) != null) {
        Path out = destDir.resolve(entry.getName()).normalize();
        if (!out.startsWith(destDir)) {
            throw new SecurityException("zip entry path traversal");
        }
        // 后续再处理大小、数量、类型等限制
    }
}

修复建议清单

风险点建议做法
扩展名绕过使用服务端白名单,统一大小写,禁止危险类型
路径穿越服务端生成文件名,路径 normalize 后校验 startsWith
文件内容伪装结合魔数、文件解析、重新编码或安全扫描
上传后直接访问存储到 Web 根目录外,通过鉴权接口下载
浏览器解析风险设置 Content-Disposition 和 X-Content-Type-Options
大文件消耗资源限制单文件大小、总量、上传频率和超时时间
压缩包风险限制路径、数量、层级、解压后大小和文件类型

常见误区

  • 只做前端校验:前端校验只能提升体验,不能作为安全边界。
  • 只判断 Content-Type:该字段可被客户端控制,最多作为辅助信息。
  • 只用黑名单:危险扩展名和容器行为太多,容易漏。
  • 认为对象存储一定安全:对象存储也要关注公开读、Content-Type、下载鉴权和生命周期。
  • 忽略业务权限:上传安全不只是不执行脚本,还包括越权读取他人附件。

总结

文件上传漏洞的核心问题通常不是单个 if 写错,而是整条链路缺少一致的安全设计。比较稳的做法是:服务端生成文件名、严格白名单、存储在非 Web 根目录、受控下载、内容校验和资源限制一起做。

审计时建议把上传功能当作一条数据流看,从入口到访问再到后处理逐段确认。这样不但能发现明显漏洞,也能发现那些在重构、迁移对象存储、增加压缩包导入功能时容易被带出来的隐患。

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

0篇意见

推荐意见

没有意见。

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