背景文件上传是 Java Web 项目里很常见的功能,头像、附件、导入模板、富文本图片都会用到。问题在于,上传链路往往同时涉及文件名处理、扩展名校验、MIME 判断、存储路径、静态资源访问和后续解析逻辑,任何一个环节处理不当,都可能让普通上传功能变成高危入口。这篇文章整理一次较典型的文件上传漏洞复盘思路,重点放在代码审计和防护改造上。内容不涉及实际入侵操作,也不提供可直接用于攻击的样例,只讨论如何识别风险、验证边界和修复问题。问题场景某 Java Web 系统提供附件上传接口,业务设计上只允许上传图片和 PDF。接口大致流程如下:前端限制上传文件后缀,例如 jpg、png、pdf;后端接收 MultipartFile;根据原始文件名截取扩展名;将文件保存到 Web 可访问目录;返回文件 URL 给前端展示或下载。从功能上看没有异常,但从安全角度看,这里有几个明显的审计切入点:是否只依赖前端校验;后端扩展名判断是否可靠;文件内容是否校验;保存目录是否可执行或可被服务器解析;文件名是否存在路径穿越、覆盖、特殊字符问题;返回 URL 是否导致敏感文件暴露。典型问题代码下面是一段常见但风险较高的实现,很多项目里都能看到类似写法:public String upload(MultipartFile file) throws IOException {
String originalName = file.getOriginalFilename();
String suffix = originalName.substring(originalName.lastIndexOf(\".\") + 1);
if (!Arrays.asList(\"jpg\", \"png\", \"pdf\").contains(suffix)) {
throw new RuntimeException(\"file type not allowed\");
}
String savePath = servletContext.getRealPath(\"/upload/\") + originalName;
file.transferTo(new File(savePath));
return \"/upload/\" + originalName;
}这段代码的问题不在某一行,而是整体安全边界不清晰:使用原始文件名保存:可能导致重名覆盖,也可能包含特殊字符、路径分隔符等风险。扩展名校验过于简单:未统一大小写,未处理多后缀、空后缀、隐藏字符等情况。仅基于后缀判断类型:后缀不能代表真实文件内容。保存到 Web 目录:如果中间件或框架存在解析配置问题,风险会被放大。路径拼接不规范:依赖字符串拼接,缺少 canonical path 校验。技术分析:上传漏洞通常卡在哪几个点1. 文件名与路径处理文件名来自用户输入,不能直接信任。审计时重点关注是否存在如下写法:String path = baseDir + \"/\" + file.getOriginalFilename();
file.transferTo(new File(path));更稳妥的方式是完全丢弃用户原始文件名,仅保留必要的扩展名,并生成服务端文件名。同时使用 Path API 处理路径,并校验最终路径仍在预期目录内。Path baseDir = Paths.get(\"/data/app/uploads\").toAbsolutePath().normalize();
String ext = getSafeExtension(originalFilename);
String newName = UUID.randomUUID().toString().replace(\"-\", \"\") + \".\" + ext;
Path target = baseDir.resolve(newName).normalize();
if (!target.startsWith(baseDir)) {
throw new SecurityException(\"invalid upload path\");
}
Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);2. 扩展名校验扩展名白名单是必要的,但不能单独作为判断依据。建议注意以下点:统一转小写后再比较;只允许明确业务需要的类型;拒绝无后缀文件;不要使用黑名单思路;不要允许服务端脚本、模板、配置文件等类型进入可访问目录。private static final Set<String> ALLOWED_EXT = Set.of(\"jpg\", \"jpeg\", \"png\", \"pdf\");
private String getSafeExtension(String filename) {
if (filename == null) {
throw new IllegalArgumentException(\"empty filename\");
}
String name = Paths.get(filename).getFileName().toString();
int index = name.lastIndexOf('.');
if (index < 0 || index == name.length() - 1) {
throw new IllegalArgumentException(\"missing extension\");
}
String ext = name.substring(index + 1).toLowerCase(Locale.ROOT);
if (!ALLOWED_EXT.contains(ext)) {
throw new IllegalArgumentException(\"extension not allowed\");
}
return ext;
}3. MIME 与文件内容校验Content-Type 由客户端提交,不能直接信任。后端可以结合文件头、解析库和业务规则进行校验。例如图片类文件可以尝试用 ImageIO 读取尺寸,PDF 可以检查基本文件头并限制解析行为。private boolean isValidImage(InputStream input) throws IOException {
BufferedImage image = ImageIO.read(input);
return image != null && image.getWidth() > 0 && image.getHeight() > 0;
}但这里有个经验点:不要为了校验而引入复杂解析链路,尤其是历史上出过漏洞的图像、压缩包、文档解析库。解析前要限制文件大小,解析过程要考虑超时、内存占用和异常处理。4. 存储目录与访问策略推荐把上传文件存放在 Web 根目录之外,通过受控接口读取或通过对象存储/CDN 下发。这样即使文件类型判断有遗漏,也不容易被应用服务器直接解析。比较合理的目录设计:/data/app/uploads/
├── image/
├── pdf/
└── tmp/如果确实需要让 Nginx 提供静态访问,也要确保该目录只作为静态文件目录,不参与脚本解释,不允许目录列表,并设置合适的响应头。location /files/ {
alias /data/app/uploads/;
autoindex off;
add_header X-Content-Type-Options nosniff;
add_header Content-Disposition \"attachment\";
types {
image/jpeg jpg jpeg;
image/png png;
application/pdf pdf;
}
default_type application/octet-stream;
}注意:不同环境的 Nginx、Tomcat、Spring Boot 静态资源配置差异较大,不能只看代码,还要一起审配置。关键审计步骤实际做代码审计时,可以按下面顺序推进,效率会高一些。第一步:定位上传入口常见关键字包括:MultipartFile
CommonsMultipartFile
FileItem
transferTo
getOriginalFilename
@RequestParam(\"file\")
@RequestPart
ServletFileUpload如果项目比较大,建议先全局搜索接收文件的 Controller,再向下追踪 Service、工具类和存储实现。第二步:确认校验位置重点确认校验发生在保存之前还是之后。有些项目先保存到临时目录,再校验并移动,这种方式可以接受,但要保证临时目录不可被直接访问,且校验失败后会删除文件。检查点风险表现建议前端限制只在 JS 中判断后缀后端必须重新校验后端后缀黑名单或简单 contains使用严格白名单文件名使用原始文件名服务端生成随机名目录保存到 Web 根目录放到 Web 根目录之外内容只看 Content-Type结合文件头和解析校验第三步:检查下载与预览接口上传接口修好了,不代表整体安全。很多问题出在下载接口,例如根据参数直接读取文件:@GetMapping(\"/download\")
public void download(String path, HttpServletResponse response) throws IOException {
File file = new File(path);
Files.copy(file.toPath(), response.getOutputStream());
}这种实现容易引入任意文件读取风险。更安全的方式是使用文件 ID 查询数据库记录,再根据服务端保存的相对路径读取文件,并做路径归一化校验。Path baseDir = Paths.get(\"/data/app/uploads\").toAbsolutePath().normalize();
Path filePath = baseDir.resolve(storedRelativePath).normalize();
if (!filePath.startsWith(baseDir)) {
throw new SecurityException(\"invalid file path\");
}
Files.copy(filePath, response.getOutputStream());第四步:检查压缩包与批量导入如果业务支持 zip、rar、tar 等压缩包,风险会更复杂。需要额外关注:解压路径穿越;压缩炸弹导致磁盘或内存耗尽;解压后文件类型未校验;文件数量和总大小未限制;嵌套压缩包递归处理。解压时不能直接信任 entry name,必须对目标路径做 normalize 和 startsWith 校验。Path destDir = Paths.get(\"/data/app/imports/tmp\").toAbsolutePath().normalize();
Path outPath = destDir.resolve(entry.getName()).normalize();
if (!outPath.startsWith(destDir)) {
throw new SecurityException(\"zip entry path traversal detected\");
}防护改造建议一个相对稳妥的上传方案,可以包含这些控制点:后端使用白名单限制扩展名;根据业务类型校验文件内容;限制单文件大小、总大小、上传频率;文件名由服务端生成,不使用用户原始文件名落盘;上传目录放在 Web 根目录之外;下载接口使用文件 ID,不直接接收物理路径;返回下载时设置安全响应头;对图片、文档解析库保持更新;失败文件及时清理,避免临时目录堆积;记录上传人、文件哈希、文件大小、业务来源,便于追溯。示例响应头可以参考:X-Content-Type-Options: nosniff
Content-Disposition: attachment; filename=\"safe-name.pdf\"
Cache-Control: private, max-age=0漏洞验证的边界在企业内部或授权测试中,验证上传风险时建议控制范围,不要做会影响业务稳定性的尝试。重点验证以下安全属性即可:不允许的扩展名是否被拒绝;大小写变形是否被正确处理;异常文件名是否被重命名或拒绝;上传目录是否能被直接列目录;下载接口是否只能访问授权文件;错误文件是否会残留在可访问路径;响应头是否避免浏览器错误嗅探。不建议在生产环境上传可执行脚本、构造破坏性压缩包,或尝试影响中间件解析行为。复现的目标是证明风险存在,而不是扩大影响。常见误区误区一:前端限制就够了。前端校验只能改善体验,不能作为安全边界。误区二:只要不允许 jsp 就安全。不同语言、框架、中间件都有自己的解析规则,黑名单长期不可靠。误区三:保存到随机目录就没问题。随机路径只能降低被猜到的概率,不能替代类型校验和访问控制。误区四:Content-Type 是可信的。这是客户端提交字段,必须结合服务端校验。误区五:上传接口没问题,下载接口就不用看。下载接口经常引入路径穿越、越权访问和敏感文件泄露。总结文件上传漏洞的本质,是把不可信输入变成了服务器上的文件,并且后续又允许它被访问、解析或下载。审计时不能只看一个 if 判断,而要把上传、存储、访问、解析、删除整个生命周期串起来看。比较实用的判断标准是:即使上传校验出现遗漏,文件也不应该被服务器当成可执行内容处理;即使用户知道文件路径,也只能访问自己有权限访问的文件;即使出现异常文件,也不会影响磁盘、内存和业务稳定性。在实际项目里,把上传目录移出 Web 根目录、服务端生成文件名、严格白名单、下载走文件 ID 这几项先落地,通常就能消掉大部分高风险问题。剩下的再结合业务类型做内容校验和访问控制,会更稳一些。
推荐意见