☰
自建被动DNS数据库:从流量采集到历史解析全流程实践
2026/10/7 3:09:59 网站建设 项目流程

在安全分析、威胁情报和基础设施追踪这个圈子里摸爬滚打久了,你会发现一个特别扎心的事实:当下看一个域名解析到哪个IP,往往说明不了太多问题。攻击者可以随时改DNS记录,把一个原本干净的域名指向C2服务器,用完又切回去。等你发现的时候,解析关系可能早就变了。所以真正有价值的,不是某一时刻的“快照”,而是长期积累下来的“历史解析轨迹”。这也是被动DNS数据库(Passive DNS Database)的价值所在——它不主动去“问”任何服务器,只是安安静静地采集真实发生的DNS解析流量,把每一次“域名→IP”的关系变化沉淀成结构化数据,供后续回溯、关联和挖掘。

这篇文章写的是我自己折腾一套自建被动DNS数据库的完整过程,从架构设计、数据模型、采集实现到查询分析,全流程拆开来讲,把踩过的坑和想明白的取舍一并放在里面。适合有安全分析或数据工程背景、想自己搭一套历史DNS查询体系的读者参考。如果你只想了解概念,前面两章也能帮助你建立起对被动DNS工作原理的清晰认识;如果你真想动手落地,后面几章可以直接拿来当施工蓝图。

1. 被动DNS到底在解决什么问题

1.1 先说清楚主动DNS为什么不够用

大多数人对DNS数据的获取方式,是“主动探测”。写一个脚本,对某个域名发起解析请求,把返回的A记录、CNAME、MX之类的信息存下来。这类做法有一个致命弱点:你只能看到“现在”的状态,而且前提是你得知道要查这个域名。对于攻击者手里那些用了几周就弃用的临时域名,如果你没有事先盯着它,那么它的解析记录消失之后,你连它曾经解析到过哪里都无从考证。

主动探测还有一个问题,就是容易打草惊蛇。你在对一台服务器做高频DNS解析时,对方如果部署了防扫描机制,完全可以给你返回伪造的结果,甚至对你的探测源做封堵。这就让历史数据的准确性打了不少折扣。而被动DNS从原理上规避了这些问题——它不主动发任何包,只在网络链路上观察已经存在的DNS流量,采集的是真实用户和真实系统发起的解析请求及响应。

1.2 被动数据是“历史的显微镜”

被动DNS的能力可以类比成一台对着DNS协议拍照的相机,它不干涉场景,只默默记录。核心数据形态是“域名、记录类型、记录值、首次出现时间、最后出现时间”这样的结构。举个例子:一个恶意域名taobao-accout-login[.]com第一天的解析结果是5.5.5.5,第二天切到6.6.6.6,第三天彻底不再解析了。这套数据会完整记录下变化过程。分析人员哪怕是在几个月之后回头做调查,也能把这条时间线拉出来,再结合其他数据源判断这是不是一次基础设施迁移,或者一个短命C2域的完整生命周期。

这项技术在国外已经有商业化落地,比如Farsight Security的DNSDB,积累了超过十年以上的历史数据,是业界公认的重要威胁情报来源。国内不少安全厂商也都有自研的被动DNS系统,但大多作为内部能力存在,开源方案和详细实践资料都比较分散。所以对个人研究者或中小企业安全团队来说,自建一套轻量级的被动DNS数据库,是一件投入产出比相当高的事。

1.3 一个自建方案大概能做什么

我这次自建的目标很明确:在我们自己管理的DNS服务器网络出口旁路采集流量,存储解析记录,提供三类核心查询能力。第一类是“IP反查域名”,给一个IP,能找出历史上哪些域名解析到过它,这在追踪托管在云主机上的恶意站点时很有用。第二类是“域名历史解析”,给一个域名,能列出它过去几个月的所有解析记录和时间线,用来判断域名是否被弃用或复用。第三类是“基础设施关联”,通过聚合相同IP上共存的域名,辅助识别同一伙人注册的域名资产。

整套系统麻雀虽小但五脏俱全,涉及流量采集、协议解析、数据清洗、批量入库、查询优化等环节。下面按我实际搭建的路线逐一展开。

2. 自建前的整体设计与关键选型

2.1 采集、存储、查询三层架构

被动DNS系统的架构从逻辑上可以拆成三块:采集层、存储层、查询层。采集层的任务是拿到原始DNS报文,解析成结构化记录;存储层负责把这些记录高效地组织起来,支撑大时间跨度的回溯;查询层则是把存储层的数据以接口或者命令行的形式暴露给分析人员使用。

三个层次如果分开考虑,各环节的替换成本会低很多。例如采集层既可以用旁路抓包实现,也可以直接解析BIND或PowerDNS的日志,两种来源最后都产出相同格式的标准化记录。存储层如果用PostgreSQL做时间分区,后续数据量太大也能平滑迁移到ClickHouse。查询层就更灵活了,可以直接SQL查,也可以包一层HTTP API方便其他系统调用。设计时我给每一层都定义了统一的输出格式,这样哪一层要升级都不至于牵一发而动全身。

2.2 采集点位置决定了数据的“口味”

被动DNS采集到的数据长什么样,很大程度取决于采集点放在网络里的哪个位置,这一点我在设计阶段反复权衡过。

放在权威DNS服务器的出口,能采集到所有针对自己托管域名的解析请求。这类数据适合分析“谁在解析我的域名”,但对于更广范围内的恶意域名发现,覆盖面就比较窄。放在递归DNS服务器的出口,能观察到大量终端用户发起的完整解析路径,数据覆盖面宽,是安全分析场景的首选,因为恶意域名在被受害机器请求时就会露出马脚。再往上走,放在骨干网或ISP侧的链路镜像上,能拿到海量的流量,但随之而来的隐私问题和合规要求也高得多,自建场景一般不建议碰这个位置。

我最终选择的方案是后者,也就是在自建的递归DNS节点上做旁路镜像。具体实现是在交换机上配置SPAN端口,把DNS服务器出入方向的流量镜像到一个专门部署采集程序的服务器网卡上。这个部署方式不影响生产DNS服务自身的稳定性,即使采集服务器宕机,也不会造成DNS解析中断。

2.3 数据库选型:先别急着写代码

很多人一听说要存历史DNS数据,第一反应就是往关系型数据库里扔几条表,然后开始写代码。但被动DNS的数据量是有“毒”的,一个中等规模的递归DNS节点,每秒处理几千个查询非常正常,如果把这些查询不做任何聚合直接落地存储,磁盘再大也撑不了几天。

所以我先把数据库技术选型的关键矛盾想清楚了。被动DNS数据本质上是“时间序列 + 实体关系”的混合体。排在最前面的候选是PostgreSQL,它生态成熟,支持分区表、多种索引类型、批量写入效率也不错,适合搭建初期几亿到几十亿行的规模。再往上一个量级,数据到数百亿行甚至更多时,ClickHouse这种列式存储会更合适,因为它天生适合海量时序数据的压缩和扫描型查询。也考虑过Elasticsearch,它的文本搜索能力很出色,但存储成本相对更高,在做精确点查时性能不如前两者稳定。

这里提一句,被动DNS数据是不适合塞进向量数据库的。它本质上是精确匹配为主的强关联数据,没有“语义相似”这种需求,用向量数据库只会徒增复杂度。

2.4 容量估算先算账再动手

我搭建之前先做了一个粗糙的容量推算,这一步非常重要,能避免系统上线几天就被数据淹掉。

假设采集点每天观察到约2000万条去重之前的原始DNS响应记录。单条记录如果按“域名、类型、值、首次时间、最后时间、来源”六个字段、平均120字节来算,一天就是2.4GB原始数据。如果每月30天,就是72GB,一年接近900GB。这还只是单节点的数据,而且还没有算索引空间——PostgreSQL的索引开销通常是数据本身的1.5到2倍。如果满打满算,一年可能吃掉2TB以上。

这个数字对于个人或中小企业自建来说不算小,但实际上存在非常有效的优化手段:时间窗口内合并。被动DNS数据天然存在大量重复,同一个域名在一个小时内可能被不同用户反复查询几百次,但它的解析结果并没有变。聚合窗口把这些重复记录合并成一条“first_seen 到 last_seen”的记录,数据量能降一至两个数量级。这个优化在采集端就做,尽量在数据进入数据库之前把水分挤掉,效果极其明显。

3. 数据模型设计:核心表与关键字段

3.1 一张核心表打天下

先看我的建表语句,实际使用中一张核心表就可以覆盖绝大多数场景:

CREATE TABLE dns_record ( id BIGSERIAL, first_seen TIMESTAMPTZ NOT NULL, last_seen TIMESTAMPTZ NOT NULL, rrname TEXT NOT NULL, rrtype TEXT NOT NULL, rdata TEXT NOT NULL, source TEXT NOT NULL DEFAULT 'local' ) PARTITION BY RANGE (first_seen);

字段的含义拆开来说。first_seen和last_seen是这条记录在观测窗口内首次和最后出现的时间,解决“同一个域名在窗口内多次出现”的冗余问题。rrname是DNS记录的名字,也就是查询的域名,统一存成小写并且去掉末尾的点。rrtype记录的是类型,A、AAAA、CNAME、MX、NS、TXT等。rdata是对应的记录值,A记录存IP,CNAME存目标域名,MX存邮件服务器。source字段用来标记数据来源,比如来自采集节点A还是节点B,这在多采集点汇总时非常关键。

3.2 为什么所有值都存成文本

可能有读者注意到,我把所有值都统一用TEXT存储,没有单独把IP地址拆成inet类型。这是经过权衡的。对于纯A记录的IP过滤查询,inet类型确实更方便,但被动DNS数据的rdata里还混着CNAME域名、TXT记录字符串、MX优先级等五花八门的格式,如果每种类型都单独建列甚至建表,查询逻辑会非常复杂,存储结构也会变得臃肿。

统一存文本的设计牺牲了一点点类型专属的查询性能,但换来了极大的灵活性。实际查询时,我需要做的通常是“这个字符串值出现在哪些记录里”,对这种等值匹配场景,TEXT类型配合正确的索引完全够用。后期如果某一类记录(比如纯A记录)数据量特别大且查询频率很高,完全可以额外建一张物化视图或专用表来承接,没必要在核心存储层做过度设计。

3.3 索引与分区的“组合拳”

表建好之后,索引是决定查询性能的核心。我的核心查询模式就两类:按域名查历史、按记录值反查域名。针对这两类,我建了如下索引:

CREATE INDEX idx_dns_record_rrname ON dns_record (rrname, rrtype, last_seen DESC); CREATE INDEX idx_dns_record_rdata ON dns_record (rdata, rrtype, last_seen DESC);

rrname索引解决的是“查某个域名的历史解析”,联合了rrtype和last_seen,方便按时间倒序快速返回最近记录。rdata索引解决的是“查某个IP历史关联过的域名”,同样组合了时间和类型。做了分区之后,数据按时间自动散落到不同分区,查询时如果带上时间范围条件,PostgreSQL的分区裁剪机制会自动跳过无关分区,性能提升非常明显。

分区键的选择也有一点讲究,我用的是first_seen的RANGE分区,按月划分。例如把2025年1月的数据独立放一个分区。有个好处是,历史数据如果太旧需要归档或清理,直接删掉整个分区、或者把它DETACH出来打包存到冷存储,都是秒级操作,不用去和几亿条DELETE语句搏斗。

如果你计划长期保留海量数据并且查询窗口经常跨越一整年,还可以考虑在分区的基础上再叠一层物化汇总表,把每天每个域名的最后一次解析单独存出来。这种分层设计能让最常用的查询路径变得极短,但属于后续优化了,初期先不用急着上。

4. 采集端实现:抓包、解析、入库

4.1 旁路抓包那点事

采集端我用的是旁路流量镜像的方式。交换机SPAN端口把DNS服务器进出的UDP/TCP 53端口流量镜像到采集服务器,采集服务器上跑一个抓包进程,用libpcap抓取原始报文。这里有一个细节必须提醒:DNS查询通常走UDP,但当响应数据量较大时,服务器会设置TC标志位,客户端会转而用TCP重发请求。所以TCP 53端口的流量一点都不能漏,只抓UDP会丢掉大量真实数据。

抓包进程本身不需要太高深的技巧,关键是处理能力要跟得上。我在采集服务器上设置了抓包缓冲区大小并开启了多线程轮询,线上实测在每秒几千个包的情况下丢包率几乎为零。如果峰值流量更高,建议用DPDK或者AF_PACKET的零拷贝机制做优化,但这个属于进阶话题,初期用libpcap足够了。

4.2 用Python实时解析DNS报文

解析环节我用的是Scapy库的DNS模块,因为它的DNS字段解析非常完备,代码写起来很直观。主要逻辑是:过滤出源端口或目的端口为53的UDP/TCP包,提取DNS层,只响应包(DNS响应),遍历Answer区域,把资源记录转换成结构化的元组。一个简化版的核心代码示例如下:

from scapy.all import sniff, IP, UDP, TCP, DNS def handle_packet(pkt): # 只处理DNS协议层,非DNS包直接丢弃 if not pkt.haslayer(DNS): return dns = pkt[DNS] # qr=1表示DNS响应,被动DNS只关心真实解析结果 if dns.qr != 1: return # 查询名 if dns.qd is None or not dns.qd.qname: return qname = dns.qd.qname.decode().rstrip('.').lower() # 遍历Answer段 for ans in dns.an: rrname = ans.rrname.decode().rstrip('.').lower() rrtype = ans.type rdata = bytes(ans.rdata).decode(errors='replace') # 在这里把 (rrname, rrtype, rdata) 交给清洗模块 emit_record(rrname, rrtype, rdata) # 抓包入口:监听eth0网卡,过滤53端口 sniff(iface="eth0", filter="port 53", prn=handle_packet, store=False)

这段代码作为原型很清晰,但生产环境不建议直接拿去用。有几个容易被忽略的问题:第一,压缩指针问题。DNS报文里的域名有时候不是完整写出来的,而是用指针指向报文其他位置的重复片段,Scapy解析后虽然大多能正确处理,但极端情况下还是要自己解压才稳妥。第二,rdata可能包含二进制内容,直接decode成文本后会有乱码,需要做好清洗和截断。第三,重复应答。同一个查询可能在Answer段里携带多条A记录,这是合法的,但每一条都要独立记录,不能只取第一条。

4.3 去噪与合并入库

从抓包到入库之间还有一道至关重要的工序——合并去重。这个过程在系统里叫“归一化”,作用是把同一时间窗口内同一(域名、类型、值)的重复出现合并成一条记录,只更新最后出现时间。

我的实现方式是每个采集节点内置一个滑动窗口,窗口长度为60秒。窗口内用一个字典结构(或者直接上Redis)维护“域名|类型|值”到记录对象的映射,命中已存在的键就更新时间戳,没命中就新建。窗口期满后,把所有记录批量写入数据库。这种设计有两个好处:写入量大幅减少,同时数据库里第一次和最后一次出现时间本身就是接近实时的。

注意不要用“同一请求”作为消重键,因为同一个响应里可能既有A记录又有CNAME记录,还要区分不同来源。我的具体经验是:消重键必须包含“来源节点标识 + 域名 + 类型 + 值”四个要素,时间窗口内的所有记录共享这个键。

4.4 入库性能的几条硬经验

数据库插入环节,第一条硬经验是不要逐条INSERT,那是性能灾难。我用的是批量插入,每凑够500条或者每隔2秒提交一次。如果是PostgreSQL,可以直接用COPY命令把一堆记录灌进去,比INSERT快一个数量级。不过批量插入要保证幂等性,所以我加了应用层的去重逻辑,让每个入库批次里不会出现完全重复的记录。

第二条硬经验是设置合理的work_mem和maintenance_work_mem。PostgreSQL排序和索引维护都吃内存,如果参数太保守,插入数据时容易频繁触发磁盘临时文件,拖慢整个入库链路。我在测试环境把maintenance_work_mem从默认的64MB调到了512MB,分区索引的构建速度快了不少。

第三条是定期VACUUM和ANALYZE。PostgreSQL的MVCC机制会产生大量死亡元组,如果不清理,表的膨胀会越来越严重,查询越跑越慢。我写了一个每周定时任务,对核心分区表做一次VACUUM ANALYZE,并在非高峰期手动触发分区维护。

5. 查询与典型分析场景

5.1 IP反查域名:最常用的一个功能

系统落地后,我第一个实现的功能就是IP反查。这个功能在威胁情报场景里几乎是天天用,给一个恶意IP,看看有哪些域名解析到过它,就能顺藤摸瓜找到更多关联资产。

一个典型的SQL长这样:

SELECT DISTINCT rrname, rrtype, rdata, min(first_seen) AS first_seen, max(last_seen) AS last_seen FROM dns_record WHERE rdata = '6.6.6.6' AND first_seen >= now() - interval '90 days' GROUP BY rrname, rrtype, rdata ORDER BY last_seen DESC;

注意我用的是rdata等值查询,配合(rdata, rrtype, last_seen DESC)这个索引,即使数据量超过10亿行,查询也可以在百毫秒内返回结果。如果你需要排除某些数据源,可以再加source条件,比如只查来自某个采集点的数据。

5.2 域名历史解析追踪

第二个高频查询是域名历史解析追踪。调查一个可疑域名时,我需要完整看它从第一次出现到现在解析过的所有IP、CNAME、NS记录变化。这个SQL相比反查只差一个条件字段,把rdata条件换成rrname即可。输出建议按“记录类型 + 时间线”分组,方便快速浏览。

这里有一个额外的细节:CNAME链的处理。查询cdn.example.com时得到的应答里往往带着一条cdn.example.com CNAME target.example.net和一条target.example.net A 6.6.6.6。这些记录应该分别入库,否则无法还原完整的解析链路。反查时如果发现某个IP同时关联了大量看起来像随机生成的子域名,那很可能就是一个CDN节点或者恶意批量域名,这两者要在分析逻辑里区分开。

5.3 基础设施关联分析

当数据积累到一定量级,可以用聚合查询做基础设施关联分析。比如找“共享同一批IP的域名组”:

SELECT rdata, count(DISTINCT rrname) AS domain_cnt, array_agg(DISTINCT rrname ORDER BY rrname) AS domains FROM dns_record WHERE rrtype IN ('A', 'AAAA') AND first_seen >= now() - interval '30 days' GROUP BY rdata HAVING count(DISTINCT rrname) BETWEEN 2 AND 20 ORDER BY domain_cnt DESC;

这个查询能找出那些“域名不多不少”的IP。只有几个域名共享的IP通常是正常的小型服务器;如果几十上百个域名都堆在一个IP上,则可能是虚拟主机或某种恶意批量托管,需要结合其他维度进一步判断。当然,真正的分析不会只看DNS数据这一维,但被动DNS数据作为关联分析的起点,能把调查范围收敛得非常快,省去大量手工枚举的时间。

6. 常见问题与排查技巧实录

6.1 数据类型混杂导致的查询翻车

我先踩的一个坑是数据类型的脏值。TXT记录里经常带有特殊字符,比如分号、双引号、反斜杠等,这些值直接进了文本字段后,如果查询时没有正确转义,SQL语句直接报语法错误或者返回空结果。解决方式有两种:入库前做一层白名单清洗,把非打印字符替换掉;查询层使用参数化查询,避免拼接SQL字符串。我强烈建议两条都做,脏数据只是时间问题。

另一个典型的脏数据问题是域名大小写和末尾点的处理。DNS协议本身不区分大小写,但在数据库里大小写不一样的字符串会被当成不同值。我在清洗环节统一用lower()和rstrip('.')归一化,否则同一个域名会分裂成多行,统计数字也会失真。

6.2 时间范围条件失效的陷阱

分区表有个很隐蔽的坑:如果查询时没有在WHERE里带上分区键first_seen,PostgreSQL就只能扫描所有分区,性能瞬间崩塌。我一开始写的反查SQL就没有带时间条件,结果在测试环境扫一次全表要几十秒,加了时间范围条件之后查询时间从“不可用”直接降到“毫秒级”。

更隐蔽的是,如果用last_seen作为筛选条件而不是first_seen,分区裁剪也会失效,因为数据是按照first_seen分区的。我的建议是:查询统一用first_seen做时间过滤,同时配合对last_seen的等值或范围条件做二次筛选,这样既能利用分区裁剪,又能保证语义正确。

6.3 磁盘写满与数据归档策略

被动DNS是持续写入型系统,写满磁盘几乎是一个必然事件,只是时间早晚问题。我的做法是在设计阶段就预留了数据分层策略:热点数据保存在本地SSD上,超过6个月的旧数据转存到低成本冷存储,超过两年的数据可以彻底清理或者按需导出。

实现方式也不复杂,就是定期运行一个归档脚本:对旧分区执行DETACH PARTITION,然后把对应的数据文件转存到对象存储或者外部归档库,需要查旧数据时再临时挂载回来。这个操作在PostgreSQL里是原生支持的,安全性很高,不会影响在线数据的写入。

6.4 别忘了监控与告警

最后一个提醒,虽然听起来像老生常谈,但实际翻车概率极高:被动DNS系统是一个典型的“持续写入型”服务,不像普通的Web站点那样有明显的外部访问,出了问题很难第一时间被发现。我给系统配了三个关键监控指标:采集端每分钟抓包数量、入库端每分钟插入条数、数据库磁盘剩余空间。前两个指标一旦出现断崖式下跌,说明抓包进程挂了或者上游镜像断了;磁盘空间则是预警信号,要在写满之前提前介入扩容或归档。

这套自建的被动DNS数据库实践下来有一个很深的体会:被动DNS系统不是一个写一遍代码就能永久跑通的项目,它更像一个需要持续维护的数据管道。真正的价值并不在于代码有多炫酷,而是长期稳定地把高质量的历史解析数据积累下来,并在关键时刻能把那一段“过去”挖出来讲清楚。如果条件允许,你完全可以从解析自己权威服务器或递归服务器的日志开始,先不做旁路抓包,把数据链路和查询逻辑跑通,再逐步升级采集方式。这套路,就是我最终推荐给预算有限又想快速见效的人走的路线。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询