From SSRF to Stolen EC2 Role Credentials: Killing the IMDSv1 Attack Chain
How an SSRF bug becomes full IAM-role credential theft through the instance metadata service, and how to enforce IMDSv2 org-wide with SCPs, condition keys, and GuardDuty detection.

The single most reliable cloud privilege-escalation primitive of the last decade is still a server-side request forgery bug pointed at 169.254.169.254. One unvalidated URL fetch on an EC2 instance, and an attacker walks away with the temporary credentials of the instance profile role. The 2019 Capital One breach was this exact chain, and despite IMDSv2 shipping that same year, fleets full of IMDSv1-reachable instances are still the norm in audits today. This article walks the full chain from the attacker's perspective, then shows the concrete controls that break it: IMDSv2 enforcement, the hop limit, organization-wide SCPs keyed on ec2:MetadataHttpTokens, and the GuardDuty finding that catches credential exfiltration when prevention fails.
The Instance Metadata Service and why v1 is exploitable
Every EC2 instance can query a link-local endpoint at 169.254.169.254 to retrieve its own metadata, including the temporary STS credentials minted for its attached IAM role under /latest/meta-data/iam/security-credentials/. IMDSv1 is a plain request/response protocol: any process that can make an outbound HTTP GET to that IP gets the credentials back, no authentication, no headers required. That property is exactly what makes it a perfect SSRF target. An attacker who can coerce a vulnerable application into fetching an attacker-controlled URL simply points it at the metadata endpoint and reads the role's AccessKeyId, SecretAccessKey, and SessionToken out of the response body.
IMDSv2 closes the SSRF path by requiring a session-oriented, two-step exchange. The client first does a PUT to /latest/api/token with a TTL header to obtain a session token, then passes that token in the X-aws-ec2-metadata-token header on every subsequent GET. Most SSRF primitives can only issue a GET with attacker-influenced URL and cannot set arbitrary request headers or change the HTTP method, so they cannot complete the PUT-then-GET handshake. IMDSv2 also defaults the response hop limit (TTL) to 1, so the metadata reply is dropped if it has to traverse a container network hop or an open proxy, neutralizing a whole class of pivot.
The offensive chain, step by step
With IMDSv1 still enabled (HttpTokens=optional), the chain is short and dependable:
- Find an SSRF: an image-fetcher, webhook validator, PDF renderer, or any feature that fetches a user-supplied URL server-side.
- Pivot to metadata: coerce a GET to http://169.254.169.254/latest/meta-data/iam/security-credentials/ to list the role name, then GET that role name to retrieve the JSON credential document.
- Exfiltrate the temporary credentials (AccessKeyId, SecretAccessKey, Token) off-host.
- Use the credentials from attacker infrastructure with the AWS CLI or SDK. sts get-caller-identity confirms the role; from there the attacker enumerates and abuses whatever the instance profile policy permits, commonly s3:GetObject, s3:ListBucket, or worse.
The tell-tale signal of the final step is that role credentials minted for an instance are suddenly called from an IP outside the VPC. That is the exact behavior the defense side keys on for detection. Note the hop limit also matters offensively: if you do force IMDSv2 but leave the hop limit high, an SSRF that traverses a sidecar or proxy on a multi-hop path can still reach the endpoint.
Enforcing IMDSv2 on the instance and in launch templates
For a running instance, set HttpTokens to required (which disables IMDSv1) and clamp the hop limit to 1. Bake the same settings into every Launch Template so new capacity is born compliant. For ECS on EC2 and EKS managed node groups, set the same metadata options on the node launch template; for tasks that legitimately need a network hop to reach IMDS, raise the hop limit to 2 deliberately rather than leaving it open.
# Harden a running instance: require IMDSv2, hop limit 1, keep endpoint on
aws ec2 modify-instance-metadata-options \
--instance-id i-0abc123def4567890 \
--http-tokens required \
--http-put-response-hop-limit 1 \
--http-endpoint enabled
# Audit the whole region for instances still allowing IMDSv1
aws ec2 describe-instances \
--filters "Name=metadata-options.http-tokens,Values=optional" \
--query "Reservations[].Instances[].InstanceId" \
--output textOrg-wide prevention with an SCP and account defaults
Per-instance fixes do not scale and drift the moment someone launches outside your pipeline. The durable control is a Service Control Policy at the organization (or OU) level that denies ec2:RunInstances unless IMDSv2 is required and the hop limit is constrained, and that blocks anyone from weakening the metadata options on an existing instance. The relevant condition keys are ec2:MetadataHttpTokens, ec2:MetadataHttpPutResponseHopLimit, and ec2:MetadataHttpEndpoint. Pair the SCP with the account-level default that forces new instances to IMDSv2 even when a request omits metadata options: aws ec2 modify-instance-metadata-defaults --http-tokens required --http-put-response-hop-limit 2 --http-endpoint enabled.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyRunInstancesWithoutIMDSv2",
"Effect": "Deny",
"Action": "ec2:RunInstances",
"Resource": "arn:aws:ec2:*:*:instance/*",
"Condition": {
"StringNotEquals": {
"ec2:MetadataHttpTokens": "required"
}
}
},
{
"Sid": "DenyHighHopLimit",
"Effect": "Deny",
"Action": "ec2:RunInstances",
"Resource": "arn:aws:ec2:*:*:instance/*",
"Condition": {
"NumericGreaterThan": {
"ec2:MetadataHttpPutResponseHopLimit": "2"
}
}
},
{
"Sid": "DenyWeakeningMetadataOptions",
"Effect": "Deny",
"Action": "ec2:ModifyInstanceMetadataOptions",
"Resource": "*",
"Condition": {
"StringNotEquals": {
"ec2:MetadataHttpTokens": "required"
}
}
}
]
}Key takeaway: IMDSv1 turns any SSRF into IAM credential theft. Set HttpTokens=required with a hop limit of 1, bake it into every launch template, and enforce it org-wide with an SCP keyed on ec2:MetadataHttpTokens. Prevention is the control; GuardDuty is the safety net, not the plan.
Detection: GuardDuty and a CloudTrail backstop
Even with enforcement, assume some legacy instance slips through. GuardDuty's UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration.OutsideAWS and .InsideAWS findings fire when credentials created for an EC2 instance role are used from an IP address that does not belong to that instance, which is the precise signature of stolen IMDS credentials being replayed from attacker infrastructure. Route these findings through EventBridge to an automated response: revoke the session, isolate the instance with a quarantine security group, and rotate. As an independent backstop, query CloudTrail in CloudWatch Logs Insights for assumed-role sessions whose source IP falls outside your known VPC and NAT ranges.
fields @timestamp, userIdentity.arn, sourceIPAddress, eventName
| filter userIdentity.type = "AssumedRole"
| filter userIdentity.sessionContext.sessionIssuer.userName like /instance/
| filter not (sourceIPAddress like /^10\./
or sourceIPAddress like /^172\.(1[6-9]|2[0-9]|3[01])\./
or sourceIPAddress like /^192\.168\./)
| stats count(*) as calls by userIdentity.arn, sourceIPAddress
| sort calls descOperational rollout without breaking workloads
The reason IMDSv1 lingers is fear of breaking SDK calls. In practice every current AWS SDK and CLI speaks IMDSv2 transparently, so the real risk is old, pinned SDK versions and homegrown metadata clients that hardcode a bare GET. Roll out safely by measuring first: CloudWatch publishes the per-instance MetadataNoToken metric, which counts IMDSv1 calls. Drive that metric to zero on every instance before flipping HttpTokens to required, then enable the SCP in the organization once the fleet is clean. Sequence it as: audit with describe-instances, watch MetadataNoToken, remediate stragglers, set account defaults, then enforce the SCP last so you never lock out a workload you have not already migrated.