作者:温清和-529|5 年安全工程师,专注安全加固与风险防范 栈:Spring Boot 3.2.4 / Spring Security 6.3、Nginx 1.25、Go 1.21(对照实现)、MinIO RELEASE.2024-01-02、file 5.45 结论先行:文件上传和文件下载是同一枚硬币的两面,但攻击面恰好相反。下载侧的根因是「校验的是字符串、OS 打开的是路径」,上传侧的根因是「校验的是扩展名、浏览器执行的是内容」——你拦的是
.txt这个后缀名,而浏览器渲染的是文件里那句<script>。这条缝,就是上传侧所有绕过的藏身之处。
一、现象与背景:一个「只收图片」的头像接口,成了存储型 XSS 的入口
去年给一家做企业协作 SaaS 的客户(约 8 万注册用户、Spring Boot 3.2 + Nginx 1.25)做季度安全复盘。目标是员工头像上传接口 POST /api/v1/avatar,业务定位「只收 jpg/png」。安全同学很自信:「我们白名单了扩展名,只放行 .jpg .jpeg .png。」
我做了三件事,全程不到 20 分钟:
- 把一段
<script>包进一张 SVG,文件名改成avatar.svg,直接被拒(白名单生效); - 换成
avatar.png,但文件内容其实是 SVG(magic bytes 是<?xml),上传成功——因为服务端只看了filename头里的后缀,没看内容; - 把这张「png 壳、SVG 核」的文件存到了和正常头像同一个桶
avatars/下,前端<img src>直接引用——浏览器按<img>的语义不执行 SVG 脚本,但一旦有人用<object>/<iframe>/富文本编辑器加载同一 URL,脚本就跑起来了。
最终影响面:存储型 XSS 一条链(头像 URL 被富文本消息引用即可触发),加上「内容≠扩展名」这个口子让白名单形同虚设。更扎心的是:WAF 一条告警都没报——因为它拦的是「扩展名是否危险」,而我传的 .png 在它眼里干干净净。
这套代码上线 14 个月,期间被内部渗透测到 2 次、都当「误报」关掉了。
二、根因:上传侧的信任边界在「内容语义」,不在「文件名」
先讲机制,才能理解为什么「白名单扩展名」不够。
上传一个文件,系统里其实有三个独立的事实,而大多数实现只校验了第一个:
| 层 | 事实 | 谁说了算 | 多数实现 |
|---|---|---|---|
| 1 | 文件名 / 扩展名 | 客户端(Content-Disposition) | ✅ 只校验这个 |
| 2 | 魔数 / 内容结构(magic bytes、MIME) | 文件本体 | ❌ 基本不看 |
| 3 | 渲染 / 执行上下文(存哪、怎么读) | 服务端存储 + 前端引用方式 | ❌ 默认「存了就能信」 |
攻击者能完全控制第 1 层(改个后缀名零成本),所以任何只锚定第 1 层的判定都是可绕的。真正决定「这个文件能不能执行/注入」的是第 3 层——它落在哪、被谁以什么上下文加载。
具体到绕过路径,有两条最常被忽略:
- 双扩展名 / 内容伪装:
evil.svg.php、或.png后缀装 SVG/HTML。Nginx 的cgi.fix_pathinfo一旦开启,/uploads/evil.svg.php会被当成evil.svg+ 参数php路由到 PHP 处理器——扩展名白名单在这里直接失效,因为决定执行的是「最后一段能被解析成脚本的扩展名」。 - SVG / GIF89a 内嵌脚本:SVG 是 XML,天然能带
<script>、<foreignObject>、onload=;GIF 头后跟 HTML 也能被某些旧浏览器部分解析。你拦不住「内容里写什么」,除非你在渲染侧把它关进<img>语义(<img>不执行脚本)或 CSP 里禁object-src/script-src对该域。
一句话:下载侧防「读错文件」,上传侧防「读对文件但执行了不该执行的内容」。 前者靠规范化路径,后者靠内容校验 + 存储隔离 + 渲染上下文,三件事缺一不可。
三、具体做法:三层加固,每层都能独立挡掉一类绕过
层 1:内容校验(别信扩展名,信魔数)
Spring Boot 侧,用 file 的语义做魔数/结构校验,而不是 String endsWith:
// ❌ 只信扩展名:可被 .png 装 SVG 绕过
if (!name.toLowerCase().endsWith(".jpg") && !name.toLowerCase().endsWith(".png")) {
throw new BizException("仅支持图片");
}
// ✅ 校验内容结构:读前 N 字节判真实类型
byte[] head = in.readNBytes(64);
String real = detectMagic(head); // PNG=89 50 4E 47,JPEG=FF D8 FF,GIF=47 49 46,SVG/HTML 以 "<?xml"/"<" 开头
if (!ALLOWED.contains(real)) {
throw new BizException("文件内容与声明类型不符"); // 命中「png 壳 SVG 核」
}
Go 侧对照(image.DecodeConfig 只认 PNG/JPEG/GIF,SVG 直接不识别,天然挡掉一类):
cfg, _, err := image.DecodeConfig(bytes.NewReader(head))
if err != nil { // SVG/HTML 会在这里失败 → 拒绝
http.Error(w, "not a raster image", http.StatusUnsupportedMediaType)
}
要点:白名单 + 魔数双校验,且以「内容」为准、扩展名只作提示。
层 2:存储隔离(让「存了」不等于「能执行/能注入」)
- 独立桶 / 独立目录:用户上传一律进
user-uploads/,和应用静态资源、和可执行目录物理分开。 - 强制改名为不可预测文件名:
uuid5(namespace, tenant+ts)+.真实扩展,杜绝evil.svg.php这种「文件名即攻击」的写法。 - Nginx 侧把上传目录禁脚本执行(这是挡双扩展名的关键一刀):
location ^~ /user-uploads/ {
# 该目录一律按静态文件处理,任何 .php/.jsp 后缀都不进 CGI
types { } default_type application/octet-stream;
include mime.types;
# 明确禁止任何脚本处理器接管
if ($request_uri ~* \.(php|jsp|asp|aspx|sh|cgi)$) { return 403; }
# 关键:关掉 fix_pathinfo 语义,禁止把 /a.svg.php 解析成 /a.svg + 参数
# (配合 CGI 层)
}
- 响应头钉死渲染语义:对
user-uploads/强制Content-Type按真实类型给,并加Content-Disposition: inline; filename="...";对 SVG 额外下发X-Content-Type-Options: nosniff,防止浏览器「猜」成 HTML 执行。
层 3:渲染上下文(最后一道、也最容易被漏的一道)
- 前端一律用
<img>渲染头像,禁用<object>/<embed>/<iframe>直接指向上传桶——<img>语义天然不执行 SVG 脚本。 - CSP 里把上传域关进
img-src,不放行object-src/frame-src/script-src到该域:
Content-Security-Policy: img-src 'self' https://cdn.user-uploads.example; object-src 'none'; frame-ancestors 'self'
- 富文本编辑器要引用外部资源时,走「服务端先转码成 PNG 再存」的链路(
rsvg-convert/sharp),把 SVG 的脚本能力在入库前就剥掉。
四、踩过的坑 / 反直觉之处
file命令会骗人:file对「PNG 头 + SVG 体」可能报SVG,也可能报PNG graphic data(取决于它读到哪段)。所以魔数校验不能只取第一个 magic,要按「声明类型 → 期望 magic」做正向比对,而不是「file 说啥信啥」。- Nginx
cgi.fix_pathinfo是隐藏雷:很多团队从没改过它,默认on。一旦上传目录里有.php后缀文件,/a.svg.php会被路由成 PHP 脚本执行——扩展名白名单 + 魔数校验都救不了,因为请求根本没走「读文件」而是走「执行脚本」。这一刀只能在 Nginx/CGI 层关掉。 <img>不执行脚本是「浏览器行为」不是「安全保证」:它依赖的是渲染上下文,而不是文件本身。同一个 URL,<img>里安全、<object>里就是 XSS。所以渲染侧的约束(CSP + 前端规范)才是根,存哪、怎么读才是因——只加固服务端、不管前端怎么引用,等于只锁了门没锁窗。- 改文件名 ≠ 安全:光把
evil.svg.php重命名成uuid.png,如果桶还能被<object>加载、或 Nginx 还路由脚本,照样打穿。改名只解决「文件名即攻击」,不解决「内容即攻击」。
五、结论与可复用清单
上传侧安全 = 内容校验(不信扩展名)× 存储隔离(存了≠能执行)× 渲染上下文(谁怎么读),三者相乘,任何一项为 0 整体就为 0。
可复用清单(按优先级):
- [ ] 魔数/结构正向比对,扩展名只作提示;SVG/HTML 对「只收图片」的接口一律拒绝或转码入库
- [ ] 上传文件强制 UUID 改名 + 独立桶/目录,与可执行目录物理隔离
- [ ] Nginx 对上传目录
return 403一切脚本后缀,并确认cgi.fix_pathinfo off - [ ] 上传目录响应头
X-Content-Type-Options: nosniff+ 按真实类型给Content-Type - [ ] 前端头像只用
<img>;CSPobject-src 'none'、img-src限定上传域 - [ ] 富文本引用外部资源走服务端转 PNG 链路(
sharp/rsvg-convert),入库前剥掉脚本能力 - [ ] 审计:拉一次上传桶全量文件,
file+ 魔数双重比对「声明类型 vs 真实内容」,把历史脏数据清掉
一句话收尾:下载侧你拦的是「能不能读到」,上传侧你拦的是「读到了会不会执行」——前者是路径问题,后者是语义问题,用错了工具箱,白名单就是张纸。