从 85% 假阳性到 0:子域名枚举踩坑实录

一次真实项目里 580 个子域名结果 85% 是假的,排查到最后发现是一个判断条件写错了。这篇文章记录我自研子域名枚举工具的完整踩坑过程,以及最终沉淀下来的工程化方案。

起因:580 个结果,85% 是假的

做资产测绘时对某集团域名跑了一轮子域名枚举,工具返回 580 个"存活子域名"。按正常流程,这批结果该进入端口扫描环节了。

但直觉告诉我不对——这个域名不大,580 个存活不太合理。

抽样验证,结果很惨:580 个里 85% 是假阳性。所谓"存活",其实根本没解析出任何 IP。

第一个坑:grep -qv '^$' 判存活

排查代码,找到了罪魁祸首:

# ❌ 错误写法:非空输出就算存活
ip=$(dig +short "$host")
[ -n "$ip" ] && echo "$host"     # 或 grep -qv '^$' 判断

# ✅ 正确写法:必须解析出合法 IPv4 才算存活
ip=$(dig +tcp +short "$host" | grep -E '^([0-9]{1,3}\.){3}[0-9]{1,3}$' | head -1)
[ -n "$ip" ] && echo "$host"

区别在哪?dig +short 对不存在的域名可能返回 CNAME 记录或 NOERROR 空应答——输出非空,但没有 A 记录。用"非空即存活"判断,这些全被当成存活。

这个错误有多致命?580 个假阳性意味着:

  • 端口扫描白跑(对不存在的域名扫端口)
  • HTTP 探测全超时(白白等)
  • 人工复核消耗大量时间
  • 最关键的:真实资产淹没在噪声里,可能被漏掉

第二个坑:泛解析(Wildcard DNS)

泛解析是枚举的另一个大敌。某些云厂商/IDC 配置了 *.domain.com → IP,任何随机子域都能解析出 IP,枚举结果全部"存活"但全是同一个 IP。

检测方法:随机生成 3 个子域名(wc + 随机串),只要能解析出 IP,就判定为泛解析:

for _ in 1 2 3; do
  rand="wc$(head -c 8 /dev/urandom | md5sum | cut -c1-8)"
  wip=$(dig +tcp +short "$rand.$DOMAIN" @$RESOLVER 2>/dev/null | tail -1)
  [ -n "$wip" ] && { echo "泛解析: $rand.$DOMAIN -> $wip"; break; }
done

检测到泛解析后,对枚举结果逐一比对 IP,等于泛解析 IP 的全部剔除。

但注意:同一泛解析 IP 上可能跑着多个真实虚拟主机(带不同 Host 头有真实站点)。所以泛解析 IP 上的 403 不能直接当假阳性——用不同 Host 头测,301/302 跳转 = 有真实站点。这个我们实测踩过(三门峡医院案例)。

第三个坑:UDP 53 出站被墙

在 Kali VM 环境,UDP 53 出站被防火墙限制。dnsx、massdns 默认走 UDP 查询,结果随机丢失——同一域名查 3 次,3 次结果完全不同。

这比假阳性更隐蔽:不是全错,是随机错,你根本不知道哪次结果可信。

解法:所有验证统一走 dig +tcp(TCP 53),稳定可靠。虽然比 UDP 慢一点,但结果可信。

第四个坑:裸域漏验,误报"0 存活"

有段时间枚举某域名返回"0 存活",但手动 dig 主域名明明有 A 记录。排查发现:subfinder 只返回子域名,裸域(主域)不会出现在输出里。如果主域有 A 记录但没配任何子域解析,不把种子本身加进验证列表,整个域名就被误判"0 存活"。

修复很简单:第一轮被动收集后,种子域名本身必须加入验证候选:

NEW="$NEW $s $(subfinder -d "$s" -all -silent 2>/dev/null)"
#             ^^^^ 种子本身

第五个坑:断点续跑被旧数据污染

支持断点续跑后,出现了一个诡异 bug:结果数量≈字典大小(5000 行)。排查发现是旧版本残留的 all_subs.txt 污染——里面存的是未严格验活的候选,续跑时对这 5000 个"假种子"全量套娃。

修复:重跑前确认 all_subs.txt 行数正常(远小于字典大小),或者删掉目录全新开跑。断点续跑保留历史结果没问题,但前提是历史结果本身是干净的。

第六个坑:候选爆炸

8 个种子 × 5000 词字典 = 40000 个候选,10 并发 dig+tcp,跑几个小时只出 1 个存活。

修复:只对主域一级爆破,不对二级子域再套娃。第 2 轮套娃只对高价值种子(dms/tms/crm 这类)小字典。

最终方案:两阶段工程化

把所有坑踩完,沉淀出两阶段方案:

阶段一:预筛 prescreen.sh
  裸域 A 记录 + 被动源命中数 + 泛解析检测
  → 判定:废弃 | 半死 | 有效 | 泛解析
  → 避免对无资产域名空跑数小时

阶段二:多轮枚举 multi_round_enum_v5.sh
  第 1 轮:subfinder 被动收集(CT日志/公开DNS/搜索引擎,不碰目标)
  第 2 轮:高频词 Top100 快筛 → 已发现子域 × 字典深挖
  每一轮:dig +tcp 严格 A 记录验证 + 双 resolver 重试
  泛解析 IP 过滤 → 断点续跑 → 汇总存活(域名+IP+HTTP状态)

核心原则:

  1. 被动打底 → 主动补深:第 1 轮 OSINT 不碰目标,挖出 idp-eagleyun、zhaopin 这类字典里没有的内部专属名;第 2 轮爆破补漏通用名
  2. 深度优先于广度:挖到 12 个高质量资产 > 挖 500 个假阳性
  3. 结果可复核:每一步留中间文件,任何判定都能回溯

效果

  • 假阳性率:85% → 0(严格 IPv4 验证 + 泛解析过滤 + 双 resolver)
  • 预筛阶段直接废弃无资产域名,节省数小时空跑
  • 输出"域名 + IP + HTTP 状态"可直接进入端口扫描环节

代码开源:github.com/WzzZh-zh/subdomain-enum-toolkit

经验一句话:枚举工具的 bug 不会让工具崩溃,它会让结果"看起来对"——而"看起来对"比报错可怕一万倍。资产收集的核心不是跑得快,而是结果可信。