简介:一套面向计算机类毕业设计或课程作业的域名生成算法检测项目,利用深度学习识别恶意软件生成的域名,帮助抵御基于该算法的僵尸网络通信。资源围绕循环神经网络、长短时记忆网络及注意力机制展开,覆盖数据预处理、特征工程、模型构建、训练评估等完整环节,适合正在研究网络安全与深度学习交叉课题的学生参考。压缩包共含十九个文件,其中十二个脚本负责模型定义、数据切分、训练评估与工具封装,三个交互式笔记演示数据准备与探索性分析,两篇论文提供算法背景与实验对比,另有说明文档,整体体积约一点四三兆字节,目录按功能模块划分,便于查找代码。目前已有二百二十六人学习。使用这份资源可以快速搭建双向长短时记忆网络检测基线,复现论文中的实验流程,同时借鉴其工程化系统集成方式、C++性能优化与调参思路,为后续改进、扩展或答辩展示打下扎实基础。
1. DGA检测在做什么:用一个二分类题目撑起毕设和课程作业
某天翻防火墙日志,看到内网主机每隔几分钟就在查询一串毫无语义的域名,像qkzwxrhyoj.com、jfqynstbafd.top。这通常不是故障,而是主机被植入恶意软件后,正在按域名生成算法(Domain Generation Algorithm,DGA)批量生成新域名,用来与远端的C2服务器保持联系。此时靠黑名单封禁已经失效,因为域名每天都在换。基于深度学习的DGA检测,正是把这个问题收敛成一个二分类任务:给定一个域名,判断它属于人工注册的正常域名,还是算法随机生成的恶意域名。对毕设和课程作业来说,这个题目数据可获得、实验可视化强、工作量适中,不需要昂贵设备,适合完整走一遍“数据处理—模型设计—训练调参—评估说明”的深度学习流程。
2. 先备好数据和标签:良性域名与恶意DGA家族的获取与切分
做DGA检测的第一步不是建模型,而是把训练数据准备到“能快速复现、能写进论文的数据清洗说明”的程度。这里面的坑比模型本身多得多。
2.1 良性域名从哪来:公开Top列表与流量日志的取舍
常见做法是用公开的热门域名列表当良性样本。早期很多人用Alexa Top 1M,但这份数据已经停更,现在更常见的是Cisco Umbrella的Top Million列表,或者Common Crawl里抽样出的域名。这类数据的优点是量大、稳定、标签干净——它们是被真实用户访问过的域名,基本可以认定是良性的。另一种来源是机构自己的DNS访问日志,能反映真实网络环境,但数据往往涉及隐私,课设阶段不容易拿到,即使拿到也需要做匿名化。
我一般建议起步阶段用公开Top列表的注册域名部分,随机抽2万到5万条作为良性样本就足够跑通流程。注意要保留“注册域名”而不要太依赖子域,比如a.b.example.com和c.example.com本质上属于同一个域名所有者,训练时会让模型记住“example.com 长得像良性”这种偏置。简单做法是用tldextract库提取注册域,再按注册域去重。
import tldextract def normalize_domain(url: str) -> str: ext = tldextract.extract(url) registered = f"{ext.domain}.{ext.suffix}" # 例如 example.com return registered.lower()这段代码解决的是“把完整URL收敛成注册域”。tldextract会处理www.example.co.uk这类多级公共后缀,拿到example.co.uk而不是误取co.uk。参数上不需要额外配置,只是要注意ext.suffix可能为空(比如内网主机名nginx-server),这种情况直接丢弃该样本。
2.2 DGA样本从哪来:公开家族样本 vs 自写生成器
恶意DGA域名最可靠的来源是安全厂商和学术机构公开的DGA家族样本集,比较常见的有360开源的DGA家族样本,以及DGArchive这类学术项目整理的家族数据。每个样本通常带域名、DGA家族名、生成时间等字段。这类数据的价值在于:一个数据集里覆盖了多个家族的生成策略,有的家族生成纯随机字符串,有的生成两个单词的拼接,有的混合数字与字母,模型在这种多样性下学到的才是“不像正常注册域名”的通用分布,而不是背下某个特定家族的模式。
另一种做法是自写生成器。部分已知DGA家族的生成算法在公开文献中有描述,为了做对照实验可以复现一套。注意自写生成器的代价是很容易生成一批高度相似的样本,导致训练集内部冗余严重。如果你选择这条路,建议每个家族至少保留1万个样本,并且通过随机种子控制,保证生成的样本有足够差异。
两类方式对比下来,公开家族样本更适合做主实验,自写生成器更适合做论文里的扩展实验,比如验证“模型在未见过的生成器上是否失效”。起步阶段先用公开样本集各家族混合取样1万到3万条,和良性样本数量大致持平,训练一个初始模型。
2.3 训练集、验证集、测试集怎么切才不“数据泄漏”
这是DGA检测里最容易被忽略的一个环节,也是后来泛化性崩盘的根源。很多课程作业直接把所有数据随机打乱,按比例7:2:1切分,结果训练集和测试集里出现了同一批时间窗口内生成的DGA域名。因为DGA生成通常依赖时间或者连续种子,同一窗口的样本高度相似,模型相当于把题目答案背下来了,测试F1可能达到0.99,换一批新域名立刻掉到及格线以下。
正确做法是按时间或生成序号排序后再切分。DGA样本集通常自带日期字段,先按日期排序,取前70%做训练,后30%做测试。这样测试集里的域名在时间上晚于训练集,更接近真实部署场景——模型要面对的是“未来”的域名。良性域名没有时间概念,就按域名长度排序后切分,避免超长域名都堆在某一侧。
以时间排序的语义很明确:恶意软件更换生成窗口后,那些在训练窗口之后出现的DGA域名就是模型未来要面对的样本。如果数据集没有显式时间字段,但有生成索引或者家族内编号,按编号排序也有近似效果。这一点建议在论文里单独写一段,它通常是答辩时区分“背答案”和“会泛化”的关键证据。
3. 把域名喂给模型:字符序列化与模型结构选型
DGA检测的输入是字符串,不是图像也不是数值表格。怎么把域名变成模型能吃的张量,以及选什么模型结构吸收这个序列,决定了后续训练是顺风顺水还是反复折腾。
3.1 域名转字符序列:长度截断、填充与样本顺序
域名先整体小写,然后用tldextract取出注册域部分,再做两件事:去掉TLD段,反转主域名。
之所以反转,是因为LSTM这类循环模型对序列末尾的信息更敏感。如果我们保留example.com正向顺序并在右侧补零,模型在最后一个时间步读到的是大量padding,有效信息被拉得很远。把主域名反转成elpmaxe,再让序列在右侧对齐,有效字符集中在序列前端,最后一个时间步的隐状态能聚合到更多真实信号。通俗讲,模型读到的顺序是“主域名的字符”在前面,而不是一堆空位在前面。
字符映射表用固定字母表,包含小写字母、数字、连字符、点,再加一个UNK占位。长度方面,注册域部分超过75个字符的样本极少,截断到75即可。如果数据集里长域名比例偏高,先打印长度分布再定截断值,不要直接拍脑袋。
ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789-." UNK_IDX = len(ALPHABET) + 1 # 未知字符单独一个索引,不能和padding共用 ch2idx = {ch: i + 1 for i, ch in enumerate(ALPHABET)} ch2idx["<UNK>"] = UNK_IDX MAX_LEN = 75 def domain_to_ids(domain: str, max_len: int = MAX_LEN) -> list[int]: ext = tldextract.extract(domain) main_part = ext.domain # 去掉suffix,避免TLD分布干扰 seq = [ch2idx.get(ch, UNK_IDX) for ch in reversed(main_part)] if len(seq) > max_len: seq = seq[:max_len] else: seq += [0] * (max_len - len(seq)) # 0 保留给 padding return seq这里的关键参数是UNK_IDX和0的分工。0是padding专用,不在字母表内;任何不在字母表里的字符(比如中文域名转punycode前的Unicode字符)都映射到UNK_IDX,而不是映射成0。踩过坑的人都知道,把未知字符映射到padding索引会让模型分不清“这个位置没有字符”和“这个位置有一个我认不出的字符”,推理时一旦遇到生僻字符,整个batch的概率分布都会乱掉。main_part取ext.domain而不是完整注册域,是为了把.com、.top这类后缀忽略掉——恶意软件换TLD成本极低,保留TLD只会让模型学到“.com更可能是良性”这种随时间漂移的假规律。
3.2 LSTM、CNN、Transformer怎么选:毕设场景下的对比表
同样一份字符序列,可以接不同模型主体。我这里给一个适合毕设的选型对比,避免一上来就上复杂结构导致调不动。
| 模型 | 训练速度 | CPU推理速度 | 对序列模式的捕捉 | 毕设里的定位 |
|---|---|---|---|---|
| TextCNN(一维卷积) | 快 | 快 | 能捕捉局部n-gram模式,对长度依赖弱 | 轻量基线,用来证明“加循环结构是否有收益” |
| BiLSTM | 中 | 中 | 双向建模,能捕捉长短程依赖 | 推荐主模型,效果和可解释性均衡 |
| Transformer | 慢 | 中 | 全局注意力,但小数据下容易过拟合 | 对比实验,体现工作量上限 |
选主模型我一般推荐BiLSTM,原因很实际:DGA域名本质上是字符序列,恶意家族生成时的随机字符之间有长程统计关系,LSTM天然适配这种序列建模;同时LSTM的参数量适中,一张普通显卡甚至纯CPU都能在几小时内跑完。TextCNN可以作为基线,论文里写一句“本文采用TextCNN作为轻量对比模型,验证序列建模模块的有效性”,就构成一组消融。Transformer可以加进去对比,但要有心理准备:几万条样本下Transformer收敛慢,而且很容易过拟合,需要更强的正则。
3.3 为什么不直接用手工特征:这个题本来就是为了端到端
有人会问,传统方法用人工特征(域名长度、数字占比、元音比例、字符熵、可发音性评分)加随机森林也能到不错的AUC,为什么非要深度学习?这个问题答辩时大概率会被问到。答案是:手工特征依赖人对“什么是异常域名”的归纳,恶意软件作者完全可以针对这些统计特征构造对抗样本;深度学习模型直接从字符分布里学决策边界,不需要预设规则,换一个未知家族时通常比手工特征泛化得更好。
所以做深度学习版本的DGA检测,核心卖点不是“我堆了一个神经网络”,而是“整个系统从原始字符串到判定结果只经过一次端到端的特征学习”。这一点要在论文和答辩里反复强调,它决定了你的题目是深度学习课设而不是传统机器学习课设。
4. 用PyTorch跑通最小训练管线:模型定义、损失与调参
前面数据准备好了,现在落到代码。下面这个管线是我在课设场景里常用的最小实现,单机CPU能跑,环境用conda隔离,避免搞坏系统Python。
4.1 先配环境:别在环境上花掉一整天
conda create -n dga_det python=3.10 -y conda activate dga_det pip install torch scikit-learn pandas tldextract --index-url https://download.pytorch.org/whl/cpu说明:--index-url指向PyTorch官方CPU wheel源,适合没有NVIDIA GPU的笔记本。如果机器有独显,去掉这行直接pip install torch会默认拉取CUDA版本。常见做法是先用CPU版把代码跑通,再决定要不要用云GPU实例做大规模实验。scikit-learn用于指标计算,tldextract用于域名标准化,这两个库是整个流程里除了PyTorch之外仅有的第三方依赖,把依赖控制在最小范围能让复现容易很多。
4.2 模型定义:Embedding + BiLSTM + 全连接
模型部分不需要很花哨,一个双向LSTM加一层全连接足够在公开数据上拿到可观的指标。
import torch import torch.nn as nn class DGALSTM(nn.Module): def __init__(self, vocab_size=41, embed_dim=64, hidden_size=64, num_layers=2, num_classes=1, dropout=0.3): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.lstm = nn.LSTM(input_size=embed_dim, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, bidirectional=True) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(hidden_size * 2, num_classes) def forward(self, x): emb = self.embedding(x) # (batch, seq_len, embed_dim) out, _ = self.lstm(emb) # (batch, seq_len, hidden*2) out = out[:, -1, :] # 取最后一步,聚合整个序列信息 out = self.dropout(out) return self.fc(out).squeeze(-1) # 输出logits,形状(batch,)关键点有三个。padding_idx=0告诉Embedding层,索引0对应的向量永远不参与梯度更新,这样padding不会给模型引入噪声;bidirectional=True让模型同时从左到右和从右到左读取字符,双向隐状态拼接后维度是hidden_size * 2,所以全连接层输入维度要翻倍;输出层不加sigmoid,直接返回logits,配合后面的BCEWithLogitsLoss,在数值上比“先sigmoid再算BCE”更可靠,不会出现概率取对数时溢出为NaN的问题。
参数解读:vocab_size=41对应26个字母+10个数字+3个符号(-、.、<UNK>)+1个padding索引;embed_dim=64和hidden_size=64是经验值,提升到128会带来小幅指标上涨,但训练时长接近翻倍,课设阶段64起步够用;num_layers=2给模型一点深度,再往上叠对几万条样本收益不明显。
4.3 训练循环:损失函数与类别不平衡处理
真实场景里良性域名数量远大于DGA域名,比例可能达到几十比一。如果直接拿原始比例训练,模型会把所有样本都判成良性,因为全判良性loss也很低。课程作业通常不会把比例拉那么极端,但训练前仍然建议按负样本:正样本≈5:1到10:1粗处理,再用pos_weight给正样本的loss加权。
from torch.utils.data import DataLoader, TensorDataset import torch.optim as optim # 假设 train_ids, train_labels 已经构造好 dataset = TensorDataset(torch.tensor(train_ids), torch.tensor(train_labels)) loader = DataLoader(dataset, batch_size=64, shuffle=True, num_workers=0) model = DGALSTM() pos_weight = torch.tensor([3.0]) # 先粗设,按负样本/正样本比例校准 criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight) optimizer = optim.Adam(model.parameters(), lr=1e-3) model.train() for epoch in range(8): for x, y in loader: optimizer.zero_grad() logits = model(x) loss = criterion(logits, y.float()) loss.backward() optimizer.step()说明:pos_weight=3.0的含义是正样本的loss权重是负样本的3倍,它补偿“正样本数量少”的劣势。如果你把训练集切成了良性3万、DGA 1万,那么负样本:正样本=3:1,pos_weight就设3.0;如果数据集比例不同,按负样本数除以正样本数估算。这个参数不是调出来的,是算出来的。batch_size=64是CPU训练时比较稳的选择,lr=1e-3配Adam是默认稳妥组合,如果loss震荡不降,把lr降到3e-4再试。
训练结束后评估,注意不能用准确率当唯一指标。类别不平衡下准确率会虚高,要同时看AUC、F1和PR曲线:
from sklearn.metrics import roc_auc_score, f1_score, classification_report # y_true为真实标签,y_prob为sigmoid后的概率,y_pred为二值化结果 auc = roc_auc_score(y_true, y_prob) f1 = f1_score(y_true, y_pred) print(classification_report(y_true, y_pred, target_names=["normal", "dga"])) print(f"AUC={auc:.4f} F1={f1:.4f}")这段代码的意义在于建立一套完整的评估口径。AUC反映排序能力,F1反映实际判定效果,classification_report能直接看到DGA类的精确率和召回率。论文里把这几个数一并报告,比单写一个“准确率99%”有说服力得多。
4.4 超参经验表:先记住这几个值
| 参数 | 推荐范围 | 说明 |
|---|---|---|
| MAX_LEN | 64~80 | 看主域名长度分布,覆盖90%以上样本即可 |
| embed_dim | 64~128 | 64起步,128用于最终实验 |
| hidden_size | 64~128 | BiLSTM的隐层维度,64够用 |
| num_layers | 1~2 | 层数超过2对小数据集收益很小 |
| dropout | 0.2~0.5 | 0.3是常见默认值 |
| batch_size | 64~128 | CPU训练用64,有GPU可提到128 |
| lr | 1e-3~3e-4 | Adam配1e-3,loss震荡就降 |
| pos_weight | 按样本比例算 | 负样本数/正样本数,不要拍脑袋 |
这些参数不是越极端越好,课设阶段追求的是“每个参数都有明确依据”。答辩被问到“为什么hidden_size取64”,你可以答:“先用小模型跑通流程,再在64/128之间对比,128在验证集上的提升小于训练成本,所以选择64。”这个回答比“因为大家都这么设”可靠得多。
5. DGA检测避坑指南:5个让训练和推理翻车的真实场景
这一节集中写我用这个方案复现和调试时遇到过的典型问题,每条按现象、原因、解决三步给出,方便直接对照排查。
5.1 训练和推理用的序列化不一致
现象:模型在验证集上AUC高达0.99,部署推理时对短域名却全部输出接近0.5,或对某些域名概率恒为0。
原因:训练时调用了domain_to_ids,其内部做了反转和右侧对齐;推理代码里图省事直接seq = [ch2idx[c] for c in domain],没有反转也没有补齐,序列分布完全不同,模型看到的是“另一种格式”的输入。
解决:把domain_to_ids作为唯一入口,训练和推理都走同一个函数,不要在推理脚本里重新实现一遍字符映射。代码评审时确认推理路径里的reversed和seq += [0] * (max_len - len(seq))两行还在。这个坑最容易出在“demo写得太快、直接复制了一段简化代码”的场景。
5.2 未知字符被映射到padding索引
现象:训练loss偶尔出现NaN,或者模型对个别包含特殊字符的域名输出概率恒等于0。
原因:字符映射用了ch2idx.get(c, 0),把字母表外的字符映射到了0。而0是padding的索引,模型无法区分“这里没字符”和“这里有个不认识的字符”,Embedding层输出混乱,梯度反向传播时数值失控。
解决:按第4章的写法,单独设UNK_IDX,让所有未知字符落在一个独立索引上。同时输入侧先做domain.lower(),再用tldextract归一化。国际化域名如果包含中文或特殊符号,用Python的idna编码先转成ASCII兼容形式,再进字符映射。
5.3 用随机切分导致的时间窗口泄漏
现象:测试F1是0.99,换一个时间段的DGA样本实测立刻掉到0.7以下,模型像“背答案”。
原因:训练测试直接随机打乱切分,同一时间窗口或同一随机种子生成的DGA域名同时出现在两端,模型记忆的是该窗口的字符分布,而不是泛化的DGA模式。
解决:数据切分前先按时间或生成序号排序,取前70%做训练、后30%做测试;如果数据集没有时间字段,但有多家族字段,可以按家族holdout——把某个家族完整地留在测试集里,训练时完全不见该家族,这是检验泛化能力的最严格做法。
5.4 类别不平衡下的准确率虚高
现象:训练输出acc=0.998,看起来非常漂亮,但细看DGA类的召回率只有0.1,几乎全是负样本。
原因:良性域名远多于DGA域名,模型只要全会判成良性,准确率就极高,但检测任务真正的目标是抓恶意样本。
解决:评估报告用PR-AUC、F1和DGA类召回率,不要只展示准确率。训练时用pos_weight校正类别权重,或者直接采样让正负样本比例落到1:5以内,保证每个batch里都有足够多的正样本参与梯度更新。
5.5 空序列和全padding样本
现象:某个batch的loss突然变成NaN,或者训练进程在某个样本上卡死。
原因:部分域名经过tldextract提取后主域名为空(比如输入是单段内网主机名、或者后缀处理异常),生成的全是padding序列,模型对全零输入计算梯度时数值不稳。
解决:数据清洗阶段过滤掉主域名长度小于2的样本;训练前检查是否存在全零序列,有则直接丢弃。给Dataset类加一个保护逻辑:如果sum(seq)==0,跳过该样本。这样既不影响数据分布,也不用担心训练崩溃。
6. 验证与进阶:从AUC到未知DGA家族的泛化测试
6.1 对比实验怎么做才不被问倒
课程作业和毕设的答辩环节,老师最常问的一句话是“你这个模型比传统方法好在哪”。所以实验部分至少要覆盖三组:手工特征加随机森林(传统基线)、TextCNN、BiLSTM。三组在完全相同的训练/测试切分下报告AUC、PR-AUC、F1和单条推理耗时。数据切分方案是否一致,决定了对比实验是否公平。建议把训练、验证、测试的种子固定,比如统一用random_state=42,这样论文里写的每个数字都可复现。
进阶做法是加一组按DGA家族holdout的实验:训练时去掉一个家族的全部样本,测试时只评估这个家族。如果模型在这个指标上也能维持不错的AUC,说明它不是记住了特定家族的字面模式,而是学到了“正常注册域名”的分布边界。这个实验在论文里非常加分,因为它直接回应了DGA检测最核心的痛点——未知家族。
6.2 从离线指标到真实流量判断
单域名判定只是第一步。真实场景里,恶意主机会在短时间内发起大量DGA域名查询,所以更可靠的做法是加一个时间窗口聚合规则:统计同一内网IP在5分钟内请求的新域名数量、这些域名各自的DGA得分均值、以及最高得分。如果单域名得分超过0.8,或者窗口内超过3个域名得分在0.5以上,触发告警。这个规则不复杂,但在答辩时体现的是系统思维。
模型导出为ONNX,方便脱离PyTorch环境做推理:
import torch.onnx model.eval() dummy = torch.randint(0, 40, (1, 75)) torch.onnx.export(model, dummy, "dga_lstm.onnx", input_names=["ids"], output_names=["logits"], dynamic_axes={"ids": {0: "batch", 1: "seq"}}, opset_version=12)用dynamic_axes允许batch维度变化,推理时配合onnxruntime一次跑一个域名或一批域名。实际部署时把模型接到DNS查询链路的旁路日志流上,比串行拦截更容易落地。
我自己第一次跑这个题的时候,手里没有真实内网日志,就在公开数据上把F1调到了0.99,一度觉得这个题太简单。后来换了一个时间段的新DGA样本一测,指标掉得厉害,才意识到这个题的难点从来不在模型结构,而在数据切分和评估口径是否贴近真实场景。从那以后,每次做这类检测任务都先把数据划分方案写在代码注释里,再开始调模型。希望帮到你。
本文还有配套的精品资源,点击获取