这几天刚处理完一起勒索软件应急,深夜两点多,客户内网三百多台机器持续回连恶意C2,域名每十五分钟换一批。传统封堵IP和域名的路子根本跑不赢对方的域名生成算法,整个排查组都盯着防火墙日志无从下手。这时候我们前一天刚搭好的被动DNS数据库成了最大的救兵——输入一个刚捕获的恶意域名,几秒内拉出过去48小时它解析到的所有IP地址、同批出现的兄弟域名,以及哪些内网主机曾经查询过它。顺着关联关系,半个小时内锁定全部感染机器。这件事之后我更加确信,对于任何有规模的安全团队来说,被动DNS数据库不是“可选”的基础设施,而是威胁狩猎和溯源的刚需。
这篇就围绕“自建被动DNS数据库”展开,讲清楚它的原理、数据模型、选型思路、落地实现和实际运行中的坑。如果你是安全工程师、渗透测试人员或运维负责人,打算在内部搭建一套属于自己的被动DNS数据底座,这篇可以直接当备案笔记用。
1. 被动DNS到底是什么,和主动扫描区别在哪里
1.1 主动枚举与被动监听的本质差异
要理解被动DNS,必须先厘清它和主动DNS扫描的不同。主动扫描是“你自己去问”DNS系统:选定一个目标域名或IP段,用字典、爆破、接口枚举等方式主动发起查询,“扫”出一份当前时刻的资产映射。这类工具很多,fierce、dnsrecon、massdns都干这事儿。主动扫描的优点是见效快,一轮命令就能拿到一份解析列表;缺点是它只反映“问的那一刻”的状态,且对方一旦切换解析,历史就断了。
被动DNS的思路恰好相反,它不去“问”,而是“听”。在递归DNS服务器或权威服务器的链路上,把流过的真实DNS查询和应答记录下来,持续归档。这样积累下来的东西,天然是不同用户、不同时间、不同客户端发起过的真实查询样本拼出来的拼图。它回答的问题不是“某个域名现在解析到哪”,而是“这个域名在过去某段时间内,分别解析到过哪些IP,这条解析关系维持了多久”。
我老婆逛街时我常拿这个打比方:主动扫描是举着望远镜冲对面大楼一间一间地喊“有人吗”,被动DNS是在楼道里装摄像头,谁什么时候进了哪扇门,全部有录像。后者看到的虽然不完整,但每一条都是真发生过的,没有推测成分。
1.2 为什么安全团队需要“自己”的数据底座
现在市场上其实有很多商业被动DNS服务,比如VirusTotal、RiskIQ、ThreatStop这些平台都提供基于被动DNS的数据查询。花钱买API确实最省事,但真正用起来会发现,商业平台的数据有三层限制。
第一层是覆盖范围。商业被动DNS平台的数据来源主要是它们自己的传感器网络,覆盖的递归服务器数量和地理范围再大,也未必覆盖你内网用户实际访问的路径。你自己的网络里发生的查询,看不到就是看不到。第二层是数据延迟。商业平台的DNS数据从采集、清洗、入库到可查询,通常会有几小时到一天的延迟,应急响应时根本等不起。第三层是查询策略和配额。应急时往往需要短时间内跑大量域名和IP的反查,API配额的瓶颈会直接卡住节奏,而且这类数据查询频繁也会触发风控审查。
自建一套,意味着数据你可以完全掌控,覆盖范围精确到自己的网络边界,查询可以任意频次,还能通过内部自己的威胁情报渠道做关联性验证。更关键的是,接入自己的SIEM、EQL、XDR等系统时,数据可以无缝融合,不用在外部平台和内部系统之间来回倒数据。
实际搭完跑起来以后,你会发现被动DNS数据库的价值密度远超预期。它既能做恶意域名回溯、内网失陷主机定位,还能做资产测绘、基线和违规访问发现,相当于给整个安全运营中心加了一台“DNS录像机”。
2. 被动DNS数据模型:从一条DNS消息到聚合记录的完整旅程
2.1 一次DNS查询中,哪些字段值得记
要明白被动DNS的数据结构,先得搞清楚一条DNS消息到达采集点时,里面到底藏了什么信息。假设内网某台客户端向递归服务器发起一条查询:
www.example.com A?
递归服务器最终拿到应答后,把结果回传给客户端。采集点如果架在递归服务器边上,拿到的就是完整的一条事务:客户端IP、递归服务器内部标识、查询域名、查询类型(A/AAAA/NS/MX等)、应答内容(比如一组IP或CNAME链)、响应码、时间戳。
这里面真正进入被动DNS数据库的核心字段,我认为是下面五个:
| 字段 | 说明 | 示例 |
|---|---|---|
| timestamp | 观测到的Unix时间戳(UTC) | 1710123456 |
| fqdn | 被查询的完整域名(小写、去尾部点号) | www.example.com |
| qtype | DNS查询类型 | A, AAAA, MX, CNAME |
| answer_ip | 应答中返回的IP地址 | 93.184.216.34 |
| rcode | 应答状态码 | NOERROR, NXDOMAIN |
很多团队还会额外记录client_ip(发起查询的客户端IP)和qname的父级域名,用于内网失陷主机的定位和资产聚类。但在最朴素的被动DNS设计中,原始的三元组(qname, qtype, answer_ip)加时间戳,已经足够支撑大部分安全场景。
2.2 原子记录与聚合记录:一桶数据为什么要“两遍法”
原始DNS消息如果全部落库,那就是天文数字。一个中等规模的递归DNS服务器,一天的查询量轻松过亿。每条查询都存一行原子记录,跑几个月之后存储和查询压力都会失控。所以被动DNS工程实践里有一个共识:必须区分“原子观测记录”和“聚合记录”两个层级。
原子记录就是上面说的,每条DNS应答一条数据,原样落库,核心用途是审计溯源,回答“在某个时刻某台客户端到底查没查过某个域名”这类精确问题。聚合记录则是把同一对(qname, answer_ip)在一段时间内的所有观测合并成一行,只保留第一次和最后一次出现时间,以及出现次数。聚合记录的典型结构是:
fqdn, answer_ip, first_seen, last_seen, query_count
举例来说,2024年6月1日里,www.example.com在这天被内网用户查询过328次,其中231次解析到93.184.216.34,97次解析到另一个IP。原子记录里这是328行数据;聚合记录里就变成了两行。一进一出,存储量压缩了99%以上。
聚合数据承担90%以上的日常查询需求,恶意域名回查、IP关联、历史解析线等跑的都是它。原子数据则只在事件定责、逐包审计时才需要深挖到那一层。
这个“两遍法”的设计,本质上是拿查询效率换存储空间,再用原子层做精确兜底。后面第三章讲部署架构的时候,你会发现这种分层还影响数据库表设计和分区策略。
2.3 聚合的窗口和多粒度设计
聚合的粒度不能只有一个。实际经验里,我最常用的聚合窗口是天级别,因为被动DNS的威胁狩猎基本上以天为单位看横向关联。但有时为了应急响应,要回溯某个小时内的解析变化,如果只有天级聚合,就会因为过度合并丢失关键时间点。
比较成熟的方案是设计两级聚合:小时级聚合和天级聚合。小时级用于应急回溯,天级用于长期存储和趋势分析。两张表用同一套清洗逻辑生成,查询时根据时间范围自动选择对应的粒度。多一层表,本质上是在查询响应速度和存储成本之间做一个更细的平衡。
3. 自建被动DNS系统的架构设计与核心选型
3.1 整体架构:采集、传输、解析、存储、查询五层
整个自建被动DNS系统,按数据处理的流向可以分为五层:
- 采集层:在递归DNS服务器侧启用dnstap或抓包工具,将DNS消息实时导出。
- 传输层:把采集到的数据从DNS服务器送入数据处理服务。常用的有protobuf over TCP、Kafka、或者最简单的直接HTTP POST批量透传。
- 解析层:把dnstap或PCAP解析成结构化字段,做归一化、去重、父子域名拆解。
- 存储层:按聚合策略写入数据库,这里直接决定你能查多快、存多久。
- 查询层:对外提供HTTP API或CLI工具,实现fqdn反查IP、IP反查域名、时间段过滤等核心功能。
五个层里,最容易做烂的是解析层与存储层的衔接。很多团队在采集和解析上花了很多精力,最后却因为存储设计不对,查询时什么都跑不出来。
3.2 采集端选型:dnstap是默认首选
采集层目前的主流方案有两个。一个是dnstap,基于protobuf定义的一种结构化日志格式,支持BIND9.14+、Knot DNS、Knot Resolver等服务器。另一个是老传统——在交换机上做端口镜像,把DNS查询的流量镜像到采集服务器上,再用tcpdump转PCAP落地。
dnstap的优势很明显:它输出的不是原始数据包,而是结构化消息,天然包含query和response的配对标识,字段清洗时省去大量解析工作。而且dnstap对DNS服务器的性能影响要比全量PCAP小得多,毕竟不用处理网络层的其他噪声。如果你用的是BIND,配置一个dnstap输出通道只需要改named.conf里的几行,把dnstap的输出路径指向一个本地的unix socket,再用一个轻量的收集进程把数据读出转发到Kafka或直接写入存储,就这么简单。
端口镜像的方案默认不推荐,除非你的DNS服务器不支持dnstap。因为PCAP里混杂着大量TCP重传、乱序、恶意扫描等无关流量,清洗成本高,采集服务器负载也大。真碰到了必须抓包的情况,也优先考虑用bro/zeek或suricata这类成熟的流量分析框架去解析DNS字段,别自己造轮子硬啃DNS报文。
3.3 存储选型比较:为什么最终选了ClickHouse
存储层是整个系统的重头。先列一个真实选型时的对比表:
| 方案 | 写入吞吐 | 查询能力 | 运维复杂度 | 适用规模 |
|---|---|---|---|---|
| SQLite | 低,单写入锁 | 简单 | 极低 | 学习原型、小规模测试 |
| PostgreSQL | 中,可并行但受限于单机 | 强,支持复杂JOIN | 中 | 百万级聚合记录的MVP |
| ClickHouse | 极高,列式存储 | 强大,聚合函数丰富 | 中偏高 | 亿级以上数据量 |
| 向量/图数据库 | 适中 | 偏向语义搜索/关联遍历 | 高 | 不适合作为主要DNS存储 |
如果你只是自己在VPS上跑着玩,验证一下被动DNS的逻辑,SQLite完全够用。但真正用于生产,我必须推荐ClickHouse。理由有两条:第一,被动DNS的基础查询是典型的按域名或按IP做前缀/后缀匹配,本质上是列式存储的拿手戏;第二,DNS数据的时间序列特征非常明显,ClickHouse的按时间分区、TTL策略可以做得非常优雅,PostgreSQL要人工维护分区表,工作量会大很多。
有朋友提过“被动DNS不是有实体关系吗,是不是适合用图数据库”。理论上确实可以建图,域名和IP是节点,解析关系是边。但实际工程里,被动DNS的原始数据先落ClickHouse,再定期把重点实体导入图数据库用于关联分析,是更经济可行的路径。拿图数据库当主存储,要么是节点数太多了管理不过来,要么是热查询的性能扛不住,两头都难受。
3.4 查询API设计:前缀、后缀与通配是要点
查询层经常被忽略,但恰恰是它决定了被动DNS数据能不能被上层工具顺畅使用。在我设计的被动DNS查询API里,有三个核心接口:
- GET /dns/fqdn/{fqdn}:输入域名,返回该域名的解析历史线(IP列表、首末时间、次数)。
- GET /dns/ip/{ip}:输入IP,返回该IP关联过的所有域名。
- GET /dns/search?q=*.example.com:模糊搜索,支持前缀、后缀通配。
还有一个容易踩的坑是域名后缀匹配。恶意软件生成域名时,通常只有随机前缀差异,而父域和根域名是固定的。所以查询API必须高效支持“example.com匹配一切*.example.com”这类后缀匹配。工程上有两种做法:一是存方言加全文索引;二是先反写域名(例如www.example.com改存为com.example.www),再用前缀索引查询。我自己实测下来,反写法配合索引,在千万级数据量下响应能稳定在百毫秒级别,全表LIKE匹配则慢到没法用。
4. 数据清洗与质量保障:决定数据库有没有“信服力”
4.1 归一化的边界条件:大小写、尾部点号、IPv6压缩
被动DNS数据库里最怕脏数据。所谓“脏”,就是同一个域名在某个地方写成大写的,换个来源又变成全小写;有的带尾部点号,有的不带;IPv6地址有的压缩写,有的完整写。如果这些没有在清洗阶段统一归化,后面做聚合查询时出来的结果会漏掉大量关联。
我定了一套清洗规则,每条消息在写入前必须走一遍:
- 域名强制转小写(lowercase),因为DNS本身对大小写不敏感,做数据库时统一成小写才能聚合。
- 去掉FQDN末尾的根点(root dot),也就是说www.example.com.和www.example.com要合并。
- IPv6统一用RFC 5952压缩格式存储,同时必须把内嵌IPv4的映射形式2001:db8::192.0.2.1转成纯IPv6。
- 时间戳统一存UTC,任何时区转换在查询层再做,数据库里永远是标准时间。
这些规则简单,但90%的被动DNS项目一开始都漏了其中一两条,然后聚合结果看着怪,排查半天才发现是大小写没统一导致的“同一个域名拆成两半”的诡异现象。
4.2 CNAME链的处理:别把情报上下文丢了
DNS应答里最常见的复杂情况是CNAME链。比如查询cdn.example.com,应答可能是cdn.example.com是www.CNAME,然后www又是另一个CNAME,最终落到一个具体IP上。很多初次搭被动DNS的人,会图省事直接把最终IP绑到原始查询域名上。这种做法的危险在于,你会丢失CNAME链上的情报上下文。
举例来说,恶意软件查询baddomain.com,CNAME转到evil-cdn.net,再由evil-cdn.net解析到C2 IP。如果只记录baddomain.com到C2 IP的映射,回家查的时候,关联到evil-cdn.net这条路径就中断了。正确做法是每条CNAME关系都单独存一行,即“父域名→CNAME目标域名”和“CNAME目标域名→IP”分别入库。查询时再做一次递归展开,就能拿到完整的解析链。虽然存储多了一倍,但情报价值高很多。
4.3 动态DNS和快速轮换:稳定与抖动的区分
被动DNS数据的另外一个痛点,来源于动态DNS。很多合法服务使用DDNS,域名和IP之间并没有稳定的长期绑定。恶意软件混在DDNS的域名里,让“域名到IP的映射”漫天飞。
对付这个问题的思路不是直接命中处理,而是给“稳定性”打一个标签。具体做法是在聚合记录里加一个stability_score字段,比如某个域名在24小时内出现了50个不同IP,那它的stability_score就接近0,基本判定为DDNS或快速翻转域名;如果24小时内只出现1个IP,那stability_score接近1,说明是静态解析。
做威胁狩猎时,优先级通常先看高稳定性域名,再看抖动极高的域名。就我的经验,2小时内解析超过10个不同IP的域名,中招概率远高于静态域名,这个指标在应急响应里太关键了。
4.4 数据生命周期管理:TTL不等于“删除时间”
DNS记录里有个TTL字段,表示缓存时间。但被动数据库里绝不能直接把TTL当成过期时间来清理数据。原因在于DNS的实际解析行为受递归服务器缓存策略影响,同一个域名可能在TTL过期前就重新请求,也可能TTL过了但实际访问端还在用旧IP。简单按TTL清理,会丢掉大量“看起来过期但实际还在用”的解析关系。
更合理的办法是给数据加“last_seen”字段并定期更新,只有当某条聚合记录在N天(比如90天)内再没有被观测到,才移入冷存储或允许被清理。这样既能保证基础数据长期积累,又让数据库不会无限膨胀。ClickHouse的数据TTL策略正好支持按时间字段自动清理,设好之后运营基本不用管。
5. 从零到可用:一套最小化被动DNS系统的完整落地
5.1 环境准备:这台搭建用了什么
为了写这篇,我把这套被动DNS数据库又完整走了一遍。环境如下:一台8核16G内存的Linux服务器,装BIND 9.18作为递归DNS,采集端用dnstap,后端用ClickHouse单机版,Python 3.10做数据处理。
如果你确实没有现成的DNS服务器实训,也可以先用一个实验域名的权威服务器模拟。流程是一样的,唯一的差别是递归服务器场景下能看到用户查询,而权威服务器场景下只看到缓存未命中时少数真实查询。真实投入生产时,还是要架在递归层。
5.2 BIND 9.18上的dnstap配置
BIND启用dnstap很直接,在named.conf的options里加上这几行:
options { dnssec-validation auto; listen-on port 53 { any; }; dnstap { output unix "/var/run/dnstap.sock"; query yes; response yes; }; };这段配置的意思是把所有query和response通过unix socket输出到/var/run/dnstap.sock。实际使用中,query和response都开,但下游清洗时主要消费response;query的作用主要是匹配应答,以及分析客户端发起查询的模式。
配置好之后重启bind,是重新生成socket并开始发送数据。
5.3 Python端的消息读取与清洗
BIND本身只是把dnstap消息写到socket,真正要消费它,需要写一个Python守护进程。可以用的库是farsight的dnstap-helper,或者直接用python包“dnstap”。
读取逻辑伪代码如下:
import dnstap from dnstap import message_pb2 import clickhouse_connect def parse_dnstap_data(data): dnstap_msg = message_pb2.Dnstap() dnstap_msg.ParseFromString(data) if dnstap_msg.type == message_pb2.Dnstap.MESSAGE: # 取query和response字段 msg = dnstap_msg.message fqdn = msg.query_name.decode('utf-8', errors='ignore') qtype = msg.query_type rcode = msg.response_code answers = [] for rr in msg.answer: if rr.type == 1 or rr.type == 28: # A或AAAA answers.append(rr.rdata) # 归一化 fqdn = fqdn.rstrip('.').lower() # 组装入队列等待批量写库5.4 入库表设计与批量写入
ClickHouse的表结构设计很重要,按天分区配合聚合表是我验证过最稳的组合。原子表和聚合表分别如下:
CREATE TABLE dns_atomic ( ts DateTime, fqdn String, qtype UInt16, answer_ip IPv6, client_ip IPv6, rcode UInt8 ) ENGINE = MergeTree() PARTITION BY toYYYYMMDD(ts) ORDER BY (fqdn, ts); CREATE TABLE dns_aggregated ( fqdn String, answer_ip IPv6, first_seen DateTime, last_seen DateTime, query_count UInt64 ) ENGINE = SummingMergeTree() PARTITION BY toYYYYMMDD(last_seen) ORDER BY (fqdn, answer_ip);聚合表用了SummingMergeTree,它会自动按(fqdn, answer_ip)合并数据,query_count自动求和。first_seen和last_seen可以提前在清洗层算好,也可以在落库后用AggregatingMergeTree的min/max函数实现。个人建议在清洗层直接算好批量插入,减少数据库的合并压力。
写入方式建议批量每次1000到5000条批量insert,千万不要逐条insert,否则ClickHouse处理不过来的同时还会触发太多小分区合并,性能直接垮掉。
5.5 查询侧:FastAPI接口与缓存
查询接口我直接用FastAPI写,接入ClickHouse。核心是fqdn反查IP和IP反查域名两个接口。最关键的是在接口上做好“布尔判断缓存”,用布隆过滤器在内存里预判一个fqdn是否真实存在,防止恶意构造大量随机域名把数据库打挂。
简单示例:
@app.get("/lookup/fqdn/{fqdn}") def lookup_fqdn(fqdn: str): fqdn = fqdn.rstrip('.').lower() if not bloom_filter.contains(fqdn): return {"data": [], "hit": False} rows = client.query( "SELECT answer_ip, first_seen, last_seen, query_count " "FROM dns_aggregated WHERE fqdn = {fqdn:String}", parameters={"fqdn": fqdn}, ) return {"data": rows.result_rows, "hit": True}布隆过滤器本身要定期和ClickHouse同步,一般每小时全量同步一次字典,增量通过binlog追。这个设计能挡住90%的无意义随机查询。
6. 真实运行中的坑:数据膨胀、时间错位与并发写入
6.1 时区问题:所有服务器统一UTC
刚上线那一个月,遇到过好几次“数据怎么突然少了几个小时”的诡异现象。查到最后全是时区惹的祸。有的服务器用UTC,有的用Asia/Shanghai,清洗层如果用了系统本地时区而非显式UTC,就会出现“当天数据被归到前一天分区”的错位。
千万别相信“服务器时区都设置成一样就没事”的说法。Docker容器、云函数、虚拟机镜像,任何一个环节临时用了本地时间,都会导致数据错位。最彻底的办法是在代码里硬编码timezone.utc,任何datetime一律带时区对象,写入ClickHouse前强转为UTC时间戳。
6.2 数据膨胀:原子表的保留策略
原子表的增长速度远超预期。我曾经一台递归服务器,一天产生30GB的原子记录,跑八天后单机磁盘就要爆。后来总结出三个减负手段:第一个是采集端过滤,丢弃rcode非NOERROR的无应答查询,这类查询在正常网络里占比可能高达40%,暂时没有情报价值;第二个是在清洗层做分钟级窗口聚合,把同一个fqdn+answer_ip在同一分钟内的多次应答直接合并成一行,查询数从每行变成count;第三是原子表只保留30天,再老的数据全部删掉,只留聚合表。
经过这三刀之后,一天的原子记录从30GB降到不到2GB,聚合表进一步降到300MB左右。一台2T的机器可以非常从容地跑一年。
6.3 并发写入与分区合并的冲突
ClickHouse并发批量写入本身没问题,但如果你每批次很小又很频繁,会产生大量待合并的小分区,导致查询性能断崖式下降。调优的实际经验是:把多个输入线程攒到一个共享队列里,由一个调度线程每5秒或每5000条触发一次批量写入。这样单个插入批次维持在2000到5000条之间,分区的合并压力可以基本忽略。
6.4 查询慢排查:ORDER BY键选错了
刚搭建的时候,我总是按ip反查域名很慢。加了老半天索引,后来发现ClickHouse里的排序键选择有门道。如果ORDER BY只设定fqdn,那么IP反查就是一次全表扫描。正确做法是把排序键做成(fqdn, answer_ip),并且复制一张表专门按(answer_ip, fqdn)排序,作为反查专用。两张表数据一致,一个正向一个反向,查起来都是毫秒级。虽然后面多一倍存储,但换来的是所有查询都能走主键索引,非常值得。
6.5 数据一致性与去重:维护一个主键视图
聚合表虽然省心,但SummingMergeTree本质上会有重复行,只是查询时自动合并了sum字段。如果直接把query_count取出来看,会发现偶尔有重行没合并。
维护数据一致性,比较稳的套路是定期跑一个去重视图,按(fqdn, answer_ip)分组,取sink的min最后一个max,再把结果整理成干净的导出表。这个任务每天一次就行,不必实时运行。
7. 被动DNS数据的实战应用:威胁狩猎与溯源
7.1 恶意域名回查:从样本到内网的闭环
被动的价值在于“回溯”。拿到一个恶意样本域名后,在被动DNS数据库里回查它过去的解析历史,就能知道它曾经关联过哪些IP。如果同一IP还解析过别的恶意域名,那这个IP很可能就是同一攻击基础设施上的兄弟节点,顺藤摸瓜就能把攻击者整个C2设备群挖出来。
7.2 内网失陷主机定位:谁查过它
内网主机如果和恶意域名有交互,一定会留下查询记录。用聚合表的client_ip字段反查,把访问过恶意域名的所有内网主机一次性拉出来。这个动作在应急响应里价值巨大,曾经帮我30分钟内定位到几十台不活跃感染主机,避免了扩大排查范围的加班战。
7.3 资产侧的反向应用
态势上,被动DNS还能当资产测绘工具。内网用户隔段时间访问某个管理后台系统,DNS数据库里就会出现该系统的域名和IP映射,即使这个IP从未出现在资产清单里。把那些“被访问但不在资产台账”的IP捞出来,正好用来发现未知影子资产。老生常谈的道理,攻击者最会利用的,恰恰是盲区的资产。
8. 写在最后的一些个人体会
自建一套被动DNS系统,投入产出比相当可观。硬件上不强求,和现有DNS服务器共用一个8C16G的虚拟机就能跑,存储选个2T以上的硬盘就敢说能跑一年。真正的成本在清洗和存储设计,把“两遍法”的架构想明白,数据规范定好,后面所有消费方都会受益。
我给同行的建议是,先别上手就搞大集群、Kafka、多活那一套。用一台机器,跑通dnstap、Python清洗、ClickHouse聚合、FastAPI查询这条链路,存上一周数据,拿几个已知恶意样本域名做一轮物联网,觉得确实有价值再考虑扩展。被动DNS的价值和数据量是成正比的,但最小的可用系统是让人产生兴趣的关键。
到现在为止,这套系统已经在我们环境里稳定跑了半年多。期间帮团队挖出过至少三起被忽视的感染事件,还有两回实际应急里帮上了大忙。如果你所在的团队也经常需要回答“这个域名过去解析到哪”、“内网谁访问过这个IP”这类问题,我建议你动手搭一套。能力不在于工具的堆砌,而在于关键时刻你是否能比别人多一个维度的数据。