免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

自建被动DNS数据库:原理、架构与实战

自建被动DNS数据库:原理、架构与实战 这几天刚处理完一起勒索软件应急深夜两点多客户内网三百多台机器持续回连恶意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时间戳UTC1710123456fqdn被查询的完整域名小写、去尾部点号www.example.comqtypeDNS查询类型A, AAAA, MX, CNAMEanswer_ip应答中返回的IP地址93.184.216.34rcode应答状态码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.3497次解析到另一个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中百万级聚合记录的MVPClickHouse极高列式存储强大聚合函数丰富中偏高亿级以上数据量向量/图数据库适中偏向语义搜索/关联遍历高不适合作为主要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/searchq*.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.comCNAME转到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都开但下游清洗时主要消费responsequery的作用主要是匹配应答以及分析客户端发起查询的模式。配置好之后重启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, errorsignore) 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%暂时没有情报价值第二个是在清洗层做分钟级窗口聚合把同一个fqdnanswer_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”这类问题我建议你动手搭一套。能力不在于工具的堆砌而在于关键时刻你是否能比别人多一个维度的数据。
返回列表