SSRF to Cloud Metadata: Why 169.254.169.254 Is Still the Highest-Value IP in Your VPC
#From one GET request to your whole S3 account#link
In the spring of 2019, an attacker walked out of Capital One with personal data on roughly 106 million credit card applicants. The initial access was not a zero-day, not a phished employee, and not a brute-forced key โ it was a single web request that convinced a misconfigured WAF server to fetch a URL on the attacker's behalf. That URL pointed at 169.254.169.254, the link-local address every EC2 instance answers on, and the response contained live IAM credentials for a role with far more reach than any WAF ever needed. The OCC's subsequent $80 million consent order reads less like a story about a clever adversary and more like a story about a chain where every single link was ordinary.
What 169.254.169.254 actually serves
Cloud instances have a bootstrapping problem: they need configuration and credentials at startup, but nobody wants secrets baked into an AMI or handed around in user data. EC2's answer is the Instance Metadata Service โ an HTTP endpoint on TCP port 80 at 169.254.169.254, reachable only from the machine itself. Alongside instance facts like instance-id and user-data, if an IAM role is attached to the instance, IMDS serves a live credential set at /latest/meta-data/iam/security-credentials/<role-name>: access key, secret key, and session token, which AWS rotates automatically before they expire. No long-lived secrets on disk, no manual rotation, no static keys in CI. The design is elegant, which is precisely why it is dangerous.
The entire trust model is one sentence: traffic to 169.254.169.254 originates from the operating system on this host, so the endpoint needs no authentication. That assumption holds right up until your application starts fetching URLs supplied by users โ a webhook verifier, an import-from-URL button, a PDF renderer, an avatar fetcher. When your code makes that request, it makes it as the instance, from the instance's network position. Server-side request forgery turns an endpoint that was never supposed to be reachable into one that is reachable through your own code. And there is a filter-bypass detail that makes this a routine bug rather than an exotic one: SSRF blocklists tend to cover the RFC 1918 private ranges and forget that 169.254.0.0/16 is link-local, not private, so metadata addresses sail through naive allowlist checks.
The SSRF pivot: your app becomes the metadata client
A URL-fetching feature on an instance with an attached role. From IMDS's point of view this is a perfectly legitimate request โ it comes from the instance itself, which is the entire problem.
{
"Code": "Success",
"LastUpdated": "2025-01-15T02:11:04Z",
"Type": "AWS-HMAC",
"AccessKeyId": "ASIAIOSFODNN7EXAMPLE",
"SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"Token": "AQoDYXdzEPT//////////wEXAMPLExkTsV7Eo2UeFzGLqma2Oq0Ld1Lc5yAqnbSjwD4vEwZbFtRjVnXwLh0Rk2C2zXw8UePz9WVqkQe9LbS1XuP7Hn2Qm3Gx6CdG0g1V7yH6fR5aR1tU0eXAMPLE",
"Expiration": "2025-01-15T08:02:11Z"
}What happens next is mechanical. The attacker exports the three values, and every AWS SDK will treat them as a valid identity. Enumerate S3, read what the role can read, pivot into whatever else the role's policies permit. In the Capital One case the credentials belonged to the WAF fleet's instance role, and the gap between what that role was for and what it could actually do was the breach. Two details matter enormously for incident response. First, the credentials keep working for hours after you patch the SSRF โ closing the hole does not recall the keys. Second, the part responders learn the hard way: IAM evaluates role policies at call time, not at credential-issue time. Attach a restrictive policy, or an explicit deny, to the role and stolen keys go inert immediately, without waiting out their expiry.
IMDSv2: what the token handshake actually changes
AWS shipped IMDSv2 in November 2019, and the design shows they understood the attack shape. The endpoint stops answering bare GETs: a client must first issue a PUT to /latest/api/token carrying a TTL header, then attach the returned token to every metadata request via X-aws-ec2-metadata-token. Requiring PUT is the whole trick. The common SSRF primitives โ parameter injection into a url= field, XXE, SSRF through image tags and redirect-chasing HTTP libraries โ can make the victim issue GETs, but they generally cannot choose the HTTP method or forge arbitrary headers. It is the same class of defense as requiring a custom header or a JSON content type to defeat CSRF. A second layer, the hop limit (default 1), restricts how many routed network hops the token response may travel, so a token request relayed through a proxy or an appliance in another subnet never gets its answer back โ the PUT has to come from the host itself.
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" \-H "X-aws-ec2-metadata-token-ttl-seconds: 21600")curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/ \-H "X-aws-ec2-metadata-token: $TOKEN"EC2-WAF-Role
Say the quiet part plainly: IMDSv2 is a mitigation for SSRF, not an isolation boundary. Any code actually executing on the instance โ or any container that can route to the link-local address โ can perform the PUT handshake itself. It raises the cost of the most common SSRF shapes and does nothing at all against a shell on the host.
Where IMDSv2 falls short
The first gap is SSRF with control of the raw request. If the primitive lets an attacker write arbitrary bytes to a TCP stream โ the gopher:// family, where tools like Gopherus and SSRFmap assemble the payload for you โ the PUT is just more bytes to write. The handshake slows that class of attack down; nothing in the protocol stops it. The second gap is more uncomfortable for Kubernetes shops: by default, every pod on an EKS node can reach 169.254.169.254, fetch a token, and read the node's role credentials, so one compromised container equals the node's identity. Scoping credentials per service account with IRSA or EKS Pod Identity matters more here than the IMDS version ever will. The third gap bites during incidents: the handshake protects future requests, but credentials already handed out keep evaluating successfully until they expire. IMDSv2 is prevention, not revocation.
The hop limit has its own trap. Container networking routes pod traffic to the host through a bridge, and that extra hop decrements the TTL โ with the default limit of 1, perfectly legitimate pods cannot complete the token handshake and fail in confusing, quiet ways: applications hang at startup, SDKs retry into a loop, and nothing prints an obvious error. AWS's guidance to raise the limit to 2 on containerized workloads fixes the pods and, in the process, weakens the defense by exactly one relayed hop. The honest posture is hop limit 2 where containers genuinely need IMDS, paired with a CNI network policy that blocks 169.254.169.254 from every pod that doesn't.
# Lock a single instance downaws ec2 modify-instance-metadata-options \--instance-id i-0a1b2c3d4e5f6a7b8 \--http-tokens required \--http-endpoint enabled \--http-put-response-hop-limit 1# Audit every running instance in the accountaws ec2 describe-instances \--filters Name=instance-state-name,Values=running \--query 'Reservations[].Instances[].[InstanceId, MetadataOptions.HttpTokens, MetadataOptions.HttpPutResponseHopLimit]' \--output table--------------------------------------------------------------------------------| InstanceId | HttpTokens | HttpPutResponseHopLimit |+----------------------+------------+-------------------------+| i-0a1b2c3d4e5f6a7b8 | required | 1 || i-1f2e3d4c5b6a7980 | optional | 1 |
| Control | What it actually stops | What it doesn't stop | Where it fits |
|---|---|---|---|
| IMDSv2 required | GET-only SSRF from off the host; token requests relayed through proxies | SSRF with raw TCP control, code execution on the host, containers that can route to link-local | Baseline on every instance โ it costs nothing |
| IMDS endpoint disabled | All credential theft through IMDS, v1 and v2 | Static secrets baked into AMIs and every other theft vector; breaks user-data fetch and any SDK relying on the role | Instances that carry no IAM role at all |
| IRSA / EKS Pod Identity | Pods reading the node's role credentials | Compromise of the node itself; each pod still holds its own scoped credentials | The default in EKS โ one role per service account, not per node |
| CNI network policy on link-local | Pod-to-IMDS traffic entirely, including the token handshake | SSRF from a pod that legitimately needs metadata; anything running on the host itself | Pair with IRSA and exempt only the pods that need it |
| GuardDuty exfiltration findings | Nothing โ this is detection after the fact | Every prevention failure above | The layer that assumes the rest has already failed |
โ ๏ธ The rollout breaks things you only find in production. SDK releases older than late 2019, hand-rolled startup scripts that curl the metadata directly, and tooling baked into golden AMIs have no idea the handshake exists โ flip an instance to required and they fail, often without loud errors. Don't discover this by enforcement: run with --http-tokens optional first, watch the MetadataNoToken CloudWatch metric (it counts IMDSv1-style calls per instance) until it flatlines, then enforce. And remember that modify-instance-metadata-options touches one instance at a time โ put the setting in launch templates and Auto Scaling groups, or you will be re-auditing the same debt next year.
Detection: the reads you never see in CloudTrail
None of this shows up where most teams look first. IMDS reads are not AWS API calls, so CloudTrail records nothing at the moment the credentials are pulled โ your first visible evidence arrives downstream: enumeration calls like GetBucketLocation and ListBuckets, GetObject volume spikes, or API calls from addresses the role has never used before. GuardDuty covers the specific scenario with the finding type UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration.OutsideAWS, which fires when credentials issued to an EC2 instance are used from outside AWS โ in practice, from the attacker's own infrastructure. Host-level detection is cheap and high-signal: a single iptables or nftables LOG rule on OUTPUT matching destination 169.254.169.254 tells you which processes are talking to metadata, and the answer is usually a shorter list than you fear.
- โชRun the fleet audit above and treat every HttpTokens: optional as open debt โ internet-facing instances first.
- โชEnforce required with hop limit 1 on plain instances and 2 on containerized ones, set in launch templates so new instances inherit it rather than relying on memory.
- โชOn EKS, scope credentials per service account with IRSA or Pod Identity, and default-deny link-local from pods with a Calico or Cilium network policy.
- โชInventory your SSRF surface: webhook verifiers, import-from-URL fields, avatar and image fetchers, PDF generators. These are the features that become IMDS proxies.
- โชDuring an incident involving instance-role credentials, restrict the role's policies immediately โ IAM evaluates them at call time, so stolen keys go inert without waiting for rotation.
- โชConfirm GuardDuty is enabled in every region you operate in and that InstanceCredentialExfiltration.OutsideAWS pages a human.
169.254.169.254 deserves a spot on your crown-jewels list. It is the rare infrastructure endpoint that is simultaneously invisible โ never on an architecture diagram, never visible to an outside port scan โ and load-bearing for your entire credential story. The metadata service solved 'no secrets on disk' by concentrating the value of a whole IAM role behind a single unauthenticated HTTP GET on every host you run. Respect the design, and treat the IP like the keys it actually serves.