跳转到帖子

背景

文件上传是 Java Web 项目里非常常见的功能,头像、附件、导入模板、富文本图片都会用到。它看起来业务属性很强,但安全边界也最容易被低估。很多项目只做了前端限制或简单判断后缀,最后导致任意文件写入、脚本文件落地、路径穿越,甚至配合解析配置形成远程代码执行。

这篇文章不讨论具体攻击站点,也不提供针对真实系统的利用流程,主要整理一次代码审计中常见的文件上传风险点和修复思路。重点放在如何识别问题、如何验证风险、如何把修复做成闭环。

典型场景

审计对象通常是 Spring Boot 或传统 Spring MVC 项目,上传接口大致长这样:接收 MultipartFile,取原始文件名,判断后缀,然后拼接保存路径,最后返回访问 URL。

@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
    String originalName = file.getOriginalFilename();
    String suffix = originalName.substring(originalName.lastIndexOf("."));

    if (!".jpg".equalsIgnoreCase(suffix) && !".png".equalsIgnoreCase(suffix)) {
        return "unsupported";
    }

    String savePath = uploadDir + "/" + originalName;
    file.transferTo(new File(savePath));
    return "/uploads/" + originalName;
}

从功能角度看,这段代码能跑;从安全角度看,它至少有几个问题:信任原始文件名、只依赖后缀、保存目录可能可执行、文件名可覆盖、缺少大小限制、缺少内容校验。

问题拆解

1. 原始文件名不可信

getOriginalFilename()来自客户端请求,不应直接作为服务端文件名使用。它可能包含特殊字符、路径分隔符、超长字符串,也可能造成同名覆盖。

常见风险包括:

  • 路径穿越:文件名中包含 ../ 或编码变体时,可能写出预期目录。
  • 覆盖文件:使用原始文件名保存,可能覆盖已有资源。
  • 日志污染:文件名中包含换行、控制字符,影响日志排查。
  • 跨平台差异:Windows 与 Linux 对保留字符、大小写、路径分隔符的处理不同。

审计时看到原始文件名直接参与路径拼接,需要重点标记。

2. 后缀校验不能代表文件安全

很多项目只做类似 .jpg、.png 的后缀判断,但后缀是用户可控字段。更稳妥的做法是组合校验:

  • 白名单后缀,而不是黑名单。
  • 服务端重新生成文件名,后缀由校验结果决定。
  • 校验 MIME 只能作为辅助,不能单独信任。
  • 读取文件头或使用成熟库判断文件类型。
  • 对图片类文件可尝试解码并重编码,去除附加内容。

在 Java 项目里,可以用 Apache Tika、图片解码库或业务允许范围内的轻量实现做内容识别。

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

private boolean isAllowedExt(String ext) {
    return ALLOWED_EXT.contains(ext.toLowerCase(Locale.ROOT));
}

注意:扩展名校验只是一层门槛,不是完整防护。

3. 上传目录是否具备执行能力

文件上传最危险的组合通常是“可写 + 可访问 + 可执行”。如果上传目录被 Web 容器当成动态脚本目录解析,风险会明显扩大。

安全设计上,应尽量满足:

  • 上传文件保存到 Web 根目录之外。
  • 通过受控下载接口读取文件,而不是直接暴露真实路径。
  • 静态资源服务器禁止脚本解析。
  • 对象存储桶关闭不必要的公开写权限。
  • 上传目录配置独立域名时,避免携带主站 Cookie。

Nginx 场景下,静态上传目录可以明确禁止脚本类文件解析,示例仅展示防护思路:

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

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

如果是 Tomcat/Spring Boot,重点是不要把上传目录配置到可被 JSP 等动态引擎解析的位置。现代 Spring Boot 默认不解析 JSP 上传目录,但老项目、混合部署、反向代理错误配置仍然需要检查。

4. 路径拼接与规范化

审计文件操作时,建议关注所有 new File(base, name)、Paths.get(base, name)、字符串拼接路径的代码。安全实现应先生成服务端文件名,再进行路径规范化校验,确保最终路径仍在允许目录下。

Path baseDir = Paths.get(uploadDir).toAbsolutePath().normalize();
Files.createDirectories(baseDir);

String safeName = UUID.randomUUID() + "." + ext;
Path target = baseDir.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);
}

这里的关键点不是 UUID 本身,而是“不要信任用户文件名”和“最终落点必须做目录边界校验”。

5. 文件大小、数量与资源消耗

上传功能还常见拒绝服务风险,例如大文件占满磁盘、并发上传拖垮带宽、压缩包解压导致磁盘膨胀。即使没有代码执行风险,也可能造成业务不可用。

Spring Boot 中可以设置基础上传限制:

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

但这只是入口限制。实际项目还应配合:

  • 用户维度限频和配额。
  • 服务端磁盘水位监控。
  • 临时目录定期清理。
  • 异步处理队列限制并发。
  • 压缩包类文件限制层级、总大小、文件数量。

如果业务允许上传压缩包,务必检查 Zip Slip 问题。解压时不能直接使用压缩包内的文件名作为落地路径。

Path destDir = Paths.get("/data/import").toAbsolutePath().normalize();

try (ZipInputStream zis = new ZipInputStream(inputStream)) {
    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");
        }
        // 后续再根据业务处理文件,省略写入细节
    }
}

审计关键步骤

实际代码审计时,我通常按下面的顺序排查,效率比较高。

第一步:定位上传入口

检索关键字:

MultipartFile
@RequestParam("file")
CommonsMultipartFile
ServletFileUpload
getOriginalFilename
transferTo
Files.copy
FileOutputStream

除了 Controller,也要看富文本编辑器、导入功能、第三方插件目录。很多老项目的上传接口不在统一文件服务里,而是散落在各个业务模块。

第二步:确认文件保存位置

重点判断:

  • 是否保存到 Web 根目录内。
  • 是否能通过 URL 直接访问。
  • 是否存在静态映射配置。
  • 是否经过反向代理或 CDN 暴露。
  • 对象存储权限是否过宽。

Spring MVC 中常见静态资源映射示例:

@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
    registry.addResourceHandler("/uploads/**")
            .addResourceLocations("file:/data/app/uploads/");
}

这类配置本身不一定有问题,但需要结合文件类型校验、目录权限、下载策略一起看。

第三步:检查校验逻辑是否可绕开

这里不是鼓励绕过,而是从防守角度确认校验是否建立在可信数据上。常见薄弱点包括:

  • 只在前端限制文件类型。
  • 只判断 Content-Type。
  • 只用 contains 判断后缀。
  • 大小写、空格、特殊字符处理不一致。
  • 业务层和网关层规则不一致。
  • 上传后异步处理,前置校验与后置使用脱节。

更稳妥的策略是:入口校验、内容识别、服务端重命名、保存目录隔离、访问出口控制同时存在。

第四步:验证风险影响面

验证时建议在本地测试环境或授权环境完成。重点不是“能不能打”,而是证明风险链条是否成立:

  • 非法类型文件是否能保存。
  • 保存路径是否可预测。
  • 文件是否可被外部访问。
  • 是否可能覆盖已有文件。
  • 是否可能写出指定目录。
  • 上传异常是否泄露绝对路径。

验证结果最好记录为“前置条件 + 触发点 + 影响范围 + 修复建议”,这样研发更容易处理。

修复参考实现

下面是一段相对完整的上传处理示例,适合作为修复方向参考。实际项目还要结合鉴权、审计日志、存储服务做调整。

public UploadResult uploadImage(MultipartFile file) throws IOException {
    if (file == null || file.isEmpty()) {
        throw new IllegalArgumentException("empty file");
    }

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

    String originalName = Optional.ofNullable(file.getOriginalFilename()).orElse("");
    String ext = getExt(originalName);
    if (!isAllowedExt(ext)) {
        throw new IllegalArgumentException("unsupported file type");
    }

    byte[] head = file.getInputStream().readNBytes(16);
    if (!looksLikeImage(head)) {
        throw new IllegalArgumentException("invalid image content");
    }

    Path baseDir = Paths.get("/data/app/uploads/images").toAbsolutePath().normalize();
    Files.createDirectories(baseDir);

    String safeName = LocalDate.now() + "-" + UUID.randomUUID() + "." + ext.toLowerCase(Locale.ROOT);
    Path target = baseDir.resolve(safeName).normalize();

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

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

    return new UploadResult(safeName, "/file/view/" + safeName);
}

需要注意,示例中的 looksLikeImage 只是占位逻辑。生产环境建议使用成熟库判断文件类型,图片场景可以进一步读取并重新编码,避免把不必要的附加数据原样保存。

下载接口也要一起看

很多团队修上传时只盯着写入,但读取接口同样重要。下载接口如果按用户传入的文件名直接读取,也可能出现任意文件读取或越权访问。

@GetMapping("/file/view/{name}")
public ResponseEntity<Resource> view(@PathVariable String name) throws IOException {
    if (!name.matches("^[a-zA-Z0-9._-]{1,120}$")) {
        return ResponseEntity.badRequest().build();
    }

    Path baseDir = Paths.get("/data/app/uploads/images").toAbsolutePath().normalize();
    Path target = baseDir.resolve(name).normalize();

    if (!target.startsWith(baseDir) || !Files.exists(target)) {
        return ResponseEntity.notFound().build();
    }

    Resource resource = new UrlResource(target.toUri());
    return ResponseEntity.ok()
            .header("Content-Disposition", "inline; filename=\"" + name + "\"")
            .body(resource);
}

如果文件涉及用户隐私或业务数据,不能只靠文件名不可猜来保护,必须做对象级鉴权,例如校验当前用户是否有权限访问该文件记录。

日志与告警

上传功能的安全日志容易被忽略。建议至少记录以下字段:

  • 用户 ID 或调用方标识。
  • 上传接口路径。
  • 原始文件名的安全化版本。
  • 最终文件 ID 或存储 key。
  • 文件大小、识别类型。
  • 失败原因分类。
  • 请求来源 IP 和 Trace ID。

日志里不要直接写入未清洗的原始文件名,避免控制字符污染日志。异常响应也不要返回服务端绝对路径、堆栈信息和存储目录。

常见误区

误区问题建议
只做前端限制请求可被直接构造服务端必须重新校验
只看后缀后缀完全可控结合内容识别与白名单
使用原始文件名覆盖、路径问题、日志污染服务端生成文件名
上传目录放 Web 根目录可能被直接访问或解析存放到 Web 根目录外
下载接口无鉴权可能越权读取按文件记录做权限校验

总结

文件上传风险很少是单点问题,更多是多个小疏忽串在一起:文件名可信、校验薄弱、目录暴露、下载无鉴权、日志不完整。审计时不要只问“能不能上传某类文件”,而要看完整链路:谁能上传、上传什么、存到哪里、如何访问、是否可执行、能否覆盖、是否可追踪。

比较稳妥的基线是:服务端白名单校验、内容识别、随机文件名、目录隔离、禁止脚本解析、大小与频率限制、下载鉴权、完整日志。做到这些,即使某一层出现疏漏,也不至于直接扩大为高危问题。

经验上看,上传功能最好作为公共能力统一收口。散落在各业务模块里的“临时上传接口”,往往才是后续安全事件的来源。
代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

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