如果你在安全行业待得够久,一定会遇到这么一种情况:凌晨两点蓝队值守,告警平台安安静静,但你随手翻了翻DNS query日志,发现某个内网主机每隔几秒就在查询一个前缀长达四十多字符的域名,而且这个域名你压根没见过。你说它是恶意吧,现有规则一条没命中;你说它是误报吧,哪有正常业务会这么查域名。这种让人睡不踏实的“灰色流量”,正是DNS入侵检测要解决的核心问题。摆脱规则库的滞后性,从流量的数学特征里找出攻击者的痕迹,是我参加DataCon 2020 DNS恶意流量检测方向比赛时一直在做的事。这篇文章不聊虚的,直接把当时从数据清洗、特征工程、模型训练到误报追踪的完整链路拆开讲,希望能给正在做流量侧安全能力建设的同学一些能直接落地的参考。
1. 为什么攻击者死磕DNS:从一条“长寿”的查询说起
1.1 一个真实场景:那条夹在正常请求里的异常查询
很多蓝队人员对DNS的第一印象是“协议太老了,没什么可看的”。但实际上,DNS是所有网络访问的“前置条件”——任何上网行为几乎都要经过一次域名解析。这种高覆盖率的特性,决定了它必然会被攻击者盯上。攻击者拿到内网权限之后,想要偷偷回传数据、下发指令,通常有两条路:一条是直连外部IP,很容易被防火墙和流量审计发现;另一条就是伪装成DNS查询,把数据藏在域名字段里发出去,再通过DNS响应把指令带回来。
我印象特别深的一次排查,是某台办公终端持续向一个所谓“安全更新域名”发起查询,平均每秒一次,子域名部分是一长串高熵字符。当时第一反应是判定它为恶意隧道,但去翻威胁情报库,这个域名并没有被任何厂商标记。后来把特征数据提取出来才发现,它的查询规律和DNS隧道工具生成的流量高度一致:固定查询频率、域名长度异常、子域名使用随机字符。规则库没收录这个域名,但它从特征层面已经暴露了。
1.2 隧道与C2:DNS协议为什么这么“好用”
DNS之所以成为入侵检测绕不开的战场,根本原因有三点:
- 全段放行:绝大多数网络允许内部主机访问外部DNS服务器,即使出方向防火墙策略严格,53端口的UDP流量通常也会被放行。
- 数据隐蔽:DNS是明文协议,但日志量巨大,每天百万、千万级的查询让安全团队很难逐条审查,恶意消息混在里面就像一滴水融进大海。
- 结构灵活:域名本身天然支持多级标签,攻击者可以把数据切分成小块塞进子域名里,编码方式从Base32、Base64到Hex都可以,检测难度被成倍放大。
DNS隧道不是唯一形态,还有DNS劫持、DNS放大攻击、域名伪造等场景。DataCon 2020这个赛题之所以聚焦DNS,深层逻辑是:在成熟的攻防对抗里,DNS是绕过传统边界防护最稳定的一条信令通道。如果不做深层次的入侵检测,只靠静态域名黑名单,面对新注册的随机域名基本等于裸奔。
2. 拿到赛题数据之后:先别急着建模,把样本的“体质”摸清楚
2.1 原始日志有多脏:一份典型DNS query日志的真实面目
比赛提供的原始数据并不是干净的标签集合,而是接近真实环境中的DNS日志。典型的记录包含时间戳、查询域名、查询类型、源IP、目的IP、响应码、解析结果等字段。我拿到数据后做的第一件事不是跑模型,而是把数据全量加载进DataFrame做了一次字段体检。
| 字段名 | 含义 | 典型脏数据问题 |
|---|---|---|
| timestamp | 查询时间 | 时间戳格式不统一,存在重复毫秒错位 |
| domain | 查询域名 | 存在大写混用、末尾多一个点、Unicode域名 |
| qtype | 查询类型 | A记录为主,但混有AAAA、TXT、CNAME等 |
| src_ip | 客户端IP | 存在多个出口NAT后的IP混淆,难以定位具体主机 |
| response_code | 响应码 | NXDOMAIN占比极高,不能直接当作异常特征 |
| resolve_result | 解析结果 | 部分记录为空,部分记录是多个IP用分号拼接 |
光是把domain字段规整成小写、去掉末尾点,就花了不少功夫。这些细节如果你不处理,后期做特征统计时,同一个域名会被拆成好几个实体,导致计数特征失真。
2.2 标签的真相:恶意样本并没有你想的那么多
赛题提供了一些标注,但恶意样本的占比普遍偏低,一般只有百分之几,甚至千分级。这意味着两个非常麻烦的问题:
- 类别不均衡:用原始数据直接训练二分类器,模型只要学会把一切判为“正常”,准确率也非常高,但这显然毫无意义。
- 标签噪声:标注来自人工抽检或情报平台,本身就有误标。有些被标记为恶意的域名,实际是被黑产批量搭建的菠菜站点;有些未标记的域名,可能就是当时还没被发现的C2。
我当时的做法是:先不去动标签,只做一轮“碰撞检查”。把标注恶意域名逐个用规则解析一遍,看它们是否具备高熵子域名、长域名、异常TTL等明显特征。结果发现至少十个标注样本的特征和普通恶意样本完全不同,更像是广告域名。这类样本如果直接拿去训练,会严重污染模型的“恶意语义”。
2.3 领域知识先行的样本切分:按天切分比随机切分更诚实
还有一个经验:DNS流量本质上是一种时间序列数据,攻击者的行为模式可能会在一天的不同时段发生变化。如果做随机切分,训练集里出现了测试集某个恶意域名的部分历史流量,测试分数会被虚高乐观。正确做法是按时间切分,比如前80%时间的样本作为训练集,后20%作为测试集。这样可以检验模型对新出现的攻击模式是否有泛化能力,而不是靠“背题”拿高分。
3. 特征工程:把DNS流量翻译成模型看得懂的数字
3.1 字符级特征:域名本身就有“体味”
同一个域名出现在日志里,正常的业务域名长度较短、语义明确,恶意域名通常会刻意拉长字符串,以增加数据载荷容量。字符级特征是用模型识别域名的第一层抓手,我实际提取过且效果比较稳的特征包括这些:
- 域名总长度:正常域名大多在15到40个字符之间,隧道域名动不动就超过50个字符。但要注意,不能用固定阈值一刀切,因为有些合法业务域名确实很长。
- 子域名标签数量与最大标签长度:恶意隧道偏好使用多层子域名,单看域名总长度区分度不如“子域名数量+最大标签长度”的组合。
- 字符熵:计算域名整体和每个标签的信息熵。正常域名里字母组合受语义约束,熵值通常在3到4之间;随机生成的隧道子域名熵值可达4.5以上。
- 元音字母占比、数字占比、连续辅音占比:这些是语言统计的小技巧。正常词汇有较多元音,字符拼接相对自然;while(1)风格的随机字符串往往元音占比极低。
- 数字与字母混排频度:连续切换字母和数字的次数越多,越像机器随机生成。
当时我写了一个Python方法把这些特征算出来,验证集上的提升非常明显。核心代码如下,熵的计算用到了信息论基础:
import math from collections import Counter def shannon_entropy(text): if not text: return 0.0 prob = [count / len(text) for count in Counter(text).values()] return -sum(p * math.log2(p) for p in prob) def domain_char_features(domain): labels = domain.rstrip('.').split('.') total_len = len(domain) max_label_len = max(len(x) for x in labels) label_count = len(labels) digit_ratio = sum(c.isdigit() for c in domain) / total_len vowel_ratio = sum(c in 'aeiou' for c in domain.lower()) / total_len entropy = shannon_entropy(domain) return { 'domain_length': total_len, 'label_count': label_count, 'max_label_len': max_label_len, 'digit_ratio': digit_ratio, 'vowel_ratio': vowel_ratio, 'entropy': entropy }别小看这组特征,对NXDOMAIN洪泛攻击、DNS隧道这类场景,光字符熵这一项就能过滤掉大半常见误报。
3.2 行为级特征:单条query看不出异常,一组query才有规律
字符特征解决的是“域名长得好不好看”,但真正的检测能力还要看行为。试想一下:一个正常用户一天可能只访问某个域名两三次,而一台被控主机为了和C2保持联系,会以固定间隔反复解析同一个域名。这种行为模式,单条日志看不出来,但放到时间窗口里就变得格外扎眼。
我实际采用的行为特征包括:
- 时间窗口内同一源IP对同一域名的查询次数:统计5分钟、30分钟、1小时三个粒度,恶意隧道往往呈现高频、周期性。
- 访问域名种类数/查询次数的比值:如果一台主机短时间查询大量不同域名,可能是DNS探测;如果主机重复查询极少数域名,可能是隧道保持连接。
- 同一域名被多少不同源IP访问:正常域名通常会被很多主机访问,恶意域名往往只被少数受控主机访问。这个特征对检测新出现的隧道域名很有效。
- 响应结果特征:NXDOMAIN的响应占比、响应IP是否为空、响应IP是否属于特定段。恶意域名经常挂马或轮换IP,导致解析结果不稳定。
- TTL特征:统计查询域名的TTL均值与方差。隧道域名为了快速更新记录,TTL往往设置得很小,例如60秒或300秒;正常业务域名的TTL一般以小时或天为单位。
3.3 特征组合与筛选:不是特征越多越好
特征提取完,我大概汇总出了60多个维度。这时候最容易犯的错是“一股脑全扔给模型”,结果训练时间长、过拟合严重,AUC看着很高但上线就拉胯。我的做法是做一轮互信息筛选,把与标签相关性弱、方差接近0的特征去掉。比如“响应IP是否属于内网段”这种特征在测试集上分布极其稀疏,直接淘汰。
另外,把字符特征和行为特征组合成交叉特征也很好用。例如“高频查询次数×高熵子域名”,这个二维组合基本就是DNS隧道的画像。光靠单维度阈值,要么放过真实恶意样本,要么误杀CDN节点和多级CNAME的合法业务。
4. 模型选型与训练细节:为什么我押注树模型而不是深度学习
4.1 三类模型的横向对比
在DataCon 2020的DNS场景里,我对比过三类主流方案:纯规则引擎、树模型集成、深度神经网络。三类方案各有利弊:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 规则引擎 | 解释性强、实时性好、部署简单 | 依赖先验知识,未知攻击变体容易漏 | 对已有恶意家族做快速封堵 |
| GBDT树模型 | 能处理表格特征、可解释性较好、调参成本低 | 需要人工特征工程,对序列关系不敏感 | 大多数流量检测场景的首选基线 |
| 深度学习 | 能自动发现特征,处理长序列 | 需要大量样本,可解释性差,部署成本高 | 超大流量规模且标注充足的情况 |
综合权衡后,我最终选择了基于LightGBM的树模型方案。核心原因是DNS入侵检测的绝大多数有效特征在结构上是离散和低维的,树模型对这类特征非常友好,不需要像深度学习那样做大量数据增强和归一化。加上当时赛题的标注样本数量并不充裕,深模型很容易过拟合到少量恶意样本上,泛化能力反而不如调参得当的GBDT。
4.2 样本不均衡:不要一上来就欠采样
恶意样本占比低是这类题目的标配。很多人第一反应是随机欠采样,把正常样本砍到和恶意样本一样多。我实际试过,这样做的结果是模型对“正常样本”的语义理解严重不足,上线后误报率高得让人崩溃。
更好的做法是先用全量数据训练一个初始模型,拿到特征重要性排序和每个样本的得分,然后针对分数偏高的“难样本”做加权。LightGBM原生支持is_unbalance=True参数,它会根据类别占比自动调整正样本权重。同时,我用scale_pos_weight结合负样本数量/正样本数量设定权重,比手动欠采样温和得多。
在验证策略上,我做了分层五折交叉验证,但加了一个约束:同一个域名在同一个折里不能同时出现在训练集和测试集。如果不加这个约束,模型等于在“背域名”,成绩虚高,实际业务环境里却完全不可用。
4.3 时间序列的验证陷阱:用后20%时间窗口判断真实泛化能力
即使做了域名级别的去重,时间穿越问题依然存在。攻击者的C2基础设施可能连续几天使用同一批域名,模型在训练集里见过某恶意域名的规律,测试集里恰好又是它的延续,这种“知识”本质上是时间穿越。我在最后提交前专门按时间顺序把数据重新划分:前80%时间训练,后20%测试。实验结果非常真实:AUC比随机划分掉了大概6到8个百分点。这个回落在意料之中,但也提醒我,模型对“已知攻击的变体”有泛化能力,但对“从未见过的攻击手法”仍然依赖字符特征来兜底。
4.4 训练结果观察:特征重要性里的意外发现
训练完成后我习惯性地查看了特征重要性,排名第一的不是熵,不是域名长度,而是“同一源IP在5分钟内查询同一域名的次数”。这让我反思了很久:单条域名的字符特征只是外貌,行为模式才是恶意流量的灵魂。攻击者想要稳定维持C2,就必须高频查询;而正常用户几乎不会在短时间内重复解析同一个域名。这种行为差异,天然比字符层面的伪装更有区分度。
5. 误报排查:一次高置信度误判里的完整追踪链路
5.1 误报出现:得分0.93的“恶意域名”其实是合法动态DNS
比赛临近结束时,我发现模型对某个样本给出了0.93的高分,但人工复核却判定它为正常业务。这条流量数据是一个内网设备在不断解析某个动态DNS服务商分发下来的子域名,子域名是设备ID+随机字符串的组合。从字符特征看,长度超过40、熵值高、全小写字母混数字,几乎就是教科书级别的“隧道域名”;从行为特征看,每当IP变化时设备会立即重新解析,导致查询频率极高。两项核心特征全部命中恶意画像,模型判罪判得理直气壮。
5.2 逐层拆解:从特征回溯到原始报文
要定位误报根因,不能只盯着模型输出,得逐层回溯。我的排查链路是这样的:
第一步,把预测得分最高的1000个样本全部导出来,检查它们的共同特征模式。结果发现大量样本集中在某一个域名提供商下的第二级域名。第二步,把对应样本的原始query日志调出来,发现这些查询记录的时间间隔并不均匀,而是集中在这个设备的内网IP变化后的几秒内——很像动态IP环境下设备为了保持连通而主动更新DNS。第三步,把域名前缀用信息熵解码工具还原,发现前缀虽然高熵,但它符合一种特定编码规则,解析出来的内容是一串设备序列号。这个规则和隧道场景的“分块传输回传数据”完全不同:一个在表达“我是谁”,一个在传递“我的数据”。
5.3 修复动作与阈值策略
搞清楚根因后,我做了两个调整:
- 加入动态DNS服务商白名单:对已知的合法动态DNS域做白名单放行。但白名单不是一刀切,而是结合“前缀是否可解码为设备标识”做二次校验,既保留对陌生隧道的检测力,又压制这方面的误报。
- 调整行为特征阈值:针对动态DNS场景,查询频率特征不再只看次数,还要看两次查询之间的时间间隔分布。如果查询峰值集中在IP变化前后,则判定为正常更新行为;如果查询间隔均匀且持续整夜,则判定为可疑心跳。
这次误报排查最大的收获是:模型输出只是概率,不是真相。上线一个检测模型,配套的必须是一套可解释、可回溯的分析工具。没有这个能力,你根本不敢把模型结果直接推到封禁策略上。
6. 从比赛到实战:把模型变成一项能持续运转的安全能力
6.1 实时检测链路怎么搭:从DNS日志采集到告警推送
比赛里的模型跑完就结束了,但实战里要做的事还很多。我当时搭的是一条准实时的检测链路:DNS日志通过采集组件从内网DNS服务器旁路方式拉取,写入Kafka消息队列,流处理引擎按窗口做聚合统计,把字符特征和行为特征算出来后送进训练好的模型打分。评分超过阈值的域名和源IP会被丢进一个“待确认队列”,由安全运营人员每日复核。
这条链路里最容易踩的坑是“全局聚合窗口”导致的计算延迟。DNS流量每秒成千上万条,如果每一条都要做一次5分钟窗口的统计,计算压力会非常大。实际做法是使用滑动窗口的预聚合结果,比如每30秒更新一次窗口内的计数特征,而不是每条都从头扫描全量窗口。
6.2 持续维护比训练模型更费功夫
模型上线三个月后,我发现误报率悄悄上升了一个百分点。查下去才知道,某个云服务商调整了域名策略,大量合法业务都开始使用高熵子域名做CDN路由。这类流量的字符特征突然变得“恶意化”,但行为特征还是正常业务的模式。
这个案例让我意识到,DNS入侵检测是一个持续对抗的过程。你需要定期用最新的真实流量做一轮“伪标签校准”:把模型得分在0.8以上的样本拉出来,人工确认哪些是误报、哪些是漏网之鱼。每次校准不只是调阈值,还要把新的特征模式沉淀到训练集里,定期重训模型。另外域名黑名单和白名单要建立生命周期机制,动态DNS域名、安全情报域名、内部业务域名都会更新,至少每月审查一次。
6.3 如果再打一次DataCon,我会在哪些地方做得不同
复盘整个比赛过程,有三点我确信下次可以做得更好。
- 第一,我会在特征工程阶段就引入更多网络层的上下文信息,比如DNS响应IP是否在威胁情报黑名单里,以及响应IP与已知恶意IP的相似度。在缺少上下文纯拼域名特征的情况下,检测能力很快就触及天花板。
- 第二,我会把CNAME链的解析关系也纳入检测。很多恶意域名会通过CNAME指向一个合法CDN域名来狐假虎威,只检测查询的原始域名很容易被绕过,必须追踪完整的CNAME链。
- 第三,我会在训练阶段同时做多模型集成,把决策树、逻辑回归、一个简单的字符级CNN同时训练,用Blending方式做最终融合。虽然树模型主力的效果已经不错,但不同算法看到的数据侧重点不一样,集成之后对个别困难样本的纠错能力会明显更强。
回到开头那个凌晨两点的场景,如果你手头有一套基于DNS入侵检测思路的辅助工具,处理方式会完全不一样:你不会盯着一条孤立的query猜来猜去,而是能立刻看到这个域名的字符熵、这个源IP的查询频率、历史窗口里是否有类似行为。这就是特征化检测相对静态规则的真正价值——它不依赖见过这个域名,而是依赖理解恶意流量背后的行为本质。希望这篇文章里从数据清洗到误报追踪的每一步,能给你在构建自己的DNS检测能力时节省一些绕路的成本。