DEV Community

Cover image for When Your Own Server Becomes the Attacker
Ali Haider
Ali Haider

Posted on

When Your Own Server Becomes the Attacker

In March 2019, a former Amazon engineer made a request to a Capital One server. Not a login, not a password guess. She asked the server to fetch a URL for her.

The server said yes.

That one request walked out with temporary AWS credentials, and those credentials unlocked the personal data of more than 100 million people. Social security numbers, bank account details, credit applications. One of the largest financial breaches in US history, and at the bottom of it was a bug so ordinary that you have probably written the ingredients for it yourself.

It's called SSRF, and I want to show you exactly how something this mundane turns into something this catastrophic.

The whole idea in one line

SSRF (Server-Side Request Forgery) is when you trick a server into making a request for you, to somewhere you could never reach on your own.

That's it. You don't break in. You get the server to walk in for you.

Think about who you trust

Picture a secure office. You're a stranger in the lobby. The server room, the records vault, the executive offices, all locked, all off-limits. Security won't let you past the front desk.

But the receptionist runs errands. "Could you grab the file from room 237?" Sure. They walk back, fetch it, hand it over.

So you try your luck: "Could you bring me whatever's in the server room?" And because the receptionist has access, and you asked politely, they do.

You never touched the lock. The receptionist did the walking. That receptionist is your server, and SSRF is the art of asking it to fetch things it shouldn't.

The feature that becomes the bug

Here's what makes SSRF sneaky: it almost never looks like a vulnerability. It looks like a feature request.

  • "Let users add a profile picture by URL."
  • "Check stock levels from the warehouse's internal API."
  • "Generate a PDF preview of any link."
  • "Fire a webhook to the customer's endpoint."

Every one of those ends up as some version of this:

const data = await fetch(userControlledUrl);
Enter fullscreen mode Exit fullscreen mode

The developer who wrote that line was picturing a normal URL. An image. A warehouse API. A customer's webhook. What they weren't picturing was someone typing:
http://localhost/admin

And the server, having no opinion about where it's pointed, fetches its own admin panel, the one that's only supposed to be reachable from inside, and hands the attacker the response. The firewall guarding that panel never even got a say, because the request came from inside the house.

When the server starts scanning for you

Reaching the admin panel is just the opening move. The deeper problem is that the server lives inside a private network you can't see from the outside.

So you make it look around.

Point it at 192.168.0.1, then .2, then .3, and watch how the responses differ. A connection that refuses instantly means nothing's there. One that hangs, or answers, means something is. Walk the whole range and the server quietly maps out the internal network for you, which machines exist, which ports are open, where the interesting things live.

You've turned the victim's own server into a scanner aimed at the victim's own network.

The part that ended Capital One

Now the finale, the move that turns "interesting bug" into "front-page breach."

Every server running on AWS, Google Cloud, or Azure can reach a special internal address: 169.254.169.254. It's the metadata service, a little endpoint the machine uses to learn about itself. Handy for the server. Devastating in the wrong hands, because on a misconfigured instance it will also hand over the server's temporary cloud credentials.

So the attacker points the SSRF here:
http://169.254.169.254/latest/meta-data/iam/security-credentials/

and the server, asked politely, returns its own keys to the kingdom. With those, you're no longer poking at one app. You're inside the cloud account.

That's the whole Capital One story in one request: a URL field that trusted its input, a server that could reach the metadata endpoint, and 100 million records out the door.

How to not be the next case study

This is the part most write-ups skip, so here's the version I'd actually want on a code review.

1. Allowlist, don't blocklist.
The instinct is to block the bad addresses. Don't. Attackers will always find a spelling you didn't think of: 127.0.0.1 can be written as 0x7f000001, or 2130706433, or hidden behind a domain that quietly resolves to an internal IP. Blocklists leak. Instead, decide the exact destinations your feature is allowed to hit, and refuse everything else. If the stock checker only ever needs one internal API, that's the only thing it should be able to reach.

2. Check the resolved IP, not the string.
If you must allow user URLs, resolve the hostname first and inspect the actual IP it points to, then block the private ranges (127.x, 169.254.x, 10.x, 192.168.x, 172.16–31.x). A domain like my-innocent-site.com can resolve straight to 169.254.169.254 if the attacker owns the DNS.

3. Kill the exotic schemes.
Allow http and https, nothing else. file:// reads local files. gopher:// can forge raw requests to internal services like Redis. If your feature doesn't need them, they're pure downside.

4. On AWS, enforce IMDSv2.
The newer metadata service requires a session token before it hands anything over, which shuts down the exact move that hit Capital One. It's close to a one-setting fix for the worst version of this bug. Turn it on.

5. Assume one layer fails.
The app server shouldn't be able to reach your sensitive internal systems in the first place. Segment the network so that even a successful SSRF runs into a wall instead of a vault.

One question before you close this tab

Go find the place in your codebase where the server fetches a URL it didn't fully choose. The image importer, the webhook, the link preview, the PDF renderer. There's almost always one.

Now ask it two things: is the destination allowlisted, and can it reach 169.254.169.254?

If you don't like the answers, you've just found the same bug that cost Capital One 100 million records, sitting quietly in your own repo, still looking like a feature.

Top comments (0)