跳转到帖子

背景

文件上传是 Web 系统里很常见的功能,头像、附件、导入模板、富文本图片都会用到。也正因为入口常见、业务差异大,上传点长期是代码审计和漏洞复现里的高频区域。

这篇文章整理一次比较典型的文件上传审计思路,重点放在漏洞成因、验证方法和修复方案上。文中不会涉及对真实站点的攻击操作,适合作为开发自查、渗透测试授权场景下的检查清单。

问题场景

某系统提供附件上传接口,后端使用 Java 实现,上传后的文件会存储到 Web 可访问目录,并把访问路径写入数据库。简化后的逻辑如下:

String fileName = multipartFile.getOriginalFilename();
String suffix = fileName.substring(fileName.lastIndexOf("."));

List<String> allowExt = Arrays.asList(".jpg", ".png", ".pdf", ".docx");
if (!allowExt.contains(suffix.toLowerCase())) {
    throw new RuntimeException("file type not allowed");
}

String savePath = request.getServletContext().getRealPath("/upload/") + fileName;
File dest = new File(savePath);
 multipartFile.transferTo(dest);

return "/upload/" + fileName;

表面上看,它做了扩展名白名单校验,但实际仍然存在几个常见风险点:文件名未重命名、路径拼接不安全、只检查扩展名、上传目录可执行、缺少大小限制和内容识别。

技术分析

1. 只校验扩展名并不可靠

扩展名只是文件名的一部分,不能代表文件真实内容。攻击者可以构造内容与扩展名不一致的文件,或者利用解析差异触发异常行为。即使不讨论执行型脚本,伪造文件类型也可能导致存储型 XSS、内容嗅探、客户端解析风险。

更稳妥的做法是同时校验:

  • 扩展名白名单。
  • MIME 类型,但不能只信任客户端提交的 Content-Type。
  • 文件头魔数或使用服务端解析库识别真实格式。
  • 业务上是否真的需要允许该格式。

例如图片类文件可使用 ImageIO 解码校验,PDF 可检查文件头和结构,Office 文档可结合压缩包结构识别。

2. 原始文件名直接落盘存在风险

使用 getOriginalFilename() 直接拼接保存路径,是审计中比较容易踩中的问题。风险包括:

  • 文件名重复导致覆盖。
  • 特殊字符影响日志、页面展示或下载响应头。
  • 路径穿越风险,尤其是在不同框架、不同中间件对分隔符处理不一致时。
  • 文件名过长导致系统异常或拒绝服务风险。

保存时应生成服务端文件名,只保留原始文件名用于展示,并进行长度和字符集限制。

String originalName = multipartFile.getOriginalFilename();
String safeExt = getAllowedExt(originalName);
String newName = UUID.randomUUID().toString().replace("-", "") + safeExt;

Path baseDir = Paths.get("/data/app/upload").toAbsolutePath().normalize();
Path target = baseDir.resolve(newName).normalize();

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

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

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

从防御角度看,上传目录最好与 Web 应用代码目录隔离。很多历史漏洞不是因为校验完全缺失,而是因为上传文件最终被放在可执行目录下,中间件或框架又允许解析某些后缀。

更推荐的架构是:

  • 上传文件存储在 WebRoot 之外。
  • 通过独立下载接口读取文件流。
  • 下载接口统一设置 Content-Type、Content-Disposition、X-Content-Type-Options。
  • 静态资源服务和应用服务隔离,上传桶禁止脚本执行。

Nginx 层可以对上传目录增加兜底限制,例如禁止脚本类后缀被转发到后端解析:

location /upload/ {
    root /data/app/static;
    autoindex off;
    add_header X-Content-Type-Options nosniff;
}

location ~* ^/upload/.*\.(php|jsp|jspx|asp|aspx)$ {
    return 403;
}

这里的配置只是兜底,不应替代后端校验。真实环境还要结合业务路径、反向代理规则和容器部署方式调整。

4. 下载响应头同样重要

很多团队只关注上传校验,忽略了下载或预览环节。假如用户上传了 HTML、SVG、XML 等可被浏览器解释的内容,即使不能在服务端执行,也可能在前端访问时形成脚本执行风险。

下载接口建议显式设置响应头:

response.setHeader("Content-Disposition", "attachment; filename=\"" + encodedName + "\"");
response.setHeader("X-Content-Type-Options", "nosniff");
response.setContentType("application/octet-stream");

如果业务确实需要在线预览,应按文件类型做独立处理,不要把用户上传内容直接以内联方式返回给浏览器。

关键审计步骤

1. 梳理所有上传入口

实际项目中上传入口不一定只有“附件上传”。审计时建议全局搜索:

  • MultipartFile、CommonsMultipartFile、FileItem。
  • transferTo、Files.copy、FileOutputStream。
  • Base64 图片上传接口。
  • 富文本编辑器、Excel 导入、头像裁剪接口。
  • 对象存储直传签名接口。

特别要注意管理后台和移动端接口,这些入口经常复用同一套上传逻辑,但权限控制和返回路径不同。

2. 检查校验逻辑是否可被绕过

重点看以下几个点:

  • 黑名单还是白名单,黑名单通常维护成本高。
  • 是否只使用 contains 判断扩展名,例如 filename.contains(".jpg")。
  • 是否处理大小写、空格、特殊字符、双扩展名。
  • 是否信任客户端传来的 Content-Type。
  • 是否在压缩包上传场景中检查解压后的文件。

常见不安全写法示例:

if (fileName.contains(".jpg") || fileName.contains(".png")) {
    // allow upload
}

更合理的是提取最后一个扩展名,做严格白名单匹配,并结合内容识别。

3. 检查存储位置和访问方式

需要确认上传文件最终在哪里:

  • 是否存放在应用部署目录。
  • 是否能通过浏览器直接访问。
  • 是否经过鉴权。
  • 是否存在跨租户访问问题。
  • 是否有临时文件未清理。

多租户系统里还要检查对象 ID、文件 ID 是否可枚举,下载接口是否只根据文件 ID 返回内容。很多信息泄露并不来自上传校验,而是来自后续访问控制缺失。

4. 检查压缩包和导入类功能

压缩包上传、模板导入、批量附件导入是风险比较高的区域。常见问题包括 Zip Slip、解压炸弹、超大文件耗尽磁盘、导入解析触发 XXE 或公式注入。

解压时应确保解压后的路径仍在目标目录内:

Path destDir = Paths.get("/data/app/import").toAbsolutePath().normalize();
Path outPath = destDir.resolve(entry.getName()).normalize();

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

同时要限制压缩包大小、解压后总大小、文件数量和递归层级。

加固建议

风险点建议措施
扩展名伪造扩展名白名单结合服务端内容识别
路径穿越服务端重命名,使用 Path normalize 并校验目录边界
脚本执行上传目录放到 WebRoot 外,禁止脚本解析
覆盖文件使用 UUID、哈希或雪花 ID 作为存储名
越权访问下载接口按用户、租户、业务对象鉴权
浏览器解析风险设置 nosniff,默认 attachment 下载
资源耗尽限制文件大小、数量、上传频率和解压规模

一个相对稳妥的处理流程

实际落地时,可以把上传流程拆成几个固定步骤:

  • 鉴权:确认当前用户是否有上传权限。
  • 基础校验:文件大小、数量、业务类型。
  • 文件名处理:原始文件名仅用于展示,存储名由服务端生成。
  • 类型校验:扩展名白名单、MIME 识别、文件头识别。
  • 内容处理:图片重编码、文档安全检查、压缩包安全解压。
  • 安全存储:保存到非执行目录或对象存储。
  • 访问控制:下载和预览走统一接口鉴权。
  • 审计日志:记录上传人、时间、业务对象、文件哈希。
经验上,文件上传安全不要依赖某一个校验点。扩展名、内容识别、存储隔离、下载鉴权、响应头控制都要配合起来,才能覆盖大多数真实风险。

注意点

  • 不要把测试文件上传到未授权系统,漏洞验证应在授权环境或本地靶场完成。
  • 不要只在前端限制文件类型,前端限制只能改善用户体验,不能作为安全边界。
  • 不要把上传目录和应用发布目录混在一起,容器重启、版本发布也容易造成文件丢失或权限异常。
  • 不要忽略对象存储直传场景,签名策略过宽同样可能导致任意类型文件上传或覆盖。
  • 不要把所有文件都以内联方式预览,浏览器解析行为比很多后端逻辑更复杂。

总结

文件上传漏洞的核心不只是“能不能上传某个危险后缀”,而是整个文件生命周期是否可控:从入口校验、落盘路径、访问权限,到下载响应和后续解析,都可能引入风险。

审计这类问题时,建议不要停留在单点 payload 验证,而是沿着业务链路完整走一遍:谁能上传、能上传什么、存到哪里、谁能访问、以什么方式被解析。把这些问题回答清楚,基本就能定位大多数文件上传相关缺陷。

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

0篇意见

推荐意见

没有意见。

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