TechEarl

Cloud Metadata SSRF: From One Vulnerable URL Fetcher to Full Cloud Compromise

Ishan Karunaratne⏱️ 13 min readUpdated
Share thisCopied
Cloud metadata SSRF chain ending in IAM credential theft

Cloud metadata SSRF is the variant of server-side request forgery that turned a dull "the server fetches a URL for you" bug into the single most impactful web vulnerability of the cloud era. The chain is short: one vulnerable URL fetcher, one HTTP request to a link-local IP, one set of temporary IAM credentials. From there, the attacker has whatever the workload's service account had, which on most real-world deployments is "more than it needs". This article is the deep dive on that one variant: how the Capital One breach made it canonical, how the three major clouds defend against it now, why so many fleets are still exposed in 2026, and what defence in depth actually looks like.

TL;DR

Cloud metadata SSRF works because every major cloud provider runs an unauthenticated HTTP service on 169.254.169.254 (AWS, Azure) or metadata.google.internal (GCP), and the response includes the temporary IAM credentials attached to the workload. On AWS IMDSv1, any SSRF that can do a plain GET reaches those credentials. AWS released IMDSv2 in November 2019 after the Capital One breach: it requires a PUT-first session token and a custom X-aws-ec2-metadata-token header on every read, which the standard SSRF primitive cannot supply. GCP and Azure use the simpler approach of requiring a custom request header (Metadata-Flavor: Google and Metadata: true respectively). None of these are switched on by default for older instances. In 2026 the realistic blast radius depends entirely on whether ops disabled IMDSv1 across the fleet, set a hop limit of 1, and put a default-deny egress firewall in front of every workload.

The Capital One case study

On July 29, 2019 Capital One disclosed that an attacker had exfiltrated personal data on roughly 106 million customers and applicants (about 100 million in the US, 6 million in Canada). The attacker was Paige Thompson, a former AWS engineer. The chain was almost embarrassingly short:

  1. Capital One ran a misconfigured ModSecurity-based web application firewall on an EC2 instance. The WAF processed request bodies and made outbound HTTP requests as part of that processing, which is where the SSRF lived.
  2. The EC2 instance had an IAM role attached, with permission to list and read S3 buckets, and IMDSv1 was enabled (it was the only version at the time).
  3. Thompson found the SSRF and pointed the URL at http://169.254.169.254/latest/meta-data/iam/security-credentials/<role-name>. IMDSv1 happily returned the role's temporary AccessKeyId, SecretAccessKey, and Token.
  4. With those bearer credentials, she made S3 calls from her own network position. The IMDSv1 credentials were not bound to the instance, so the calls were authorised.
  5. She listed and downloaded around 700 buckets' worth of data.

Thompson was arrested in July 2019, convicted in June 2022 on seven federal counts (five felonies and two misdemeanours, including wire fraud and CFAA violations), and sentenced to time served plus five years' probation. Capital One paid an $80 million OCC fine and settled a $190 million class action.

The reason this breach is the canonical SSRF case study is not its scale (other breaches are bigger) but its mechanism. Before Capital One, "SSRF" was a long-tail bug class that scanners barely caught. After Capital One, every cloud security team had a playbook entry for it, and AWS shipped a new version of the metadata service three months later.

AWS IMDSv1 vs IMDSv2

IMDSv1 is unauthenticated. Any process that can make an HTTP GET to 169.254.169.254 (which on EC2 includes any process on the host, any container with default networking, and any SSRF-able application) can read the credentials:

bash
# IMDSv1: one GET, two requests, full credential blob
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/
# -> role-name

curl http://169.254.169.254/latest/meta-data/iam/security-credentials/role-name
# -> { "AccessKeyId": "...", "SecretAccessKey": "...", "Token": "...", "Expiration": "..." }

IMDSv2 (released November 19, 2019) added a session-token requirement. The caller has to PUT to /latest/api/token first, with a TTL header, get a short-lived token back, and then send that token in X-aws-ec2-metadata-token on every subsequent request:

bash
# IMDSv2: PUT first to get a session token
TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" \
  -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")

# Then GET with the token header
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/role-name \
  -H "X-aws-ec2-metadata-token: $TOKEN"

This is the design that defeats most SSRF. The standard SSRF primitive is "the server makes a GET to a URL the attacker controls". It does not generally control the HTTP method, and it does not generally let the attacker inject custom request headers. IMDSv2 requires both, which moves the attacker from "one GET is enough" to "I need an SSRF that also forwards an arbitrary PUT and an arbitrary header", which is a much rarer bug shape.

It is worth being precise about what IMDSv2 does not fix. If the SSRF lets the attacker control the HTTP method and headers (some library-based fetchers do, and request smuggling can sometimes get you there), IMDSv2 falls. If the instance is launched with HttpTokens=optional, IMDSv1 still answers and the attacker just uses that path. The hop limit (more on this below) is a separate concern again.

GCP metadata

GCP's metadata server lives at http://metadata.google.internal/computeMetadata/v1/ (also reachable at 169.254.169.254 for compatibility) and requires the Metadata-Flavor: Google request header on every read:

bash
curl "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token" \
  -H "Metadata-Flavor: Google"

A request without that header returns a 403. The defence principle is identical to IMDSv2: require something the standard SSRF primitive cannot supply. A vanilla GET-only SSRF cannot add Metadata-Flavor, so a vanilla SSRF cannot read GCP metadata. This has been the behaviour from the start; there was no equivalent of the "v1" footgun.

Azure metadata

Azure's Instance Metadata Service lives at http://169.254.169.254/metadata/instance?api-version=2021-02-01 and requires both the Metadata: true header and the explicit api-version query parameter:

bash
curl "http://169.254.169.254/metadata/instance?api-version=2021-02-01" \
  -H "Metadata: true"

Same shape, same principle. The header requirement defeats GET-only SSRF; the explicit api-version adds a second guardrail (no implicit default, the caller has to opt in to a contract).

Lab walkthrough

The ssrf-basic lab in the techearl-labs repo runs three containers: the vulnerable PHP app, a mock internal admin panel, and a mock IMDSv1. Bring it up:

bash
docker compose up ssrf-basic ssrf-basic-internal ssrf-basic-metadata

The vulnerable endpoint is /fetch.php, which passes $_GET['url'] straight to file_get_contents. To trigger the metadata exfil:

bash
curl 'http://localhost:8082/fetch.php?url=http://ssrf-basic-metadata/latest/meta-data/iam/security-credentials/role-name'

The response is a JSON blob containing the documented AWS example access key AKIAIOSFODNN7EXAMPLE (a public example value from AWS docs, not a real key). The mock service is IMDSv1-shaped on purpose: a single GET returns the credential blob. The lab simulates v1 specifically because v2 is awkward to mock locally (PUT + token + header) and because v1 is what most legacy environments still ship. If you can read the lab's JSON with a plain GET, you can read an unmigrated EC2 instance's real credentials the same way.

The lab uses a Docker hostname (ssrf-basic-metadata) rather than 169.254.169.254 because containers cannot bind to link-local addresses on most host network stacks. In production the URL is literally http://169.254.169.254/latest/meta-data/...; the lab's substitution does not change the lesson.

The realistic 2026 picture

The thing nobody likes to admit is that IMDSv1 is still everywhere. AWS shipped IMDSv2 in 2019 but kept v1 enabled by default on existing AMIs to avoid breaking workloads that used the older SDKs. Newer AMIs ship with HttpTokens=required by default, but only on AMIs released in the last couple of years, and only on launches that did not explicitly override the setting. The migration was opt-in for the fleet, not automatic.

In a typical 2026 enterprise AWS account I see three populations of EC2 instances:

  1. Recently launched, IMDSv2-required, hop-limit 1. Safe by construction. The SSRF cannot reach the credentials even if it reaches the IP.
  2. Older instances with HttpTokens=optional. v2 works for the modern SDK, but v1 still answers because nobody set the flag. From the attacker's view this is indistinguishable from a v1-only instance.
  3. Long-running instances launched before 2020. Still IMDSv1-only. Still one GET away from compromise.

GKE node pools default to GCP metadata with the required header, and the metadata-concealment feature for older clusters strips access from workload pods entirely. AKS does the same with Azure's header requirement. The fleet-wide picture for GCP and Azure is better than for AWS, mostly because they did not have a v1 footgun to walk back.

To audit your AWS fleet:

bash
aws ec2 describe-instances \
  --query 'Reservations[].Instances[].[InstanceId, MetadataOptions.HttpTokens, MetadataOptions.HttpPutResponseHopLimit]' \
  --output table

Anything where HttpTokens is not required is exposed. Anything where the hop limit is not 1 is exposed to the container-escape variant.

Defences beyond IMDSv2

IMDSv2 closes the most direct path, but it is not the only thing worth doing. The layered defences:

Enforce HttpTokens=required and hop limit 1 at launch. In Terraform:

hcl
resource "aws_instance" "web" {
  metadata_options {
    http_endpoint               = "enabled"
    http_tokens                 = "required"
    http_put_response_hop_limit = 1
  }
}

A hop limit of 1 means the IMDS response cannot be forwarded out of the host's network stack. A container running with NAT bridging would otherwise see a hop count of 2 and could read the host's credentials. Setting it to 1 blocks that variant.

Service Control Policy at the org level. Deny ec2:RunInstances unless aws:RequestTag/imds:HttpTokens equals required. Greenfield becomes safe by construction; brownfield is the remediation queue.

Default-deny egress. The application's security group should allow only the destinations it actually needs. The metadata IP is not one of them for most workloads (the SDK on the same host reaches IMDS through the host kernel, not through the application's egress path). Blocking 169.254.169.254/32 outbound at the security group level is a clean one-line defence:

hcl
# Egress SG: explicit deny on metadata IP would be a NACL rule,
# since SGs are allow-only. Use a NACL for the deny:
resource "aws_network_acl_rule" "deny_imds_egress" {
  network_acl_id = aws_network_acl.app.id
  rule_number    = 100
  egress         = true
  protocol       = "tcp"
  rule_action    = "deny"
  cidr_block     = "169.254.169.254/32"
  from_port      = 80
  to_port        = 80
}

(Security groups in AWS are allow-only, so the explicit-deny pattern lives at the NACL or network firewall layer.)

Kubernetes network policy on EKS / GKE / AKS. Pod-level egress policy denying the metadata IP from any pod that does not explicitly need it. The metadata-concealment proxy on GKE does this by default; on EKS and AKS it is a manual policy:

yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-metadata-egress
spec:
  podSelector: {}
  policyTypes: [Egress]
  egress:
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
            except:
              - 169.254.169.254/32
              - 169.254.170.2/32  # ECS task metadata

Separate VPC for outbound fetchers. Image proxies, link unfurlers, RSS readers, and tool-call orchestrators all legitimately fetch arbitrary URLs. Run them in their own VPC with no IAM role, no path to internal services, and no metadata endpoint reachable. The blast radius of an SSRF inside that VPC is then the public internet, which is the blast radius the attacker already has.

Real-world incidents

Capital One is the headliner, but it is not the only one. A few representative cases:

  • Capital One (2019). Covered above. ModSecurity WAF on EC2, IMDSv1, around 106 million records exfiltrated through S3.
  • Shopify (2018). A researcher reported an SSRF in Shopify's exchange-marketplace screenshot service that reached the EC2 metadata service and returned IAM credentials. Disclosed via HackerOne, fixed before exploitation. The screenshot service is the archetypal "fetch an arbitrary URL on the server" feature.
  • Tesla Kubernetes dashboard (2018). RedLock reported an exposed Kubernetes dashboard on Tesla's AWS environment with no authentication. From the dashboard, attackers reached IAM credentials via the pod's access to the EC2 metadata endpoint and used them to run cryptominers on Tesla's infrastructure. Not strictly an application SSRF, but the same metadata-credential-theft primitive.
  • Container-runtime CVEs. A recurring class of bugs (runc escapes, containerd issues) gives a compromised container access to the host's network, and from there to the host's IMDS. Hop-limit 1 mitigates this even when the runtime bug is unpatched.

Where to go next

Sources

Authoritative references this article was fact-checked against.

Tagsssrfcloud-securityaws-imdscapital-oneiam-credentials

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

Dalfox Tutorial: Exploiting a Vulnerable App End to End

A complete Dalfox walkthrough against a deliberately vulnerable XSS lab: reflected, stored, and DOM sinks, captured request files, blind callbacks, custom payloads, and a working cookie-theft chain. Updated for the Dalfox v3 Rust rewrite (May 2026) with the unified scan subcommand.

commix Tutorial: Exploiting a Vulnerable App End to End

A complete commix walkthrough against a deliberately vulnerable lab app: identify the sink, capture the request, run the classic, time-based, and file-based techniques, pop an os-shell, catch a reverse TCP, and exploit the escapeshellcmd argument-injection gap.