用 Go 找到 Cloudflare 背后的源站 IP

用 Go 找到 Cloudflare 背后的源站 IP

你好,我是 Bobby。

cfsearch 是一个 Go 写的命令行工具,用来在网站接入 Cloudflare 或类似 CDN 后,从一批候选 IP 里筛出可能的源站地址。

Cloudflare 挡在目标站点前面时,DNS 查到的是边缘节点,而不是源站。cfsearch 做的事情,就是直接请求候选 IP,同时把 HTTP Host 头设置成目标域名,再根据响应内容判断这个 IP 是否值得继续验证。

它的实现很简单。核心代码在一个 main.go 里,主要依赖 Go 标准库,net/http 发请求,net 解析 IP 和 CIDR,sync 控制并发,time 处理超时。

这篇文章整理一下它的实现思路。

CDN 后面的源站为什么还能被找到

网站接入 Cloudflare 以后,普通 DNS 查询拿到的是 Cloudflare 的边缘节点地址。源站 IP 不会直接出现在 DNS 结果里。

但有些源站服务器仍然暴露在公网。只要你知道它的 IP,并且请求里带上正确的域名,服务器可能照常返回目标网站的内容。

这里用到的是 HTTP Host 头。

假设目标域名是 example.com,候选 IP 是 1.2.3.4。请求实际连接的是:

1
http://1.2.3.4/

但 HTTP 请求里带的是:

1
Host: example.com

很多 Web 服务器会根据 Host 决定把请求交给哪个虚拟主机处理。于是客户端虽然连的是裸 IP,服务端仍然可能按 example.com 的站点配置返回页面。

cfsearch 自动化的就是这个过程:

  1. 读取一批 IP 地址或 CIDR 网段。
  2. 对每个 IP 发起 HTTP 或 HTTPS 请求。
  3. 请求时把 Host 设置成目标域名。
  4. 如果返回结果里出现目标域名,就把这个 IP 记录为可能的源站。

它不是在证明某个 IP 一定是源站,只是在大量地址里筛出值得继续验证的候选项。

单文件结构适合这种小工具

cfsearch 没有拆出 cmd/internal/pkg/ 这类目录,所有逻辑都放在一个 main.go 文件里。

对这种只做一件事的 CLI 来说,这个选择并不奇怪。它没有复杂领域模型,也没有多种运行模式。用户传入目标域名、IP 文件或 CIDR,程序并发扫描,最后把命中的结果输出。

如果为了”项目结构完整”硬拆包,反而会把阅读成本抬高。

cfsearch 支持的参数也比较贴近扫描过程本身,比如:

1
2
3
4
5
6
flag.StringVar(&targetHost, "target", "", "")
flag.StringVar(&scheme, "scheme", "https", "")
flag.IntVar(&workers, "workers", 100, "")
flag.DurationVar(&reqTimeout, "timeout", 2*time.Second, "")
flag.Var(&cidrs, "cidr", "")
flag.StringVar(&filePath, "file", "", "")

这些参数对应几个关键问题:扫哪个域名,用 HTTP 还是 HTTPS,开多少并发,请求等多久,从文件读 IP 还是直接展开 CIDR。

并发扫描靠 goroutine 和 WaitGroup

逐个 IP 顺序请求会很慢。大部分地址不会响应,或者会等到超时。cfsearch 用 goroutine 把请求并发发出去,再用 WaitGroup 等待扫描结束。

它还用了一个带缓冲的 channel 当并发限制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
sem := make(chan struct{}, workers)

for ip := range ipChan {
sem <- struct{}{}
workerWg.Add(1)

go func(ip net.IP) {
defer func() {
<-sem
workerWg.Done()
}()

if checkHTTP(ip, client) {
// 记录命中的 IP
}
}(ip)
}

这个写法很常见。workers 控制同时进行的请求数量,避免一下子启动过多 goroutine,把本机文件描述符、网络连接或对端防护打爆。

工具类程序通常还要处理进度、输出文件、去重、错误打印这些细节。

关键点是 req.Host

整套探测方法里,最关键的一行是:

1
req.Host = targetHost

Go 的 http.Request 里有 URL,也有单独的 Host 字段。请求 URL 可以是某个 IP,但 Host 字段可以覆盖实际发出去的 Host 头。

Go 官方 net/http 文档也明确写到,客户端请求里,Host 可以用来覆盖要发送的 Host header。

所以 cfsearch 的请求构造大概是这样:

1
2
3
4
5
6
url := fmt.Sprintf("%s://%s/", scheme, ip.String())
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return false
}
req.Host = targetHost

这段代码把两个概念分开了,网络连接的目标是 IP,HTTP 层声明的目标站点是域名。

平时写业务代码时,URL.Host 和请求里的 Host 通常一致,开发者很少需要单独改 req.Host

怎么判断一个 IP 是否是源站

cfsearch 收到响应后,会先看状态码。

如果返回 200,它读取响应 body 的一小段内容,然后检查里面是否包含目标域名:

1
2
3
4
if resp.StatusCode == 200 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return strings.Contains(string(body), targetHost)
}

这里用了 io.LimitReader,只读前 256 字节。它不需要下载整个页面,只想快速判断返回内容里有没有目标域名。

如果遇到跳转,工具会看 Location 头里是否包含目标域名:

1
2
3
4
if resp.StatusCode >= 301 && resp.StatusCode <= 308 {
location := resp.Header.Get("Location")
return strings.Contains(location, targetHost)
}

CIDR 展开用 net.ParseCIDR

除了单个 IP,cfsearch 也支持传入 CIDR 网段。比如你给它一个 /24,它会把这个网段里的地址逐个送进扫描队列。

Go 标准库已经提供了 net.ParseCIDR

1
2
3
4
5
6
7
8
9
10
ip, ipnet, err := net.ParseCIDR(cidr)
if err != nil {
return
}

for ip := ip.Mask(ipnet.Mask); ipnet.Contains(ip); inc(ip) {
ipCopy := make(net.IP, len(ip))
copy(ipCopy, ip)
ipChan <- ipCopy
}

这里有一个小细节,代码会复制一份 ip 再发送到 channel。因为循环里的 ip 会不断自增,如果直接把同一个底层切片传出去,后面的 goroutine 可能看到已经变化的地址。复制一份可以避免这个问题。

自增 IP 的函数也很短:

1
2
3
4
5
6
7
8
func inc(ip net.IP) {
for j := len(ip) - 1; j >= 0; j-- {
ip[j]++
if ip[j] > 0 {
break
}
}
}

写这种网络工具时,IP 解析和 CIDR 判断可以交给 net,HTTP 请求和超时控制可以交给 net/http

超时和连接配置不能省

扫描大量 IP 时,大多数请求会失败。失败并不麻烦,麻烦的是连接或读取一直等不到结果。

cfsearch 创建了一个自定义 http.Client,给整个请求设置超时,也给底层拨号设置超时:

1
2
3
4
5
6
7
8
9
10
11
12
13
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
DisableKeepAlives: true,
DialContext: (&net.Dialer{
Timeout: reqTimeout,
}).DialContext,
},
Timeout: reqTimeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}

Timeout 用来限制单次请求生命周期。DialContext 里的超时限制 TCP 连接建立过程。CheckRedirect 返回 http.ErrUseLastResponse,表示不要自动跟随跳转,这样程序可以自己读取 Location 头。

HTTPS 扫描还有一个额外问题,你直接连接的是 IP,但证书通常签给域名。证书校验会失败,所以代码里设置了 InsecureSkipVerify: true

这个配置在普通业务代码里应该非常谨慎,很多场景不能用。放在这类授权安全测试工具里,它解决的是“我知道域名和 IP 不匹配,但我仍然要观察服务响应”的问题。

使用时要注意什么

这类工具只能用于你自己拥有或得到授权测试的系统。它适合检查自己的源站是否错误暴露在公网,或者在授权渗透测试中验证 CDN 配置。未经授权地寻找别人的源站 IP,性质完全不同。实现上,cfsearchworkers 参数控制并发,避免触发对端滥用检测;HTTPS 直连 IP 时会跳过证书验证,因为证书签给域名而不是 IP,这个配置只是为了让请求能发出去,不能当作正常浏览器访问的安全判断。

把网站接到 Cloudflare 之后,源站最好只允许 Cloudflare 的回源 IP 段访问,或者放到私有网络后面。

否则 CDN 隐藏了 DNS 层入口,但源站本身仍然可能被直接访问。

值得看的地方

cfsearch 生成候选 IP,带着伪造的 Host 发请求,再根据响应做简单匹配。

单文件结构让读者可以从参数解析一直读到请求发送和结果输出,标准库覆盖了大部分工作,goroutine 和 channel 用在最直接的位置,没有为了”工程化”拆出一堆暂时用不上的抽象。

如果你平时写的是 HTTP 服务端代码,也可以看看这种客户端工具。它会让你重新注意到几个平时容易忽略的点,req.HostURL.Host 可以不同,http.Client 的超时要明确设置,响应 body 要关闭,CIDR 展开时要注意 net.IP 的底层切片。

资料:


用 Go 找到 Cloudflare 背后的源站 IP
https://blog.zhangliangliang.cc/post/find-cloudflare-origin-ip.html
作者
Bobby Zhang
发布于
2026年6月2日
许可协议