跳转到帖子

背景

文件上传是 Web 系统里很常见的功能,但也是安全问题高发点。很多漏洞并不是因为框架本身有问题,而是业务代码在文件类型判断、文件名处理、存储路径控制、访问权限隔离等环节做得不够严谨。

这篇文章整理一次偏实战的代码审计思路,以 Spring Boot 项目中的文件上传接口为例,讨论如何定位风险点、如何复现验证、以及如何做相对稳妥的修复。内容只面向防御、审计和加固,不涉及未授权攻击或恶意利用。

常见场景

审计中经常遇到这样的上传接口:前端限制只能上传图片,后端接收 MultipartFile,然后根据原始文件名拼接路径并保存到服务器某个目录。代码大致类似:

@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
    String filename = file.getOriginalFilename();
    File dest = new File("/data/upload/" + filename);
    file.transferTo(dest);
    return "/upload/" + filename;
}

这段代码看起来简单,但问题比较集中:文件名完全信任用户输入,未限制后缀,未校验真实内容,保存目录可能可被 Web 直接访问,也没有大小限制和权限隔离。

风险点分析

文件上传相关风险通常不止一个点,需要结合部署方式和业务逻辑综合判断。

  • 路径穿越:如果直接使用原始文件名,攻击者可能提交包含 ../ 或特殊路径分隔符的文件名,导致文件写入非预期目录。
  • 危险文件类型:仅依赖前端校验或 Content-Type 判断不可靠。Content-Type 可以由客户端自行指定,文件扩展名也可以伪造。
  • 双后缀与大小写绕过:例如 .jpg.jsp、.pHp、带空格或特殊字符的后缀,在不同服务器和系统上可能存在解析差异。
  • 可执行目录问题:如果上传目录被 Web 容器当作可执行目录处理,风险会明显升高。静态资源目录也要确认是否存在脚本解析能力。
  • 文件覆盖:使用原始文件名保存,容易覆盖已有文件,造成业务数据异常或安全配置被覆盖。
  • 拒绝服务:未限制文件大小、数量和解析复杂度时,可能被大文件或压缩包炸弹拖垮存储与 CPU。

审计时的定位方法

代码审计可以从入口、处理、存储、访问四个环节入手。

1. 查找上传入口

在 Java 项目中,可以先搜索以下关键字:

MultipartFile
@RequestParam("file")
CommonsMultipartFile
transferTo(
getOriginalFilename()
getInputStream()

如果项目使用对象封装,也可以搜索 Controller 层中与 upload、file、attachment、avatar、import 等相关的接口。

2. 观察文件名处理

重点看是否直接使用 file.getOriginalFilename(),以及是否做了 basename 提取、特殊字符过滤、随机重命名。

风险代码通常具有这类特征:

String name = file.getOriginalFilename();
String path = uploadDir + "/" + name;
file.transferTo(new File(path));

较好的做法是不信任原始文件名,只保留用于展示的名称,实际落盘使用服务端生成的随机文件名。

3. 检查类型校验方式

仅判断 Content-Type 通常不够:

if (file.getContentType().startsWith("image/")) {
    // 保存文件
}

更合理的方式是多层校验:后缀白名单、MIME 类型辅助判断、文件魔数校验,必要时使用图片解码库重新读取并重编码。对于头像、封面这类图片上传,重编码可以有效降低混入异常内容的风险。

4. 确认存储目录和访问方式

上传文件最好放在 Web 根目录之外,通过受控接口读取。如果必须对外静态访问,也要保证该目录不会执行脚本,不允许目录列表,并配置合适的响应头。

# Nginx 示例:上传目录仅作为静态文件返回,不做脚本解析
location /uploads/ {
    alias /data/app/uploads/;
    autoindex off;
    add_header X-Content-Type-Options nosniff;
}

如果后端还有下载接口,需要检查是否存在任意文件读取问题,尤其是根据文件名或路径参数直接读取本地文件的逻辑。

安全实现示例

下面是一个相对稳妥的上传处理思路,重点是:白名单、随机文件名、路径归一化、大小限制、非 Web 根目录存储。

private static final Set<String> ALLOWED_EXT = Set.of("jpg", "jpeg", "png", "gif", "webp");
private static final long MAX_SIZE = 2 * 1024 * 1024;
private static final Path UPLOAD_ROOT = Paths.get("/data/app/uploads").toAbsolutePath().normalize();

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

    String original = Optional.ofNullable(file.getOriginalFilename()).orElse("");
    String cleanName = Paths.get(original).getFileName().toString();
    String ext = getExt(cleanName).toLowerCase(Locale.ROOT);

    if (!ALLOWED_EXT.contains(ext)) {
        throw new IllegalArgumentException("unsupported file type");
    }

    if (!looksLikeImage(file.getInputStream())) {
        throw new IllegalArgumentException("invalid image content");
    }

    String storedName = UUID.randomUUID() + "." + ext;
    Path target = UPLOAD_ROOT.resolve(storedName).normalize();

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

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

    return Map.of("fileId", storedName);
}

private String getExt(String filename) {
    int idx = filename.lastIndexOf('.');
    if (idx < 0 || idx == filename.length() - 1) {
        return "";
    }
    return filename.substring(idx + 1);
}

其中 looksLikeImage 不建议自己简单判断几个字节就结束。可以结合业务要求选择更严格的方式,比如使用 ImageIO 读取并确认宽高,或者对图片进行重新编码。

private boolean looksLikeImage(InputStream input) throws IOException {
    BufferedImage image = ImageIO.read(input);
    if (image == null) {
        return false;
    }
    return image.getWidth() > 0 && image.getHeight() > 0;
}

配置层面的限制

除了业务代码,框架和网关层也应设置上传大小限制。只靠代码判断,往往已经让请求体进入应用层,资源消耗会更高。

# Spring Boot
spring.servlet.multipart.max-file-size=2MB
spring.servlet.multipart.max-request-size=4MB
# Nginx
client_max_body_size 4m;

如果上传功能面向高频场景,还应考虑限流、鉴权、日志审计和对象存储隔离。对象存储的 Bucket 权限也要单独检查,避免上传后被公开列目录或越权访问。

复现与验证建议

验证上传风险时,建议在本地测试环境或授权环境中进行,重点验证以下项目:

  • 上传超大文件是否被网关和应用正确拒绝。
  • 上传无后缀、异常后缀、大小写后缀是否被拒绝。
  • 提交包含路径分隔符的文件名,确认不会写入上传目录之外。
  • 上传伪造 Content-Type 的非图片文件,确认会被内容校验拦截。
  • 重复上传同名文件,确认不会覆盖已有文件。
  • 直接访问上传目录,确认不会列目录,不会执行脚本。
  • 下载接口传入 ../ 等路径片段,确认不会读取非授权文件。

这里给一个简单的本地验证命令,用于确认服务端不是只看 Content-Type:

curl -F "[email protected];type=image/png"  Content-Type 判断,这类请求可能会被误判为图片。正确实现下,应继续检查文件内容并拒绝。

日志与监控

上传接口的安全日志很容易被忽略。建议记录以下字段,但不要记录完整敏感内容:

  • 用户标识或调用方标识。
  • 源 IP、User-Agent、请求路径。
  • 原始文件名、最终文件 ID、文件大小、校验结果。
  • 失败原因,例如后缀不允许、大小超限、内容非法。

对于频繁失败、上传大量异常后缀、短时间内大量请求的行为,可以接入告警或风控规则。很多事件早期都能在上传失败日志里看到迹象。

容易踩坑的细节

  • 不要把黑名单当核心策略:危险后缀太多,且不同中间件解析规则不同。上传类型应使用白名单。
  • 不要相信前端校验:前端校验只能改善体验,不能作为安全边界。
  • 不要直接返回本地路径:接口返回 /data/app/uploads/xxx 这类路径会暴露部署信息,也可能给后续攻击提供线索。
  • 注意 SVG:SVG 本质是 XML,可能包含脚本或外部引用。若无必要,不建议允许上传 SVG。
  • 注意压缩包:如果业务需要上传并解压压缩包,必须额外处理 Zip Slip、解压后总大小、文件数量和路径归一化。
  • 注意对象存储回调:直传对象存储时,不要只相信客户端回传的文件地址,应由服务端校验上传结果和对象元数据。

加固清单

检查项建议做法
文件名服务端生成随机名,原始文件名仅展示使用
后缀白名单控制,统一转小写后判断
内容检查魔数或使用解析库读取,图片建议重编码
路径使用 normalize 后确认仍在上传根目录内
目录存放在 Web 根目录之外,静态目录禁止脚本执行
大小网关、框架、业务三层限制
权限上传、访问、删除分开鉴权
日志记录失败原因和异常行为,便于追踪

总结

文件上传安全的核心不是某一个判断条件,而是一组边界控制:入口限流和大小限制、类型白名单、内容校验、随机落盘、路径归一化、存储隔离、访问鉴权和日志监控。

从代码审计角度看,看到 getOriginalFilename、transferTo、静态目录映射、下载接口路径参数时,都值得顺手多看几眼。很多真实风险并不复杂,往往就是几个基础细节叠在一起导致的。

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

0篇意见

推荐意见

没有意见。

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

SiteMap HACKHAT © 2026 All rights reserved.