背景
文件上传一直是 Web 安全里很常见、也很容易被低估的风险点。很多业务后台认为“只有管理员能上传”“上传后不直接展示给外部用户”就可以放松限制,但实际审计中经常会看到:上传目录可执行、扩展名校验不严、MIME 信任客户端、文件名未规范化、对象存储回源配置不当等问题叠加,最后把一个普通功能变成高危入口。
这篇文章不讨论利用攻击链,只整理一次后台上传模块审计中比较有代表性的检查方法和加固建议,偏长期可复用的经验。适合做代码审计、上线前安全评审、内部安全规范梳理时参考。
典型场景
业务侧常见需求是:运营人员在后台上传图片、附件、模板文件,系统保存到本地目录或对象存储,并将访问地址写入数据库。看起来功能很简单,但安全边界往往不清晰。
一个典型的上传流程大致如下:
- 前端选择文件并提交 multipart/form-data 请求。
- 后端读取文件名、后缀、Content-Type、文件大小。
- 校验通过后保存到 upload 目录或转存到对象存储。
- 返回 URL,前台或后台页面引用该 URL。
问题通常出在“校验”和“存储”两个环节。单点缺陷未必严重,但多个缺陷组合后风险会明显上升。
审计关注点
1. 扩展名校验是否存在绕过空间
审计时先看后端如何判断文件类型。如果只依赖原始文件名中的字符串判断,就要重点关注大小写、双扩展名、空白字符、URL 编码、路径分隔符等情况。
不推荐的写法一般有这些特征:
- 使用 contains 判断扩展名,例如文件名里包含 .jpg 就放行。
- 直接取用户传入的 filename 保存,未重新生成文件名。
- 只在前端限制文件类型,后端没有重复校验。
- 黑名单过滤少数危险后缀,而不是白名单允许业务所需类型。
更稳妥的做法是使用白名单,并且从规范化后的文件名中提取最后一个扩展名。示例逻辑如下:
String originalName = multipartFile.getOriginalFilename();
String safeName = Paths.get(originalName).getFileName().toString();
String ext = StringUtils.getFilenameExtension(safeName);
Set<String> allowExt = Set.of("jpg", "jpeg", "png", "gif", "pdf");
if (ext == null || !allowExt.contains(ext.toLowerCase(Locale.ROOT))) {
throw new IllegalArgumentException("unsupported file type");
}这里的重点不是某个语言 API,而是几个原则:去路径、取最后扩展名、统一小写、使用白名单。
2. MIME 与文件魔数不能只信一个
Content-Type 由客户端提交,不能作为安全判断依据。很多系统会写类似 image/jpeg 就放行,但这个值可以被任意指定。更合理的方式是结合扩展名、服务端 MIME 探测、文件头魔数进行综合判断。
例如图片类文件可以检查常见魔数:
| 类型 | 常见文件头 |
|---|---|
| JPEG | FF D8 FF |
| PNG | 89 50 4E 47 0D 0A 1A 0A |
| GIF | 47 49 46 38 |
| 25 50 44 46 |
需要注意的是,魔数检查也不是万能的。比如多格式混淆文件、带元数据的图片、某些容器格式都可能让简单判断失效。对高风险场景,建议走成熟库解析并重编码,比如图片上传后统一用图像处理库重新生成,丢弃原始元数据和异常结构。
3. 上传目录是否具备执行权限
这是上传风险里最关键的一点。即使前面的类型校验出现疏漏,只要上传目录不具备脚本执行能力,也能把风险压低很多。
如果使用 Nginx,可以将上传目录作为纯静态资源目录,并明确禁止脚本执行或转发到后端解释器。示例:
location /uploads/ {
alias /data/app/uploads/;
autoindex off;
add_header X-Content-Type-Options nosniff;
}
location ~* ^/uploads/.*\.(php|jsp|jspx|asp|aspx|cgi|pl)$ {
return 403;
}如果是 Java、Node.js、Go 这类应用,也要避免把上传目录放在 Web 根目录、模板目录、类路径目录下。更推荐的方式是:文件存储在非 Web 目录,访问时通过受控下载接口或专门的静态服务读取。
4. 文件名与路径处理是否安全
不少问题不是文件内容导致的,而是文件名导致的。例如目录穿越、覆盖已有文件、特殊字符污染日志或响应头、同名文件竞争等。
建议上传后不要保留原始文件名作为真实存储名,可以只作为展示字段保存。真实存储名使用随机值或哈希值,并按日期、业务 ID 分目录。
String ext = ext.toLowerCase(Locale.ROOT);
String storedName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
Path baseDir = Paths.get("/data/app/uploads/2026/10/10");
Path target = baseDir.resolve(storedName).normalize();
if (!target.startsWith(baseDir)) {
throw new SecurityException("invalid path");
}
Files.copy(inputStream, target, StandardCopyOption.REPLACE_EXISTING);这里有两个细节:第一,真实文件名由服务端生成;第二,resolve 后做 normalize,并确认最终路径仍在基准目录内。
5. 对象存储场景的额外风险
现在很多系统会直接上传到 OSS、S3、COS 等对象存储,风险点会从“服务器目录执行”转到“访问控制和内容分发”。
常见问题包括:
- Bucket 配成公共写,导致任意人可上传。
- 上传凭证权限过大,前端临时凭证可写任意路径。
- 对象返回 Content-Type 不受控,浏览器可能按可执行内容解析。
- CDN 回源后缺少 nosniff、Content-Disposition 等响应头控制。
- 删除业务记录后对象未删除,产生长期暴露文件。
对象存储更推荐按业务前缀隔离,并给临时凭证设置最小权限。例如只允许写入当前用户或当前工单对应前缀,且限制文件大小和类型。下载敏感文件时不要直接公开永久 URL,可以使用短期签名 URL。
关键加固步骤
从实践角度,后台上传模块可以按以下顺序整改,优先级比较明确。
- 后端强制白名单扩展名,不依赖前端校验。
- 服务端检查 MIME、魔数,必要时对图片进行重编码。
- 真实存储文件名由服务端生成,不使用原始文件名。
- 上传目录放在非 Web 可执行路径下,禁止脚本执行。
- 限制文件大小、单用户上传频率、总存储配额。
- 下载响应增加安全头,避免浏览器嗅探。
- 对敏感附件做鉴权,避免“拿到 URL 即可访问”。
- 记录上传人、IP、文件哈希、业务关联 ID,便于追踪。
下载接口可以考虑设置如下响应头:
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="download.bin"
X-Content-Type-Options: nosniff
Cache-Control: private, max-age=0如果业务要求图片在线预览,可以只对确认安全的图片类型返回 image/jpeg、image/png,并且上传后统一转码输出,避免直接回显原始文件。
测试与验证方法
安全测试不需要一开始就构造复杂样本,先覆盖基础边界条件更有效。建议准备一组用例,验证每一层防护是否真实生效。
- 正常图片、正常 PDF 是否可上传。
- 大小写后缀是否按统一规则处理。
- 双扩展名文件是否被拒绝或按最后后缀处理。
- Content-Type 与真实内容不一致时是否被拒绝。
- 超大文件是否被限制,失败后是否残留临时文件。
- 文件名包含路径分隔符时是否被规范化。
- 访问上传目录中的脚本类后缀是否返回 403 或下载。
- 未授权用户是否能访问他人附件。
对于 CI 或上线前检查,可以把部分用例固化为自动化测试。尤其是“上传目录不可执行”和“对象存储权限最小化”,建议纳入部署检查,而不是只靠代码审计。
容易忽略的细节
- 压缩包上传要谨慎,解压时要防止 Zip Slip、压缩炸弹和文件数量失控。
- SVG 属于高风险格式,可能包含脚本或外部引用,不建议作为普通图片直接放行。
- Excel、Word 等 Office 文档可能包含宏,内部系统流转时应结合杀毒或沙箱扫描。
- 图片元数据可能包含 GPS、设备信息,公开展示前可以清理 EXIF。
- 错误信息不要返回完整服务器路径,日志中也要避免记录敏感 URL。
- 临时目录同样要限制权限,并定期清理。
小结
文件上传安全的核心不是某一个校验函数,而是多层约束:类型白名单、内容识别、路径安全、目录不可执行、访问鉴权、日志审计。只要其中一层松动,问题未必马上变成严重漏洞;但如果几层同时缺失,后台上传功能就很容易成为高风险入口。
实际审计时,我一般会先看三个问题:文件最终存在哪里、能否被解释执行、访问是否需要鉴权。确认这三点后,再细看扩展名、MIME、魔数、对象存储策略和边界用例。这样的顺序比较节省时间,也更容易抓住真正影响风险等级的关键点。
推荐意见