Analyse Google Cloud logs to determine the malicious actions of an insider.
A service account in a crypto startup’s Google Cloud project authenticated from a single foreign IP, using a Mac and the gsutil client. Its first move was an API call that was denied, an attempt to enable a service it had no rights over. It then switched to storage, listed the contents of one bucket, and downloaded a single object from it. The whole intrusion sits in one Cloud Audit Log export, and the identity, source address and user agent stay constant from the first event to the last.
evidence/gcp.json → Cloud Audit Log export, read with grep and Python. Each entry carries methodName, status, authenticationInfo, requestMetadata (caller IP and user agent) and resourceNameFormat note: The answer masks are strict. Each
*is one character, so a mask gives the exact length of the answer. The API call in Q6 is a fully qualified method name, not a short label.
Read the first entries of the export. Every record is tagged with its project in resource.labels.project_id, and the file holds only one value.
head -c 3000 evidence/gcp.json
The acting identity is in authenticationInfo.principalEmail. One principal appears on every event, and it sits in the project’s iam.gserviceaccount.com domain, so a service account credential was leaked or abused rather than a human login.
grep -o '"principalEmail": *"[^"]*"' evidence/gcp.json | sort | uniq -c
From Q2 we know the identity. Count requestMetadata.callerIp across its events. Every call comes from the same external address.
grep -o '"callerIp": *"[^"]*"' evidence/gcp.json | sort | uniq -c
Cloud Audit Logs carry no geolocation, so the address from Q3 has to go to an IP intelligence service. ipinfo.io returns the country code RO for it, which is Romania.
curl -s https://ipinfo.io/178.132.108.38/json
Gotcha: The mask fits many countries, and guessing from the look of the address is a trap. Look it up.
The only device hint in Cloud Audit Logs is requestMetadata.callerSuppliedUserAgent. Here it is a gsutil string whose platform token is darwin, which is macOS. That points to a Mac, and the nine-character mask fits Macintosh. This answer was reasoned from the darwin token and the mask rather than read as a device name in the log.
grep -o '"callerSuppliedUserAgent": *"[^"]*"' evidence/gcp.json | sort | uniq -c
Tip: The user agent never says “Mac” outright. Map
darwinto macOS, then fit the answer to the mask.
Sort the events by timestamp and print method, status, caller IP, user agent and resource for each. The first event is a Service Usage call that returns status code 7 (permission denied). The service account tried to enable an API on the project and was refused, then fell back to what it could already reach.
python3 -I sort_events.py evidence/gcp.json
A quicker check without sorting is to list methods next to status codes and read the top of the file.
grep -oE '"methodName": *"[^"]*"|"code": *[0-9]+' evidence/gcp.json | head -20
After the denied call the identity issues storage.objects.list. The resourceName on that event names the bucket being listed.
grep -B2 -A12 'storage.objects.list' evidence/gcp.json | grep -o '"resourceName": *"[^"]*"'
The listing from Q7 is followed by a read of one object. In storage audit logs a download is storage.objects.get, not list, so that is the call that moved data out of the bucket. Counting the storage methods shows it appears once.
grep -o '"methodName": *"storage[^"]*"' evidence/gcp.json | sort | uniq -c
The user agent on the storage.objects.get event from Q8 identifies the client. It is a gsutil string, consistent with a gsutil cp download, and carries the same darwin token used in Q5.
grep -A20 'storage.objects.get' evidence/gcp.json | grep -o '"callerSuppliedUserAgent": *"[^"]*"'
The resourceName on the storage.objects.get event is the full object path inside the bucket from Q7. The last path segment is the file name.
grep -A20 'storage.objects.get' evidence/gcp.json | grep -o '"resourceName": *"[^"]*"'
| Phase | Action |
|---|---|
| Initial Access | Service account cloud-storage-helper authenticates from a Romanian IP using gsutil on a Mac |
| Discovery | First call, ServiceUsage.EnableService, is denied |
| Discovery | storage.objects.list enumerates importantbucket |
| Collection | storage.objects.get via gsutil downloads secretcode.java |
| Type | Value |
|---|---|
| IP | 178.132.108[.]38 |
| Service account | [email protected] |
| Project | cryptostartup |
| Bucket | importantbucket |
| File | secretcode.java |
| User agent | gsutil on darwin |
| Technique | ID | Description |
|---|---|---|
| Valid Accounts: Cloud Accounts | T1078.004 | Service account credential used from an external IP |
| Cloud Storage Object Discovery | T1619 | storage.objects.list against importantbucket |
| Data from Cloud Storage | T1530 | storage.objects.get through gsutil pulled secretcode.java |
A service account used from a foreign IP is an alert. Workload identities normally call from Google ranges or a known egress. A callerIp outside those, paired with a desktop gsutil user agent, shows the key left the environment. Alert on service-account calls whose source is not on an allowlist.
Denied calls are an early signal. The first event was a permission-denied EnableService. A PERMISSION_DENIED from a principal that normally only touches storage is reconnaissance, and it arrives before any data moves.
Turn on Data Access audit logs for Cloud Storage. The list and get events in this case exist only because data access logging was on. Without it, the bucket listing and the download would be invisible.
Prefer short-lived credentials over exported service account keys. cloud-storage-helper was usable from a laptop, which means a copyable credential existed. Workload identity or impersonation removes that key, and a role scoped to specific buckets limits what a stolen one can read.
One service account credential was enough for an outsider to list a bucket and copy a source file, and nothing stopped a Mac in Romania from acting as a trusted workload. The logs recorded every step; the gap was that identity and source address were never compared.