背景
文件上传是 Java Web 项目里很常见的功能,但也是代码审计中高频出问题的位置。很多漏洞并不是因为开发完全没有做校验,而是校验点放错了、信任了客户端参数,或者只处理了扩展名却忽略了存储路径、内容类型和访问控制。
这篇文章整理一个偏通用的审计思路,重点放在如何发现风险、如何验证风险边界,以及如何给出可落地的修复方案。内容不会涉及真实站点攻击,只讨论本地测试环境和代码层面的安全检查。
典型场景
常见业务包括头像上传、附件上传、富文本图片上传、工单附件、导入文件等。审计时我通常会先关注以下几个点:
- 上传文件是否直接保存到 Web 可访问目录。
- 文件名是否直接使用用户传入值。
- 扩展名校验是否只依赖前端或 Content-Type。
- 是否允许上传 HTML、SVG、JSP、jspx、jspx 等高风险类型。
- 是否存在路径拼接导致的目录穿越。
- 上传后的文件是否有独立访问鉴权。
- 是否对文件大小、数量、解压内容做限制。
很多项目表面上做了白名单,但细看代码会发现只对原始文件名做了字符串判断,保存时又从其他参数取文件名,导致校验和落盘对象不是同一个。
问题代码示例
下面是一段简化后的示例代码,便于说明问题。不要直接套用到生产环境。
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file,
@RequestParam("name") String name) throws IOException {
String originalName = file.getOriginalFilename();
if (originalName == null || !originalName.endsWith(".jpg")) {
return "invalid file";
}
File dir = new File("/data/app/static/upload/");
if (!dir.exists()) {
dir.mkdirs();
}
File target = new File(dir, name);
file.transferTo(target);
return "/upload/" + name;
}这段代码有几个明显风险:
- 校验的是 originalName,但保存使用的是 name 参数,二者不一致。
- 只用 endsWith 校验扩展名,大小写、特殊后缀、双扩展名等情况都容易漏掉。
- 保存目录位于静态资源目录,上传后可直接访问。
- name 未做规范化处理,存在路径穿越和覆盖已有文件的风险。
- 没有校验真实文件类型,也没有限制文件大小。
审计时的分析方法
做代码审计时,不建议只搜 upload 或 MultipartFile 就结束。更稳妥的方式是从数据流角度看完整链路:
- 入口:MultipartFile、ServletInputStream、Commons FileUpload、Base64 文件内容。
- 校验:扩展名、MIME、魔数、大小、图片解析、业务类型。
- 命名:是否使用用户输入、是否随机化、是否保留原始名称。
- 存储:本地目录、对象存储、NFS、临时目录。
- 访问:静态映射、下载接口、鉴权、Content-Disposition。
- 后处理:图片压缩、文档解析、压缩包解压、异步扫描。
尤其要注意“校验对象”和“保存对象”是否一致。如果校验 file.getOriginalFilename(),但保存路径来自 request 参数、数据库字段或前端传入的 key,就要重点看是否能被用户控制。
关键验证点
在本地测试环境中,可以围绕以下几个方向验证风险,不需要对外部系统做任何操作。
1. 扩展名白名单是否严格
推荐使用服务端固定白名单,并统一转小写后判断。不要使用黑名单,因为遗漏成本很高。
private static final Set<String> ALLOWED_EXT = Set.of("jpg", "jpeg", "png", "gif", "pdf");
private boolean isAllowedExt(String filename) {
if (filename == null) {
return false;
}
String cleanName = Paths.get(filename).getFileName().toString();
int idx = cleanName.lastIndexOf('.');
if (idx <= 0 || idx == cleanName.length() - 1) {
return false;
}
String ext = cleanName.substring(idx + 1).toLowerCase(Locale.ROOT);
return ALLOWED_EXT.contains(ext);
}这里的重点不是“能不能拦住所有情况”,而是先保证判断逻辑清晰,且只允许业务确实需要的类型。
2. 文件名是否可控
上传后的文件名建议由服务端生成,原始文件名只作为展示字段保存,不能参与实际路径拼接。
String ext = getSafeExt(file.getOriginalFilename());
String storedName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
Path baseDir = Paths.get("/data/app/uploads").toAbsolutePath().normalize();
Path target = baseDir.resolve(storedName).normalize();
if (!target.startsWith(baseDir)) {
throw new SecurityException("invalid path");
}
Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);即使文件名由服务端生成,也建议保留 normalize 和 startsWith 检查。这类防御看起来重复,但能减少后续维护人员改动代码时引入路径问题。
3. 是否校验文件内容
Content-Type 是客户端传入值,不能直接信任。对于图片类上传,可以结合魔数识别和图片解码。对于 PDF、Office 等文档,可以做基本类型识别,并配合杀毒或沙箱扫描。
private boolean isPng(byte[] header) {
byte[] png = new byte[] {(byte)0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
return header.length >= png.length && Arrays.equals(Arrays.copyOf(header, png.length), png);
}如果业务只允许图片,建议上传后重新编码生成新图片,而不是原样保存。这样可以在一定程度上去掉多余内容和异常结构。
4. 上传目录是否可执行或可直接访问
文件尽量存储在 Web 根目录之外,通过受控下载接口访问。下载接口要做鉴权,并设置合适响应头,避免浏览器直接解析高风险内容。
response.setHeader("Content-Disposition", "attachment; filename=\"" + safeDownloadName + "\"");
response.setHeader("X-Content-Type-Options", "nosniff");
response.setContentType("application/octet-stream");如果必须使用 Nginx 暴露静态文件,也要确保该目录没有脚本执行能力,并限制危险类型。
location /uploads/ {
alias /data/app/uploads/;
autoindex off;
add_header X-Content-Type-Options nosniff;
types { }
default_type application/octet-stream;
}压缩包上传的额外风险
很多系统允许上传 zip 包后自动解压,这类功能风险更高。除了文件类型校验,还要关注 Zip Slip、解压炸弹、文件数量过多、嵌套目录过深等问题。
安全解压时至少需要做以下限制:
- 每个条目解压后的路径必须仍在目标目录内。
- 限制压缩包总大小和解压后总大小。
- 限制文件数量和目录层级。
- 禁止符号链接、绝对路径和特殊设备文件。
- 解压后的每个文件仍要按业务白名单检查。
Path destDir = Paths.get("/data/app/unzip").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 path traversal");
}
// 后续再处理大小、数量、类型等限制
}
}修复建议清单
| 风险点 | 建议做法 |
|---|---|
| 扩展名绕过 | 使用服务端白名单,统一大小写,禁止危险类型 |
| 路径穿越 | 服务端生成文件名,路径 normalize 后校验 startsWith |
| 文件内容伪装 | 结合魔数、文件解析、重新编码或安全扫描 |
| 上传后直接访问 | 存储到 Web 根目录外,通过鉴权接口下载 |
| 浏览器解析风险 | 设置 Content-Disposition 和 X-Content-Type-Options |
| 大文件消耗资源 | 限制单文件大小、总量、上传频率和超时时间 |
| 压缩包风险 | 限制路径、数量、层级、解压后大小和文件类型 |
常见误区
- 只做前端校验:前端校验只能提升体验,不能作为安全边界。
- 只判断 Content-Type:该字段可被客户端控制,最多作为辅助信息。
- 只用黑名单:危险扩展名和容器行为太多,容易漏。
- 认为对象存储一定安全:对象存储也要关注公开读、Content-Type、下载鉴权和生命周期。
- 忽略业务权限:上传安全不只是不执行脚本,还包括越权读取他人附件。
总结
文件上传漏洞的核心问题通常不是单个 if 写错,而是整条链路缺少一致的安全设计。比较稳的做法是:服务端生成文件名、严格白名单、存储在非 Web 根目录、受控下载、内容校验和资源限制一起做。
审计时建议把上传功能当作一条数据流看,从入口到访问再到后处理逐段确认。这样不但能发现明显漏洞,也能发现那些在重构、迁移对象存储、增加压缩包导入功能时容易被带出来的隐患。
推荐意见