Introduction
Server-Side Request Forgery (SSRF) allows attackers to make the application server send requests to internal resources. In cloud environments, the most critical target is the instance metadata service (IMDS) at 169.254.169.254, which provides IAM credentials, user data, and instance configuration. If an attacker retrieves IAM temporary credentials via SSRF, they can use those credentials to access cloud resources from outside the compromised instance.
Symptoms
- Application logs show requests to metadata endpoints:
`- 2026-04-09T03:22:15Z GET /api/image?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/ 200
- 2026-04-09T03:22:18Z GET /api/image?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/app-role 200
- 2026-04-09T03:22:22Z GET /api/image?url=http://169.254.169.254/latest/user-data 200
- 2026-04-09T03:22:25Z GET /api/image?url=http://169.254.169.254/latest/dynamic/instance-identity/document 200
`- Or encoded/internal service targets:
`- GET /api/image?url=http://169.254.169.254%2Flatest%2Fmeta-data%2F
- GET /api/proxy?url=http://internal-service:8080/admin
- GET /api/fetch?target=http://10.0.1.50:3306/
`- CloudTrail shows API calls from the instance role credentials at unexpected times:
- ```json
- {
- "userIdentity": {
- "type": "AssumedRole",
- "arn": "arn:aws:sts::123456789:assumed-role/app-role/i-abc123def456"
- },
- "sourceIPAddress": "203.0.113.50"
- }
`
Common Causes
- Application fetches user-provided URLs server-side without validation
- Image proxy, webhook handler, or URL preview feature allows arbitrary URLs
- IMDSv1 (no token required) enabled on EC2 instances
- No egress filtering allowing outbound requests to metadata IP ranges
- Internal services accessible from the application server without authentication
Step-by-Step Fix
Phase 1: Contain and Rotate Credentials
- 1.Immediately rotate the compromised IAM credentials:
- 2.```bash
- 3.# Revoke all sessions for the compromised instance role
- 4.# Force a new IMDS token by stopping/starting the instance
- 5.aws ec2 stop-instances --instance-ids i-abc123def456
- 6.aws ec2 start-instances --instance-ids i-abc123def456
# Or create a new instance profile and attach it aws iam create-instance-profile --instance-profile-name app-role-new aws iam add-role-to-instance-profile \ --instance-profile-name app-role-new \ --role-name app-role aws ec2 replace-iam-instance-profile-association \ --iam-instance-profile Name=app-role-new \ --association-id iip-assoc-abc123 ```
- 1.Identify what the attacker accessed with stolen credentials:
- 2.```bash
- 3.# Search CloudTrail for API calls from the instance
- 4.aws cloudtrail lookup-events \
- 5.--lookup-attributes AttributeKey=AccessKeyId,AttributeValue=ASIA... \
- 6.--start-time "2026-04-09T03:00:00Z" \
- 7.--end-time "2026-04-09T05:00:00Z"
# Check S3 access aws s3api get-bucket-policy --bucket my-bucket aws cloudtrail lookup-events \ --lookup-attributes AttributeKey=EventName,AttributeValue=GetObject
# Check for data exfiltration aws cloudtrail lookup-events \ --lookup-attributes AttributeKey=EventName,AttributeValue=GetObject \ --start-time "2026-04-09T03:00:00Z" ```
- 1.Block metadata endpoint access at the network level:
- 2.```bash
- 3.# Add iptables rule on the instance (blocks IMDSv1 and IMDSv2)
- 4.sudo iptables -A OUTPUT -d 169.254.169.254 -j DROP
- 5.# Exception: only allow IMDS for trusted processes
- 6.# (This is a workaround; proper fix is IMDSv2 requirement)
- 7.
`
Phase 2: Secure the Metadata Service
- 1.Enforce IMDSv2 (requires session token):
- 2.```bash
- 3.# Require IMDSv2 for all instances
- 4.aws ec2 modify-instance-metadata-options \
- 5.--instance-id i-abc123def456 \
- 6.--http-tokens required \
- 7.--http-endpoint enabled
# Apply to all instances in an auto-scaling group aws ec2 modify-instance-metadata-options \ --instance-id $(aws ec2 describe-instances \ --filters "Name=tag:Name,Values=app-server" \ --query "Reservations[].Instances[].InstanceId" --output text) \ --http-tokens required
# Verify the setting aws ec2 describe-instances \ --instance-ids i-abc123def456 \ --query "Reservations[].Instances[].MetadataOptions" # Should show: "HttpTokens": "required" ```
- 1.Test that IMDSv1 is blocked:
- 2.```bash
- 3.# This should fail (IMDSv1 - no token):
- 4.curl http://169.254.169.254/latest/meta-data/iam/security-credentials/
- 5.# Should return: 401 Unauthorized
# This should work (IMDSv2 - with token): TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" \ -H "X-aws-ec2-metadata-token-ttl-seconds: 21600") curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \ http://169.254.169.254/latest/meta-data/iam/security-credentials/ ```
Phase 3: Patch the SSRF Vulnerability
- 1.Fix the URL-fetching code:
- 2.```python
- 3.import ipaddress
- 4.from urllib.parse import urlparse
- 5.import socket
# Block internal and metadata IP ranges BLOCKED_NETWORKS = [ ipaddress.ip_network('169.254.169.254/32'), # AWS IMDS ipaddress.ip_network('10.0.0.0/8'), # Private ipaddress.ip_network('172.16.0.0/12'), # Private ipaddress.ip_network('192.168.0.0/16'), # Private ipaddress.ip_network('127.0.0.0/8'), # Loopback ipaddress.ip_network('0.0.0.0/8'), # Current network ipaddress.ip_network('100.64.0.0/10'), # CGNAT ]
def is_safe_url(url): parsed = urlparse(url) if parsed.scheme not in ('http', 'https'): return False
try: ip = ipaddress.ip_address(socket.gethostbyname(parsed.hostname)) except (socket.gaierror, ValueError): return False
for network in BLOCKED_NETWORKS: if ip in network: return False
return True
# Usage if not is_safe_url(user_url): abort(400, "URL not allowed") response = requests.get(user_url, timeout=5) ```
Prevention
- Require IMDSv2 (
http-tokens required) on all EC2 instances - Block metadata IP ranges at the VPC/network level
- Validate and allowlist URL domains before server-side fetching
- Use egress proxy to restrict outbound traffic from application servers
- Implement least-privilege IAM roles - scope permissions to minimum needed
- Monitor CloudTrail for unusual API calls from instance roles
- Use AWS WAF to block SSRF patterns in request parameters