跳转到帖子

背景

文件上传是 Java Web 项目里很常见的功能,但也是代码审计中高频出现问题的点。很多漏洞并不是因为框架本身不安全,而是业务代码在文件名处理、类型校验、存储路径、访问权限等环节留下了缺口。

这篇文章整理一次偏防御视角的复现与审计过程,场景是内部测试环境中的一个文件上传接口。重点放在如何定位风险、如何验证影响、以及如何修复,不包含对真实站点的攻击操作。

本文适用于安全自测、代码审计和开发修复参考。不要在未授权系统上进行漏洞验证。

场景描述

目标系统是一个传统 Java Web 应用,提供头像上传和附件上传两个接口。上传后的文件会被保存到 Web 目录下,前端可直接通过 URL 访问。

审计时关注到一个接口大致逻辑如下:

@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws Exception {
    String fileName = file.getOriginalFilename();
    String savePath = request.getServletContext().getRealPath("/upload/");
    File dest = new File(savePath + fileName);
    file.transferTo(dest);
    return "/upload/" + fileName;
}

这段代码看起来很常见,但存在几个明显问题:

  • 直接使用客户端传入的原始文件名。
  • 未校验文件扩展名和真实内容类型。
  • 文件保存到 Web 可访问目录。
  • 未处理路径穿越、重名覆盖、特殊字符等情况。
  • 没有限制文件大小和上传频率。

问题分析

文件上传漏洞通常不是单点问题,而是多个弱校验组合造成的。这里可以拆成几个风险面来看。

1. 文件名可信度问题

getOriginalFilename() 返回的是客户端提交的文件名,本身不可信。如果直接拼接路径,可能导致路径穿越、覆盖已有文件、产生异常文件名等问题。

String fileName = file.getOriginalFilename();
File dest = new File(savePath + fileName);

更安全的做法是服务端重新生成文件名,只保留经过白名单校验的扩展名。

2. 扩展名校验缺失

如果系统只允许上传图片,但没有校验扩展名,就可能上传非预期类型文件。即使做了扩展名黑名单,也容易遗漏大小写、双扩展名、特殊后缀等情况。

建议采用白名单策略,例如只允许:

  • jpg
  • jpeg
  • png
  • gif
  • pdf

白名单需要结合业务场景控制,不要为了省事允许过多格式。

3. Content-Type 不可靠

很多代码会依赖 file.getContentType() 判断文件类型,但这个值来自请求头,客户端可以伪造。它可以作为辅助信息,但不能作为唯一依据。

String contentType = file.getContentType();
if (!contentType.startsWith("image/")) {
    throw new RuntimeException("invalid file type");
}

上面的判断并不可靠。更稳妥的方式是同时检查扩展名、文件头特征,以及必要时使用成熟库检测 MIME 类型。

4. 文件保存位置不当

把上传文件直接保存到 Web 根目录下,是风险比较高的设计。如果服务器或中间件对某些扩展名存在解析行为,或者历史配置比较复杂,可能导致非预期执行或敏感文件访问。

更推荐的做法是:

  • 上传文件保存到 Web 根目录之外。
  • 通过下载接口读取文件并返回。
  • 下载接口做权限校验、文件类型控制和响应头处理。
  • 静态资源场景可以使用对象存储,并关闭脚本执行能力。

复现思路

在授权测试环境中,可以通过构造不同文件来验证上传接口的边界处理。这里不展示恶意利用过程,只说明验证维度。

1. 验证扩展名限制

准备不同扩展名的普通文本文件,观察接口是否无差别接收。例如:

test.txt
test.jsp
test.php
test.jpg
test.jpg.txt
test.JPEG

如果业务只允许图片,但上述非图片文件也被保存并返回可访问 URL,就说明扩展名校验不足。

2. 验证文件内容校验

可以准备一个扩展名为 .jpg 但内容并非图片的测试文件,观察系统是否只看后缀。

echo "this is not an image" > fake.jpg

如果接口接受该文件,并且后续业务把它当作图片处理,就可能带来解析异常、内容欺骗或安全绕过风险。

3. 验证路径处理

测试文件名中包含特殊字符、空格、中文、长文件名、路径分隔符等情况,观察服务端保存路径和返回 URL 是否异常。

normal.png
中文文件名.png
space name.png
very-long-file-name-xxxxxxxxxxxxxxxx.png

如果出现目录跳转、覆盖文件、异常报错暴露绝对路径等情况,需要立即修复。

4. 验证访问权限

上传后直接访问返回 URL,检查是否存在以下问题:

  • 未登录用户能否访问私有附件。
  • 其他用户能否枚举文件地址。
  • 删除后的文件是否仍可访问。
  • 返回头是否缺少安全控制。

附件类功能经常出现越权访问,不应只关注上传本身。

代码审计关注点

实际审计时,可以从控制器入口一路追到存储层和访问层。下面是比较常见的检查清单。

审计点风险说明建议
文件名原始文件名不可信服务端生成随机文件名
扩展名黑名单容易遗漏使用白名单
文件内容仅校验 Content-Type 不可靠检查魔数或使用检测库
保存目录Web 目录可能被直接访问或解析保存到非 Web 目录
权限控制附件地址可能被枚举下载接口校验权限
大小限制可能造成磁盘占满或内存压力限制单文件和总量
异常处理可能暴露绝对路径或框架信息统一错误响应

相对安全的实现示例

下面是一个更稳妥的处理方式示例。注意这只是示意,生产环境还需要结合权限、审计日志、对象存储、病毒扫描等策略。

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

@PostMapping("/upload")
public UploadResult upload(@RequestParam("file") MultipartFile file) throws IOException {
    if (file == null || file.isEmpty()) {
        throw new IllegalArgumentException("empty file");
    }

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

    String originalName = file.getOriginalFilename();
    String ext = getSafeExt(originalName);
    if (!ALLOW_EXT.contains(ext)) {
        throw new IllegalArgumentException("unsupported file type");
    }

    if (!checkMagicBytes(file, ext)) {
        throw new IllegalArgumentException("invalid file content");
    }

    String newName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
    Path baseDir = Paths.get("/data/app/uploads").toAbsolutePath().normalize();
    Files.createDirectories(baseDir);

    Path target = baseDir.resolve(newName).normalize();
    if (!target.startsWith(baseDir)) {
        throw new SecurityException("invalid path");
    }

    file.transferTo(target.toFile());

    return new UploadResult(newName);
}

private String getSafeExt(String fileName) {
    if (fileName == null) {
        throw new IllegalArgumentException("invalid file name");
    }
    String cleanName = Paths.get(fileName).getFileName().toString();
    int idx = cleanName.lastIndexOf('.');
    if (idx < 0 || idx == cleanName.length() - 1) {
        throw new IllegalArgumentException("missing extension");
    }
    return cleanName.substring(idx + 1).toLowerCase(Locale.ROOT);
}

这里有几个关键点:

  • 不直接信任原始文件名。
  • 使用扩展名白名单。
  • 保存路径做 normalize,并检查是否仍在预期目录下。
  • 用 UUID 生成新文件名,避免覆盖和枚举。
  • 限制文件大小,避免资源消耗问题。

文件头校验示例

对于图片类文件,可以做简单的魔数检查。不要把它当成唯一安全措施,但比只看后缀可靠很多。

private boolean checkMagicBytes(MultipartFile file, String ext) throws IOException {
    byte[] header = new byte[8];
    try (InputStream in = file.getInputStream()) {
        int len = in.read(header);
        if (len < 4) {
            return false;
        }
    }

    if (ext.equals("jpg") || ext.equals("jpeg")) {
        return (header[0] & 0xFF) == 0xFF && (header[1] & 0xFF) == 0xD8;
    }

    if (ext.equals("png")) {
        return (header[0] & 0xFF) == 0x89
            && header[1] == 0x50
            && header[2] == 0x4E
            && header[3] == 0x47;
    }

    if (ext.equals("gif")) {
        return header[0] == 0x47
            && header[1] == 0x49
            && header[2] == 0x46;
    }

    if (ext.equals("pdf")) {
        return header[0] == 0x25
            && header[1] == 0x50
            && header[2] == 0x44
            && header[3] == 0x46;
    }

    return false;
}

如果业务允许 Office、压缩包等复杂格式,不建议自己手写完整解析逻辑,可以使用 Apache Tika 等库辅助识别,同时结合业务白名单。

下载接口的处理

上传修好了,不代表整个文件功能就安全。很多项目的问题出在下载接口:只要知道文件名就能访问,没有鉴权,也没有检查归属关系。

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

    if (!filePermissionService.canRead(principal.getName(), record)) {
        return ResponseEntity.status(403).build();
    }

    Path filePath = Paths.get(record.getStoragePath()).normalize();
    Resource resource = new FileSystemResource(filePath);

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

建议文件记录使用数据库维护,外部访问使用文件 ID,而不是直接暴露物理文件名。对于私有附件,下载时必须检查用户身份和资源归属。

Nginx 层面的加固

如果确实需要让上传目录通过 Nginx 提供静态访问,至少要确保该目录不会执行任何脚本,并限制可访问类型。

location /uploads/ {
    alias /data/app/uploads/;
    autoindex off;
    add_header X-Content-Type-Options nosniff;

    location ~* \.(jsp|php|asp|aspx|jspx|sh|exe|dll)$ {
        return 403;
    }
}

不过这类配置属于兜底措施,不应替代应用层校验。更稳妥的设计仍然是上传目录不放在 Web 根目录,私有文件统一走下载接口。

日志与告警

上传功能建议记录安全日志,便于后续排查异常行为。常见字段包括:

  • 用户 ID 和来源 IP。
  • 原始文件名和服务端文件名。
  • 文件大小、扩展名、检测到的 MIME 类型。
  • 上传结果和失败原因。
  • 请求链路 ID,方便和网关日志关联。

如果短时间内出现大量失败上传、异常扩展名、超大文件请求,可以触发告警或临时限流。

常见误区

  • 只校验前端:前端限制只能改善体验,不能作为安全边界。
  • 只看 Content-Type:请求头可控,不能单独信任。
  • 使用黑名单:扩展名变体太多,维护成本高。
  • 上传后直接返回真实路径:容易暴露目录结构和文件命名规则。
  • 忽视下载鉴权:上传接口安全,但附件仍可能被越权读取。
  • 依赖中间件默认配置:历史系统里经常有遗留解析规则,不能假设默认安全。

修复建议清单

  • 服务端生成随机文件名,不使用用户提交的文件名作为存储名。
  • 扩展名采用白名单,按业务最小化开放类型。
  • 结合文件头、MIME 检测库进行内容校验。
  • 上传目录放在 Web 根目录之外。
  • 私有文件通过下载接口读取,并校验权限。
  • 限制文件大小、数量、上传频率。
  • 统一异常处理,避免返回绝对路径和堆栈信息。
  • 记录上传安全日志,接入异常告警。
  • 对历史存量文件做一次巡检,清理可疑文件。

总结

文件上传漏洞的本质,是把不可信输入变成了服务器上的可访问资源。审计时不要只看某一行校验代码,而要从上传、存储、访问、权限、日志几个环节串起来看。

比较可靠的思路是:服务端控制文件名,白名单控制类型,非 Web 目录存储,下载接口做鉴权,中间件配置做兜底。这样即使某个环节出现小问题,也不至于直接扩大成严重风险。

这类问题在老项目中很常见,修复成本通常不高,但需要开发和运维一起确认存储路径、访问方式和历史兼容逻辑。建议把上传功能作为代码审计中的固定检查项长期保留。

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

0篇意见

推荐意见

没有意见。

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