跳转到帖子

背景

文件上传是 Java Web 项目里很常见的功能,头像、附件、导入模板、富文本图片都可能涉及。实际代码审计中,这类功能看起来简单,但经常把“业务可用”和“安全边界”混在一起:只判断后缀、不限制存储位置、上传后可直接访问、依赖前端校验等。

这篇文章整理一次偏真实项目的审计思路,重点放在如何发现风险、如何验证问题、以及如何修复。内容只讨论防护和复现验证,不涉及恶意利用。

常见场景

在 Java Web 项目中,上传逻辑通常分布在以下几类位置:

  • Controller 层直接接收 MultipartFile。
  • 统一文件服务,例如 FileService.upload()。
  • 富文本编辑器插件上传接口。
  • Excel、CSV 等导入功能。
  • 第三方对象存储封装,例如 MinIO、OSS、S3。

审计时不建议只搜索 upload,还可以关注以下关键词:

MultipartFile
CommonsMultipartFile
transferTo
getOriginalFilename
FileOutputStream
Files.copy
Path.of
new File
@RequestPart
@RequestParam("file")

问题代码示例

下面是一个简化后的典型写法。很多项目里会在这个基础上增加一点后缀判断,但整体思路相似。

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

    if (!Arrays.asList(".jpg", ".png", ".gif").contains(suffix)) {
        throw new RuntimeException("invalid file type");
    }

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

这段代码的问题并不罕见,主要包括:

  • 直接信任 getOriginalFilename(),可能引入路径穿越、特殊字符、重名覆盖等问题。
  • 只按后缀判断文件类型,无法保证内容真实类型。
  • 保存文件名由用户控制,不利于追踪和隔离。
  • 上传目录如果位于 Web 可访问目录下,风险会进一步放大。
  • 没有限制文件大小、数量、压缩包解压深度等资源消耗问题。

技术分析

1. 文件名信任问题

getOriginalFilename() 来自客户端请求,不应直接拼接到服务端路径中。不同浏览器和客户端可能提交不同格式的文件名,攻击者也可以构造包含路径分隔符、控制字符、超长字符串的名称。

审计时重点看是否存在类似写法:

String path = baseDir + "/" + file.getOriginalFilename();
file.transferTo(new File(path));

更合理的处理方式是服务端生成文件名,仅保留必要扩展名,并对路径进行规范化检查。

2. 后缀校验不足

只判断后缀无法确认真实文件类型。即使业务只允许图片,也应结合 MIME 类型、文件魔数、图片解码验证等方式做多层判断。

例如图片可以通过 Java ImageIO 做一次实际解码,避免只看扩展名:

try (InputStream input = file.getInputStream()) {
    BufferedImage image = ImageIO.read(input);
    if (image == null) {
        throw new IllegalArgumentException("not a valid image");
    }
}

这里需要注意,Content-Type 请求头也来自客户端,只能作为辅助信息,不能作为唯一依据。

3. 存储位置和访问边界

上传文件建议存储在 Web 根目录之外,访问时通过受控接口读取,避免上传内容被容器直接解析或直接下载。尤其是老项目中,常见上传目录如下:

src/main/webapp/upload
webapps/ROOT/upload
resources/static/upload
public/upload

如果文件必须被公网访问,可以考虑对象存储加只读桶策略,或者通过网关做统一鉴权和响应头控制。

4. 路径规范化

即便文件名已处理,也建议对最终路径做规范化判断,确保最终写入路径没有逃逸出预期目录。

Path basePath = Paths.get(uploadDir).toAbsolutePath().normalize();
Path targetPath = basePath.resolve(serverFileName).normalize();

if (!targetPath.startsWith(basePath)) {
    throw new SecurityException("invalid path");
}

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

这类检查在处理压缩包解压、批量导入附件时尤其重要。

关键审计步骤

第一步:定位上传入口

建议先从路由和控制器入手,搜索以下特征:

@PostMapping
@RequestMapping
MultipartFile
@RequestPart
CommonsMultipartResolver
transferTo
Files.copy

找到入口后,记录接口路径、鉴权情况、上传参数名、文件保存路径、返回 URL。

第二步:确认校验逻辑

重点看校验发生在前端还是后端。前端校验只影响普通用户体验,不属于安全控制。后端需要至少覆盖:

  • 文件大小限制。
  • 扩展名白名单。
  • 文件内容校验。
  • 文件名重命名。
  • 存储路径隔离。
  • 访问权限控制。

第三步:检查配置项

Spring Boot 项目通常会在配置文件里限制上传大小,但很多项目只设置了默认值,或者环境配置不一致。

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

如果使用 Nginx,还需要同时检查:

client_max_body_size 10m;

应用层、网关层、反向代理层的限制应保持一致,否则容易出现测试环境正常、生产环境异常的问题。

第四步:验证是否可直接访问

上传成功后,如果接口返回了文件 URL,需要确认该 URL 是否无需鉴权即可访问。对于头像、公开图片可能是合理的;对于合同、工单附件、导入结果文件,则通常不应公开访问。

建议关注响应头是否合理,例如下载类文件应避免浏览器错误解析:

Content-Type: application/octet-stream
Content-Disposition: attachment; filename="safe-name.ext"
X-Content-Type-Options: nosniff

相对安全的实现示例

下面示例只展示核心思路,实际项目还需要结合业务权限、审计日志和对象存储策略。

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

public String safeUpload(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 = Objects.toString(file.getOriginalFilename(), "");
    String ext = getExt(originalName).toLowerCase(Locale.ROOT);

    if (!ALLOWED_EXT.contains(ext)) {
        throw new IllegalArgumentException("unsupported file type");
    }

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

    String serverFileName = UUID.randomUUID() + "." + ext;
    Path basePath = Paths.get(uploadDir).toAbsolutePath().normalize();
    Path targetPath = basePath.resolve(serverFileName).normalize();

    if (!targetPath.startsWith(basePath)) {
        throw new SecurityException("invalid file path");
    }

    try (InputStream input = file.getInputStream()) {
        Files.copy(input, targetPath, StandardCopyOption.REPLACE_NEW);
    }

    return serverFileName;
}

private String getExt(String filename) {
    int index = filename.lastIndexOf('.');
    if (index < 0 || index == filename.length() - 1) {
        return "";
    }
    return filename.substring(index + 1);
}

上面代码有一个细节:读取图片内容后再次获取输入流进行保存。多数 MultipartFile 支持多次 getInputStream(),但如果项目封装过上传对象,最好确认实现细节,避免流只能读取一次。

压缩包上传的额外风险

如果业务支持 zip、tar 等压缩包导入,风险会更复杂。审计时至少关注:

  • 解压文件路径是否做规范化检查,避免写出目标目录。
  • 是否限制压缩包总解压大小。
  • 是否限制文件数量和目录层级。
  • 是否过滤符号链接、特殊设备文件。
  • 是否允许覆盖已有文件。

解压时应对每个 entry 做路径检查:

Path destDir = Paths.get(importDir).toAbsolutePath().normalize();
Path destFile = destDir.resolve(entry.getName()).normalize();

if (!destFile.startsWith(destDir)) {
    throw new SecurityException("zip entry path traversal");
}

日志和审计建议

上传功能出问题时,排查成本通常不低。建议记录以下信息,但不要记录敏感内容本身:

  • 用户 ID、租户 ID、来源 IP。
  • 原始文件名和服务端文件名。
  • 文件大小、扩展名、校验结果。
  • 业务对象 ID,例如工单 ID、合同 ID。
  • 失败原因和异常栈摘要。

如果是多租户系统,文件存储路径和访问接口也应包含租户隔离逻辑,避免只依赖前端传入的业务 ID。

修复清单

检查项建议
文件名服务端生成随机文件名,不直接使用用户输入
扩展名使用白名单,不使用黑名单
内容校验结合魔数、解码验证、业务格式解析
存储路径放在 Web 根目录之外,路径规范化检查
访问控制附件下载接口做鉴权和对象归属校验
大小限制应用、网关、反向代理多层限制一致
响应头设置 nosniff、下载类文件使用附件响应
日志记录上传行为,便于追踪和应急

注意点

  • 不要把前端限制当作安全策略,后端必须独立校验。
  • 不要依赖单一校验点,后缀、MIME、内容解析应组合使用。
  • 不要让上传目录具备脚本执行能力。
  • 不要在错误信息里返回服务端绝对路径。
  • 不要允许用户控制完整保存路径。
  • 不要忽略对象存储桶策略和 CDN 缓存策略。
经验上,文件上传问题很少是某一行代码单独造成的,更多是“上传、存储、访问、解析”几个环节的边界没有统一设计。审计时把链路画完整,比单点搜索更有效。

总结

文件上传功能的安全重点不只是“能不能传某种后缀”,而是要确认整个生命周期是否可控:入口是否鉴权、内容是否可信、路径是否隔离、访问是否受控、日志是否可追溯。

在代码审计中,我通常会先定位所有上传入口,再按文件名、类型校验、存储位置、访问权限、资源限制五个维度逐项确认。对于历史项目,优先修复直接使用原始文件名、上传目录可直接访问、缺少后端校验这三类问题,收益通常比较明显。

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

0篇意见

推荐意见

没有意见。

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