Server-side request forgery is the vulnerability that the cloud era took and turned into a credential-theft primitive. The mechanism is dull (the server fetches a URL chosen by the user), the consequences are not (one curl from a vulnerable image-resizer to http://169.254.169.254/ and the attacker walks out with the instance's IAM role). It is catalogued as CWE-918 and ranked A10 in the OWASP Top 10 2021, where it was added on the strength of community survey signal rather than scanner data, because most SSRF lives in the long tail of bespoke "fetch this URL" features that no generic scanner covers.
This is the deep-dive companion to the web application security vulnerabilities taxonomy. I cover the mechanism, the breach that made every security team care about it, every major variant with a working exploit against the ssrf-basic lab in the techearl-labs repo, and then turn around and cover the defences (IMDSv2, allowlists done right, egress firewalling) in equal depth. The tools that automate the discovery side live in the best SSRF tools for 2026 listicle.
What is SSRF?
Server-side request forgery is what happens when an application takes a URL (or any value that gets used as part of a URL) from the user and then has its own backend make that request. The attacker does not get to make the request directly; the server makes it on the attacker's behalf, from inside whatever network position the server occupies. That is the entire shape of the bug, and it is also the reason it matters: the server's network position is almost always more privileged than the attacker's.
The canonical vulnerable code, in PHP, is:
$url = $_GET['url'];
echo file_get_contents($url);A normal request is ?url=https://example.com/feed.xml. An attacker request is ?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/, which on an EC2 instance running IMDSv1 returns the names of the IAM roles attached to the instance. A second request peels off the actual credentials. From there the attacker has whatever the role was permitted to do.
SSRF is distinct from two siblings it often gets confused with. Open redirect changes where the user's browser navigates after a redirect (a phishing aid, not a server-side primitive). Remote file inclusion is SSRF's older cousin where the fetched content is then executed as code (include($_GET['url'])); SSRF stops at the fetch, which is enough on its own once the target is a metadata service or an internal admin panel.
The Capital One breach: the canonical SSRF case study
The reason every cloud security team takes SSRF seriously is one breach in 2019. A former AWS engineer, Paige Thompson, used an SSRF in a misconfigured ModSecurity web application firewall running on an EC2 instance in Capital One's infrastructure to reach the EC2 instance metadata service version 1. The metadata endpoint returned the temporary IAM credentials attached to the instance. With those credentials, Thompson listed and exfiltrated S3 buckets containing data on roughly 106 million Capital One customers and applicants (about 100 million in the US, 6 million in Canada). Capital One disclosed the breach on July 29, 2019. Thompson was indicted under the Computer Fraud and Abuse Act, convicted in 2022, and sentenced in 2022 to time served plus five years of probation.
The mechanics are worth pinning down because they are the template every cloud-era SSRF follows:
- Public-facing service makes outbound HTTP requests on user input. In Capital One's case, a WAF that was processing request bodies.
- The service runs on EC2 with an IAM role attached and IMDSv1 enabled.
- The attacker discovers the SSRF and points the URL at
http://169.254.169.254/latest/meta-data/iam/security-credentials/<role-name>. IMDSv1 happily returns the role's temporaryAccessKeyId,SecretAccessKey, andToken. - The attacker uses those credentials from anywhere on the internet (the token is bearer-only; IMDSv1 did not bind them to the instance).
- Whatever the role was permitted to do, the attacker can now do. In this case, list and read S3 buckets.
AWS released IMDSv2 on November 19, 2019 (about three months after the Capital One disclosure) specifically to break this attack chain. New EC2 launches default to IMDSv2-required on most modern AMIs, but instances launched before that date, or with explicit IMDSv1 configuration, are still out there. Greenfield is mostly safe; brownfield is not.
SSRF variants
Variant axes for SSRF are: does the response come back to the attacker (basic vs blind), is the attacker hitting a metadata endpoint or an internal service, and does the bypass live in URL parsing or DNS resolution. Pick one from each axis and you have a real attack class.
Basic SSRF (response reflected to attacker)
The simplest case. The server fetches the URL and includes the response body in its own response to the attacker. The PHP file_get_contents example above is this. The attacker reads everything the server reads. Useful for hitting internal admin panels, internal HTTP APIs, and anything else inside the perimeter that does not expect to receive attacker-shaped requests.
Blind SSRF (no response body, infer via side channels)
The server fetches the URL but discards the body. Maybe it only logs the HTTP status code, maybe it just notes whether the fetch succeeded. The attacker reads nothing directly and has to infer state via:
- Response timing. A reachable port returns connection-refused or 200 quickly. A non-routable address hangs until the connect timeout. The difference is the oracle.
- Status-code differences. A reachable HTTP service returns 200 / 401 / 404; an unroutable address returns the application's "fetch failed" path.
- Out-of-band DNS. Point the URL at
something.attacker.example, watch your authoritative DNS for the lookup, confirm the server actually tried to resolve it. Burp Collaborator andinteractshautomate the listener.
Blind SSRF maps internal networks one port at a time. Slow, scriptable, and what most modern SSRF actually looks like, because frameworks have mostly stopped echoing fetch responses verbatim.
DNS rebinding (TOCTOU between validation and connection)
The most elegant SSRF variant and the one allowlist authors keep missing. The attacker registers a hostname under their control with a very short TTL DNS record. On the first lookup, the record points at an innocuous public IP (passes the allowlist). On the second lookup, milliseconds later, it points at 127.0.0.1 or 169.254.169.254 or an internal RFC1918 address. The application validates the URL, sees the safe IP, then re-resolves at connection time and connects to the dangerous IP. The window between check and use is the bug.
The fix is not "block private IPs in the input" (the input does not contain a private IP at validation time). The fix is to resolve the hostname once, validate the resolved IP, and then connect by IP not by hostname. Or to use an HTTP client that pins the resolved address. More on this in the defences section.
Cloud metadata theft (AWS / GCP / Azure)
Every major cloud provider exposes an instance metadata service on the link-local address 169.254.169.254. Each works slightly differently:
| Provider | URL | Auth | Notable |
|---|---|---|---|
| AWS IMDSv1 | http://169.254.169.254/latest/meta-data/ | None | Legacy. Vulnerable to plain SSRF. |
| AWS IMDSv2 | http://169.254.169.254/latest/meta-data/ | Token from PUT /latest/api/token with X-aws-ec2-metadata-token-ttl-seconds | Requires PUT + custom header; defeats most SSRF. |
| GCP | http://metadata.google.internal/computeMetadata/v1/ | Required header Metadata-Flavor: Google | Custom header defeats SSRF that only forwards GET. |
| Azure | http://169.254.169.254/metadata/instance?api-version=... | Required header Metadata: true | Same idea as GCP. |
The pattern across the post-Capital-One designs is identical: require something an SSRF cannot easily forge. AWS chose PUT + a header. GCP and Azure chose a custom request header. All three defeat the naive GET arbitrary URL SSRF primitive. They do not defeat an SSRF that lets the attacker control the HTTP method or arbitrary headers, which is a separate (and rarer) failure mode.
SSRF via wrapper schemes (gopher://, file://, dict://, ftp://)
If the HTTP client is libcurl with all protocols enabled (the default in many language bindings), the attacker is not limited to http://. The interesting wrappers:
file:///etc/passwd,file:///proc/self/environ. Read arbitrary files on the server.gopher://internal-redis:6379/_INFO%0d%0a. Speak raw TCP to anything the server can reach. The classic Redis-via-gopher exploit writes SSH keys, schedules cron jobs, or runs Lua scripts.dict://internal:11211/stats. Same trick against memcached or any other line-protocol service.ftp://. Sometimes useful for SMTP or other protocols by abusing CRLF in the URL.
The fix is to restrict the scheme to http and https explicitly, then to disallow the rest. Curl's CURLOPT_PROTOCOLS_STR (or CURLOPT_PROTOCOLS on older versions) sets this per handle. Most language wrappers expose it.
SSRF in 2026: tool-call orchestrators
The newest SSRF surface is the tool-calling pattern in autonomous coding and browsing systems. A user (or a third-party document the system was told to "read") supplies a URL, and the orchestrator's fetch_url tool retrieves it. From the orchestrator's network position. With whatever credentials the orchestrator carries. This is just SSRF with a new front door, but the front door is wide: a prompt-injection payload in a fetched page can rewrite the next URL the tool is asked to fetch, including a metadata IP. Defences are the same as the rest of this article: scheme allowlist, DNS-pinned client, default-deny egress, IMDSv2-required hop-limit-1 on every host the orchestrator runs on.
Walk a working SSRF chain (the lab)
The ssrf-basic lab in the techearl-labs repo is a small PHP app with three vulnerable endpoints and two internal-only backend services (a mock admin panel and a mock AWS IMDSv1) on the Docker network. Bring it up with:
docker compose up ssrf-basic ssrf-basic-internal ssrf-basic-metadataThe app listens on http://localhost:8082. The two backend services have no published ports on purpose; the only way to reach them is to route through the SSRF.
Exploit 1: basic SSRF to internal service
The vulnerable endpoint is /fetch.php, which passes $_GET['url'] straight to file_get_contents:
curl 'http://localhost:8082/fetch.php?url=http://ssrf-basic-internal/'The response is the HTML of the internal admin panel. From the public-facing app's perspective the perimeter is intact; from the attacker's perspective the perimeter does not exist, because the perimeter does not apply to outbound requests originating inside the network.
Exploit 2: cloud-metadata theft (AWS IMDSv1 equivalent)
The same /fetch.php endpoint, pointed at the mock metadata service:
curl 'http://localhost:8082/fetch.php?url=http://ssrf-basic-metadata/latest/meta-data/iam/security-credentials/role-name'The response is a JSON blob containing AKIAIOSFODNN7EXAMPLE (the documented AWS example access key, not a real one). In production this URL is http://169.254.169.254/latest/meta-data/iam/security-credentials/<role-name>; the lab uses a Docker hostname because containers cannot bind to link-local addresses. The lesson is the same. On an EC2 instance with IMDSv1 enabled, this one HTTP request is the end of the engagement.
Exploit 3: naive allowlist bypass (userinfo trick)
The "better" endpoint is /fetch-allowlist.php, which checks parse_url($url)['host'] against ['example.com', 'api.example.com'] before fetching. Looks safe. Is not:
curl 'http://localhost:8082/fetch-allowlist.php?url=http://example.com@ssrf-basic-internal/'The URL has the form http://user@host/. PHP's parse_url reports the host as example.com (the segment before @, which is the userinfo segment). file_get_contents then makes the request, and the HTTP authority is actually ssrf-basic-internal (the segment after @). The allowlist passes; the fetch lands on the internal target. Substring matching against parse_url output is a recurring own-goal in real codebases.
Exploit 4: blind SSRF via response-time inference
The third endpoint, /fetch-blind.php, fetches the URL with a 5-second timeout and returns only "OK" or "Timeout" with no body. No response leakage. Still mappable, via timing:
# Reachable internal host: returns "OK" in a few milliseconds
time curl 'http://localhost:8082/fetch-blind.php?url=http://ssrf-basic-internal/'
# Non-routable address: hangs until the 5-second timeout fires
time curl 'http://localhost:8082/fetch-blind.php?url=http://10.255.255.1/'Two distinguishable outcomes. The fast one means the destination accepted the connection; the slow one means it dropped or did not exist. Sweep an internal IP range and a port list against that oracle and you have a network map without ever seeing a response body.
Modern defences
The defences that work are layered. None of them on their own is enough; together they make the bug expensive to exploit even when the application code makes the original mistake.
IMDSv2 with a hop limit of 1
The single highest-impact change for any AWS workload. IMDSv2 requires the caller to first PUT to /latest/api/token with a X-aws-ec2-metadata-token-ttl-seconds header to retrieve a session token, and then to send that token in a X-aws-ec2-metadata-token header on every subsequent metadata request. An SSRF that only forwards a GET URL cannot satisfy either requirement.
The hop-limit setting matters too. Set HttpPutResponseHopLimit to 1 and the IMDS responses cannot be forwarded out of the instance (a container running on the host with NAT would otherwise see a hop count of 2). This blocks the container-escape variant of metadata theft.
Enforce IMDSv2 with a token TTL and hop limit at launch:
aws ec2 modify-instance-metadata-options \
--instance-id i-0123456789abcdef0 \
--http-tokens required \
--http-put-response-hop-limit 1 \
--http-endpoint enabledOr in Terraform:
resource "aws_instance" "web" {
# ...
metadata_options {
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
}
}
For organisation-wide enforcement, use a Service Control Policy that denies launching instances without http_tokens = required. Greenfield is then safe by construction; you only have to remediate brownfield.
Allowlists done right
The naive allowlist is parse_url($url)['host'] in approved_hosts. As exploit 3 above showed, that fails on the userinfo trick. The robust pattern is:
- Parse the URL into its components. Reject anything that does not parse cleanly.
- Reject any URL with a userinfo segment. No legitimate fetch needs
user@host. - Reject schemes other than
httpandhttps. Nofile://, nogopher://, nodict://. - Resolve the hostname to an IP yourself, once. Use the standard resolver.
- Validate the resolved IP against a deny-list of internal ranges. All of RFC1918, RFC4193, link-local (
169.254.0.0/16), loopback (127.0.0.0/8,::1), broadcast, IPv6 multicast, and any internal VPC ranges your environment uses. - Validate the resolved hostname against an allowlist of permitted external hosts (if the use case allows). Free-form outbound is rarely necessary.
- Make the actual request using an HTTP client pinned to the IP you validated. Do not let the client re-resolve. This is what closes the DNS-rebinding window.
In Python, the IP-pinning step looks like passing a custom socket.create_connection to urllib3's pool, or using a library like requests-ip-rotator or httpx with a transport that takes a pre-resolved IP. In Node.js, set the lookup option on the HTTP request (or on the connection pool) to a function that returns the IP you already validated. In Go, set Resolver and the Dialer.Control callback. The point is that the IP you validated is the IP you connect to, not whatever DNS returns at connection time.
Egress firewalling
The cheapest defence and the one most teams skip. Public-facing application servers should not be able to make arbitrary outbound HTTP requests. Concretely:
- Default-deny outbound on the application's security group / NACL. Allow only the destinations the app actually needs (payment processor, S3, logging endpoint). Everything else, including the metadata IP, gets dropped at the network.
- Block
169.254.169.254explicitly from any workload that does not need it. On Linux, aniptablesrule on the host or a network policy in Kubernetes. In AWS, a security group rule denying169.254.169.254/32. Note: this only helps if the app is not the IMDS client itself; for SDK calls you need IMDSv2-required instead. - Separate VPC for outbound fetches. If the app legitimately fetches arbitrary URLs (image proxy, link unfurler, RSS reader), run that subsystem in a separate VPC with no IAM role, no access to internal services, and no metadata endpoint reachable. The blast radius of an SSRF in that subsystem is then the public internet, which is the same blast radius an attacker already has.
- Network namespaces or sidecar proxies for per-process egress policy. Useful when the same host runs multiple services with different egress needs.
The combination of "IMDSv2 required" and "default-deny egress" reduces the cloud-metadata SSRF risk to essentially zero, even on application code that still has the original bug.
HTTP client configuration
The HTTP client itself should be configured to refuse the dangerous protocols. For libcurl-based clients:
curl_easy_setopt(handle, CURLOPT_PROTOCOLS_STR, "http,https");
curl_easy_setopt(handle, CURLOPT_REDIR_PROTOCOLS_STR, "http,https");Same idea in language wrappers: Python pycurl exposes it; Node.js node-fetch and undici only support http/https by default; PHP curl exposes CURLOPT_PROTOCOLS. Disable redirects entirely if the application does not need them, or follow at most one redirect and re-validate the destination after each hop.
Real-world incidents
The pattern repeats. A handful of representative cases:
- Capital One (2019), covered above. SSRF in a misconfigured ModSecurity WAF on EC2, IMDSv1 credential theft, ~106M records.
- CVE-2021-26855 (Microsoft Exchange ProxyLogon). Pre-auth SSRF in Exchange Server that let an attacker reach internal Exchange endpoints with admin privilege. Mass-exploited in early 2021, chained with three other CVEs into full RCE on tens of thousands of Exchange servers. Not a cloud-metadata case, but the same primitive (server makes a request the attacker chose, the destination trusts the source).
- CVE-2021-22214 (GitLab). Unauthenticated SSRF in GitLab CI/webhooks reaching internal services. The bug let unauthenticated attackers ask a GitLab instance to fetch arbitrary URLs from the GitLab server's network position. Patched in GitLab 13.10.5, 13.11.5, and 13.12.2.
- CVE-2019-3396 (Confluence Server "Widget Connector"). Path traversal and SSRF in the Widget Connector macro in pre-6.14.2 Confluence. Used in the wild to drop cryptominers and webshells on self-hosted Confluence instances.
Each of these is the same shape: a feature whose job is "fetch a URL", input control by the attacker, and a target reachable from the server that is not reachable from the internet. The fix in every case was a combination of input validation and tightening the network's outbound reach.
Where to go next
- The best SSRF tools for 2026 listicle compares the SSRF-specific scanners and intruders.
- The SQL injection deep dive is the sister spoke for the most-exploited injection class, with the same lab-driven structure as this one.
- The cross-site scripting deep dive covers the other half of the injection family, on the client side.
- Back up to the web application security vulnerabilities taxonomy for the full map of attack classes.
Sources
Authoritative references this article was fact-checked against.
- OWASP Top 10 2021, A10 SSRFowasp.org
- AWS, Instance metadata service (IMDSv2)docs.aws.amazon.com
- PortSwigger Web Security Academy, SSRFportswigger.net
- CWE-918: Server-Side Request Forgery (SSRF)cwe.mitre.org
- 2019 Capital One data breachen.wikipedia.org





