作者:温清和-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 还路由脚本,照样打穿。改名只解决「文件名即攻击」,不解决「内容即攻击」。
五、对象存储侧的隐藏坑(补录)
上面三层加固默认「上传桶是私有读、靠应用层发 presigned URL」。但实际项目里,很多团队为了省事把桶设成 public-read(尤其用 CDN 加速头像时),这会让攻击面从「一个文件」扩散到「整个桶」:
-
presigned URL 的作用域陷阱:MinIO / S3 的 presigned URL 默认只签名了 object key,但 bucket policy 如果是
s3:GetObject对avatars/*全放行,攻击者拿到任意一个有效 URL 后,枚举相邻 key(uuid5虽然不可预测,但日志里会泄露——access log、浏览器历史、CDN 边缘日志)就能直接读同桶其他用户头像。建议:presigned URL 的X-Amz-Scope限定到具体 key(不是前缀),且 bucket policy 用s3:Prefix条件限制到租户子目录avatars/{tenant_id}/*。 -
rsvg-convert/sharp转码的残留风险:「转码成 PNG 就安全了」这个假设不完全成立。rsvg-convert默认会解析 SVG 里的<foreignObject>和外部引用(<image href="http://evil.com/track.gif">),转出的 PNG 虽然不执行脚本,但转码过程本身会发起外网请求(SSRF 向量);sharp底层用libvips,同样会解析外部引用。正确做法:
# rsvg-convert:禁止外部引用(--no-xml-parser 不存在,用 --keep-aspect-ratio + 手动剥离)
# 实际上最可靠的是先用 XML 解析器剥离危险节点,再转码:
python3 -c "
import xml.etree.ElementTree as ET
tree = ET.parse('input.svg')
for el in tree.iter():
tag = el.tag.split('}')[-1] if '}' in el.tag else el.tag
if tag in ('script', 'foreignObject', 'use', 'image', 'a'):
el.getparent().remove(el) if hasattr(el, 'getparent') else None
# 实际用 lxml 的 remove()
"
rsvg-convert -f png -o output.png input.svg
// sharp:用 .rotate() 触发解码前先走 SVG 解析,但 sharp 本身不发起外网请求
// 关键:sharp 对 <foreignObject> 支持有限(会忽略),对 <image href="http://..."> 会发起请求
// 所以最安全的做法还是先 XML 清洗再 sharp 转码
const sharp = require('sharp');
const cleaned = await stripSvgDangers(svgBuffer); // 自定义:剥 script/foreignObject/image/a/use
await sharp(cleaned, { density: 144 }).png().toFile('output.png');
- MinIO bucket policy 的具体写法(挡枚举 + 挡跨租户读):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"AWS": ["arn:aws:iam::ACCOUNT:role/cdn-read"]},
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::user-uploads/avatars/{tenant_id}/*"],
"Condition": {
"StringEquals": {"s3:prefix": ["avatars/{tenant_id}/"]}
}
}
]
}
注意:{tenant_id} 是实际租户 ID,不是通配符——每个租户一个 policy 条目,或用 MinIO 的 mc policy 按子目录授权。
六、结论与可复用清单
上传侧安全 = 内容校验(不信扩展名)× 存储隔离(存了≠能执行)× 渲染上下文(谁怎么读)× 存储桶访问控制(谁能读哪个文件),四项相乘,任何一项为 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 链路,转码前先 XML 清洗(剥
script/foreignObject/image/a/use节点),防止转码过程发起外网请求(SSRF) - [ ] 对象存储 bucket policy 限定到租户子目录,presigned URL 限定到具体 key(不是前缀),防止枚举
- [ ] 审计:拉一次上传桶全量文件,
file+ 魔数双重比对「声明类型 vs 真实内容」,把历史脏数据清掉
一句话收尾:下载侧你拦的是「能不能读到」,上传侧你拦的是「读到了会不会执行」——前者是路径问题,后者是语义问题,用错了工具箱,白名单就是张纸。