跳转到帖子

背景

文件上传是 Web 系统里很常见的功能,头像、附件、工单截图、导入文件都会用到。也正因为入口普遍,上传点经常成为代码审计和渗透测试中的重点关注对象。实际项目里,上传漏洞并不总是表现为“直接上传可执行脚本”这么简单,更多时候是校验链路不完整、存储目录设计不合理、文件解析行为理解偏差导致的组合风险。

这篇文章整理一次常见文件上传功能的审计思路,重点放在漏洞成因、复现验证、风险判断和修复方案上。内容仅面向授权测试和防护建设,不涉及未授权攻击利用。

典型场景

某业务系统提供附件上传功能,后端使用 Java Spring Boot,前端限制只能上传图片和 PDF。后端代码大致逻辑如下:

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

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

    String saveName = UUID.randomUUID().toString() + suffix;
    Path path = Paths.get("/data/app/uploads/" + saveName);
    Files.copy(file.getInputStream(), path);

    return "/uploads/" + saveName;
}

乍看做了扩展名白名单和随机文件名,似乎问题不大。但从审计角度看,这段逻辑仍然有几个需要确认的点。

问题分析

1. 只校验扩展名,不等于校验文件类型

扩展名来自用户输入,不能作为唯一判断依据。攻击者可以构造“内容与扩展名不一致”的文件,例如文件名是 .jpg,但实际内容不是图片。即使当前环境不会执行该文件,也可能在后续被其他组件处理时触发风险,例如图片处理库解析异常、PDF 解析器漏洞、内容嗅探导致的前端脚本执行等。

更可靠的做法是同时检查:

  • 扩展名白名单;
  • MIME 类型,但不能完全信任客户端传入的 Content-Type;
  • 文件魔数,例如 JPEG、PNG、PDF 的文件头;
  • 必要时重新编码图片,去除非预期内容。
// 示例:简单检查文件头,不应作为唯一安全边界
private boolean isPng(byte[] header) {
    byte[] png = new byte[] {(byte)0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
    return header.length >= png.length && Arrays.equals(Arrays.copyOf(header, png.length), png);
}

2. 上传目录是否会被 Web 容器直接解析

文件保存到了 /data/app/uploads/,返回路径是 /uploads/xxx。如果该目录被 Nginx 或应用静态资源映射直接暴露,需要确认它只作为静态下载目录使用,不能被脚本引擎解析。

在 Java 项目中,上传 JSP 通常只有在特定部署方式下才可能被解析,但很多历史系统会把上传目录放在 webapps 下,这类风险更高。PHP、ASP、老旧混合架构中尤其需要关注上传目录是否有执行权限。

Nginx 层面可明确禁止上传目录执行动态脚本,示例:

location /uploads/ {
    alias /data/app/uploads/;
    autoindex off;
    add_header X-Content-Type-Options nosniff;
    default_type application/octet-stream;
}

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

3. 原始文件名的处理

示例代码使用 UUID 作为保存名,这是比较好的习惯。但仍建议对 originalFilename 做最小化信任处理,因为它可能包含特殊字符、路径分隔符、空字节历史兼容问题或非常长的字符串。即使不直接作为保存路径,也可能进入日志、数据库、下载响应头,引发日志污染、响应头注入或展示层 XSS。

如果需要保存原始文件名,建议做长度限制和字符规范化:

private String normalizeFileName(String name) {
    if (name == null) return "unknown";
    name = name.replaceAll("[\\r\\n]", "");
    name = name.replaceAll("[\\\\/]", "_");
    if (name.length() > 120) {
        name = name.substring(0, 120);
    }
    return name;
}

4. 文件大小和资源消耗

上传功能容易被忽略的另一类问题是资源消耗。没有大小限制时,可能导致磁盘打满、临时目录堆积、图片解压炸弹、PDF 解析耗时过长等问题。

Spring Boot 可以在配置层限制单文件和请求总大小:

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

同时,业务层还应根据实际场景做二次限制。例如头像上传不应允许 20MB,工单附件和离线导入文件也应该分开配置。

5. 下载与预览链路同样重要

很多上传漏洞不是在上传时触发,而是在下载、预览、转码、索引时暴露。例如:

  • 下载接口直接拼接路径,导致路径穿越;
  • 预览接口将用户上传的 HTML、SVG 以内联方式展示,引发脚本执行;
  • 服务端调用第三方工具处理文档,未隔离进程权限;
  • 返回 Content-Type 不准确,浏览器发生 MIME 嗅探。

下载接口建议通过文件 ID 查询真实路径,不允许用户直接传入磁盘路径:

@GetMapping("/download/{id}")
public ResponseEntity<Resource> download(@PathVariable Long id) {
    FileRecord record = fileService.getById(id);
    if (record == null) {
        return ResponseEntity.notFound().build();
    }

    Path base = Paths.get("/data/app/uploads").toAbsolutePath().normalize();
    Path target = base.resolve(record.getSaveName()).normalize();

    if (!target.startsWith(base)) {
        return ResponseEntity.status(403).build();
    }

    Resource resource = new FileSystemResource(target);
    return ResponseEntity.ok()
        .header("Content-Disposition", "attachment; filename=\"" + record.getSafeName() + "\"")
        .header("X-Content-Type-Options", "nosniff")
        .body(resource);
}

审计时的关键检查点

检查项关注点建议
扩展名校验是否黑名单、是否大小写绕过、是否多后缀处理混乱使用白名单,统一转小写,严格解析最后一个扩展名
内容校验是否只信任 Content-Type结合魔数、文件解析、必要时重编码
保存路径是否可控、是否存在路径穿越固定目录,使用随机文件名,normalize 后校验 base path
执行权限上传目录是否可能被动态解析上传目录与应用代码目录分离,禁用脚本执行
大小限制是否可导致磁盘或内存耗尽配置层和业务层双重限制
下载预览是否内联展示不可信内容默认附件下载,设置 nosniff,谨慎开放预览

安全复现思路

在授权环境中验证文件上传安全性,不建议直接尝试高危载荷。更稳妥的方式是用无害样本确认边界条件:

  • 上传扩展名合法但内容不匹配的文件,观察是否被接受;
  • 上传超大文件,确认大小限制和错误处理;
  • 上传带特殊文件名的样本,检查日志、数据库和响应头;
  • 访问返回的文件 URL,确认是否被当作附件下载或静态资源展示;
  • 检查上传目录在服务器侧的权限和 Web 映射配置。
# 示例:生成一个内容为文本、扩展名为 jpg 的测试文件
printf 'not a real image' > test.jpg

# 示例:生成 12MB 测试文件,用于验证大小限制
 dd if=/dev/zero of=large.bin bs=1M count=12

这类验证不会涉及恶意控制行为,但足以发现“只看扩展名”“无限制上传”“错误暴露过多”等常见问题。

加固建议

  • 上传目录与应用代码目录分离,原则上只读访问,不赋予执行权限。
  • 文件名使用服务端生成的随机值,原始文件名仅作为展示字段,且必须清洗。
  • 使用白名单策略,不使用黑名单兜底。
  • 对图片类文件进行解码再编码,避免保留非预期内容。
  • 对文档预览、压缩包解压、图片处理等高风险操作做沙箱隔离。
  • 下载接口基于文件 ID,不接受用户传入任意路径。
  • 统一设置 X-Content-Type-Options: nosniff,必要时强制 attachment 下载。
  • 上传失败时返回通用错误,详细异常写入服务端日志即可。
  • 建立文件清理机制,避免临时文件和孤儿文件长期占用磁盘。
文件上传的核心不是“能不能传某个后缀”,而是整个文件生命周期是否受控:上传、存储、访问、预览、处理、清理,每个环节都可能成为风险点。

总结

文件上传漏洞看似基础,但在真实项目里往往以组合问题出现。仅依赖前端限制或简单扩展名判断是不够的,后端需要从文件类型、存储路径、访问权限、资源限制和后续处理链路一起设计。

代码审计时建议不要只盯着 upload 接口本身,还要顺着返回 URL、下载接口、预览服务、Nginx 配置和文件处理组件继续看。很多高风险问题并不在第一段上传代码里,而是在文件被“再次使用”的地方。

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

0篇意见

推荐意见

没有意见。

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