文件上传是 Web 系统里很常见的能力,但也是代码审计和应急排查中反复出问题的模块。很多事故并不是因为某个单点校验缺失,而是上传入口、文件解析、存储位置、访问控制、异步处理几个环节叠加后,形成了可利用面。
这篇文章整理一套偏实战的审计思路,适合做代码审计、自查加固和上线前安全评审。内容只讨论防护和验证方法,不涉及未授权攻击操作。
一、背景:为什么上传模块容易出问题
一个完整的上传流程通常包含这些步骤:
- 前端选择文件并提交 multipart/form-data 请求。
- 后端接收文件流,读取文件名、后缀、Content-Type、大小等信息。
- 后端进行格式校验、重命名、落盘或上传到对象存储。
- 系统生成访问 URL,供用户预览、下载或进一步业务处理。
- 后台任务可能对文件做解压、转码、缩略图、OCR、病毒扫描等处理。
风险也基本围绕这些环节产生:校验不严格、文件可执行、路径拼接不安全、对象存储权限过大、解析库存在历史漏洞、压缩包处理不当、下载接口越权等。
二、常见问题场景
审计中比较高频的问题包括:
- 只依赖前端限制文件类型,后端没有强制校验。
- 只检查文件后缀,不检查真实文件头和解析结果。
- 使用用户原始文件名直接落盘,存在路径穿越、覆盖文件、特殊字符兼容问题。
- 上传目录位于 Web 可访问目录,并且服务器对脚本后缀有执行能力。
- 对象存储桶设置为公共读写,或上传后 URL 权限过宽。
- 允许上传 HTML、SVG、XML 等主动内容,导致存储型脚本风险。
- 解压 ZIP、RAR、7z 时未限制文件数量、总大小和解压路径。
- 图片处理、文档转换组件版本过旧,存在已知解析漏洞。
三、审计入口怎么找
如果是白盒审计,建议先把上传入口摸清楚。不要只搜 upload,因为很多项目会用 attachment、avatar、import、media、file、oss、document 等命名。
rg -n "MultipartFile|Part|FormFile|multer|busboy|formidable|Request.Files|IFormFile" .
rg -n "upload|avatar|attachment|media|import|oss|cos|s3|bucket|fileName|originalFilename" .Java/Spring 项目里重点看 MultipartFile 的使用方式;Node.js 项目里重点看 multer、busboy、formidable;PHP 项目看 $_FILES;.NET 项目看 IFormFile 或 Request.Files。
除了 Controller 层,还要继续追到 Service 层和存储层,因为很多项目在入口处只是临时接收,真正的风险发生在落盘、重命名、上传对象存储和生成访问 URL 的地方。
四、关键技术分析
1. 文件类型校验不要只看后缀
后缀名属于用户可控输入,只能作为第一层参考。更稳妥的方式是后缀白名单、MIME 白名单、魔数识别、业务解析结果一起判断。
例如头像上传,只允许 jpg、jpeg、png、webp,后端需要确认:
- 扩展名归一化后在白名单内。
- Content-Type 不能单独信任,只能作为辅助。
- 读取文件头,确认符合对应格式。
- 使用图片库实际解码一次,确认能被当作图片处理。
- 重新编码输出,去掉潜在的附加内容和元数据。
伪代码示例:
String ext = getNormalizedExt(originalName);
if (!Set.of("jpg", "jpeg", "png", "webp").contains(ext)) {
throw new IllegalArgumentException("unsupported file type");
}
byte[] head = readFirstBytes(file, 16);
if (!isAllowedMagic(head)) {
throw new IllegalArgumentException("invalid file signature");
}
BufferedImage image = ImageIO.read(file.getInputStream());
if (image == null) {
throw new IllegalArgumentException("invalid image content");
}注意,魔数也不是万能的。部分格式允许复杂结构或嵌入内容,所以业务上能重编码的文件,建议统一重编码后再存储和分发。
2. 文件名必须重新生成
用户上传的原始文件名不应直接用于存储路径。常见问题包括:
- 包含 ../ 或 ..\ 导致路径穿越。
- 包含空字节、控制字符、Unicode 混淆字符,造成日志和文件系统表现不一致。
- 同名覆盖已有文件。
- 超长文件名导致异常或拒绝服务。
推荐做法是:原始文件名只用于展示,并经过转义后入库;实际存储名使用服务端生成的随机值或哈希值。
String storageName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
Path base = Paths.get("/data/app/uploads").toRealPath();
Path target = base.resolve(storageName).normalize();
if (!target.startsWith(base)) {
throw new SecurityException("invalid path");
}3. 上传目录不要具备脚本执行能力
如果文件落在 Web 根目录下,风险会明显升高。更稳妥的方式是上传目录放在应用目录之外,通过受控下载接口或独立静态域名访问。
Nginx 可以增加一层兜底,禁止上传目录内的脚本执行和危险类型解析:
location /uploads/ {
alias /data/app/uploads/;
autoindex off;
add_header X-Content-Type-Options nosniff;
location ~* \.(php|jsp|jspx|asp|aspx|sh|pl|py|cgi)$ {
return 403;
}
}
如果业务允许用户下载任意上传文件,建议统一使用 attachment 下载,而不是让浏览器直接渲染不可信内容。
Content-Disposition: attachment; filename="safe-name.bin"
X-Content-Type-Options: nosniff4. SVG、HTML、XML 要谨慎开放
很多系统会把 SVG 当成图片处理,但 SVG 本质上是 XML,可能包含脚本、外链资源、事件属性等内容。如果业务不强依赖 SVG,建议直接禁止。
如果必须支持 SVG,需要做专门的安全净化,例如移除 script、foreignObject、事件属性、外部引用,并配合严格的 Content-Security-Policy。不要用普通图片白名单把 SVG 放进去。
5. 压缩包处理要限制解压行为
导入类功能经常允许上传 ZIP 或 Excel。ZIP 解压常见风险包括路径穿越、压缩炸弹、文件数量过多、单文件过大、嵌套压缩包递归展开。
审计解压逻辑时重点看:
- 解压目标路径是否 normalize 并确认仍在基准目录下。
- 是否限制压缩包总大小、解压后总大小、文件数量、目录层级。
- 是否禁止符号链接和绝对路径。
- 是否对每个解压出的文件再次做类型校验。
Path destDir = Paths.get("/data/app/imports/job-123").toRealPath();
Path out = destDir.resolve(entry.getName()).normalize();
if (!out.startsWith(destDir)) {
throw new SecurityException("zip entry path traversal");
}6. 对象存储不是天然安全边界
现在很多项目会把文件直接上传到 S3、OSS、COS、MinIO 等对象存储。这里容易出现两个误区:一是认为文件不在应用服务器就安全;二是为了方便把桶设置成公共读写。
建议检查:
- 桶权限是否最小化,避免公共写。
- 上传凭证是否短期有效,并限制路径前缀、文件大小和 Content-Type。
- 用户是否只能访问自己有权限的对象。
- 预签名 URL 有效期是否合理。
- 回调接口是否校验签名,避免伪造上传成功通知。
如果使用前端直传,后端签发策略时不要只给一个宽泛的目录权限,应按用户、业务、时间窗口收敛。
五、建议的验证清单
| 检查项 | 建议标准 |
| 后缀校验 | 使用白名单,统一小写,处理多后缀和空格 |
| 内容校验 | 校验魔数,并尽量使用业务解析库实际解析 |
| 文件名 | 服务端生成存储名,原始文件名仅展示 |
| 存储路径 | normalize 后确认在基准目录内 |
| 执行权限 | 上传目录禁止脚本执行,最好不放 Web 根目录 |
| 访问控制 | 下载和预览接口必须校验资源归属 |
| 主动内容 | HTML、SVG、XML 默认禁止或净化后使用 |
| 压缩包 | 限制数量、大小、层级,防路径穿越和压缩炸弹 |
| 对象存储 | 最小权限、短期凭证、回调验签 |
| 审计日志 | 记录用户、文件摘要、大小、类型、存储位置和处理结果 |
六、上线前可以做的安全回归
不需要复杂工具,也可以做一些基础回归。以下示例以本地测试环境为前提,用于验证后端是否真的做了限制。
# 上传一个超过限制大小的文件,确认被拒绝
curl -i -F "[email protected]"
# 上传不在白名单内的后缀,确认被拒绝
curl -i -F "[email protected]"
# 修改 Content-Type,确认后端不只依赖请求头
curl -i -F "[email protected];type=image/png" A 上传的文件,用户 B 是否能通过猜测 ID 或对象 key 下载。这个问题在真实项目里比单纯文件类型绕过更常见。七、几个容易忽略的注意点
- 日志中不要直接打印未经处理的原始文件名,避免控制字符污染日志。
- 文件哈希可以用于去重和溯源,但不要把哈希当作权限控制手段。
- 图片 EXIF 可能包含 GPS、设备信息,面向公众展示前建议清理。
- 导入 Excel 时也要限制行数、列数、公式和外部链接,避免资源消耗和数据外联风险。
- 异步处理失败时,不要把临时文件长期留在可访问目录。
- 杀毒扫描是加分项,但不能替代类型校验、权限控制和安全存储。
上传安全的核心不是某一个校验函数,而是把“不可信文件”从接收、处理、存储到访问的生命周期都管住。
八、总结
文件上传模块的安全设计建议遵循几条原则:后端强校验、白名单优先、服务端重命名、上传目录不可执行、访问接口做鉴权、对象存储最小权限、复杂文件谨慎解析。
实际审计时,不要只停留在 Controller 层的 if 判断。沿着文件流继续看落盘路径、对象 key、预览 URL、下载接口、异步任务和清理逻辑,通常更容易发现真正影响业务安全的问题。

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