只校验后缀名危险,因用户可手动改后缀(如shell.php→shell.jpg)、浏览器可伪造Content-Type;仅凭后缀或Content-Type判断类型等于给攻击者开后门,易导致恶意文件被当作图片执行而引发RCE。

为什么只校验后缀名是危险的
浏览器可随意伪造 Content-Type,用户也能手动改后缀(比如把 shell.php 改成 shell.jpg),仅靠 file.Header.Get("Content-Type") 或 filepath.Ext(file.Filename) 判断类型,等于给攻击者开了后门。真实场景中,恶意文件绕过校验后可能被当作图片执行,导致 RCE。
用 magic number 读文件头做类型判断
必须从文件流开头读取前 512 字节,再用 http.DetectContentType 或硬编码比对 magic 字节。注意:不能直接用 file.Open() 后读,得先用 file.Open() 获取句柄,再 io.ReadFull(src, head);否则后续 io.Copy 会从偏移位置开始读,漏掉头部。
-
file.Open()返回的multipart.File是可 seek 的,但要确保第一次读就是从 offset 0 开始 - PNG 的 magic 是
[89 50 4E 47],JPEG 是[FF D8 FF],GIF 是[47 49 46 38] - 别用正则匹配
http.DetectContentType(head)输出——它返回的是推测的 MIME,比如image/jpeg,但可能不准;优先用白名单 byte slice 匹配
后缀名必须和内容类型一致且可控
即使内容是 PNG,如果用户传的是 xxx.png.php,你按后缀保存为 .php 就完蛋了。所以:提取后缀时只取最后一个 . 之后的部分;再根据 magic 判断结果,强制指定合法后缀(如 PNG 内容 → 强制用 .png);最终文件名用 UUID + 固定后缀生成,不保留原始后缀。
- 禁止用
strings.Split(file.Filename, ".")取最后一段后缀——evil.png.php会被截成.php - 更安全的做法:
ext := getExtByMagic(head),然后newName := uuid.NewString() + ext - 如果
getExtByMagic返回空或不匹配任何白名单,直接c.AbortWithStatusJSON(400, gin.H{"error": "invalid file content"})
如何集成进 Gin handler 流程
别在 c.FormFile 之后立刻保存,中间必须插一层内容校验。典型顺序是:获取文件 → 打开 → 读 head → 校验 magic 和白名单 → 关闭(或重置 offset)→ 再打开用于 copy。关键点是:校验失败时,必须显式 src.Close(),否则临时文件残留。
- 示例片段中常见错误:校验完没
src.Close(),或io.Copy前没调src.Seek(0, 0)(因前面已读过 head) - 推荐结构:
src, _ := file.Open(); defer src.Close(); head := make([]byte, 512); _, _ = io.ReadFull(src, head); src.Seek(0, 0) - 白名单建议硬编码 map[[]byte]string,比如
map[[4]byte]string{{0x89, 0x50, 0x4E, 0x47}: ".png"},避免运行时解析开销
png.DecodeConfig 或 jpeg.DecodeConfig 做二次解析验证——这步常被跳过,但能拦住很多“头对尾错”的畸形文件。


















