从 85% 假阳性到 0:子域名枚举踩坑实录
资产测绘实战踩坑:580 个子域名结果 85% 是假阳性,从 grep -qv '^$' 空应答误判、泛解析、UDP 53 被墙到裸域漏验,逐个排坑后沉淀出"预筛 + 多轮枚举"两阶段方案,误报率降至 0。代码已开源。
从 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 轮 OSINT 不碰目标,挖出
idp-eagleyun、zhaopin这类字典里没有的内部专属名;第 2 轮爆破补漏通用名 - 深度优先于广度:挖到 12 个高质量资产 > 挖 500 个假阳性
- 结果可复核:每一步留中间文件,任何判定都能回溯
效果
- 假阳性率:85% → 0(严格 IPv4 验证 + 泛解析过滤 + 双 resolver)
- 预筛阶段直接废弃无资产域名,节省数小时空跑
- 输出"域名 + IP + HTTP 状态"可直接进入端口扫描环节
代码开源:github.com/WzzZh-zh/subdomain-enum-toolkit
经验一句话:枚举工具的 bug 不会让工具崩溃,它会让结果"看起来对"——而"看起来对"比报错可怕一万倍。资产收集的核心不是跑得快,而是结果可信。