背景
文件上传是 Web 系统里很常见的功能,头像、附件、报表导入、工单截图都会用到。也正因为入口多、业务差异大,它一直是代码审计中容易反复出问题的点。很多团队已经知道要限制后缀、限制大小,但在实际项目里,问题往往出在“校验位置不对”“存储位置不对”“文件名处理不对”和“运行环境配置不对”。
这篇文章结合一次 Java Web 项目审计中遇到的上传模块,整理一套比较通用的排查思路。内容偏防守和复盘,不涉及利用链构造,也不提供攻击性操作步骤,重点放在如何识别风险、验证风险和落地修复。
典型场景
项目采用 Spring Boot,提供一个附件上传接口。上传后的文件会写入服务器本地目录,并将访问路径保存到数据库。业务侧要求支持图片、PDF、Office 文档等类型。审计时看到的简化代码大致如下:
@PostMapping("/upload")
public Result upload(@RequestParam("file") MultipartFile file) throws IOException {
String originalName = file.getOriginalFilename();
String suffix = originalName.substring(originalName.lastIndexOf("."));
String newName = UUID.randomUUID().toString() + suffix;
File target = new File(uploadPath + "/" + newName);
file.transferTo(target);
return Result.ok("/uploads/" + newName);
}这段代码看上去已经做了随机文件名,也没有直接使用用户传入的完整文件名。但从审计角度看,它仍然有几个需要继续确认的点:
- 是否校验了允许上传的文件类型。
- 是否只依赖后缀判断文件类型。
- 上传目录是否位于 Web 可直接解析的目录下。
- 是否对文件大小、数量和并发做了限制。
- 是否存在路径拼接、目录穿越、特殊文件名问题。
- 上传后的文件是否会被其他功能二次处理,例如预览、解压、转码。
问题分析
1. 后缀校验不是文件类型校验
很多上传逻辑会使用后缀白名单,例如只允许 jpg、png、pdf、docx。这个方向没问题,但不能把后缀当成唯一依据。后缀来自用户输入,天然不可信。更稳妥的做法是同时检查扩展名、MIME 类型和文件头,并且以后端检测结果为准。
private static final Set<String> ALLOWED_EXT = Set.of("jpg", "jpeg", "png", "pdf", "docx");
private boolean isAllowedExt(String filename) {
if (filename == null || !filename.contains(".")) {
return false;
}
String ext = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
return ALLOWED_EXT.contains(ext);
}注意,Content-Type 也不能单独信任,因为它来自请求头。建议把它作为辅助信号,再结合 Apache Tika、文件魔数检测或业务侧更严格的解析器。
// 示例:使用 Apache Tika 做类型识别,结果仍建议与扩展名白名单交叉验证
Tika tika = new Tika();
String detectedType = tika.detect(file.getInputStream());
if (!Set.of("image/jpeg", "image/png", "application/pdf").contains(detectedType)) {
throw new IllegalArgumentException("unsupported file type");
}2. 上传目录不要放在可执行路径下
审计时最需要确认的一点是:上传目录是否会被 Web 容器直接访问并解析。如果应用把文件写到静态资源目录、Web 根目录,或者某些会被脚本引擎解析的位置,风险会明显放大。
比较安全的设计是:上传文件保存到 Web 根目录之外,仅通过受控接口读取。读取接口负责鉴权、类型设置、文件名映射和下载头控制。
# 示例配置:文件存储目录放在应用目录之外
app.upload-dir=/data/app_uploads/business_attachment@GetMapping("/files/{id}")
public ResponseEntity<Resource> download(@PathVariable Long id) {
Attachment meta = attachmentService.getAndCheckPermission(id);
Resource resource = fileStorage.load(meta.getStorageName());
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + meta.getSafeDownloadName() + "\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
}如果业务确实需要图片在线展示,也建议通过专门的文件服务或对象存储访问,并设置合理的响应头,避免上传内容被浏览器当作 HTML、SVG 或脚本执行。
3. 文件名处理容易被忽略
即使最终存储名使用 UUID,也不要完全忽略原始文件名。原始文件名通常会被保存到数据库,用于页面展示或下载时的文件名。如果没有处理,可能引入 XSS、响应头注入、日志污染等问题。
建议策略:
- 存储文件名和展示文件名分离。
- 展示文件名做长度限制和字符过滤。
- 下载时对 filename 做安全编码。
- 不要把原始文件名直接拼接到路径中。
private String sanitizeDisplayName(String filename) {
if (filename == null) {
return "unknown";
}
String name = Paths.get(filename).getFileName().toString();
name = name.replaceAll("[\\r\\n\u0000]", "");
name = name.replaceAll("[\\\\/:*?\"<>|]", "_");
if (name.length() > 120) {
name = name.substring(0, 120);
}
return name;
}4. 路径拼接要防止越界写入
虽然 MultipartFile 的 originalFilename 在多数场景下只是文件名,但不能假设所有客户端都会按正常浏览器行为提交。只要代码中存在路径拼接,就应使用 canonical path 或 normalize 后确认目标路径仍在预期目录内。
Path baseDir = Paths.get(uploadDir).toAbsolutePath().normalize();
Path target = baseDir.resolve(storageName).normalize();
if (!target.startsWith(baseDir)) {
throw new SecurityException("invalid upload path");
}
Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);这里的 storageName 应该由服务端生成,不应直接来自用户输入。即便如此,边界校验仍然值得保留,因为后续维护中很容易有人把参数改成可控值。
5. 上传后的二次处理风险
很多严重问题不是发生在上传瞬间,而是发生在后续处理环节。例如:
- 图片压缩、EXIF 读取、缩略图生成。
- Office 文档预览转换。
- 压缩包解压。
- PDF 转图片。
- 导入 Excel 后解析公式或外部引用。
这些处理通常依赖第三方库或外部程序。审计时应关注版本、解析参数、临时目录权限、超时控制和资源限制。特别是压缩包处理,需要防止 Zip Slip、压缩炸弹和超大文件展开。
Path destDir = Paths.get("/data/app_uploads/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 detected");
}
// 还应增加总大小、单文件大小、文件数量等限制
}
}关键排查步骤
实际审计时,我一般按下面顺序过一遍,效率比较高。
1. 定位所有上传入口
先不要只搜 upload。建议结合框架特征和参数类型搜索:
# Java 项目常用搜索关键词
MultipartFile
@RequestParam("file")
CommonsMultipartFile
Part
transferTo
Files.copy
FileOutputStream
ObjectMetadata
putObject如果项目接了对象存储,还要看 putObject、presigned URL、临时凭证生成逻辑。很多团队把“直传对象存储”当作天然安全,其实签名策略、回调校验、文件类型限制仍然要审。
2. 梳理数据流
每个上传入口至少记录几项信息:
| 检查项 | 关注点 |
| 入口接口 | 是否需要登录,是否有业务权限校验 |
| 文件类型 | 扩展名、MIME、文件头是否交叉验证 |
| 存储位置 | 是否在 Web 根目录外,是否可被脚本解析 |
| 文件命名 | 是否服务端生成,是否保留原名 |
| 访问方式 | 是否通过受控下载接口,是否有鉴权 |
| 后续处理 | 是否解压、转码、预览、解析内容 |
3. 做低风险验证
验证阶段不建议上来就尝试危险载荷。可以先用普通文本、伪造后缀、异常文件名、超大文件、空文件等方式验证边界条件。重点确认服务端是否按预期拒绝、日志是否清晰、异常是否泄露路径信息。
# 构造一个简单的测试文件,不包含攻击性内容
printf 'test file' > sample.txt
# 观察接口是否仅因后缀放行,而不是基于内容识别
cp sample.txt sample.jpg如果 sample.jpg 被当作真实图片处理,就说明类型识别不足。这个验证不涉及攻击行为,但足以证明控制点存在缺陷。
4. 检查运行环境配置
代码修好了,环境没配好也会出问题。需要关注 Nginx、Tomcat、静态资源映射和对象存储策略。
# Nginx 示例:上传目录只作为下载资源时,禁止自动嗅探
add_header X-Content-Type-Options nosniff;
add_header Content-Security-Policy "default-src 'none'; img-src 'self' data:; style-src 'self'";
# 对不需要在线预览的附件,建议统一 attachment 下载
add_header Content-Disposition "attachment";如果有历史原因必须暴露某个上传目录,至少要确认该目录不会被解释执行,不支持目录列表,不允许上传覆盖已有关键文件。
加固建议
- 使用白名单策略,不使用黑名单策略。黑名单永远容易漏。
- 扩展名、MIME、文件头三者交叉校验,结果不一致时拒绝或进入人工审核。
- 服务端生成存储文件名,不信任客户端文件名。
- 上传目录放在 Web 根目录之外,通过受控接口读取。
- 限制单文件大小、用户总配额、上传频率和并发。
- 对图片、PDF、Office 等复杂格式的解析库保持更新。
- 对解压、转码、预览任务设置超时、内存和输出大小限制。
- 下载接口做鉴权,不要只靠不可猜测 URL。
- 记录上传人、业务 ID、文件摘要、检测结果和访问日志,便于追溯。
- 对历史文件做一次离线扫描,修代码不等于历史风险自动消失。
一个相对稳妥的上传流程
综合下来,一个比较稳妥的流程可以设计成这样:
1. 鉴权与业务权限校验
2. 检查文件是否为空、大小是否超限
3. 规范化原始文件名,仅用于展示
4. 校验扩展名白名单
5. 读取文件头并识别真实类型
6. 扩展名与真实类型交叉验证
7. 生成服务端存储名
8. 写入 Web 根目录之外的存储目录
9. 计算文件哈希并记录元数据
10. 如需异步处理,进入隔离队列并设置资源限制
11. 下载或预览统一走受控接口这里最重要的是“受控”。文件上传不是一个简单的 IO 功能,而是一个把外部不可信内容引入系统内部的入口。只要后续有解析、展示、下载、分发,就需要把它当作完整的数据流来设计。
常见误区
“已经改成 UUID 文件名了,所以安全。”
UUID 只能降低文件名猜测和覆盖风险,不能解决类型校验、解析执行、越权访问等问题。
“对象存储托管了文件,所以应用没有风险。”
对象存储只是改变了存储位置。直传策略、回调可信度、桶权限、跨租户访问控制、预览域名安全头都需要检查。
“只允许图片上传,所以问题不大。”
图片同样可能触发解析库漏洞、EXIF 处理问题、SVG 脚本风险或内容嗅探问题。是否允许 SVG 要非常谨慎,很多业务其实没有必要支持。
总结
文件上传漏洞的排查不能只盯着一个后缀判断。更有效的方法是从入口、校验、存储、访问、二次处理、环境配置几个环节串起来看。审计时优先确认三件事:上传内容是否可信地被识别,上传结果是否可能被执行或误解析,上传后的访问是否受控。
对业务系统来说,最实际的修复路线通常不是追求一次性做得很复杂,而是先完成几项基础改造:白名单校验、Web 根目录外存储、受控下载、大小和频率限制、日志追踪。把这些做好,绝大多数上传类风险都能被压到可控范围内。
推荐意见