Introduction
Server-Side Request Forgery (SSRF) is often thought of as a way to retrieve internal resources. A more powerful, yet under-exploited, use-case is port scanning - probing the internal network for open services by observing how the vulnerable server reacts to crafted requests. When a request targets a closed port, many HTTP clients (or the underlying library) emit a distinct error message, status code, or latency that can be distinguished from a successful connection.
Why does this matter? Internal services that are not exposed to the Internet are still valuable footholds. Knowing which ports are open lets an attacker pivot, enumerate databases, or chain SSRF with other vulnerabilities (e.g., deserialization, RCE). Real-world bug-bounty reports (e.g., on Netflix, Atlassian, and several SaaS platforms) have demonstrated that attackers can map entire internal networks using only a single SSRF endpoint.
Prerequisites
- Fundamentals of HTTP and URL structures
- Basic networking concepts (IP, ports, TCP handshake)
- Understanding of SSRF fundamentals and common vectors
- Familiarity with command-line tools (curl, netcat) and a scripting language such as Python
Core Concepts
At its core, an SSRF-based port scan works like this:
- The vulnerable application accepts a URL (or similar) from the attacker.
- The backend library (e.g.,
requests,urllib3,java.net.HttpURLConnection) attempts to open a TCP connection to the supplied host/port. - If the port is open, the library receives an HTTP response (or at least a TCP ACK) and forwards it to the attacker.
- If the port is closed, the library throws an exception or returns a distinct HTTP status (often
502 Bad Gatewayor504 Gateway Timeout) and may include the underlying error text in the response body.
By correlating these differencesâstatus code, response body content, and roundâtrip timeâwe can infer the state of the target port.
Two practical observations are key:
- ErrorâMessage Leakage: Some frameworks forward the exact socket error (e.g., "Connection refused", "Connection timed out") back to the client.
- Timing SideâChannels: A connection to a listening service typically returns faster than a timeout, especially when the remote host silently drops packets (e.g., filtered ports).
Crafting SSRF payloads that trigger error responses for closed ports
The first step is to build a URL that forces the backend to connect to a specific IP and port. Most SSRF filters only allow http and https schemes, but they often ignore the port component.
Typical payload format:
http://127.0.0.1:3306/
To increase reliability:
- Append a trailing slash or a dummy path (e.g.,
/health) to force a full HTTP request. - For services that do not speak HTTP (MySQL, Redis), the request will still cause a TCP handshake; the library will abort and surface an error.
When the target service is closed, many HTTP clients emit the following patterns:
502 Bad Gateway
Connection refused
or
504 Gateway Timeout
connect timeout
These strings can be captured by the attacker if the application reflects the response body (common in imageâproxy or URLâpreview features).
Interpreting HTTP status codes, response bodies, and timing differences
Three orthogonal signals can be combined into a reliable scanner:
| Signal | Open Port | Closed/Filtered Port |
|---|---|---|
| Status Code | 200-299 (or 301/302 if redirected) | 502, 504, 500 (often with error text) |
| Body Content | HTML/JSON from the service (or empty for nonâHTTP) | "Connection refused", "timed out", or generic error page |
| Latency | <200âŻms on localhost, <500âŻms on LAN | >1âŻs for timeout, or ~0âŻms for immediate RST (refused) |
By logging these three values for each port, a simple decision matrix can be built:
def classify(status, body, rtt): if 200 <= status < 300: return "open" if "Connection refused" in body: return "closed" if rtt > 1.0: return "filtered" return "unknown"
In practice, you will see noisy results for services that close the connection after a TLS handshake, so always corroborate with at least two signals.
Using curl, Burp Suite, and custom Python scripts (requests/urllib3) for automated port scans
Below are three common automation approaches.
1. curl with timeâmeasure
for port in {1..1024}; do start=$(date +%s%3N) resp=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:${port}/" 2>&1) end=$(date +%s%3N) rtt=$((end-start)) echo "${port} ${resp} ${rtt}ms"
done
This oneâliner prints the port, HTTP code, and roundâtrip time. The -s flag silences progress, and -w "%{http_code}" extracts the status.
2. Burp Suite - Repeater + Intruder
Burpâs Repeater can be used to manually test a single port, while Intruder automates the enumeration:
- Capture a request that the vulnerable endpoint forwards (e.g.,
GET /fetch?url=...). - Place
${payload}where the port number lives. - Configure Intruder payload type to Numbers (1â65535).
- Enable GrepâMatch for strings like "Connection refused" and status codes 502/504.
Burp will then generate a report with each payload and the matching grep results, making it easy to spot open ports.
3. Python - requests + urllib3
A reusable script that iterates over a port list, captures status, body, and timing, and prints a concise table.
import time, sys
import urllib3
urllib3.disable_warnings()
http = urllib3.PoolManager(timeout=urllib3.Timeout(connect=2.0, read=2.0))
def scan(host, ports): for p in ports: url = f"http://{host}:{p}/" start = time.time() try: r = http.request('GET', url, preload_content=False, redirect=False) status = r.status body = r.data[:200].decode(errors='ignore') if r.data else '' except urllib3.exceptions.HTTPError as e: status = 0 body = str(e) rtt = round((time.time() - start) * 1000, 1) print(f"{p}{status}{rtt}ms{body[:30].replace('', ' ')}")
if __name__ == '__main__': host = sys.argv[1] if len(sys.argv) > 1 else '127.0.0.1' ports = range(1, 1025) scan(host, ports)
The script treats any exception as a closed/filtered result (status 0) and prints the first 30 characters of the error message for quick visual analysis.
Bypassing simple filters with URL encoding, doubleâencoding, and protocol smuggling
Many applications attempt to block nonâHTTP ports by checking the URL string for a colon after the host. Simple filters can be fooled with encoding tricks.
- Percentâencoding the colon:
%3Aâ the backend URL parser usually decodes before connecting. - Doubleâencoding:
%253Aâ the first decode yields%3A, the second yields:. - Protocolâsmuggling â using a supported scheme that forwards to another protocol, e.g.,
gopher://127.0.0.1:11211/_(if the server supports gopher) orredis://127.0.0.1:6379for Redis, where the library still opens a raw TCP socket.
When the target application normalises the URL using a library like java.net.URI or Pythonâs urllib.parse, doubleâencoding often survives the first sanitisation pass and is only decoded at the socket layer, allowing the attacker to reach any port.
Enumerating internal services (HTTP, SMB, MySQL, Redis) via SSRF
Once you have a reliable portâscan primitive, you can pivot to serviceâspecific enumeration.
HTTP
Open web servers return HTML, headers, or even custom API JSON. Use the response body to fingerprint the software (e.g., Server: Apache vs nginx).
SMB (port 445)
SMB does not speak HTTP, but a TCP connection attempt will still cause the backend library to raise a Connection reset or Timeout. The presence of a quick reset (often RST) indicates an SMB listener. Tools like smbclient can be chained after you have a reachable IP.
MySQL (port 3306)
When the request hits MySQL, the server sends its protocol handshake packet. Some SSRF implementations forward the raw bytes back as a base64âencoded string or as binary data in the HTTP response body. Detect the classic MySQL handshake signature (0a 00 00 01 0a etc.) and you know the port is MySQL.
Redis (port 6379)
Redis also replies with a plainâtext +PONG or -ERR after a INFO command. By appending ?cmd=INFO to the SSRF URL (if the vulnerable endpoint forwards query strings) you can retrieve the service banner directly.
<img src="http://internal-service:6379/INFO">
Because the <img> tag is escaped, the browser will attempt to load the image, the backend will contact Redis, and the error message (or PONG) will be reflected in the HTML source.
Practical Examples
Example 1 - Scanning a corporate LAN from a vulnerable imageâproxy
Assume an endpoint /proxy?url= that fetches any URL and returns the body. An attacker wants to discover which internal services are reachable.
- Craft a payload list with ports 22, 80, 443, 3306, 6379, 445.
- Encode each as
http%3A%2F%2F10.10.10.5%3A{port}%2Fand send via Burp Intruder. - Collect responses: 200 on 80/443 (web), 502 with "Connection refused" on 22 (closed), 200 with Redis handshake on 6379, etc.
Example 2 - Using Python to enumerate MySQL banners
import base64, urllib3
http = urllib3.PoolManager()
host = '10.10.10.5'
port = 3306
url = f'http://{host}:{port}/'
resp = http.request('GET', url, preload_content=False)
if resp.status == 200: raw = resp.read() try: decoded = base64.b64decode(raw) print('MySQL banner:', decoded[:20]) except Exception: print('Nonâbase64 response')
else: print('Port closed or filtered')
This script attempts to read the MySQL handshake. If the vulnerable server forwards binary data asâis, you will see the classic MySQL version string.
Tools & Commands
- curl - quick adâhoc scans, timing via
-w "%{time_total}". - Burp Suite - Intruder payloads, grepâmatch, and session handling rules.
- Python (requests/urllib3) - programmable, can handle binary bodies, easily extended for parallel scanning with
concurrent.futures. - netcat (nc) - useful for confirming findings outside of SSRF (if you gain a foothold).
- ssrfmap - openâsource scanner that already implements errorâmessage based port probing.
Sample command with curl showing timing:
curl -s -o /dev/null -w "code=%{http_code} time=%{time_total}
" "http://vuln-app/proxy?url=http%3A%2F%2F10.0.0.5%3A8080/"
Defense & Mitigation
- Whitelist Destinations - Only allow URLs that resolve to public IP ranges (e.g., using CIDR checks).
- Enforce Scheme & Port Policies - Reject any URL containing a port component unless it is explicitly allowed.
- Do Not Propagate Errors - Strip or genericâify any error messages before returning them to the client.
- Network Segmentation - Isolate the SSRFâcapable service in a sandbox with no access to internal ports.
- Outbound Proxy - Force all outbound HTTP traffic through a proxy that can enforce ACLs and hide internal error details.
- Response Time Randomisation - Add jitter to outbound requests to reduce timingâsideâchannel reliability.
Common Mistakes
- Relying on a single signal - Some libraries return 200 even on connection failure (e.g., when they fallback to a cached response). Always crossâcheck with latency.
- Scanning too fast - Triggering rateâlimits or IDS alerts. Use modest delays (e.g., 100âŻms) or parallelism with a limited thread pool.
- Forgetting URL encoding - Plain colons are often blocked; forgetting to encode will result in false negatives.
- Assuming HTTP response means an HTTP service - NonâHTTP services may still produce a 200 if the backend wraps the raw bytes in an HTTP 200 body.
RealâWorld Impact
In a 2023 incident, a cloudâbased documentâpreview service allowed SSRF to any http URL. Attackers used errorâmessage probing to enumerate the entire 10.0.0.0/16 range, discovering an exposed internal Elasticsearch cluster on port 9200. By chaining the discovered endpoint with an unauthenticated _search query, they exfiltrated thousands of documents containing PII. The root cause was a missing whitelist and the server returning raw socket errors.
Trends indicate that more SaaS platforms expose âpreviewâ or âfetchâ endpoints, and developers often forget to sanitise error output. As a result, portâscanning via SSRF is becoming a firstâstep reconnaissance technique for supplyâchain attacks.
Practice Exercises
- Basic Scan: Deploy a simple Flask app with an
/fetch?url=endpoint that usesrequests.get. Write a Bash script that scans ports 1â1024 on127.0.0.1and logs status, body snippet, and latency. - Encoding Bypass: Modify the Flask app to reject any URL containing a colon after the host. Demonstrate how doubleâencoding
%253Abypasses the filter and still reaches the target port. - Service Fingerprint: Using the same app, enumerate an internal Redis instance (port 6379). Capture the
+PONGresponse and extract it with a Python script. - Parallel Scanner: Extend the Python script to run 50 concurrent scans using
concurrent.futures.ThreadPoolExecutor. Measure the speedâup compared to sequential scanning. - Defence Test: Implement a whitelist that only allows example.com. Verify that all internal ports are now blocked, and that generic error messages are returned.
Further Reading
- PortSwigger - "ServerâSide Request Forgery" (deep dive)
- OWASP - "SSRF Prevention Cheat Sheet"
- "The Art of Network Scanning" - Nmap documentation (for understanding timing analysis)
- ssrfmap GitHub repository - openâsource SSRF scanner
- RFC 2616 - HTTP/1.1 (section on status codes)
Summary
Port scanning through SSRF leverages the subtle differences in how HTTP clients handle successful and failed TCP connections. By crafting payloads that target specific ports, interpreting status codes, response bodies, and timing, and automating the process with curl, Burp, or Python, an attacker can map internal networks without needing direct network access. Defences revolve around strict whitelisting, sanitising error output, and network segmentation. Mastering these techniques equips security professionals to both discover hidden attack surfaces and design robust mitigations.