简介:本资源是一套面向网络安全研究人员与机器学习实践者的DNS流量异常检测实战方案,聚焦僵尸网络识别这一关键防御场景,融合特征工程、传统机器学习与深度学习建模全流程。压缩包共27个文件,含15个核心Python源码(如DnsAnalyser.py、BotDAD.ipynb、PcapParser.py等)、6个编译后pyc文件用于快速验证、2个README说明文档及2个文本配置文件,整体3.06MB,结构清晰,覆盖数据解析、特征提取、模型训练与结果输出全链路。已有313人下载学习,适合具备Python基础与网络协议常识的中高级学习者开展复现实验。读者可直接运行Jupyter Notebook进行端到端分析,获取完整的DNS异常检测Pipeline:从PCAP包解析、IP与域名行为特征构建,到Isolation Forest与深度时序模型(RNN/Attention)对比实验,附带白名单机制、阈值调优脚本及可视化输出模块,显著降低安全AI落地门槛。
1. 为什么 DNS 流量里藏着僵尸网络的“心跳声”:不是看域名是否可疑,而是看它怎么问、问得多快、问得有多怪
你手头有一台边缘网关设备,每天收到上万条 DNS 查询日志——www.baidu.com、api.github.com、update.microsoft.com……看起来全是合法请求。但某天凌晨 3:17,它突然向xk9q2n.dynu.net、a7m8p.vv2024.top、z3t1l.1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345......(超长子域)连续发起 237 次 A 记录查询,间隔均值 113ms,TTL 全为 60,且无对应响应包。这不是误配置,也不是用户行为——这是典型的 DNS 隧道 C&C 通信。
基于 DNS 流量分析异常的僵尸网络检测,核心不是识别“坏域名”,而是建模合法 DNS 行为的统计指纹:查询频率分布、域名长度熵值、子域层级深度、QTYPE 分布偏移、响应延迟离群度、客户端 IP 的查询多样性(Shannon entropy over domain set)。它不依赖黑名单或签名,对 Fast Flux、DGA(Domain Generation Algorithm)、DNS Tunneling、NXDOMAIN Flood 等高级隐蔽 C&C 手段具备天然敏感性。适合部署在企业出口防火墙旁路镜像口、云 WAF 日志流、IDC DNS 服务器日志管道,或嵌入到 SOAR 平台的 IOC 提取模块中。如果你正在处理dns协议分析实验头歌类教学场景,或需要落地工业异常检测算法在 OT 网络 DNS 日志中的适配,这个方案比 YARA 规则或 Suricata DNS 模块更轻量、更可解释、更易调参。
2. 从原始 PCAP 到结构化特征:三步构建 DNS 异常检测流水线
DNS 流量分析不是直接喂给模型——原始数据里混着协议握手、重传、截断、EDNS 扩展、IPv6 AAAA 查询、CDN 调度跳转,全都是干扰项。必须先做精准解析、再做行为聚合、最后做特征工程。我一般用tshark+pandas+scikit-learn构建最小可行流水线,全程可复现、无外部服务依赖、单机 16G 内存可处理 50GB/天 DNS 日志。
2.1 用 tshark 提取干净 DNS 会话(过滤掉所有非查询/响应噪声)
关键不是抓全包,而是只保留有明确 QNAME、QTYPE、RCODE、QUERY_TIME、RESPONSE_SIZE 的完整事务对。以下命令从dns.pcap中提取结构化 CSV,已通过2021-绿城杯-misc-流量分析真题数据验证:
tshark -r dns.pcap \ -Y "dns && (dns.flags.response == 0 || dns.flags.response == 1)" \ -T fields \ -e frame.time_epoch \ -e ip.src \ -e ip.dst \ -e dns.qname \ -e dns.qtype \ -e dns.flags.rcode \ -e dns.time \ -e dns.count.answers \ -e dns.resp.len \ -E header=y \ -E separator=, \ -E quote=d \ > dns_raw.csv注意:
-Y过滤器必须同时包含 query(dns.flags.response == 0)和 response(dns.flags.response == 1),否则会丢失无响应的 NXDOMAIN 或超时请求;dns.time是查询到响应的毫秒级延迟,是判断 C&C 心跳的关键;dns.resp.len可区分隧道载荷(常 >1000B)与普通解析(通常 <200B)。
2.2 用 Pandas 聚合会话级特征(按 client_ip + qname + qtype 三元组归并)
单条 DNS 包信息价值极低,真正异常的是客户端行为模式。我们按ip.src(客户端)聚合,计算其每分钟的 12 维统计量:
| 特征名 | 计算逻辑 | 异常指向 |
|---|---|---|
qps_mean | 每分钟查询总数 / 60 | DGA 僵尸快速轮询 |
qps_std | 每分钟查询数的标准差 | 心跳周期抖动(如 113±5ms → std≈2.1) |
domain_len_entropy | 所有 QNAME 字符长度的 Shannon 熵 | DGA 域名长度高度随机 |
subdomain_depth_mean | QNAME 中.的个数均值 | Fast Flux 多层子域(如a.b.c.d.e.f.g.h.dynu.net) |
nx_ratio | RCODE=3(NXDOMAIN)占比 | 域生成失败率高 |
tunnel_size_ratio | dns.resp.len > 1024占比 | DNS Tunneling 载荷嵌入 |
qtype_diversity | QTYPE(A/AAAA/MX/TXT)的 Shannon 熵 | C&C 混合使用 TXT(指令)、MX(心跳)、A(下载) |
ttl_mean | 所有响应中 TTL 均值 | 僵尸网络常设 TTL=60/120,规避缓存 |
resp_delay_mean | dns.time均值(ms) | 隧道响应慢于正常解析(>200ms) |
unique_domain_count | 每分钟唯一 QNAME 数 | 正常用户查固定域名,僵尸扫大量 DGA |
query_burst_ratio | 最大 1s 内查询数 / 总查询数 | 爆发式查询(如 Botnet 同步指令) |
edns_flag_ratio | EDNS0 OPT RR 出现占比 | 新型 Bot 使用 EDNS 扩展传指令 |
Python 聚合脚本(aggregate_dns.py)核心逻辑:
import pandas as pd import numpy as np from scipy.stats import entropy df = pd.read_csv('dns_raw.csv', parse_dates=['frame.time_epoch']) df['minute'] = df['frame.time_epoch'].dt.floor('T') # 按分钟切片 df['domain_len'] = df['dns.qname'].str.len() df['subdomain_depth'] = df['dns.qname'].str.count('\.') df['is_tunnel'] = (df['dns.resp.len'] > 1024).astype(int) df['is_nxdomain'] = (df['dns.flags.rcode'] == 3).astype(int) # 按 client_ip + minute 分组 grouped = df.groupby(['ip.src', 'minute']) features = grouped.agg( qps_mean=('ip.src', 'count'), qps_std=('ip.src', 'count'), domain_len_entropy=('domain_len', lambda x: entropy(np.bincount(x) + 1e-9)), subdomain_depth_mean=('subdomain_depth', 'mean'), nx_ratio=('is_nxdomain', 'mean'), tunnel_size_ratio=('is_tunnel', 'mean'), qtype_diversity=('dns.qtype', lambda x: entropy(pd.value_counts(x) + 1e-9)), ttl_mean=('dns.resp.ttl', 'mean'), resp_delay_mean=('dns.time', 'mean'), unique_domain_count=('dns.qname', 'nunique'), query_burst_ratio=('ip.src', lambda x: x.rolling(window=10, min_periods=1).count().max() / len(x) if len(x) > 0 else 0), edns_flag_ratio=('dns.edns0.length', lambda x: (~x.isna()).mean()) ).reset_index() # 标准化:对每个 client_ip 的历史窗口做 z-score(滑动窗口 1h) features['qps_zscore'] = features.groupby('ip.src')['qps_mean'].transform( lambda x: (x - x.rolling(60).mean()) / (x.rolling(60).std() + 1e-6) ) features.to_parquet('dns_features.parquet', index=False)参数说明:
rolling(60)表示用过去 60 分钟(即 60 行)作为基线计算 Z-score,避免单点突刺误报;entropy(... + 1e-9)防止 log0;qtype_diversity对dns.qtype值频次做熵计算,合法客户端多查 A/AAAA,僵尸常混合 TXT/MX/SRV。
2.3 特征向量标准化与异常分数映射(不用深度学习,用 Isolation Forest 更稳)
DNS 行为是高维稀疏+长尾分布,LSTM/RNN 容易过拟合,而Isolation Forest(iForest)对孤立点敏感、训练快、无需标签——正是 C&C 检测的黄金组合。我们用sklearn.ensemble.IsolationForest,但必须关闭contamination自动估计(它会把前 5% 当异常,而真实僵尸占比常 <0.01%),改用手动阈值:
from sklearn.ensemble import IsolationForest from sklearn.preprocessing import StandardScaler # 加载特征,剔除缺失值过多的 client feat_df = pd.read_parquet('dns_features.parquet') feat_df = feat_df.dropna(subset=['qps_mean', 'domain_len_entropy', 'nx_ratio']) X = feat_df[[ 'qps_zscore', 'domain_len_entropy', 'subdomain_depth_mean', 'nx_ratio', 'tunnel_size_ratio', 'qtype_diversity', 'ttl_mean', 'resp_delay_mean', 'unique_domain_count', 'query_burst_ratio', 'edns_flag_ratio' ]].values scaler = StandardScaler() X_scaled = scaler.fit_transform(X) # 关键:n_estimators=100(足够)、max_samples=256(小样本隔离)、contamination=1e-4(强制设为 0.01%) clf = IsolationForest( n_estimators=100, max_samples=256, contamination=1e-4, # 不要 auto!手动设为预期僵尸比例 random_state=42, n_jobs=-1 ) y_pred = clf.fit_predict(X_scaled) # -1=异常,1=正常 anomaly_scores = clf.score_samples(X_scaled) # 越负越异常 # 输出 top 10 最可疑 client_ip feat_df['anomaly_score'] = anomaly_scores feat_df.nlargest(10, 'anomaly_score')[['ip.src', 'anomaly_score', 'qps_mean', 'nx_ratio']]为什么选 iForest 而非 AutoEncoder?—— AutoEncoder 在 DNS 特征上容易把高频查询(如 CDN 域名)误判为正常,而 iForest 直接定位“最不像多数”的点;
contamination=1e-4是血泪经验:在 IDC DNS 日志中,真实僵尸 IP 占比约 0.005%~0.03%,设太高(如 0.1)会导致告警泛滥,设太低(如 1e-6)会漏掉早期感染节点。
3. DNS 异常检测的四大避坑指南:那些让模型在生产环境集体翻车的细节
DNS 流量分析看似简单,实则处处是黑匣子陷阱。以下是我在线上环境踩过的坑,每一条都附带真实日志片段和修复动作,不是理论推演。
3.1 坑:tshark 解析 DNS 时漏掉 EDNS 扩展字段,导致dns.resp.len恒为 0
现象:dns.resp.len字段全为 NaN 或 0,tunnel_size_ratio恒为 0,无法识别 DNS Tunneling。
原因:默认tshark不解析 EDNS OPT RR,dns.resp.len仅统计传统 DNS 报文长度(不含 OPT),而隧道载荷实际藏在 OPT 的DATA字段中。
解决:强制启用 EDNS 解析,并用dns.opt.len替代dns.resp.len:
tshark -r dns.pcap \ -Y "dns && dns.flags.response == 1" \ -T fields \ -e dns.opt.len \ # 关键!用这个替代 dns.resp.len -e dns.flags.rcode \ -e dns.time \ -E header=y -E separator=, -E quote=d \ > dns_with_edns.csv提示:
dns.opt.len是 OPT RR 中DATA字段长度,DGA 僵尸常用此字段编码指令(如dig @8.8.8.8 TXT a.b.c.d.e.f.g.h.dynu.net +edns=0 +ednsflags=0x20000)。
3.2 坑:dns.qname字段含\x00截断符,Pandas 读取后变成乱码,域名长度统计失真
现象:domain_len_entropy计算结果异常高(>8.0),远超正常值(1.2~3.5),误报 DGA。
原因:某些 DNS 实现(如旧版 dnsmasq)在qname末尾补\x00,tshark 导出 CSV 时未转义,Pandas 读取为b'google.com\x00',.str.len()返回 12 而非 10。
解决:清洗qname字段,移除不可见字符:
df['dns.qname'] = df['dns.qname'].str.replace(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', regex=True) df['dns.qname'] = df['dns.qname'].str.strip('.') # 去掉末尾点3.3 坑:dns.time在 UDP 重传场景下为 0,导致resp_delay_mean被拉低,漏判慢响应隧道
现象:已知 DNS Tunneling 流量dns.time > 500ms,但特征中resp_delay_mean常为 12ms,模型不告警。
原因:UDP 重传时,tshark 将首次查询与最后一次响应配对,若响应丢失则dns.time=0;且dns.time仅对成功响应有效。
解决:改用frame.time_delta_displayed(包间时间差)估算延迟,并过滤dns.time > 0的样本:
# 仅用 dns.time > 0 的记录计算延迟 valid_resp = df[df['dns.time'] > 0].copy() valid_resp['delay_ms'] = valid_resp['dns.time'] * 1000 feat_df['resp_delay_mean'] = valid_resp.groupby(['ip.src', 'minute'])['delay_ms'].mean()3.4 坑:同一 client_ip 多网卡(如笔记本 WiFi+以太网),IP 聚合导致行为混淆
现象:某办公 IP 的unique_domain_count高达 1200/分钟,被标为僵尸,实为员工同时开 Chrome/Firefox/Teams/Zoom 四个应用查不同域名。
原因:DNS 查询源 IP 是出口 NAT 后地址,无法区分终端。
解决:引入dns.id(DNS transaction ID)+src_port二元组作为会话标识,替代单纯ip.src:
# 构建会话 ID:dns.id + src_port(需从 pcap 提取) df['session_id'] = df['dns.id'].astype(str) + '_' + df['udp.srcport'].astype(str) # 后续聚合按 session_id 而非 ip.src注意:
dns.id是 16 位整数,udp.srcport是端口,二者组合可唯一标识一次 DNS 事务(只要不发生端口复用冲突,概率极低)。
4. 如何验证你的 DNS 异常检测器没在“玄学告警”:三类黄金测试集构建法
模型上线前,必须用可控、可复现、可溯源的测试集验证。不能只靠历史日志跑 AUC——那只是“拟合过去”,不是“预测未来”。我坚持用以下三类测试集交叉验证:
4.1 白盒测试集:用 Scapy 主动构造 DGA 流量(100% 可控)
目标:验证模型对已知 DGA 算法的检出率。用dga库生成 Gozi、CryptoLocker 等 12 种 DGA 域名,用 Scapy 发送 DNS 查询:
from scapy.all import * from dga import gozi, cryptolocker # 生成 Gozi DGA 域名(2024-06-01) domains = [gozi.generate(i, '2024-06-01') for i in range(100)] # 构造 DNS 查询包(UDP) for domain in domains: pkt = IP(dst="192.168.1.1")/UDP(dport=53)/DNS(rd=1, qd=DNSQR(qname=domain)) send(pkt, verbose=0) # 抓包并提取特征,检查是否命中 anomaly_score < -0.8验证指标:对 100 个 Gozi 域名,应有 ≥95 个 client_session 的
anomaly_score < -0.7;若低于 80,说明domain_len_entropy或nx_ratio特征权重不足,需调参。
4.2 灰盒测试集:注入真实 CTF 流量(2021-绿城杯-misc-流量分析)
目标:验证对真实攻击链的端到端检出能力。该赛题 pcap 包含dnscat2DNS Tunneling 流量,特征明显:
- QNAME 长度 > 200 字符(Base32 编码载荷)
- RCODE 全为 0(成功响应)
dns.resp.len≈ 1500B(MTU 限制)- 查询间隔 100±10ms(心跳)
操作:
- 下载
2021-绿城杯-misc-流量分析.pcap - 用前述
tshark命令提取 CSV - 运行特征工程脚本,检查
anomaly_score排名前 3 是否为192.168.110.130(攻击机 IP) - 若未命中,检查
subdomain_depth_mean是否因域名过长被截断(Scapy 默认截断 QNAME),需在 tshark 中加-o tcp.truncated_packet_length:0。
4.3 黑盒测试集:模拟企业办公流量(用dnsmasq+curl构建基线)
目标:验证对正常业务的误报率(FPR)。搭建本地 DNS 服务器,模拟真实负载:
# 启动 dnsmasq 作为权威 DNS(响应预设域名) echo "address=/google.com/8.8.8.8" > /etc/dnsmasq.conf dnsmasq -d & # 用 curl 并发查 50 个常见域名,持续 10 分钟 for i in {1..50}; do curl -s "http://$(shuf -i 1-50 -n1).com" --resolve "$(shuf -i 1-50 -n1).com:80:127.0.0.1" > /dev/null & done验收标准:运行 24 小时后,
anomaly_score < -0.5的 client_ip 数量 ≤ 3 个(允许偶发重试),且人工核查均为curl重试或浏览器预加载,非真实僵尸。
5. 生产环境调参实战:三个必调参数与一个后悔药机制
在 IDC 部署时,我发现 90% 的误报来自参数失配,而非模型缺陷。以下是我在 3 个不同规模网络(500终端/5000终端/5万终端)中反复验证的调参策略。
5.1contamination:不是超参数,是业务 SLA
contamination不是模型内部参数,而是你对“可接受告警量”的承诺。公式:
contamination = (预期僵尸 IP 数) / (总活跃 client IP 数)- 500 终端网络:按 0.5% 感染率 →
contamination=0.005 - 5000 终端网络:按 0.1% 感染率 →
contamination=0.001 - 5 万终端网络:按 0.02% 感染率 →
contamination=2e-4
血泪经验:曾将
contamination设为 0.1 上线,结果每天告警 200+ IP,安全员拒看;改为2e-4后,日均告警 3~5 个,全部确认为真实感染。
5.2max_samples:小样本隔离,防过拟合
max_samples决定每棵决策树看到多少样本。设太小(如 64)→ 树太浅,漏判;设太大(如 1000)→ 树太深,把正常波动当异常。
推荐值:
- 日均 DNS 请求 < 100 万 →
max_samples=128 - 日均 DNS 请求 100~1000 万 →
max_samples=256 - 日均 DNS 请求 > 1000 万 →
max_samples=512
验证方法:在验证集上画max_samplesvsF1-score曲线,拐点即最优值。
5.3qps_zscore的滚动窗口:别用静态阈值,用动态基线
很多人用qps_mean > 100做硬阈值,结果凌晨运维批量更新触发告警。正确做法是:
- 对每个
ip.src,维护一个 1 小时滑动窗口(60 分钟)的qps_mean历史 - 实时计算
zscore = (current_qps - window_mean) / (window_std + 1e-6) - 仅当
zscore > 5.0且nx_ratio > 0.8时才触发高危告警
这样,运维脚本的突发查询会被平滑掉,而僵尸的zscore=12.3仍能捕获。
5.4 后悔药机制:白名单 + 人工反馈闭环
再好的模型也会误报。必须设计“一键降权”通道:
- 告警页面提供「标记为误报」按钮
- 后台自动将该
ip.src加入 Redis 白名单,未来 24 小时跳过检测 - 每日汇总误报 IP,用
scikit-learn的calibration_curve检查模型置信度校准度,若anomaly_score与真实风险不匹配,则重训 iForest
我给自己定的铁律:任何新上线的 DNS 异常检测规则,必须先在测试环境跑满 72 小时,且人工复核前 20 条告警全部为真,才能切生产。这慢,但省去后续 3 天的救火时间。
希望帮到你。
本文还有配套的精品资源,点击获取