背景
文件上传一直是 Java Web 项目里比较容易被低估的风险点。很多系统在业务上确实需要上传头像、附件、报表、压缩包,但实现时如果只校验前端后缀,或者把文件直接落到 Web 可访问目录,很容易演变成任意文件上传、存储型 XSS、路径穿越,严重时甚至会造成远程代码执行。
这篇文章整理的是一次偏防守视角的复盘,重点放在代码审计、风险确认和加固验证。文中不提供可用于非法利用的攻击载荷,也不讨论拿权限、控制主机之类的操作,只关注如何识别问题、复现安全边界以及修正实现。
问题场景
目标系统是一个常见的 Spring Boot 后台,存在多个上传入口,包括用户头像、工单附件、富文本图片上传。代码风格比较典型:Controller 接收 MultipartFile,服务层生成文件名,然后保存到本地磁盘,最后返回 URL 给前端展示。
初看业务逻辑没有明显问题,但审计时发现几个危险信号:
- 后缀校验只依赖原始文件名,且没有统一封装。
- Content-Type 被直接信任,用来决定文件类型。
- 上传目录位于静态资源目录下,可被外部直接访问。
- 文件名拼接使用字符串处理,没有做路径规范化校验。
- 部分接口没有鉴权或只做了弱权限校验。
典型风险点分析
1. 只校验前端或原始文件名
比较常见的代码如下:
String filename = file.getOriginalFilename();
String suffix = filename.substring(filename.lastIndexOf(".") + 1);
if (!Arrays.asList("jpg", "png", "gif", "pdf").contains(suffix)) {
throw new RuntimeException("file type not allowed");
}这类写法至少有三个问题。第一,后缀没有统一转小写,大小写绕过会造成校验不一致。第二,没有处理无后缀、隐藏文件名、特殊字符等异常情况。第三,文件真实内容和扩展名没有绑定关系,业务上允许图片并不代表传入内容一定是图片。
后端不能只依赖文件名判断类型。更合理的做法是同时检查扩展名、MIME 类型和文件头特征,并且按业务场景限制最小集合。例如头像上传只允许 jpg、png,就不要把 gif、svg、pdf 都放进去。
2. 信任 Content-Type
有些代码会这样判断:
String contentType = file.getContentType();
if (contentType != null && contentType.startsWith("image/")) {
// save file
}Content-Type 通常来自客户端请求头,不能作为安全判断依据。它可以作为辅助信息记录日志,但不应该作为唯一准入条件。对于图片类文件,建议使用服务端解析库读取基本信息,例如 ImageIO 读取宽高、格式;对于 PDF、Office、压缩包等,则需要结合魔数和安全解包策略。
3. 上传目录可直接执行或访问
如果上传目录在 Web 根目录下,比如:
src/main/resources/static/upload/
webapp/upload/
nginx html/upload/那么任何上传成功的文件都可能被外部直接访问。即使后端阻止了危险脚本扩展名,也仍然可能出现存储型 XSS、敏感文件泄露、钓鱼文件托管等问题。
更稳妥的方式是把上传文件存储在 Web 根目录之外,通过受控下载接口读取,并在下载时设置固定响应头。例如:
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="safe-name.pdf"
X-Content-Type-Options: nosniff对于确实需要预览的图片,应使用独立域名或独立静态服务,并关闭脚本执行能力,避免和主业务 Cookie、管理后台共域。
4. 路径拼接导致目录穿越
审计中还经常看到类似写法:
String basePath = "/data/upload/";
String filename = file.getOriginalFilename();
File dest = new File(basePath + filename);
file.transferTo(dest);如果原始文件名没有经过清洗,可能引入路径穿越、特殊字符污染、覆盖已有文件等风险。即便框架或浏览器在多数情况下会处理文件名,也不能把安全性建立在客户端行为上。
建议保存时完全丢弃用户提交的文件名,仅保留原始文件名作为展示字段。实际落盘文件名使用服务端生成的随机值,例如 UUID、雪花 ID 或内容哈希,并统一追加白名单扩展名。
关键审计步骤
我在审计这类问题时,一般按下面顺序走,效率会比较高。
- 全局搜索上传入口:MultipartFile、CommonsMultipartFile、request.getPart、transferTo、FileOutputStream。
- 确认接口权限:是否需要登录,是否校验业务权限,是否存在越权上传。
- 确认类型校验:扩展名、MIME、文件头是否都在服务端完成。
- 确认保存路径:是否位于 Web 根目录,是否可被直接访问,是否存在脚本执行能力。
- 确认文件名处理:是否使用原始文件名落盘,是否可能路径穿越或覆盖。
- 确认大小和数量限制:是否可能造成磁盘打满、内存占用过高。
- 确认后续处理链路:图片压缩、文档转换、压缩包解压是否引入二次风险。
安全实现示例
下面是一段偏简化的 Java 示例,重点展示几个关键点:白名单、随机文件名、路径规范化、大小限制。实际项目里建议封装成统一上传组件,不要让每个业务模块各写一套。
private static final long MAX_SIZE = 2 * 1024 * 1024;
private static final Set<String> ALLOW_EXT = Set.of("jpg", "jpeg", "png");
public UploadResult saveImage(MultipartFile file, Path uploadRoot) throws IOException {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("empty file");
}
if (file.getSize() > MAX_SIZE) {
throw new IllegalArgumentException("file too large");
}
String originalName = Optional.ofNullable(file.getOriginalFilename()).orElse("");
String ext = getSafeExt(originalName);
if (!ALLOW_EXT.contains(ext)) {
throw new IllegalArgumentException("file type not allowed");
}
BufferedImage image = ImageIO.read(file.getInputStream());
if (image == null || image.getWidth() <= 0 || image.getHeight() <= 0) {
throw new IllegalArgumentException("invalid image");
}
String storedName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
Path normalizedRoot = uploadRoot.toAbsolutePath().normalize();
Path target = normalizedRoot.resolve(storedName).normalize();
if (!target.startsWith(normalizedRoot)) {
throw new SecurityException("invalid path");
}
Files.createDirectories(normalizedRoot);
try (InputStream in = file.getInputStream()) {
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}
return new UploadResult(storedName, originalName, file.getSize());
}
private String getSafeExt(String filename) {
int index = filename.lastIndexOf('.');
if (index < 0 || index == filename.length() - 1) {
return "";
}
return filename.substring(index + 1).toLowerCase(Locale.ROOT);
}这里还有一个细节:示例中使用 ImageIO 做基本图片解析,但它不等同于完整的内容安全检测。对于对抗性文件、畸形图片、超大尺寸图片,还需要限制像素大小、处理库版本,并在异常时记录日志。
Nginx 层面的防护建议
如果业务必须提供静态访问,建议至少在 Web 服务器层面限制执行能力和响应行为。下面是一个示例,仅表达思路,具体路径要按实际环境调整。
location /uploads/ {
alias /data/app/uploads/;
autoindex off;
add_header X-Content-Type-Options nosniff;
add_header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'none'; script-src 'none'";
location ~* \.(jsp|php|asp|aspx|sh|bat|cmd|exe|dll)$ {
deny all;
}
}如果上传文件不需要浏览器内联展示,更建议统一走下载接口,并设置 attachment,降低浏览器解析带来的风险。
验证思路
修复后不要只测正常图片上传,还需要覆盖一些边界用例。比较实用的验证点包括:
- 无后缀文件是否被拒绝。
- 大写扩展名是否按统一规则处理。
- 超大文件是否在服务端被拒绝。
- 伪造 Content-Type 是否不会影响最终判断。
- 畸形图片或无法解析的图片是否被拒绝。
- 包含路径分隔符的原始文件名是否不会影响落盘路径。
- 上传成功后的 URL 是否不会泄露真实服务器路径。
- 未授权用户是否无法调用上传接口。
可以用 curl 做一些基础回归测试,例如验证大小限制和响应码:
curl -i -X POST \
-H "Authorization: Bearer <token>" \
-F "[email protected];type=image/png" \
CI,也可以把上传组件的单元测试和接口测试纳入流水线,避免后续业务模块复制旧代码导致问题回流。日志与监控
上传接口的日志经常被忽视,但它对事件排查很有价值。建议记录以下字段:
- 用户 ID、来源 IP、接口路径。
- 原始文件名、服务端文件名、文件大小。
- 判定出的扩展名、解析出的文件类型。
- 拒绝原因,例如 size_exceeded、invalid_ext、invalid_content。
- 业务对象 ID,例如工单 ID、文章 ID。
注意不要在日志里记录文件完整内容,也不要把敏感业务附件路径直接暴露到前端。对于短时间内大量上传失败、频繁尝试异常扩展名的行为,可以接入风控或告警。
常见误区
- 认为有 WAF 就可以不做后端校验。上传安全最终还是要落在业务代码和存储策略上。
- 认为限制后缀就足够。文件内容、访问方式、解析链路同样重要。
- 认为内网系统风险低。很多上传漏洞是在后台系统、运维系统里出现的。
- 认为对象存储天然安全。对象存储同样需要权限、Content-Type、下载策略和生命周期管理。
总结
文件上传安全的核心不是某一个判断条件,而是一组边界控制:入口要鉴权,类型要服务端验证,文件名要服务端生成,路径要规范化,存储要和 Web 执行环境隔离,访问要受控,日志要可追溯。
从代码审计角度看,上传点通常不难找,难点在于把后续链路看完整:文件保存在哪里、谁能访问、浏览器如何解析、后台是否会二次处理。只要把这几个问题逐一确认,大多数上传类风险都能在上线前被拦下来。

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