跳转到帖子

背景

文件上传是 Java Web 项目里很常见的功能,但也是代码审计中反复出现问题的点。很多团队已经做了后缀名校验、大小限制、登录态校验,但在实际审计里,风险往往不只来自“能不能上传 JSP”,还包括路径拼接、文件覆盖、内容类型信任、解压缩处理、对象存储回调、预览解析等边界问题。

这篇文章整理一次典型 Java Web 上传模块的审计思路,重点放在如何发现问题、如何修复和加固,不提供任何非法利用或入侵操作引导。适合作为日常代码审计、研发自查和安全评审时的检查清单。

常见场景

审计对象通常包括以下几类上传入口:

  • 用户头像、附件、工单截图等普通文件上传。
  • 后台 CMS 的图片、模板、资源包上传。
  • Excel、CSV 导入功能。
  • ZIP 压缩包上传后自动解压。
  • 对象存储直传、服务端签名上传、回调通知。
  • 文件预览功能,如 PDF、Office、图片缩略图生成。

从经验看,真正的问题经常出现在“上传之后”的处理链路里,例如保存路径可控、文件名复用、异步解析、解压目录穿越、预览组件调用外部命令等。

审计入口定位

Java Web 项目可以优先从 Controller 层和依赖库入手。常见关键字包括:

MultipartFile
CommonsMultipartFile
Part
@RequestParam("file")
transferTo
getOriginalFilename
FileUtils.copyInputStreamToFile
Files.copy
ZipInputStream
ZipFile
Tika
ImageIO
ProcessBuilder
Runtime.getRuntime

如果项目使用 Spring MVC 或 Spring Boot,通常会看到类似代码:

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

这段代码看起来简单,但至少存在几个审计点:原始文件名是否可信、路径是否规范化、是否允许覆盖、保存目录是否在 Web 根目录下、是否做了内容校验、返回的文件名是否会被后续拼接访问。

技术分析:几个高频风险点

1. 仅校验后缀名

只通过文件名后缀判断类型是不够的。攻击者可以构造带有多重后缀、大小写变体、特殊字符或伪造 Content-Type 的文件。更稳妥的做法是采用“白名单后缀 + MIME 探测 + 文件头校验 + 安全存储位置”的组合策略。

不建议信任以下数据作为唯一判断依据:

  • 浏览器提交的 Content-Type。
  • 用户上传的原始文件名。
  • 前端限制的 accept 属性。
  • 简单的字符串 contains 判断。

示例加固方式:

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

private String safeExt(String originalName) {
    if (originalName == null) {
        throw new IllegalArgumentException("empty filename");
    }
    String name = originalName.replace('\\', '/');
    name = name.substring(name.lastIndexOf('/') + 1);
    int idx = name.lastIndexOf('.');
    if (idx < 0 || idx == name.length() - 1) {
        throw new IllegalArgumentException("missing extension");
    }
    String ext = name.substring(idx + 1).toLowerCase(Locale.ROOT);
    if (!ALLOWED_EXT.contains(ext)) {
        throw new IllegalArgumentException("unsupported extension");
    }
    return ext;
}

2. 原始文件名导致路径穿越或覆盖

很多历史项目会直接使用 getOriginalFilename() 作为落盘文件名。如果文件名包含 ../、反斜杠、URL 编码后的路径符号或系统保留名称,就可能造成路径问题。即使框架对部分字符做了处理,也不应依赖框架行为。

推荐做法是服务端生成随机文件名,只保留受控后缀,同时对最终路径做 normalize 校验:

Path baseDir = Paths.get(uploadDir).toAbsolutePath().normalize();
String ext = safeExt(file.getOriginalFilename());
String newName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
Path target = baseDir.resolve(newName).normalize();

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

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

如果业务需要保留原始文件名,应将原名作为元数据存储到数据库中,展示时做 HTML 编码,不要直接用于文件系统路径。

3. 上传目录位于 Web 可执行路径

老式 Java Web 项目中,上传目录可能放在 webapp、static、resources 等可直接访问的位置。风险在于一旦类型校验失误,文件可能被 Web 容器解释或被浏览器直接渲染执行。

建议:

  • 上传文件放在 Web 根目录之外。
  • 通过受控下载接口读取文件,不直接暴露真实路径。
  • 下载接口设置 Content-Disposition: attachment。
  • 对图片预览场景设置明确的 Content-Type 和安全响应头。
  • 对象存储桶关闭公共写入,读权限按业务最小化授权。

下载接口中也要避免通过用户参数直接拼接路径,例如 /download?file=xxx。更稳妥的方式是使用文件 ID 查询数据库中的存储键,再由服务端映射到真实路径。

4. ZIP 解压导致目录穿越

压缩包上传后自动解压是审计中的重点。常见问题是没有校验 zip entry 的目标路径,导致压缩包内的文件名包含上级目录。修复思路同样是 normalize 后确认仍在目标目录内。

Path destDir = Paths.get(unzipDir).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 outside target dir");
        }
        if (entry.isDirectory()) {
            Files.createDirectories(out);
        } else {
            Files.createDirectories(out.getParent());
            Files.copy(zis, out, StandardCopyOption.REPLACE_EXISTING);
        }
        zis.closeEntry();
    }
}

还要额外关注压缩炸弹问题。可以限制解压后的总大小、文件数量、目录层级、单文件大小,并设置超时或异步任务配额。

5. 文件预览链路中的命令执行风险

有些系统为了生成缩略图或预览,会调用 ImageMagick、LibreOffice、ffmpeg 等外部程序。这里不建议把用户输入直接拼进命令字符串,而应使用参数数组形式,避免 shell 解释,并对文件路径、格式、大小做限制。

ProcessBuilder pb = new ProcessBuilder(
    "libreoffice",
    "--headless",
    "--convert-to", "pdf",
    "--outdir", outputDir.toString(),
    inputFile.toString()
);
pb.redirectErrorStream(true);
Process process = pb.start();
boolean finished = process.waitFor(30, TimeUnit.SECONDS);
if (!finished) {
    process.destroyForcibly();
    throw new RuntimeException("preview timeout");
}

生产环境里,预览服务最好和主业务服务隔离运行,使用低权限账号、容器隔离、只读根文件系统、临时目录清理和资源限制。解析复杂文件格式时,组件漏洞本身也需要纳入补丁管理。

审计关键步骤

实际项目中可以按下面顺序推进,效率会比较高:

  • 梳理所有上传入口,包括后台接口、开放 API、对象存储回调、批量导入接口。
  • 确认鉴权和权限边界,检查是否存在低权限用户访问高风险上传功能。
  • 追踪文件名来源,重点看 getOriginalFilename() 是否进入路径拼接、日志、HTML 展示或命令参数。
  • 检查类型校验逻辑,确认是否为白名单策略,是否结合文件头和实际解析。
  • 确认存储位置是否在 Web 可访问或可执行目录内。
  • 检查下载、预览、解析、解压、转码等后处理链路。
  • 检查文件大小、数量、频率限制以及清理机制。
  • 核对对象存储权限、签名有效期、回调验签、Bucket 策略。

一份可落地的上传安全清单

检查项建议
文件名服务端生成随机名,原始文件名仅作为元数据保存
文件类型使用白名单,结合后缀、MIME 探测、文件头和解析结果
存储路径放在 Web 根目录之外,路径 normalize 后校验边界
访问方式通过文件 ID 下载,不暴露真实路径
权限控制上传、下载、预览都要做业务权限校验
压缩包限制文件数量、总大小、层级,防止目录穿越
预览解析隔离运行,限制资源,避免命令字符串拼接
对象存储最小权限、短期签名、回调验签、禁止公共写入
响应头下载使用 attachment,必要时设置 X-Content-Type-Options: nosniff
审计日志记录上传人、文件 ID、大小、类型、结果和异常原因

注意点

  • 不要把前端限制当作安全措施,前端只负责体验,安全判断必须在服务端完成。
  • 不要只靠黑名单过滤危险后缀,黑名单很容易遗漏环境相关的可执行类型。
  • 不要在异常信息中返回服务器真实路径、组件版本、对象存储 Key 等敏感信息。
  • 不要让上传文件与应用代码、配置文件、模板文件处于同一目录结构。
  • 如果业务允许上传 HTML、SVG、Markdown 等可渲染内容,要单独评估 XSS 和内容安全策略。
  • 异步处理失败后要清理临时文件,避免磁盘被长期占满。
一个经验判断:上传功能是否安全,不只看“能不能传某类文件”,还要看文件进入系统后会被谁读取、如何解析、用什么权限处理、最终以什么方式返回给用户。

总结

文件上传风险的核心在于“不可信输入进入了文件系统和后处理链路”。代码审计时不要停留在后缀校验这一层,而要把上传、存储、访问、解析、预览、清理作为完整链路来看。

比较稳妥的基线是:服务端生成文件名、白名单类型校验、上传目录脱离 Web 根目录、路径规范化校验、受控下载、后处理隔离、对象存储最小权限。做到这些,绝大多数常见上传类问题都能在设计阶段被挡住,后续维护成本也会低很多。

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

0篇意见

推荐意见

没有意见。

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