Skip to main content

The request your own server makes

Most vulnerability classes are about what an attacker sends you. This one is about what you send on their behalf.

Server-side request forgery is the bug where an attacker chooses a URL and your server fetches it. On a laptop that is an annoyance. On a cloud instance it is a way to ask the host, politely and from an address the host trusts, for the machine's credentials.

What happened

In 2019 an attacker obtained data on roughly 106 million credit-card applicants of Capital One: about 100 million in the United States and 6 million in Canada. The exposed set included around 140,000 US Social Security numbers and 80,000 linked bank-account numbers.

The regulatory outcome was an $80 million civil penalty from the OCC in 2020, at the time the largest cyber penalty that regulator had issued, followed by a $190 million class-action settlement in 2022.

The interesting part is the mechanism, because the component that leaked the credentials was the security appliance.

The chain, step by step

Step one: an outbound request primitive. A misconfigured web application firewall, running as an Apache and ModSecurity reverse proxy in front of the application, could be induced to issue an HTTP request to a URL of the attacker's choosing. Nothing was broken into. The proxy did what proxies do.

Step two: point it at the metadata service. Every major cloud provider exposes an instance metadata service at the link-local address 169.254.169.254. It is how an instance discovers its own configuration, and, critically, how it obtains the temporary credentials for the IAM role attached to it. The path is a plain, memorable URL: http://169.254.169.254/latest/meta-data/iam/security-credentials/.

Step three: the version of the metadata service that answers anything. Under IMDSv1, that endpoint replies to an ordinary unauthenticated HTTP GET. No token, no header, no signature. If you can make a request from the instance, you get the instance's credentials. The proxy had no rule denying requests to the link-local range, so the SSRF primitive became a credential-disclosure primitive with one hop.

Step four: the role was wider than the job. The IAM role attached to the proxy tier held list and get permissions across S3. With those temporary credentials the attacker enumerated and downloaded from more than 700 buckets.

Four steps, and only the first of them is what most people would call a vulnerability. The other three are configuration decisions that each looked defensible in isolation.

Why it shipped

Fetching a URL is the feature. Unlike a buffer overflow, SSRF has no "correct" version of the code that is obviously different from the broken version. Webhook delivery, link preview generation, "import from URL", PDF rendering, image resizing from a remote source, SAML and OIDC metadata fetching, health checks, and every reverse proxy in existence are all built on "take an address and go get it". The vulnerability is a property of where the address came from, which is exactly the property that is invisible at the call site.

The dangerous destination never appears in the source. Grep your codebase for 169.254.169.254. It is not there. It is not in your dependencies either. It exists only in the runtime environment, so no amount of reading the application code will show you the sink. This is why SSRF is so consistently underestimated in review: the exploit's most important constant is supplied by the platform.

Blocklists feel like a fix and are not. Anyone who has tried to write a URL validator has discovered how many ways there are to name the same host. The address can be given in decimal (2852039166), in octal, in hex, as an IPv6-mapped form, or via a DNS name that resolves to a link-local address. A permitted host can answer with a 302 redirect to a forbidden one. A name can resolve to a safe address when you validate it and a hostile one a millisecond later when your HTTP client resolves it again, which is the DNS-rebinding variant of a time-of-check to time-of-use race. Every one of those defeats a naive string check.

Least privilege loses arguments quietly. The proxy role's S3 permissions were the difference between a leaked credential and a 106-million-record breach. Broad permissions on infrastructure components are the path of least resistance, and the cost of that decision is only ever visible in retrospect.

The class of flaw

SSRF is its own category in the OWASP Top 10 (A10:2021), which is unusual, because the list normally groups by mechanism rather than by named bug. It got its own slot precisely because of the cloud dynamic above: the impact of an outbound request primitive changed completely once every server started carrying an ambient identity that a local HTTP request could unwrap.

There is a more general lesson underneath, and it is worth separating from the cloud specifics. Your server has network position that your users do not. It can reach the internal network, the admin interfaces bound to loopback, the databases with no external route, the service mesh, the metadata endpoint. Any bug that lets an outsider borrow that network position converts your internal topology into their attack surface. The credential theft is one instance of that. Internal port scanning, reaching an unauthenticated admin API on localhost, and pulling files with a file:// scheme are others.

What to check in your own codebase

Find every outbound fetch that takes a caller-influenced address. Not just full URLs. A hostname, a path fragment appended to a base URL, a bucket name, a region, a callback address stored on a record months ago by a different user, a value from a webhook registration form. Anything an outsider can steer.

Resolve first, check the resolved address, then connect to that address. Validating a hostname and then handing the hostname to your HTTP client leaves the rebinding race open. The robust pattern is to resolve the name yourself, reject the resulting IP if it falls in any private, loopback, link-local, multicast, or reserved range, and then connect to the IP you validated while passing the original hostname for TLS and Host purposes.

Do not follow redirects, or re-run the whole check on every hop. A redirect is a second request to a second address, and if you only validated the first one you validated nothing.

Allowlist the scheme. Permit https (and http if you must). Refuse file, gopher, ftp, dict, and anything else your HTTP library will cheerfully accept.

Prefer an allowlist of hosts over a blocklist of addresses. Where the set of legitimate destinations is knowable, which is more often than teams assume, enumerate it. You cannot enumerate every encoding of every internal address, but you can usually enumerate the four partners you actually call.

Require the metadata service token handshake. IMDSv2 makes the caller do a PUT to obtain a short-lived token before any read, and it lets you set a response hop limit so a request forwarded by a proxy or a container network is rejected. Turn IMDSv1 off rather than leaving both enabled, because an endpoint that still answers the old way is still a credential endpoint.

Give outbound-fetching components their own identity, and make it small. The question to ask about any service that fetches URLs is not "is our SSRF protection good" but "if this component's credentials leaked right now, what could the holder read". Scope the role to named resources. Separate the identity of the component that talks to the internet from the identity of the component that reads customer data.

Egress-filter the link-local range at the network layer. Application-level checks are the primary control, but a host or sidecar rule that refuses outbound traffic to 169.254.0.0/16 from workloads that have no reason to reach it costs nothing and catches the cases your validator missed.

Test with a redirect, not just a blocked URL. The standard regression test for an SSRF fix is a permitted host that returns a 302 to 169.254.169.254. If your fix passes that, it is a real fix.

Where the record is thin

The chain above is the widely documented account: an outbound request from a misconfigured reverse-proxy WAF, the metadata endpoint, temporary IAM credentials, and an over-broad S3 role. The precise proxy configuration that made the first step possible was never published in full, so treat the exact rule as unknown rather than reconstructing it. The victim counts, the penalty and the settlement are on the public record and are cited above.

We also want to be careful with the framing. It is tempting to tell this as a story about a firewall failing. The firewall did not fail. It processed a request, and its own privileges were the payload. That is the uncomfortable part, and blurring it into "the perimeter was weak" loses the entire lesson.

Sources


Where Vulkro fits

Vulkro traces caller-influenced values through the call graph into outbound HTTP clients and reports the fetches that reach the network with no resolved address check in between. The analysis runs on your own machine: no source code and no findings leave it. What it cannot see is your runtime environment, so the metadata-service configuration and the IAM role scope stay your job.

Read next: