跳转到帖子

背景

文件上传是 Java Web 项目里很常见的功能,头像、附件、导入模板、富文本图片都会用到。它看起来只是一个业务能力,但安全边界比较复杂:文件名、扩展名、MIME、文件内容、存储路径、访问方式、后续解析链路都会影响风险。

这篇文章整理一次偏通用的代码审计思路,重点放在如何发现问题、如何验证风险、如何修复。内容不提供可直接用于入侵的攻击载荷,主要用于研发自查、安全评审和漏洞复盘。

问题场景

某 Java Web 系统提供附件上传接口,业务逻辑大致如下:

@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
    String fileName = file.getOriginalFilename();
    String uploadDir = request.getServletContext().getRealPath("/upload/");
    File dest = new File(uploadDir + fileName);
    file.transferTo(dest);
    return "/upload/" + fileName;
}

这段代码在内部项目里很常见,但从安全角度看至少有几个问题:

  • 直接信任客户端传入的原始文件名。
  • 文件存放在 Web 可访问目录。
  • 没有限制扩展名、大小和内容类型。
  • 没有做路径规范化,存在目录穿越风险。
  • 返回了可直接访问的文件路径。

如果项目使用了某些会解析动态脚本的容器配置,或者上传目录被错误映射为可执行目录,风险会进一步放大。

审计关注点

审计文件上传功能时,不建议只看“有没有判断后缀”。实际项目里,很多问题不是单个判断缺失,而是多个条件叠加导致。

1. 文件名是否可信

MultipartFile#getOriginalFilename() 返回的是客户端提交的文件名,不应直接用于服务端落盘。常见风险包括路径穿越、特殊字符污染日志、覆盖已有文件、在不同操作系统下解析差异等。

不安全示例:

String fileName = file.getOriginalFilename();
File dest = new File(uploadDir + fileName);

更稳妥的方式是服务端生成文件名,仅保留经过校验的扩展名:

String ext = getSafeExtension(file.getOriginalFilename());
String newName = UUID.randomUUID().toString().replace("-", "") + ext;
Path dest = uploadRoot.resolve(newName).normalize();

同时需要确认 dest 仍然位于预期目录下:

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

2. 扩展名校验是否可靠

扩展名校验建议采用白名单,而不是黑名单。黑名单很容易漏掉大小写、复合扩展名、容器解析差异和未来新增类型。

推荐做法:

  • 只允许业务确实需要的类型,例如 .jpg、.png、.pdf。
  • 统一转小写再比较。
  • 处理无扩展名、多扩展名、隐藏文件名等边界情况。
  • 不要仅依赖前端校验。
private static final Set<String> ALLOWED_EXT = Set.of(".jpg", ".jpeg", ".png", ".pdf");

private String getSafeExtension(String originalName) {
    if (originalName == null) {
        throw new IllegalArgumentException("empty filename");
    }
    String name = Paths.get(originalName).getFileName().toString();
    int index = name.lastIndexOf('.');
    if (index < 0) {
        throw new IllegalArgumentException("missing extension");
    }
    String ext = name.substring(index).toLowerCase(Locale.ROOT);
    if (!ALLOWED_EXT.contains(ext)) {
        throw new IllegalArgumentException("unsupported file type");
    }
    return ext;
}

3. MIME 与文件头校验

Content-Type 来自客户端请求头,不能作为唯一依据。实际审计中可以关注是否只用了类似下面的判断:

if (!file.getContentType().startsWith("image/")) {
    throw new RuntimeException("invalid content type");
}

更可靠的方案是结合扩展名、服务端探测结果和文件魔数。对于图片类文件,还可以使用解码库尝试读取,读取失败则拒绝。

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

如果是 PDF、Office、压缩包等复杂格式,需要根据业务选择成熟解析库,并注意解析库本身的安全更新。

4. 存储位置是否可执行

上传文件不应直接放在 WebRoot、static、public、resources 等可直接映射访问的目录下。推荐存储在应用目录之外,通过下载接口读取,并设置固定响应头。

例如将上传目录配置到独立路径:

app.upload-dir=/data/app/uploads

下载时不要把用户输入直接拼路径:

@GetMapping("/files/{id}")
public ResponseEntity<Resource> download(@PathVariable Long id) {
    FileMeta meta = fileService.findById(id);
    Path path = uploadRoot.resolve(meta.getStorageName()).normalize();
    if (!path.startsWith(uploadRoot)) {
        return ResponseEntity.badRequest().build();
    }
    Resource resource = new FileSystemResource(path);
    return ResponseEntity.ok()
        .header("Content-Disposition", "attachment; filename=\"" + meta.getOriginalName() + "\"")
        .header("X-Content-Type-Options", "nosniff")
        .contentType(MediaType.APPLICATION_OCTET_STREAM)
        .body(resource);
}

这里有两个细节值得注意:

  • 对外使用文件 ID,不暴露真实落盘路径。
  • 默认按附件下载,避免浏览器尝试解析内容。

5. 文件大小与压缩包风险

上传接口必须限制大小,否则容易被大文件拖垮磁盘或内存。Spring Boot 可以在配置中限制:

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

如果业务允许上传压缩包,还需要额外处理 Zip Slip、压缩炸弹和超大解压结果。解压时应检查每个条目的规范路径:

Path target = destDir.resolve(entry.getName()).normalize();
if (!target.startsWith(destDir)) {
    throw new SecurityException("invalid zip entry");
}

同时建议限制:

  • 压缩包总解压大小。
  • 单文件最大大小。
  • 文件数量。
  • 目录层级深度。

6. 权限与业务边界

很多上传漏洞不是技术校验单点失败,而是权限边界设计不清。例如普通用户能上传管理员可见附件,或者上传后可被其他用户直接访问。审计时建议确认:

  • 上传接口是否需要登录。
  • 上传后的文件是否绑定用户或业务对象。
  • 下载接口是否校验访问权限。
  • 临时文件是否会被定期清理。
  • 失败上传是否会留下半成品文件。

关键验证步骤

在授权测试或自查环境中,可以按下面顺序验证,不需要构造危险载荷,也能发现大部分设计问题。

步骤一:定位上传入口

从路由、Controller、网关日志或接口文档中查找上传接口,常见关键字包括:

upload
file
attachment
avatar
import
image
multipart

代码层面可搜索:

MultipartFile
CommonsMultipartFile
Part
transferTo
getOriginalFilename

步骤二:检查服务端校验

重点确认校验发生在服务端,而不是只在前端。前端限制文件类型只能作为用户体验优化,不能作为安全边界。

建议记录以下信息:

  • 允许上传的扩展名列表。
  • 是否检查文件内容。
  • 是否限制大小。
  • 是否改名存储。
  • 是否存储到 Web 可访问目录。
  • 是否进行鉴权和业务归属校验。

步骤三:检查返回值与访问路径

如果上传接口直接返回 /upload/xxx 这类路径,需要继续确认该目录是否可直接访问、是否会被容器解析、是否允许目录索引。

Nginx 场景下,建议至少确认上传目录没有被配置为脚本执行路径。安全侧更推荐单独域名或对象存储,并关闭不必要的解析能力。

步骤四:检查异常处理和日志

上传失败时不要把完整服务端路径、堆栈信息直接返回给用户。日志中可以记录审计必要信息,但要避免记录过多用户可控内容,特别是文件名和请求头。

修复建议

一个相对稳妥的上传实现,可以遵循下面这些原则:

  • 服务端生成随机文件名。
  • 扩展名白名单,按业务最小化开放。
  • 结合文件头、解析库进行内容校验。
  • 上传目录放在 WebRoot 外部。
  • 下载通过受控接口完成。
  • 设置大小限制和请求频率限制。
  • 对上传、下载都做权限校验。
  • 保留文件元数据,便于审计和清理。
  • 异常信息对用户收敛,对日志可追踪。

示例代码片段如下:

@PostMapping("/attachments")
public UploadResult upload(@RequestParam("file") MultipartFile file,
                           @AuthenticationPrincipal UserPrincipal user) throws IOException {
    if (file.isEmpty()) {
        throw new IllegalArgumentException("empty file");
    }
    if (file.getSize() > 10 * 1024 * 1024) {
        throw new IllegalArgumentException("file too large");
    }

    String ext = getSafeExtension(file.getOriginalFilename());
    validateContent(file, ext);

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

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

    FileMeta meta = fileService.saveMeta(user.getId(), file.getOriginalFilename(), storageName, file.getSize());
    return new UploadResult(meta.getId());
}

常见误区

误区问题建议
只看 Content-Type请求头可被客户端控制结合扩展名、文件头和解析结果
黑名单拦截危险后缀容易遗漏变体和环境差异使用业务白名单
上传到 static 目录文件可能被直接访问或解析放到 WebRoot 外,通过接口下载
保留原始文件名可能路径穿越、覆盖文件、污染日志服务端生成随机文件名
只限制前端接口可被直接调用所有校验必须在服务端完成

注意点

不同技术栈和部署方式下,文件上传风险表现不一样。比如同样是上传图片,Tomcat、Nginx、对象存储、CDN、反向代理的处理链路不同,最终风险也不同。审计时不要只看应用代码,还要看部署配置。

一个经验:上传功能的安全性,取决于“文件能不能被当成代码或主动内容处理”,以及“谁能访问这个文件”。只要这两个问题没确认清楚,就不要轻易认为上传功能安全。

另外,如果系统支持富文本上传,还要关注 HTML、SVG、Markdown、Office 预览等主动内容。很多 XSS、SSRF、文件读取问题并不发生在上传瞬间,而是发生在后续预览、转码、解析阶段。

总结

文件上传漏洞的本质是用户可控内容进入了服务端文件系统,并在后续链路中被访问、解析或分发。安全设计不能只依赖某一个校验点,而应从文件名、类型、内容、路径、权限、访问方式和生命周期整体处理。

如果要做一次有效的上传功能审计,可以按三个问题快速切入:第一,服务端是否真正限制了可上传内容;第二,文件是否存放在不可执行、不可直接枚举的位置;第三,上传后访问是否经过权限控制。把这三点做扎实,大多数常见风险都能被压住。

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

0篇意见

推荐意见

没有意见。

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