ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于DNN的恶意网站检测:URL特征工程与PyTorch实战

基于DNN的恶意网站检测:URL特征工程与PyTorch实战 简介这份资源面向计算机、信息安全及相关专业学生与入门开发者提供一套基于传统机器学习DNN算法的恶意网站检测完整项目可直接用于课程设计或期末大作业。压缩包共7个文件以5个Python源码文件为主涵盖数据处理、特征转换与DNN模型训练等核心环节另含1个数据压缩包和1份说明文档整体约3.31MB结构紧凑、便于快速上手。项目已获导师指导并通过据描述取得97分高分下载后无需修改即可运行适合作为机器学习分类任务的实践参考。目前已有446人学习关注。读者可从中获得完整的恶意网站检测实现思路、可复用的DNN建模代码、配套数据集以及项目说明既能理解传统机器学习在安全检测场景中的应用流程也能据此完成实验报告与答辩展示对需要短周期交付高质量课程作业的读者尤为实用。1. 恶意网站检测为什么值得用 DNN 重做一遍如果你正在做机器学习期末大作业或者想找一个能写进简历、又能真正跑起来的项目恶意网站检测是个被低估的选题。它不像手写数字识别那样烂大街也不像目标检测那样吃显卡一台普通笔记本就能跑完全流程。更关键的是这个任务天然适合传统机器学习你从 URL 里抽特征喂给 DNN输出一个二分类结果整条链路清晰、可解释、可复现。我见过太多同学一上来就上深度学习框架结果卡在环境配置和数据清洗上最后交了个跑不通的代码。其实恶意网站检测的核心不在模型多深而在特征工程做得对不对。URL 长度、特殊字符数量、域名层级、是否含 IP 地址这些特征比模型结构重要得多。这篇笔记就按「特征怎么抽 → DNN 怎么搭 → 参数怎么调 → 坑在哪」的顺序把整个方案拆开讲清楚让你能照着复现也能看懂每一步为什么这么做。2. 恶意网站检测的特征工程与数据准备2.1 为什么 URL 特征比模型结构更决定成败恶意网站检测的本质是分类问题给一条 URL判断它是正常网站还是钓鱼、挂马、诈骗站点。传统做法有两种一种是基于黑名单匹配维护一个已知恶意域名列表来了新 URL 就查表。这种方法准确率高但覆盖率低攻击者换个域名就绕过了。另一种是基于内容分析把网页抓下来看 HTML、JS 里有没有恶意代码但抓取本身有风险速度也慢。机器学习走的是第三条路从 URL 字符串本身抽特征不访问目标站点直接分类。这样做的好处是速度快、无风险、可批量处理。而特征抽得好不好直接决定模型上限。我一般会从四个维度抽特征长度类URL 总长度、域名长度、路径长度、参数个数字符类数字个数、特殊字符-、_、、?、、个数、大写字母比例结构类域名层级数、是否含 IP 地址、是否含端口号、路径深度语义类是否含敏感词login、verify、account、secure、顶级域名类型这些特征加起来大概 20 到 30 维足够 DNN 学到区分模式。下面是一个特征抽取函数的实现import re from urllib.parse import urlparse def extract_features(url): 从单条 URL 抽取 22 维特征返回列表 features [] parsed urlparse(url) domain parsed.netloc path parsed.path # 1-4: 长度类特征 features.append(len(url)) # URL 总长度 features.append(len(domain)) # 域名长度 features.append(len(path)) # 路径长度 features.append(url.count(/)) # 斜杠数量 # 5-10: 字符类特征 features.append(sum(c.isdigit() for c in url)) # 数字个数 features.append(url.count(-)) # 连字符 features.append(url.count(_)) # 下划线 features.append(url.count()) # 符号 features.append(url.count(?)) # 问号 features.append(url.count()) # 等号 # 11-14: 结构类特征 features.append(domain.count(.)) # 域名层级 features.append(1 if re.match(r\d\.\d\.\d\.\d, domain) else 0) # 是否 IP features.append(1 if : in domain else 0) # 是否带端口 features.append(len(path.strip(/).split(/))) # 路径深度 # 15-18: 语义类特征 sensitive [login, verify, account, secure, update, confirm] features.append(sum(1 for w in sensitive if w in url.lower())) features.append(1 if url.startswith(https) else 0) # 是否 HTTPS features.append(len(re.findall(r[A-Z], url))) # 大写字母数 features.append(len(re.findall(r[^a-zA-Z0-9], url))) # 非字母数字字符数 # 19-22: 补充特征 features.append(1 if // in url[8:] else 0) # 路径中是否含 // features.append(url.count(.)) # 点号总数 features.append(len(domain.split(.)[-1])) # 顶级域名长度 features.append(1 if any(c in url for c in [%, , ;]) else 0) # 编码字符 return features这段代码的逻辑很直白把 URL 拆成域名和路径然后从不同角度数数。参数说明上urlparse负责拆分re负责正则匹配。注意第 12 个特征判断 IP 地址很多钓鱼网站直接用 IP 访问这个特征区分度很高。第 15 个特征统计敏感词正常网站很少在 URL 里堆 login、verify 这类词恶意站点则经常用。提示特征抽取函数要保证对任何输入都不报错。我一般会在外面包一层 try-except遇到解析失败的 URL 返回全零向量避免训练中断。2.2 数据集怎么划分才不会虚高准确率数据集通常包含正常 URL 和恶意 URL 两类正常样本可以从公开的域名列表里取恶意样本用钓鱼网站黑名单。拿到数据后第一件事是去重很多黑名单里同一个域名出现几十次不去重的话模型会记住这个域名测试集里再出现就变成作弊。划分比例我一般用 7:1.5:1.5训练集 70%验证集 15%测试集 15%。关键是划分前要先打乱并且保证正负样本比例在三个集合里一致。如果恶意样本只占 10%直接随机划分可能导致某个集合里恶意样本太少评估指标波动大。稳妥做法是分层抽样from sklearn.model_selection import train_test_split import pandas as pd # df 包含两列url 和 label0 正常1 恶意 df pd.read_csv(urls_dataset.csv) df df.drop_duplicates(subseturl).reset_index(dropTrue) # 先抽 70% 训练集剩余 30% 再对半分成验证和测试 train_df, temp_df train_test_split( df, test_size0.3, stratifydf[label], random_state42 ) val_df, test_df train_test_split( temp_df, test_size0.5, stratifytemp_df[label], random_state42 ) print(f训练集 {len(train_df)}验证集 {len(val_df)}测试集 {len(test_df)})stratify参数保证按 label 分层random_state固定随机种子让结果可复现。去重那一步用drop_duplicates按 url 列去重这一步不做的话后面准确率能虚高十几个百分点血泪经验。2.3 特征归一化与数据加载器DNN 对输入尺度敏感URL 长度可能几百数字个数可能几十量纲不统一会让梯度下降变慢。我一般用 MinMax 归一化把每个特征压到 0 到 1 之间from sklearn.preprocessing import MinMaxScaler import numpy as np # 对所有 URL 抽特征 X_train np.array([extract_features(u) for u in train_df[url]]) X_val np.array([extract_features(u) for u in val_df[url]]) X_test np.array([extract_features(u) for u in test_df[url]]) y_train train_df[label].values y_val val_df[label].values y_test test_df[label].values # 归一化只在训练集上 fit避免数据泄漏 scaler MinMaxScaler() X_train scaler.fit_transform(X_train) X_val scaler.transform(X_val) X_test scaler.transform(X_test) print(f特征维度{X_train.shape[1]})注意fit_transform只在训练集上调用验证集和测试集用transform。如果对全量数据做归一化测试集的统计信息会泄漏到训练过程评估结果不可信。这个坑很多人踩过模型在测试集上表现很好一上线就翻车。3. DNN 模型搭建与训练参数怎么定3.1 用 PyTorch 搭一个三隐层 DNN 分类器恶意网站检测的输入是 22 维特征向量输出是二分类概率。网络不需要太深三到四个隐层足够。我一般用 64 → 32 → 16 的递减结构每层后面接 ReLU 和 Dropout。Dropout 比例设 0.3防止过拟合。输出层用单个神经元加 Sigmoid配合 BCELoss。import torch import torch.nn as nn class MaliciousURLDetector(nn.Module): def __init__(self, input_dim): super().__init__() self.net nn.Sequential( nn.Linear(input_dim, 64), nn.ReLU(), nn.Dropout(0.3), nn.Linear(64, 32), nn.ReLU(), nn.Dropout(0.3), nn.Linear(32, 16), nn.ReLU(), nn.Linear(16, 1), nn.Sigmoid() ) def forward(self, x): return self.net(x).squeeze(-1) model MaliciousURLDetector(input_dimX_train.shape[1]) print(model)input_dim是特征维度从数据里自动读取不要写死。squeeze(-1)把输出从[batch, 1]压成[batch]方便和标签计算损失。Dropout 只在训练时生效推理时自动关闭这是 PyTorch 的内置行为。3.2 训练循环与三个必调参数训练循环里我关注三个参数学习率、batch size、早停轮数。学习率用 1e-3 配 Adam 优化器这是最稳的起点。batch size 设 64太小梯度震荡太大收敛慢。早停设 10 轮验证集损失连续 10 轮不降就停防止过拟合。from torch.utils.data import DataLoader, TensorDataset # 转成 Tensor train_ds TensorDataset( torch.FloatTensor(X_train), torch.FloatTensor(y_train) ) val_ds TensorDataset( torch.FloatTensor(X_val), torch.FloatTensor(y_val) ) train_loader DataLoader(train_ds, batch_size64, shuffleTrue) val_loader DataLoader(val_ds, batch_size64) device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) criterion nn.BCELoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) best_val_loss float(inf) patience, wait 10, 0 for epoch in range(100): model.train() for xb, yb in train_loader: xb, yb xb.to(device), yb.to(device) optimizer.zero_grad() pred model(xb) loss criterion(pred, yb) loss.backward() optimizer.step() # 验证 model.eval() val_loss 0 with torch.no_grad(): for xb, yb in val_loader: xb, yb xb.to(device), yb.to(device) val_loss criterion(model(xb), yb).item() val_loss / len(val_loader) if val_loss best_val_loss: best_val_loss val_loss torch.save(model.state_dict(), best_model.pth) wait 0 else: wait 1 if wait patience: print(f早停于第 {epoch} 轮) break if epoch % 10 0: print(fEpoch {epoch}, Val Loss: {val_loss:.4f})optimizer.zero_grad()必须放在反向传播前否则梯度会累加。model.train()和model.eval()切换训练和推理模式影响 Dropout 和 BatchNorm 行为。保存最优模型而不是最后一轮模型因为最后一轮可能已经过拟合。3.3 评估指标不能只看准确率恶意网站检测的数据集通常不平衡正常样本远多于恶意样本。如果恶意样本只占 5%模型全预测正常也有 95% 准确率但这个模型毫无用处。所以要同时看精确率、召回率和 F1from sklearn.metrics import classification_report, confusion_matrix model.load_state_dict(torch.load(best_model.pth)) model.eval() with torch.no_grad(): X_test_t torch.FloatTensor(X_test).to(device) probs model(X_test_t).cpu().numpy() preds (probs 0.5).astype(int) print(confusion_matrix(y_test, preds)) print(classification_report(y_test, preds, target_names[正常, 恶意]))阈值 0.5 是默认值实际部署时可以根据业务调整。如果漏报恶意网站的代价高就把阈值降到 0.3提高召回率牺牲一点精确率。这个权衡在安全场景里很常见。4. 恶意网站检测的避坑与排查清单4.1 准确率 99% 但上线就废数据泄漏的三种典型现象训练时验证集准确率 99%测试集也有 98%但拿新 URL 一测错得离谱。原因数据泄漏。最常见的是去重没做干净同一个域名在训练集和测试集里都出现。模型记住了这个域名而不是学到通用特征。第二种是对全量数据做了归一化测试集统计信息泄漏。第三种是特征里包含了标签信息比如某个特征只在恶意样本里出现。解决划分数据集前先按域名去重归一化只在训练集 fit抽特征时检查每个特征的分布如果某个特征在正负样本里完全分离要警惕它是不是标签的代理。4.2 损失不下降学习率和特征尺度先查这两个现象训练几轮后 loss 一直在 0.69 附近震荡降不下去。0.69 是二分类随机猜测的损失值。原因要么学习率太大导致梯度爆炸要么特征没归一化导致某些维度主导梯度。解决先把学习率降到 1e-4 试试如果还不行就检查特征尺度。打印每个特征的最大最小值如果有特征范围在 0 到 10000而其他特征在 0 到 1那肯定有问题。归一化后所有特征应该在相同量级。4.3 过拟合Dropout 和早停要一起用现象训练集 loss 持续下降验证集 loss 先降后升两者差距越来越大。原因模型容量过大或训练轮数过多记住了训练集的噪声。解决先加 Dropout比例从 0.2 开始试到 0.5。再加早停验证集 loss 连续 10 轮不降就停。如果还过拟合就减小隐层维度把 64-32-16 改成 32-16-8。数据量太少的话考虑做数据增强比如对 URL 做轻微变形生成新样本。4.4 推理速度慢批处理和模型量化现象单条 URL 预测要几十毫秒批量检测时吞吐量上不去。原因逐条推理没有利用批处理或者模型在 CPU 上跑但没做优化。解决推理时把 URL 攒成 batch一次喂给模型。如果还慢用torch.quantization做动态量化把权重从 float32 压到 int8速度能提升 2 到 3 倍精度损失很小。部署到生产环境时可以把模型导出成 ONNX 格式用 ONNX Runtime 推理比原生 PyTorch 快不少。4.5 新样本分布偏移定期用新数据重训现象模型上线三个月后准确率从 95% 掉到 80%。原因恶意网站的 URL 模式在变化攻击者会调整策略绕过检测导致新样本分布和训练集不一致。解决定期收集新样本用增量训练或全量重训更新模型。我一般每个月重训一次把新标注的数据加进去。同时监控线上预测的置信度分布如果大量样本落在 0.4 到 0.6 之间说明模型对当前数据不确定该重训了。5. 把 DNN 检测器用起来的两个进阶技巧5.1 用集成学习把 F1 再提两个点单个 DNN 的 F1 通常在 0.95 左右想再往上提可以试试集成。最简单的做法是训练 5 个结构相同但随机种子不同的 DNN推理时取平均概率。这样能降低方差F1 一般能提升 1 到 2 个点。代码改动很小def ensemble_predict(models, X): 多个模型概率平均 probs [] for m in models: m.eval() with torch.no_grad(): p m(torch.FloatTensor(X).to(device)).cpu().numpy() probs.append(p) return np.mean(probs, axis0) # 训练时用不同随机种子初始化 5 个模型 models [] for seed in [42, 123, 456, 789, 1024]: torch.manual_seed(seed) m MaliciousURLDetector(input_dimX_train.shape[1]).to(device) # ... 训练代码同上 ... models.append(m) final_probs ensemble_predict(models, X_test) final_preds (final_probs 0.5).astype(int)集成的好处是不用改模型结构代价是推理时间翻 5 倍。如果对延迟不敏感这个投入很划算。5.2 用 SHAP 解释模型到底学到了什么DNN 常被诟病是黑匣子但用 SHAP 可以看每个特征对预测的贡献。这对写论文很有用能解释模型为什么把某条 URL 判为恶意import shap # 取 100 条测试样本做解释 explainer shap.DeepExplainer(model, torch.FloatTensor(X_train[:100]).to(device)) shap_values explainer.shap_values(torch.FloatTensor(X_test[:100]).to(device)) # 特征名列表和 extract_features 顺序对应 feature_names [url_len, domain_len, path_len, slash_cnt, digit_cnt, hyphen_cnt, underscore_cnt, at_cnt, question_cnt, equal_cnt, domain_level, is_ip, has_port, path_depth, sensitive_cnt, is_https, upper_cnt, special_cnt, double_slash, dot_cnt, tld_len, encoded_char] shap.summary_plot(shap_values, X_test[:100], feature_namesfeature_names)跑完能看到哪些特征最重要。我的经验是url_len、sensitive_cnt、is_ip这三个排前面。如果某个你没想到的特征排很高要检查它是不是数据泄漏的代理。比如dot_cnt如果特别重要可能是域名层级和恶意性有强相关但也要警惕是不是数据集偏差导致的。这套方案我从特征抽取到模型部署跑过好几遍最深的教训是别一上来就调模型结构先把特征和数据划分做扎实。我见过太多人花三天调网络层数结果去重没做准确率虚高答辩时被老师一问就露馅。另一个习惯是每次实验都固定随机种子把配置写进配置文件不然一周后自己都复现不出来。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进