跳转到帖子

背景

文件上传是 Web 系统里很常见的能力,头像、附件、导入模板、工单截图都会用到。也正因为它和文件系统、对象存储、解析器、访问控制都有关,实际审计中经常成为风险集中点。

这篇文章整理一次偏真实场景的代码审计思路,重点放在如何识别上传链路中的设计缺陷,以及如何做可落地的修复。内容以防御和审计为主,不涉及未授权入侵或恶意利用引导。

典型场景

某业务系统提供附件上传接口,后端使用 Java/Spring Boot,文件最终落到本地磁盘或对象存储。简化后的接口逻辑大致如下:

@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws Exception {
    String originalName = file.getOriginalFilename();
    String suffix = originalName.substring(originalName.lastIndexOf("."));
    String filename = UUID.randomUUID().toString() + suffix;

    File dest = new File("/data/upload/" + filename);
    file.transferTo(dest);

    return "/upload/" + filename;
}

这段代码看起来已经使用 UUID 重命名,避免了直接覆盖文件的问题。但从安全审计角度看,仍然需要继续追问:允许上传什么类型?文件内容是否可信?文件是否会被 Web 容器解析?访问是否需要鉴权?文件大小和数量是否有限制?

问题拆解

文件上传风险通常不是单点问题,而是多个薄弱点叠加后的结果。审计时可以从以下几个方面展开。

一、仅依赖后缀名判断不可靠

很多系统会通过后缀名限制上传类型,例如只允许 jpg、png、pdf、docx。这是必要措施,但不能作为唯一判断依据。

常见问题包括:

  • 后缀名来自用户输入,可被随意修改。
  • 大小写、空格、特殊字符处理不一致,例如 .JPG、.jpg 、file.png.pdf。
  • 服务端和下游解析组件对文件类型的判断方式不同。
  • 某些格式本身是容器格式,例如 docx、xlsx、jar、zip,需要额外检查内部结构。

更稳妥的做法是同时检查扩展名、MIME 类型、文件头魔数,并根据业务需要做内容级校验。示例:

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

private boolean isAllowed(MultipartFile file) throws IOException {
    String originalName = file.getOriginalFilename();
    String ext = FilenameUtils.getExtension(originalName).toLowerCase(Locale.ROOT);
    if (!ALLOW_EXT.contains(ext)) {
        return false;
    }

    String contentType = file.getContentType();
    if (contentType == null) {
        return false;
    }

    byte[] header = file.getInputStream().readNBytes(8);
    if (ext.equals("png")) {
        return header.length >= 8
            && header[0] == (byte)0x89
            && header[1] == 0x50
            && header[2] == 0x4E
            && header[3] == 0x47;
    }

    if (ext.equals("jpg") || ext.equals("jpeg")) {
        return header.length >= 3
            && header[0] == (byte)0xFF
            && header[1] == (byte)0xD8
            && header[2] == (byte)0xFF;
    }

    if (ext.equals("pdf")) {
        return header.length >= 4
            && header[0] == 0x25
            && header[1] == 0x50
            && header[2] == 0x44
            && header[3] == 0x46;
    }

    return false;
}
注意:文件头校验也不是万能的,但它能有效减少低成本伪造。对于高风险业务,建议使用成熟库进行文件类型识别,并结合业务语义做更严格校验。

二、上传目录不应具备脚本执行能力

一个常见错误是把上传目录放在 Web 根目录下,并且该目录可能被应用服务器、脚本引擎或中间件解析。即使系统限制了后缀名,也可能因为解析规则、历史配置或组件差异引入风险。

推荐原则:

  • 上传文件存储目录与应用部署目录隔离。
  • 上传目录只作为静态文件读取,不允许执行脚本。
  • 优先使用对象存储,并通过受控下载接口或签名 URL 访问。
  • 文件访问域名与主业务域名隔离,降低同源风险。

Nginx 静态文件服务可做基础限制,例如禁止解析敏感后缀、强制下载部分类型:

server {
    listen 80;
    server_name files.example.com;

    root /data/upload;

    location / {
        autoindex off;
        try_files $uri =404;
    }

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

    add_header X-Content-Type-Options nosniff;
}

如果业务允许用户上传 HTML、SVG、XML 等可包含脚本或外部引用的内容,建议默认按附件下载,或者进行严格清洗。很多 XSS 问题不是出在传统表单,而是出在“可预览附件”。

三、文件名和路径处理要避免路径穿越

即使服务端使用 UUID 生成新文件名,也应避免在任何位置直接拼接用户传入路径。尤其是“分片上传”“导入任务”“解压上传包”这类功能,路径穿越风险更常见。

错误写法通常类似:

String savePath = baseDir + "/" + request.getParameter("dir") + "/" + file.getOriginalFilename();
file.transferTo(new File(savePath));

更安全的处理方式是固定根目录,规范化后检查目标路径是否仍位于根目录下:

Path base = Paths.get("/data/upload").toAbsolutePath().normalize();
Path target = base.resolve(filename).normalize();

if (!target.startsWith(base)) {
    throw new IllegalArgumentException("invalid path");
}

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

这里有两个关键点:一是不要信任用户传入的目录;二是必须在 normalize 后做 startsWith 检查,避免 ../ 这类路径被解析到根目录之外。

四、大小、数量和频率限制不能缺位

上传接口经常被忽略资源消耗问题。即使没有代码执行风险,也可能因为超大文件、海量小文件、压缩包炸弹导致磁盘、内存、CPU 或对象存储费用异常。

建议至少设置以下限制:

  • 单文件大小限制。
  • 单用户每日上传总量限制。
  • 单业务对象附件数量限制。
  • 上传接口频率限制。
  • 临时文件定期清理。
  • 压缩包解压后的总大小、文件数量、目录深度限制。

Spring Boot 可以先做基础配置:

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

但这只是框架层限制,业务层仍然需要做配额控制。例如同一个工单最多允许 10 个附件,同一个用户每天最多上传 200MB。框架限制解决的是请求层问题,业务限制解决的是滥用问题。

五、访问控制常被遗漏

不少系统上传时做了鉴权,但下载时直接返回静态路径,例如:

 URL 可被猜测、可被枚举,或者附件本身包含敏感信息,就会形成越权访问风险。尤其是工单、合同、身份证明、后台导出文件等场景,不能只依赖“文件名足够随机”。

更推荐的方式:

  • 文件元数据入库,记录 owner、业务对象 ID、权限范围、文件状态。
  • 下载走后端接口,根据当前用户权限判断是否可访问。
  • 对象存储使用短期签名 URL,不长期暴露公共读地址。
  • 敏感附件避免被搜索引擎索引,文件域名关闭目录浏览。

下载接口可以保持简单,但权限判断不能省:

@GetMapping("/files/{id}")
public ResponseEntity<Resource> download(@PathVariable Long id, Principal principal) {
    FileMeta meta = fileService.getMeta(id);

    if (!permissionService.canRead(principal.getName(), meta.getBizType(), meta.getBizId())) {
        return ResponseEntity.status(403).build();
    }

    Resource resource = storageService.load(meta.getStorageKey());
    return ResponseEntity.ok()
        .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + meta.getSafeName() + "\"")
        .contentType(MediaType.APPLICATION_OCTET_STREAM)
        .body(resource);
}

六、压缩包上传需要单独审计

导入功能常支持 zip 包上传,风险比普通附件更复杂。除了路径穿越,还要关注压缩包炸弹、符号链接、嵌套压缩、超长文件名、特殊字符文件名等问题。

解压时建议做硬限制:

  • 总解压大小上限。
  • 单文件大小上限。
  • 文件数量上限。
  • 目录层级上限。
  • 禁止符号链接或特殊文件。
  • 解压目标路径必须做 normalize 校验。

伪代码思路如下:

long totalSize = 0;
int fileCount = 0;
Path base = Paths.get("/data/import/tmp").toAbsolutePath().normalize();

while ((entry = zip.getNextEntry()) != null) {
    if (entry.isDirectory()) {
        continue;
    }

    fileCount++;
    if (fileCount > 1000) {
        throw new IllegalArgumentException("too many files");
    }

    Path target = base.resolve(entry.getName()).normalize();
    if (!target.startsWith(base)) {
        throw new IllegalArgumentException("invalid zip entry");
    }

    long entrySize = entry.getSize();
    if (entrySize < 0 || entrySize > 10 * 1024 * 1024) {
        throw new IllegalArgumentException("invalid file size");
    }

    totalSize += entrySize;
    if (totalSize > 100 * 1024 * 1024) {
        throw new IllegalArgumentException("zip too large");
    }

    // copy entry content with bounded stream
}

审计清单

实际做代码审计时,可以按下面这张表快速过一遍。它不一定覆盖所有场景,但能发现大部分常见问题。

检查项关注点建议
类型校验是否只看后缀名扩展名、MIME、文件头、内容语义综合判断
存储位置是否位于 Web 根目录上传目录与应用目录隔离,禁止脚本执行
文件名处理是否直接使用原始文件名服务端生成存储名,原名仅用于展示并做转义
路径拼接是否存在 ../ 或绝对路径问题使用 Path normalize 并校验根目录
访问控制下载是否鉴权通过文件 ID 下载,结合业务权限校验
资源限制是否限制大小、数量、频率框架层和业务层都要限制
预览能力是否渲染用户上传内容默认下载,预览前做清洗和安全响应头
压缩包处理是否安全解压限制数量、大小、层级,校验解压路径
日志审计是否记录上传行为记录用户、IP、文件摘要、业务对象和结果

加固后的设计建议

一个相对稳妥的上传链路可以这样设计:

  • 上传前:校验用户身份、业务权限、文件大小、文件数量。
  • 上传中:使用服务端生成的随机存储名,不信任原始文件名和路径。
  • 上传后:计算文件摘要,识别文件类型,必要时接入杀毒或内容检测。
  • 存储层:上传目录不具备执行权限,对象存储默认私有读。
  • 访问层:通过文件 ID 下载,后端做权限判断,敏感文件使用短期签名。
  • 运维层:监控磁盘、对象存储用量、异常上传频率和失败率。

响应头也建议统一处理,减少浏览器嗅探和内联执行带来的问题:

X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'none'; img-src 'self'; style-src 'self'
Content-Disposition: attachment; filename="safe-name.pdf"

如果确实需要在线预览,建议把预览服务和主站隔离到不同域名,并对可预览格式做白名单控制。不要为了体验直接把用户上传内容嵌到主站页面里。

日志与应急排查

上传接口的日志不要只记录“上传成功”。建议至少包含:

  • 用户 ID、角色、来源 IP、User-Agent。
  • 业务对象 ID,例如工单 ID、项目 ID。
  • 原始文件名、服务端存储 key、文件大小、文件摘要。
  • 识别出的文件类型和最终处理结果。
  • 失败原因,例如类型不允许、超限、权限不足。

当发现异常上传行为时,可以通过文件摘要快速定位重复文件,通过业务对象 ID 判断影响范围,通过访问日志确认是否被下载或预览。这些信息在事后复盘里很有价值。

总结

文件上传安全不能只靠“限制后缀名”解决。比较可靠的做法是把上传当成一条完整链路来看:入口校验、存储隔离、权限控制、资源限制、内容识别、日志审计都要覆盖。

从代码审计经验看,真正容易出问题的地方往往不是某一行明显危险函数,而是业务为了方便做了默认公开访问、直接预览、路径拼接、解压导入等设计。审计时多沿着文件从上传到访问的生命周期走一遍,通常能发现不少隐藏风险。

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

0篇意见

推荐意见

没有意见。

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

SiteMap HACKHAT © 2026 All rights reserved.