跳转到帖子

背景

文件上传是 Web 系统里很常见的功能,但也是代码审计中长期高频出现问题的点。很多事故并不是因为单个校验缺失,而是上传入口、存储位置、访问方式、解析规则、权限控制组合在一起后形成了风险。

这篇文章整理一次偏真实场景的 Java 文件上传风险排查思路,重点放在如何审计、如何复现安全边界、如何加固。内容不会涉及攻击利用细节,更多是给研发和安全同学在做代码审计、上线前检查时参考。

典型场景

业务系统提供头像、附件、工单凭证等上传功能,后端常见实现方式如下:

  • Spring MVC / Spring Boot 接收 MultipartFile。
  • 按原始文件名或时间戳生成文件名。
  • 文件保存到本地目录、NFS、对象存储或静态资源目录。
  • 上传成功后返回可访问 URL。

这类功能表面看只是保存文件,但实际要关注几个问题:

  • 上传目录是否可被 Web 容器直接解析或访问。
  • 文件类型校验是否只依赖扩展名或 Content-Type。
  • 文件名、路径是否存在路径穿越风险。
  • 是否限制文件大小、数量和用户权限。
  • 对象存储或 CDN 是否存在错误的公开读写策略。

问题代码示例

下面是审计中经常能见到的一类简化写法。单看功能能跑通,但安全边界比较薄弱。

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

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

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

    return Map.of("url", "/upload/" + originalName);
}

主要问题有:

  • 直接使用原始文件名,可能引入特殊字符、覆盖文件、路径穿越等问题。
  • 只校验扩展名,无法确认真实文件类型。
  • 上传目录位于静态目录下,文件会被直接访问。
  • 没有做大小限制、用户鉴权、频率控制。
  • 没有记录上传审计日志,后续排查困难。

审计关注点

1. 上传入口定位

代码审计时可以先全局搜索关键字,快速定位上传入口和文件落点。

MultipartFile
CommonsMultipartFile
@RequestParam("file")
transferTo(
FileOutputStream
Files.copy
putObject
uploadFile

如果项目里封装了统一文件服务,还要继续追踪底层实现,例如本地存储、MinIO、OSS、S3、NFS 等。

2. 文件名处理

上传文件不应直接使用用户提交的原始文件名作为最终存储名。推荐保留原始文件名仅用于展示,并对存储文件名使用服务端生成的随机值。

String originalName = file.getOriginalFilename();
String safeExt = detectAndNormalizeExt(file);
String storedName = UUID.randomUUID().toString().replace("-", "") + safeExt;

同时需要注意:

  • 过滤路径分隔符,例如 ../、..\、/、\。
  • 限制文件名长度,避免异常路径或日志污染。
  • 不要信任前端传入的目录参数。
  • 避免同名覆盖,尤其是静态资源目录。

3. 类型校验

只看扩展名和 Content-Type 都不够。比较稳妥的方式是多层校验:

  • 白名单扩展名。
  • 服务端识别 MIME 类型。
  • 检查文件头魔数。
  • 必要时使用图片解码、PDF 解析等方式确认文件可被正常解析。

例如图片上传可以尝试通过 ImageIO 读取宽高,如果读取失败就拒绝。

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

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

对于办公文档、压缩包等复杂格式,不建议简单放行。业务确实需要时,应有更严格的大小限制、解压限制和异步安全检测。

4. 存储位置和访问方式

上传文件尽量不要放在 Web 容器可执行、可解析的目录下。比较推荐的做法是:

  • 文件存储目录与应用部署目录分离。
  • 上传目录只作为静态文件下载,不参与服务端脚本解析。
  • 通过下载接口鉴权后读取文件流返回。
  • 对象存储使用私有 Bucket,通过临时签名 URL 或后端代理下载。

如果使用 Nginx 提供静态访问,也应明确禁止不必要的类型解析,并加上基础安全头和下载策略。

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

这里的关键不是某一条配置,而是确保上传内容不会被当成服务端可执行内容处理。

5. 大小、频率和资源消耗

文件上传除了内容风险,还容易带来资源消耗问题。建议至少配置以下限制:

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

业务层还应补充:

  • 单用户上传频率限制。
  • 单业务对象附件数量限制。
  • 总容量配额。
  • 异常上传失败后的临时文件清理。

如果允许压缩包上传,需要关注压缩炸弹风险。不能只判断压缩包自身大小,还要限制解压后的文件数量、层级和总大小。

6. 权限和越权检查

文件上传和下载都要绑定业务权限。常见问题是上传接口做了登录校验,但下载 URL 是公开的,导致知道地址即可访问。

建议文件元数据表至少包含:

  • 文件 ID。
  • 存储路径。
  • 原始文件名。
  • 上传用户。
  • 业务归属对象。
  • 文件大小和类型。
  • 创建时间和状态。

下载时不要直接按路径读取,而是按文件 ID 查询元数据,再校验当前用户是否有权访问对应业务对象。

@GetMapping("/files/{id}")
public ResponseEntity<Resource> download(@PathVariable Long id, Principal principal) {
    FileMeta meta = fileService.getMeta(id);
    permissionService.checkReadable(principal.getName(), meta.getBizId());
    Resource resource = fileService.loadAsResource(meta.getStorageKey());
    return ResponseEntity.ok()
            .header("Content-Disposition", "attachment; filename=\"" + meta.getDisplayName() + "\"")
            .body(resource);
}

推荐加固方案

可以把上传流程拆成几个明确步骤,避免所有逻辑堆在 Controller 里。

1. 鉴权:确认用户是否允许在当前业务对象下上传。
2. 基础校验:大小、空文件、文件名长度、扩展名白名单。
3. 内容识别:MIME、魔数、格式解析。
4. 生成存储名:UUID 或哈希,不使用原始文件名落盘。
5. 安全存储:写入非执行目录或私有对象存储。
6. 元数据入库:记录用户、业务对象、大小、类型、哈希。
7. 异步检测:可选接入杀毒、DLP、敏感内容检测。
8. 下载鉴权:通过文件 ID 和业务权限控制访问。

一个更稳妥的伪代码结构如下:

public UploadResult upload(MultipartFile file, Long bizId, String username) {
    permissionService.checkUploadable(username, bizId);

    validator.checkNotEmpty(file);
    validator.checkSize(file, 10 * 1024 * 1024);

    FileType type = fileTypeDetector.detect(file);
    validator.checkAllowedType(type, Set.of(FileType.PNG, FileType.JPG, FileType.PDF));

    String storageKey = storageKeyGenerator.generate(type.getExt());
    String sha256 = digestService.sha256(file);

    storageService.save(storageKey, file.getInputStream());

    FileMeta meta = fileMetaRepository.save(new FileMeta(
            bizId,
            username,
            storageKey,
            sanitizeDisplayName(file.getOriginalFilename()),
            type.getMime(),
            file.getSize(),
            sha256
    ));

    return new UploadResult(meta.getId());
}

复现与验证建议

在授权测试或内部自测时,不建议只验证“正常图片能上传”。可以围绕安全边界做检查:

  • 上传超大文件,确认是否被限制且不会占满磁盘。
  • 上传空文件,确认是否拒绝。
  • 使用异常文件名,确认不会产生路径穿越或覆盖。
  • 修改 Content-Type,确认服务端不依赖单一请求头判断。
  • 上传扩展名和真实内容不一致的文件,确认会被拒绝。
  • 访问他人文件 ID,确认下载接口有权限校验。
  • 检查上传目录是否可以列目录。
  • 检查对象存储 Bucket 是否存在公开写入或过度公开读取。

验证结果最好形成表格,方便研发修复后回归。

检查项预期结果
异常文件名被规范化或拒绝,不影响存储路径
超大文件服务端拒绝并记录日志
伪造 Content-Type不应绕过类型校验
越权下载返回无权限
目录访问不可列目录

日志与应急排查

上传功能建议记录足够的审计日志,但不要记录敏感内容本身。比较有用的字段包括:

  • 用户 ID、IP、User-Agent。
  • 文件 ID、原始文件名、存储 Key。
  • 文件大小、识别出的 MIME、哈希值。
  • 业务对象 ID。
  • 校验失败原因。

一旦发现异常上传,可以通过哈希、用户、时间窗口、业务对象快速定位影响范围。如果使用对象存储,也要结合访问日志确认文件是否被公开访问过。

经验上,很多文件上传问题的修复难点不在“加一个后缀名判断”,而在于理清文件生命周期:谁能上传、存到哪里、谁能访问、是否会被解析、后续怎么删除和追踪。

常见误区

  • 只在前端限制文件类型,后端不校验。
  • 信任 Content-Type 请求头。
  • 认为对象存储就天然安全,忽略 Bucket 策略。
  • 上传后直接返回真实存储路径。
  • 下载接口只根据路径读取文件,没有业务鉴权。
  • 压缩包只限制包体大小,不限制解压后体积。
  • 修复后不清理历史已上传风险文件。

总结

文件上传安全不是单点校验问题,而是输入校验、存储隔离、访问控制、资源限制和审计追踪的组合。代码审计时建议从入口开始追踪到最终访问路径,确认每一环都有明确边界。

比较稳的原则可以概括为:不信任文件名,不信任请求头,不把上传目录当执行目录,不让未鉴权 URL 直接暴露敏感文件。只要把这几条落实到代码和配置里,大多数上传类风险都能提前消掉。

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

0篇意见

推荐意见

没有意见。

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