SSRF Demystified: Exploiting Cloud Metadata in a Local AWS Lab
Ethical SSRF reproduction against IMDS using LocalStack, with real payloads, simulated credential theft and definitive mitigation via IMDSv2.
The endpoint 169.254.169.254 has torched more SRE careers than any other IP in cloud history. Capital One in 2019 lost 100 million records because a misconfigured WAF let an SSRF reach the Instance Metadata Service and pull temporary credentials from the EC2 role. Seven years later we still get bug bounty reports with the same pattern: app fetches an image from a user-supplied URL, nobody validates the target, IMDSv1 stays enabled, game over. Lets rebuild that attack from scratch in a local AWS lab using LocalStack, see why IMDSv2 changes the math, and close with a hardening checklist you can ship today.
Environment first. Spin up LocalStack Pro 4.x via docker-compose with ec2, iam, sts and s3 enabled, plus a Flask container exposed on port 5000 running a vulnerable fetch-image API. The code is eighteen lines: receives ?url=, calls requests.get with no validation, returns the body. To simulate IMDS inside LocalStack you need the localstack-ec2-metadata-mock plugin or a dedicated mock bound to 169.254.169.254 via netns. Anyone wanting maximum fidelity uses a real t3.micro lab at two cents per hour, but LocalStack covers 95% of the learning with no credit card. Web Pentesting From Scratch: Building a Safe Lab with DVWA, Juice Shop and Burp Suite covers the base container setup you reuse here.
With the lab live, the classic IMDSv1 payload is literally a GET. From Burp, intercept the app request and swap the url parameter for http://169.254.169.254/latest/meta-data/iam/security-credentials/. The response leaks the attached role name, say app-server-role. Then GET http://169.254.169.254/latest/meta-data/iam/security-credentials/app-server-role returns JSON with AccessKeyId, SecretAccessKey and Token. Export them as environment variables, run aws sts get-caller-identity --endpoint-url http://localhost:4566, and you are authenticated as the application. Thats the moment blue teams jump out of their chairs during the demo.
Escalation depends on what the role can do. In our lab I attached a deliberately permissive policy with s3:* and iam:ListRoles. With stolen creds: aws s3 ls reveals a backups-prod-2026 bucket, aws s3 cp s3://backups-prod-2026/db.dump pulls the dump, and aws iam list-attached-role-policies maps the path to privilege escalation via PassRole. Tools like Pacu, ScoutSuite and cloudfox automate this post-compromise enumeration. Similar patterns show up in REST and GraphQL API Pentest: Technical Checklist for Legal Bug Bounty when webhook or image-proxy endpoints accept internal URLs without an allowlist. Timestamp every command, because a bug bounty report without a reproducible PoC pays zero.
IMDSv2 breaks the attack by requiring a token session. The correct flow is PUT http://169.254.169.254/latest/api/token with header X-aws-ec2-metadata-token-ttl-seconds: 21600, then GET with header X-aws-ec2-metadata-token. An SSRF that only does GET, with no control over headers or method, just stalls. Force IMDSv2 with aws ec2 modify-instance-metadata-options --http-tokens required --http-put-response-hop-limit 1. The hop-limit 1 part is crucial: it stops bridge-network containers from reaching IMDS via the host NAT. Combine with egress block of 169.254.0.0/16 in the security group and the app loses any route to the magic endpoint.
Defense in depth does not end at IMDS. Validate URLs in the app, rejecting 169.254.0.0/16, 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fd00::/8 and resolve the hostname before the request to defeat DNS rebinding. Use a library like ssrf-protect or implement it with getaddrinfo plus per-family IP checks. Trim role permissions to the minimum needed, prefer IRSA on EKS, enable GuardDuty for anomalous credential use, and run scanners like Prowler regularly. Related web techniques live in SQL Injection in Practice: Exploiting, Detecting and Mitigating in a Controlled Lab and Modern XSS: DOM, Stored and Reflected With Real Examples in a Test Lab, which together with SSRF form the most common trio in corporate bug bounty. If you want to dig into internal pivoting after grabbing credentials, Pivoting with Chisel and Ligolo-ng: Segmented Networks in a Pentest Lab is the next stop.
Practical takeaway: run aws ec2 describe-instances --query 'Reservations[].Instances[].[InstanceId,MetadataOptions.HttpTokens]' across your inventory NOW. Any instance returning optional is exposed to the classic SSRF. Force required in bulk via an SSM Automation Document, audit roles with *:* policies, and add an E2E test in CI that tries to reach 169.254.169.254 from the app container and fails the build on a 200 response. It took seventeen minutes to reproduce Capital One in the lab. It takes the same seventeen minutes to close the hole in production.