背景
文件上传是 Java Web 项目里很常见的功能,头像、附件、工单截图、导入文件都会用到。它看起来只是业务能力,但在实际代码审计和应急排查中,上传点经常是高风险入口:一旦校验不严,可能引入 WebShell、恶意脚本、存储型 XSS、任意文件覆盖,甚至配合解析缺陷形成远程代码执行。
这篇文章整理一次典型的上传漏洞复盘思路,重点放在防守侧:如何识别风险代码、如何复现安全问题、如何设计加固方案。文中示例均为本地靶场或脱敏代码,不涉及真实目标攻击。
问题场景
某 Java Web 系统提供附件上传功能,后端使用 Spring MVC 接收 MultipartFile,上传后把文件保存到 Web 根目录下的 upload 目录,并将访问路径返回给前端。
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
String fileName = file.getOriginalFilename();
File dest = new File(servletContext.getRealPath("/upload/") + fileName);
file.transferTo(dest);
return "/upload/" + fileName;
}从业务角度看,这段代码能正常运行;从安全角度看,问题比较集中:
- 直接信任原始文件名,存在路径穿越和文件覆盖风险。
- 文件保存目录位于 Web 可访问目录下。
- 未限制文件类型、大小、内容。
- 未做文件名规范化处理。
- 返回了可直接访问的文件路径。
技术分析
文件上传风险并不只取决于“扩展名是否允许”。很多系统只做了简单判断,例如:
if (!fileName.endsWith(".jpg") && !fileName.endsWith(".png")) {
throw new RuntimeException("invalid file type");
}这种方式容易遗漏大小写、双后缀、特殊字符、服务端解析差异等问题。更关键的是,上传后的文件如果进入 Web 可解析目录,风险会被明显放大。
常见风险点可以拆成几类:
| 风险点 | 说明 | 影响 |
| 扩展名校验不足 | 只基于字符串后缀判断 | 上传非预期文件 |
| Content-Type 可信任 | HTTP 头可由客户端伪造 | 绕过简单 MIME 判断 |
| 保存到 Web 目录 | 文件可被直接访问或解析 | 扩大利用面 |
| 使用原始文件名 | 未处理 ../、路径分隔符、特殊字符 | 路径穿越、覆盖文件 |
| 无大小限制 | 可上传超大文件 | 磁盘耗尽、服务异常 |
本地复现思路
复现时不建议直接使用真实业务环境。可以在本地搭一个最小 Spring Boot 项目,保留上传接口和静态资源映射,用于验证风险点。
首先观察上传后的落盘位置和访问路径。如果文件进入应用静态目录,例如 src/main/resources/static/upload 或部署后的 Web 根目录,就需要重点关注。
其次测试文件名处理逻辑。重点不是构造攻击,而是确认服务端是否会原样使用客户端文件名。可以用正常文本文件进行测试,例如带空格、中文、重复文件名、长文件名、特殊符号的文件,观察是否产生异常或覆盖。
# 仅用于本地环境验证上传行为
curl -F "[email protected];filename=normal.txt"
curl -F "[email protected];filename=repeat.txt" 是否直接接收 MultipartFile、Base64、远程 URL。校验:是否限制大小、扩展名、MIME、文件魔数。 文件名:是否使用 getOriginalFilename,是否做规范化和随机化。 路径:是否允许用户控制目录、是否拼接字符串生成路径。 存储:是否保存到 Web 可访问目录,是否有执行权限。 访问:是否通过下载接口鉴权访问,还是直接暴露静态路径。 后处理:是否调用图片压缩、Office 解析、PDF 预览等组件。 其中“后处理组件”很容易被忽略。比如图片处理库、文档转换服务、解压缩逻辑都可能引入新的攻击面。上传功能不只是保存文件,还包括后续解析链路。
推荐加固方案
比较稳妥的做法是采用“白名单校验 + 随机文件名 + 非 Web 目录存储 + 下载接口鉴权”的组合。不要依赖单一校验点。
1. 限制文件大小
Spring Boot 可以先在配置层限制请求大小,避免大文件直接打到业务逻辑。
spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=10MB
业务层仍然建议再判断一次,配置限制和代码限制是两道不同的防线。
2. 规范化文件名
不要直接使用客户端传入的原始文件名作为落盘名称。原始文件名只适合作为展示字段保存到数据库。
String originalName = file.getOriginalFilename();
String ext = StringUtils.getFilenameExtension(originalName);
if (ext == null) {
throw new IllegalArgumentException("missing extension");
}
ext = ext.toLowerCase(Locale.ROOT);
Set<String> allowExt = Set.of("jpg", "jpeg", "png", "pdf");
if (!allowExt.contains(ext)) {
throw new IllegalArgumentException("unsupported file type");
}
String savedName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
3. 校验文件魔数
扩展名只能作为第一层判断。对于图片、PDF 等常见文件,建议读取文件头做魔数校验。
private boolean isPng(byte[] header) {
return header.length >= 8
&& header[0] == (byte) 0x89
&& header[1] == 0x50
&& header[2] == 0x4E
&& header[3] == 0x47
&& header[4] == 0x0D
&& header[5] == 0x0A
&& header[6] == 0x1A
&& header[7] == 0x0A;
}
魔数校验也不是绝对安全,但能过滤掉大量明显不符合预期的文件。对于复杂格式,建议使用成熟库解析并重新生成安全版本,例如图片重新编码。
4. 保存到非 Web 目录
上传文件不应放在应用可直接解析的目录里。推荐保存到独立数据目录,通过受控下载接口读取。
Path baseDir = Paths.get("/data/app-upload").toAbsolutePath().normalize();
Path target = baseDir.resolve(savedName).normalize();
if (!target.startsWith(baseDir)) {
throw new SecurityException("invalid path");
}
Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);
这里的 normalize 和 startsWith 检查很重要,可以避免路径拼接过程中出现意外目录跳转。
5. 下载接口做权限控制
不要把真实文件路径直接暴露给前端。前端拿到的应是文件 ID,后端根据文件 ID 查询元数据、校验权限,再读取文件流返回。
@GetMapping("/files/{id}")
public ResponseEntity<Resource> download(@PathVariable Long id, Principal principal) {
FileMeta meta = fileService.findById(id);
fileService.checkPermission(meta, principal);
Resource resource = fileService.loadAsResource(meta.getSavedName());
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + meta.getOriginalName() + "\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
}
如果是图片预览,也建议通过接口返回,并设置合理的 Content-Type、Content-Disposition、缓存策略和鉴权逻辑。
6. 去除执行权限和解析能力
从部署角度看,上传目录应当不可执行。Nginx 可对上传目录禁用脚本解析,Java 应用则尽量不要把上传目录映射为静态资源目录。
location /upload/ {
alias /data/app-upload/;
default_type application/octet-stream;
add_header Content-Disposition "attachment";
}
如果确实需要 Nginx 提供静态访问,也要注意不要和 PHP、CGI、脚本解释器等解析规则混在一起。静态文件服务和脚本解析服务最好隔离。
注意点
- 不要只相信 Content-Type,它来自客户端请求头,可被伪造。
- 不要只做前端限制,前端限制只能改善体验,不能作为安全控制。
- 不要把上传目录放到 classpath、static、public、webapp 等可直接访问路径。
- 不要使用原始文件名落盘,原始文件名应仅用于展示。
- 不要在上传后立即调用高权限解析器处理未知文件。
- 对压缩包上传要额外处理解压路径、文件数量、压缩比,防止 Zip Slip 和压缩炸弹。
- 日志中记录文件 ID、上传用户、大小、类型、校验结果,避免记录敏感业务内容。
应急排查建议
如果怀疑系统存在上传风险,可以优先排查以下位置:
- 最近一段时间上传目录内新增的异常后缀文件。
- 文件名包含特殊字符、路径分隔符、多个后缀的文件。
- 访问日志中对上传目录的高频访问和异常 User-Agent。
- 应用日志中的文件解析异常、下载异常、权限校验失败记录。
- 部署目录是否出现非发布流程产生的新文件。
排查时建议先冻结现场,复制上传目录和日志再分析,避免直接删除导致线索丢失。确认风险后,再进行隔离、修复、密钥轮换和补充监控。
总结
文件上传漏洞的根因通常不是某一行代码,而是上传链路缺少整体安全设计。比较可靠的方案是:入口限制大小,服务端做白名单和魔数校验,文件名随机化,存储目录与 Web 解析环境隔离,访问通过鉴权接口完成。
实际项目里,上传功能往往分散在多个模块,建议把校验和存储封装成统一组件,禁止业务代码各自实现一套。这样既能降低漏洞重复出现的概率,也方便后续审计和应急排查。

代码审计、调用链与关键函数定位示意
推荐意见