SSRF, Explained: When Your Server Becomes the Attacker's Proxy
Server-side request forgery turns a trusted server into a request-making proxy for the attacker, reaching internal services and cloud credentials the internet should never touch. Here is how it works and how to lock it down.
http://10.0.0.5:8080/adminOn this page
Server-side request forgery (SSRF) happens when an application can be tricked into making HTTP — or other-protocol — requests to a destination the attacker chooses. The request leaves from the server, so it carries the server's network position and trust. Anything the application can reach from inside the perimeter, the attacker can now reach too: internal-only admin panels, databases, and the cloud instance-metadata service that hands out credentials.
That last point is why SSRF earned its own slot in the OWASP Top 10. In cloud environments it is one of the most reliable paths from a single vulnerable parameter to full account compromise. This post walks through how it happens, what an attacker does with it, and how to stop it.
How it happens
The root cause is simple: the application takes a user-supplied URL and fetches it without checking where it points. Think of a feature that generates link previews, imports an image from a URL, or proxies a webhook:
@app.route("/fetch") def fetch(): url = request.args.get("url") resp = requests.get(url) return resp.content
The developer pictured url being https://example.com/logo.png. But nothing forces that. The value is fully attacker-controlled, and the server will dutifully request whatever it is given — including addresses that only exist inside the network.
A concrete attack
On a cloud host, the attacker does not aim at the public internet. They aim inward, at the instance-metadata endpoint that every major cloud exposes on a link-local address:
GET /fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/ HTTP/1.1
Host: app.example
The server connects to 169.254.169.254 from inside the VPC, the metadata service answers because the request came from the instance itself, and the response — temporary IAM access keys — gets handed straight back to the attacker. From there they can act as the application's cloud role.
The same trick reaches internal services. Pointing url at http://127.0.0.1:6379 or http://10.0.0.5:8080/admin lets the attacker probe ports and talk to back-ends that were never meant to face the internet. Naive filters that just block the string 127.0.0.1 are easy to slip past:
GET /fetch?url=http://2130706433/ # decimal form of 127.0.0.1
GET /fetch?url=http://0x7f000001/ # hex form
GET /fetch?url=http://0177.0.0.1/ # octal form
GET /fetch?url=http://[::1]/ # IPv6 loopback
GET /fetch?url=http://[email protected]/ # userinfo trick
The attacker never gained access to the internal network. They borrowed the server's access — every request is made with the server's identity, from inside the firewall.
What an attacker gains
Once the server can be pointed anywhere, the blast radius is large:
- Cloud credential theft — reading IAM tokens from
169.254.169.254and pivoting to the whole cloud account. - Internal service access — reaching admin panels, dashboards and databases bound to localhost or private ranges.
- Port scanning and enumeration — mapping the internal network by watching which addresses respond.
- Protocol smuggling — using
gopher://,dict://orfile://to talk to Redis, hit other TCP services, or read local files like/etc/passwd.
The fix: allowlist destinations, block the internal world
Blocklists lose against the long tail of IP encodings and URL tricks. The reliable approach is to decide up front exactly where the server is allowed to go, and refuse everything else. Resolve the hostname, check the resolved IP against an allowlist (and against blocked link-local and private ranges), and only then make the request:
import ipaddress, socket from urllib.parse import urlparse ALLOWED_HOSTS = {"images.example.com", "cdn.example.com"} def safe_fetch(url): parsed = urlparse(url) if parsed.scheme not in ("https",): raise ValueError("scheme not allowed") if parsed.hostname not in ALLOWED_HOSTS: raise ValueError("host not allowed") resolved = ipaddress.ip_address(socket.gethostbyname(parsed.hostname)) if resolved.is_private or resolved.is_loopback or resolved.is_link_local: raise ValueError("internal address blocked") return requests.get(url, allow_redirects=False, timeout=5).content
Reinforce it with a few more controls: disable URL schemes you do not need (only https survives above, killing file, gopher and dict), do not blindly follow redirects (a 302 to 169.254.169.254 defeats a host check made before the redirect), and require the IMDSv2 token flow so the metadata service is not readable by a plain GET. Where possible, route outbound fetches through an egress proxy that enforces the allowlist for you.
How SelfSec proves this one
Scanner behaviour- Module
- SSRF
- How it probes
- Rewrites URL-bearing parameters toward internal ranges, cloud metadata endpoints and attacker-controlled hosts.
- How it confirms
- A DNS or HTTP callback originating from the target's own infrastructure confirms the blind cases no response body reveals.
Reported as CWE-918 · ATT&CK T1190 · SARIF 2.1.0
A reflected response alone is not enough to promote a SSRF finding — the classical engine has to confirm the behaviour before it reaches the report. See the detection engine
SelfSec is intended strictly for authorized security testing of systems you own or are explicitly permitted to assess.
Do both things about SSRF
SelfSec covers this class from both sides — the scanner confirms it in your own app, the firewall blocks it in front of your origin while the fix ships.