跳转到帖子

背景

文件上传一直是 Java Web 项目里比较容易被低估的风险点。很多系统在业务上确实需要上传头像、附件、报表、压缩包,但实现时如果只校验前端后缀,或者把文件直接落到 Web 可访问目录,很容易演变成任意文件上传、存储型 XSS、路径穿越,严重时甚至会造成远程代码执行。

这篇文章整理的是一次偏防守视角的复盘,重点放在代码审计、风险确认和加固验证。文中不提供可用于非法利用的攻击载荷,也不讨论拿权限、控制主机之类的操作,只关注如何识别问题、复现安全边界以及修正实现。

问题场景

目标系统是一个常见的 Spring Boot 后台,存在多个上传入口,包括用户头像、工单附件、富文本图片上传。代码风格比较典型:Controller 接收 MultipartFile,服务层生成文件名,然后保存到本地磁盘,最后返回 URL 给前端展示。

初看业务逻辑没有明显问题,但审计时发现几个危险信号:

  • 后缀校验只依赖原始文件名,且没有统一封装。
  • Content-Type 被直接信任,用来决定文件类型。
  • 上传目录位于静态资源目录下,可被外部直接访问。
  • 文件名拼接使用字符串处理,没有做路径规范化校验。
  • 部分接口没有鉴权或只做了弱权限校验。

典型风险点分析

1. 只校验前端或原始文件名

比较常见的代码如下:

String filename = file.getOriginalFilename();
String suffix = filename.substring(filename.lastIndexOf(".") + 1);
if (!Arrays.asList("jpg", "png", "gif", "pdf").contains(suffix)) {
    throw new RuntimeException("file type not allowed");
}

这类写法至少有三个问题。第一,后缀没有统一转小写,大小写绕过会造成校验不一致。第二,没有处理无后缀、隐藏文件名、特殊字符等异常情况。第三,文件真实内容和扩展名没有绑定关系,业务上允许图片并不代表传入内容一定是图片。

后端不能只依赖文件名判断类型。更合理的做法是同时检查扩展名、MIME 类型和文件头特征,并且按业务场景限制最小集合。例如头像上传只允许 jpg、png,就不要把 gif、svg、pdf 都放进去。

2. 信任 Content-Type

有些代码会这样判断:

String contentType = file.getContentType();
if (contentType != null && contentType.startsWith("image/")) {
    // save file
}

Content-Type 通常来自客户端请求头,不能作为安全判断依据。它可以作为辅助信息记录日志,但不应该作为唯一准入条件。对于图片类文件,建议使用服务端解析库读取基本信息,例如 ImageIO 读取宽高、格式;对于 PDF、Office、压缩包等,则需要结合魔数和安全解包策略。

3. 上传目录可直接执行或访问

如果上传目录在 Web 根目录下,比如:

src/main/resources/static/upload/
webapp/upload/
nginx html/upload/

那么任何上传成功的文件都可能被外部直接访问。即使后端阻止了危险脚本扩展名,也仍然可能出现存储型 XSS、敏感文件泄露、钓鱼文件托管等问题。

更稳妥的方式是把上传文件存储在 Web 根目录之外,通过受控下载接口读取,并在下载时设置固定响应头。例如:

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

对于确实需要预览的图片,应使用独立域名或独立静态服务,并关闭脚本执行能力,避免和主业务 Cookie、管理后台共域。

4. 路径拼接导致目录穿越

审计中还经常看到类似写法:

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

如果原始文件名没有经过清洗,可能引入路径穿越、特殊字符污染、覆盖已有文件等风险。即便框架或浏览器在多数情况下会处理文件名,也不能把安全性建立在客户端行为上。

建议保存时完全丢弃用户提交的文件名,仅保留原始文件名作为展示字段。实际落盘文件名使用服务端生成的随机值,例如 UUID、雪花 ID 或内容哈希,并统一追加白名单扩展名。

关键审计步骤

我在审计这类问题时,一般按下面顺序走,效率会比较高。

  • 全局搜索上传入口:MultipartFile、CommonsMultipartFile、request.getPart、transferTo、FileOutputStream。
  • 确认接口权限:是否需要登录,是否校验业务权限,是否存在越权上传。
  • 确认类型校验:扩展名、MIME、文件头是否都在服务端完成。
  • 确认保存路径:是否位于 Web 根目录,是否可被直接访问,是否存在脚本执行能力。
  • 确认文件名处理:是否使用原始文件名落盘,是否可能路径穿越或覆盖。
  • 确认大小和数量限制:是否可能造成磁盘打满、内存占用过高。
  • 确认后续处理链路:图片压缩、文档转换、压缩包解压是否引入二次风险。

安全实现示例

下面是一段偏简化的 Java 示例,重点展示几个关键点:白名单、随机文件名、路径规范化、大小限制。实际项目里建议封装成统一上传组件,不要让每个业务模块各写一套。

private static final long MAX_SIZE = 2 * 1024 * 1024;
private static final Set<String> ALLOW_EXT = Set.of("jpg", "jpeg", "png");

public UploadResult saveImage(MultipartFile file, Path uploadRoot) throws IOException {
    if (file == null || file.isEmpty()) {
        throw new IllegalArgumentException("empty file");
    }
    if (file.getSize() > MAX_SIZE) {
        throw new IllegalArgumentException("file too large");
    }

    String originalName = Optional.ofNullable(file.getOriginalFilename()).orElse("");
    String ext = getSafeExt(originalName);
    if (!ALLOW_EXT.contains(ext)) {
        throw new IllegalArgumentException("file type not allowed");
    }

    BufferedImage image = ImageIO.read(file.getInputStream());
    if (image == null || image.getWidth() <= 0 || image.getHeight() <= 0) {
        throw new IllegalArgumentException("invalid image");
    }

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

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

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

    return new UploadResult(storedName, originalName, file.getSize());
}

private String getSafeExt(String filename) {
    int index = filename.lastIndexOf('.');
    if (index < 0 || index == filename.length() - 1) {
        return "";
    }
    return filename.substring(index + 1).toLowerCase(Locale.ROOT);
}

这里还有一个细节:示例中使用 ImageIO 做基本图片解析,但它不等同于完整的内容安全检测。对于对抗性文件、畸形图片、超大尺寸图片,还需要限制像素大小、处理库版本,并在异常时记录日志。

Nginx 层面的防护建议

如果业务必须提供静态访问,建议至少在 Web 服务器层面限制执行能力和响应行为。下面是一个示例,仅表达思路,具体路径要按实际环境调整。

location /uploads/ {
    alias /data/app/uploads/;
    autoindex off;
    add_header X-Content-Type-Options nosniff;
    add_header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'none'; script-src 'none'";

    location ~* \.(jsp|php|asp|aspx|sh|bat|cmd|exe|dll)$ {
        deny all;
    }
}

如果上传文件不需要浏览器内联展示,更建议统一走下载接口,并设置 attachment,降低浏览器解析带来的风险。

验证思路

修复后不要只测正常图片上传,还需要覆盖一些边界用例。比较实用的验证点包括:

  • 无后缀文件是否被拒绝。
  • 大写扩展名是否按统一规则处理。
  • 超大文件是否在服务端被拒绝。
  • 伪造 Content-Type 是否不会影响最终判断。
  • 畸形图片或无法解析的图片是否被拒绝。
  • 包含路径分隔符的原始文件名是否不会影响落盘路径。
  • 上传成功后的 URL 是否不会泄露真实服务器路径。
  • 未授权用户是否无法调用上传接口。

可以用 curl 做一些基础回归测试,例如验证大小限制和响应码:

curl -i -X POST \
  -H "Authorization: Bearer <token>" \
  -F "[email protected];type=image/png" \
   CI,也可以把上传组件的单元测试和接口测试纳入流水线,避免后续业务模块复制旧代码导致问题回流。

日志与监控

上传接口的日志经常被忽视,但它对事件排查很有价值。建议记录以下字段:

  • 用户 ID、来源 IP、接口路径。
  • 原始文件名、服务端文件名、文件大小。
  • 判定出的扩展名、解析出的文件类型。
  • 拒绝原因,例如 size_exceeded、invalid_ext、invalid_content。
  • 业务对象 ID,例如工单 ID、文章 ID。

注意不要在日志里记录文件完整内容,也不要把敏感业务附件路径直接暴露到前端。对于短时间内大量上传失败、频繁尝试异常扩展名的行为,可以接入风控或告警。

常见误区

  • 认为有 WAF 就可以不做后端校验。上传安全最终还是要落在业务代码和存储策略上。
  • 认为限制后缀就足够。文件内容、访问方式、解析链路同样重要。
  • 认为内网系统风险低。很多上传漏洞是在后台系统、运维系统里出现的。
  • 认为对象存储天然安全。对象存储同样需要权限、Content-Type、下载策略和生命周期管理。

总结

文件上传安全的核心不是某一个判断条件,而是一组边界控制:入口要鉴权,类型要服务端验证,文件名要服务端生成,路径要规范化,存储要和 Web 执行环境隔离,访问要受控,日志要可追溯。

从代码审计角度看,上传点通常不难找,难点在于把后续链路看完整:文件保存在哪里、谁能访问、浏览器如何解析、后台是否会二次处理。只要把这几个问题逐一确认,大多数上传类风险都能在上线前被拦下来。

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

0篇意见

推荐意见

没有意见。

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

SiteMap HACKHAT © 2026 All rights reserved.