cfsearch is a single-file Go CLI that hunts down origin IPs hiding behind CDNs. I break down how it uses Host headers, goroutines, and the standard library to pull it off.

Finding Real IPs Behind Cloudflare with Go


Cloudflare sits in front of your target. DNS gives you edge IPs, not the real server. But the origin is often still out there, listening on the open internet, waiting for someone to knock on the right door with the right name. cfsearch is a Go CLI that does exactly that knocking.

The whole thing lives in a single main.go file, which I find refreshing. It sends HTTP requests to candidate IPs with a spoofed Host header, checks whether the target domain shows up in the response, and prints hits. Simple concept, clean implementation, and a surprisingly good tour of Go’s standard library.

How CDN bypassing works

When a site sits behind Cloudflare or a similar CDN, DNS resolution gives you the CDN’s addresses. The origin server’s real IP never shows up in a lookup. But that origin server is usually still reachable if you know where it is. Send an HTTP request directly to the origin IP with the correct Host header, and the server responds like nothing unusual happened.

The detection strategy is three steps:

  1. Take a list of IP addresses or CIDR ranges.
  2. For each IP, send an HTTP/HTTPS request with the target domain as the Host header.
  3. If the response comes back 200 and the body contains the domain name, that IP is probably the origin.

That’s what cfsearch automates. The interesting part is the Go code that makes it fast.

The single-file architecture

No cmd/ directory. No internal packages. No models. Everything is in main.go. For a tool this focused, I think that’s the right call. You run it, it scans, it prints results. Adding package structure would be ceremony for ceremony’s sake.

The tool takes a domain and a file of IPs or CIDR ranges, then fans out HTTP requests concurrently across all of them. This is where Go earns its keep. Spinning up concurrent HTTP checks across thousands of IPs is almost trivially easy with goroutines.

Concurrency with goroutines and WaitGroups

Checking thousands of IPs sequentially would be brutal. cfsearch uses goroutines to parallelize the work.

If you’re building something similar, the pattern looks like this:

package main

import (
	"fmt"
	"net/http"
	"sync"
	"time"
)

func checkIP(ip string, domain string, wg *sync.WaitGroup) {
	defer wg.Done()

	client := &http.Client{
		Timeout: 5 * time.Second,
	}

	url := fmt.Sprintf("http://%s", ip)
	req, err := http.NewRequest("GET", url, nil)
	if err != nil {
		return
	}
	req.Host = domain

	resp, err := client.Do(req)
	if err != nil {
		return
	}
	defer resp.Body.Close()

	if resp.StatusCode == 200 {
		fmt.Printf("Potential origin: %s\n", ip)
	}
}

func main() {
	ips := []string{"1.2.3.4", "5.6.7.8"} // from file
	domain := "example.com"

	var wg sync.WaitGroup
	for _, ip := range ips {
		wg.Add(1)
		go checkIP(ip, domain, &wg)
	}
	wg.Wait()
}

Standard Go concurrency: sync.WaitGroup to track completion, goroutines for parallelism. The line that does the real work is req.Host = domain. That one assignment tells the origin server which virtual host you want, even though you’re connecting by raw IP.

For more on how Go handles context and cancellation in concurrent patterns like this, see What is Context in Go?.

The Host header trick

This is the heart of the tool. When you set req.Host in Go’s net/http package, it overrides the Host header in the outgoing request. You connect to an IP address, but the server thinks you’re asking for example.com.

Go’s http.Request struct has both a URL field and a Host field. When Host is set, it wins over URL.Host in the wire request. From the Go standard library docs:

For client requests, Host optionally overrides the Host header to send.

I’ve seen plenty of Go code where developers set the URL and assume the Host header matches. Usually it does. In cfsearch’s case, the mismatch is the whole point.

Body matching as detection

After getting a 200 response, cfsearch reads the body and checks whether the target domain appears anywhere in it. It’s a heuristic. If the page mentions example.com somewhere in its HTML, that’s probably the right server.

In Go, this is straightforward with io.ReadAll and strings.Contains:

package main

import (
	"io"
	"net/http"
	"strings"
)

func bodyContainsDomain(resp *http.Response, domain string) bool {
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return false
	}
	return strings.Contains(string(body), domain)
}

It won’t catch everything. A site might not include its own domain anywhere in the HTML. But for a quick-and-dirty origin finder, it works well enough. You could extend it to match page titles, specific strings, or content hashes if you need more precision.

CIDR range expansion

cfsearch accepts CIDR ranges like 192.168.1.0/24, not just individual IPs. Go’s net package handles CIDR parsing natively with net.ParseCIDR. Expanding a range into individual IPs means iterating through the network:

package main

import (
	"fmt"
	"net"
)

func expandCIDR(cidr string) []string {
	ip, ipNet, err := net.ParseCIDR(cidr)
	if err != nil {
		return nil
	}

	var ips []string
	for addr := ip.Mask(ipNet.Mask); ipNet.Contains(addr); incrementIP(addr) {
		ips = append(ips, addr.String())
	}
	return ips
}

func incrementIP(ip net.IP) {
	for j := len(ip) - 1; j >= 0; j-- {
		ip[j]++
		if ip[j] > 0 {
			break
		}
	}
}

func main() {
	ips := expandCIDR("192.168.1.0/24")
	fmt.Printf("Found %d IPs\n", len(ips))
}

net.ParseCIDR gives you the network address and mask. Then you increment through the IP bytes. No third-party dependencies. The standard library handles it all, which is one of the things I genuinely like about Go for network tooling.

Timeouts and error handling

Most of those thousands of IPs won’t respond. Without proper timeouts, your goroutines pile up waiting for connections that never complete, and you’ve got a resource leak on your hands.

Go’s http.Client has a Timeout field that covers the entire request lifecycle: DNS lookup, TCP connection, TLS handshake, reading the body. cfsearch sets reasonable timeouts to avoid hanging.

If you’ve ever debugged a Go service that was leaking file descriptors because someone forgot resp.Body.Close(), you know how annoying this gets. cfsearch handles both the timeout and the body close, which puts it ahead of a lot of production code I’ve seen.

Practical considerations

A few things worth thinking about if you use or extend this tool:

Rate limiting is something cfsearch doesn’t do out of the box. Scanning large IP ranges without throttling can trigger abuse detection on the other end. A buffered channel as a semaphore is the standard Go approach to limiting concurrent goroutines.

TLS certificates won’t match when you connect to an IP directly over HTTPS. The cert is issued for the domain, not the IP. You’ll need to skip certificate verification or handle the mismatch explicitly. Normal for security tooling, but worth knowing.

Legal and ethical use matters here. This tool is for testing systems you own or have written permission to test. Using it to bypass protections on someone else’s infrastructure is a different thing entirely.

What makes this worth studying

cfsearch packs a lot into a single file. net/http for requests, net for CIDR parsing, sync.WaitGroup for concurrency, zero external dependencies. It’s the kind of tool that makes you appreciate how much Go’s standard library gives you for network programming. I keep coming back to projects like this when I want a reminder that not everything needs 40 dependencies and a microservice architecture.

If you want to see another compact, single-purpose Go project with the same energy, take a look at a spreadsheet in your terminal, written in Go. Small scope, clean implementation, surprisingly useful.