跳转到帖子

背景

文件上传一直是 Web 安全里比较常见、也比较容易被低估的一类风险。很多项目在上线前会做登录、权限、SQL 注入等检查,但上传模块往往只验证了前端扩展名,或者简单判断 Content-Type,导致后续出现任意文件写入、存储型 XSS、敏感文件覆盖等问题。

这篇文章整理一次代码审计中的典型排查思路,重点放在如何定位风险点、如何验证安全边界、以及如何做可维护的加固。内容不涉及真实目标攻击,也不提供恶意利用链,只讨论防护和审计方法。

问题场景

审计对象是一个常见的后台管理系统,提供头像、附件、富文本图片三类上传入口。代码结构大致如下:

src/
  controller/
    UploadController.java
  service/
    FileStorageService.java
  config/
    WebMvcConfig.java
static/
  upload/

上传接口功能正常,业务上允许上传图片和部分文档。初看代码时,几个明显的关注点包括:

  • 文件类型是否只依赖前端或请求头判断。
  • 上传目录是否位于 Web 可访问路径。
  • 文件名是否使用原始文件名,是否存在路径穿越风险。
  • 是否存在覆盖已有文件、软链接写入等问题。
  • 上传后的访问路径是否经过权限控制。

技术分析

审计时先看 Controller 层,发现接口大致类似下面这种写法:

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

    if (!Arrays.asList("jpg", "png", "gif", "pdf").contains(ext)) {
        return Result.fail("unsupported file type");
    }

    Path target = Paths.get("static/upload/" + originalName);
    file.transferTo(target);

    return Result.ok("/upload/" + originalName);
}

这段代码看起来做了扩展名限制,但实际上存在多个问题。

  • 扩展名判断未统一大小写,也没有处理无扩展名、多个点、特殊字符等情况。
  • 直接使用 originalFilename,可能引入路径穿越或特殊文件名问题。
  • 存储目录在 static 下,上传后直接暴露给 Web 访问。
  • 没有校验文件真实内容,Content-Type 和后缀都可能不可靠。
  • 文件名未重命名,可能覆盖已有文件,也可能造成缓存或引用混乱。

这里需要强调一点:文件上传风险不只等于“上传脚本执行”。在不同技术栈下,真正导致事故的点可能是图片 SVG 触发 XSS、HTML 文件被浏览器解析、PDF 携带主动内容、压缩包解压造成目录穿越,或者附件被公开访问导致敏感信息泄露。

关键审计步骤

1. 梳理所有上传入口

不要只看名为 upload 的接口。实际项目里常见入口还包括头像、导入、附件、富文本编辑器、工单评论、插件安装、主题上传、数据恢复等。可以通过关键字先做粗筛:

grep -R "MultipartFile\|RequestPart\|getOriginalFilename\|transferTo\|FileOutputStream" -n src/
grep -R "upload\|attach\|avatar\|import\|editor" -n src/

如果是 Node.js、PHP、Python 项目,也可以按框架特征搜索,例如 multer、formidable、move_uploaded_file、werkzeug FileStorage 等。

2. 确认文件名处理逻辑

原始文件名不应直接进入最终路径。更稳妥的做法是服务端生成随机文件名,并保留经过白名单判断后的扩展名。例如:

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

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

String safeName = UUID.randomUUID().toString().replace("-", "") + "." + ext;

同时要注意路径拼接后的归一化校验,避免出现 ../、绝对路径、反斜杠混用等情况:

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

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

3. 不只校验后缀,还要校验内容

后缀和 Content-Type 都只能作为辅助信息。对于图片类文件,建议读取文件头并尝试使用图像库解码;对于文档类文件,至少要做 MIME 探测和扩展名一致性检查。

String detectedType = Files.probeContentType(tempFile);
Set<String> allowMime = Set.of("image/jpeg", "image/png", "image/gif", "application/pdf");

if (!allowMime.contains(detectedType)) {
    throw new IllegalArgumentException("invalid mime type");
}

需要注意,Files.probeContentType 在不同系统上的结果可能不完全一致,生产环境不要只依赖这一项。更稳的方式是结合 Apache Tika、magic number、实际解码结果和业务白名单共同判断。

4. 上传目录尽量放在 Web 根目录之外

如果业务允许,上传文件应存储在 Web 根目录之外,通过受控下载接口读取。这样可以统一做鉴权、审计、限速和响应头控制。

@GetMapping("/files/{id}")
public ResponseEntity<Resource> download(@PathVariable String id, Principal principal) {
    FileMeta meta = fileService.findAndCheckPermission(id, principal);
    Resource resource = fileService.loadAsResource(meta.getStorageName());

    return ResponseEntity.ok()
        .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + meta.getOriginalName() + "\"")
        .header(HttpHeaders.X_CONTENT_TYPE_OPTIONS, "nosniff")
        .contentType(MediaType.APPLICATION_OCTET_STREAM)
        .body(resource);
}

如果确实需要图片可直接访问,也建议使用独立域名或对象存储域名,并设置合理的 Content-Type、Content-Disposition、CSP 和 nosniff,降低同源风险。

5. 控制文件大小、数量和处理链路

上传安全不只是类型判断。大文件、畸形图片、压缩包炸弹也可能导致服务异常。常见限制包括:

  • 单文件大小限制,例如头像 2 MB、普通附件 20 MB。
  • 用户级、租户级、接口级上传频率限制。
  • 异步扫描或处理队列,避免请求线程中执行重 CPU 任务。
  • 图片重编码,去除潜在的附加内容和异常元数据。
  • 压缩包类文件默认不在线解压;如需解压,必须限制层级、文件数、总大小和目标目录。

Spring Boot 中可以先从配置层限制请求体大小:

spring.servlet.multipart.max-file-size=20MB
spring.servlet.multipart.max-request-size=25MB

Nginx 侧也要有对应限制,避免大请求直接打到后端:

server {
    client_max_body_size 25m;
}

6. 关注响应头和浏览器解析行为

很多上传问题最后是由浏览器解析行为放大的。例如用户上传了 HTML、SVG、XML,服务端又以 text/html 或 image/svg+xml 形式返回,并且与主站同源,就可能引入脚本执行风险。

下载类接口建议默认使用:

Content-Disposition: attachment
X-Content-Type-Options: nosniff
Content-Type: application/octet-stream

如果必须在线预览,应按文件类型拆分策略,不要所有文件都走同一套响应头。SVG 这类格式虽然属于图片,但在很多场景下不建议允许普通用户上传并同源预览。

一个相对稳妥的处理流程

结合上面的点,上传模块可以按以下流程设计:

  • 先接收文件到临时目录,不直接落最终可访问路径。
  • 检查文件大小、扩展名、MIME、magic number、业务类型是否匹配。
  • 图片类文件尝试解码,并按业务需要重编码。
  • 服务端生成随机存储名,原始文件名只作为展示字段保存。
  • 最终路径做 normalize 和 startsWith 校验。
  • 文件元数据入库,包括创建人、业务归属、大小、哈希、存储路径、状态。
  • 访问文件时走鉴权接口或隔离域名,不直接暴露内部路径。
  • 记录上传、下载、删除日志,便于后续追溯。

代码审计时容易忽略的细节

  • 富文本编辑器通常自带上传接口,权限校验容易弱于后台接口。
  • 导入功能可能接收 Excel、CSV、ZIP,后续解析链路也要审计。
  • 文件删除接口如果按路径删除,可能存在越权或路径穿越风险。
  • 头像裁剪、图片压缩等二次处理组件可能存在解析漏洞,需要关注依赖版本。
  • 对象存储 Bucket 权限配置要单独检查,避免误设为公共读写。
  • 多租户系统要确认文件 ID 是否可被横向枚举访问。

注意点

在验证上传安全时,建议只在授权测试环境中进行,并控制样本内容,不要向生产系统上传带有破坏性或敏感特征的文件。对于第三方系统,重点应放在配置核查、代码审计和安全基线建设,不应进行未授权测试。

另外,安全策略不能只靠单点判断。扩展名白名单、内容识别、路径隔离、访问鉴权、响应头控制、日志审计组合起来,才比较接近可落地的防线。

总结

文件上传模块的审计核心不是找到某一个“可疑后缀”,而是确认整个文件生命周期是否受控:从接收、校验、存储、处理、访问到删除,每一步都可能引入风险。实际项目中,优先处理原始文件名直接落盘、Web 根目录存储、缺少鉴权访问、类型校验薄弱这几类问题,通常能显著降低风险。

如果团队准备做安全加固,可以先把上传能力抽成统一组件,业务侧只声明允许的文件类型和大小限制,底层统一完成路径、命名、检测、元数据和访问控制。这样比每个接口各写一套判断更可靠,也更方便后续审计和维护。

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

0篇意见

推荐意见

没有意见。

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