← Attack pathsSSRF turns a url fetch into AWS credentials

SSRF turns a url fetch into AWS credentials

$aws ec2 modify-instance-metadata-options --http-tokens required

the admin tool that would fetch anything

somebody built an internal tool that lets admins paste a url and the server fetches it for them. maybe it's for pulling in a webhook payload, checking a link, grabbing an avatar image. handy little feature. except nobody asked "fetch it from where, exactly?" and that question is the whole ballgame.

this is server side request forgery, ssrf for short. the attacker doesn't send the request themselves, they trick your server into sending it. and your server has network access, permissions, and trust that the attacker's laptop never will. that's the entire exploit in one sentence: you're borrowing the server's identity to go somewhere you shouldn't be able to reach.

why the metadata service is the jackpot

every major cloud provider gives instances a way to ask "who am i and what can i do." on aws it lives at a special address, 169.254.169.254. it's not on the public internet, it's not even really "the internet," it's a link local address the instance can reach to fetch its own configuration and, critically, temporary credentials for whatever iam role that instance runs as.

if your url fetcher will hit any address you hand it, an attacker just points it at that address instead of a normal website. the server, doing exactly what it was told, dutifully fetches those credentials and hands them right back in the response. no password guessing, no phishing, just a feature working as designed against a target nobody thought to block.

the bug, in one line

r = requests.get(user_url)

that's it. that's the whole vulnerability. there's no check on what user_url actually resolves to. it trusts the string completely. an attacker doesn't need a fancy payload, they just need to know that address exists and that your app will follow it there.

the fix: check where the url actually goes

the trick attackers use is that a hostname can look innocent but resolve to something internal. so don't trust the hostname, resolve it yourself and inspect the actual ip before you fetch anything:

ip = socket.gethostbyname(urlparse(user_url).hostname)
a = ipaddress.ip_address(ip)
if a.is_private or a.is_link_local:
    abort(400)

walking through it: urlparse pulls the hostname out of whatever url the user submitted. gethostbyname resolves that hostname to an actual ip address, which is the part attackers try to obscure with redirects or weird dns tricks. then ipaddress.ip_address turns that into an object you can actually reason about. the check for is_private catches internal ranges like 10.x and 192.168.x, and is_link_local catches that 169.254.x.x metadata range specifically. if either is true, you refuse the request instead of fetching it.

a couple things worth knowing if you're building this yourself: check the ip after every redirect too, not just the first hop, because attackers will happily set up a public url that 302s straight to the internal one. and resolve right before you connect, not earlier, since dns responses can change between the time you check and the time you fetch.

lock it down at the instance level too

code review catches bugs, but you also want a backstop that doesn't depend on every developer remembering to validate urls forever. aws lets you require a token for anyone talking to the metadata service, which kills off the simple version of this attack even if your app code slips up:

aws ec2 modify-instance-metadata-options \
  --instance-id i-0123456789abcdef0 \
  --http-tokens required \
  --http-endpoint enabled

--http-tokens required forces imdsv2, meaning you can't just get credentials with a plain get request anymore, you need a session token from a put request first, with a hop limit that most simple ssrf proxying can't satisfy. it's not a magic shield, but it raises the bar a lot and it's a five minute change per instance, or one line in your launch template so every future instance gets it by default.

the takeaway

find every place in your stack that fetches a url based on user input: webhooks, image proxies, pdf generators, link previews, anything. audit them for the "does this resolve internally" check. add the resolve and reject logic before you ship the feature, not after someone finds it for you. then go flip every ec2 instance to require imdsv2 as a defense in depth measure, it costs you almost nothing and it turns a full compromise into a dead end. two small changes, one afternoon, and this whole class of bug stops being your problem.

watch the reel ↗
the weekly drop

one command a week that makes you harder to hack.

a single tool, explained in plain english, every week. straight to your inbox.

no spam. one email a week. unsubscribe anytime.