跳转到帖子

背景

文件上传一直是 Web 安全里比较常见但也容易被低估的一类风险。很多团队在设计上传接口时,会关注文件大小、后缀白名单、登录态校验,但容易忽略存储路径、访问路径、文件内容校验以及后续处理链路。一旦这些环节组合不当,就可能从普通的文件上传问题扩大成敏感文件覆盖、存储型 XSS、任意文件落盘,甚至影响后端解析逻辑。

这篇文章整理一次比较典型的 Spring Boot 文件上传风险排查过程。内容以代码审计和本地复现为主,重点放在如何发现问题、如何验证风险边界、以及如何给出可落地的修复建议。文中示例均为本地测试环境,不涉及真实目标攻击。

问题场景

某业务系统提供头像、附件、富文本图片等上传能力,后端使用 Spring Boot 实现,上传后的文件会保存到服务器本地目录,并通过静态资源映射对外访问。简化后的代码如下:

@PostMapping("/upload")
public Map<String, Object> upload(@RequestParam("file") MultipartFile file) throws IOException {
    String originalFilename = file.getOriginalFilename();
    String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));

    if (!Arrays.asList(".jpg", ".png", ".gif", ".pdf").contains(suffix)) {
        throw new RuntimeException("file type not allowed");
    }

    String fileName = System.currentTimeMillis() + "_" + originalFilename;
    File dest = new File("/data/app/upload/" + fileName);
    file.transferTo(dest);

    Map<String, Object> result = new HashMap<>();
    result.put("url", "/files/" + fileName);
    return result;
}

静态资源映射如下:

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/files/**")
                .addResourceLocations("file:/data/app/upload/");
    }
}

从表面看,这段代码做了后缀限制,也没有直接执行上传文件。但在实际审计中,仍然可以看到几个值得关注的问题。

技术分析

一、文件名直接拼接导致路径风险

代码直接使用 originalFilename 拼接服务端路径。如果框架、容器或中间代理没有对文件名做严格处理,攻击者可能尝试构造包含路径分隔符的文件名,造成目录穿越式落盘。不同环境下行为会有差异,所以不能只依赖“本地测不出来”来判断安全。

需要重点关注这些输入形态:

  • ../、..\ 这类路径跳转字符
  • URL 编码后的路径分隔符
  • Windows 与 Linux 路径差异
  • 包含空格、控制字符、Unicode 混淆字符的文件名

审计时建议检查是否存在类似写法:

new File(basePath + filename)
Paths.get(basePath, filename)
file.transferTo(new File(uploadDir, originalFilename))

正确思路不是清理用户传入文件名后继续使用,而是彻底放弃用户文件名作为落盘文件名,只把原始文件名作为展示字段保存到数据库。

二、只校验后缀不足以判断文件类型

后缀白名单只能提供很弱的约束。比如一个 HTML 内容的文件可以命名为 a.jpg,如果后续响应头或访问方式处理不当,仍可能造成浏览器侧风险。富文本场景中尤其常见:上传文件被直接返回 URL,前端直接渲染或插入到页面中。

相对稳妥的做法是组合校验:

  • 后缀白名单:限制用户可提交的文件类型
  • MIME 类型校验:使用服务端检测结果,不完全信任客户端上传的 Content-Type
  • 文件魔数校验:对图片、PDF 等常见类型做头部特征判断
  • 图片重编码:头像、图片类文件可用服务端库重新解码并编码,去除异常内容

例如图片类文件可以做基本魔数判断:

private boolean isAllowedImage(byte[] header) {
    // JPEG: FF D8 FF
    if (header.length >= 3
            && (header[0] & 0xFF) == 0xFF
            && (header[1] & 0xFF) == 0xD8
            && (header[2] & 0xFF) == 0xFF) {
        return true;
    }

    // PNG: 89 50 4E 47 0D 0A 1A 0A
    if (header.length >= 8
            && (header[0] & 0xFF) == 0x89
            && header[1] == 0x50
            && header[2] == 0x4E
            && header[3] == 0x47
            && header[4] == 0x0D
            && header[5] == 0x0A
            && header[6] == 0x1A
            && header[7] == 0x0A) {
        return true;
    }

    return false;
}

三、静态资源响应头会影响风险边界

上传目录被映射成 /files/** 后,文件内容会由 Web 服务直接返回。此时需要关注响应头,尤其是:

  • Content-Type:是否可能被识别成 HTML、SVG、XML 等可执行上下文
  • X-Content-Type-Options: nosniff:是否启用,避免浏览器 MIME 嗅探
  • Content-Disposition:附件类文件是否强制下载
  • CSP:页面是否允许加载不可信上传内容

如果业务不需要浏览器直接内联展示 PDF、SVG、HTML 等内容,建议统一使用附件下载方式返回,减少浏览器解析带来的额外风险。

Content-Disposition: attachment; filename="safe-name.pdf"
X-Content-Type-Options: nosniff

四、上传目录不应位于可执行路径

Spring Boot 本身不会像传统 PHP/JSP 站点那样把上传的脚本文件直接执行,但这不代表可以忽略上传目录位置。比较常见的问题是:同一套代码被部署到不同架构时,上传目录可能被 Nginx、Tomcat、对象存储网关或其他服务以不同方式暴露。

建议遵循几个原则:

  • 上传目录与应用部署目录分离
  • 上传目录只允许应用进程写入,避免被其他服务误解析
  • 静态访问服务只提供读取能力,不提供脚本解析能力
  • 对象存储桶关闭公共写入,访问权限按业务拆分

关键步骤:本地复现与验证方法

排查这类问题时,不建议一上来就追求“打出结果”,更应该先确认风险边界。下面是我常用的一套本地验证流程。

1. 搭建最小复现环境

准备一个仅包含上传接口和静态映射的 Spring Boot 项目,使用本地目录作为上传路径,便于观察实际落盘文件。

mkdir -p /tmp/upload-test
ls -la /tmp/upload-test

配置上传大小限制,避免测试过程中影响环境:

spring.servlet.multipart.max-file-size=2MB
spring.servlet.multipart.max-request-size=2MB

2. 验证文件名处理

使用正常文件名、长文件名、包含特殊字符的文件名分别上传,观察服务端真实落盘结果。重点看日志中记录的原始文件名和最终文件名是否一致。

curl -i -X POST  \
  -F "[email protected];filename=normal.png"

对于路径相关问题,建议只在本地可控环境做验证,确认框架和运行系统如何处理异常文件名。生产环境排查时更适合通过代码审计和日志分析判断,不应随意构造破坏性请求。

3. 验证内容类型和响应头

上传一个合法图片后,查看访问 URL 的响应头:

curl -I  Content-Type,以及是否存在 X-Content-Type-Options。如果系统允许上传 PDF、SVG、TXT 等类型,还应逐类确认浏览器行为。

4. 验证覆盖与重复文件问题

示例代码使用时间戳加原始文件名生成文件名,在高并发或毫秒级重复上传情况下仍有碰撞概率。虽然概率不高,但不建议依赖时间戳作为唯一标识。更好的方案是使用随机 UUID 或内容哈希,并保留扩展名。

String ext = ".png";
String safeName = UUID.randomUUID().toString().replace("-", "") + ext;
Path target = uploadRoot.resolve(safeName).normalize();

同时要确保 normalize() 后的路径仍位于上传根目录内:

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

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

修复建议

下面是一份相对完整的安全上传处理示例,核心思路是:不信任原始文件名,不只依赖后缀,限制大小和类型,落盘路径固定,响应时控制头部。

@PostMapping("/upload")
public Map<String, Object> upload(@RequestParam("file") MultipartFile file) throws IOException {
    if (file.isEmpty()) {
        throw new IllegalArgumentException("empty file");
    }

    if (file.getSize() > 2 * 1024 * 1024) {
        throw new IllegalArgumentException("file too large");
    }

    String originalName = Optional.ofNullable(file.getOriginalFilename()).orElse("");
    String ext = getSafeExt(originalName);
    if (!Set.of(".jpg", ".jpeg", ".png").contains(ext)) {
        throw new IllegalArgumentException("file type not allowed");
    }

    byte[] header = file.getInputStream().readNBytes(16);
    if (!isAllowedImage(header)) {
        throw new IllegalArgumentException("invalid image content");
    }

    Path uploadRoot = Paths.get("/data/app/upload").toAbsolutePath().normalize();
    Files.createDirectories(uploadRoot);

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

    try (InputStream in = file.getInputStream()) {
        Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
    }

    Map<String, Object> result = new HashMap<>();
    result.put("url", "/files/" + safeName);
    return result;
}

private String getSafeExt(String filename) {
    String name = Paths.get(filename).getFileName().toString();
    int index = name.lastIndexOf('.');
    if (index < 0) {
        return "";
    }
    return name.substring(index).toLowerCase(Locale.ROOT);
}

如果需要保留原始文件名用于展示,可以单独存储到数据库,但展示时仍要做 HTML 转义,下载时也要处理响应头中的文件名编码,避免响应头注入等问题。

注意点

  • 不要把客户端传来的 Content-Type 当作可信依据,它只能作为参考。
  • 不要将上传目录放在应用源码、模板目录或可被脚本解析的目录下。
  • 不要允许任意后缀,即使当前框架不会执行,也可能在后续迁移或代理配置中变成风险。
  • 头像、图片类场景尽量做重编码,降低多格式混淆和元数据带来的风险。
  • 附件下载接口建议统一设置 Content-Disposition: attachment 和 X-Content-Type-Options: nosniff。
  • 上传失败、类型不合法等错误日志要记录必要上下文,但不要把完整文件内容、敏感路径暴露给前端。
  • 对象存储场景要检查 Bucket 权限、临时凭证权限范围、回调接口鉴权和防重放。

审计检查清单

检查项风险点建议
文件名处理路径穿越、覆盖文件、特殊字符异常服务端生成随机文件名,原始文件名仅展示
类型校验伪造后缀、MIME 混淆后缀、MIME、魔数组合校验
存储位置误解析、误执行、权限扩大上传目录与应用目录隔离
访问方式浏览器解析不可信内容控制 Content-Type、nosniff、下载头
权限控制越权访问他人文件文件 ID 与用户权限绑定,避免直接暴露敏感路径
日志记录敏感路径泄露、排障困难记录文件 ID、用户、类型、大小、结果

总结

文件上传风险的难点不在于某一行代码,而在于上传、存储、访问、展示、下载这些环节经常被拆散到不同模块里。单看接口可能只是后缀校验不严,放到完整链路里就可能变成存储型 XSS、敏感文件覆盖或越权访问。

实际排查时,建议先从代码中定位文件名拼接、后缀判断、存储目录和静态映射,再结合本地环境验证框架行为。修复时不要试图靠黑名单补丁解决所有问题,稳定的方案应该是:服务端生成文件名、固定安全目录、严格类型校验、控制响应头、按业务做权限绑定。这样即使后续部署架构变化,也能把风险控制在相对可预期的范围内。

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

0篇意见

推荐意见

没有意见。

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

SiteMap HACKHAT © 2026 All rights reserved.