跳转到帖子

背景

文件上传是 Web 系统里很常见的功能,头像、附件、工单截图、导入文件都可能涉及。它的问题也比较典型:业务上看只是“保存一个文件”,安全上却同时涉及文件类型识别、存储路径、访问权限、解析行为、后续处理链路等多个环节。

这篇文章整理一次比较典型的文件上传漏洞复盘。场景来自日常代码审计和测试环境验证,重点放在如何识别风险、如何验证影响、以及如何做可靠修复。文中不会涉及真实目标攻击,也不会给出用于未授权利用的操作引导。

问题场景

某业务系统提供附件上传接口,用于上传工单图片和说明文档。前端限制只允许 jpg、png、pdf,后端也做了扩展名判断。初看逻辑比较完整,但实际审计时发现几个问题叠加在一起:

  • 后端只依赖原始文件名后缀判断类型。
  • 上传目录位于 Web 可访问目录下。
  • 保存文件名部分来自用户输入,未做充分规范化处理。
  • 服务端未统一关闭上传目录的脚本解析能力。
  • 文件访问 URL 直接返回给前端,缺少权限校验。

单个问题未必立即造成严重影响,但组合起来会显著放大风险。例如,攻击者可能上传伪装文件、构造异常文件名、覆盖同名文件,或者让不该公开的附件被直接访问。

代码审计切入点

审计上传功能时,我一般不会只看控制器里的一段 if 判断,而是沿着完整链路看:

  • 入口:接口是否需要认证,是否校验用户权限和业务归属。
  • 校验:扩展名、MIME、文件魔数、文件大小是否都在服务端判断。
  • 命名:保存文件名是否由服务端生成,是否避免路径穿越和覆盖。
  • 存储:是否存放在非 Web 根目录,是否隔离临时目录和正式目录。
  • 访问:下载/预览是否经过鉴权,而不是直接暴露静态路径。
  • 处理:图片压缩、PDF 解析、Office 预览等二次处理是否存在额外风险。

下面是一段简化后的风险代码,很多问题都集中在这里:

String fileName = multipartFile.getOriginalFilename();
String ext = fileName.substring(fileName.lastIndexOf('.') + 1);

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

File dest = new File("/var/www/html/upload/" + fileName);
multipartFile.transferTo(dest);

return "/upload/" + fileName;

这段代码的问题不在于“完全没有校验”,而是校验方式过于脆弱。它默认用户提供的文件名可信,默认后缀可以代表文件内容,默认上传目录可直接访问没有问题。这些假设在安全场景里通常都不成立。

技术分析

文件上传风险可以分成几类,不同类型的修复方式也不同。

1. 类型校验绕不开内容识别

只判断扩展名是不够的。用户可以随意修改文件名,HTTP 里的 Content-Type 也可以伪造。更稳妥的方式是多层判断:

  • 扩展名白名单:只允许业务真正需要的类型。
  • MIME 检测:作为辅助判断,不单独作为安全依据。
  • 文件魔数检测:识别常见格式头部特征。
  • 安全重编码:对图片类文件可重新解码再编码,剥离异常内容。

例如,Java 中可以结合 Apache Tika 或自定义魔数判断。实际项目里不要追求识别所有格式,原则是业务需要什么,就只放行什么。

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

private boolean isAllowedExt(String ext) {
    return ALLOWED_EXT.contains(ext.toLowerCase(Locale.ROOT));
}

图片场景还可以在服务端进行重新编码。这样即使用户上传的是结构异常的图片,也能降低后续解析链路的风险:

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

需要注意,ImageIO 本身也属于解析器,历史上各类文件解析库都出现过漏洞。因此上传处理服务最好有资源限制和隔离策略,不能让单次上传消耗过多内存或 CPU。

2. 文件名必须由服务端生成

原始文件名适合用于展示,不适合用于落盘。安全的做法是服务端生成随机文件名,并把原始文件名作为元数据保存到数据库。

String originalName = multipartFile.getOriginalFilename();
String ext = getSafeExt(originalName);
String storageName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
Path target = uploadRoot.resolve(storageName).normalize();

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

Files.copy(multipartFile.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);

这里有几个细节:

  • 不要直接拼接字符串路径,使用 Path.resolve 和 normalize。
  • 保存前确认目标路径仍在预期目录下。
  • 避免使用用户输入作为真实文件名,防止覆盖、特殊字符、路径穿越等问题。
  • 文件名随机化后,访问控制仍然不能省略,随机路径不能替代鉴权。

3. 上传目录不要放在 Web 根目录

上传文件最好存放在 Web 根目录之外,通过受控下载接口读取。这样即使上传了异常文件,也不会被 Web 容器直接当作资源处理。

/opt/app/
  uploads/
    attachments/
      2025/
        01/
  logs/
  config/

/var/www/html/
  static/
  assets/

下载时由后端做权限判断,然后以附件或安全预览方式返回:

GET /api/files/{fileId}

接口内部需要校验:

  • 当前用户是否登录。
  • 文件是否属于当前用户可访问的业务对象。
  • 文件状态是否有效,是否已删除或过期。
  • 返回头是否设置合理,避免浏览器误解析。

常见响应头示例:

Content-Type: application/octet-stream
Content-Disposition: attachment; filename="example.pdf"
X-Content-Type-Options: nosniff
Cache-Control: private, no-store

如果确实需要预览图片,也建议限定明确的 Content-Type,并避免让浏览器自行猜测类型。

4. Web 服务器侧需要兜底配置

代码层修复很重要,但 Web 服务器配置同样要兜底。尤其是历史系统中,上传目录已经暴露在静态目录下时,应至少禁用脚本解析和目录索引。

Nginx 可以参考如下思路:

location /upload/ {
    autoindex off;
    add_header X-Content-Type-Options nosniff;

    location ~* \.(php|jsp|jspx|asp|aspx|cgi|pl|sh)$ {
        return 403;
    }
}

如果使用对象存储,也不要简单地把 bucket 设置为完全公开。更推荐使用私有 bucket,加服务端签名 URL 或后端转发,并设置合理过期时间。

关键复现与验证步骤

在测试环境验证上传漏洞时,不建议直接追求“能不能执行代码”这种高风险验证方式。更稳妥的验证点包括:

  • 上传非业务允许格式,观察后端是否只看后缀。
  • 修改 Content-Type,确认服务端是否信任请求头。
  • 构造特殊文件名,验证是否存在路径规范化问题。
  • 上传同名文件,确认是否会覆盖已有文件。
  • 未登录或低权限用户访问文件 URL,确认是否存在越权访问。
  • 上传超大文件或异常结构文件,观察资源限制是否生效。

一个简单的测试清单可以这样记录:

检查项预期结果
后缀白名单只允许业务明确需要的类型
内容识别不依赖用户声明的 Content-Type
文件名处理服务端随机命名,原始名仅用于展示
存储位置不在 Web 根目录下直接暴露
访问控制下载和预览均经过鉴权
资源限制限制大小、数量、解析耗时

修复方案建议

一个相对稳妥的上传处理流程如下:

  • 接口层校验登录态和业务权限。
  • 限制单文件大小、总上传数量和请求体大小。
  • 读取原始文件名,只提取展示名和候选扩展名。
  • 使用白名单校验扩展名。
  • 检测文件内容类型,必要时做重编码或安全转换。
  • 由服务端生成随机存储名。
  • 保存到 Web 根目录之外,或保存到私有对象存储。
  • 文件元数据入库,包括属主、业务 ID、类型、大小、哈希、状态。
  • 访问文件时通过 fileId 查询并鉴权,不直接暴露真实路径。
  • 对上传和下载操作记录审计日志。

伪代码大致如下:

public FileMeta upload(MultipartFile file, User user, String bizId) {
    checkLogin(user);
    checkBizPermission(user, bizId);
    checkSize(file);

    String originalName = sanitizeDisplayName(file.getOriginalFilename());
    String ext = extractExt(originalName);
    checkExtWhitelist(ext);

    byte[] head = readHeadBytes(file);
    checkMagicBytes(ext, head);

    String storageName = generateRandomName(ext);
    Path target = buildSafePath(storageName);

    saveFileSafely(file, target);

    FileMeta meta = new FileMeta();
    meta.setOwnerId(user.getId());
    meta.setBizId(bizId);
    meta.setOriginalName(originalName);
    meta.setStorageName(storageName);
    meta.setSize(file.getSize());
    meta.setStatus("ACTIVE");
    return fileMetaRepository.save(meta);
}

容易忽略的注意点

  • 压缩包上传要特别谨慎,解压时需要防 Zip Slip、炸弹文件和递归压缩。
  • PDF、Office、图片等格式的预览服务要单独隔离,避免解析器漏洞影响主业务。
  • 文件哈希可以用于去重和审计,但不要把哈希当作访问凭证。
  • 对象存储的临时 URL 要设置过期时间,并避免把长期有效链接写入前端页面。
  • 删除文件时应同步处理数据库状态和物理文件,避免残留文件继续可访问。
  • 上传失败的临时文件要定期清理,防止磁盘被打满。
  • 日志中不要记录完整敏感文件内容,也不要记录可长期访问的下载链接。

事件复盘里的几个经验

文件上传漏洞经常不是某一行代码导致的,而是多个“不严谨”叠加出来的结果。比如开发认为前端已经限制类型,运维认为后端会做好校验,测试只覆盖正常图片上传,最后上传目录又被静态服务直接暴露。

比较有效的方式是把上传能力做成统一组件,而不是每个业务各写一套。统一组件至少应内置类型白名单、随机命名、路径规范化、鉴权下载、审计日志和资源限制。这样后续业务接入时,只需要声明允许的文件类型和业务权限规则,风险会低很多。

安全上传的核心不是“禁止所有危险文件”,而是让文件在整个生命周期里都处于可控状态:可校验、可隔离、可鉴权、可审计、可回收。

总结

文件上传功能看似简单,但它连接了用户输入、文件系统、Web 容器、解析器和访问控制,是 Web 安全里很容易出问题的一环。审计时不要只看后缀判断,要沿着上传、存储、访问、处理、清理全链路检查。

实际修复时建议优先做到四点:服务端生成文件名、内容级校验、非 Web 根目录存储、下载接口鉴权。再配合 Web 服务器兜底配置、资源限制和日志审计,基本可以覆盖大多数常见上传风险。

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

0篇意见

推荐意见

没有意见。

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