比伪造更可怕:一封 Robinhood 亲自签名的钓鱼邮件
千里之堤,溃于蚁穴。—— 《韩非子·喻老》
题记:昨天傍晚,我收件箱里躺着一封 Robinhood 的“登录确认”邮件。我盯着它看了很久,在放下所有心房后,点开了 Review Activity Now 的链接。当链接显示不是 robinhood 的网站后,我意识到情况不对,然后把邮件原文下载下来,让 Claude Code 帮我探索。

初见:真得让人卸下心防
从任何一个角度来看:这封邮件不是伪造的。
它的每一个字节都从 Robinhood 自家的事务性邮件管道流出来,由 Robinhood 上传到 SendGrid 的私钥签名,从 Robinhood 授权的 SendGrid 出口 IP 投递给 Gmail。SPF 通过,DKIM 通过,DMARC 对齐通过,ARC 完整继承通过:四道认证全部绿灯。
发件人是 noreply@robinhood.com,签名 selector 是 s1._domainkey.robinhood.com,Return-Path 是 bounces+1739348-...@sg-email.robinhood.com,出口 IP 是 SendGrid 的 167.89.11.250。任何一项你想质疑,都会撞回 Robinhood 自己手里。
视觉上更狠:MJML 模板、品牌图、CDN 资源、页脚法律链接,长得跟 Robinhood 平时的合规通知一模一样,因为它本来就是从那个模板里出来的。
那它怎么钓我?整封邮件中间藏了一张血红色的卡片,写着 “UNRECOGNIZED ACTIVITY · Case #RH-2536 · 你的手机号被改成了 ••87 ·〔立即审核〕”。按钮指向一个 googletagmanager.com 的链接。
你的反应可能跟我一样:等等,这怎么可能?哪一项是假的?
往下我会一层层拆开来。但请你先记住这个时刻:那些“先看 From、再 hover URL、再看认证状态”的肌肉记忆,在这封邮件面前全部失效。
既有的防线,为何悉数失守
业界花数十年盖起来的反钓鱼基础设施,骨架就是 SPF、DKIM、DMARC 三件套。但它们的设计目标其实是“挡住伪造发件人”,不是“挡住所有钓鱼”。这两件事不是一回事。
SPF:为信封盖上出生印
Received: from o1.email.robinhood.com (167.89.11.250)
Return-Path: <bounces+1739348-f13e-xxxx=gmail.com@sg-email.robinhood.com>
spf=pass
退信域是 sg-email.robinhood.com,也就是 Robinhood 在 SendGrid 上的专用子域。167.89.11.0/24 属于 SendGrid,被这个子域的 SPF 记录授权为合法发件源。SPF 校验的是信封发件人(envelope from),不是用户能看到的 From:,这是 SendGrid 给企业客户的标准合规配置。
DKIM:Robinhood 亲手按下的私章
DKIM-Signature: v=1; d=robinhood.com; s=s1; b=r2O3Vjwp...
DKIM-Signature: v=1; d=sendgrid.info; s=smtpapi; b=p6FRnY7Z...
第一条是关键:d=robinhood.com s=s1。邮件正文与头部用的是 Robinhood 上传到自己 SendGrid subuser 的生产私钥。这把钥匙只有 Robinhood 的合法生产链路握有,攻击者拿不到,就算泄漏也会立刻被发现。
DMARC:对得上号,便一路放行
header.from = robinhood.com
DKIM d=robinhood.com (s1) → PASS → 与 From 域对齐
dmarc=pass (p=QUARANTINE, dis=NONE)
DMARC 的逻辑很简单:SPF 或 DKIM 至少有一个跟可见 From: 域对齐,就算通过。DKIM 已对齐,处置就是 NONE,不隔离。
当前的 DMARC 疑似伪造的应该进垃圾箱(p=QUARANTINE),否则 pass。但这封邮件不是伪造,所以这条策略压根没机会触发。
ARC:信任沿转发链一路传承
Gmail 的 ARC-Authentication-Results 把上面三项 PASS 全部继承下来。任何尊重 ARC 的下游系统(自动转发、邮件列表、企业 SEG)都会继续信任这封邮件。
单看邮件头,没有一丝危险信号。基于发件人声誉的过滤器甚至会 提高 对这封邮件的信任度。Feedback-ID 里写着 REGULATORY_REQUIRED:postoffice,表明它走的是合规通知通道,跟 Robinhood 平时大量的合法事务性邮件流量完全一致。
真正的破口:一个 User-Agent 撕开整张模板
那攻击是怎么发生的?答案不在邮件头,在邮件正文里 Device: 这一行。
Robinhood 的登录确认邮件是个老模板,结构很简单:
Hi <name>,
We're emailing to confirm your recent login to Robinhood.
• Time: <时间>
• Device: <user_agent>
• IP address: <ip>
• Approximate location: <城市, 州>
Device: 那行正常应该是 iPhone 17 Pro 或 Chrome on macOS 这种规规矩矩的字符串。但这封邮件里,源码第 302 行长这样:
<li>Device: iPhone 17 Pro<!doctype html><html lang="en"><head>
<style>...</style></head><body><div class="wrap">
<table style="border:2px solid #cc0000">
UNRECOGNIZED ACTIVITY Case #RH-2536
Unrecognized Activity Detected on Your Account
...
PHONE NUMBER UPDATE: Ending in ••87
STATUS: Pending your review – no changes applied yet
[ Review Activity Now → ] ← 这个按钮就是钓鱼链接
</body></html> </li>
<li>IP address: 70.44.21.22</li>
<li>Approximate location: Hawley, PA</li>
看到了吗?Device: 字段的值是 iPhone 17 Pro 后面跟着一整份完整的 HTML 文档。这份文档被原封不动地拼到了合法模板的某个 <li> 元素里。</html> 之后,原始模板继续跑:</li><li>IP address: 70.44.21.22</li>。整封邮件依然是一封“完整邮件”,模板的后处理也没发现异常。
为什么有效?因为绝大多数邮件客户端(Gmail 网页版、iOS Gmail App、Outlook)会直接渲染嵌套 DOM,不会因为“一封邮件里有两个 <!doctype html>“就拒绝。HTML 的容错语法在这里成了攻击者的免费助力。
视觉上,那张红色 “UNRECOGNIZED ACTIVITY” 卡片占满整个屏幕,加粗、压迫、倒计时式的措辞;而真正的字段(IP address: 70.44.21.22、Hawley, PA)只剩注入卡片下方两行小灰字。用户的注意力被完全吸到那张卡片上,根本不会去看下面那两行才是真实的登录元数据。
注入卡片里的 “Case #RH-2536”、“手机号末位 ••87”、“Chrome — Windows”,全是攻击者自定的字符串,模仿的是 Robinhood 安全控制台的真实 UI 用语。
这段 HTML,是怎么爬进 Device: 字段的
模板里的 Device: 是从认证事件里记录的 User-Agent / 设备显示名取的。两条合理路径。
一种是攻击者已经掌握了受害者的凭据(撞库、买信息窃取器日志、之前钓过、密码复用),用一个自定义 User-Agent 头登录 Robinhood,把整段 HTML 作为 UA 字符串发出去。Robinhood 的认证系统老老实实把这个字符串记成“设备描述”,传给负责发邮件的 postoffice 服务,模板把它拼进登录确认邮件,最后发回给真正的账号主人。
另一种是攻击者通过某个“注册新设备 / 重命名设备”的流程,把恶意字符串落入这个字段。
无论哪条路径,前提都是:攻击者必须先制造一次自己能控制内容的认证事件。然后钓鱼载荷就搭着 Robinhood 自家的出口邮件管道回到受害者邮箱,DKIM 与 DMARC 全部正确对齐,因为这就是合法链路。
这就是事务邮件版的 Stored XSS
Web 安全里有个老问题叫 Stored XSS:用户输入被持久化到数据库,每次渲染时未经转义地拼进 HTML,最终在受害者浏览器里执行。
postoffice 模板渲染 Device: 字段时,是把 user-agent 值直接字符串拼接进 MJML / HTML 的。<!doctype html> 没被吞掉这件事本身就证明:该字段在进入模板时没有 HTML 实体转义,连最起码的“剥离 <“都没有。
正确做法本应当是这三条里至少做一条:拼接前把 < > & " ' 做实体转义;或者用一个把值视作纯文本节点的模板函数(Jinja autoescape、React {value} 这种);再或者在捕获阶段就拒绝任何含 < 的 User-Agent。三个都没做。
注入物被持久化到 Robinhood 的认证 / 设备库里,按需渲染到出站邮件 HTML 里,这就是事务性邮件版的 Stored XSS。它跟 Web XSS 的区别只在于:渲染发生在 Gmail 的邮件客户端而不是浏览器里。所以“邮件客户端禁用 JS”这条防线救不了你:视觉欺骗、误导链接、外链图片,这些根本不需要 JS。
URL 的障眼法:借 Google 的招牌一路通关
整封邮件里只有一条钓鱼链接:
https://www.googletagmanager.com/debug/clearcookies?url=
https%3A%2F%2Ftinzio.net%2Fvalidate%3Ftoken%3D
KgyRKOGMvG42WYK4eX_lS627ZCWgkBp7QX-CYiaU92ZBpVKl8Srdgv5fo9a5_HTakiuu
URL 解码后,真实落地页是 tinzio.net/validate?token=...。
那 googletagmanager.com/debug/clearcookies?url=... 是干嘛的?这是 Google Tag Manager 的一个调试帮助端点,会在客户端把页面跳到 url= 参数指定的地址。说白了,就是一个挂在 Google 自有域名上的开放重定向。
为什么要绕这一道?因为 googletagmanager.com 是全球被普遍放行的高信任域。URL 信誉引擎(Google Safe Browsing、SmartScreen、Talos、VirusTotal、企业 SEG)一看到这个域名就放行,几乎不会再去深究 query string。Gmail 的链接预览(hover 或长按弹出的小卡片)显示的也是 googletagmanager.com,一个“先 hover 再点击”的老练用户看到 Google 域名,自然就放下戒心。企业链接重写器(Safe Links、Mimecast)对信任域不剥离 query 参数,redirect target 完整保留。URL 沙箱如果只跟一跳,看到 Google 服务器返回 200 就停了。
至于真正的钓鱼站 tinzio.net,/validate?token=... 这种路径加上一长串不透明 token,是在模仿 Robinhood 自己的一次性链接(密码重置、magic link 之类)。落地页基本可以预判:仿 Robinhood 皮肤的页面,输入邮箱密码,再输 TOTP / 短信验证码,验证码被实时转发给攻击者侧的活动会话。这就是 AiTM (Adversary-in-the-Middle) 钓鱼套件,已经商品化好几年,能同时击穿短信和 TOTP。
而邮件中其他所有链接都指向真正的 Robinhood:robinhood.com/contact、robinhood.com/support/、官方社交账号。所有图片都从 cdn.robinhood.com 与 images.ctfassets.net(Contentful,Robinhood 真实的 CMS)加载。基于内容的过滤器没有任何可标记的:整段 DOM 里只有这一个恶意锚点,而且套着 Google 信任域。
十几层防御,为何全部哑火
把 Gmail 的钓鱼检测层逐一套到这封邮件上:
| 层级 | 检查内容 | 失灵原因 |
|---|---|---|
| SPF | 信封发件 IP 授权 | 通过,出口 IP 是 Robinhood 授权的 SendGrid IP |
| DKIM | 正文 / 头部加密签名 | robinhood.com 与 sendgrid.info 都通过,正文是 Robinhood 生产私钥签的 |
| DMARC | 与 From: 对齐 + 策略 |
DKIM 对齐 → 通过;p=QUARANTINE 只在伪造时才生效 |
| 发件人声誉 | noreply@robinhood.com 历史信誉 |
极高,背后是数以百万计的合法事务性邮件 |
| 品牌仿冒检测 | 品牌内容与发件域不一致 | 没有不一致,品牌就是 Robinhood,邮件就是 Robinhood 发的 |
| 相似域检测 | punycode / typo-squat | 没有,From: 一字不差,链接域要么 robinhood.com 要么 googletagmanager.com |
| URL 信誉 | 主机名与黑名单比对 | 公开主机名只有 googletagmanager.com,处处放行 |
| 附件 / 宏扫描 | 不适用 | 无附件 |
| NLP 内容分类 | “verify your account” 等措辞 | 这些词本来就出现在合法 Robinhood 安全邮件里 |
| ARC | 转发链完整性 | 通过,任意转发都继续信任 |
唯一可能拦下这封邮件的,是一个能识别“一份 HTML 里嵌套了第二份 <!doctype html>“的结构异常检测器。但主流邮件提供商默认不开,因为良莠不齐的营销邮件平台经常生成嵌套片段,误报代价过高。
让我后背发凉的就是这一点:不是某一层防御出了 bug,而是十几层防御从设计上就不适用于这种攻击。它们全是为“伪造”准备的,可这封邮件不是伪造的。
不是一封邮件,是一类正在成型的攻击
这种套路这两年越来越常见,总共有三个要素。
一是攻击载荷搭载在合法品牌的事务性邮件管道上。银行、券商、SaaS 厂商的登录通知、订单确认、密码重置、收据邮件,任何会把“用户输入”渲染进 HTML 模板的事务性邮件,都是潜在注入点。
二是注入点都长在那些“看起来是结构化数据”的字段上。User-Agent、设备名、订单备注、收件人姓名、账户昵称、受益人地址、工单标题,这些字段在产品经理眼里是“显示用的字符串”,在安全工程师眼里是 Stored XSS sink,但很多公司中间这道墙根本没人砌。
三是外发链接通过开放重定向洗白。除了 GTM,还有 Google AMP cache、accounts.google.com/ServiceLogin?continue=、各种营销追踪平台、URL 短链服务、SaaS 厂商的 deep-link 端点。任何一个高信任域上的开放重定向,都是流通的“信任货币”。
把这三要素拼起来,就是当下最有效的钓鱼路径:合法发件域 + 合法发件管道注入 + 信任域开放重定向 = 几乎无法在传输层被拦下来的钓鱼邮件。
写给开发者:几道必须补上的闸门
如果你正好在做事务性邮件,下面几件事请赶紧 review。
首先是任何最终会被放进出站 HTML 模板的用户输入字符串,都是 Stored XSS sink。即便最终的渲染发生在 Gmail / Outlook 而不是你自家的 Web 应用,也要按相同标准对待。“反正不在浏览器执行”不是借口,视觉欺骗、误导链接、外链图片这些根本不需要 JS。
具体怎么做?同时挂上两道闸门:进库前,对显示名、地址、工单标题、账户昵称、User-Agent、设备名这一类字段做白名单 / 长度限制 / 字符过滤;出库进模板时,再走一遍 context-aware 的实体编码。出口 HTML 再加一道结构 lint,凡是渲染后含一次以上 <!doctype、两个 <html> 开标签、或 </html> 之后还有内容的,一律拒绝、报警。这两道闸门成本极低,能挡住绝大部分注入型 payload。
再就是 DMARC 策略。Robinhood 当前还停在 p=QUARANTINE,对于一个金融品牌来说太宽松了。确认所有合法发件源都已对齐之后,就该收紧到 p=reject。
还有审计的范围。postoffice 一个模板出问题,意味着同一个团队、同一套渲染逻辑维护的所有模板都可能有同样的问题。审计是一次性投资,但能堵住一片。
写给普通人:几条胜过任何培训的铁律
不写代码的同学,下面几条比任何反钓鱼培训都管用。
永远不要从邮件里的“审核 / 同意 / 拒绝”按钮采取行动,哪怕邮件认证显示来自你的银行或券商。请打开 App,或者自己手动输入网址进。光这一条做到,今天大半的钓鱼对你失效。
有疑问就 hover 或长按链接,看完整 URL(含 query string)。来自券商的邮件里出现 googletagmanager.com/debug/clearcookies?url=...、accounts.google.com/ServiceLogin?continue=...,或任何 ?url= ?redirect= ?continue= 后面跟着一个跨域目标的,毫无疑问是恶意的。
任何“7 天内 / 一旦同意无法撤销 / 立即 / 紧急 / 还剩 X 小时”之类的措辞都是胁迫框架。真正的安全通知很少使用倒计时式的紧迫感。
最后,凭据保持唯一,启用绑定硬件的二次验证。Passkey / FIDO2 / 硬件 token,短信和 TOTP 已经被 AiTM 套件批量击穿,只有 origin-bound 的凭据才能挡住实时中继。这是 2026 年的安全基线,不再是可选项。
结语:信任,才是这条管道里最贵的零件
数十年来反钓鱼工程的主线是“识破伪造”。SPF、DKIM、DMARC、域名声誉、品牌相似度,所有这些机制建立在同一个假设上:坏人会冒充好人。
这封 Robinhood 邮件让我意识到一件事:最高级的攻击不是冒充,而是借用。 攻击者不需要伪造任何东西,他们让 Robinhood 替他们发了这封钓鱼邮件,让 Google 替他们洗白了那条链接,让 Gmail 替他们盖上了“已认证”的章。
这跟前一阵发生在 litellm/axios 上的 supply-chain 攻击的逻辑是一样的:你不需要攻破目标的防火墙,你只需要污染它已经信任的某个上游。区别在于,这一次“上游”不是某个第三方包,而是品牌自己的事务性邮件管道,那条号称“系统内部”、号称“合规通道”、号称“用户无法退订”的管道。
韩非子那句“千里之堤,溃于蚁穴”,在这里有个工程层面的精确翻译:Device: 字段没做 HTML 转义。一行可能两年前就该写的 htmlEscape() 调用,是这座千里之堤的那个蚁穴。它通过 Robinhood 的合规邮件管道,连接到 Google Tag Manager 的开放重定向,再连接到一个用户全家的银行账户。
那封邮件还躺在我的归档目录里,我打算留着。 Device: 那一行的注入,会比任何培训记得都牢。