跳转到帖子

背景

文件上传一直是 Java Web 项目里比较容易被低估的风险点。很多业务只是做了前端后缀限制,或者在服务端简单判断文件名是否以 .jpg、.png 结尾,但实际落地到生产环境时,还会受到解析容器、静态资源目录、对象存储回源、Nginx 配置、权限控制等因素影响。

这篇文章整理一次比较典型的 Java Web 文件上传代码审计过程,重点放在如何识别风险、如何构造安全的验证链路,以及上线修复时容易遗漏的细节。内容偏防御和审计,不涉及利用链扩展。

问题场景

某个后台系统提供了素材上传功能,支持上传头像、活动图片和附件。审计时看到上传接口大致流程如下:

@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
    String fileName = file.getOriginalFilename();
    if (!fileName.endsWith(".jpg") && !fileName.endsWith(".png")) {
        throw new RuntimeException("invalid file type");
    }

    File dest = new File("/data/app/static/upload/" + fileName);
    file.transferTo(dest);
    return "/upload/" + fileName;
}

这段代码看起来做了后缀检查,但存在几个常见问题:

  • 直接信任 getOriginalFilename(),存在路径穿越和特殊文件名风险。
  • 只判断后缀,没有校验真实文件类型。
  • 文件名由用户控制,可能覆盖已有文件。
  • 上传目录在静态资源目录下,文件可被直接访问。
  • 没有大小限制、权限隔离和异常日志审计。

技术分析

文件上传安全不能只看“能不能传上来”,还要看上传后的文件会被什么组件处理。风险通常来自以下几个环节。

1. 文件名处理风险

用户提交的原始文件名不应该直接参与服务端路径拼接。除了常见的 ../,还需要关注 URL 编码、反斜杠、Unicode 混淆、空白字符、超长文件名等情况。即使业务只允许图片,也建议服务端完全丢弃用户文件名,改用随机文件名。

String original = file.getOriginalFilename();
String ext = FilenameUtils.getExtension(original).toLowerCase(Locale.ROOT);
String safeName = UUID.randomUUID().toString().replace("-", "") + "." + ext;

注意:这里提取扩展名只是为了保留展示或下载体验,不能把它当作唯一安全判断。

2. 后缀校验不足

单纯的 endsWith() 有很多问题,例如大小写差异、双后缀、尾部空白、特殊字符等。更稳妥的做法是使用白名单,并且在标准化后判断。

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

String ext = FilenameUtils.getExtension(originalName);
if (ext == null) {
    throw new IllegalArgumentException("missing extension");
}
ext = ext.toLowerCase(Locale.ROOT).trim();
if (!ALLOWED_EXT.contains(ext)) {
    throw new IllegalArgumentException("unsupported file type");
}

如果业务并不需要支持附件,建议只保留必要类型,上传类型越少,后续处理面越小。

3. MIME 与文件头校验

MultipartFile.getContentType() 来自客户端请求头,不能完全信任。实践中可以结合文件头魔数和服务端解析库进行检测。比如图片类文件,可以用 ImageIO 读取并确认宽高;文档类文件可以使用 Apache Tika 做基础识别。

BufferedImage image = ImageIO.read(file.getInputStream());
if (image == null) {
    throw new IllegalArgumentException("invalid image content");
}

int width = image.getWidth();
int height = image.getHeight();
if (width <= 0 || height <= 0 || width > 8000 || height > 8000) {
    throw new IllegalArgumentException("invalid image size");
}

这里不建议只检查前几个字节后就放行。文件头校验能拦截一部分低成本伪造,但不是完整的内容安全方案。

4. 上传目录与执行权限

上传目录不要放在应用可执行路径下,也不要让脚本解释器处理上传文件。比较稳妥的设计是:

  • 上传文件存储到应用目录之外,例如 /data/uploads。
  • 应用以最小权限用户运行,只授予必要读写权限。
  • 下载通过受控接口或独立静态服务输出。
  • Web 服务器对上传目录禁用脚本解析。

Nginx 层可以增加一些基本限制,例如:

location /uploads/ {
    alias /data/uploads/;
    autoindex off;
    default_type application/octet-stream;

    location ~* \.(php|jsp|jspx|asp|aspx|sh|py|pl|cgi)$ {
        return 403;
    }
}

如果使用对象存储,也要关注 Bucket 权限、回源域名、Content-Type、Content-Disposition,以及是否允许用户控制元数据。

5. 覆盖与并发问题

直接使用原文件名保存,除了路径风险,还可能造成覆盖。攻击面不一定只是代码执行,也可能是业务数据污染,例如替换某个公开素材或诱导管理员查看异常文件。建议服务端生成不可预测文件名,并且按日期或业务维度分目录。

LocalDate now = LocalDate.now();
Path baseDir = Paths.get("/data/uploads");
Path targetDir = baseDir.resolve(now.toString());
Files.createDirectories(targetDir);

String safeName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
Path target = targetDir.resolve(safeName).normalize();

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

try (InputStream in = file.getInputStream()) {
    Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}

如果不希望覆盖,可以使用 StandardOpenOption.CREATE_NEW 或先判断文件是否存在。实际项目里 UUID 冲突概率很低,但编码习惯上仍建议明确策略。

关键修复步骤

针对上述问题,可以把上传接口整理成一条相对完整的校验链路:

  • 限制上传大小:在框架和反向代理层同时设置限制。
  • 丢弃用户原始文件名:只保留扩展名或记录到数据库展示字段。
  • 扩展名白名单:标准化后判断,不使用黑名单。
  • 内容校验:结合文件头、解析库、业务规则判断。
  • 目录隔离:上传目录不放到应用执行路径。
  • 权限控制:文件读写权限最小化,下载接口做鉴权。
  • 日志记录:记录上传用户、文件大小、检测结果、存储路径映射。

Spring Boot 中可以先配置上传大小:

spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=10MB

Nginx 层也应同步限制,避免大文件请求直接打到后端:

client_max_body_size 10m;

参考实现片段

下面是一段简化后的防御性写法,适合作为审计时的对照,不建议直接复制到生产环境而不做业务适配。

@PostMapping("/upload/image")
public UploadResult uploadImage(@RequestParam("file") MultipartFile file) throws IOException {
    if (file == null || file.isEmpty()) {
        throw new IllegalArgumentException("empty file");
    }

    if (file.getSize() > 5 * 1024 * 1024) {
        throw new IllegalArgumentException("file too large");
    }

    String originalName = Optional.ofNullable(file.getOriginalFilename()).orElse("");
    String ext = FilenameUtils.getExtension(originalName).toLowerCase(Locale.ROOT).trim();

    Set<String> allowed = Set.of("jpg", "jpeg", "png");
    if (!allowed.contains(ext)) {
        throw new IllegalArgumentException("unsupported extension");
    }

    BufferedImage image = ImageIO.read(file.getInputStream());
    if (image == null) {
        throw new IllegalArgumentException("invalid image");
    }
    if (image.getWidth() > 8000 || image.getHeight() > 8000) {
        throw new IllegalArgumentException("image dimension too large");
    }

    Path baseDir = Paths.get("/data/uploads/images").toAbsolutePath().normalize();
    Path dayDir = baseDir.resolve(LocalDate.now().toString()).normalize();
    Files.createDirectories(dayDir);

    String safeName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
    Path target = dayDir.resolve(safeName).normalize();
    if (!target.startsWith(baseDir)) {
        throw new SecurityException("invalid path");
    }

    try (InputStream in = file.getInputStream()) {
        Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
    }

    return new UploadResult(safeName, file.getSize(), image.getWidth(), image.getHeight());
}

审计时的检查清单

检查点风险表现建议
文件名直接拼接原始文件名服务端生成随机文件名
后缀黑名单或 endsWith 判断标准化后白名单判断
内容只信任 Content-Type结合解析库和业务规则
路径上传到 Web 根目录存储目录与执行目录隔离
权限上传文件可被解释执行禁用脚本解析,最小权限
大小无限制上传代理层和应用层同时限制
访问所有上传文件公开访问敏感附件走鉴权下载

注意点

  • 图片重编码是一种有效的降风险手段。对于头像、封面图这类场景,可以读取后重新编码输出,避免保留原始复杂内容。
  • 不要把文件安全完全寄托在杀毒引擎上。杀毒可以作为补充检测,但上传链路本身仍要做好类型、权限和隔离。
  • 如果业务允许压缩包上传,需要额外检查解压路径、压缩炸弹、文件数量、总大小和嵌套层级。
  • 日志不要记录完整敏感路径给前端,也不要把服务端绝对路径暴露在接口返回中。
  • 修复后要补充回归测试,尤其是大小写后缀、空文件、超大图片、异常文件名、并发上传等用例。

总结

文件上传漏洞的本质不是某一个判断条件缺失,而是“用户可控内容进入服务端文件系统后,被后续组件以危险方式处理”。审计时不要只停留在后缀判断,要沿着上传、存储、访问、解析、权限这条链路完整看一遍。

比较稳妥的修复思路是:用户文件名不可信、扩展名只做辅助、内容必须校验、目录必须隔离、权限必须收敛、访问必须受控。把这些基础点落实好,绝大多数常见上传风险都能被压到可控范围内。

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

0篇意见

推荐意见

没有意见。

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