~/home/study/ssrf-port-scanning-via-error

SSRF Port Scanning via Error Messages - Techniques & Automation

Learn how to turn SSRF into a stealthy port scanner by leveraging error messages, timing, and response codes. Covers payload crafting, automated tools, bypass tricks, and enumeration of internal services.

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:

  1. The vulnerable application accepts a URL (or similar) from the attacker.
  2. The backend library (e.g., requests, urllib3, java.net.HttpURLConnection) attempts to open a TCP connection to the supplied host/port.
  3. If the port is open, the library receives an HTTP response (or at least a TCP ACK) and forwards it to the attacker.
  4. If the port is closed, the library throws an exception or returns a distinct HTTP status (often 502 Bad Gateway or 504 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:

SignalOpen PortClosed/Filtered Port
Status Code200-299 (or 301/302 if redirected)502, 504, 500 (often with error text)
Body ContentHTML/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:

  1. Capture a request that the vulnerable endpoint forwards (e.g., GET /fetch?url=...).
  2. Place ${payload} where the port number lives.
  3. Configure Intruder payload type to Numbers (1‑65535).
  4. 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) or redis://127.0.0.1:6379 for 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.

  1. Craft a payload list with ports 22, 80, 443, 3306, 6379, 445.
  2. Encode each as http%3A%2F%2F10.10.10.5%3A{port}%2F and send via Burp Intruder.
  3. 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

  1. Basic Scan: Deploy a simple Flask app with an /fetch?url= endpoint that uses requests.get. Write a Bash script that scans ports 1‑1024 on 127.0.0.1 and logs status, body snippet, and latency.
  2. Encoding Bypass: Modify the Flask app to reject any URL containing a colon after the host. Demonstrate how double‑encoding %253A bypasses the filter and still reaches the target port.
  3. Service Fingerprint: Using the same app, enumerate an internal Redis instance (port 6379). Capture the +PONG response and extract it with a Python script.
  4. Parallel Scanner: Extend the Python script to run 50 concurrent scans using concurrent.futures.ThreadPoolExecutor. Measure the speed‑up compared to sequential scanning.
  5. 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.