背景
文件上传是 Web 系统里很常见的功能,头像、附件、工单截图、富文本图片都会用到。它看起来只是业务能力,但在安全审计里一直属于高风险入口:一旦校验不足,可能导致脚本文件落地、解析规则误用、敏感文件覆盖、存储桶公开访问等问题。
这篇整理来自几次代码审计和复盘里的共性问题,重点放在“如何发现风险”和“如何把上传链路做稳”。不讨论未授权入侵、恶意控制等内容,只从防护和验证角度展开。
常见场景与风险点
一个典型上传链路大致包括:前端选择文件、后端接收 multipart 请求、校验文件、保存到本地或对象存储、返回访问 URL。问题通常不出在某一个点,而是多个小疏忽叠加。
- 只校验文件后缀,未校验真实文件类型。
- 使用用户提交的原始文件名保存,存在路径穿越或覆盖风险。
- 上传目录位于 Web 可执行路径下,被服务器按脚本解析。
- 图片处理库未限制尺寸,导致内存消耗异常。
- 对象存储权限配置过宽,上传文件被公开列举或任意读取。
- 业务接口缺少鉴权或额度限制,被滥用为临时网盘。
审计时建议先看入口
代码审计时,我一般先定位接收 multipart 的接口,再看三个问题:谁能传、能传什么、传到哪里。很多漏洞不是因为某段代码特别复杂,而是因为入口默认信任了用户输入。
以 Java Spring 项目为例,可以先全局搜索以下关键词:
MultipartFile
@RequestParam("file")
transferTo(
getOriginalFilename()
Files.copy(
putObject(
uploadPHP 项目可以关注:
$_FILES
move_uploaded_file
pathinfo
mime_content_type
getimagesizeNode.js 项目可以关注:
multer
busboy
formidable
originalname
mimetype
destination
filename找到上传入口后,不要只看控制器层。实际保存逻辑经常封装在 FileService、StorageClient、OssUtil 之类的工具类里,真正的风险点往往在这些通用组件中。
技术分析:几个容易被忽略的细节
1. 后缀白名单不是唯一依据
后缀校验必须做,但不能只依赖后缀。用户提交的 filename、Content-Type 都可以伪造。更稳妥的做法是:后缀白名单、魔数识别、解码验证三者结合。
例如图片上传,至少应确认:
- 扩展名只允许 jpg、jpeg、png、gif、webp 等明确类型。
- 文件头符合预期格式。
- 可以被安全图片库正常解码。
- 重新编码后再保存,避免保留异常附加内容。
如果业务只需要展示图片,推荐“解码后重编码”而不是原样保存。这样可以显著降低伪造文件和异常结构文件带来的风险。
2. 文件名必须由服务端生成
不要直接使用 getOriginalFilename() 或 originalname 作为最终落盘文件名。原始文件名最多用于展示,保存时应由服务端生成随机名,并去掉路径语义。
// 示例:Java 中生成安全文件名思路
String ext = getSafeExtension(originalName);
String fileName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
Path target = uploadRoot.resolve(fileName).normalize();
if (!target.startsWith(uploadRoot)) {
throw new SecurityException("invalid upload path");
}这里有两个点比较关键:一是文件名不可预测,二是 normalize 后必须确认最终路径仍在预期目录内,避免路径穿越。
3. 上传目录不要具备脚本执行能力
如果文件保存到 Web 根目录下,一定要确认服务器不会把该目录中的文件当作脚本执行。更推荐的方案是:上传文件存放在 Web 根目录之外,通过静态资源服务或对象存储读取。
Nginx 场景下,可以单独给上传目录设置只读静态访问,并禁止脚本解析:
location /uploads/ {
alias /data/app/uploads/;
autoindex off;
add_header X-Content-Type-Options nosniff;
types {
image/jpeg jpg jpeg;
image/png png;
image/gif gif;
image/webp webp;
application/pdf pdf;
}
default_type application/octet-stream;
}
# 不要在 uploads 路径下配置 PHP、JSP、CGI 等动态解析如果是对象存储,建议区分上传桶和分发桶,或者通过后端审核、转码后再发布到可访问区域。
4. MIME 类型要服务端控制
浏览器访问上传文件时,如果响应头不准确,可能引发内容嗅探问题。建议显式设置 Content-Type,并加上 X-Content-Type-Options: nosniff。
对于不需要浏览器直接打开的附件,可以统一使用下载头:
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="safe-name.ext"
X-Content-Type-Options: nosniff这类细节经常被忽略,但在富文本附件、工单附件、站内消息附件场景里很实用。
5. 图片处理要限制尺寸和资源消耗
很多系统会在上传后生成缩略图。如果未限制图片像素、帧数、文件大小,可能被异常大图拖垮处理进程。这里不需要复杂技巧,只要缺少限制就可能出问题。
- 限制单文件大小,例如头像 2MB、普通图片 10MB。
- 限制图片宽高和总像素,例如不超过 8000x8000。
- 限制 GIF/WebP 动图帧数或直接禁止动图。
- 图片处理放到异步队列,避免阻塞主请求线程。
- 处理失败时删除临时文件,避免垃圾文件堆积。
关键步骤:一套比较稳的上传处理流程
实际落地时,可以按下面的流程实现,不必依赖单一校验点。
- 接口鉴权:确认用户身份和业务权限。
- 额度限制:限制频率、单文件大小、每日总量。
- 基础校验:检查扩展名、文件大小、文件数量。
- 内容识别:通过文件头和安全库判断真实类型。
- 安全处理:图片重编码,文档走杀毒或沙箱扫描。
- 生成文件名:服务端生成随机名,避免使用原始文件名。
- 隔离存储:上传目录不可执行,最好不在 Web 根目录。
- 返回引用:返回文件 ID 或受控 URL,不直接暴露内部路径。
- 访问控制:下载接口校验权限,敏感附件不要公开访问。
- 日志审计:记录用户、IP、文件摘要、大小、类型、结果。
如果论坛编辑器不支持有序列表,上面这套也可以理解为从“入口控制”到“访问控制”的闭环。重点是不要让上传后的文件脱离业务权限体系。
一个简化版安全校验示例
下面是偏伪代码的 Java 示例,主要表达处理思路,具体库需要按项目情况替换。
public UploadResult uploadImage(MultipartFile file, User user) throws Exception {
if (user == null) {
throw new SecurityException("unauthorized");
}
if (file.isEmpty() || file.getSize() > 5 * 1024 * 1024) {
throw new IllegalArgumentException("invalid file size");
}
String originalName = file.getOriginalFilename();
String ext = getSafeExtension(originalName);
if (!List.of("jpg", "jpeg", "png", "webp").contains(ext)) {
throw new IllegalArgumentException("unsupported extension");
}
byte[] bytes = file.getBytes();
String detectedType = detectByMagic(bytes);
if (!isAllowedImageType(detectedType, ext)) {
throw new IllegalArgumentException("invalid file type");
}
BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes));
if (image == null) {
throw new IllegalArgumentException("image decode failed");
}
long pixels = (long) image.getWidth() * image.getHeight();
if (pixels > 40_000_000L) {
throw new IllegalArgumentException("image too large");
}
String safeName = UUID.randomUUID().toString().replace("-", "") + ".jpg";
Path target = uploadRoot.resolve(safeName).normalize();
if (!target.startsWith(uploadRoot)) {
throw new SecurityException("invalid path");
}
// 重编码保存,避免原样落盘
ImageIO.write(image, "jpg", target.toFile());
String sha256 = sha256(target);
auditLog(user.getId(), safeName, sha256, file.getSize(), detectedType);
return new UploadResult(safeName, "/media/" + safeName);
}这里把最终文件统一转成 jpg,只是示例。实际业务如果需要透明背景,就要对 png 做专门处理。关键点是:不要把用户上传的字节流在校验不足的情况下直接放到可访问目录。
审计清单
| 检查项 | 常见问题 | 建议 |
|---|---|---|
| 鉴权 | 上传接口匿名可访问 | 接入登录态和业务权限校验 |
| 类型校验 | 只看后缀或 Content-Type | 后缀、魔数、解码验证结合 |
| 文件名 | 使用原始文件名保存 | 服务端生成随机名 |
| 存储路径 | 位于可执行 Web 目录 | 隔离存储,禁止脚本解析 |
| 访问控制 | 拿到 URL 即可访问 | 敏感文件走鉴权下载 |
| 资源限制 | 未限制大小、像素、频率 | 设置大小、尺寸、频率和总量限制 |
| 日志 | 无法追踪上传来源 | 记录用户、IP、摘要、类型、结果 |
测试验证建议
加固完成后,建议做一轮回归验证。这里的验证目标是确认防护是否生效,不涉及攻击利用。
- 上传允许类型文件,确认正常保存和访问。
- 上传不在白名单内的扩展名,确认被拒绝。
- 修改 Content-Type 后上传,确认服务端不受影响。
- 使用异常文件名,如包含路径分隔符,确认不会影响保存路径。
- 上传超大文件、超大尺寸图片,确认被限制。
- 访问上传目录下不存在的文件,确认不会列目录。
- 检查响应头,确认存在 nosniff 等必要安全头。
如果项目使用对象存储,还要额外检查 Bucket ACL、临时凭证权限、回调鉴权、跨域规则。很多线上问题不是应用代码导致的,而是云存储权限过宽。
注意点
上传安全不要寄希望于某个单点规则。比较可靠的做法是:入口鉴权、内容校验、存储隔离、访问控制、日志审计同时具备。
- 前端校验只能改善体验,不能作为安全边界。
- 黑名单策略不可靠,优先使用明确白名单。
- 不要把内部绝对路径返回给前端。
- 临时文件要及时清理,失败流程也要覆盖。
- 富文本图片和普通附件最好使用不同策略。
- 敏感附件不要放公共 CDN,至少使用短期签名 URL。
总结
文件上传漏洞的根源通常是“把用户输入当成可信文件”以及“上传后文件进入了不该进入的执行或公开访问环境”。审计时抓住三个问题:谁能传、能传什么、传到哪里,基本可以定位大部分风险。
加固也不复杂:白名单、真实类型识别、服务端命名、重编码、存储隔离、下载鉴权、资源限制和日志审计。真正难的是在业务迭代中保持这些规则不被绕开,所以建议把上传能力收敛到统一组件,避免每个业务线各写一套。
推荐意见