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
- Infosecurity Magazine: Capital One fined $80m over breach
- CBS News: Capital One hack and settlement
- Capital One reaches $190 million settlement
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:
- API7 Server-Side Request Forgery: the rule page, with the non-compliant and compliant shapes per framework.
- Taint analysis: how a source is connected to a sink, and what breaks the connection.
- API10 Unsafe Consumption of APIs: the same fetch, read from the response side.
- What Vulkro does: the product overview.
- Static analysis with no network: running the scanner on a disconnected machine.