跳转到帖子

背景

文件上传一直是 Web 安全里比较常见、也比较容易被低估的风险点。很多业务团队会认为“只要限制后缀名”“只要上传目录不在 Web 根目录”就足够了,但在实际代码审计和应急排查中,上传链路往往会牵涉文件名处理、存储路径拼接、对象存储回调、图片解析、异步任务、预览接口等多个环节。

这篇文章整理一个相对通用的排查方法,以 Spring Boot 项目为例,讨论如何从代码审计角度识别文件上传风险,并用安全、可控的方式做复现验证。内容偏防守和工程落地,不涉及恶意利用或未授权攻击。

典型场景

某业务系统提供附件上传功能,接口大致如下:

  • 用户上传头像、合同、工单附件等文件;
  • 后端根据原始文件名生成存储路径;
  • 上传后返回文件访问 URL;
  • 部分文件支持在线预览或下载。

审计时看到类似代码:

@PostMapping("/upload")
public Result upload(@RequestParam("file") MultipartFile file) throws IOException {
    String originalName = file.getOriginalFilename();
    String suffix = originalName.substring(originalName.lastIndexOf("."));
    String fileName = System.currentTimeMillis() + suffix;

    File target = new File(uploadDir + "/" + fileName);
    file.transferTo(target);

    return Result.ok("/files/" + fileName);
}

这段代码在很多项目里都能看到,表面上逻辑简单,但安全边界并不完整。下面按审计维度逐项拆开看。

技术分析

1. 文件名与路径处理

文件上传的第一类问题通常出在文件名和路径处理上。常见风险包括路径穿越、特殊字符污染、同名覆盖、操作系统差异导致的解析不一致等。

如果直接使用 getOriginalFilename(),需要注意它来自客户端请求,不能视为可信输入。即使最终没有直接使用原文件名保存,也要避免在日志、预览、下载头、对象存储 metadata 中原样输出。

安全处理建议:

  • 服务端重新生成文件名,不依赖用户上传的原始名称;
  • 存储路径使用固定根目录,并进行规范化校验;
  • 避免字符串拼接路径,使用 Paths / Path API;
  • 将原始文件名作为展示字段时,需要做长度限制和字符过滤。
Path baseDir = Paths.get(uploadDir).toAbsolutePath().normalize();
String safeName = UUID.randomUUID().toString().replace("-", "") + ".jpg";
Path target = baseDir.resolve(safeName).normalize();

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

file.transferTo(target.toFile());

2. 后缀名校验不等于类型校验

很多项目只基于后缀名判断文件类型,例如只允许 .jpg、.png、.pdf。这一步有价值,但不能作为唯一校验。后缀名是用户可控的,服务端还需要结合 MIME、文件头魔数、解析库验证等多层检查。

较稳妥的做法是白名单策略:

  • 明确业务允许的文件类型,不做“黑名单拦截”;
  • 后缀名统一转小写后匹配;
  • 结合文件头识别实际类型;
  • 图片类文件可进行重新编码,去除异常结构和附加内容;
  • 办公文档、压缩包等高风险类型应单独评估。
private static final Set<String> ALLOWED_EXT = Set.of("jpg", "jpeg", "png", "pdf");

String ext = getExtension(originalName).toLowerCase(Locale.ROOT);
if (!ALLOWED_EXT.contains(ext)) {
    throw new IllegalArgumentException("unsupported file type");
}

如果是图片上传,建议不要只保存原始文件。可以使用可靠的图片处理库读取后再输出成标准格式。这样可以降低异常文件结构带来的风险,也方便控制尺寸和压缩率。

3. 上传目录是否可直接执行

对 Java Web 应用来说,上传目录通常不应放在应用可执行路径下,也不应让容器将上传文件当作动态脚本处理。虽然 Spring Boot 常见部署方式不一定存在传统 WebShell 执行问题,但如果前面挂了 Nginx、历史包里有 JSP 容器、或者上传目录与静态资源目录混用,就可能引入额外风险。

建议:

  • 上传文件存储在 Web 根目录之外;
  • 下载通过受控接口或对象存储临时 URL;
  • Nginx 对上传目录禁用脚本解析;
  • 静态资源目录与用户上传目录分离;
  • 预览服务和主业务服务隔离部署更安全。
location /uploads/ {
    alias /data/app/uploads/;
    autoindex off;
    add_header X-Content-Type-Options nosniff;

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

4. 下载与预览接口的访问控制

不少文件上传漏洞并不是出在“上传”本身,而是出在后续访问链路。比如上传接口有鉴权,但下载接口只要知道文件名就能访问;或者预览接口根据参数读取任意路径文件。

审计下载接口时,可以重点看这些点:

  • 文件 ID 是否绑定当前用户或当前租户;
  • 是否直接根据 URL 参数拼接磁盘路径;
  • 是否允许传入绝对路径或包含 ../ 的路径;
  • Content-Disposition 中的文件名是否做了安全处理;
  • 是否存在批量下载、压缩下载引入的路径问题。

一个更合理的模式是:外部只暴露文件 ID,后端根据数据库记录查找真实存储路径,并做权限判断。

@GetMapping("/files/{id}")
public void download(@PathVariable Long id, HttpServletResponse response) {
    FileRecord record = fileService.getById(id);
    if (!permissionService.canRead(record, currentUser())) {
        throw new AccessDeniedException("no permission");
    }

    Path path = storageService.resolve(record.getStorageKey());
    // 后续再进行文件存在性、大小、响应头等处理
}

关键步骤:一次安全复现验证怎么做

复现验证不建议直接在生产环境进行,尤其文件上传会影响存储、日志、CDN、索引服务和异步处理链路。比较稳妥的流程如下。

1. 搭建隔离环境

  • 使用测试环境或本地 Docker 环境;
  • 上传目录指向临时目录;
  • 关闭外部回调、消息队列消费和真实对象存储写入;
  • 保留应用日志,方便确认代码路径。

可以在本地临时设置上传目录:

app:
  upload-dir: /tmp/demo-upload

2. 验证后缀与内容校验

准备几类无害测试文件:

  • 正常 JPG、PNG、PDF;
  • 后缀与内容不一致的普通文本文件;
  • 超大文件名、包含空格和特殊字符的文件名;
  • 超过业务限制大小的文件。

重点不是构造攻击载荷,而是确认系统是否按预期拒绝异常输入,并在日志中给出可排查的信息。

curl -F "[email protected]" 
curl -F "[email protected]"  验证路径边界

如果代码中存在路径拼接,应确认规范化校验是否生效。测试时只需要观察服务端是否拒绝异常文件名或异常参数,不需要进行越权读取或写入。

// 推荐检查逻辑
Path base = Paths.get("/tmp/demo-upload").toAbsolutePath().normalize();
Path target = base.resolve(inputName).normalize();
if (!target.startsWith(base)) {
    throw new SecurityException("invalid path");
}

4. 验证访问控制

上传成功后,不要只看文件能不能打开,还要验证不同用户、不同租户之间是否存在越权访问。比较实用的检查方式:

  • 用户 A 上传文件,用户 B 是否能通过文件 ID 下载;
  • 删除权限后,历史链接是否仍然可访问;
  • 对象存储临时 URL 是否过期;
  • 文件名枚举是否能访问他人文件。

5. 验证响应头

下载接口建议设置合适的响应头,降低浏览器误解析风险。尤其当文件类型复杂、可能由用户控制时,X-Content-Type-Options: nosniff 很有必要。

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

常见问题与注意点

1. 不要依赖前端限制

前端限制文件类型、大小、数量只能改善体验,不能作为安全边界。服务端必须重复校验,并且校验逻辑要靠近最终写入点。

2. 文件大小限制要分层设置

只在业务代码里判断大小可能太晚了。建议同时在网关、Nginx、Spring 配置中限制,避免大文件在进入业务逻辑前就消耗过多资源。

spring:
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 20MB

3. 压缩包要防止解压风险

如果业务允许上传压缩包并在服务端解压,需要额外检查解压后的路径、文件数量、总大小、压缩比和嵌套层级。不要直接信任压缩包内的文件名。

4. 预览服务要做隔离

Office、PDF、图片预览经常依赖第三方解析库,历史上这类库出现过不少解析漏洞。高安全要求场景下,建议将预览服务放到独立容器或沙箱环境,限制网络访问和文件系统权限。

5. 日志不要记录敏感文件内容

排查上传问题时,日志记录文件名、大小、类型、文件 ID 通常已经够用,不要把文件内容、身份证件信息、合同正文等写入日志。

审计清单

检查项建议
文件名服务端生成存储名,原始名仅用于展示且需过滤
路径使用 Path API 规范化,并校验不能逃逸根目录
类型白名单校验,结合后缀、文件头和解析验证
存储上传目录与 Web 根目录、静态资源目录隔离
权限下载和预览接口按文件 ID 做用户/租户级鉴权
大小网关、容器、应用多层限制
响应头设置 nosniff,下载场景优先 attachment
预览高风险解析逻辑隔离运行,限制权限

总结

文件上传风险的核心不是某一个校验点,而是整条链路的边界控制。审计时建议从“输入是否可信、路径是否可控、类型是否准确、存储是否隔离、访问是否鉴权、解析是否安全”这几个维度逐项排查。

从工程角度看,比较稳的方案是:服务端生成文件名、固定根目录存储、白名单类型校验、下载接口按文件 ID 授权、预览服务隔离、响应头保守处理。这样即使某个环节出现疏漏,也不至于把风险直接扩大到代码执行、越权访问或敏感文件泄露。

这类问题适合沉淀成团队级组件,而不是每个业务接口各写一套。统一上传 SDK、统一存储服务和统一下载鉴权,通常比单点修补更可靠。

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

0篇意见

推荐意见

没有意见。

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

SiteMap HACKHAT © 2026 All rights reserved.