背景文件上传是 Web 系统里很常见的功能,但也是代码审计中高频出问题的位置。很多漏洞并不是因为开发完全没有做校验,而是校验点分散、信任了客户端参数、存储路径设计不合理,最后形成了可被利用的风险。这篇文章整理一套偏实战的审计思路,重点放在如何识别上传接口中的安全缺陷,以及如何做工程化加固。内容以防护和复盘为主,不提供针对真实目标的攻击操作。典型场景一个业务系统提供头像、附件、工单截图等上传能力。常见接口形态大致如下:POST /api/file/upload
Content-Type: multipart/form-data
[email protected]
type=avatar后端收到文件后,通常会做几件事:检查文件大小检查文件扩展名生成存储路径保存到本地磁盘或对象存储返回可访问 URL问题往往出在这些步骤之间的边界条件上。例如扩展名校验不等于内容校验,文件保存目录不等于安全目录,返回 URL 可访问不等于应该具备执行能力。审计关注点1. 扩展名校验是否可靠很多代码只通过文件名后缀判断类型,例如:String filename = file.getOriginalFilename();
if (filename.endsWith(\".jpg\") || filename.endsWith(\".png\")) {
save(file);
}这种写法至少有几个问题:没有统一转小写,可能被大小写差异绕过校验逻辑没有处理多后缀文件,例如 name.jpg.xxx没有处理特殊字符、空字节历史兼容问题、路径分隔符完全信任客户端传入的原始文件名更稳妥的方式是:文件名只作为展示字段,不参与存储决策;服务端生成随机文件名;扩展名来自白名单映射。String original = file.getOriginalFilename();
String ext = getSafeExtension(original).toLowerCase(Locale.ROOT);
Set<String> allowExt = Set.of(\"jpg\", \"jpeg\", \"png\", \"pdf\");
if (!allowExt.contains(ext)) {
throw new IllegalArgumentException(\"unsupported file type\");
}
String newName = UUID.randomUUID() + \".\" + ext;2. MIME Type 不能作为唯一依据有些系统会检查 Content-Type,例如 image/png、application/pdf。这个字段来自客户端请求头,不能作为强信任依据。Content-Type: image/png服务端可以把 MIME Type 作为辅助信息,但关键判断应结合文件头魔数、解析库校验和业务场景。例如图片上传可以尝试用图像库解码,如果无法被正常解析,就拒绝。BufferedImage image = ImageIO.read(file.getInputStream());
if (image == null) {
throw new IllegalArgumentException(\"invalid image file\");
}需要注意,文件头校验也不是万能的。某些格式允许附加数据,或者解析器存在兼容行为。所以更推荐采用“白名单格式 + 安全解析 + 重编码”的组合策略。3. 上传目录是否可执行文件上传最危险的情况之一,是上传目录同时具备 Web 可访问和脚本执行能力。正确做法是把上传内容当作静态资源或对象存储数据处理,不应该让应用服务器对其进行动态解析。如果使用 Nginx 提供静态访问,可以针对上传目录禁用脚本执行,并固定响应类型策略。示例:location /uploads/ {
alias /data/app/uploads/;
autoindex off;
# 不把上传目录交给 PHP、JSP 等动态解析器
location ~* \\.(php|jsp|jspx|asp|aspx)$ {
return 403;
}
add_header X-Content-Type-Options nosniff;
}如果是 Java 应用,尽量不要把上传文件放在 webapp 目录下,例如 WEB-INF 外部可访问路径、静态资源目录等。更推荐放到应用目录之外,再通过受控下载接口或对象存储访问。4. 路径拼接是否存在目录穿越风险审计时要重点搜索类似代码:String path = uploadDir + \"/\" + file.getOriginalFilename();
file.transferTo(new File(path));如果原始文件名未处理,可能造成路径混乱,甚至覆盖非预期文件。加固时应避免使用用户传入文件名作为最终路径,并在落盘前做规范化校验。Path base = Paths.get(\"/data/app/uploads\").toAbsolutePath().normalize();
Path target = base.resolve(newName).normalize();
if (!target.startsWith(base)) {
throw new SecurityException(\"invalid path\");
}
file.transferTo(target.toFile());5. 文件大小和资源消耗控制上传接口不仅要考虑文件类型,还要考虑资源消耗。常见问题包括:未限制单文件大小,导致磁盘或内存压力未限制单请求文件数量图片解析时未限制像素尺寸,触发解压炸弹类问题临时文件未清理,长期占满磁盘Spring Boot 可以从配置层先限制请求大小:spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=20MB业务层也要做二次校验,避免配置变更或网关差异导致防线失效。long maxSize = 10 * 1024 * 1024;
if (file.getSize() > maxSize) {
throw new IllegalArgumentException(\"file too large\");
}6. 下载接口的权限边界上传安全不只在上传动作本身。很多系统上传后会返回 fileId,然后通过下载接口访问:GET /api/file/download?id=123这里要检查 fileId 是否和当前用户、当前业务对象绑定。否则可能出现越权下载,导致附件、合同、工单截图等敏感文件泄露。推荐模型是:文件表记录 owner、bizType、bizId、storageKey、visibility 等字段;下载时根据业务权限判断,而不是只看文件是否存在。FileRecord record = fileService.getById(fileId);
if (!permissionService.canRead(currentUser, record.getBizType(), record.getBizId())) {
throw new AccessDeniedException(\"forbidden\");
}关键加固步骤使用服务端随机文件名,避免信任原始文件名采用扩展名白名单,不做黑名单拦截结合 MIME、魔数、解析库进行格式确认图片类文件建议重编码,去除异常附加内容上传目录不允许脚本执行,最好与应用部署目录隔离下载接口必须做鉴权和业务绑定校验限制文件大小、数量、像素尺寸和临时文件生命周期记录上传日志,包括用户、IP、文件哈希、大小、业务对象一个较稳妥的处理流程可以把上传接口拆成以下流程:1. 校验登录态和业务权限
2. 校验文件是否为空、大小是否超限
3. 提取并规范化扩展名
4. 判断扩展名是否在白名单
5. 使用文件头和解析库验证真实格式
6. 对图片等格式进行安全重编码
7. 生成随机 storageKey
8. 保存到不可执行目录或对象存储
9. 写入文件元数据和业务绑定关系
10. 返回 fileId,而不是直接暴露真实路径这个流程的核心思想是:上传文件永远按“不可信输入”处理,最终对外暴露的是受控资源标识,而不是服务端路径。日志与监控建议上传接口容易被自动化探测,因此日志很重要。建议至少记录:用户 ID 或匿名会话 ID来源 IP、User-Agent原始文件名、服务端文件名文件大小、扩展名、MIME Type文件哈希,例如 SHA-256上传结果和拒绝原因对于连续上传异常后缀、超大文件、解析失败文件的行为,可以做简单告警。很多风险在真正造成影响前,日志里已经有明显迹象。常见误区只做前端校验:前端限制只能改善体验,不能作为安全边界只检查 Content-Type:该字段可由客户端构造使用黑名单:后缀和解析链太多,容易漏掉上传后直接返回真实路径:不利于权限控制和后续迁移认为对象存储一定安全:桶策略、公开读写、回源配置同样需要审计总结文件上传接口的安全性,不是某一个 if 判断能解决的,而是路径设计、格式校验、权限模型、存储隔离和日志监控共同决定的。审计时建议从“文件如何进入系统、如何保存、如何访问、谁能访问、是否会被执行”这几个问题入手,通常能快速定位主要风险。对存量系统来说,优先级可以这样排:先确认上传目录不可执行,再补齐下载鉴权和路径规范化,最后逐步完善格式校验、重编码和监控。这样改动风险相对可控,也能覆盖大部分实际问题。
推荐意见