1 万个域名扫一遍还不出错,httpx 凭什么
- 大规模 HTTP 扫描痛点:DNS 超时、CDN 共享节点端口浪费、503 重试策略、并发控制、TLS 证书提取
- httpx 不是简单 HTTP 客户端,而是为扫描场景深度优化的可靠性工程:连接池、重试、DNS 缓存、TLS 指纹
- 扫描 1 万个域名不出错的关键:分层容错、渐进式并发控制、智能重试退避、连接复用优化
- httpx 已成为渗透测试、子域名枚举、互联网测绘等安全场景的标准 HTTP 工具
凌晨三点,你拿到一份子域名清单,八千条。任务很简单,挨个发 HTTP 请求,看谁活着、谁返回 200、标题是什么、用了什么技术栈、TLS 证书里藏了哪些备用域名。听起来不就是写个 for 循环套个 http.Get 吗。
你真去写就知道死在哪了。并发拉到 100,DNS 解析开始超时;某些 IP 是 CDN 共享节点,你拿 8080 端口去戳根本没意义还浪费配额;有些主机第一轮就 503,你继续重试只是把对方 WAF 惹毛;还有那个永远重定向的登录页,跟着跳三跳就进了内网地址。八千条扫完,结果里一半是噪音,一半是漏报。
大家好,我是若风。今天拆的这个项目就是专门解决上面这堆破事的,projectdiscovery/httpx,GitHub 上 10139 颗星,Go 写的,MIT 协议。它不是又一个 HTTP 客户端库,而是一台为大规模 HTTP 探测而生的工程机器。我想搞清楚一个问题,为什么这个工具能在上万目标的规模下还保持结果靠谱,而不是跑着跑着就烂掉。
一个 HTTP 探测工具到底要 probe 什么
先把它的能力边界画清楚。httpx 的核心动作就一句话,给一个目标列表(域名、IP、URL、CIDR 都行),对每个目标发请求,然后把几十种「探针」的结果吐出来。
探针是它的基本单位。翻 runner/options.go 里的 ScanOptions 结构体,你能看到它关心的东西有多细,状态码、内容长度、响应时间、行数、词数、Web 服务器、TLS 证书、CSP 头、CDN 归属、favicon 哈希、技术栈指纹、WebSocket 支持、HTTP/2、HTTP Pipeline、虚拟主机、ASN 信息,光默认开启的就有十几个。这还只是探针,match 和 filter 又是一整套,可以按状态码、内容长度、favicon 哈希、响应时间、甚至 DSL 表达式来筛选。
你想想看,单目标发一个请求拿这么多信息,并不难。难的是当目标变成一万个、端口还要扩成几十个组合时,这套东西怎么不崩。这才是 httpx 真正值钱的地方,也是绝大多数人只把它当「会输出彩色状态码的工具」而忽略掉的部分。
50 个线程不是写死的,是会自己伸缩的
先看并发。httpx 默认 50 个线程,这个数字在 runner/options.go 里写死成 defaultThreads = 50。但它用的不是普通的 sync.WaitGroup,而是 ProjectDiscovery 自己造的 syncutil.AdaptiveWaitGroup。
关键在 runner.go 第 1668 行的 process 方法里这一段。
if r.options.Threads > 0 && wg.Size != r.options.Threads {
if err := wg.Resize(context.Background(), r.options.Threads); err != nil {
gologger.Error().Msgf("Could not resize workpool: %s\n", err)
}
}
每次处理一个新目标,它都会检查当前工作池大小是不是和用户设的线程数一致,不一致就动态 Resize。这听起来多此一举,毕竟线程数一般是固定的。但 httpx 的输入是流式的,CIDR 展开也好、TLS 证书里再挖出一堆 SAN 域名也好,目标数量是边跑边长的。工作池能在运行中伸缩,意味着你不用预先知道总共有多少目标,也不用为了一个突发的大批量输入重启进程。
再看真正发请求的地方,analyze 方法(runner.go:1826)里有一行很容易被忽略的调用。
r.ratelimiter.Take()
这一行在每次构造请求前阻塞,默认每秒 150 个请求(-rl 参数控制)。它和线程池是两套独立的限速机制,线程池管并发度,ratelimiter 管吞吐量。说真的,很多自写的并发脚本死就死在只有线程池没有全局限速,跑到 CDN 节点上瞬间被 429 糊一脸。httpx 把这两个维度拆开,是工程上很清醒的做法。
还有一层容错,叫 HostErrorsCache。在 analyze 开头有这段判断。
if r.options.HostMaxErrors >= 0 && r.HostErrorsCache.Has(hostPort) {
numberOfErrors, err := r.HostErrorsCache.GetIFPresent(hostPort)
if err == nil && numberOfErrors >= r.options.HostMaxErrors {
return Result{URL: target.Host, Err: errors.New("skipping as previously unresponsive")}
}
}
一个 host:port 连续失败到阈值,直接进黑名单,后续不再戳它。这解决的是那种「第一轮超时、第二轮还是超时、第三轮依然是超时」的死主机问题。你不用等它把整个扫描拖慢,httpx 会自己学会跳过。坦白讲,这个机制 README 里几乎没提,但它是大规模扫描不掉链子的关键之一,光靠 retryablehttp 的重试是覆盖不了这种「主机本身就是坏的」情况的。
favicon 哈希那一行 76 字符的换行
说完并发,讲一个我觉得最有意思的细节,favicon 哈希。
httpx 有个 -favicon 参数,会算出目标 /favicon.ico 的 mmh3 哈希。这个哈希不是随便算的,它要能直接丢进 Shodan 或 fofa 去反查同指纹的站点,所以算法必须和那些平台的实现完全对齐。这就有意思了,因为「正确」的算法本身是个历史遗留的怪东西。
看 common/stringz/stringz.go 第 128 行的 murmurhash 函数。
func murmurhash(data []byte) int32 {
stdBase64 := base64.StdEncoding.EncodeToString(data)
stdBase64 = InsertInto(stdBase64, 76, '\n')
hasher := murmur3.New32WithSeed(0)
hasher.Write([]byte(stdBase64))
return int32(hasher.Sum32())
}
注意第二行,InsertInto(stdBase64, 76, '\n')。它把 base64 编码后的字符串每隔 76 个字符插一个换行符。76 这个数字不是拍脑袋来的,它是 MIME base64 的标准行宽,源自早期邮件协议每行不超过 76 字符的限制。
也就是说,httpx 算 favicon 哈希的流程是,原始图标字节 → base64 编码 → 按 MIME 规范每 76 字符折行 → murmur3 哈希(种子为 0)→ 转 int32。这套绕来绕去的步骤,是为了复刻 Python 生态里那个最早的 favicon 哈希脚本,而那个脚本又是为了对齐 Shodan 数据库里已经存好的哈希值。common/hashes/hashes.go 里的 Mmh3 函数走的是一模一样的路径。
我一直觉得这段代码特别能说明一个问题,开源工具里很多看起来「丑」或「怪」的实现,背后都是兼容性债。你不能改它,一改哈希值就全错,下游所有依赖这个哈希去搜 Shodan 的工作流都崩。所以 httpx 老老实实把 MIME 折行这一步保留下来,哪怕在 2026 年看来毫无必要。这种「为了对齐历史结果而保留怪异实现」的取舍,比任何「优雅架构」都更真实。
favicon 的提取也不只是抓 /favicon.ico 这一条路。extractPotentialFavIconsURLs 函数(runner.go:2873)会用 goquery 解析 HTML,把 <link rel="icon">、shortcut-icon、apple-touch-icon、mask-icon、alternate 这些 rel 全收进候选列表,还会读 <base href> 做相对路径补全,并且把 .ico 结尾的候选排在前面优先算。一个网站 favicon 的位置可能五花八门,httpx 这套兜底逻辑挺细的。
CDN 上的主机只戳 80 和 443,这是写死的
再讲一个我很欣赏的设计决策,CDN 感知。skipCDNPort 方法在 runner.go:2982。
if isCdnIP && port != "80" && port != "443" {
return true
}
逻辑很直白,如果目标 IP 被判定为 CDN 节点,并且你要探的端口不是 80 也不是 443,直接跳过。这个判断是硬编码的,80 和 443 写死在代码里,没有配置项。
为什么这么做。CDN 节点是共享的,一个 CloudFront 的 IP 后面可能挂着几千个客户的站点,你拿 -p 8080,8443,8888 去扫它,扫到的全是 CDN 边缘节点的统一响应,跟客户真实服务毫无关系,还会被 CDN 厂商的 WAF 当成攻击拦掉。httpx 在这里主动收手,只允许在 CDN IP 上探标准 web 端口,这是对扫描结果的诚实,也是对目标的礼貌。
但这个硬编码也是它的一个限制。如果你确实有理由相信某个 CDN IP 上跑了非标准端口的真实服务(比如某种 API 网关),httpx 没给你留口子,你只能关掉整个 ExcludeCDN 选项,那样所有 CDN IP 又都会被无差别戳。没有「只对这个 CDN 放行特定端口」的细粒度控制。其实吧,这种「宁可少做也不做错」的取向贯穿了 httpx 整个设计,后面还会反复看到。
TLS 证书里藏的域名,它会递归往下挖
httpx 有两个很有侦察价值的参数,-tls-probe 和 -csp-probe。它们的实现不在某个独立的探针函数里,而是嵌在 process 方法的回调里(runner.go:1668)。
if scanopts.TLSProbe && result.TLSData != nil {
for _, tt := range result.TLSData.SubjectAN {
if !r.testAndSet(tt) {
continue
}
r.process(tt, wg, hp, protocol, scanopts, output)
}
if r.testAndSet(result.TLSData.SubjectCN) {
r.process(result.TLSData.SubjectCN, wg, hp, protocol, scanopts, output)
}
}
注意这是递归调用 r.process。拿到一个目标的 TLS 证书后,把 SubjectAltName 和 SubjectCN 里所有域名提出来,每个都当成新目标再扫一遍。CSP 头里的域名同理,CSPData.Domains 和 CSPData.Fqdns 也会被递归展开。
testAndSet 是去重闸门,用 hybrid.HybridMap(一种内存加磁盘的混合存储)记录已经处理过的目标,避免无限递归。这个设计的妙处在于,一次扫描会自动滚雪球,你给 10 个种子域名,最后可能扫出 200 个真实存在的服务端点,因为它会把证书里那些「备用域名」「老域名」全挖出来。攻击面就是这样被一点点撑大的。
我读这段代码时注意到一个细节,CSP 探测的递归里有一行 scanopts.CSPProbe = false,也就是说每个目标的 CSP 只会被展开一次,不会层层套娃。这种防爆炸的克制,和前面 CDN 端口的硬编码是同一种工程哲学。
TLS 指纹伪装,功能在但坑也在
httpx 支持一个 -tls-impersonate 参数,号称能伪装浏览器 TLS 指纹绕过基于 JA3 的检测。这个功能在 common/httpx/httpx.go 的 DialTLSContext 里实现。
if options.TlsImpersonate {
return httpx.Dialer.DialTLSWithConfigImpersonate(ctx, network, addr,
&tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS10},
impersonate.Random, nil)
}
两个细节值得说。第一,InsecureSkipVerify: true,开了 TLS 伪装就等于关掉证书校验。这在侦察场景能理解,你本来就不在乎对方证书是不是合法的,你要的是拿到响应。但如果有人把 httpx 当成生产环境的健康检查工具用,这个默认值就是个隐患,中间人攻击在这个模式下完全不设防。README 不会主动告诉你这一点。
第二,impersonate.Random 是目前唯一支持的伪装策略。也就是说你没法指定「我要伪装成 Chrome 120」或者「伪装成 Edge」,它只能随机挑一个。这点在 issue #2044 里被用户明确吐槽过,从提出到现在一直开着没合上。相关 issue #2461 还在讨论怎么改进 TLS 指纹伪装。对于要做精细反检测的人来说,这个功能目前是半成品,能用但不精确。
说一个最近的真实崩溃
批判不能只停留在「设计上有取舍」这种客气话。httpx 也有过让人头疼的实际故障。
2026 年 5 月有人提了 issue #2501,标题很直接,「httpx 1.9.0 SIGSEGV on macOS Tahoe arm64」。v1.9.0 是目前最新的正式 release(2026 年 3 月发布),但在 macOS 26.4.1 Tahoe 上,连 httpx -version 都会段错误退出,崩溃在 github.com/shoenig/go-m1cpu 这个依赖的 cgo 初始化里。
原因是 Apple 在 Tahoe 里改了 sysctl 的语义,而 go-m1cpu v0.1.6 依赖的 sysctlbyname 调用在新系统上行为变了,cgo init 阶段直接炸。上游 go-m1cpu 在 2025 年 9 月就修了(v0.1.7),但 httpx 的 v1.9.0 还在用旧版本。截至我写这篇文章,v1.9.0 仍是最新 release,这个崩溃在 Apple Silicon 的最新 macOS 上是确定性的,每次必现。
你想想这意味着什么。一批用 brew 装 httpx 的 macOS 用户,系统一更新就工具全挂,而 fix 还没发。这不是什么边缘场景,是最新正式版在最新主流系统上跑不起来。对于依赖 httpx 做日常侦察的人来说,这几个月只能回退到 v1.8.x 或者自己从源码编译。项目维护节奏整体是健康的(近一年 v1.7.x 到 v1.9.0 稳步推进,贡献者主要就 Mzack9999 和 ehsandeep 两个人扛大头,bus factor 大概也就两三人),但这次依赖锁版本的滞后,确实暴露了 cgo 依赖在跨版本 macOS 上的脆弱性。
输入和输出都为流水线而生
最后补两个工程上的亮点,都是让 httpx 能塞进自动化流水线的设计。
输入侧,streamInput 方法(runner.go:711)支持流式读取,不用一次性把目标文件全读进内存。配合 -stream 参数,你可以拿另一个工具的输出直接管道喂给 httpx,比如 subfinder -d example.com | httpx。对于那种「子域名列表大到几个 G」的场景,流式输入是刚需。它还支持 resume,ResumeCfg 记录扫描到第几个目标(currentIndex),中断后能从断点续扫,SaveResumeConfig 把进度落盘。扫一晚上断了不用从头来,这在实战里太重要了。
输出侧,json、csv、md、纯文本四种格式可以同时写,-oa 一键全开。jsonl 格式特别适合再喂给 jq 或者下游的 nuclei、naabu。httpx 在 ProjectDiscovery 全家桶里的定位就是个「前置过滤器」,把活着的、有响应的目标筛出来,再交给后面的工具深挖。它的输出结构就是按这个定位设计的,每个字段都对齐了下游工具的输入预期。
它真正教会我的,是「克制的并发」这件事
拆完 httpx,我带走的不只是「又认识一个好用的安全工具」。它示范了一种我觉得可以命名的工程模式,我管它叫三层防御式并发。
第一层是工作池,管并发度,用 AdaptiveWaitGroup 在运行中伸缩;第二层是全局限速,管吞吐量,用 ratelimiter.Take 卡住每秒请求数;第三层是主机级容错,用 HostErrorsCache 记住谁坏了,主动跳过。三层各管各的,互不替代。再加上 CDN 端口白名单和 CSP 递归的防爆炸开关,整套系统在任何一层出问题时都不会雪崩。
这个模式的迁移价值不止在安全工具。你写一个要批量调外部 API 的数据抓取脚本,写一个要轮询几百个微服务健康状态的后台任务,甚至写一个 AI Agent 要并发跑几十个工具调用,都可以套这个思路。并发度、吞吐量、失败隔离,这三个维度缺一个,你的程序就会在规模上去之后以某种意想不到的方式烂掉。httpx 把它们都补齐了,这才是它 1 万颗星背后真正硬的东西。
至于什么场景该用 httpx,结论很清晰。做资产侦察、做 bug bounty 的信息收集、做大规模存活探测,它是目前 Go 生态里最成熟的选择,没有之一。但如果你要的是精细的 TLS 指纹伪装、或者要拿它当生产健康检查工具,那得想清楚,前者功能还是半成品,后者默认不校验证书。工具的边界和它的能力一样重要,httpx 把边界画在了「快速、可靠、大规模探测」这条线上,线外的东西它要么不做,要么做得不算好,但它从不假装自己什么都行。
npm run start
跑起来预览。
评论互动