跳转到帖子

背景

文件上传是 Web 系统里很常见的功能,头像、附件、导入模板、富文本图片都会用到。也正因为链路长、组件多,它经常成为代码审计里的高频风险点。这里整理一套偏实战的审计思路,重点不是“如何打”,而是如何在研发和安全评审阶段把风险收敛掉。

文章基于常见 Java Web 项目场景:Spring Boot、Nginx 反向代理、对象存储或本地磁盘存储。不同项目实现会有差异,但审计方法基本通用。

典型问题场景

我在项目审计中比较常见的上传问题主要有几类:

  • 只校验前端后缀,后端未做强校验。
  • 服务端信任 Content-Type 或原始文件名。
  • 上传目录位于 Web 可访问目录,且允许脚本类文件被解析。
  • 文件名未重命名,存在覆盖、路径穿越或特殊字符问题。
  • 图片处理、压缩包解压、文档解析等二次处理环节缺少安全边界。
  • 临时文件、失败文件未清理,导致磁盘占满或敏感文件残留。

这些问题单独看可能不严重,但组合起来往往会变成高危链路。例如:后端仅校验扩展名,上传目录又在静态资源路径下,Nginx 或容器配置不当时,就可能扩大影响面。

审计入口:先看上传链路而不是单点代码

文件上传审计不要只盯一个 Controller 方法。建议按链路拆开看:

  • 入口层:Controller、接口鉴权、参数来源、文件大小限制。
  • 校验层:后缀、MIME、魔数、文件名、文件内容特征。
  • 存储层:本地磁盘、对象存储、数据库、临时目录。
  • 访问层:下载接口、静态资源映射、Nginx 配置、CDN 配置。
  • 处理层:图片裁剪、文档转换、压缩包解压、异步任务。
  • 清理层:临时文件删除、失败回滚、日志脱敏。

真实项目里,很多漏洞并不是某一行代码“明显写错”,而是上传、存储、访问三个环节的假设不一致。

关键风险点分析

1. 后缀校验不能依赖原始文件名

常见写法是直接从 originalFilename 里取后缀,然后判断是否在白名单内:

String filename = file.getOriginalFilename();
String suffix = filename.substring(filename.lastIndexOf(".") + 1);
if (!allowList.contains(suffix.toLowerCase())) {
    throw new BizException("unsupported file type");
}

这段代码至少有几个问题:

  • originalFilename 由客户端提供,不可信。
  • 未处理空文件名、无后缀、多后缀、特殊 Unicode 字符。
  • 仅依赖后缀,无法识别伪造内容。
  • 未限制文件名长度,可能影响日志、数据库字段或文件系统。

更稳妥的做法是:原始文件名只用于展示,存储文件名由服务端生成;类型判断至少结合扩展名、魔数和业务允许范围。

String originalName = StringUtils.cleanPath(file.getOriginalFilename());
String ext = FilenameUtils.getExtension(originalName).toLowerCase(Locale.ROOT);

Set<String> allowExt = Set.of("jpg", "jpeg", "png", "pdf");
if (!allowExt.contains(ext)) {
    throw new BizException("unsupported extension");
}

String storageName = UUID.randomUUID() + "." + ext;

注意,cleanPath 只能做路径规范化辅助,不能把它当成完整安全措施。服务端仍应禁止使用客户端文件名作为实际存储路径。

2. Content-Type 只能作为参考

很多接口会这样判断:

if (!file.getContentType().startsWith("image/")) {
    throw new BizException("only image allowed");
}

Content-Type 同样来自请求头,不能作为唯一依据。比较合理的做法是把它作为辅助信息,同时读取文件头进行判断。比如图片类文件可以检查魔数:

private boolean isPng(byte[] header) {
    byte[] png = new byte[] {(byte)0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
    if (header.length < png.length) return false;
    for (int i = 0; i < png.length; i++) {
        if (header[i] != png[i]) return false;
    }
    return true;
}

生产环境可以使用 Apache Tika 等库做类型识别,但也要注意:类型识别不是杀毒引擎,不能解决所有恶意样本问题。它的价值在于减少明显伪造和误上传。

3. 上传目录不要放在可执行路径下

这点非常关键。上传文件建议放到 Web 根目录之外,通过下载接口或对象存储授权访问。不要把用户上传文件直接放在类似下面的目录:

/usr/local/tomcat/webapps/ROOT/upload/
/var/www/html/upload/
src/main/resources/static/upload/

更推荐:

/data/app/uploads/tenant-a/2025/01/

再由后端下载接口读取并返回,返回时显式设置 Content-Disposition 和安全响应头:

Content-Disposition: attachment; filename="safe-name.pdf"
X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'none'; sandbox

如果业务必须支持图片在线预览,也建议单独域名、单独桶、最小权限、禁止脚本解析,并配置合理的 CSP。

4. Nginx 和容器配置要一起审

上传安全经常败在部署配置。即使代码做了限制,如果 Nginx 把上传目录映射成静态资源,且某些后缀被错误转发给后端解析,也会留下隐患。

一个偏保守的静态上传目录配置示例:

location /user-content/ {
    alias /data/app/uploads/;
    autoindex off;
    default_type application/octet-stream;
    add_header X-Content-Type-Options nosniff always;
    add_header Content-Security-Policy "default-src 'none'; sandbox" always;

    types { }
    try_files $uri =404;
}

如果线上使用对象存储,也要检查桶策略:是否公开写、是否允许覆盖、是否开启版本控制、是否有生命周期清理、是否有跨租户路径隔离。

5. 压缩包解压要防路径穿越和资源消耗

很多系统有“批量导入素材”“上传压缩包自动解压”的功能,这是高风险点。审计时重点看两个问题:

  • Zip Slip:压缩包内文件名包含 ../ 导致写出目标目录。
  • 解压炸弹:极小压缩包解出超大文件或大量文件,耗尽磁盘和 CPU。

安全解压时应做路径归一化校验:

Path destDir = Paths.get("/data/app/imports/job-123").toRealPath();
Path target = destDir.resolve(entry.getName()).normalize();

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

同时限制:

  • 压缩包总大小。
  • 解压后总大小。
  • 文件数量。
  • 单文件大小。
  • 目录层级。
  • 允许的文件类型。

这些限制比单纯判断后缀更有价值,因为它们能有效降低资源消耗类风险。

6. 图片处理库也需要边界

图片上传不是天然安全。图片解析、缩略图生成、EXIF 读取都依赖第三方库。审计时建议确认:

  • 图片处理库版本是否存在已知漏洞。
  • 是否限制图片像素宽高,而不只是限制文件大小。
  • 是否清理 EXIF,避免泄露设备、GPS 等信息。
  • 是否对异常图片解析失败做了兜底,不把堆栈直接返回给前端。

例如,一个 5MB 的图片可能拥有非常大的像素尺寸,处理时占用大量内存。建议在业务层增加像素限制:

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

int maxWidth = 8000;
int maxHeight = 8000;
if (image.getWidth() > maxWidth || image.getHeight() > maxHeight) {
    throw new BizException("image dimension too large");
}

推荐的安全实现思路

一个相对稳妥的上传流程可以参考下面的顺序:

  • 接口鉴权:确认用户身份和上传权限。
  • 大小限制:网关、应用、业务层都要有上限。
  • 文件名处理:原始文件名仅展示,存储名服务端生成。
  • 类型校验:扩展名白名单、魔数校验、必要时使用 Tika。
  • 内容处理:图片重编码、文档转换、压缩包安全解压。
  • 隔离存储:Web 根目录外或对象存储独立桶。
  • 访问控制:下载接口鉴权,避免越权读取。
  • 安全响应头:nosniff、CSP、attachment。
  • 审计日志:记录上传人、文件哈希、大小、类型、存储路径。
  • 清理机制:临时文件、失败任务、过期文件定期清理。

文件哈希建议记录 SHA-256,方便后续排查和去重:

MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream in = file.getInputStream()) {
    byte[] buffer = new byte[8192];
    int len;
    while ((len = in.read(buffer)) != -1) {
        digest.update(buffer, 0, len);
    }
}
String sha256 = HexFormat.of().formatHex(digest.digest());

审计检查清单

检查项建议标准
上传接口是否鉴权必须鉴权,并校验业务权限
是否限制文件大小网关、应用、业务层均应限制
是否信任原始文件名不能用于实际存储路径
是否只校验 Content-Type不能作为唯一判断依据
上传目录是否可执行应放在 Web 根目录外,禁止脚本解析
下载是否鉴权按用户、租户、资源归属校验
压缩包是否安全解压校验路径、数量、大小、层级
图片是否限制像素限制宽高和内存消耗
是否记录审计日志记录上传人、哈希、大小、类型
是否有清理机制临时文件和过期文件应定期清理

常见误区

  • 误区一:认为后缀白名单足够。实际还要看存储位置、访问方式和解析行为。
  • 误区二:认为对象存储就一定安全。桶策略、路径隔离、临时授权同样会出问题。
  • 误区三:只限制文件大小,不限制图片像素、解压后大小和文件数量。
  • 误区四:把上传安全全部交给前端。前端校验只能改善体验,不能作为安全边界。
  • 误区五:下载接口只按文件 ID 查询,不校验资源归属,容易产生越权读取。

总结

文件上传安全的核心不是某一个校验函数,而是整条链路的边界控制。比较可靠的原则是:客户端输入不可信、上传内容不直接执行、存储路径不暴露、访问必须鉴权、处理过程有资源上限。

做代码审计时,建议把上传功能当成一个小型系统来看:入口、校验、存储、访问、处理、清理逐项过一遍。多数风险都能在这个过程中提前发现并修掉,比上线后再靠应急排查要省很多成本。

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

0篇意见

推荐意见

没有意见。

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