Surf: Finding SSRF Candidates in Cloud Environments Where Traditional Filters Fail
Hook
Most SSRF filters check if an IP is in 10.0.0.0/8, 172.16.0.0/12, or 192.168.0.0/16—then call it a day. But what about the externally-routable 18.x.x.x IP running an internal AWS service that's firewalled from the internet but wide open to SSRF?
Context
Server-Side Request Forgery remains one of the most impactful vulnerability classes in modern web applications, particularly as organizations migrate to cloud infrastructure. The classic SSRF scenario involves an attacker tricking a server into making requests to RFC1918 private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) to access internal services. Developers and security teams have responded by implementing filters that block requests to these well-known private ranges.
But cloud environments fundamentally changed the network topology assumptions these filters rely on. In AWS, Azure, and GCP, services often run on public IP addresses that are restricted by security groups, network ACLs, or firewall rules—not by being on private networks. An EC2 instance might have a public IP in the 3.x.x.x range that's completely inaccessible from the internet due to security group rules, yet perfectly reachable from other resources within the same VPC or region. Traditional SSRF filters that only check RFC1918 ranges will happily allow requests to these IPs, creating a massive blind spot. Surf emerged from this recognition at the HackerOne H1-4420 2023 live hacking event, where Assetnote researchers needed a way to systematically identify these overlooked attack surfaces.
Technical Insight
Surf's approach is elegant in its simplicity: it identifies SSRF candidates not by analyzing IP address ranges, but by detecting the gap between what resolves and what responds. The tool takes a list of hostnames, performs DNS resolution, attempts HTTP connections, and flags hosts that resolve to IPs but don't respond to direct HTTP requests. These unresponsive hosts become your SSRF candidate list—targets that might be protected by network-level restrictions but could be accessible via a vulnerable application.
The core workflow operates in three stages. First, surf resolves all provided hostnames to their IP addresses using Go's standard DNS resolver. Second, it leverages httpx (ProjectDiscovery's HTTP toolkit) to probe each resolved host with configurable timeouts and retry logic. Third, it categorizes results based on response patterns: hosts that resolve but return connection errors, timeouts, or filtered responses get written to timestamped output files. Here's what a basic invocation looks like:
# Basic usage: read hosts from file, output SSRF candidates
cat hosts.txt | surf -o candidates.txt
# With custom concurrency and timeout
cat hosts.txt | surf -c 50 -t 10 -o candidates.txt
# Separate external and internal candidates
cat hosts.txt | surf -o external-candidates.txt
The output structure is particularly thoughtful. Surf generates two distinct files: one for external IP candidates (publicly routable addresses that don't respond) and one for internal candidates (RFC1918 ranges that don't respond). This separation is crucial for prioritization—external IPs that are firewalled often represent higher-value targets because they might be production services protected only by network rules, while internal IPs might be development environments or honeypots.
Under the hood, surf uses Go's concurrency primitives to process hosts efficiently. The default worker pool of 100 goroutines can be adjusted via the -c flag to balance speed against rate limiting concerns. Each worker pulls hosts from a shared channel, performs the DNS resolution and HTTP probe, then writes results to output channels that are drained by dedicated writer goroutines. This producer-consumer pattern with multiple worker pools is a textbook Go concurrency design:
// Simplified conceptual flow (not actual surf code)
func processHosts(hosts []string, workers int) ([]string, error) {
hostChan := make(chan string, workers)
resultChan := make(chan SSRFCandidate, workers)
// Worker pool
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for host := range hostChan {
if candidate := probeHost(host); candidate != nil {
resultChan <- *candidate
}
}
}()
}
// Feed hosts to workers
go func() {
for _, host := range hosts {
hostChan <- host
}
close(hostChan)
}()
// Collect results
go func() {
wg.Wait()
close(resultChan)
}()
var candidates []string
for result := range resultChan {
candidates = append(candidates, result.Host)
}
return candidates, nil
}
The httpx integration is where surf derives much of its power. Rather than implementing custom HTTP logic, surf delegates to httpx's battle-tested probing engine that handles redirects, TLS verification, custom headers, and various edge cases. This architectural decision keeps surf focused on its core logic—the SSRF candidate filtering—while benefiting from ProjectDiscovery's continuous improvements to httpx. The tool passes through httpx flags for timeout configuration (-t), retry attempts (-retries), and other HTTP behavior.
One subtle but important implementation detail is how surf handles DNS resolution timing. By resolving hostnames before HTTP probing, surf can identify hosts that resolve to multiple IPs (round-robin DNS, CDNs, etc.) and probe each resolved IP individually. This catches scenarios where a hostname resolves to both external and internal IPs—common in split-horizon DNS configurations used by cloud environments. A hostname might resolve to a public load balancer IP from your attack machine but to a private IP from within the victim's VPC, making it an ideal SSRF candidate.
The timestamped output files (surf-external-<timestamp>.txt and surf-internal-<timestamp>.txt) serve a practical purpose beyond organization. During extended reconnaissance campaigns or bug bounty programs, these timestamps let you track how an organization's attack surface evolves over time. Comparing candidate lists from different dates can reveal newly deployed services, infrastructure migrations, or changes in network segmentation—all valuable intelligence for targeted SSRF testing.
Gotcha
Surf's active probing approach generates substantial network traffic that's easily detected. Every host in your input list receives at least one HTTP connection attempt, and with default settings of 100 concurrent workers, you might send thousands of requests in seconds. This will absolutely trigger rate limiting on cloud providers, set off security monitoring alerts, and potentially get your IP blocked. During authorized penetration tests, you should coordinate with the blue team about expected traffic patterns and consider throttling concurrency significantly (-c 10 or lower) to reduce noise. The tool is fundamentally incompatible with stealthy reconnaissance.
The false positive rate can be significant depending on your input quality. Surf flags any host that resolves but doesn't respond to HTTP requests as an SSRF candidate, but there are many legitimate reasons for this behavior: services running on non-standard ports, hosts that only respond to HTTPS (surf tries both by default, but configuration matters), services that require specific headers or authentication, or simply hosts that are legitimately offline. You'll need to manually validate each candidate, which for large-scale scans could mean hundreds of potential targets to test. Additionally, surf doesn't actually test for SSRF vulnerabilities—it only identifies potential targets. You still need a vulnerable injection point and a way to observe out-of-band responses (DNS exfiltration, Burp Collaborator, etc.) to confirm exploitability. Think of surf as reconnaissance tooling that feeds into your actual testing workflow, not an end-to-end exploitation framework.
Verdict
Use if: You're conducting authorized penetration testing or bug bounty work against organizations with significant cloud infrastructure (AWS, Azure, GCP) where network segmentation creates complex attack surfaces. Surf excels at finding the non-obvious SSRF targets—externally routable IPs protected by security groups, multi-cloud environments with inconsistent network policies, or hybrid setups with split-horizon DNS. It's particularly valuable when you have a large subdomain enumeration output (from tools like subfinder or amass) and need to triage thousands of hosts for SSRF testing. Skip if: You lack proper authorization (surf is extremely noisy and will get you noticed), your target uses traditional on-premise infrastructure with straightforward RFC1918 private networks (standard SSRF payloads work fine), you need fully automated exploitation rather than candidate identification, or you're working with strict time constraints that don't allow for manual validation of potentially hundreds of false positives. For smaller scopes or when you need more surgical precision, manual testing with Burp Suite or using nuclei's SSRF templates against known injection points will be more efficient.