背景
文件上传是 Java Web 项目里非常常见的功能,头像、附件、导入模板、富文本图片都会用到。它看起来业务属性很强,但安全边界也最容易被低估。很多项目只做了前端限制或简单判断后缀,最后导致任意文件写入、脚本文件落地、路径穿越,甚至配合解析配置形成远程代码执行。
这篇文章不讨论具体攻击站点,也不提供针对真实系统的利用流程,主要整理一次代码审计中常见的文件上传风险点和修复思路。重点放在如何识别问题、如何验证风险、如何把修复做成闭环。
典型场景
审计对象通常是 Spring Boot 或传统 Spring MVC 项目,上传接口大致长这样:接收 MultipartFile,取原始文件名,判断后缀,然后拼接保存路径,最后返回访问 URL。
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
String originalName = file.getOriginalFilename();
String suffix = originalName.substring(originalName.lastIndexOf("."));
if (!".jpg".equalsIgnoreCase(suffix) && !".png".equalsIgnoreCase(suffix)) {
return "unsupported";
}
String savePath = uploadDir + "/" + originalName;
file.transferTo(new File(savePath));
return "/uploads/" + originalName;
}从功能角度看,这段代码能跑;从安全角度看,它至少有几个问题:信任原始文件名、只依赖后缀、保存目录可能可执行、文件名可覆盖、缺少大小限制、缺少内容校验。
问题拆解
1. 原始文件名不可信
getOriginalFilename()来自客户端请求,不应直接作为服务端文件名使用。它可能包含特殊字符、路径分隔符、超长字符串,也可能造成同名覆盖。
常见风险包括:
- 路径穿越:文件名中包含
../或编码变体时,可能写出预期目录。 - 覆盖文件:使用原始文件名保存,可能覆盖已有资源。
- 日志污染:文件名中包含换行、控制字符,影响日志排查。
- 跨平台差异:Windows 与 Linux 对保留字符、大小写、路径分隔符的处理不同。
审计时看到原始文件名直接参与路径拼接,需要重点标记。
2. 后缀校验不能代表文件安全
很多项目只做类似 .jpg、.png 的后缀判断,但后缀是用户可控字段。更稳妥的做法是组合校验:
- 白名单后缀,而不是黑名单。
- 服务端重新生成文件名,后缀由校验结果决定。
- 校验 MIME 只能作为辅助,不能单独信任。
- 读取文件头或使用成熟库判断文件类型。
- 对图片类文件可尝试解码并重编码,去除附加内容。
在 Java 项目里,可以用 Apache Tika、图片解码库或业务允许范围内的轻量实现做内容识别。
private static final Set<String> ALLOWED_EXT = Set.of("jpg", "jpeg", "png", "gif");
private boolean isAllowedExt(String ext) {
return ALLOWED_EXT.contains(ext.toLowerCase(Locale.ROOT));
}注意:扩展名校验只是一层门槛,不是完整防护。
3. 上传目录是否具备执行能力
文件上传最危险的组合通常是“可写 + 可访问 + 可执行”。如果上传目录被 Web 容器当成动态脚本目录解析,风险会明显扩大。
安全设计上,应尽量满足:
- 上传文件保存到 Web 根目录之外。
- 通过受控下载接口读取文件,而不是直接暴露真实路径。
- 静态资源服务器禁止脚本解析。
- 对象存储桶关闭不必要的公开写权限。
- 上传目录配置独立域名时,避免携带主站 Cookie。
Nginx 场景下,静态上传目录可以明确禁止脚本类文件解析,示例仅展示防护思路:
location /uploads/ {
alias /data/app/uploads/;
autoindex off;
default_type application/octet-stream;
location ~* \.(php|jsp|jspx|asp|aspx)$ {
return 403;
}
}如果是 Tomcat/Spring Boot,重点是不要把上传目录配置到可被 JSP 等动态引擎解析的位置。现代 Spring Boot 默认不解析 JSP 上传目录,但老项目、混合部署、反向代理错误配置仍然需要检查。
4. 路径拼接与规范化
审计文件操作时,建议关注所有 new File(base, name)、Paths.get(base, name)、字符串拼接路径的代码。安全实现应先生成服务端文件名,再进行路径规范化校验,确保最终路径仍在允许目录下。
Path baseDir = Paths.get(uploadDir).toAbsolutePath().normalize();
Files.createDirectories(baseDir);
String safeName = UUID.randomUUID() + "." + ext;
Path target = baseDir.resolve(safeName).normalize();
if (!target.startsWith(baseDir)) {
throw new SecurityException("invalid upload path");
}
try (InputStream in = file.getInputStream()) {
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}这里的关键点不是 UUID 本身,而是“不要信任用户文件名”和“最终落点必须做目录边界校验”。
5. 文件大小、数量与资源消耗
上传功能还常见拒绝服务风险,例如大文件占满磁盘、并发上传拖垮带宽、压缩包解压导致磁盘膨胀。即使没有代码执行风险,也可能造成业务不可用。
Spring Boot 中可以设置基础上传限制:
spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=10MB但这只是入口限制。实际项目还应配合:
- 用户维度限频和配额。
- 服务端磁盘水位监控。
- 临时目录定期清理。
- 异步处理队列限制并发。
- 压缩包类文件限制层级、总大小、文件数量。
如果业务允许上传压缩包,务必检查 Zip Slip 问题。解压时不能直接使用压缩包内的文件名作为落地路径。
Path destDir = Paths.get("/data/import").toAbsolutePath().normalize();
try (ZipInputStream zis = new ZipInputStream(inputStream)) {
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");
}
// 后续再根据业务处理文件,省略写入细节
}
}审计关键步骤
实际代码审计时,我通常按下面的顺序排查,效率比较高。
第一步:定位上传入口
检索关键字:
MultipartFile
@RequestParam("file")
CommonsMultipartFile
ServletFileUpload
getOriginalFilename
transferTo
Files.copy
FileOutputStream除了 Controller,也要看富文本编辑器、导入功能、第三方插件目录。很多老项目的上传接口不在统一文件服务里,而是散落在各个业务模块。
第二步:确认文件保存位置
重点判断:
- 是否保存到 Web 根目录内。
- 是否能通过 URL 直接访问。
- 是否存在静态映射配置。
- 是否经过反向代理或 CDN 暴露。
- 对象存储权限是否过宽。
Spring MVC 中常见静态资源映射示例:
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/uploads/**")
.addResourceLocations("file:/data/app/uploads/");
}这类配置本身不一定有问题,但需要结合文件类型校验、目录权限、下载策略一起看。
第三步:检查校验逻辑是否可绕开
这里不是鼓励绕过,而是从防守角度确认校验是否建立在可信数据上。常见薄弱点包括:
- 只在前端限制文件类型。
- 只判断 Content-Type。
- 只用
contains判断后缀。 - 大小写、空格、特殊字符处理不一致。
- 业务层和网关层规则不一致。
- 上传后异步处理,前置校验与后置使用脱节。
更稳妥的策略是:入口校验、内容识别、服务端重命名、保存目录隔离、访问出口控制同时存在。
第四步:验证风险影响面
验证时建议在本地测试环境或授权环境完成。重点不是“能不能打”,而是证明风险链条是否成立:
- 非法类型文件是否能保存。
- 保存路径是否可预测。
- 文件是否可被外部访问。
- 是否可能覆盖已有文件。
- 是否可能写出指定目录。
- 上传异常是否泄露绝对路径。
验证结果最好记录为“前置条件 + 触发点 + 影响范围 + 修复建议”,这样研发更容易处理。
修复参考实现
下面是一段相对完整的上传处理示例,适合作为修复方向参考。实际项目还要结合鉴权、审计日志、存储服务做调整。
public UploadResult uploadImage(MultipartFile file) throws IOException {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("empty file");
}
long maxSize = 5 * 1024 * 1024;
if (file.getSize() > maxSize) {
throw new IllegalArgumentException("file too large");
}
String originalName = Optional.ofNullable(file.getOriginalFilename()).orElse("");
String ext = getExt(originalName);
if (!isAllowedExt(ext)) {
throw new IllegalArgumentException("unsupported file type");
}
byte[] head = file.getInputStream().readNBytes(16);
if (!looksLikeImage(head)) {
throw new IllegalArgumentException("invalid image content");
}
Path baseDir = Paths.get("/data/app/uploads/images").toAbsolutePath().normalize();
Files.createDirectories(baseDir);
String safeName = LocalDate.now() + "-" + UUID.randomUUID() + "." + ext.toLowerCase(Locale.ROOT);
Path target = baseDir.resolve(safeName).normalize();
if (!target.startsWith(baseDir)) {
throw new SecurityException("invalid path");
}
try (InputStream in = file.getInputStream()) {
Files.copy(in, target, StandardCopyOption.CREATE_NEW);
}
return new UploadResult(safeName, "/file/view/" + safeName);
}需要注意,示例中的 looksLikeImage 只是占位逻辑。生产环境建议使用成熟库判断文件类型,图片场景可以进一步读取并重新编码,避免把不必要的附加数据原样保存。
下载接口也要一起看
很多团队修上传时只盯着写入,但读取接口同样重要。下载接口如果按用户传入的文件名直接读取,也可能出现任意文件读取或越权访问。
@GetMapping("/file/view/{name}")
public ResponseEntity<Resource> view(@PathVariable String name) throws IOException {
if (!name.matches("^[a-zA-Z0-9._-]{1,120}$")) {
return ResponseEntity.badRequest().build();
}
Path baseDir = Paths.get("/data/app/uploads/images").toAbsolutePath().normalize();
Path target = baseDir.resolve(name).normalize();
if (!target.startsWith(baseDir) || !Files.exists(target)) {
return ResponseEntity.notFound().build();
}
Resource resource = new UrlResource(target.toUri());
return ResponseEntity.ok()
.header("Content-Disposition", "inline; filename=\"" + name + "\"")
.body(resource);
}如果文件涉及用户隐私或业务数据,不能只靠文件名不可猜来保护,必须做对象级鉴权,例如校验当前用户是否有权限访问该文件记录。
日志与告警
上传功能的安全日志容易被忽略。建议至少记录以下字段:
- 用户 ID 或调用方标识。
- 上传接口路径。
- 原始文件名的安全化版本。
- 最终文件 ID 或存储 key。
- 文件大小、识别类型。
- 失败原因分类。
- 请求来源 IP 和 Trace ID。
日志里不要直接写入未清洗的原始文件名,避免控制字符污染日志。异常响应也不要返回服务端绝对路径、堆栈信息和存储目录。
常见误区
| 误区 | 问题 | 建议 |
|---|---|---|
| 只做前端限制 | 请求可被直接构造 | 服务端必须重新校验 |
| 只看后缀 | 后缀完全可控 | 结合内容识别与白名单 |
| 使用原始文件名 | 覆盖、路径问题、日志污染 | 服务端生成文件名 |
| 上传目录放 Web 根目录 | 可能被直接访问或解析 | 存放到 Web 根目录外 |
| 下载接口无鉴权 | 可能越权读取 | 按文件记录做权限校验 |
总结
文件上传风险很少是单点问题,更多是多个小疏忽串在一起:文件名可信、校验薄弱、目录暴露、下载无鉴权、日志不完整。审计时不要只问“能不能上传某类文件”,而要看完整链路:谁能上传、上传什么、存到哪里、如何访问、是否可执行、能否覆盖、是否可追踪。
比较稳妥的基线是:服务端白名单校验、内容识别、随机文件名、目录隔离、禁止脚本解析、大小与频率限制、下载鉴权、完整日志。做到这些,即使某一层出现疏漏,也不至于直接扩大为高危问题。
经验上看,上传功能最好作为公共能力统一收口。散落在各业务模块里的“临时上传接口”,往往才是后续安全事件的来源。
推荐意见