Featured image of post Discovering a Zero-day in Google Chrome Enterprise Connectors

Discovering a Zero-day in Google Chrome Enterprise Connectors

Discovering a Zero-day in Google Chrome Enterprise Connectors

Bottom Line Up Front

During some security research for Counter Hack Challenges I found a Zero-day in Google Cloud. This vulnerability revolves around the challenge and challenge-response authorization from the Google Chrome Enterprise Connectors. The challenge-responses were never invalidated and could be generated across organizations. That means if a threat actor gained access to a challenge-response, it would remain valid indefinitely. A threat actor could also generate challenge-responses for your organization by only knowing the service-key name. Nonsensitive information is suddenly sensitive. This was disclosed to Google through their Google Bug Bounty program in November 2024. Google responded that they have patched the vulnerability, but without access to Google Enterprise, I can not verify that all vulnerabilities have been mitigated. This system is not intended for multi-factor authentication(MFA), but if you previously did use it as such, it is visible in network logs but not Google Cloud logs. Search for multiple similar X-Verified-Access-Challenge-Response requests to your server. Due to errors and missing documentation in the v1 version of the API, I could not verify the vulnerabilities there.

Discovery

During my work with Counter Hack Challenges, we stumbled upon some Identity Provider (IdP) research. Part of the research had a Google Enterprise connector implemented as a method of authorization. The connectors main purpose is to ensure that the authenticating computer is authenticating through an enrolled browser or device. When we had an enrolled device we could generate challenge-responses when challenged by submitting the challenge-response to the endpoint https://verifiedaccess.googleapis.com/v2/challenge, but our research uncovered that the challenge-responses were never invalidated. Google documentation stated that the challenge-responses were only valid for a minute, but the challenge-responses were never invalidated. This sparked further investigation into what was going on.

Concept

The Google Chrome Enterprise Connectors are a way to ensure that the device is managed by an organization. It is not supposed to be a MFA system but as it is a way to ensure that the device is managed by an organization. However it is not unthinkable that someone would use it as a MFA method since it should ensure that only enrolled devices are able to access the system. To get a managed browser or device, you need to enroll the device in the Google Enterprise program. This involves having an enrollment key and allowing the attached company to manage the device by setting up policies. An example of an authentication flow is shown in image 1. A user supplies their username and password from a managed device to the company server, then the server accepts the request and redirects the user to a page defined within the Google Enterprise Connector settings. The managed device recognizes the URL as an authorized page and initiates the connection with a special header for proof of device trust. The server triggers a request to the Google API to generate a challenge, and forwards the challenge to the managed device. The managed device generates a challenge-response and sends it back to the server. The server then verifies the challenge-response with the Google API and asserts the user is authenticated.

Image 1. Google Enterprise Connector Flow

Before diving into the vulnerabilities, I will explain how the lab was set up. If you do not want to know how the lab is set up, skip to the The vulnerabilities section.

Lab setup

To control the circumstances, I had to set up my own infrastructure, replicate and isolate as much as possible. I signed up for a trial period for Google Enterprise, that grants you 300 USD in Google cloud credits and 7 days of Enterprise access. The requirement for the trial is your own domain, hence the haphazard registration of capturethetgc.win.

After registering your domain in the enterprise program, you need to set up the cloud environment. In Google Cloud, set up a service account with a reasonable amount of access. Then go over to Google Cloud Enterprise, and edit the Chrome Enterprise connectors settings. Set the access to enable users to use enterprise connectors as shown in image 2.

Image 2. Google Enterprise Connectors Configuration

Then go over to the devices page, navigate to Chrome (or Chromebook if you want to), and connectors. Set up a new connector by clicking on NEW PROVIDER CONFIGURATION. This is shown in image 3.

Image 3. Google Enterprise Connectors Creation

It really does not matter what provider you choose. I tested them all and it made no difference for this vulnerability. Enter your service account that will be authorized to issue and verify the challenge and challenge-responses. In addition, you need to set the URL, which will add to the scope for challenge-responses. This is important for later, there is an interesting error here. In image 4 I set up an Okta connector.

Image 4. Google Enterprise Okta Connector

After setting up your connector, generate an Enrollment Token that will be used to enroll the browser or Chromebook. I set up a Windows 10 VM to use as a managed device in order to isolate whatever happens with the machine and roll back if needed. On the Windows machine, I enrolled Google Chrome using Googles own guide. The Google managed browser is shown in image 5.

Image 5. Google Chrome Managed Browser

The managed browser or device will respond to the challenges and generate a challenge-response. I did not have the time to go in depth about how the challenge-responses are generated, but that can be for future work. With a device to respond to challenges and the connector configuration set up, we need to request and validate challenges. I could not find any good documentation on how to set this up in the time frame given, therefore I opted to create this myself and mold it as a normal CTF challenge structure. I created two Python scripts, pasted below in the Code section. In short, one script is responsible for requesting challenges from https://verifiedaccess.googleapis.com/v2/challenge:generate, and the other script verifies the challengeResponse at https://verifiedaccess.googleapis.com/v2/challenge:verify. I will be explaining the Python scripts in the next section.

The vulnerabilities

Before describing the vulnerability I will briefly explain the requirements for the Google Enterprise challenge flow. The three components are:

  • A backend that can request and submit challenges to Google APIs
  • A managed browser or device that will respond to challenges
  • A connector that will respond to our URL

When setting up the connector, we had to set the URL we wanted the device to verify its access against. When the browser visits the URL, it automatically adds the header X-Device-Trust. The device trusts this URL and is now requesting a challenge. The backend written in Flask, presented in the Code section reacts to all requests with that header by requesting a challenge with a Google API Token.

With all of this setup, I start up the server and request the page from the managed browser. The browser recognizes the URL as an authorized page and initiates the connection with the X-Device-Trust header. The Python app then requests a challenge from googleapis.com and forwards it to the managed browser through an X-Verified-Access-Challenge header. The browser generates the response and replies with the challenge-responses in an X-Verified-Access-Challenge-Response header. The flask application will verify the challenge-response and return the JSON blob from the Google API verification. The response from verifying a challenge-response at Google API is presented in image 6.

Image 6. Returned information from a verified Challenge-response

Vulnerability 1 - The never ending token

The obvious vulnerability that I was exploiting was the fact that the challenge-response was valid for more than one minute as stated in the documentation. The challenge-response was valid for days and was seemingly never invalidated, leading to us reusing the same challenge-response over the span of 56 hours. When checking the output from verifying a challenge-response at Google APIs, I could not find any timestamp stating when the associated challenge was requested.

Vulnerability 2 - Your name is your key

The last vulnerability is something we noticed during the setup of the connector. When setting up the connector, you need to set the URL that the device will trust and a service account that will validate the challenges. We attempted to add a service account of a different enterprise and generate challenges for that enterprise. Our backend could not validate challenge-response for that enterprise domain but could generate valid challenges for that domain. This means that the challenge-response can be generated across organizations and cross domains, without access to the service account credentials. The service account name is not sensitive information, with this vulnerability, it suddenly becomes the key to generate challenges for an organization. The attacker can not validate the challenge-response in their own environment as they do not have access to the service account credentials. This means that if a threat actor gains access to a service account name, they can generate challenge-responses for that organization indefinitely. The conceptual flow of this vulnerability is shown in image 7.

Image 7. Conceptual Flow Of Service Account Name Abuse

Vulnerability 3 - Scrambled Challenges? We do not care

There is an observable pattern in how the challenge-response is built. The challenge always starts with the string CkEKFkVudGVycHJpc2VLZXlDaGFsbGVuZ2USI, that roughly base64 decodes to EnterpriseKeyChallenge with some extra bytes. After the initial string in the challenge, the actual challenge is passed in the base64 encoded string.

The challenge-response is built in a similar way; the initial string is Cq8PCsYC followed by the entire initial challenge string in base64 encoded format; giving it the following format; Cq8PCsYC + base64(Challenge) + base64(ChallengeResponse). The byte offset for the challenge is 8 bytes, and the offset for the challenge-response is 443 bytes. It turns out the first 91 characters of the challenge need to originate from a valid challenge, but the rest of the challenge is not validated. The deconstructed challenge-response is shown in image 8.

Image 8. Deconstructed Challenge-response

Image 9 shows a different challenge injected in challenge-response from image 8. Image 10 shows a mangled challenge in the challenge-response from image 8. Both challenge-responses are verified as valid challenge-responses by the Google API.

Image 9. Injected Challenge in Challenge-response

Image 10. Mangled Challenge in Challenge-response

This vulnerability is not the worst, it just allows an attacker to mangle challenge-responses, and avoid some detection. But this just shows that the challenge-response is not nested in a challenge. The conceptual flow of this vulnerability is shown in image 11.

Image 11. Conceptual Flow of An Injected Challenge or Mangled Challenge

Bonus Vulnerability 4 - Token replay attacks

As Vulnerability 1 states challenge-responses are never invalidated, and my mock backend also included a logic flaw. The mock backend responds to requests based on the header, meaning it is not nesting challenge requests and responses. This vulnerability is not directly associated with Google APIs as the API only responds if it is a valid challenge-response. The vulnerability is affecting systems designed to blindly react to requests in a similar fashion. An attacker with a valid response could issue the X-Verified-Access-Challenge-Response as the initial request and verify their access without issuing a challenge to the attacker. This can be classified as a token replay attack. Furthermore, if the attacker initiates an authentication flow with the X-Device-Trust header, my mock backend would not have nested challenge and challenge-responses. Meaning if you request a challenge and respond with a different challenge-response, the backend would just ignore it and verify the challenge-response. If you use Google Managed Devices as a form of authentication, I recommend you check your own server for this vulnerability. A concept of the backend logic flaw is shown in image 12.

Image 12. Backend Logic Flaw Concept

Disclosure

These vulnerabilities were disclosed to Google through their Google Bug Bounty program in November 2024. They have verified the vulnerabilities and responded that they have patched the vulnerabilities. No bug bounty was awarded for the vulnerabilities, but I was added to Google’s Honorable Mentions list. Since the vulnerabilities were found in a trial period, I am unable to verify whether the vulnerabilities have been mitigated. I have not been able to verify the vulnerabilities in the v1 version of the API due to errors and missing documentation but urged Google to look into it as well.

Final Thoughts

This was a fun research project that I did not expect to happen. The vulnerabilities exploit logic flaws and showcase the importance of session management and challenge-response invalidation. The vulnerabilities only affect Google Enterprise customers and are not accessible to the general public as the price to enter the program is quite high. I spent 300 dollars of Google Credits within 3 days just on challenge validation, which is the entire budget for the trial period. Setting up an isolated lab environment is crucial as it uncovered other vulnerabilities that I could not have found otherwise.

All of this was discovered accidentally and sparked an impromptu research session over the weekend. The research into Google Enterprise Connectors was a bit rushed and I did not have time to fully understand the API as I only had three days to finish the research.

Probably the most important lesson learned is to not compromise your Google API key in a bug bounty report. That would be awkward if anyone did that… 🤦🏽‍♂️

Code

Web Application and Verify Script Dependencies

1
pip3 install Flask request jsonify subprocess google-api-python-client google-auth

In order to use the script, add a service key to a local file acc.json and run Flask with the command python3 app.py.

Python Web Application Frontend

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from flask import Flask, request, jsonify
import subprocess
from verify import verify_signed_data

app = Flask(__name__)

@app.route('/')
def index():
    response = None
    if 'X-Device-Trust' in request.headers:
        response = jsonify(message="Access Verified")
        curl_command = ["curl","-X","POST", "https://verifiedaccess.googleapis.com/v2/challenge:generate?key=GOOGLE-SERVICE-KEY"]
        result = subprocess.run(curl_command, capture_output=True, text=True)
        response_content = result.stdout.replace('\n', '').replace('\r', '').strip()
        print(response_content)
        response.headers['X-Verified-Access-Challenge'] = response_content
        response.headers['Location'] = '/'
        response.status_code = 302
    elif 'X-Verified-Access-Challenge-Response' in request.headers:
        challenge_response = request.headers.get('X-Verified-Access-Challenge-Response')
        response = verify_signed_data(challenge_response)
        print(response)
        
    else:
        response = jsonify(message="Access Denied")
        response.status_code = 403
    
    
    return response

if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0', port=8000)

Python Verification Script verify.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import json
import requests
from google.oauth2 import service_account
import google.auth.transport.requests


def verify_signed_data(response_org):
    # Load your service account credentials from a JSON file
    service_account_file = 'acc.json'

    # Define the required scope for the Verified Access API
    SCOPES = ['https://www.googleapis.com/auth/verifiedaccess']

    # Proxy everything through BurpSuite
    proxies = {
        'http': 'http://127.0.0.1:8080',
        'https': 'http://127.0.0.1:8080'
        }

    # Load the service account credentials
    credentials = service_account.Credentials.from_service_account_file(
        service_account_file,
        scopes=SCOPES
    )
    session = requests.Session()
    session.proxies.update(proxies)
    session.verify = False
    # Create a request object
    auth_request = google.auth.transport.requests.Request()

    # Manually create a JWT assertion and exchange it for an access token
    token_uri = 'https://oauth2.googleapis.com/token'
    assertion = credentials._make_authorization_grant_assertion()
    headers = {'Content-Type': 'application/x-www-form-urlencoded'}
    data = {
        'assertion': assertion,
        'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer'
    }

    # Send the request to Google's token endpoint
    response = requests.post(token_uri, headers=headers, data=data, proxies=proxies, verify=False)

    # Parse the response
    if response.status_code == 200:
        token_response = response.json()
        access_token = token_response.get('access_token')
        print("Access Token:", access_token)
    else:
        print("Failed to obtain access token:", response.status_code, response.text)
        exit(1)

    # Use the access token to make the Verified Access API request
    if access_token:
        
        session.headers.update({'Authorization': f'Bearer {access_token}'})
        

        # Make the Verified Access API call
        api_url = 'https://verifiedaccess.googleapis.com/v2/challenge:verify'
        request_body = response_org
        response = session.post(api_url, data=request_body)

        # Print the API response
        if response.status_code == 200:
            print("API Response:", response.json())
            res = response.json()
        else:
            print("API Request Failed:", response.status_code, response.text)
            res = response.text
    else:
        print("No access token retrieved.")
    return res
Built with Hugo
Theme Stack designed by Jimmy