Instance metadata service: the 169.254.169.254 credential leak

Instance metadata service: the 169.254.169.254 credential leak

Written by

in

The instance metadata service is a small web server that every cloud virtual machine can reach at one fixed address, 169.254.169.254, and it answers questions about the machine it runs on. Ask it nicely and it will hand back the instance ID, the network setup, the startup script, and, the part that matters most for security, a set of live cloud credentials for whatever role the instance was given. No password, no signature, just an HTTP GET from inside the box. That last detail is why a single server side request forgery bug in a web app can turn into a full cloud account takeover. This post takes the instance metadata service apart from the address up: why the magic IP exists, what lives behind it, how the credential handoff works, how attackers reach it, and the exact mechanics of the defense that AWS bolted on after it went badly wrong.

Why there is a magic IP address at all

Start with the address itself, because it is not arbitrary. 169.254.169.254 sits inside 169.254.0.0/16, the block reserved for link local addresses by RFC 3927. Link local means the address is only valid on the local network segment. A packet sent to it is never routed off the link and never leaves for the internet. Your laptop uses the same range when DHCP fails and it has to invent an address to talk to whatever is directly attached.

Cloud providers borrowed that property on purpose. Every instance, in every account, in every region, reaches its metadata at the exact same IP. The address resolves to nothing on the public internet, so an instance can hardcode it and never worry about discovery. When the guest sends a packet to 169.254.169.254, the hypervisor or the host networking stack intercepts it before it goes anywhere and answers locally. There is no real server sitting at that address out in the network. The host is quietly impersonating one, on a link that only this instance can see.

That design choice is elegant and it is also the root of the whole problem. The metadata endpoint is reachable by anything running on the instance that can open a socket. It does not check who is asking. It assumes that if a request arrived from inside the machine, the request is trusted. Hold on to that assumption, because every attack in this post is a way of making the metadata service answer a question on behalf of someone who is not trusted at all.

What actually lives behind 169.254.169.254

The metadata service exposes a tree of plain text, browsable like a tiny filesystem over HTTP. On AWS the root of the useful part is http://169.254.169.254/latest/meta-data/. Ask for it and you get a listing:

ami-id
block-device-mapping/
hostname
iam/
instance-id
instance-type
local-ipv4
mac
placement/
public-ipv4
security-groups
...

Most of this is housekeeping. instance-id and ami-id identify the machine and the image it booted from. local-ipv4 and mac describe its place on the network. placement/ tells you the availability zone. None of that is secret in any meaningful way. An automation tool reads these so it can configure itself without being told where it is running. This is the honest, boring purpose of the service, and it is genuinely useful.

Then there is the iam/ branch, and this is where boring ends. Follow it to iam/security-credentials/ and the service lists the name of the role attached to the instance. Imagine a role called app-server-role. Ask for that name directly:

GET http://169.254.169.254/latest/meta-data/iam/security-credentials/app-server-role

and the response is a block of JSON that looks like this:

{
  "Code": "Success",
  "Type": "AWS-HMAC",
  "AccessKeyId": "ASIAEXAMPLE7XYZ",
  "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
  "Token": "IQoJb3JpZ2luX2VjE...long base64 session token...",
  "Expiration": "2026-06-21T12:00:00Z"
}

Those three fields, AccessKeyId, SecretAccessKey, and Token, are a working set of AWS credentials. Anyone holding them can sign API calls as the instance role until the Expiration time. There is no extra factor and no challenge. The credentials are simply sitting there at a known URL, waiting for a GET.

The credential flow: from role to STS to keys

To see why credentials appear out of thin air, follow where they come from. When you launch an instance you can attach an instance profile, which wraps an IAM role. The role is a bundle of permissions, for example the ability to read objects in one S3 bucket. The role has no long lived password. Instead, the host runs an agent that asks AWS Security Token Service, STS, for temporary credentials that embody the role. STS mints a short lived key, secret, and session token, stamps them with an expiry usually a few hours out, and the agent parks them in the metadata service for the instance to read.

This is a good design in isolation. The instance never stores a permanent secret on disk. The credentials rotate automatically before they expire, so a copy you steal stops working on its own. The application code does not even need to know the keys exist, because the AWS SDK reads them from the metadata service for you. The whole point is to keep secrets off the box and short lived. The flaw is not in STS or in rotation. The flaw is that the doorway to those credentials is an unauthenticated HTTP endpoint that trusts the caller by location alone.

How the instance metadata service becomes an attack

An attacker who already has a shell on the instance does not need the metadata service. They can read those credentials, but they could read your disk and your environment variables too. The reason this endpoint is dangerous out of all proportion is that an attacker does not need a shell. They need only a way to make the instance issue one HTTP request to a URL of their choosing. That primitive is called server side request forgery, and it is one of the most common bugs in web applications. We cover the general class in our writeup on server side request forgery, but the metadata service is its highest value target by a wide margin.

Picture a feature that fetches a URL for you. A SaaS app, call it Acme Notes, lets users add a profile picture by pasting an image URL. The server fetches that URL and stores the image. The developer pictured users pasting links to photos. Nothing stops a user from pasting this instead:

http://169.254.169.254/latest/meta-data/iam/security-credentials/app-server-role

The server, doing exactly what it was told, fetches that URL from inside its own network, where 169.254.169.254 resolves to the metadata service. The JSON credential block comes back and gets stored or echoed where the attacker can read it. The attacker never logged in to the instance. They handed it a URL and the instance read its own credentials out loud. With those keys an attacker configures the AWS command line tool and now acts with the full permissions of the role, from their own laptop, anywhere in the world.

The metadata service does not leak credentials because it is broken. It leaks them because it answers honestly, and the application was tricked into asking the question on the attacker’s behalf.

Capital One: the textbook case

This is not theoretical. In July 2019 Capital One disclosed a breach that exposed personal data from roughly 106 million credit card applicants across the United States and Canada. The attack chain is now a standard teaching example because every link in it is one of the pieces above.

The entry point was a misconfigured web application firewall running on an EC2 instance, built on ModSecurity. The firewall could be coerced into making a request on the attacker’s behalf, a server side request forgery. The attacker pointed that request at 169.254.169.254 and pulled the temporary credentials for the role attached to the firewall instance, a role reported as ISRM-WAF-Role. That role had permission to list and read S3 buckets, far more access than a firewall needed. Using the stolen credentials the attacker listed and then synced the contents of more than 700 buckets to a machine they controlled. One SSRF bug, one over permissioned role, and an unauthenticated metadata endpoint combined into one of the largest financial data breaches on record. The instance was using the original version of the metadata service, the one with no token required, which is the version we look at next.

IMDSv1 versus IMDSv2: the token dance

The version Capital One used, now called IMDSv1, is a plain request and response. You GET a URL, you get the answer. That is the entire protocol. It is also exactly what makes SSRF so effective against it, because the one thing a typical SSRF bug can do is cause a GET to an attacker chosen URL. The bug and the defense were a perfect match for each other, in the attacker’s favor.

AWS responded with IMDSv2, a session oriented scheme that is worth understanding precisely, because the defense is clever and it leans on what SSRF usually cannot do. Under IMDSv2 you cannot just GET the data. First you have to open a session by making a PUT request for a token:

PUT http://169.254.169.254/latest/api/token
X-aws-ec2-metadata-token-ttl-seconds: 21600

The service returns a token string. The TTL header sets how long the token stays valid, with a maximum of six hours, which is 21600 seconds. Every later request for actual metadata must carry that token in a header:

GET http://169.254.169.254/latest/meta-data/iam/security-credentials/app-server-role
X-aws-ec2-metadata-token: <token from the PUT>

When the instance is configured to require IMDSv2, a request with no token or an expired token is refused with 401 Unauthorized. Now look at why this stops the profile picture attack. A normal SSRF bug lets you control a URL. It does not usually let you change the HTTP method from GET to PUT, and it does not usually let you add an arbitrary request header like X-aws-ec2-metadata-token-ttl-seconds. The attacker can still make the server GET the metadata URL, but without a token that GET now returns 401 instead of credentials. The defense does not try to detect malicious URLs. It raises the bar from a single GET to a two step exchange that uses verbs and headers a forged request almost never controls.

There is a second, quieter guard built into the same scheme. The PUT that mints a token is rejected if it carries an X-Forwarded-For header. That header is the fingerprint of a request that passed through a proxy, which is precisely the shape of many SSRF and open proxy attacks. If your forged request arrived by way of a proxy that stamped X-Forwarded-For, the token request fails before it starts.

The hop limit, a defense at the IP layer

IMDSv2 adds one more control that lives below HTTP entirely. The response to the token PUT is sent with an IP time to live, the hop limit, of 1 by default. Time to live is the field in every IP packet that counts down by one at each router and drops the packet when it hits zero. A hop limit of one means the token response can reach a process on the instance itself, but it cannot survive being forwarded even a single hop further.

Why does that matter? A common modern setup runs containers on the instance, and a misconfigured container network can let a pod reach the metadata service through the host, adding a hop. With the default hop limit of one, the token packet dies before it reaches the container, so a compromised container cannot complete the IMDSv2 handshake through that extra hop. You can raise the limit with modify-instance-metadata-options when a legitimate setup needs it, but the safe default assumes the only thing that should be talking to the metadata service is the instance itself, not anything one network hop away.

The same idea on the other clouds

This is not an AWS quirk. The pattern is industry wide, and the same magic address shows up on the other major providers, which is worth knowing because a single SSRF payload is often tried against all three.

Google Cloud serves metadata at 169.254.169.254 and at the friendlier name metadata.google.internal. Its defense is a required header: every request must include Metadata-Flavor: Google. A plain GET with no header is refused. The reasoning is the same as the IMDSv2 token, that a typical SSRF bug controls the URL but not the headers, so demanding a custom header filters out the forged requests that only know how to set a path.

Azure uses the same IP and requires the header Metadata: true plus an api-version parameter on the query string. Again the shape is identical. The metadata is valuable, the endpoint is unauthenticated by network position, and the guard is a request element that a forged URL fetch is unlikely to carry. Three clouds, one address, and the same lesson about trusting a caller because of where it sits.

When blocking the address is not enough

A defender who learns about this attack reaches for the obvious fix: if a user supplied URL points at 169.254.169.254, reject it. That helps, but a naive string match is a speed bump, because the address can be written in many shapes and an attacker needs only one of them to slip through. The evasions are the difference between a filter that holds and one that only looks like it holds.

The same address has many spellings. 169.254.169.254 is four bytes, and those bytes can be written as one decimal number, 2852039166, or in octal, or in hex, and many HTTP clients parse all of them back to the same destination. A blocklist that only knows the dotted form never sees the decimal one. AWS also serves the metadata service over IPv6 at [fd00:ec2::254] on newer instances, so a filter that only thinks in IPv4 misses an entire second door.

Then there are the tricks that defeat checking the host at all. With DNS rebinding, the attacker controls a domain that resolves to a harmless address the first time the app checks it, then flips to 169.254.169.254 a moment later when the app actually connects. The validation and the connection see different answers. With a redirect, the attacker hands the app a URL on a domain that passes validation, and that server replies with an HTTP redirect to the metadata IP, which many fetch libraries follow on their own. The app checked the first hop and walked into the second. We pull that thread further in our writeup on open redirects, because the same trust in a validated host powers both bugs.

There is also the case where the app fetches the URL but never shows you the result. That is blind server side request forgery. The metadata response comes back, but it lands in a log or a thumbnail the attacker cannot read directly. The attack is not dead, only quieter. The attacker arranges for the fetched credentials to surface somewhere reachable, a field that is displayed later or an out of band channel they control. Blind does not mean safe, it means slower.

Once the credentials are out, the metadata service has done its damage and the attacker moves on. The first thing a careful attacker does with stolen keys is ask who they belong to and what they are allowed to touch, then map the blast radius before doing anything noisy. That is why least privilege on the role matters as much as blocking the address. The endpoint decides whether credentials leak. The role decides how much the leak is worth.

How to actually lock it down

The good news is that the controls stack, and none of them depend on finding every SSRF bug first. Defense in depth here is real, not a slogan.

  • Require IMDSv2 and turn IMDSv1 off. Set the instance metadata options so that a token is mandatory. This single change neutralizes the plain GET attack that took down Capital One. New instances can enforce it from launch, and you can flip existing ones with modify-instance-metadata-options.
  • Keep the hop limit at 1 unless a specific workload proves it needs more. If you run containers, prefer a setup that gives pods their own scoped credentials rather than reaching through the host.
  • Give the role the least privilege it can do its job with. The Capital One role could read hundreds of buckets it never needed. If that role had been allowed to touch only the one bucket the firewall required, the same SSRF would have leaked a far smaller blast radius. The metadata service handing out credentials is only as dangerous as the credentials it hands out.
  • Filter egress and block the metadata IP at the application layer. If a feature fetches user supplied URLs, refuse any request whose host resolves into the link local range, and do the check after resolving the name, not before, so a hostname that points at 169.254.169.254 cannot sneak past.

The assumption that breaks

Step back from the headers and the JSON and the one thing left is an assumption. The metadata service was built to trust any caller that reaches it from inside the instance, because in 2009 the inside of an instance was a place only you could be. The web application running on top of that instance quietly broke the assumption. The moment an app fetches a URL on a user’s behalf, the user can reach anything the app can reach, and the app can reach 169.254.169.254. The boundary everyone pictured, the wall around the instance, was not the boundary that mattered. The boundary that mattered ran through a profile picture field.

That gap between what a system assumes about its callers and what an attacker can actually arrange is the kind of thing you find by asking what each component trusts and why, rather than by scanning for a known bad string. The metadata service is honest, the SDK is convenient, the role rotates its keys, and the sum of those reasonable parts is a path from one web request to a cloud account. Require the token, cut the permissions, block the address at the edge, and the most dangerous IP in your cloud goes back to being a boring configuration helper.

Frequently asked questions

What is the instance metadata service used for?

It is a local endpoint at 169.254.169.254 that lets a cloud virtual machine read facts about itself, like its instance ID, network setup, and startup script, without being configured by hand. The dangerous part is that it also serves the temporary credentials for the IAM role attached to the instance, which is why it is a prime target once an attacker can make the machine send a request.

How does SSRF lead to stealing cloud credentials?

If an application can be tricked into fetching an attacker chosen URL, the attacker points it at http://169.254.169.254/latest/meta-data/iam/security-credentials/ and the server reads its own role credentials back. The endpoint trusts any caller on the instance, so a single server side request forgery bug becomes a full set of working AWS keys. This is the exact chain behind the 2019 Capital One breach.

Does IMDSv2 fully prevent metadata attacks?

IMDSv2 raises the bar a lot but is not a complete fix on its own. It forces a PUT request for a session token and a custom header on every read, which a typical SSRF cannot supply, so plain GET attacks fail. You still need least privilege on the role and egress filtering, because attackers chain redirects, DNS rebinding, and alternate IP encodings to reach the endpoint. AWS documents the scheme in its IMDS guide.

Do Google Cloud and Azure have the same metadata risk?

Yes, both serve metadata at the same 169.254.169.254 address and carry the same risk. Google Cloud requires a Metadata-Flavor: Google header and Azure requires Metadata: true, and like IMDSv2 those required headers exist to filter out forged URL fetches that only control the path. A single SSRF payload is often tested against all three clouds.