背景
文件上传是 Java Web 项目里很常见的功能,但也是代码审计中高频出现问题的点。很多漏洞并不是因为框架本身不安全,而是业务代码在文件名处理、类型校验、存储路径、访问权限等环节留下了缺口。
这篇文章整理一次偏防御视角的复现与审计过程,场景是内部测试环境中的一个文件上传接口。重点放在如何定位风险、如何验证影响、以及如何修复,不包含对真实站点的攻击操作。
本文适用于安全自测、代码审计和开发修复参考。不要在未授权系统上进行漏洞验证。
场景描述
目标系统是一个传统 Java Web 应用,提供头像上传和附件上传两个接口。上传后的文件会被保存到 Web 目录下,前端可直接通过 URL 访问。
审计时关注到一个接口大致逻辑如下:
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws Exception {
String fileName = file.getOriginalFilename();
String savePath = request.getServletContext().getRealPath("/upload/");
File dest = new File(savePath + fileName);
file.transferTo(dest);
return "/upload/" + fileName;
}这段代码看起来很常见,但存在几个明显问题:
- 直接使用客户端传入的原始文件名。
- 未校验文件扩展名和真实内容类型。
- 文件保存到 Web 可访问目录。
- 未处理路径穿越、重名覆盖、特殊字符等情况。
- 没有限制文件大小和上传频率。
问题分析
文件上传漏洞通常不是单点问题,而是多个弱校验组合造成的。这里可以拆成几个风险面来看。
1. 文件名可信度问题
getOriginalFilename() 返回的是客户端提交的文件名,本身不可信。如果直接拼接路径,可能导致路径穿越、覆盖已有文件、产生异常文件名等问题。
String fileName = file.getOriginalFilename();
File dest = new File(savePath + fileName);更安全的做法是服务端重新生成文件名,只保留经过白名单校验的扩展名。
2. 扩展名校验缺失
如果系统只允许上传图片,但没有校验扩展名,就可能上传非预期类型文件。即使做了扩展名黑名单,也容易遗漏大小写、双扩展名、特殊后缀等情况。
建议采用白名单策略,例如只允许:
jpgjpegpnggifpdf
白名单需要结合业务场景控制,不要为了省事允许过多格式。
3. Content-Type 不可靠
很多代码会依赖 file.getContentType() 判断文件类型,但这个值来自请求头,客户端可以伪造。它可以作为辅助信息,但不能作为唯一依据。
String contentType = file.getContentType();
if (!contentType.startsWith("image/")) {
throw new RuntimeException("invalid file type");
}上面的判断并不可靠。更稳妥的方式是同时检查扩展名、文件头特征,以及必要时使用成熟库检测 MIME 类型。
4. 文件保存位置不当
把上传文件直接保存到 Web 根目录下,是风险比较高的设计。如果服务器或中间件对某些扩展名存在解析行为,或者历史配置比较复杂,可能导致非预期执行或敏感文件访问。
更推荐的做法是:
- 上传文件保存到 Web 根目录之外。
- 通过下载接口读取文件并返回。
- 下载接口做权限校验、文件类型控制和响应头处理。
- 静态资源场景可以使用对象存储,并关闭脚本执行能力。
复现思路
在授权测试环境中,可以通过构造不同文件来验证上传接口的边界处理。这里不展示恶意利用过程,只说明验证维度。
1. 验证扩展名限制
准备不同扩展名的普通文本文件,观察接口是否无差别接收。例如:
test.txt
test.jsp
test.php
test.jpg
test.jpg.txt
test.JPEG如果业务只允许图片,但上述非图片文件也被保存并返回可访问 URL,就说明扩展名校验不足。
2. 验证文件内容校验
可以准备一个扩展名为 .jpg 但内容并非图片的测试文件,观察系统是否只看后缀。
echo "this is not an image" > fake.jpg如果接口接受该文件,并且后续业务把它当作图片处理,就可能带来解析异常、内容欺骗或安全绕过风险。
3. 验证路径处理
测试文件名中包含特殊字符、空格、中文、长文件名、路径分隔符等情况,观察服务端保存路径和返回 URL 是否异常。
normal.png
中文文件名.png
space name.png
very-long-file-name-xxxxxxxxxxxxxxxx.png如果出现目录跳转、覆盖文件、异常报错暴露绝对路径等情况,需要立即修复。
4. 验证访问权限
上传后直接访问返回 URL,检查是否存在以下问题:
- 未登录用户能否访问私有附件。
- 其他用户能否枚举文件地址。
- 删除后的文件是否仍可访问。
- 返回头是否缺少安全控制。
附件类功能经常出现越权访问,不应只关注上传本身。
代码审计关注点
实际审计时,可以从控制器入口一路追到存储层和访问层。下面是比较常见的检查清单。
| 审计点 | 风险说明 | 建议 |
| 文件名 | 原始文件名不可信 | 服务端生成随机文件名 |
| 扩展名 | 黑名单容易遗漏 | 使用白名单 |
| 文件内容 | 仅校验 Content-Type 不可靠 | 检查魔数或使用检测库 |
| 保存目录 | Web 目录可能被直接访问或解析 | 保存到非 Web 目录 |
| 权限控制 | 附件地址可能被枚举 | 下载接口校验权限 |
| 大小限制 | 可能造成磁盘占满或内存压力 | 限制单文件和总量 |
| 异常处理 | 可能暴露绝对路径或框架信息 | 统一错误响应 |
相对安全的实现示例
下面是一个更稳妥的处理方式示例。注意这只是示意,生产环境还需要结合权限、审计日志、对象存储、病毒扫描等策略。
private static final Set<String> ALLOW_EXT = Set.of("jpg", "jpeg", "png", "gif", "pdf");
@PostMapping("/upload")
public UploadResult upload(@RequestParam("file") MultipartFile file) throws IOException {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("empty file");
}
if (file.getSize() > 5 * 1024 * 1024) {
throw new IllegalArgumentException("file too large");
}
String originalName = file.getOriginalFilename();
String ext = getSafeExt(originalName);
if (!ALLOW_EXT.contains(ext)) {
throw new IllegalArgumentException("unsupported file type");
}
if (!checkMagicBytes(file, ext)) {
throw new IllegalArgumentException("invalid file content");
}
String newName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
Path baseDir = Paths.get("/data/app/uploads").toAbsolutePath().normalize();
Files.createDirectories(baseDir);
Path target = baseDir.resolve(newName).normalize();
if (!target.startsWith(baseDir)) {
throw new SecurityException("invalid path");
}
file.transferTo(target.toFile());
return new UploadResult(newName);
}
private String getSafeExt(String fileName) {
if (fileName == null) {
throw new IllegalArgumentException("invalid file name");
}
String cleanName = Paths.get(fileName).getFileName().toString();
int idx = cleanName.lastIndexOf('.');
if (idx < 0 || idx == cleanName.length() - 1) {
throw new IllegalArgumentException("missing extension");
}
return cleanName.substring(idx + 1).toLowerCase(Locale.ROOT);
}这里有几个关键点:
- 不直接信任原始文件名。
- 使用扩展名白名单。
- 保存路径做 normalize,并检查是否仍在预期目录下。
- 用 UUID 生成新文件名,避免覆盖和枚举。
- 限制文件大小,避免资源消耗问题。
文件头校验示例
对于图片类文件,可以做简单的魔数检查。不要把它当成唯一安全措施,但比只看后缀可靠很多。
private boolean checkMagicBytes(MultipartFile file, String ext) throws IOException {
byte[] header = new byte[8];
try (InputStream in = file.getInputStream()) {
int len = in.read(header);
if (len < 4) {
return false;
}
}
if (ext.equals("jpg") || ext.equals("jpeg")) {
return (header[0] & 0xFF) == 0xFF && (header[1] & 0xFF) == 0xD8;
}
if (ext.equals("png")) {
return (header[0] & 0xFF) == 0x89
&& header[1] == 0x50
&& header[2] == 0x4E
&& header[3] == 0x47;
}
if (ext.equals("gif")) {
return header[0] == 0x47
&& header[1] == 0x49
&& header[2] == 0x46;
}
if (ext.equals("pdf")) {
return header[0] == 0x25
&& header[1] == 0x50
&& header[2] == 0x44
&& header[3] == 0x46;
}
return false;
}如果业务允许 Office、压缩包等复杂格式,不建议自己手写完整解析逻辑,可以使用 Apache Tika 等库辅助识别,同时结合业务白名单。
下载接口的处理
上传修好了,不代表整个文件功能就安全。很多项目的问题出在下载接口:只要知道文件名就能访问,没有鉴权,也没有检查归属关系。
@GetMapping("/files/{id}")
public ResponseEntity<Resource> download(@PathVariable Long id, Principal principal) {
FileRecord record = fileService.findById(id);
if (record == null) {
return ResponseEntity.notFound().build();
}
if (!filePermissionService.canRead(principal.getName(), record)) {
return ResponseEntity.status(403).build();
}
Path filePath = Paths.get(record.getStoragePath()).normalize();
Resource resource = new FileSystemResource(filePath);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"" + record.getDisplayName() + "\"")
.header("X-Content-Type-Options", "nosniff")
.body(resource);
}建议文件记录使用数据库维护,外部访问使用文件 ID,而不是直接暴露物理文件名。对于私有附件,下载时必须检查用户身份和资源归属。
Nginx 层面的加固
如果确实需要让上传目录通过 Nginx 提供静态访问,至少要确保该目录不会执行任何脚本,并限制可访问类型。
location /uploads/ {
alias /data/app/uploads/;
autoindex off;
add_header X-Content-Type-Options nosniff;
location ~* \.(jsp|php|asp|aspx|jspx|sh|exe|dll)$ {
return 403;
}
}不过这类配置属于兜底措施,不应替代应用层校验。更稳妥的设计仍然是上传目录不放在 Web 根目录,私有文件统一走下载接口。
日志与告警
上传功能建议记录安全日志,便于后续排查异常行为。常见字段包括:
- 用户 ID 和来源 IP。
- 原始文件名和服务端文件名。
- 文件大小、扩展名、检测到的 MIME 类型。
- 上传结果和失败原因。
- 请求链路 ID,方便和网关日志关联。
如果短时间内出现大量失败上传、异常扩展名、超大文件请求,可以触发告警或临时限流。
常见误区
- 只校验前端:前端限制只能改善体验,不能作为安全边界。
- 只看 Content-Type:请求头可控,不能单独信任。
- 使用黑名单:扩展名变体太多,维护成本高。
- 上传后直接返回真实路径:容易暴露目录结构和文件命名规则。
- 忽视下载鉴权:上传接口安全,但附件仍可能被越权读取。
- 依赖中间件默认配置:历史系统里经常有遗留解析规则,不能假设默认安全。
修复建议清单
- 服务端生成随机文件名,不使用用户提交的文件名作为存储名。
- 扩展名采用白名单,按业务最小化开放类型。
- 结合文件头、MIME 检测库进行内容校验。
- 上传目录放在 Web 根目录之外。
- 私有文件通过下载接口读取,并校验权限。
- 限制文件大小、数量、上传频率。
- 统一异常处理,避免返回绝对路径和堆栈信息。
- 记录上传安全日志,接入异常告警。
- 对历史存量文件做一次巡检,清理可疑文件。
总结
文件上传漏洞的本质,是把不可信输入变成了服务器上的可访问资源。审计时不要只看某一行校验代码,而要从上传、存储、访问、权限、日志几个环节串起来看。
比较可靠的思路是:服务端控制文件名,白名单控制类型,非 Web 目录存储,下载接口做鉴权,中间件配置做兜底。这样即使某个环节出现小问题,也不至于直接扩大成严重风险。
这类问题在老项目中很常见,修复成本通常不高,但需要开发和运维一起确认存储路径、访问方式和历史兼容逻辑。建议把上传功能作为代码审计中的固定检查项长期保留。
推荐意见