背景
文件上传是 Web 系统里很常见的功能,但也是代码审计中反复出现问题的模块。很多团队会在入口处做扩展名校验,却忽略存储路径、解析行为、对象存储回源、预览服务、异步处理任务等后续链路,最后导致风险并不在“上传接口本身”,而是在上传后的使用方式里被放大。
这篇文章结合平时审计中的经验,整理一套偏长期可复用的分析思路。内容聚焦于防守视角和安全验证,不提供未授权攻击或恶意利用步骤。
典型场景
一个常见业务链路大概是这样:
- 用户通过前端选择头像、附件、合同、图片素材等文件;
- 后端接口接收 multipart/form-data 请求;
- 服务端校验文件大小、扩展名、Content-Type;
- 文件保存到本地、NFS、对象存储或 CDN 源站;
- 业务侧返回可访问 URL;
- 后续可能进入图片压缩、文档预览、OCR、病毒扫描、异步转码等流程。
审计时如果只盯着上传接口,通常只能发现一部分问题。真正需要关注的是:这个文件最终会被谁读取、以什么权限读取、在哪里被解析、是否会被当作可执行内容处理。
风险拆解
1. 扩展名校验不足
最基础的问题是仅依赖前端限制或简单字符串判断,例如只检查文件名是否包含 .jpg,而不是检查最后一个扩展名。更隐蔽的是多层代理或中间件在不同阶段对文件名做了解码、截断、规范化,导致校验阶段和落盘阶段看到的文件名不一致。
建议审计以下点:
- 是否只在前端做限制;
- 后端是否使用白名单而不是黑名单;
- 是否处理大小写、空格、Unicode 变体、双扩展名;
- 保存文件名是否由服务端重新生成;
- 是否保留用户原始文件名作为真实路径的一部分。
一个相对稳妥的做法是:业务展示可保留原始文件名,但物理存储路径不要直接使用用户输入。
allowed_ext = {"jpg", "jpeg", "png", "gif", "pdf"}
filename = get_original_filename()
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
if ext not in allowed_ext:
raise ValueError("unsupported file type")
safe_name = generate_uuid() + "." + ext
save_path = os.path.join(upload_dir, safe_name)2. Content-Type 不可信
Content-Type 来自客户端请求头,只能作为辅助信息,不能作为安全判断依据。很多历史问题都源于服务端认为 image/png 就一定是图片。实际审计时,应检查服务端是否读取文件头、是否使用可靠库进行格式识别,以及识别失败时是否拒绝。
但也要注意,魔数检测同样不是万能的。某些格式本身允许附加数据,或者业务后续处理库存在解析差异。因此更合理的策略是多层防护:
- 扩展名白名单;
- 服务端 MIME 识别;
- 文件头校验;
- 必要时重新编码生成新文件;
- 上传目录禁止脚本执行;
- 下载响应强制设置安全头。
3. 上传目录解析风险
在传统部署里,上传文件常被保存到 Web 目录下。如果 Web 服务器或应用容器会解析该目录中的脚本文件,就可能形成高风险链路。即使扩展名看起来被限制,也不应把安全建立在“不会传上来危险扩展名”这个假设上。
Nginx 场景下,上传目录建议只作为静态资源目录,并关闭不必要的解析行为。示例配置仅用于说明防护思路:
location /uploads/ {
alias /data/app/uploads/;
autoindex off;
add_header X-Content-Type-Options nosniff;
add_header Content-Disposition "attachment";
}
location ~* ^/uploads/.*\.(php|jsp|asp|aspx|cgi|pl)$ {
return 403;
}如果使用对象存储,也要关注 Bucket 权限、回源规则和 CDN 的 Content-Type 推断行为。对象存储不是天然安全边界,错误的公开读写策略同样会造成问题。
4. 路径穿越与覆盖写入
上传逻辑里另一个常见问题是直接拼接用户提供的文件名。例如用户文件名中包含路径分隔符,或者经过 URL 解码、Unicode 规范化后产生意料之外的路径。即便框架会处理一部分情况,也不建议依赖默认行为。
审计时可重点看:
- 是否使用
../、绝对路径、反斜杠等危险字符过滤; - 是否在保存前做路径归一化并校验根目录;
- 是否允许同名覆盖;
- 是否存在用户可控的子目录参数;
- 临时文件移动过程是否存在竞态问题。
保存路径校验可以参考如下思路:
base_dir = os.path.realpath("/data/app/uploads")
target = os.path.realpath(os.path.join(base_dir, safe_name))
if not target.startswith(base_dir + os.sep):
raise ValueError("invalid path")5. 文件预览和转码链路
很多上传漏洞并不发生在上传接口,而是发生在后续处理环节。例如图片处理、压缩包解压、Office/PDF 预览、音视频转码、OCR 等。这些组件往往调用第三方库或系统命令,风险类型包括资源消耗、解析器漏洞、命令参数注入、压缩包路径穿越等。
比较容易被忽略的是“异步任务权限过高”。上传服务本身可能运行在低权限容器中,但转码任务运行在宿主机或共享任务节点,能访问更多目录和内网服务,这会扩大影响面。
建议:
- 文件处理任务放入隔离容器或沙箱;
- 限制 CPU、内存、运行时间和输出文件大小;
- 不要把用户输入直接拼接进命令行;
- 优先使用参数数组调用,避免 shell 解释;
- 压缩包解压前检查文件列表和最终路径;
- 处理失败的文件不要继续对外发布。
例如调用外部工具时,应避免这种写法:
# 不建议:用户输入参与 shell 字符串拼接
cmd = "convert " + input_file + " " + output_file
os.system(cmd)更稳妥的方式是传入参数数组,并确保路径来自服务端生成:
subprocess.run(
["convert", input_file, output_file],
timeout=10,
check=True
)关键审计步骤
实际做代码审计时,可以按下面的顺序推进,效率会高一些。
第一步:找入口
搜索上传相关关键字,例如:
multipart
MultipartFile
FormFile
upload
move_uploaded_file
Storage::put
request.files
IFormFile
busboy
multer不要只看控制器,还要追踪到 service、storage adapter、异步消息队列、文件处理 worker。
第二步:确认信任边界
明确哪些字段来自用户输入:
- 原始文件名;
- 扩展名;
- Content-Type;
- 用户指定目录;
- 回调地址;
- 预览参数;
- 压缩包内文件名。
这些字段都应该被视为不可信输入。审计时重点看它们是否进入文件系统、命令执行、模板渲染、日志展示、数据库查询或网络请求。
第三步:追踪文件生命周期
建议画一条简单的数据流:
- 上传入口:谁能上传、是否鉴权、是否限频;
- 临时保存:路径在哪里、权限如何;
- 正式存储:本地、NFS、对象存储还是 CDN;
- 访问方式:直链、签名 URL、网关代理;
- 后处理:是否转码、解压、预览;
- 清理机制:失败文件、临时文件是否清理。
如果文件会跨服务流转,还要检查消息队列里的字段是否可信,避免下游服务重新信任上游传来的文件名或路径。
第四步:验证配置是否一致
不少问题来自配置不一致。开发环境、测试环境、生产环境使用不同 Nginx 配置、不同对象存储策略、不同预览组件,导致代码看起来没问题,线上行为却不一样。
建议至少核对:
- Web 服务器对上传目录的解析规则;
- 对象存储 Bucket 是否允许匿名写;
- CDN 是否会覆盖 Content-Type;
- 应用是否设置
X-Content-Type-Options: nosniff; - 下载接口是否使用
Content-Disposition; - 容器挂载目录是否过宽。
防护清单
| 风险点 | 建议措施 |
| 危险扩展名 | 使用白名单,服务端重新生成文件名 |
| MIME 伪造 | 结合扩展名、文件头和格式解析结果判断 |
| 脚本解析 | 上传目录禁止执行,静态域名隔离 |
| 路径穿越 | 路径归一化后校验根目录,不使用原始文件名落盘 |
| 覆盖写入 | 使用随机文件名,避免用户控制完整路径 |
| 资源消耗 | 限制大小、数量、处理时间和输出体积 |
| 预览组件风险 | 沙箱隔离,及时更新解析库 |
| 对象存储误配置 | 最小权限,分离读写凭证,开启审计日志 |
日志与监控
上传模块建议记录必要的安全日志,但不要记录敏感内容本身。比较有价值的字段包括:
- 用户 ID、租户 ID、来源 IP;
- 原始文件名和服务端文件 ID;
- 文件大小、识别出的 MIME、扩展名;
- 上传结果和失败原因;
- 后处理任务状态;
- 下载或访问次数异常。
如果发现短时间内大量失败上传、异常扩展名、超大文件、重复触发解析失败,应考虑告警。这类信号在事件复盘时通常很有用。
注意点
文件上传安全不是单点校验问题,而是“上传、存储、访问、处理、清理”整条链路的问题。只修一个扩展名判断,通常不能真正收敛风险。
- 不要依赖前端校验;
- 不要直接使用用户原始文件名作为存储路径;
- 不要把上传目录放在可执行脚本目录下;
- 不要让转码、预览任务以高权限运行;
- 不要默认信任对象存储和 CDN 的安全配置;
- 不要忽略失败文件和临时文件的清理。
总结
文件上传模块的审计价值在于链路分析。入口处的校验只是第一层,后面的存储路径、Web 服务器解析、对象存储策略、预览服务、异步任务权限,都会影响最终风险。
比较稳妥的建设思路是:白名单校验、服务端重命名、目录不可执行、静态资源隔离、处理任务沙箱化、对象存储最小权限、日志可追踪。这样即使某一层出现疏漏,也不至于直接演变成严重问题。
实际项目里建议把上传能力做成统一组件,避免各业务线重复实现。安全策略集中以后,后续修复和审计都会轻很多。
推荐意见