The e-commerce startup “DummyExample” experienced a data breach of sensitive user data hosted on Google Cloud. Investigate who is behind the breach and how it happened.
An internal Windows workstation logged into a self-hosted Gitea server running an old, vulnerable release and edited a repository’s post-receive git hook through the web UI. The hook ran a bash reverse shell to an ngrok tunnel, giving the actor a shell on the Gitea instance. From there the attached default Compute Engine service account was used against Cloud Storage and OS Login, and a dedicated storage service account read the customer-data workbook. A request to make the bucket public with allUsers was denied, so the actor encrypted the file with a repeating-key XOR and served it over HTTP on the RDP port, which the default VPC rules already allowed. The audit log and the packet capture together show every step.
GCloud_Logs.json → Cloud Audit Log export. Read with Python, grouping by principalEmail, methodName and statusVPCNetwork_Traffic.pcap → packet mirror capture. Open with tshark or WiresharkFormat note: Q4 expects
IP:insiderorIP:outsider, Q6 expects the machine names comma-separated in alphabetical order, and Q12 expectsSSN:CardNumber.
Start with conversation statistics on the capture to find the internal hosts. One private address carries Edge and Google update traffic (gvt1), takes an RDP session from an external address, and browses the Gitea web UI as the user James. That is the Windows workstation; the Linux and mirror instances do not browse the web.
tshark -r VPCNetwork_Traffic.pcap -q -z conv,ip
tshark -r VPCNetwork_Traffic.pcap -Y http.request -T fields -e ip.src -e http.host -e http.user_agent
Pull the HTTP requests to the Gitea server (port 3000). The page footer reports Gitea 1.2.0, and the interesting traffic is a git-receive-pack push followed by a web-UI edit of a repository’s post-receive git hook. Gitea 1.2.0 lets any repository owner edit server-side git hooks from the settings page, which is arbitrary command execution as the Gitea service user. That is the CVE-2020-14144 pattern. The version and the hook edit are the evidence; the CVE number itself comes from the NVD entry for Gitea’s git-hook feature rather than from anything in the capture.
tshark -r VPCNetwork_Traffic.pcap -Y 'http.request' -T fields -e frame.number -e ip.src -e http.request.method -e http.request.uri
Follow the stream that carries the hook POST (/James/Test-Learn/settings/hooks/git/post-receive). The form body carries the hook script base64-encoded. Decoded, it is a bash one-liner that redirects an interactive shell to /dev/tcp/ on an ngrok TCP tunnel in the EU region, backgrounded so the push does not hang.
tshark -r VPCNetwork_Traffic.pcap -Y 'http.request.method==POST && http.request.uri contains "post-receive"' -T fields -e http.file_data
echo '<base64 from hook>' | base64 -d
Tip: Decode the base64 and the host and port are right there in the
/dev/tcp/redirect.
Check the source of the hook POST, not the source of the login. The POST to post-receive comes from the Windows workstation found in Q1, an internal address, so the exploit was run from inside the VPC and the threat is an insider. A second external address does log in as John and push code, but it never touches the hook settings. The format mask expects IP:insider/outsider.
tshark -r VPCNetwork_Traffic.pcap -Y 'http.request.method==POST && http.request.uri contains "hooks/git"' -T fields -e ip.src -e ip.dst -e http.request.uri
Gotcha: The external IP that authenticates as John and pushes is a decoy for this question. It looks like the attacker, but the exploitation request originates from the internal Windows host, so “outsider” with that IP is wrong.
The reverse shell lands on the Gitea Linux instance, which sits in GCP with the default Compute Engine service account attached. In GCloud_Logs.json, filter the audit entries by protoPayload.authenticationInfo.principalEmail and look for the default compute service account ([email protected]) calling the API from the external address 34.66.191.87. It tries storage.buckets.getIamPolicy on the data bucket and gets denied, which is the pivot out of the VM into cloud APIs.
python3 -I -c "import json,collections as c;d=json.load(open('GCloud_Logs.json'));print(c.Counter((e['protoPayload']['authenticationInfo']['principalEmail'],e['protoPayload']['methodName'],e['protoPayload'].get('status',{}).get('code',0)) for e in d))"
From Q5’s account, list the OS Login CheckPolicy calls. Each call names the instance the service account was authorising a login to. Three instances appear, sorted alphabetically below. These are the hosts the actor logged into, not every instance in the project.
python3 -I -c "import json;[print(e['protoPayload']['resourceName']) for e in json.load(open('GCloud_Logs.json')) if 'CheckPolicy' in e['protoPayload']['methodName']]"
Look at storage.objects.get calls on the customer-data object in GCloud_Logs.json. A dedicated storage service account performs the successful read, which is the account behind the data leaving the bucket. It is distinct from the compute account in Q5, whose bucket access was denied.
python3 -I -c "import json;[print(e['protoPayload']['authenticationInfo']['principalEmail'],e['protoPayload']['methodName'],e['protoPayload'].get('status',{}).get('code',0)) for e in json.load(open('GCloud_Logs.json')) if e['protoPayload']['methodName'].startswith('storage.')]"
The same storage.objects.get entry carries the object’s real resourceName. The attacker’s copy on the wire is cusdata.xlsx.enc, but the bucket object is the original customer data workbook with a long hash suffix. Copy the name from the log field exactly.
python3 -I -c "import json;[print(e['protoPayload']['resourceName']) for e in json.load(open('GCloud_Logs.json')) if e['protoPayload']['methodName']=='storage.objects.get']"
Tip: Answer with the bucket object name from the audit log, not the renamed file the attacker served.
Filter storage.objects.update and bucket IAM calls for policyDelta. One request tries to ADD allUsers with a read role to make the data public. The status code on the entry is 9 (failed precondition), so the change was denied, but the role in the request is what the question asks for.
python3 -I -c "import json;[print(e['protoPayload']['serviceData']['policyDelta']['bindingDeltas']) for e in json.load(open('GCloud_Logs.json')) if 'policyDelta' in json.dumps(e)]"
With the bucket route blocked, the actor staged the file on the compromised Linux host and served it over HTTP. tshark does not decode HTTP on that port by default, which is why Wireshark’s export-objects missed it. Find the TCP conversation that carries a Python SimpleHTTP response with /cusdata.xlsx.enc going to the external RDP address. The port is the one the default VPC ingress rules already allow: RDP.
tshark -r VPCNetwork_Traffic.pcap -q -z conv,tcp | sort -k9 -rn | head
tshark -r VPCNetwork_Traffic.pcap -d tcp.port==3389,http -Y http.request.uri contains cusdata -T fields -e ip.src -e ip.dst -e tcp.dstport
Hint: The default VPC network allows ingress on 22, 3389 and ICMP. The big transfer rides one of them even though it is not that protocol.
Follow the large TCP stream, strip the HTTP headers, and write the body to cusdata.xlsx.enc. The file is not a valid zip. The ciphertext shows a repeating keystream with a 36-byte period, so it is a repeating-key XOR. Recover the key bytes from the keystream, then pick the rotation so the output starts with the zip magic PK\x03\x04. The key was recovered by known-plaintext reasoning against the zip header; the only ambiguity was which rotation of the 36 characters to write down, and the one aligned to file offset 0 is the readable passphrase.
python3 -I extract_stream.py VPCNetwork_Traffic.pcap 126 cusdata.xlsx.enc
python3 -I xor_dec.py cusdata.xlsx.enc cusdata.xlsx # try each key rotation, keep the one producing PK\x03\x04
Gotcha: The rotation of the 36-byte key matters. A wrong rotation produces garbage that still looks structured, so check for the
PKheader, not just entropy.
Open the decrypted workbook and search every sheet for the Founder John row. The SSN column holds the value in the HTB placeholder format and the next column holds the card number. Submit them joined with a colon.
python3 -I find_row.py cusdata.xlsx 'Founder John'
These three are cloud-hardening questions answered from Google Cloud product documentation, not from the evidence.
Gitea is a self-hosted Git service, and Google Cloud’s managed private Git hosting is Cloud Source Repositories. This is reference knowledge, not something read from an artifact, and the answer is reasoned rather than observed.
The default Compute Engine service account is created with the broad Editor role in many projects, and the attacker pivoted through it in Q5. Google’s guidance is to create dedicated, least-privilege service accounts per workload instead. This is reasoned from that guidance.
The control that sets a perimeter around Google APIs such as Cloud Storage and blocks exfiltration to resources outside it is VPC Service Controls. Reference knowledge.
| Phase | Action |
|---|---|
| Initial Access | Internal Windows workstation logs into Gitea 1.2.0 over the VPC and pushes to a test repository |
| Execution | post-receive git hook edited in the web UI to run a bash reverse shell (CVE-2020-14144) |
| Command and Control | Shell connects out to an ngrok TCP tunnel |
| Credential Access | Default Compute Engine service account used from the Gitea VM; bucket getIamPolicy denied |
| Lateral Movement | OS Login policy checks for three instances |
| Collection | Storage service account reads the customer-data workbook |
| Exfiltration | Attempt to grant allUsers read on the bucket is denied |
| Exfiltration | Workbook XOR-encrypted and served over HTTP on port 3389 to the external RDP host |
| Type | Value |
|---|---|
| IP (insider workstation, hook POST) | 10.128.0[.]3 |
| IP (external, RDP/John login) | 169.150.196[.]101, 154.198.108[.]200 |
| IP (audit log, compute SA) | 34.66.191[.]87 |
| Host | 0[.]tcp[.]eu[.]ngrok[.]io:14509 |
| File | cusdata.xlsx.enc |
| Account | [email protected] |
| Account | storage-svc-acc@qwiklabs-gcp-00-848c1b920007.iam.gserviceaccount.com |
| Technique | ID | Description |
|---|---|---|
| Exploit Public-Facing Application | T1190 | Gitea 1.2.0 git-hook editing (CVE-2020-14144) |
| Unix Shell | T1059.004 | Bash reverse shell in the post-receive hook |
| Valid Accounts: Cloud Accounts | T1078.004 | Compute and storage service accounts used for cloud API access |
| Remote Services: SSH | T1021.004 | Logins to Linux instances via OS Login |
| Data from Cloud Storage | T1530 | storage.objects.get on the customer workbook |
| Exfiltration Over Alternative Protocol | T1048 | HTTP served on the RDP port |
| Obfuscated Files or Information | T1027 | Repeating-key XOR of the workbook |
Patch or retire self-hosted Git. Gitea 1.2.0 lets a repository owner write server-side hooks from the web UI, which is code execution as the service user. Upgrade, disable user-editable git hooks, and alert on POSTs to settings/hooks/git.
Treat internal users as a threat model. The exploit came from a workstation inside the VPC, so perimeter-only controls never saw it. Log and review administrative actions on internal developer tools.
Stop attaching the default Compute Engine service account. One compromised VM gave the actor a service account usable against Cloud Storage and OS Login. Use per-workload service accounts with least privilege and watch for getIamPolicy denials as a recon signal.
Block egress, not just ingress. Data left over port 3389 because the default VPC rules allow it inbound. Remove the default allow rules, restrict egress, and put Cloud Storage inside a VPC Service Controls perimeter.
Alert on public-access attempts. A denied allUsers binding is still a signal that someone is trying to expose a bucket. Alert on policyDelta entries containing allUsers or allAuthenticatedUsers.
An insider used an outdated Gitea server to reach cloud credentials and walked a customer workbook out of the project, even after the public-bucket attempt failed. The data left on a port the default network rules already trusted.