Skip to content
Categoria: Pentest8 min read

SSRF Demystified: Exploiting Cloud Metadata in a Local AWS Lab

Por Lucas Andrade ·

Ethical SSRF reproduction against IMDS using LocalStack, with real payloads, simulated credential theft and definitive mitigation via IMDSv2.

SSRF Demystified: Exploiting Cloud Metadata in a Local AWS Lab

The endpoint 169.254.169.254 has torched more SRE careers than any other IP in cloud history. Capital One in 2019 lost roughly 100 million records because a misconfigured WAF let a Server-Side Request Forgery reach the Instance Metadata Service and pull temporary credentials from the EC2 role. Years later we still receive bug-bounty reports with the identical pattern: an app fetches an image from a user-supplied URL, nobody validates the target, IMDSv1 stays enabled, game over. Let us rebuild that attack from scratch in a local AWS lab using LocalStack, understand exactly why IMDSv2 changes the math, and close with a hardening checklist you can ship today. Everything runs on your own machine, so there is no target but your own lab.

What SSRF is, and what the metadata service does

Server-Side Request Forgery is a class of bug where an attacker controls the destination of a request the server makes on their behalf. The application is the confused deputy: it has network position and identity the attacker lacks, and it will happily connect wherever the attacker points it. In AWS, the highest-value internal destination is the Instance Metadata Service (IMDS) at 169.254.169.254, a link-local address every EC2 instance can reach. It exposes instance data and, critically, temporary IAM role credentials under /latest/meta-data/iam/security-credentials/. An SSRF that reaches it turns a harmless image fetch into credential theft, which is why it is the single most consequential target in cloud pentesting.

Threat context: why this keeps happening

The pattern repeats because the vulnerable code looks reasonable. A product needs to fetch a user avatar, render a link preview, or proxy a webhook, so it accepts a URL and calls it server-side. The developer is thinking about images, not about a link-local metadata endpoint that hands out cloud credentials. Meanwhile IMDSv1 remained the default on older instances for years, so the credential vault sat one unvalidated request away. The same shape appears in REST and GraphQL API Pentest: Technical Checklist for Legal Bug Bounty whenever webhook or image-proxy endpoints accept internal URLs without an allowlist. Understanding the pattern is what lets you find it in a target and, more importantly, kill it in your own code.

Building the lab with LocalStack

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 deliberately vulnerable fetch-image API. The vulnerable code is about eighteen lines: it receives ?url=, calls requests.get with no validation, and returns the body. To simulate IMDS inside LocalStack you use the ec2 metadata mock or a dedicated mock bound to 169.254.169.254 via a network namespace. Anyone wanting maximum fidelity uses a real t3.micro at a couple of cents per hour, but LocalStack covers about 95% of the learning with no credit card. The base container setup you reuse here is covered in Web Pentesting From Scratch: Building a Safe Lab with DVWA, Juice Shop and Burp Suite.

Exploiting IMDSv1, step by step

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 request http://169.254.169.254/latest/meta-data/iam/security-credentials/app-server-role and you get JSON containing 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. That is the moment blue teams jump out of their chairs during the demo, because a single unvalidated parameter just became full role identity.

Escalation with stolen credentials

Escalation depends on what the role can do. In our lab we attach a deliberately permissive policy with s3:* and iam:ListRoles. With the 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 so you see the blast radius quickly. Timestamp every command, because a bug-bounty report without a reproducible proof of concept pays zero, and a clean timeline is what turns a finding into a triaged, paid report. 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.

Why IMDSv2 breaks the attack

IMDSv2 breaks the classic attack by requiring a token session. The correct flow is a PUT to http://169.254.169.254/latest/api/token with header X-aws-ec2-metadata-token-ttl-seconds: 21600, then a GET carrying header X-aws-ec2-metadata-token. An SSRF that only performs a GET, with no control over method or headers, simply stalls: it cannot obtain a token, so it cannot read credentials. Force IMDSv2 with aws ec2 modify-instance-metadata-options --http-tokens required --http-put-response-hop-limit 1. The hop-limit of 1 is crucial: it stops bridge-network containers from reaching IMDS via the host NAT, closing a common container-to-metadata path. Combine that with an 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 beyond IMDS

Defense does not end at IMDS. Validate URLs in the application, 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 and fd00::/8, and resolve the hostname before the request to defeat DNS rebinding, then pin that resolved IP for the actual connection. Use a vetted library like ssrf-protect or implement it with getaddrinfo plus per-family IP checks; a naive string blocklist is trivially bypassed with decimal IPs, IPv6-mapped addresses or redirects. Trim role permissions to the minimum needed, prefer IRSA on EKS so pods get scoped short-lived credentials, 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.

Detection and monitoring

Assume prevention will occasionally fail and instrument for detection. Credential theft via IMDS shows a tell: the same IAM role suddenly authenticating from an IP outside your infrastructure. GuardDuty's UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration finding is built exactly for that, so enable it and route it to an on-call channel. At the application layer, log every outbound URL the fetcher resolves and alert on any request whose destination lands in a private or link-local range, because a legitimate avatar fetch never targets 169.254.169.254. Feed those logs into the same pipeline you use for other detections so an SSRF attempt becomes a paged alert rather than a line nobody reads.

Common pitfalls

The recurring mistakes are worth naming. Teams block the literal string 169.254.169.254 and miss 2852039166, its decimal form, or an attacker-controlled hostname that resolves to it. They validate the URL once, then follow redirects that point straight back at the metadata service. They force IMDSv2 on new instances but leave a long tail of old ones on optional. They set hop-limit but forget the egress rule, or the reverse. And they grant the instance role far more than it needs, so even a brief credential leak becomes a full account compromise. Defense in depth exists precisely because any single one of these will fail eventually.

Hardening checklist

Ship these today: run aws ec2 describe-instances --query 'Reservations[].Instances[].[InstanceId,MetadataOptions.HttpTokens]' across your inventory now, and any instance returning optional is exposed to the classic SSRF; force required in bulk via an SSM Automation Document; set hop-limit to 1; add an egress block for 169.254.0.0/16 where feasible; audit roles for *:* policies and trim them; validate and pin outbound URLs in the app with a vetted library; enable GuardDuty and route the exfiltration finding to on-call; 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.

FAQ: Is IMDSv2 alone enough to stop SSRF?

IMDSv2 defeats the specific SSRF-to-credential-theft path when the SSRF is limited to simple GETs, which covers the large majority of real cases. It is not a complete SSRF fix. An attacker who can control request methods and headers, or who pivots to other internal services besides IMDS, still has room to work. Treat IMDSv2 as a mandatory, high-value control, then add URL validation, least-privilege roles and egress restrictions so the metadata service is not your only line of defence.

FAQ: Can I practise this safely without an AWS bill?

Yes. LocalStack Pro gives you EC2, IAM, STS and S3 plus a metadata mock, which reproduces about 95% of the learning with no cloud spend and no risk of touching anything you do not own. Keep the vulnerable Flask app and the metadata mock strictly on a local docker network. When you eventually want maximum fidelity for a specific edge case, a real t3.micro costs a couple of cents per hour, but never point any of this tooling at infrastructure you are not authorised to test.

Conclusion

Practical takeaway: it takes about seventeen minutes to reproduce the Capital One pattern in the lab, and it takes the same seventeen minutes to close the hole in production. Run the inventory query now, force IMDSv2 everywhere, trim the over-permissive roles, validate outbound URLs in your apps, and add the CI test that fails the build if the app container can reach 169.254.169.254. SSRF against cloud metadata is not exotic; it is a default-configuration problem with a well-understood fix. The teams that get breached are not the ones that lacked the knowledge, but the ones that never ran the query.

Related posts

Nenhum comentário ainda

Seja o primeiro a comentar.

Deixe seu comentário

Entre com sua conta Canverly para comentar. Você pode usar a mesma conta em qualquer site da rede.

Entrar com Canverly