背景
文件上传是 Web 系统里很常见的功能,头像、附件、工单截图、导入文件都会用到。也正因为入口普遍,上传点经常成为代码审计和渗透测试中的重点关注对象。实际项目里,上传漏洞并不总是表现为“直接上传可执行脚本”这么简单,更多时候是校验链路不完整、存储目录设计不合理、文件解析行为理解偏差导致的组合风险。
这篇文章整理一次常见文件上传功能的审计思路,重点放在漏洞成因、复现验证、风险判断和修复方案上。内容仅面向授权测试和防护建设,不涉及未授权攻击利用。
典型场景
某业务系统提供附件上传功能,后端使用 Java Spring Boot,前端限制只能上传图片和 PDF。后端代码大致逻辑如下:
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
String originalName = file.getOriginalFilename();
String suffix = originalName.substring(originalName.lastIndexOf("."));
if (!Arrays.asList(".jpg", ".png", ".pdf").contains(suffix)) {
throw new RuntimeException("file type not allowed");
}
String saveName = UUID.randomUUID().toString() + suffix;
Path path = Paths.get("/data/app/uploads/" + saveName);
Files.copy(file.getInputStream(), path);
return "/uploads/" + saveName;
}乍看做了扩展名白名单和随机文件名,似乎问题不大。但从审计角度看,这段逻辑仍然有几个需要确认的点。
问题分析
1. 只校验扩展名,不等于校验文件类型
扩展名来自用户输入,不能作为唯一判断依据。攻击者可以构造“内容与扩展名不一致”的文件,例如文件名是 .jpg,但实际内容不是图片。即使当前环境不会执行该文件,也可能在后续被其他组件处理时触发风险,例如图片处理库解析异常、PDF 解析器漏洞、内容嗅探导致的前端脚本执行等。
更可靠的做法是同时检查:
- 扩展名白名单;
- MIME 类型,但不能完全信任客户端传入的 Content-Type;
- 文件魔数,例如 JPEG、PNG、PDF 的文件头;
- 必要时重新编码图片,去除非预期内容。
// 示例:简单检查文件头,不应作为唯一安全边界
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);
}2. 上传目录是否会被 Web 容器直接解析
文件保存到了 /data/app/uploads/,返回路径是 /uploads/xxx。如果该目录被 Nginx 或应用静态资源映射直接暴露,需要确认它只作为静态下载目录使用,不能被脚本引擎解析。
在 Java 项目中,上传 JSP 通常只有在特定部署方式下才可能被解析,但很多历史系统会把上传目录放在 webapps 下,这类风险更高。PHP、ASP、老旧混合架构中尤其需要关注上传目录是否有执行权限。
Nginx 层面可明确禁止上传目录执行动态脚本,示例:
location /uploads/ {
alias /data/app/uploads/;
autoindex off;
add_header X-Content-Type-Options nosniff;
default_type application/octet-stream;
}
location ~* ^/uploads/.*\.(php|jsp|jspx|asp|aspx)$ {
return 403;
}3. 原始文件名的处理
示例代码使用 UUID 作为保存名,这是比较好的习惯。但仍建议对 originalFilename 做最小化信任处理,因为它可能包含特殊字符、路径分隔符、空字节历史兼容问题或非常长的字符串。即使不直接作为保存路径,也可能进入日志、数据库、下载响应头,引发日志污染、响应头注入或展示层 XSS。
如果需要保存原始文件名,建议做长度限制和字符规范化:
private String normalizeFileName(String name) {
if (name == null) return "unknown";
name = name.replaceAll("[\\r\\n]", "");
name = name.replaceAll("[\\\\/]", "_");
if (name.length() > 120) {
name = name.substring(0, 120);
}
return name;
}4. 文件大小和资源消耗
上传功能容易被忽略的另一类问题是资源消耗。没有大小限制时,可能导致磁盘打满、临时目录堆积、图片解压炸弹、PDF 解析耗时过长等问题。
Spring Boot 可以在配置层限制单文件和请求总大小:
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=20MB同时,业务层还应根据实际场景做二次限制。例如头像上传不应允许 20MB,工单附件和离线导入文件也应该分开配置。
5. 下载与预览链路同样重要
很多上传漏洞不是在上传时触发,而是在下载、预览、转码、索引时暴露。例如:
- 下载接口直接拼接路径,导致路径穿越;
- 预览接口将用户上传的 HTML、SVG 以内联方式展示,引发脚本执行;
- 服务端调用第三方工具处理文档,未隔离进程权限;
- 返回 Content-Type 不准确,浏览器发生 MIME 嗅探。
下载接口建议通过文件 ID 查询真实路径,不允许用户直接传入磁盘路径:
@GetMapping("/download/{id}")
public ResponseEntity<Resource> download(@PathVariable Long id) {
FileRecord record = fileService.getById(id);
if (record == null) {
return ResponseEntity.notFound().build();
}
Path base = Paths.get("/data/app/uploads").toAbsolutePath().normalize();
Path target = base.resolve(record.getSaveName()).normalize();
if (!target.startsWith(base)) {
return ResponseEntity.status(403).build();
}
Resource resource = new FileSystemResource(target);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"" + record.getSafeName() + "\"")
.header("X-Content-Type-Options", "nosniff")
.body(resource);
}审计时的关键检查点
| 检查项 | 关注点 | 建议 |
|---|---|---|
| 扩展名校验 | 是否黑名单、是否大小写绕过、是否多后缀处理混乱 | 使用白名单,统一转小写,严格解析最后一个扩展名 |
| 内容校验 | 是否只信任 Content-Type | 结合魔数、文件解析、必要时重编码 |
| 保存路径 | 是否可控、是否存在路径穿越 | 固定目录,使用随机文件名,normalize 后校验 base path |
| 执行权限 | 上传目录是否可能被动态解析 | 上传目录与应用代码目录分离,禁用脚本执行 |
| 大小限制 | 是否可导致磁盘或内存耗尽 | 配置层和业务层双重限制 |
| 下载预览 | 是否内联展示不可信内容 | 默认附件下载,设置 nosniff,谨慎开放预览 |
安全复现思路
在授权环境中验证文件上传安全性,不建议直接尝试高危载荷。更稳妥的方式是用无害样本确认边界条件:
- 上传扩展名合法但内容不匹配的文件,观察是否被接受;
- 上传超大文件,确认大小限制和错误处理;
- 上传带特殊文件名的样本,检查日志、数据库和响应头;
- 访问返回的文件 URL,确认是否被当作附件下载或静态资源展示;
- 检查上传目录在服务器侧的权限和 Web 映射配置。
# 示例:生成一个内容为文本、扩展名为 jpg 的测试文件
printf 'not a real image' > test.jpg
# 示例:生成 12MB 测试文件,用于验证大小限制
dd if=/dev/zero of=large.bin bs=1M count=12这类验证不会涉及恶意控制行为,但足以发现“只看扩展名”“无限制上传”“错误暴露过多”等常见问题。
加固建议
- 上传目录与应用代码目录分离,原则上只读访问,不赋予执行权限。
- 文件名使用服务端生成的随机值,原始文件名仅作为展示字段,且必须清洗。
- 使用白名单策略,不使用黑名单兜底。
- 对图片类文件进行解码再编码,避免保留非预期内容。
- 对文档预览、压缩包解压、图片处理等高风险操作做沙箱隔离。
- 下载接口基于文件 ID,不接受用户传入任意路径。
- 统一设置 X-Content-Type-Options: nosniff,必要时强制 attachment 下载。
- 上传失败时返回通用错误,详细异常写入服务端日志即可。
- 建立文件清理机制,避免临时文件和孤儿文件长期占用磁盘。
文件上传的核心不是“能不能传某个后缀”,而是整个文件生命周期是否受控:上传、存储、访问、预览、处理、清理,每个环节都可能成为风险点。
总结
文件上传漏洞看似基础,但在真实项目里往往以组合问题出现。仅依赖前端限制或简单扩展名判断是不够的,后端需要从文件类型、存储路径、访问权限、资源限制和后续处理链路一起设计。
代码审计时建议不要只盯着 upload 接口本身,还要顺着返回 URL、下载接口、预览服务、Nginx 配置和文件处理组件继续看。很多高风险问题并不在第一段上传代码里,而是在文件被“再次使用”的地方。
推荐意见