背景
文件上传是 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 根目录、路径规范化校验、受控下载、后处理隔离、对象存储最小权限。做到这些,绝大多数常见上传类问题都能在设计阶段被挡住,后续维护成本也会低很多。
推荐意见