ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

自建被动DNS数据库:从流量采集到历史解析全流程实践

自建被动DNS数据库:从流量采集到历史解析全流程实践 在安全分析、威胁情报和基础设施追踪这个圈子里摸爬滚打久了你会发现一个特别扎心的事实当下看一个域名解析到哪个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记录存IPCNAME存目标域名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] # qr1表示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(errorsreplace) # 在这里把 (rrname, rrtype, rdata) 交给清洗模块 emit_record(rrname, rrtype, rdata) # 抓包入口监听eth0网卡过滤53端口 sniff(ifaceeth0, filterport 53, prnhandle_packet, storeFalse)这段代码作为原型很清晰但生产环境不建议直接拿去用。有几个容易被忽略的问题第一压缩指针问题。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_seenPostgreSQL就只能扫描所有分区性能瞬间崩塌。我一开始写的反查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系统不是一个写一遍代码就能永久跑通的项目它更像一个需要持续维护的数据管道。真正的价值并不在于代码有多炫酷而是长期稳定地把高质量的历史解析数据积累下来并在关键时刻能把那一段“过去”挖出来讲清楚。如果条件允许你完全可以从解析自己权威服务器或递归服务器的日志开始先不做旁路抓包把数据链路和查询逻辑跑通再逐步升级采集方式。这套路就是我最终推荐给预算有限又想快速见效的人走的路线。
返回列表