
DNS入侵检测在 datacon2020 大数据安全分析比赛里是个特别考验基本功的赛道。比赛拿到的并不是什么好看的流量包而是大量真实环境里抓出来的被动 DNS 日志目标很直接从海量域名查询里把恶意域名、失陷主机和攻击设施找出来。这篇文章我就把这套 DNS 入侵检测的思路、数据预处理、特征工程、模型选型到常见坑位完整复盘一遍经验都来自我实际跑完整个流程之后的总结你可以直接拿去做参考。1. datacon2020 的 DNS 入侵检测赛题本质是在解决什么问题1.1 一次 DNS 查询背后藏了多少攻击机会在展开检测方案之前先把 DNS 域名解析过程说清楚因为整个入侵检测都建立在这个流程上。客户端发起一个域名解析请求本地会先查缓存缓存没有就交给递归服务器递归服务器再一级一级去找权威服务器拿回 A 记录、CNAME、TXT 这些资源记录最后返回给客户端。整个过程对外表现为一次简单的 Query 和 Response但对攻击者来说这是一条几乎无法被彻底封死的沟通通道。我在实际分析 datacon2020 的数据时最大的感受就是恶意行为并不像大家想象中的那样藏得有多深多数反而就是混在正常 DNS 请求里靠“看起来合理”来规避封禁。比如失陷主机要回连 C2 服务器直接写死 IP 很容易被情报库拉黑改成用自己的域名IP 变了域名还能继续用。还有更多的恶意家族直接用 DGA 算法每天生成几千个域名一次性试探几个到几十个只要有一个能解析出来就用来上线。DNS 随之变成了一种天然的“信标”机制你要是不盯着这块单靠防火墙策略根本防不住。1.2 这场比赛和实际项目有哪些不一样datacon2020 的 DNS 赛道考察的不是单纯的“域名声誉查询”而是让你在没有明确标签的情况下靠对数据的理解去发现异常。这个导向对实际工程项目非常友好因为真实企业里的 DNS 日志也就是这样一个原始状态没有帮你标好哪条是攻击流量没有完整的威胁情报兜底更没有现成的特征表。和实际项目最大的不同在于比赛给了相对可控的时间范围和一批真实性较高的数据并且最终的评分会围绕“能否发现恶意域名、发现时间早晚、是否误报”来综合衡量。我当时的目标就是你得在尽可能早的阶段把可疑域名挑出来而不是等它跟后端 C2 通信了才反应过来。这个目标导向了整套方案的设计早期检测 行为基线 少量情报辅助。后面我会详细拆这一段。还有一个容易忽略的点真实日志往往非常脏。重复查询、内部域名、广告流量、CDN 域名、运营商的劫持页面都会干扰你的判断。单纯把“高熵域名”等同于恶意第一轮就会误报爆炸。所以在 datacon2020 这种赛题里第一步反而不是上算法而是把数据理解透。2. 拿到 DNS 日志后的预处理字段、清洗和聚合的实操细节2.1 DNS 日志常见字段与真正有用的部分我拿到的这份 DNS 数据是带时间戳的日志文本一行一条查询记录核心字段大致如下字段名含义检测中的用途ts查询发生时间做时间切片、计算频次和周期client_ip发起查询的客户端 IP统计单域名覆盖范围、识别失陷主机domain被查询的域名提取域名文本特征、DGA 判断qtype查询类型发现 TXT/ANY 隧道等异常类型rcode响应码NOERROR、NXDOMAIN、SERVFAIL 等answers回答内容提取 A 记录、CNAME、TXT 内容ttl缓存时长识别 fast-flux、短 TTL 异常这些字段里面真正影响检测精度的关键字段是 domain、client_ip 和 rcode。域名本身的字符分布、子域名长度、结构层级基本决定了你能不能直接认出 DGAclient_ip 的分布和数量则能帮你区分“一个内网主机在反复请求”和“全网机器都在请求同一个域名”这两种情况的判断逻辑是反的rcode 里的 NXDOMAIN 比例更是常年排在告警规则第一位。ttl 也很重要但它的坑比较多后面在 fast-flux 部分我会单独说。2.2 时间窗口、去重和聚合的思路DNS 日志里重复查询非常多同一个客户端可能在几秒内反复请求同一个域名也可能因为缓存失效产生周期性重复查询。拿到数据的第一件事不是统计总量而是先对时间格式做统一。我见过很多人在时间戳上栽跟头有的字段是秒级有的是毫秒级还有带着时区偏移的字符串。你如果不先统一成 UTC 时间后面按小时聚合时会出现边界错位看起来像是一阵一阵的规律请求其实是时区误差。统一时间后再去考虑去重。去重的粒度根据检测场景决定如果你关注“查询次数”就不要把同一个人在同一秒内的完全重复请求全部去掉因为高频重复本身就是一个行为异常如果你关注“域名覆盖范围”则要把完全相同的 (client_ip, domain, qtype) 去重后再统计。我当时是按 5 分钟滑窗来做原始记录切分再在窗口内做一次精确去重用这样的粒度去算后续特征。时间窗口选太短低频 C2 信标会被漏掉选太长会把多个不同阶段的行为混成一个统计单元导致特征失真。2.3 几十 GB 日志读不进去先解决内存问题比赛数据解压后动辄几十 GBpandas 一把梭全部 read_csv 直接内存爆炸。这里我建议不要硬读用分块读取或者转换成 parquet 格式再做后续处理。我第一版方案就是这么干的先写一个读取脚本把原始文本按日期切片每一片转成 parquet 落盘后续所有特征计算只读 parquet。这样不仅能避开内存问题后面反复调试特征时也不会每次重新解析一遍原始数据。分块处理时要注意保持窗口边界完整。如果按文件大小硬切可能把一个 5 分钟窗口里的查询切到两块数据里特征计算就会少算一部分。我当时是先把全量时间排好序再按时间范围切片宁可让相邻块有少量重复也不能让边界有缺失。另外一个实战建议先随机抽 1% 数据把整个 pipeline 跑通确认特征、模型、告警输出都没问题再上全量。这个习惯能帮你省掉大把调试时间。很多人在第一步就陷入“全量数据 → 内存爆 → 换机器 → 继续爆”的循环其实最优解是先让流程变轻。3. DNS 入侵检测特征工程怎么让恶意域名自己露馅3.1 域名文本特征DGA 最藏不住的破绽很多 DGA 算法生成的域名在文本层面就和正常域名有很明显的差异。正常业务域名通常是有语义的词组比如 news、login、api就算加了随机子域名主体部分也相对可读而 DGA 生成的一大串字符几乎不包含元音数字占比高甚至会出现连续四五个数字的情况。我常用的文本特征包括域名总长度子域名部分长度子域名部分的香农熵越大越可疑数字字符占比、特殊字符占比最大标签长度连续字母/数字的个数。香农熵的计算方式不复杂就是把字符出现的频率带进公式。比如正常域名mail.google.com的子域名字符熵通常在 2.5 到 3.5 之间而一个 DGA 域名像3kx7f2q9p1d8.example.com子域名字符熵很容易超过 4.2。熵值本身不能单独当判定标准因为一些随机生成的 CDN 节点名也会有高熵但它作为组合特征里的第一维区分度很高。3.2 查询行为特征失陷主机有固定的“工作节奏”C2 木马回连的明显特征就是周期性。有的木马每 30 秒查一次 C2 域名有的每 5 分钟来一次有的还会故意加上随机间隔来绕过简单的固定周期检测。行为特征就是把这种“节奏感”量化出来。常用特征包括域名总查询次数、唯一客户端数、平均每客户端查询次数、查询时间间隔的平均值和方差、以及每小时请求数的规律性。如果一个域名只有少数几台内网主机在查并且查询间隔非常稳定这就很符合一台失陷主机的行为模式。反过来如果大量客户端都查同一个域名那更像正常业务或 CDN 服务需要配合其他维度判断不能一竿子打死。还有一个值得关注的行为特征查询趋势的突变。一个长期每天只有几十次查询的域名突然在某一天飙升到几万次而且伴随着大量 NXDomain这通常意味着恶意活动开始集中爆发。对这种突变用滑动窗口的请求量差分来计算比单纯看总次数要稳健得多。3.3 应答记录特征NXDomain、TTL 和 fast-flux应答记录里的信息非常关键但很多人会把注意力全放在域名本身忽略应答侧。一个恶意域名刚开始被用于 C2 时可能会在几个小时内频繁更换 A 记录指向不同的 IP也就是 fast-flux。正常稳定业务的 TTL 可能是 300 到 86400 秒而 fast-flux 通常把 TTL 压得很低比如 30 秒、60 秒为的是让下游缓存快速失效以便快速切换 IP。特征上可以统计每个域名的 TTL 均值、最小值、变异系数或者统计一段时间内 A 记录去重后的数量。NXDomain 比例是另一个重要指标。DGA 域名在没有被攻击者注册时查询后返回的就是 NXDomain但失陷主机依然会坚持不懈地查导致某个域名的 NXDomain 比例异常高。这里有个坑正常环境里也会因为用户手误输错网址、内网解析不到某些服务产生大量 NXDomain。所以单纯统计 NXDomain 比例一定会误报。更稳的做法是结合客户端数量和查询频率一台主机疯狂查几百个不存在的随机域名远比一万个客户端偶尔查一个不存在域名要可疑得多。对于 DNS 隧道最常见的特征就是大体积 TXT 记录、超长域名查询以及非标准的查询类型。也可以顺带关注一下所谓 DNS 放大攻击网上经常能看到现成的 dns 放大攻击 python 代码这种攻击通常表现为大量客户端向开放解析器发送小查询、收到超大应答。放在检测任务里就需要统计答案长度分布、UDP 负载大小以及响应包与查询包的体积比。不过这类攻击更偏流量侧日志里能发挥的空间有限我一般只作为辅助特征使用。3.4 一份可直接改来用的特征计算代码下面这段代码是我在 datacon2020 方案里用到的特征计算核心部分按天聚合后输出一个特征宽表每行对应一个域名。实际落地时可以把时间切片换成 5 分钟或 1 小时根据业务需要调整。import math import pandas as pd from collections import Counter def shannon_entropy(text: str) - float: 计算字符串香农熵 if not text: return 0.0 length len(text) freq Counter(text) entropy -sum((n / length) * math.log2(n / length) for n in freq.values()) return entropy def extract_domain_features(df: pd.DataFrame) - pd.DataFrame: 输入必须包含 domain 列 输出每个域名一行附带基础文本特征 df df.copy() # 拆分主域名与子域名 df[labels] df[domain].str.split(.) df[sld] df[labels].map(lambda x: ..join(x[-2:]) if len(x) 2 else df[domain]) df[subdomain] df.apply( lambda row: ..join(row[labels][:-2]) if len(row[labels]) 2 else , axis1 ) df[domain_len] df[domain].str.len() df[sub_len] df[subdomain].str.len() df[label_count] df[labels].map(len) df[max_label_len] df[labels].map(lambda x: max(len(i) for i in x)) df[entropy] df[subdomain].map(shannon_entropy) df[digit_ratio] df[subdomain].map( lambda s: sum(c.isdigit() for c in s) / (len(s) 1e-6) ) df[alpha_ratio] df[subdomain].map( lambda s: sum(c.isalpha() for c in s) / (len(s) 1e-6) ) # 按主域名聚合 feature_df df.groupby(sld).agg( query_count(domain, count), unique_clients(client_ip, nunique), avg_entropy(entropy, mean), max_entropy(entropy, max), avg_digit_ratio(digit_ratio, mean), avg_alpha_ratio(alpha_ratio, mean), max_domain_len(domain_len, max), avg_ttl(ttl, mean), ttl_std(ttl, std), nxdomain_ratio(rcode, lambda x: (x NXDOMAIN).mean()), answer_ip_count(answers, lambda x: x.str.findall(r\d\.\d\.\d\.\d).apply(set).apply(len).mean()) ).reset_index() return feature_df这段代码跑完你会得到一张特征宽表。需要注意的是ttl_std在只有一个样本的域名上会是 NaN需要统一填充否则后面训练模型会报错。还有answers列的 IP 提取是非常粗糙的正则如果数据里有 CNAME 或 TXT 记录这个特征就不够准确更严谨的做法是用dnspython库先做资源记录解析再提取 IP 集合。4. 规则、机器学习与多阶段融合检测方案怎么选4.1 规则引擎不是老古董它负责兜底很多做安全算法的人一上来就想上深度学习但在 DNS 检测这种场景下规则引擎反而是效率最高的第一层。原因很简单恶意家族的行为模式相对固定规则能快速覆盖大部分已知情况而且完全可解释出告警了你能告诉对方为什么。我当时先用规则做了三层筛选第一层是阈值型规则高熵域名、超高 NXDomain 比例、极短 TTL、超大 TXT 记录第二层是行为规则单客户端周期性查询、查询时间间隔方差极小、短时间查询域名数量激增第三层是关联规则新出现域名 内网主机高频查询 过去从未在数据集中出现。规则阈值不要拍脑袋定。先对全量数据做统计看每个指标的正常分布区间。比如把熵值按 95 分位切成初始阈值再在验证集上做一次人工复核慢慢把阈值从 95 分位往 99 分位调。这个流程比一开始定一个好看的数字要靠谱得多。4.2 有监督模型和特征选择的关键点如果比赛给了部分已知恶意标签或者你能从威胁情报里拿到一批标注好的恶意域名那有监督模型会明显提升检测精度。我用的主力模型是随机森林和 LightGBM特征就是上一节算出来的那些统计量再加上少量外部情报特征比如域名是否出现在公开黑名单里、注册时间是否很短、解析 IP 的地理位置是否和业务所在地不一致。这类模型在异常检测中的表现并不取决于单个特征有多强而在于特征之间的组合。比如entropy 4可能误报很高但当entropy 4且nxdomain_ratio 0.8且unique_clients 5同时满足时基本就是 DGA 查询的特征。树模型天然适合处理这种特征组合所以哪怕你不用深度学习效果也已经很好。训练时要注意样本不平衡。恶意域名的数量通常只占全量的千分之几直接用原始分布训练模型会全预测成正常。我会对恶意样本做上采样同时给正常样本降低权重或者直接用class_weightbalanced。评估的时候别只看 accuracy要重点看召回率、精确率和 AUC。对安全场景来说漏报比误报更危险所以我会先保证召回率达到一定水平再通过调阈值去压误报。4.3 多阶段打分先百科召回再精排确认规则和机器学习模型各有优势但它们不是替代关系。我的最终方案是三级流水线先走规则引擎把明显异常的域名全部捞出来这一阶段允许一定误报追求的是高召回。然后把规则命中的域名连同统计特征一起送入分类模型算出一个风险分数。最后再经过一次查询时间轴分析和情报库去重输出带证据的告警。这个流程的好处是可控。你可以在每一步设置独立的开关和阈值上线后如果误报太多先看是哪一层产生的再针对那一层调。比如发现大量高熵规则命中但没有模型分那就说明规则阈值过宽或者模型缺少区分这个场景的特征。直接调规则阈值就行不需要把整个模型重新训练一遍。多阶段融合时还要注意告警聚合。DNS 日志里一个域名可能对应上千条查询如果每条查询都输出一条告警值班的人会被刷屏刷到麻木。我当时是按域名聚合再按客户端 IP 细分输出“某个 IP 在某天高频访问了某个可疑域名”这种带语境的信息它在实际安全运营里比纯粹的一个域名列表有用得多。5. 从原始日志到告警完整 DNS 入侵检测流程复盘5.1 第一步分块加载数据并做时间切片我先把原始日志按小时切块转成 parquet 文件然后按时间窗口做初步聚合。这里有一个小细节DNS 日志的 client_ip 字段在 IPv6 环境下可能带::ffff:前缀需要统一清洗成纯 IPv4 或 IPv6 地址否则去重时同一个客户端会算成多个。import pandas as pd def load_and_slice(raw_path, out_dir): reader pd.read_csv( raw_path, sep\t, names[ts, client_ip, domain, qtype, rcode, answers, ttl], chunksize2_000_000, ) for i, chunk in enumerate(reader): chunk[ts] pd.to_datetime(chunk[ts], units) chunk[day] chunk[ts].dt.date chunk[client_ip] chunk[client_ip].str.replace(::ffff:, , regexFalse) for day, day_df in chunk.groupby(day): day_df.to_parquet(f{out_dir}/day_{day}.parquet)切块之后再对每一块做五分钟窗口聚合。窗口聚合的目的是把原始请求转换成“域名 × 时间窗口 × 客户端数量”的统计结构后续所有特征都从这个聚合结果里算。5.2 第二步特征汇总与恶意域名评分特征计算完进入评分阶段。如果你有标注直接训一个有监督模型如果没有可以用孤立森林这类无监督模型或者直接按规则分数加权求和。下面是一段用随机森林做训练和评分的最小示例。from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import train_test_split from sklearn.metrics import roc_auc_score features [query_count, unique_clients, avg_entropy, max_entropy, avg_digit_ratio, nxdomain_ratio, ttl_std] X feature_df[features].fillna(0) y feature_df[is_malicious] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.3, random_state42, stratifyy ) clf RandomForestClassifier( n_estimators400, max_depth12, class_weightbalanced, n_jobs-1, random_state42 ) clf.fit(X_train, y_train) print(AUC:, roc_auc_score(y_test, clf.predict_proba(X_test)[:, 1])) feature_df[risk_score] clf.predict_proba(X)[:, 1] alerts feature_df[feature_df[risk_score] 0.9]在实际项目里我不会只用单一模型的分值做最终截断而是会叠加一层规则比如风险分数超过 0.9且 NXDomain 比例超过 0.7才进入高优先级告警分数中等但情报库有命中则进入中优先级待观察列表。这样分级处理能大幅减少告警疲劳。5.3 第三步结果验证、可视化与误报复查做完检测只是第一步验证才是真正花时间的环节。我会把命中的域名拉出来做三件事第一看它们的历史解析记录确认是否在事件发生前从未出现过第二用公开威胁情报库交叉验证第三直接把查询时间轴画出来确认行为是否有周期性。画时间轴这步特别关键。比如某个域名被判定为高风险但你把时间轴拉出来后看到全网几万个客户端都在同时段请求它而且请求模式是平滑上升那这大概率是某个新上线的正常业务或者一个大型 CDN 的服务入口不是 C2。真正恶意的 C2 时间轴通常呈脉冲状集中在少数几个客户端上而且和业务流量有明显的“社交距离”。可视化工具不需要多复杂matplotlib把单个域名的请求频次按 5 分钟粒度折线图画出来再叠加客户端去重数量就能解决大部分复查问题。如果同一批客户端同时访问多个可疑域名还可以画一个客户端和域名的二分图快速圈出失陷主机群组。6. 实战踩坑记录与高频问题排查6.1 一堆 NXDomain 日志到底是攻击还是网络故障比赛数据里最常见的高频告警来源就是 NXDomain。但真实场景中NXDomain 激增未必是攻击。内网用户把某个服务域名拼错、某台设备循环探测一个已下线的域名、或者路由器自己周期性地做 DNS 检查都会产生大量 NXDomain 记录。我见过最夸张的一种情况是某办公楼里的智能电视系统每 30 秒就请求一个已经不存在的升级服务器域名一年下来积累了上百万条 NXDomain。如果不加前置过滤这套系统会直接把你的告警队列灌满。我的处理方式是先建立一个“正常 NXDomain 基线”。把近 30 天每个域名或每个来源 IP 的 NXDomain 比例做一个滑动平均然后只看那些偏离基线超过设定倍数的对象。简单讲同一个人天天都查不存在的域名这叫基线特征不叫突发异常真正需要盯的是过去几乎不产生 NXDomain 的主机突然开始大面积查询随机域名。这才符合 DGA 上线前的行为特征。另外一个常见干扰项是本地 DNS 配置被改。很多人会遇到 linux 修改 dns 后重启网络还原这一般是/etc/resolv.conf被网络管理工具覆盖导致的。放在检测场景里一旦内网大量机器因为配置问题反复解析失败也会造成 NXDomain 比例虚高。你排查告警时要留个心眼先确认不是自己这边的基础设施问题再往攻击方向分析。6.2 DNS 代理和 NAT 把客户端 IP 抹平了怎么办很多企业网络出口会部署 DNS 代理像华三防火墙这类设备默认可能开启 DNS 代理功能导致内部所有 DNS 请求在日志里都显示成防火墙的 IP。有人会问华三防火墙关闭 dns 代理是不是更合理从安全检测角度说关闭代理确实能让后台看到真实客户端的查询来源对溯源帮助极大。但在比赛中你也没法控制对方网络架构遇到日志里 client_ip 被抹平的情况就只能换思路。如果所有查询看起来都来自同一个源那涉及客户端数量的特征全部失效。这时候我会退回到“域名侧”特征用纯文本特征加时间规律来判断同时把聚合单位从“客户端 × 域名”改成“域名 × 时间窗口”。判断失陷主机这个目标就变成判断可疑域名虽然精度下降但总比什么都做不了要强。如果后院有自己的流量采集设备建议提前把原始 DNS 包抓一份利用被动 DNS 的数据还原客户端维度这是最稳的解法。6.3 阈值怎么调才不再验证集上自嗨我在做特征和模型的过程中反复遭遇一个尴尬验证集上 AUC 高得惊人一上全量数据就崩。原因有两个一是特征泄漏二是阈值过拟合。特征泄漏最容易出现在时间窗口处理上。如果你在计算某个域名的“未来行为”特征时把整个时间范围的数据都拿来平均那训练时模型已经偷看了未来上线后当然表现断崖式下跌。解决方式是按时间顺序分割训练集和验证集比如前 70% 时间做训练后 30% 时间做验证模型在训练阶段只能看到验证时间点之前的数据。这是我踩过最重的一个坑提醒所有做流量安全检测的朋友时间序列数据千万不要随便随机切分。阈值过拟合则是另一个常见问题。validation 集上调到最完美的 0.95 阈值换一段新数据可能就会出现大量误报。我后来的做法是不盯单一阈值而是把所有潜在告警都输出按风险分排序人工只看 top 100 或 top 200。这样就算阈值稍微偏了只要你排序逻辑是对的高价值告警还是会在列表头部。6.4 给想复现类似项目的几条实在建议先说结论DNS 入侵检测不是一个“训练一个模型就完事”的项目它更像一个需要持续维护的数据分析系统。如果你也想拿一份 DNS 日志做类似的检测实验我建议按下面的顺序来推进。第一先把数据探索做扎实。写脚本统计每天的查询总量、唯一域名数、唯一客户端数、Top 域名、Top NXDomain 域名这些基础统计能帮你建立对数据分布的第一感觉。我见过很多人连数据总量都没数清楚就直接开始跑模型最后所有判断都没有参照系。第二优先用规则把最明显的恶意行为捞出来。规则方案在初期能给你一个非常稳定的召回底仓后续加的模型只是在规则遗漏的区域做增量补充。规则要有可解释性每条规则至少能对应一类攻击模式。第三特征计算一定要留版本。DNS 检测的特征数量不多但很容易在迭代中改乱。我用的是把特征函数命名加上日期后缀比如feature_v20241020.py每次改动保留旧版本方便回溯。第四留出足够时间做人工误报分析。这个环节没有捷径你得把告警日志一条条拉出来看和正常业务域名做对比慢慢会发现哪些特征在特定场景下会失效。整个流程里我对这个环节的收益评价是最高的它比单纯调模型参数有用得多。最后说一个细节处理 DNS 数据时常见的一个用户端现象是打开网页找不到 dns 地址这在日志里通常体现为解析超时或 SERVFAIL。如果你在数据里看到某个域名频繁出现这种异常可以先检查是不是用户侧网络配置问题。别把所有异常都当成攻击有时候安全检测最大的敌人不是漏报而是把自己淹没在误报里。把基础问题过滤干净剩下的才值得你花精力去深挖。