SSRFCloud Metadata

Cloud-Metadata SSRF: From One URL to Full Account Takeover

On a cloud host, a single server-side request forgery aimed at the link-local metadata endpoint can hand an attacker temporary cloud credentials. Here is how the IMDSv1, GCP and Azure variants work, and how to shut the door.

SelfSec Team5 min read
Part 3 of 4from theSSRFseries
http://169.254.169.254/
On this page

Server-side request forgery is dangerous anywhere, but on a cloud host it has one target that towers over the rest: the instance-metadata service. Every major provider exposes it on the link-local address 169.254.169.254, reachable only from the instance itself, and it answers with the machine's identity — including the temporary credentials of whatever cloud role the instance runs as. An SSRF that reaches it does not just leak a file; it leaks the keys to the account.

This is the highest-value SSRF target on the internet, and it is why a single vulnerable URL parameter can escalate to full cloud account takeover. This post walks through how the metadata endpoint works across providers, how an attacker pivots from a leaked token to the whole account, and how to lock the door.

How it happens

The root cause is the ordinary SSRF mistake: the application fetches an attacker-supplied URL without checking where it points. The difference is the destination. Instead of the public internet, the attacker aims the server inward at the link-local metadata address, which is unrouted and only reachable from inside the instance:

GET /fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/ HTTP/1.1
Host: app.example

The server connects from inside the VPC, the metadata service trusts the request because it came from the instance, and the response flows straight back to the attacker. Whether that is enough to steal credentials depends entirely on which version of the metadata service is enabled.

A concrete attack: IMDSv1 versus IMDSv2

On AWS, IMDSv1 is a plain unauthenticated GET. List the role, then read its credentials:

GET /fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/app-role HTTP/1.1
Host: app.example

The response is a JSON document with AccessKeyId, SecretAccessKey and a Token — short-lived STS credentials for the instance role. A GET-only SSRF reads them directly. IMDSv2 closes that path by requiring a session token obtained with a PUT, which most SSRF primitives cannot issue, and by enforcing a hop limit on the response so it cannot leave the host through a proxy:

PUT /latest/api/token HTTP/1.1
Host: 169.254.169.254
X-aws-ec2-metadata-token-ttl-seconds: 21600

Every subsequent read must carry that token in X-aws-ec2-metadata-token. A simple GET-only SSRF cannot mint the token and is defeated. The other clouds add their own friction with required headers: GCP serves metadata from metadata.google.internal (which resolves to the same link-local address) only when the request carries Metadata-Flavor: Google, and Azure's IMDS responds only when Metadata: true is present.

GET http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token
Metadata-Flavor: Google
GET http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/
Metadata: true

Those header requirements are a real obstacle for SSRF that can only control the URL, but they fall the moment the vulnerable feature lets the attacker set request headers too — many webhook and "import from URL" features do.

The attacker never broke into the cloud account. They borrowed the instance's identity for one request, and the metadata service handed over credentials because the call came from inside the host.

The pivot to account takeover

A leaked token is not the prize; it is the foothold. The credentials carry the instance role's permissions, and those are rarely scoped tightly. From a developer's laptop the attacker exports the three values and starts acting as the role:

export AWS_ACCESS_KEY_ID=ASIA...
export AWS_SECRET_ACCESS_KEY=...
export AWS_SESSION_TOKEN=...
aws sts get-caller-identity
aws s3 ls

get-caller-identity confirms the role, then enumeration begins: list buckets, read secrets from the parameter store, describe instances. If the role can attach policies or create users, the attacker grants themselves persistence and the single SSRF becomes a full account compromise. The same pattern holds on GCP and Azure with their respective tokens and CLIs.

No single control is enough, so stack them. First, require the authenticated metadata flow and a low hop limit, so a GET-only SSRF cannot read credentials and a stolen response cannot be relayed off the host:

aws ec2 modify-instance-metadata-options \
  --instance-id i-0abcd1234 \
  --http-tokens required \
  --http-put-response-hop-limit 1 \
  --http-endpoint enabled

Second, fix the application the same way you fix any SSRF: resolve the hostname, check the resolved IP against an allowlist and against blocked link-local and private ranges, and fetch the URL you actually validated — not a fresh one the resolver might map elsewhere:

import ipaddress, socket
from urllib.parse import urlparse

ALLOWED_HOSTS = {"images.example.com", "cdn.example.com"}

def safe_fetch(url):
    parsed = urlparse(url)
    if parsed.scheme != "https" or parsed.hostname not in ALLOWED_HOSTS:
        raise ValueError("destination not allowed")

    resolved = ipaddress.ip_address(socket.gethostbyname(parsed.hostname))
    if resolved.is_private or resolved.is_loopback or resolved.is_link_local:
        raise ValueError("internal address blocked")

    return requests.get(url, allow_redirects=False, timeout=5).content

Re-validating after DNS resolution is what defeats DNS rebinding: an allowlisted host that resolves to 169.254.169.254 on the second lookup is caught because the resolved IP, not the hostname, is the thing checked. Reinforce it at the network edge by blocking egress to 169.254.169.254 entirely for workloads that have no reason to read metadata, and route outbound fetches through a proxy that enforces the host allowlist for you.

How SelfSec finds it

SelfSec's crawler maps every parameter, header and form field that feeds an outbound request, then probes them with cloud-metadata endpoints across providers — the AWS link-local address, metadata.google.internal, and the Azure IMDS path — including the header variants (Metadata-Flavor: Google, Metadata: true) that gate GCP and Azure. It confirms a finding only when the response carries proof an internal request actually succeeded — metadata-service markers, internal file contents or service banners, or an out-of-band callback to its listener — rather than guessing from the payload alone. The whole scan runs locally on 127.0.0.1, your targets and findings stay on your machine, and each finding ships with a validated reproduction request you can replay.

SelfSec is intended strictly for authorized security testing of systems you own or are explicitly permitted to assess.

Do both things about SSRF

SelfSec covers this class from both sides — the scanner confirms it in your own app, the firewall blocks it in front of your origin while the fix ships.

The SSRF series

  1. 01OverviewSSRF, Explained: When Your Server Becomes the Attacker's Proxy3 min
  2. 02BlindBlind and Out-of-Band SSRF: Detecting the Request You Can't See5 min
  3. 03Cloud MetadataCloud-Metadata SSRF: From One URL to Full Account TakeoverReading
  4. 04Protocol SmugglingProtocol Smuggling in SSRF: gopher, dict and file Schemes5 min

Related reading