背景
文件上传是 Web 系统里很常见的功能,头像、附件、工单截图、导入文件都会用到。它的问题在于:业务上看起来只是“保存一个文件”,安全上却同时涉及文件类型识别、存储路径、访问权限、解析行为、元数据处理和后续消费链路。很多历史漏洞并不是单点判断写错,而是多个小问题叠加后形成风险。
这篇文章整理的是我在 Java Web 项目代码审计中常见的一类上传问题。内容偏防守和工程落地,重点放在如何识别风险、如何在本地安全环境复现、以及如何设计加固方案,不涉及对真实系统的攻击操作。
典型场景
某业务系统提供附件上传接口,后端使用 Spring MVC 接收 MultipartFile,文件保存到应用服务器本地目录,并返回可访问 URL。简化后的代码大致如下:
@PostMapping("/upload")
public Map<String, Object> upload(@RequestParam("file") MultipartFile file) throws IOException {
String originalName = file.getOriginalFilename();
String suffix = originalName.substring(originalName.lastIndexOf("."));
String newName = UUID.randomUUID() + suffix;
File dir = new File("/data/app/uploads/");
if (!dir.exists()) {
dir.mkdirs();
}
File dest = new File(dir, newName);
file.transferTo(dest);
return Map.of("url", "/uploads/" + newName);
}这段代码在功能上没有问题,但从安全角度看,至少需要继续确认以下几个点:
- 是否只根据扩展名判断文件类型。
- 上传目录是否会被 Web 容器直接解析或执行。
- 文件名、路径、后缀处理是否存在边界问题。
- 是否限制文件大小、数量、频率。
- 是否对文件内容做二次处理,例如解压、图片解析、Office 解析。
- 返回的 URL 是否暴露内部存储结构。
问题分析
1. 扩展名判断并不等于文件类型判断
很多项目会用后缀白名单,例如只允许 jpg、png、pdf、docx。这个方向没错,但只做后缀判断是不够的。原因是后缀来自用户输入,不能代表真实内容。
更稳妥的做法是组合判断:
- 扩展名白名单:控制业务允许的文件类型范围。
- Content-Type 参考:只能作为辅助信息,不能单独信任。
- 文件头魔数识别:识别常见二进制格式。
- 文件解析校验:例如图片用 ImageIO 解码,PDF 用专门库读取基本结构。
- 业务语义校验:例如导入 Excel 需要校验 Sheet、表头和字段类型。
示例:对图片上传做基础校验时,不建议只看 image/jpeg,而应尝试真正解码图片。
private boolean isValidImage(MultipartFile file) {
try (InputStream in = file.getInputStream()) {
BufferedImage image = ImageIO.read(in);
if (image == null) {
return false;
}
return image.getWidth() > 0 && image.getHeight() > 0;
} catch (IOException e) {
return false;
}
}2. 上传目录不应具备执行能力
一个比较关键的原则是:上传目录只用于存储,不应该参与代码执行。即使文件类型校验有遗漏,也要避免上传文件被 Web 容器、脚本引擎或中间件当作可执行资源处理。
工程上可以采用以下做法:
- 上传文件存储在 Web 根目录之外。
- 通过专门的下载接口读取文件流,而不是直接暴露磁盘路径。
- 静态资源服务器只提供下载能力,不启用脚本解析。
- 对象存储桶设置为私有读,通过后端鉴权后签发短期访问地址。
- 上传文件统一改名,避免用户可控文件名影响解析行为。
例如,不建议将文件直接保存到类似下面这种目录:
/usr/local/tomcat/webapps/ROOT/uploads/更建议保存到应用外部数据目录:
/data/app/storage/uploads/然后通过受控接口输出:
@GetMapping("/files/{id}")
public ResponseEntity<Resource> download(@PathVariable String id) {
// 1. 查询文件元数据
// 2. 校验当前用户是否有权限访问
// 3. 从非 Web 根目录读取文件
// 4. 设置安全的 Content-Disposition 和 Content-Type
}3. 文件名和路径处理要避免信任原始输入
原始文件名只能作为展示信息保存,不能直接参与最终落盘路径。审计时常见的问题包括:
- 使用 originalFilename 拼接路径。
- 未处理 ../ 等路径穿越片段。
- 未处理 Windows 与 Linux 路径分隔符差异。
- 未限制文件名长度,导致日志、数据库或文件系统异常。
- 直接在响应头中回显原始文件名,造成响应头注入风险。
推荐做法是:落盘文件名完全由服务端生成,原始文件名仅作为元数据字段,并在展示时做转义。
String originalName = StringUtils.cleanPath(file.getOriginalFilename());
String safeExt = resolveAllowedExtension(originalName, file);
String storedName = LocalDate.now() + "/" + UUID.randomUUID() + "." + safeExt;其中 resolveAllowedExtension 不应简单返回最后一个点后的字符串,而应结合白名单、内容识别和业务类型一起判断。
4. 大文件、压缩包与解析库是另一类风险入口
上传功能经常和后续处理结合,例如压缩包导入、Excel 导入、图片缩略图生成、PDF 预览。这类逻辑的风险不在“上传成功”本身,而在后续解析。
需要重点关注:
- 压缩包解压是否存在路径穿越,解压后文件是否超出目标目录。
- 压缩包是否限制总解压大小、文件数量、递归层级。
- 图片解析库是否及时更新,是否处理超大分辨率图片。
- Office、PDF 解析是否禁用外部实体、远程资源加载等危险能力。
- 解析任务是否放入隔离队列,避免阻塞主业务线程。
解压路径校验可以参考下面的写法,核心是对解压后的 canonical path 做约束:
Path targetDir = Paths.get("/data/app/imports/").toRealPath();
try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
Path targetPath = targetDir.resolve(entry.getName()).normalize();
if (!targetPath.startsWith(targetDir)) {
throw new SecurityException("invalid zip entry path");
}
// 后续再处理文件数量、大小、类型等限制
}
}本地复现与验证思路
在安全审计中,不建议直接对线上系统做试探。更合理的方式是搭建本地或测试环境,用最小化样例验证风险点。下面是一个偏防守的验证流程。
步骤 1:确认上传链路
- 定位 Controller、Service、存储组件和返回 URL 的位置。
- 确认文件最终保存位置,是本地磁盘、共享存储还是对象存储。
- 确认文件是否会被后续任务解析、转码、解压或预览。
- 确认访问文件时是否经过鉴权。
步骤 2:检查配置边界
Spring Boot 项目通常会配置 multipart 限制。没有限制时,大文件上传可能造成磁盘、内存或线程资源压力。
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=20MB
server.tomcat.max-swallow-size=20MB这里只是示例值,实际应根据业务场景调整。比如头像上传和数据导入的限制一定不应该相同。
步骤 3:验证文件类型策略
建议准备几类测试样本,在本地环境验证:
- 正常业务文件,例如标准 jpg、png、pdf。
- 后缀与内容不一致的文件。
- 超大尺寸图片。
- 空文件、无扩展名文件、多重扩展名文件。
- 文件名包含特殊字符、空格、中文、超长字符串。
验证目的不是寻找可利用方式,而是确认服务端是否能稳定拒绝异常输入,并返回明确、可控的错误信息。
步骤 4:验证访问控制
上传后的文件访问也需要审计。常见缺陷是“上传接口鉴权了,下载接口没鉴权”,或者 URL 可预测导致越权访问。
建议检查:
- 文件元数据是否绑定租户、用户或业务对象。
- 下载接口是否校验当前用户对该业务对象的访问权限。
- URL 中的 ID 是否可枚举。
- 对象存储临时链接是否有合理过期时间。
- 删除业务对象后,附件是否同步失效或标记不可访问。
加固方案
一个相对稳妥的上传模块,可以按下面的思路设计。
1. 输入层限制
- 限制单文件大小、总请求大小、上传频率。
- 不同业务使用不同白名单,不要做全局“大而全”的允许列表。
- 拒绝空文件、无意义文件名、超长文件名。
- 对 multipart 解析异常做统一处理,避免异常堆栈暴露。
2. 内容层校验
- 扩展名、MIME、魔数、解析结果组合判断。
- 对图片、文档、压缩包等高风险类型使用专门校验逻辑。
- 必要时接入杀毒或沙箱扫描,但不要把它当作唯一防线。
3. 存储层隔离
- 上传文件放在 Web 根目录之外。
- 服务端生成存储文件名,避免用户控制路径。
- 目录按日期、业务或哈希分散,避免单目录文件过多。
- 存储桶默认私有,不直接公开读。
4. 访问层鉴权
- 下载接口按业务对象做权限判断。
- 响应头设置 Content-Disposition,避免浏览器直接解析不可信内容。
- 对可在线预览的类型单独处理,不要默认所有文件 inline 展示。
Content-Disposition: attachment; filename="safe-name.pdf"
X-Content-Type-Options: nosniff5. 运维与监控
- 记录上传用户、业务对象、文件大小、类型、存储位置、校验结果。
- 对异常上传失败率、短时间大量上传、超限请求做告警。
- 定期清理孤儿文件,避免存储长期膨胀。
- 解析任务使用独立队列和低权限运行账户。
审计清单
| 检查项 | 关注点 |
| 文件类型 | 是否只依赖后缀或 Content-Type |
| 保存路径 | 是否位于 Web 根目录内,是否可能被解析执行 |
| 文件名 | 是否使用用户原始文件名拼接路径 |
| 大小限制 | 是否限制单文件和总请求大小 |
| 访问控制 | 下载、预览接口是否校验权限 |
| 后续处理 | 是否存在解压、转码、解析、预览等链路 |
| 异常处理 | 是否暴露堆栈、真实路径或内部错误 |
| 日志监控 | 是否记录关键元数据并可追踪 |
几个容易忽略的细节
- 不要把 Nginx 静态目录和上传目录简单绑定,除非确认不会产生解析和权限问题。
- 不要让前端传来的业务类型直接决定存储目录,业务类型应由服务端根据接口上下文确定。
- 图片缩略图生成后,也要重新生成文件名和元数据,不要沿用不可信输入。
- 导入类接口建议异步处理,先保存、扫描、校验,再进入业务导入阶段。
- 错误信息尽量返回统一业务错误码,详细原因写入服务端日志。
上传安全的核心不是“拦住某一种危险后缀”,而是把不可信文件从输入、存储、解析、访问各阶段都放进受控边界里。
总结
文件上传漏洞之所以常见,是因为它横跨了业务、框架、中间件、文件系统和浏览器行为。单靠一个后缀白名单很难覆盖所有风险。比较可靠的方案是分层处理:输入层限制、内容层校验、存储层隔离、访问层鉴权、解析层降权和监控层追踪。
在代码审计时,我通常不会只看 upload 方法本身,而是沿着“上传入口、保存位置、返回 URL、下载接口、后续处理任务、清理逻辑”完整走一遍。很多真正有价值的问题,往往出现在这些链路的连接处。
推荐意见