跳转到帖子

背景

文件上传是 Web 系统里很常见的功能,头像、附件、报表导入、工单截图都会用到。也正因为入口多、业务差异大,它一直是代码审计中容易反复出问题的点。很多团队已经知道要限制后缀、限制大小,但在实际项目里,问题往往出在“校验位置不对”“存储位置不对”“文件名处理不对”和“运行环境配置不对”。

这篇文章结合一次 Java Web 项目审计中遇到的上传模块,整理一套比较通用的排查思路。内容偏防守和复盘,不涉及利用链构造,也不提供攻击性操作步骤,重点放在如何识别风险、验证风险和落地修复。

典型场景

项目采用 Spring Boot,提供一个附件上传接口。上传后的文件会写入服务器本地目录,并将访问路径保存到数据库。业务侧要求支持图片、PDF、Office 文档等类型。审计时看到的简化代码大致如下:

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

    String newName = UUID.randomUUID().toString() + suffix;
    File target = new File(uploadPath + "/" + newName);
    file.transferTo(target);

    return Result.ok("/uploads/" + newName);
}

这段代码看上去已经做了随机文件名,也没有直接使用用户传入的完整文件名。但从审计角度看,它仍然有几个需要继续确认的点:

  • 是否校验了允许上传的文件类型。
  • 是否只依赖后缀判断文件类型。
  • 上传目录是否位于 Web 可直接解析的目录下。
  • 是否对文件大小、数量和并发做了限制。
  • 是否存在路径拼接、目录穿越、特殊文件名问题。
  • 上传后的文件是否会被其他功能二次处理,例如预览、解压、转码。

问题分析

1. 后缀校验不是文件类型校验

很多上传逻辑会使用后缀白名单,例如只允许 jpg、png、pdf、docx。这个方向没问题,但不能把后缀当成唯一依据。后缀来自用户输入,天然不可信。更稳妥的做法是同时检查扩展名、MIME 类型和文件头,并且以后端检测结果为准。

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

private boolean isAllowedExt(String filename) {
    if (filename == null || !filename.contains(".")) {
        return false;
    }
    String ext = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
    return ALLOWED_EXT.contains(ext);
}

注意,Content-Type 也不能单独信任,因为它来自请求头。建议把它作为辅助信号,再结合 Apache Tika、文件魔数检测或业务侧更严格的解析器。

// 示例:使用 Apache Tika 做类型识别,结果仍建议与扩展名白名单交叉验证
Tika tika = new Tika();
String detectedType = tika.detect(file.getInputStream());

if (!Set.of("image/jpeg", "image/png", "application/pdf").contains(detectedType)) {
    throw new IllegalArgumentException("unsupported file type");
}

2. 上传目录不要放在可执行路径下

审计时最需要确认的一点是:上传目录是否会被 Web 容器直接访问并解析。如果应用把文件写到静态资源目录、Web 根目录,或者某些会被脚本引擎解析的位置,风险会明显放大。

比较安全的设计是:上传文件保存到 Web 根目录之外,仅通过受控接口读取。读取接口负责鉴权、类型设置、文件名映射和下载头控制。

# 示例配置:文件存储目录放在应用目录之外
app.upload-dir=/data/app_uploads/business_attachment
@GetMapping("/files/{id}")
public ResponseEntity<Resource> download(@PathVariable Long id) {
    Attachment meta = attachmentService.getAndCheckPermission(id);
    Resource resource = fileStorage.load(meta.getStorageName());

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

如果业务确实需要图片在线展示,也建议通过专门的文件服务或对象存储访问,并设置合理的响应头,避免上传内容被浏览器当作 HTML、SVG 或脚本执行。

3. 文件名处理容易被忽略

即使最终存储名使用 UUID,也不要完全忽略原始文件名。原始文件名通常会被保存到数据库,用于页面展示或下载时的文件名。如果没有处理,可能引入 XSS、响应头注入、日志污染等问题。

建议策略:

  • 存储文件名和展示文件名分离。
  • 展示文件名做长度限制和字符过滤。
  • 下载时对 filename 做安全编码。
  • 不要把原始文件名直接拼接到路径中。
private String sanitizeDisplayName(String filename) {
    if (filename == null) {
        return "unknown";
    }
    String name = Paths.get(filename).getFileName().toString();
    name = name.replaceAll("[\\r\\n\u0000]", "");
    name = name.replaceAll("[\\\\/:*?\"<>|]", "_");
    if (name.length() > 120) {
        name = name.substring(0, 120);
    }
    return name;
}

4. 路径拼接要防止越界写入

虽然 MultipartFile 的 originalFilename 在多数场景下只是文件名,但不能假设所有客户端都会按正常浏览器行为提交。只要代码中存在路径拼接,就应使用 canonical path 或 normalize 后确认目标路径仍在预期目录内。

Path baseDir = Paths.get(uploadDir).toAbsolutePath().normalize();
Path target = baseDir.resolve(storageName).normalize();

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

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

这里的 storageName 应该由服务端生成,不应直接来自用户输入。即便如此,边界校验仍然值得保留,因为后续维护中很容易有人把参数改成可控值。

5. 上传后的二次处理风险

很多严重问题不是发生在上传瞬间,而是发生在后续处理环节。例如:

  • 图片压缩、EXIF 读取、缩略图生成。
  • Office 文档预览转换。
  • 压缩包解压。
  • PDF 转图片。
  • 导入 Excel 后解析公式或外部引用。

这些处理通常依赖第三方库或外部程序。审计时应关注版本、解析参数、临时目录权限、超时控制和资源限制。特别是压缩包处理,需要防止 Zip Slip、压缩炸弹和超大文件展开。

Path destDir = Paths.get("/data/app_uploads/unzip").toAbsolutePath().normalize();

try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) {
    ZipEntry entry;
    while ((entry = zis.getNextEntry()) != null) {
        Path out = destDir.resolve(entry.getName()).normalize();
        if (!out.startsWith(destDir)) {
            throw new SecurityException("zip entry path traversal detected");
        }
        // 还应增加总大小、单文件大小、文件数量等限制
    }
}

关键排查步骤

实际审计时,我一般按下面顺序过一遍,效率比较高。

1. 定位所有上传入口

先不要只搜 upload。建议结合框架特征和参数类型搜索:

# Java 项目常用搜索关键词
MultipartFile
@RequestParam("file")
CommonsMultipartFile
Part
transferTo
Files.copy
FileOutputStream
ObjectMetadata
putObject

如果项目接了对象存储,还要看 putObject、presigned URL、临时凭证生成逻辑。很多团队把“直传对象存储”当作天然安全,其实签名策略、回调校验、文件类型限制仍然要审。

2. 梳理数据流

每个上传入口至少记录几项信息:

检查项关注点
入口接口是否需要登录,是否有业务权限校验
文件类型扩展名、MIME、文件头是否交叉验证
存储位置是否在 Web 根目录外,是否可被脚本解析
文件命名是否服务端生成,是否保留原名
访问方式是否通过受控下载接口,是否有鉴权
后续处理是否解压、转码、预览、解析内容

3. 做低风险验证

验证阶段不建议上来就尝试危险载荷。可以先用普通文本、伪造后缀、异常文件名、超大文件、空文件等方式验证边界条件。重点确认服务端是否按预期拒绝、日志是否清晰、异常是否泄露路径信息。

# 构造一个简单的测试文件,不包含攻击性内容
printf 'test file' > sample.txt

# 观察接口是否仅因后缀放行,而不是基于内容识别
cp sample.txt sample.jpg

如果 sample.jpg 被当作真实图片处理,就说明类型识别不足。这个验证不涉及攻击行为,但足以证明控制点存在缺陷。

4. 检查运行环境配置

代码修好了,环境没配好也会出问题。需要关注 Nginx、Tomcat、静态资源映射和对象存储策略。

# Nginx 示例:上传目录只作为下载资源时,禁止自动嗅探
add_header X-Content-Type-Options nosniff;
add_header Content-Security-Policy "default-src 'none'; img-src 'self' data:; style-src 'self'";

# 对不需要在线预览的附件,建议统一 attachment 下载
add_header Content-Disposition "attachment";

如果有历史原因必须暴露某个上传目录,至少要确认该目录不会被解释执行,不支持目录列表,不允许上传覆盖已有关键文件。

加固建议

  • 使用白名单策略,不使用黑名单策略。黑名单永远容易漏。
  • 扩展名、MIME、文件头三者交叉校验,结果不一致时拒绝或进入人工审核。
  • 服务端生成存储文件名,不信任客户端文件名。
  • 上传目录放在 Web 根目录之外,通过受控接口读取。
  • 限制单文件大小、用户总配额、上传频率和并发。
  • 对图片、PDF、Office 等复杂格式的解析库保持更新。
  • 对解压、转码、预览任务设置超时、内存和输出大小限制。
  • 下载接口做鉴权,不要只靠不可猜测 URL。
  • 记录上传人、业务 ID、文件摘要、检测结果和访问日志,便于追溯。
  • 对历史文件做一次离线扫描,修代码不等于历史风险自动消失。

一个相对稳妥的上传流程

综合下来,一个比较稳妥的流程可以设计成这样:

1. 鉴权与业务权限校验
2. 检查文件是否为空、大小是否超限
3. 规范化原始文件名,仅用于展示
4. 校验扩展名白名单
5. 读取文件头并识别真实类型
6. 扩展名与真实类型交叉验证
7. 生成服务端存储名
8. 写入 Web 根目录之外的存储目录
9. 计算文件哈希并记录元数据
10. 如需异步处理,进入隔离队列并设置资源限制
11. 下载或预览统一走受控接口

这里最重要的是“受控”。文件上传不是一个简单的 IO 功能,而是一个把外部不可信内容引入系统内部的入口。只要后续有解析、展示、下载、分发,就需要把它当作完整的数据流来设计。

常见误区

“已经改成 UUID 文件名了,所以安全。”

UUID 只能降低文件名猜测和覆盖风险,不能解决类型校验、解析执行、越权访问等问题。

“对象存储托管了文件,所以应用没有风险。”

对象存储只是改变了存储位置。直传策略、回调可信度、桶权限、跨租户访问控制、预览域名安全头都需要检查。

“只允许图片上传,所以问题不大。”

图片同样可能触发解析库漏洞、EXIF 处理问题、SVG 脚本风险或内容嗅探问题。是否允许 SVG 要非常谨慎,很多业务其实没有必要支持。

总结

文件上传漏洞的排查不能只盯着一个后缀判断。更有效的方法是从入口、校验、存储、访问、二次处理、环境配置几个环节串起来看。审计时优先确认三件事:上传内容是否可信地被识别,上传结果是否可能被执行或误解析,上传后的访问是否受控。

对业务系统来说,最实际的修复路线通常不是追求一次性做得很复杂,而是先完成几项基础改造:白名单校验、Web 根目录外存储、受控下载、大小和频率限制、日志追踪。把这些做好,绝大多数上传类风险都能被压到可控范围内。

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

0篇意见

推荐意见

没有意见。

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