跳转到帖子

背景

文件上传是 Web 系统里很常见的功能,头像、附件、工单截图、富文本图片都会用到。它看起来只是业务能力,但在安全审计里一直属于高风险入口:一旦校验不足,可能导致脚本文件落地、解析规则误用、敏感文件覆盖、存储桶公开访问等问题。

这篇整理来自几次代码审计和复盘里的共性问题,重点放在“如何发现风险”和“如何把上传链路做稳”。不讨论未授权入侵、恶意控制等内容,只从防护和验证角度展开。

常见场景与风险点

一个典型上传链路大致包括:前端选择文件、后端接收 multipart 请求、校验文件、保存到本地或对象存储、返回访问 URL。问题通常不出在某一个点,而是多个小疏忽叠加。

  • 只校验文件后缀,未校验真实文件类型。
  • 使用用户提交的原始文件名保存,存在路径穿越或覆盖风险。
  • 上传目录位于 Web 可执行路径下,被服务器按脚本解析。
  • 图片处理库未限制尺寸,导致内存消耗异常。
  • 对象存储权限配置过宽,上传文件被公开列举或任意读取。
  • 业务接口缺少鉴权或额度限制,被滥用为临时网盘。

审计时建议先看入口

代码审计时,我一般先定位接收 multipart 的接口,再看三个问题:谁能传、能传什么、传到哪里。很多漏洞不是因为某段代码特别复杂,而是因为入口默认信任了用户输入。

以 Java Spring 项目为例,可以先全局搜索以下关键词:

MultipartFile
@RequestParam("file")
transferTo(
getOriginalFilename()
Files.copy(
putObject(
upload

PHP 项目可以关注:

$_FILES
move_uploaded_file
pathinfo
mime_content_type
getimagesize

Node.js 项目可以关注:

multer
busboy
formidable
originalname
mimetype
destination
filename

找到上传入口后,不要只看控制器层。实际保存逻辑经常封装在 FileService、StorageClient、OssUtil 之类的工具类里,真正的风险点往往在这些通用组件中。

技术分析:几个容易被忽略的细节

1. 后缀白名单不是唯一依据

后缀校验必须做,但不能只依赖后缀。用户提交的 filename、Content-Type 都可以伪造。更稳妥的做法是:后缀白名单、魔数识别、解码验证三者结合。

例如图片上传,至少应确认:

  • 扩展名只允许 jpg、jpeg、png、gif、webp 等明确类型。
  • 文件头符合预期格式。
  • 可以被安全图片库正常解码。
  • 重新编码后再保存,避免保留异常附加内容。

如果业务只需要展示图片,推荐“解码后重编码”而不是原样保存。这样可以显著降低伪造文件和异常结构文件带来的风险。

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

不要直接使用 getOriginalFilename() 或 originalname 作为最终落盘文件名。原始文件名最多用于展示,保存时应由服务端生成随机名,并去掉路径语义。

// 示例:Java 中生成安全文件名思路
String ext = getSafeExtension(originalName);
String fileName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
Path target = uploadRoot.resolve(fileName).normalize();

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

这里有两个点比较关键:一是文件名不可预测,二是 normalize 后必须确认最终路径仍在预期目录内,避免路径穿越。

3. 上传目录不要具备脚本执行能力

如果文件保存到 Web 根目录下,一定要确认服务器不会把该目录中的文件当作脚本执行。更推荐的方案是:上传文件存放在 Web 根目录之外,通过静态资源服务或对象存储读取。

Nginx 场景下,可以单独给上传目录设置只读静态访问,并禁止脚本解析:

location /uploads/ {
    alias /data/app/uploads/;
    autoindex off;
    add_header X-Content-Type-Options nosniff;
    types {
        image/jpeg jpg jpeg;
        image/png png;
        image/gif gif;
        image/webp webp;
        application/pdf pdf;
    }
    default_type application/octet-stream;
}

# 不要在 uploads 路径下配置 PHP、JSP、CGI 等动态解析

如果是对象存储,建议区分上传桶和分发桶,或者通过后端审核、转码后再发布到可访问区域。

4. MIME 类型要服务端控制

浏览器访问上传文件时,如果响应头不准确,可能引发内容嗅探问题。建议显式设置 Content-Type,并加上 X-Content-Type-Options: nosniff。

对于不需要浏览器直接打开的附件,可以统一使用下载头:

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

这类细节经常被忽略,但在富文本附件、工单附件、站内消息附件场景里很实用。

5. 图片处理要限制尺寸和资源消耗

很多系统会在上传后生成缩略图。如果未限制图片像素、帧数、文件大小,可能被异常大图拖垮处理进程。这里不需要复杂技巧,只要缺少限制就可能出问题。

  • 限制单文件大小,例如头像 2MB、普通图片 10MB。
  • 限制图片宽高和总像素,例如不超过 8000x8000。
  • 限制 GIF/WebP 动图帧数或直接禁止动图。
  • 图片处理放到异步队列,避免阻塞主请求线程。
  • 处理失败时删除临时文件,避免垃圾文件堆积。

关键步骤:一套比较稳的上传处理流程

实际落地时,可以按下面的流程实现,不必依赖单一校验点。

  1. 接口鉴权:确认用户身份和业务权限。
  2. 额度限制:限制频率、单文件大小、每日总量。
  3. 基础校验:检查扩展名、文件大小、文件数量。
  4. 内容识别:通过文件头和安全库判断真实类型。
  5. 安全处理:图片重编码,文档走杀毒或沙箱扫描。
  6. 生成文件名:服务端生成随机名,避免使用原始文件名。
  7. 隔离存储:上传目录不可执行,最好不在 Web 根目录。
  8. 返回引用:返回文件 ID 或受控 URL,不直接暴露内部路径。
  9. 访问控制:下载接口校验权限,敏感附件不要公开访问。
  10. 日志审计:记录用户、IP、文件摘要、大小、类型、结果。

如果论坛编辑器不支持有序列表,上面这套也可以理解为从“入口控制”到“访问控制”的闭环。重点是不要让上传后的文件脱离业务权限体系。

一个简化版安全校验示例

下面是偏伪代码的 Java 示例,主要表达处理思路,具体库需要按项目情况替换。

public UploadResult uploadImage(MultipartFile file, User user) throws Exception {
    if (user == null) {
        throw new SecurityException("unauthorized");
    }

    if (file.isEmpty() || file.getSize() > 5 * 1024 * 1024) {
        throw new IllegalArgumentException("invalid file size");
    }

    String originalName = file.getOriginalFilename();
    String ext = getSafeExtension(originalName);
    if (!List.of("jpg", "jpeg", "png", "webp").contains(ext)) {
        throw new IllegalArgumentException("unsupported extension");
    }

    byte[] bytes = file.getBytes();
    String detectedType = detectByMagic(bytes);
    if (!isAllowedImageType(detectedType, ext)) {
        throw new IllegalArgumentException("invalid file type");
    }

    BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes));
    if (image == null) {
        throw new IllegalArgumentException("image decode failed");
    }

    long pixels = (long) image.getWidth() * image.getHeight();
    if (pixels > 40_000_000L) {
        throw new IllegalArgumentException("image too large");
    }

    String safeName = UUID.randomUUID().toString().replace("-", "") + ".jpg";
    Path target = uploadRoot.resolve(safeName).normalize();
    if (!target.startsWith(uploadRoot)) {
        throw new SecurityException("invalid path");
    }

    // 重编码保存,避免原样落盘
    ImageIO.write(image, "jpg", target.toFile());

    String sha256 = sha256(target);
    auditLog(user.getId(), safeName, sha256, file.getSize(), detectedType);

    return new UploadResult(safeName, "/media/" + safeName);
}

这里把最终文件统一转成 jpg,只是示例。实际业务如果需要透明背景,就要对 png 做专门处理。关键点是:不要把用户上传的字节流在校验不足的情况下直接放到可访问目录。

审计清单

检查项常见问题建议
鉴权上传接口匿名可访问接入登录态和业务权限校验
类型校验只看后缀或 Content-Type后缀、魔数、解码验证结合
文件名使用原始文件名保存服务端生成随机名
存储路径位于可执行 Web 目录隔离存储,禁止脚本解析
访问控制拿到 URL 即可访问敏感文件走鉴权下载
资源限制未限制大小、像素、频率设置大小、尺寸、频率和总量限制
日志无法追踪上传来源记录用户、IP、摘要、类型、结果

测试验证建议

加固完成后,建议做一轮回归验证。这里的验证目标是确认防护是否生效,不涉及攻击利用。

  • 上传允许类型文件,确认正常保存和访问。
  • 上传不在白名单内的扩展名,确认被拒绝。
  • 修改 Content-Type 后上传,确认服务端不受影响。
  • 使用异常文件名,如包含路径分隔符,确认不会影响保存路径。
  • 上传超大文件、超大尺寸图片,确认被限制。
  • 访问上传目录下不存在的文件,确认不会列目录。
  • 检查响应头,确认存在 nosniff 等必要安全头。

如果项目使用对象存储,还要额外检查 Bucket ACL、临时凭证权限、回调鉴权、跨域规则。很多线上问题不是应用代码导致的,而是云存储权限过宽。

注意点

上传安全不要寄希望于某个单点规则。比较可靠的做法是:入口鉴权、内容校验、存储隔离、访问控制、日志审计同时具备。
  • 前端校验只能改善体验,不能作为安全边界。
  • 黑名单策略不可靠,优先使用明确白名单。
  • 不要把内部绝对路径返回给前端。
  • 临时文件要及时清理,失败流程也要覆盖。
  • 富文本图片和普通附件最好使用不同策略。
  • 敏感附件不要放公共 CDN,至少使用短期签名 URL。

总结

文件上传漏洞的根源通常是“把用户输入当成可信文件”以及“上传后文件进入了不该进入的执行或公开访问环境”。审计时抓住三个问题:谁能传、能传什么、传到哪里,基本可以定位大部分风险。

加固也不复杂:白名单、真实类型识别、服务端命名、重编码、存储隔离、下载鉴权、资源限制和日志审计。真正难的是在业务迭代中保持这些规则不被绕开,所以建议把上传能力收敛到统一组件,避免每个业务线各写一套。

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

0篇意见

推荐意见

没有意见。

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