说实话要是哪个网站允许用户填外部图片URL当头像,就相当于给每个访客装了窃听器
本质上图片URL就是发起一次请求,那么一个请求能做的事,这个图片URL都能做。比如:
- 内容随时被替换(恐怖图)
- 401 Basic Auth 钓鱼弹窗(现在玩不了了)
- 302 重定向到 localhost:8080/admin
- 8 秒慢响应
- 10MB 流量炸弹
- 404 直接破图
- 返回一段JS,比如给SVG 里塞脚本
先做个实验:外链头像安全实验室
添加图片注释,不超过 140 字(可选)
页面会给你一个专属的 UUID 头像链接(指向我自己的后端服务)。你可以打开 7 个攻击场景开关,亲眼看着同一个 URL 在不同场景下如何把浏览器耍得团团转
整个 demo 都基于一个朴素的事实: 是浏览器主动发出的 HTTP 请求,对面服务器返回什么都得照单全收。
风险一:访客信息泄露(至今有效)
这个手法早在电子邮件营销中用了二十年,俗称「追踪像素」。
原理极其简单:攻击者在自己的服务器上放一个图片(哪怕只是 1x1 透明像素),把这个 URL 填到某个论坛的头像栏。
添加图片注释,不超过 140 字(可选)
从此,每一个浏览到这个帖子的人,浏览器都会向攻击者的服务器发一个请求。攻击者的 Nginx 日志里清清楚楚记着:
203.0.113.45 - [13/Jun/2025 14:23:01] "GET /avatar.png HTTP/1.1" 200
Referer: https://forum.example.com/thread/12345
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_0...)
这一条日志就暴露了:
- IP 地址(精确到城市级别)
- 正在看哪个帖子(Referer 头)
- 使用什么设备(User-Agent)
- 什么时候看的(时间戳)
在我的 demo 中,默认场景下(什么开关都不开)就已经能复现这件事——下方的「访问日志表格」会实时显示你自己的 IP/UA/Referer。这条日志是后端真实收到的,浏览器没法绕过。
如果这个用户在多个帖子里发了回复(每个帖子都加载他的头像),攻击者还能画出一张「某 IP 在什么时间访问了哪些帖子」的行为轨迹图。GDPR 和中国《个人信息保护法》都将 IP 地址列为个人信息。
风险二:内容随时被替换(至今有效)
图片在别人服务器上,今天放正常头像,明天悄悄换成血腥/政治敏感/广告图——平台的用户列表瞬间被攻陷。
Demo 里开启 「替换为恐怖图片」 开关后,刷新页面,圆形头像框里立刻变成裂纹丧尸脸。
添加图片注释,不超过 140 字(可选)
这件事浏览器根本管不了,因为图片 URL 没变,返回的也确实是图片。
防御方法就只有一条:自己再存一份
风险三:Basic Auth 钓鱼弹窗(2018 后被压制)
这是最有意思的一个,因为它演示了浏览器厂商主动给开发者擦屁股的过程。
攻击者控制的服务端这样响应:
添加图片注释,不超过 140 字(可选)
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="请登录以查看头像"
浏览器收到这个响应会怎么做?理论上是弹出一个原生账号密码框。这个框长得非常像系统对话框,普通用户区分不出来「这是头像服务在要密码」还是「主站在要密码」。再配合一个「Session 过期,请重新登录」的 realm 文案,骗到一些用户的主站密码不难。
但是:Chrome 自 64 版本(2018 年初)起,Firefox / Safari 跟进,统一禁用了「跨域子资源(img / iframe / script)触发 Basic Auth 弹窗」。
所以我们安全了!但是暂时的
CVE-2017-15400 是促成这个修复的关键 issue,背后逻辑是:攻击者通过
让用户的浏览器以登录态请求任意第三方服务,弹出钓鱼框。修复后子资源被拦下,但 top-level 仍保留——因为人们确实有「打开一个需要 Basic Auth 的网站」的合法需求。
风险四:SSRF —— 服务端代下载头像的重灾区(前端拦了,服务端裸奔)
前面三个风险都是「URL 暴露给浏览器」的事。SSRF 是另一个维度:如果你的后端有一个功能是「用户提交头像 URL,服务器去下载这张图做缩略图/缓存」,那事情就严重了。
添加图片注释,不超过 140 字(可选)
这时候发出请求的不再是浏览器,而是你的服务器。攻击者提交:
http://169.254.169.254/latest/meta-data/iam/security-credentials/
这是 AWS EC2 实例元数据接口——从实例内部访问无需认证,直接返回临时凭证(AccessKeyId + SecretAccessKey)。攻击者拿到凭证就能操作你所有 AWS 资源。
2019 年 Capital One 数据泄露事件就是这个模式:攻击者通过一个 SSRF 漏洞获取了 WAF 角色的临时凭证,窃取了 1.06 亿用户的信用卡申请数据,Capital One 被罚 8000 万美元(来源:美国货币监理署 OCC Consent Order 2020-080,2020年8月)。
SSRF 的变体:
| 目标 URL | 能拿到什么 |
|---|---|
| http://192.168.1.1/admin | 路由器/交换机管理面板 |
| http://localhost:6379/ | Redis 未授权访问 |
| http://内网jenkins/script | Jenkins 远程代码执行 |
| http://169.254.169.254/... | AWS 临时凭证 |
| metadata.google.internal/... | GCP 服务账号 Token |
Demo 里的 「重定向到 localhost:8080」 开关演示的是 SSRF 的入门版本:用户给你一个看起来是 .jpg 的 URL,服务端拉过去发现是 302 跳到内网地址。如果你拉头像的代码默认 follow redirect(绝大多数 HTTP 客户端都默认开),那就直接命中内网。
风险五:资源耗尽 / DoS(至今有效)
Demo 里两个开关对应两种:
- 「故意延迟 8 秒」:永远不返回的图片把整个 feed 流卡住。
浏览器对图片没有强制超时——主线程虽不卡,但 HTTP/2 连接池或 HTTP/1.1 的 6 个并发被打满,后续真实图片只能排队。后端如果做了同步 fetch(缩略图场景),连接池耗尽,整个服务不可用。
- 「10MB 流量炸弹」:100 个用户访问就是 1GB 流量,移动端首当其冲。
进阶版本是「解压炸弹」:1KB 的 GIF 解码后是 10GB 像素数据,如果后端有自动解码逻辑,直接 OOM。
可莉:好心人,买个炸弹帮帮可莉吧。图片来源:www.youtube.com/watch?v=uY0…
一个常见的脑洞:能不能让
加载的”图片”其实是一段 JS?
很多人第一反应是:既然服务端能返回任意字节,那我返回一段 JavaScript,前端不就拿到一个 XSS 入口了吗?这条路死透了,原因有四个:
- 浏览器只接受图片字节流:拿到响应后走的是 image decoder 流水线(PNG/JPEG/WebP/SVG 解码器),不会进 JS 引擎。
- Content-Type 不能降级:哪怕你设 Content-Type: application/javascript,浏览器看到 destination 是 image,直接判定类型不匹配 → 拒绝加载。
- X-Content-Type-Options: nosniff 现在是默认行为:Chrome 自 2019 起对所有
强制启用。
- 没有 DOM 上下文:即使脚本能执行(不可能),它也拿不到 cookie / document,因为图片是孤立的 fetch,不挂在任何文档树上。
这是浏览器同源策略 + MIME 类型分离 20 年来最坚固的护城河之一。
但是如果换成 SVG,剧本就完全不一样了哦——
风险七:SVG 是披着图片外衣的脚本(换个标签就 XSS)
SVG 后缀是 .svg,MIME 是 image/svg+xml,看起来和 PNG/JPEG 一样是”图片”,但本质是 XML 文档,可以包含
Demo 里开启 「SVG 里塞脚本」 场景后,页面会出现一个对比演示区,左边用 加载(静态渲染,脚本不执行),右边点按钮用 加载——同一个 URL,同一份 SVG,立刻弹 alert + 给你的页面顶部注入一条红色横幅,亲眼看 XSS 是怎么发生的。
添加图片注释,不超过 140 字(可选)
因为本质上同一份文件,用什么标签加载,结果完全不同:
| 标签 | 浏览器解析模式 | 能执行 JS 吗 |
|---|---|---|
| image decoder(sandbox) | ❌ 脚本被禁 | |
| SVG 完整模式 | ✅ | |
| full document | ✅ | |
| 同 object | ✅ | |
| 服务端内联 SVG 到主页面 | inline | ✅ 以宿主权限 |
所以:
- 用户提交头像 → 后端拉下来发现是 SVG → 没解码重绘就直接保存
- 前端用
加载 → 看起来安全
- 但只要平台某个角落用了非
(导出 PDF / 邮件预览 / 后台审核页 / 富文本预览 / Markdown 渲染)→ 立刻 XSS
当然我也替你们尝试了,能不能读取访客的剪切板,是不行的。
剪切板那条路被现代浏览器堵了(navigator.clipboard.readText() 需要用户主动授权 + 页面 focused + transient activation),但拿到 JS 执行权后能做的事仍然不少:
- 读 document.cookie(前提是没 HttpOnly)
- 注入伪登录表单
- 用 keydown 监听键盘输入
- 偷 localStorage / sessionStorage
- 带着用户 Cookie 做 CSRF
GitHub 2017 年那次紧急修复就是这个套路:用户 README 里嵌 SVG → SVG 里塞 写完整 HTML → 完整 XSS(GitHub Security Advisories, 2017)。
正确做法:把用户 URL 当原始素材,过一道完整的清洗工序
按防护强度从弱到强:
(防 Referrer 泄露)
(不发 Cookie)
- 禁止 .svg 后缀
- CSP img-src 白名单
这些能堵住一部分,但 IP 泄露的问题永远在——只要 出去就一定带着 IP,这是 TCP/IP 决定的。
正解:服务端下载 + 校验 + 重绘 + 转存
完整工序(demo 底部有可视化流程图):
添加图片注释,不超过 140 字(可选)
- 协议白名单:只允许 https://,拒绝 http / file / data / javascript / gopher
- SSRF 防护:DNS 解析后校验 IP 不在 127/8、10/8、172.16/12、192.168/16、169.254/16(云元数据)等保留段
- 禁用重定向:服务端拉取时关闭 follow redirects,或对每一跳都重新做 IP 校验
- Content-Type + Magic Number 双校验:响应头说 image/jpeg 不够,还要校验文件头 FF D8 FF
- 大小 / 尺寸限制:超过 5MB 或 4096×4096 直接拒绝
- 解码重绘:ImageIO / sharp 解码后重新编码,剥离 EXIF、SVG 脚本、隐写 payload
- 内容审核:接黄反/暴恐/版权检测
- 转存自家 CDN:以自有域名对外服务
各大平台怎么做
| 平台 | 做法 | 是否允许外部URL |
|---|---|---|
| GitHub | 上传后存入自家 CDN (avatars.githubusercontent.com) | 否 |
| 微信 | 服务器处理后存入自家对象存储 | 否 |
| Discord | 上传后存入 CDN (cdn.discordapp.com) | 否 |
| Gravatar | 邮箱哈希映射,URL 固定格式 | 否(它本身就是头像 CDN) |
| 部分老论坛(Discuz/phpBB) | 允许填外部 URL | 是(存在上述所有风险) |
几乎所有成熟平台都选择了「转存自家 CDN」。维护成本换来的是彻底消除这一类攻击面。
最后
允许用户填外部图片 URL 当头像 = 允许用户在你的页面上嵌入一个对任意第三方服务器的请求。对查看者是隐私泄露(IP、指纹、行为追踪),对服务器是 SSRF(内网探测、云凭证窃取)。
交互式 demo → 外链头像安全实验室
添加图片注释,不超过 140 字(可选)
数据来源:
- Capital One SSRF 数据泄露罚款:US Office of the Comptroller of the Currency, Consent Order 2020-080, August 2020
- Chrome 子资源 Basic Auth 拦截:CVE-2017-15400 / Chromium bug 435547,2018 年随 Chrome 64 发布
- GitHub SVG 安全修复:GitHub Security Advisories, 2017
- OWASP SSRF Prevention Cheat Sheet: cheatsheetseries.owasp.org/cheatsheets…
- MDN Referrer-Policy: developer.mozilla.org/en-US/docs/…