跳转到帖子

背景

文件上传是 Web 系统里很常见的功能,头像、附件、工单截图、导入文件都会用到。它的问题在于:业务上看起来只是“保存一个文件”,安全上却同时涉及文件类型识别、存储路径、访问权限、解析行为、元数据处理和后续消费链路。很多历史漏洞并不是单点判断写错,而是多个小问题叠加后形成风险。

这篇文章整理的是我在 Java Web 项目代码审计中常见的一类上传问题。内容偏防守和工程落地,重点放在如何识别风险、如何在本地安全环境复现、以及如何设计加固方案,不涉及对真实系统的攻击操作。

典型场景

某业务系统提供附件上传接口,后端使用 Spring MVC 接收 MultipartFile,文件保存到应用服务器本地目录,并返回可访问 URL。简化后的代码大致如下:

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

    File dir = new File("/data/app/uploads/");
    if (!dir.exists()) {
        dir.mkdirs();
    }

    File dest = new File(dir, newName);
    file.transferTo(dest);

    return Map.of("url", "/uploads/" + newName);
}

这段代码在功能上没有问题,但从安全角度看,至少需要继续确认以下几个点:

  • 是否只根据扩展名判断文件类型。
  • 上传目录是否会被 Web 容器直接解析或执行。
  • 文件名、路径、后缀处理是否存在边界问题。
  • 是否限制文件大小、数量、频率。
  • 是否对文件内容做二次处理,例如解压、图片解析、Office 解析。
  • 返回的 URL 是否暴露内部存储结构。

问题分析

1. 扩展名判断并不等于文件类型判断

很多项目会用后缀白名单,例如只允许 jpg、png、pdf、docx。这个方向没错,但只做后缀判断是不够的。原因是后缀来自用户输入,不能代表真实内容。

更稳妥的做法是组合判断:

  • 扩展名白名单:控制业务允许的文件类型范围。
  • Content-Type 参考:只能作为辅助信息,不能单独信任。
  • 文件头魔数识别:识别常见二进制格式。
  • 文件解析校验:例如图片用 ImageIO 解码,PDF 用专门库读取基本结构。
  • 业务语义校验:例如导入 Excel 需要校验 Sheet、表头和字段类型。

示例:对图片上传做基础校验时,不建议只看 image/jpeg,而应尝试真正解码图片。

private boolean isValidImage(MultipartFile file) {
    try (InputStream in = file.getInputStream()) {
        BufferedImage image = ImageIO.read(in);
        if (image == null) {
            return false;
        }
        return image.getWidth() > 0 && image.getHeight() > 0;
    } catch (IOException e) {
        return false;
    }
}

2. 上传目录不应具备执行能力

一个比较关键的原则是:上传目录只用于存储,不应该参与代码执行。即使文件类型校验有遗漏,也要避免上传文件被 Web 容器、脚本引擎或中间件当作可执行资源处理。

工程上可以采用以下做法:

  • 上传文件存储在 Web 根目录之外。
  • 通过专门的下载接口读取文件流,而不是直接暴露磁盘路径。
  • 静态资源服务器只提供下载能力,不启用脚本解析。
  • 对象存储桶设置为私有读,通过后端鉴权后签发短期访问地址。
  • 上传文件统一改名,避免用户可控文件名影响解析行为。

例如,不建议将文件直接保存到类似下面这种目录:

/usr/local/tomcat/webapps/ROOT/uploads/

更建议保存到应用外部数据目录:

/data/app/storage/uploads/

然后通过受控接口输出:

@GetMapping("/files/{id}")
public ResponseEntity<Resource> download(@PathVariable String id) {
    // 1. 查询文件元数据
    // 2. 校验当前用户是否有权限访问
    // 3. 从非 Web 根目录读取文件
    // 4. 设置安全的 Content-Disposition 和 Content-Type
}

3. 文件名和路径处理要避免信任原始输入

原始文件名只能作为展示信息保存,不能直接参与最终落盘路径。审计时常见的问题包括:

  • 使用 originalFilename 拼接路径。
  • 未处理 ../ 等路径穿越片段。
  • 未处理 Windows 与 Linux 路径分隔符差异。
  • 未限制文件名长度,导致日志、数据库或文件系统异常。
  • 直接在响应头中回显原始文件名,造成响应头注入风险。

推荐做法是:落盘文件名完全由服务端生成,原始文件名仅作为元数据字段,并在展示时做转义。

String originalName = StringUtils.cleanPath(file.getOriginalFilename());
String safeExt = resolveAllowedExtension(originalName, file);
String storedName = LocalDate.now() + "/" + UUID.randomUUID() + "." + safeExt;

其中 resolveAllowedExtension 不应简单返回最后一个点后的字符串,而应结合白名单、内容识别和业务类型一起判断。

4. 大文件、压缩包与解析库是另一类风险入口

上传功能经常和后续处理结合,例如压缩包导入、Excel 导入、图片缩略图生成、PDF 预览。这类逻辑的风险不在“上传成功”本身,而在后续解析。

需要重点关注:

  • 压缩包解压是否存在路径穿越,解压后文件是否超出目标目录。
  • 压缩包是否限制总解压大小、文件数量、递归层级。
  • 图片解析库是否及时更新,是否处理超大分辨率图片。
  • Office、PDF 解析是否禁用外部实体、远程资源加载等危险能力。
  • 解析任务是否放入隔离队列,避免阻塞主业务线程。

解压路径校验可以参考下面的写法,核心是对解压后的 canonical path 做约束:

Path targetDir = Paths.get("/data/app/imports/").toRealPath();

try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) {
    ZipEntry entry;
    while ((entry = zis.getNextEntry()) != null) {
        Path targetPath = targetDir.resolve(entry.getName()).normalize();
        if (!targetPath.startsWith(targetDir)) {
            throw new SecurityException("invalid zip entry path");
        }
        // 后续再处理文件数量、大小、类型等限制
    }
}

本地复现与验证思路

在安全审计中,不建议直接对线上系统做试探。更合理的方式是搭建本地或测试环境,用最小化样例验证风险点。下面是一个偏防守的验证流程。

步骤 1:确认上传链路

  • 定位 Controller、Service、存储组件和返回 URL 的位置。
  • 确认文件最终保存位置,是本地磁盘、共享存储还是对象存储。
  • 确认文件是否会被后续任务解析、转码、解压或预览。
  • 确认访问文件时是否经过鉴权。

步骤 2:检查配置边界

Spring Boot 项目通常会配置 multipart 限制。没有限制时,大文件上传可能造成磁盘、内存或线程资源压力。

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

这里只是示例值,实际应根据业务场景调整。比如头像上传和数据导入的限制一定不应该相同。

步骤 3:验证文件类型策略

建议准备几类测试样本,在本地环境验证:

  • 正常业务文件,例如标准 jpg、png、pdf。
  • 后缀与内容不一致的文件。
  • 超大尺寸图片。
  • 空文件、无扩展名文件、多重扩展名文件。
  • 文件名包含特殊字符、空格、中文、超长字符串。

验证目的不是寻找可利用方式,而是确认服务端是否能稳定拒绝异常输入,并返回明确、可控的错误信息。

步骤 4:验证访问控制

上传后的文件访问也需要审计。常见缺陷是“上传接口鉴权了,下载接口没鉴权”,或者 URL 可预测导致越权访问。

建议检查:

  • 文件元数据是否绑定租户、用户或业务对象。
  • 下载接口是否校验当前用户对该业务对象的访问权限。
  • URL 中的 ID 是否可枚举。
  • 对象存储临时链接是否有合理过期时间。
  • 删除业务对象后,附件是否同步失效或标记不可访问。

加固方案

一个相对稳妥的上传模块,可以按下面的思路设计。

1. 输入层限制

  • 限制单文件大小、总请求大小、上传频率。
  • 不同业务使用不同白名单,不要做全局“大而全”的允许列表。
  • 拒绝空文件、无意义文件名、超长文件名。
  • 对 multipart 解析异常做统一处理,避免异常堆栈暴露。

2. 内容层校验

  • 扩展名、MIME、魔数、解析结果组合判断。
  • 对图片、文档、压缩包等高风险类型使用专门校验逻辑。
  • 必要时接入杀毒或沙箱扫描,但不要把它当作唯一防线。

3. 存储层隔离

  • 上传文件放在 Web 根目录之外。
  • 服务端生成存储文件名,避免用户控制路径。
  • 目录按日期、业务或哈希分散,避免单目录文件过多。
  • 存储桶默认私有,不直接公开读。

4. 访问层鉴权

  • 下载接口按业务对象做权限判断。
  • 响应头设置 Content-Disposition,避免浏览器直接解析不可信内容。
  • 对可在线预览的类型单独处理,不要默认所有文件 inline 展示。
Content-Disposition: attachment; filename="safe-name.pdf"
X-Content-Type-Options: nosniff

5. 运维与监控

  • 记录上传用户、业务对象、文件大小、类型、存储位置、校验结果。
  • 对异常上传失败率、短时间大量上传、超限请求做告警。
  • 定期清理孤儿文件,避免存储长期膨胀。
  • 解析任务使用独立队列和低权限运行账户。

审计清单

检查项关注点
文件类型是否只依赖后缀或 Content-Type
保存路径是否位于 Web 根目录内,是否可能被解析执行
文件名是否使用用户原始文件名拼接路径
大小限制是否限制单文件和总请求大小
访问控制下载、预览接口是否校验权限
后续处理是否存在解压、转码、解析、预览等链路
异常处理是否暴露堆栈、真实路径或内部错误
日志监控是否记录关键元数据并可追踪

几个容易忽略的细节

  • 不要把 Nginx 静态目录和上传目录简单绑定,除非确认不会产生解析和权限问题。
  • 不要让前端传来的业务类型直接决定存储目录,业务类型应由服务端根据接口上下文确定。
  • 图片缩略图生成后,也要重新生成文件名和元数据,不要沿用不可信输入。
  • 导入类接口建议异步处理,先保存、扫描、校验,再进入业务导入阶段。
  • 错误信息尽量返回统一业务错误码,详细原因写入服务端日志。
上传安全的核心不是“拦住某一种危险后缀”,而是把不可信文件从输入、存储、解析、访问各阶段都放进受控边界里。

总结

文件上传漏洞之所以常见,是因为它横跨了业务、框架、中间件、文件系统和浏览器行为。单靠一个后缀白名单很难覆盖所有风险。比较可靠的方案是分层处理:输入层限制、内容层校验、存储层隔离、访问层鉴权、解析层降权和监控层追踪。

在代码审计时,我通常不会只看 upload 方法本身,而是沿着“上传入口、保存位置、返回 URL、下载接口、后续处理任务、清理逻辑”完整走一遍。很多真正有价值的问题,往往出现在这些链路的连接处。

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

0篇意见

推荐意见

没有意见。

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