
One SSRF Bug, Zero Passwords: How a Web App Hands Over Your AWS Keys (and the 1-Line Fix)
Steal your own EC2 role credentials through the metadata service, then flip one setting and watch the attack die. A hands-on IMDSv1 vs IMDSv2 lab with the full SSRF attack chain, org-wide enforcement, GuardDuty detection, and a checklist.
TL;DR: Every EC2 instance can ask a "magic URL" for its own IAM credentials. If a web app has an SSRF bug and the instance still allows IMDSv1, an attacker can make the app fetch that URL and hand over working AWS keys. IMDSv2 breaks that chain. In this lab you'll steal your own keys, flip one setting, and watch the same attack die.
Start here: what's wrong with this URL?
A site has a feature: "Paste an image URL and we'll fetch a preview."
1
https://shop.example.com/preview?url=https://cdn.example.com/cat.png
An attacker changes it to:
1
https://shop.example.com/preview?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/
What comes back?
A) Nothing, that IP isn't routable B) An error page C) The name of the IAM role attached to the server, and one more request away, its access keys D) A 403 from AWS
If you said C, you've just understood one of the most famous cloud attack chains. If you said A, keep reading, because
169.254.169.254 only works from inside the instance, and the vulnerable app is inside the instance.What you'll get
- โ What the metadata service (IMDS) is and why it exists
- โ The full SSRF to stolen-keys attack chain, step by step
- โ A 15-minute lab: steal your own test keys, then block the attack
- โ Why IMDSv2 stops it (and what it does not stop)
- โ How to enforce it across an account, and detect abuse
- โ A checklist and a cheat sheet
๐ชช The mental model: a name tag the server reads off itself
Your EC2 instance needs AWS credentials to call S3, DynamoDB, and so on. AWS doesn't want you to hardcode keys, so it gives the instance an IAM role and a way to ask for temporary credentials.
That "way to ask" is the Instance Metadata Service (IMDS), a local-only HTTP endpoint at
169.254.169.254. Anything running on the box can call it with no password. That's convenient, and that's the problem: "anything running on the box" includes a vulnerable web app that an attacker can steer.| IMDSv1 | IMDSv2 | |
|---|---|---|
| How you call it | Plain GET | PUT to get a session token, then GET with a token header |
| Needs custom headers? | No | Yes |
| Needs a PUT request? | No | Yes |
| Typical SSRF can abuse it? | Yes | Generally no |
๐งจ The attack chain
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Attacker Web app on EC2 IMDS (169.254.169.254)
| | |
| 1. /preview?url=http://169.254.169.254/... |
|------------------------->| |
| | 2. server fetches that URL |
| |----------------------------->|
| | |
| | 3. role name + temp keys |
| |<-----------------------------|
| 4. app returns the keys inside the "preview" |
|<-------------------------| |
| |
| 5. attacker uses the keys from their own laptop |
| aws sts get-caller-identity -> "I am your server" |
The attacker never logged in to anything. The server made the request on their behalf (that's what SSRF means: Server-Side Request Forgery), and the server was trusted by AWS.
The blast radius is whatever the instance's role allows. An over-permissive role plus IMDSv1 is how a single web bug becomes a data breach. This pattern was at the center of the widely discussed 2019 Capital One incident, which is why it's still taught everywhere.
๐งช Lab: steal your own keys, then block the attack
Use a test account. You'll create a small instance, grab its temporary credentials from inside, then enforce IMDSv2. The role has almost no permissions on purpose. It costs a few cents if you terminate the instance when you're done.
Step 1: Create a low-privilege role and instance profile
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export AWS_DEFAULT_REGION=ap-south-1
ROLE=lab-imds-role
aws iam create-role --role-name $ROLE --assume-role-policy-document '{
"Version":"2012-10-17",
"Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]
}'
# Lets us connect with Session Manager (no SSH port, no key pair)
aws iam attach-role-policy --role-name $ROLE \
--policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
aws iam create-instance-profile --instance-profile-name $ROLE
aws iam add-role-to-instance-profile --instance-profile-name $ROLE --role-name $ROLE
sleep 15 # let IAM propagate
Step 2: Launch an instance with IMDSv1 allowed
Amazon Linux 2023 requires IMDSv2 by default, so we have to deliberately weaken it to recreate the vulnerable setup:
1
2
3
4
5
6
7
8
9
10
11
12
AMI=$(aws ssm get-parameter \
--name /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 \
--query Parameter.Value --output text)
IID=$(aws ec2 run-instances --image-id $AMI --instance-type t3.micro \
--iam-instance-profile Name=$ROLE \
--metadata-options HttpTokens=optional,HttpEndpoint=enabled \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=lab-imds}]' \
--query 'Instances[0].InstanceId' --output text)
aws ec2 wait instance-status-ok --instance-ids $IID
echo $IID
This uses your default VPC, which gives the instance a public IP so Session Manager can reach it. If you've deleted your default VPC, launch it in a subnet that has internet access or SSM VPC endpoints.
Step 3: Be the attacker (IMDSv1)
Connect to the instance with Session Manager (EC2 console, select the instance, Connect, Session Manager), or from the CLI if you have the plugin:
aws ssm start-session --target $IID.Inside the instance:
1
2
3
4
5
# 1. What role is attached?
curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/
# 2. Get the credentials for that role
curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/lab-imds-role
You'll see JSON with
AccessKeyId, SecretAccessKey, Token, and Expiration. That's it. No password, no header, one GET. A vulnerable app that fetches arbitrary URLs would hand these over exactly the same way.Prove the keys work from outside. On your own laptop, in a fresh shell, with the values you just saw (these are temporary and expire, but still don't paste them anywhere public):
1
2
3
4
export AWS_ACCESS_KEY_ID=ASIA...
export AWS_SECRET_ACCESS_KEY=...
export AWS_SESSION_TOKEN=...
aws sts get-caller-identity
The output shows the assumed-role ARN of your instance. You're now "the server," from anywhere on the internet. Then close that shell so the variables don't linger.
Step 4: Flip the switch (defender)
From your normal terminal, not the instance:
1
2
aws ec2 modify-instance-metadata-options --instance-id $IID \
--http-tokens required --http-endpoint enabled
No reboot. It takes effect within seconds.
Step 5: Repeat the attack
Back in the Session Manager shell:
1
2
3
# The old way: now fails
curl -s -o /dev/null -w "%{http_code}\n" \
http://169.254.169.254/latest/meta-data/iam/security-credentials/
You get 401. Now the legitimate IMDSv2 way, which your own code and the AWS SDKs do automatically:
1
2
3
4
5
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 60")
curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
http://169.254.169.254/latest/meta-data/iam/security-credentials/
It works again, but only because you made a
PUT and attached a header. A basic SSRF bug that can only trigger a GET to a URL you control can't do either. Same app, same bug, attack dead.๐งน Cleanup
1
2
3
4
5
6
7
8
aws ec2 terminate-instances --instance-ids $IID
aws ec2 wait instance-terminated --instance-ids $IID
aws iam remove-role-from-instance-profile --instance-profile-name $ROLE --role-name $ROLE
aws iam delete-instance-profile --instance-profile-name $ROLE
aws iam detach-role-policy --role-name $ROLE \
--policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
aws iam delete-role --role-name $ROLE
๐ต Why IMDSv2 works (and its limits)
IMDSv2 raises the bar in three ways:
- It needs a
PUT. Most SSRF bugs only let the attacker triggerGETrequests - It needs a custom header. SSRF through a URL parameter usually can't add one
- It rejects requests that look proxied. Requests carrying an
X-Forwarded-Forheader are refused, and the default hop limit of 1 keeps token responses from traveling past the instance
What it does not do:
- If an attacker gets remote code execution on the box, they can just do the
PUTand read the keys. IMDSv2 mitigates SSRF-style abuse, not a fully compromised host - It doesn't fix an over-permissive role. If the role can read every bucket, a stolen session can too
- Containers sometimes need a hop limit of 2 to reach IMDS. Raise it only where you must, and prefer task or pod roles (ECS task roles, IRSA, EKS Pod Identity) over letting containers reach the node's credentials
๐ก๏ธ Enforce it everywhere
Fix existing instances: find the weak ones first
1
2
3
aws ec2 describe-instances \
--query "Reservations[].Instances[]|[?MetadataOptions.HttpTokens=='optional'].[InstanceId,State.Name]" \
--output table
Run it in every region you use. Before enforcing, check that nothing legacy is still calling IMDSv1. The EC2 CloudWatch metric
MetadataNoToken counts calls made without a token. If it's zero for a while, switching is safe.Fix the default for new launches (per region)
1
aws ec2 modify-instance-metadata-defaults --http-tokens required
Block insecure launches at the organization level
Use a policy that denies
ec2:RunInstances unless the request has IMDSv2 required, using the ec2:MetadataHttpTokens condition key, and deny ec2:ModifyInstanceMetadataOptions for everyone except a break-glass role. AWS also offers declarative policies for EC2 metadata defaults at the Organizations level. Check the current docs for the exact syntax before rolling out, since this area changes.Detect abuse
- GuardDuty has findings for stolen instance credentials being used from elsewhere:
UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration.OutsideAWSand...InsideAWS - CloudTrail records
ModifyInstanceMetadataOptions. Alert if someone setsHttpTokensback tooptional, the same way you alerted onCreateAccessKeyearlier in this series - Review role usage: IAM Access Analyzer and last-accessed data help you shrink what a stolen session could ever touch
๐ด Attacker view vs ๐ต Defender view
| What the attacker does | What the defender does |
|---|---|
| Hunts for features that fetch URLs: webhooks, image previews, PDF generators, import-from-URL | Allow-list destinations. Block link-local 169.254.0.0/16 and private ranges in the app |
| Hits 169.254.169.254 through the vulnerable feature | Require IMDSv2, hop limit 1 unless a container needs more |
| Reads role name, then credentials | Least-privilege roles so a stolen session is nearly useless |
| Uses the keys from their own machine | GuardDuty exfiltration findings, CloudTrail anomaly alerts |
| Enumerates what the role can do | Short role sessions, permission boundaries, SCP guardrails |
โ Mistakes that actually cause incidents
- Leaving
HttpTokens=optionalbecause "it's an old instance and nothing broke" - A web-facing instance whose role has
s3:*or admin - Fetch-a-URL features with no destination allow-list
- Raising the hop limit to 2 "to make containers work" on every host
- No alert when someone turns IMDSv2 enforcement off
- Treating IMDSv2 as a replacement for fixing the SSRF bug itself. It's a safety net, not a fix
โ Your IMDSv2 checklist
- [ ] Audit all regions for instances with
HttpTokens=optional - [ ] Check
MetadataNoTokenbefore enforcing, so nothing legacy breaks - [ ] Enforce
requiredon existing instances - [ ] Set the account/region default to
required - [ ] Launch templates and Auto Scaling groups set
HttpTokens=required - [ ] Org-level guardrail denying IMDSv1 launches
- [ ] Roles on internet-facing instances reviewed for least privilege
- [ ] GuardDuty on, plus an alert on
ModifyInstanceMetadataOptions - [ ] Apps that fetch URLs have an allow-list and block link-local ranges
๐ Cheat sheet
| I want to... | Run |
|---|---|
| Find weak instances | describe-instances filtered on HttpTokens=='optional' |
| Enforce on one instance | aws ec2 modify-instance-metadata-options --instance-id ID --http-tokens required |
| Default for new launches | aws ec2 modify-instance-metadata-defaults --http-tokens required |
| Get a v2 token | curl -X PUT .../latest/api/token -H "X-aws-ec2-metadata-token-ttl-seconds: 60" |
| Call IMDSv2 | curl -H "X-aws-ec2-metadata-token: $TOKEN" .../latest/meta-data/... |
| Spot stolen credentials | GuardDuty InstanceCredentialExfiltration findings |
โ ๏ธ Where this falls short
- This lab uses a single instance and a near-empty role. Real incidents depend on how much the role can touch, so the role review matters as much as the setting
- IMDSv2 is not a fix for code execution, only for the common SSRF path
- Always confirm the exact syntax of condition keys and org-level policies in current AWS docs before applying them to production
๐ฌ Your turn
- What status code did Step 5 give you before you used the token? Drop it in the comments
- How many instances in your account still allow IMDSv1? Run the audit and tell us the number (no account IDs please)
- Vote for the next post: 1) IAM role chaining and
AssumeRoleattacks, 2) investigating an incident with CloudTrail, 3) ECS and EKS: stopping containers from reaching node credentials
Found this useful? Save it for your next AWS interview. ๐
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article