
简介面向网络安全与机器学习初学者的恶意网站检测系统源码项目围绕SVM、随机森林与DNN三种算法构建黑白名单分类模型涵盖特征向量提取、数据可视化、交叉验证等完整流程适合高校计算机、人工智能等专业开展实践教学或作为安全方向的基础实验。资源包为zip格式共11个文件包含5个Python脚本负责翻译处理、特征提取、分布可视化及模型训练、3个备份文件保留可运行的历史版本、2个zip数据包含原始数据集与备份数据及1个md说明文档整体大小约3.32MB结构紧凑便于按需查阅。目前已有49人学习下载模块划分清晰且代码经过严格测试读者可依据附带特征对照表调整参数或引入增量学习机制快速复现准确率95%以上的分类效果也可在此框架上扩展特征工程用于学术研究与功能优化。1. 恶意网站检测系统为什么偏偏选 SVM、随机森林和 DNN纯黑名单的恶意网站拦截方案上线第一天能卡住六成攻击流量第二周就衰减到三成以下。攻击者靠批量注册域名、随机生成 URL、动态切换页面内容让规则库的更新永远追不上攻击速度。SVM、随机森林和 DNN 是恶意网站检测里最常放在一起对比的三类模型SVM 在小样本高维特征下用少量数据就能给出可靠基线随机森林不挑特征尺度、自带特征重要性排序DNN 则把特征交互关系丢给网络自己学。下文从特征工程开始把三个模型逐一落地到代码、对比实验踩坑、上线阈值选择这条链路讲透适合手里有一份带标签 URL 列表、要尽快产出可用模型的工程人员照着做。2. 特征工程先行28 维 URL 特征的提取、标签与数据切分2.1 特征决定上限为什么先定特征而不是先选模型恶意网站检测这个任务里模型只是切开特征空间里那些点的刀特征本身才决定点怎么分布。同一个 URL用 5 个特征和用 28 个特征训练出来的 SVMF1 可以差 15 个百分点以上。这不是模型能力差距而是信息量差距。常见做法是先从 URL 字符串本身提取静态特征因为在没有流量侧数据时URL 就是最稳定的判别信号。攻击者要在 URL 里藏恶意行为就一定会在域名、路径、参数里留下痕迹这些痕迹包括超长路径、连续数字串、敏感关键词、可疑顶级域等。完整特征集覆盖长度、字符分布、数字占比、特殊符号、敏感关键字、域名属性六个维度共 28 维。特征维度不需要贪多很多公开数据集里常见的 70 维特征有将近一半是共线性的加进去只会让 SVM 的核矩阵计算变慢让随机森林的特征重要性变得分散。28 维是我在类似项目里反复试出来的一个平衡点信息量足够又不会稀释关键信号的权重。2.2 Python 实现从 URL 字符串到 28 维特征向量下面这段代码把一条 URL 变成 28 个数值特征直接喂给后面的 SVM、随机森林和 DNN 都行。import re import math from collections import Counter import tldextract from urllib.parse import urlparse def extract_url_features(url: str) - list: parsed urlparse(url) host parsed.hostname or path parsed.path query parsed.query ext tldextract.extract(url) subdomain ext.subdomain domain ext.domain suffix ext.suffix features [] # 1-3: 长度类特征 features.append(len(url)) features.append(len(host)) features.append(len(path)) # 4-6: 字符比例类特征 digits sum(c.isdigit() for c in url) letters sum(c.isalpha() for c in url) total max(len(url), 1) features.append(round(digits / total, 4)) features.append(round(letters / total, 4)) features.append(round(digits / max(letters digits, 1), 4)) # 7-10: 特殊符号数量 for sym in [, -, _, .]: features.append(url.count(sym)) # 11-14: 敏感关键词是否出现 keywords [login, verify, account, secure] for kw in keywords: features.append(1 if kw in url.lower() else 0) # 15-18: 主机名分段与数字段 segments re.split(r[\W_], host) features.append(sum(1 for s in segments if s.isdigit())) features.append(1 if any(s.isdigit() for s in segments) else 0) features.append(max((len(s) for s in segments), default0)) features.append(len(segments)) # 19-22: 域名属性 features.append(len(subdomain)) features.append(1 if subdomain.split(.)[0] in [www, m, api] else 0) features.append(1 if suffix in [tk, ml, ga, cf, top] else 0) features.append(1 if domain.isdigit() else 0) # 23-26: 路径与查询特征 path_parts [p for p in path.split(/) if p] features.append(len(path_parts)) features.append(1 if in query else 0) features.append(query.count()) features.append(1 if any(kw in query.lower() for kw in [id, token, auth, session]) else 0) # 27-28: 字符熵与连续长数字检测 char_counter Counter(url) entropy -sum((c / total) * math.log2(c / total) for c in char_counter.values()) features.append(round(entropy, 4)) features.append(1 if re.search(r\d{5,}, url) else 0) return features逻辑说明第 27 维字符熵用来捕捉随机字符串风格的域名这是钓鱼站和挂马页里非常常见的特征——正常站点不会把一串无意义字符放进 URL。第 28 维检测连续 5 位以上数字对应攻击者用时间戳或随机数生成 URL 路径的常见做法。第 22 维的 IP 直连检测同样关键很多恶意站直接拿纯数字 IP 当主机名这种 URL 在正常业务里几乎见不到。参数说明tldextract 会把 URL 拆成子域名、主域名、顶级域名三段比直接用 urlparse 的 netloc 做字符串切割更稳因为带端口号或用户信息的 URL 用简单切割会切错。敏感关键词列表根据自己的威胁情报源调整不同行业遇到的恶意站点关键词差异很大我见过金融行业场景里secure出现频次特别高而在游戏行业里gift才是高频词。提示特征提取函数要保证输入无异常建议在外层捕获 tldextract 的解析异常兜底返回一个全零向量。2.3 标签构造与数据集划分三个必须遵守的约束标签通常来自威胁情报平台导出或历史告警确认记录但原始数据里脏值很多。我一般会做三件事第一按域名去重同一个域名下几百个恶意 URL 只保留一条防止训练集和测试集里出现同源样本第二按时间切分前 90 天数据训练后 30 天数据测试模拟线上真实分布第三控制正负样本比例在 1:10 到 1:1 之间差太多就用欠采样或 SMOTE 拉回来。# 按域名去重假设 CSV 第二列是域名第一列是标签 awk -F, !seen[$2] urls_labeled.csv urls_dedup.csv # 按时间排序后再打乱保证训练集与测试集时间不重叠 shuf urls_dedup.csv -o urls_shuffled.csv这里去重键必须是域名而不是完整 URL原因是攻击者通常批量生成同一域名下的不同路径。如果按完整 URL 去重测试集里会出现训练集同域名的其他路径模型记住了域名而不是真正的恶意模式评测指标虚高上线后立刻翻车。时间切分的道理也类似攻击者的工具和手法按月迭代上个月的恶意 URL 模式和这个月的不完全一样模型必须证明自己能泛化到未知时间段的样本上。3. 用 SVM 跑通第一版基线核函数、C 与 gamma 的调参路径3.1 为什么从 SVM 开始小样本、高维、二分类的适配性很多人一上来就上深度网络结果训了两天连随机森林都不如原因是手里根本没有一个可对比的基线。SVM 在恶意网站检测里的价值首先是快速给整个项目定一个性能下限。SVM 支持向量机的核心思想是找一个最大间隔的超平面把两类样本分开在 28 维特征上它有三个天然适配点一是样本量需求低几千条标注 URL 就能训练出可用的分类器二是高维特征不敏感28 维对 SVM 来说几乎不构成压力三是二分类问题正好踩在 SVM 的几何间隔表达上。缺点也明确训练复杂度随样本量上升快到几十万条样本时训练时间会明显失控核函数的选择和参数调优有点玄学但那是后面要考虑的事。3.2 SVM 训练最小代码标准化、训练与交叉验证下面这段是 SVM 支持向量机的 Python 代码基于 scikit-learn用五折交叉验证直接输出 F1避免在验证集上反复试错。from sklearn.svm import SVC from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline from sklearn.model_selection import cross_val_score # X: m x 28 的特征矩阵, y: m 维 0/1 标签 pipe make_pipeline( StandardScaler(), SVC(kernelrbf, C1.0, gammascale, class_weightbalanced, probabilityTrue, random_state42) ) scores cross_val_score(pipe, X, y, cv5, scoringf1) print(f5折交叉验证 F1 {scores.mean():.4f} ± {scores.std():.4f})代码逻辑StandardScaler 做零均值单位方差标准化这一步是 SVM 的刚需。RBF 核计算样本间距离URL 长度取值能从几十到几百而布尔型特征只有 0 和 1如果不标准化距离计算会被长度类特征完全主导其他特征全白费。class_weightbalanced 按类别频率自动放大少数类的损失权重在正样本只有 5% 到 10% 的数据集上这一步直接决定模型能不能学到正样本形态。参数说明gammascale 让 sklearn 根据特征数自动计算 gamma 基准值 1/(特征数×方差)在 28 维特征下通常落在 0.01 到 0.1 之间比手动写死一个数值稳定。probabilityTrue 会额外做 Platt 缩放让输出变成可解释的概率值后面选阈值时必须要用到。3.3 核函数与参数范围从线性核到 RBF别一上来就默认很多人把 kernelrbf 当成默认答案但我建议先把线性核跑一遍对比。恶意网站检测的特征里有一批特征本身就是强线性信号比如顶级域是不是 .tk、路径里有没有等号、主机名是不是纯数字这些特征和标签的关系几乎是线性的。线性核在这种场景下训练速度是 RBF 的十倍以上而且没有 gamma 过拟合的隐患。实操做法很简单把上面代码里的 kernel 参数改成 linear其他不动对比两份交叉验证 F1。如果差距在 2 个百分点以内直接选线性核线上推理更快、模型文件更小、解释性也更好。如果 RBF 核明显占优说明特征之间存在需要非线性边界才能分开的交互关系这时候有两种选择一是继续调 C 和 gamma二是把这个信号转化为特征工程的方向——后面 DNN 那章专门处理这类交互。C 和 gamma 的取值范围我通常先用网格搜索圈定一个粗糙范围再手工精调。from sklearn.model_selection import GridSearchCV param_grid { svc__C: [0.1, 1, 10, 100], svc__gamma: [0.001, 0.01, 0.1, 1], } grid GridSearchCV(pipe, param_grid, cv3, scoringf1, n_jobs-1) grid.fit(X_train, y_train) print(grid.best_params_, grid.best_score_)C 控制的是误分类惩罚强度C 越大模型越努力把所有训练样本分对边界越复杂也越容易过拟合C 越小边界越平滑对噪声容忍度越高。gamma 控制的是 RBF 核的作用半径gamma 越大每个支持向量的影响范围越小边界越曲折gamma 越小决策边界越平滑。恶意网站检测里通常 C 在 1 到 10、gamma 在 0.01 到 0.1 范围内效果比较好再大就要考虑是不是特征里混进了太多噪声。4. 随机森林与 DNN两套互补的非线性模型4.1 随机森林不挑尺度、自带特征重要性的工程兜底模型随机森林在这个任务里几乎是零成本起步的模型。它不要求特征标准化对缺失值有天然容忍度训练可以全核并行最重要的优势是能直接输出特征重要性帮我们反过来验证特征工程有没有做对。随机森林和决策树的区别在于单棵决策树容易过拟合对训练集里的噪声样本敏感而随机森林同时训练几百棵树每棵树用有放回抽样和随机特征子集最后取多数投票或平均概率方差大幅降低。恶意网站检测的特征空间里单棵决策树很容易抓住某个特征值死记硬背随机森林的随机性恰好压住了这点。4.2 随机森林训练代码与特征重要性排序from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import train_test_split # 按时间切分train: 前90天, val: 后30天 X_train, X_val, y_train, y_val train_test_split( X, y, test_size0.25, stratifyy, random_state42 ) rf RandomForestClassifier( n_estimators300, max_depth12, min_samples_leaf4, class_weightbalanced_subsample, n_jobs-1, random_state42 ) rf.fit(X_train, y_train) # 输出特征重要性确认模型在依赖什么 feature_names [ff{i} for i in range(28)] for name, imp in sorted( zip(feature_names, rf.feature_importances_), keylambda x: -x[1] )[:8]: print(f{name}: {imp:.4f})代码逻辑n_estimators300 在这个特征规模下够用再往上收益递减且训练时间线性增长。max_depth12 限制单棵树深度防止树记住训练集里的异常样本。min_samples_leaf4 强制叶子节点至少有 4 个样本这在样本量不大时能显著降低方差。class_weightbalanced_subsample 会对每棵树的自助采样样本做类别权重调整比全局 class_weight 更细在正样本偏少时效果更好。特征重要性输出后如果 top 特征集中在字符熵、顶级域、路径层级这几个维度说明特征工程方向正确。如果敏感词特征排在最前面要警惕训练集里正样本的敏感词出现频率过高模型可能学到了数据集的采样偏差而不是真正的恶意模式这是需要回到数据层面解决的问题。4.3 DNN 实现从 28 维输入到二分类输出的三层全连接网络随机森林擅长利用单特征的分割能力而 DNN 的价值在于自动组合特征交互。比如某个恶意域名后缀配合特定路径结构这个组合信号随机森林要拆成多次分割才能学到而 DNN 第一层的线性加权就能直接组合出来。下面是用 PyTorch 实现的一个小型全连接网络。import torch import torch.nn as nn class MalURLNet(nn.Module): def __init__(self, input_dim28): super().__init__() self.net nn.Sequential( nn.Linear(input_dim, 64), nn.ReLU(), nn.BatchNorm1d(64), nn.Dropout(0.3), nn.Linear(64, 32), nn.ReLU(), nn.Dropout(0.2), nn.Linear(32, 1) ) def forward(self, x): return self.net(x).squeeze(-1) model MalURLNet(28) criterion nn.BCEWithLogitsLoss(pos_weighttorch.tensor([3.0])) optimizer torch.optim.Adam(model.parameters(), lr1e-3)逻辑说明输入 28 维对应前面的特征向量。中间层设计成 64 再压到 32这个规模对 28 维特征来说容量已经足够再宽只会让模型在几千条样本上快速过拟合。BatchNorm1d 让中间层输出分布保持稳定Dropout 随机丢神经元增强泛化这两个是标配。pos_weight3.0 给恶意类 3 倍损失权重等价于 class_weightbalanced但更灵活——如果验证集显示召回率不够直接把这个值从 3 调到 5 再试。参数说明学习率 1e-3 配合 Adam 是通用起点训练时如果 loss 震荡不收敛降到 3e-4。输出层只有一个节点配合 BCEWithLogitsLoss训练时用 logits 算损失推理时对输出做 sigmoid 得到概率值。4.4 DNN 训练早停、类别权重与学习率的实际操作DNN 训练不能像随机森林那样一次 fit 完就跑必须盯训练曲线。核心动作是早停每个 epoch 后在验证集上算一次 F1连续 5 个 epoch 没有提升就停止训练保存之前最佳模型。这是防止 DNN 过拟合最有效的手段比调 Dropout 更直观。类别权重调整我在实践里总结出一个规律先固定 pos_weight2看验证集的精确率和召回率如果精确率高而召回率低说明模型太保守把 pos_weight 上调如果召回率高而精确率低说明模型太激进把权重下调。调两轮基本能找到合适的范围。学习率方面如果 loss 曲线出现锯齿形波动说明步长太大降到三分之一再试如果 loss 下降极其缓慢说明步长太小可以放大 3 倍试试。训练完成后三个模型会输出三组不同的预测概率。接下来的关键问题不是哪个模型准确率最高而是这些概率在哪些样本上存在分歧——分歧样本通常就是特征工程和模型结构还没覆盖到的盲区。5. 三模型对比实验中的 5 个踩坑现场与排查方法5.1 特征泄漏让验证集 F1 冲到 0.98上线后原形毕露现象SVM 在五折交叉验证里 F1 高达 0.98训练和验证指标都近乎完美上线后召回率直接掉到 0.4安全运营提交的恶意 URL 大量漏报。原因数据按完整 URL 去重同一个恶意域名下的几百个不同路径被随机分进了训练集和测试集。模型实际学到的是“这个域名见过就是恶意”而不是通用的恶意 URL 模式。域名级去重没做等于考试时把答案递到了模型面前。解决回到第 2.3 节按域名去重后重新切分数据。去重后 F1 从 0.98 掉到 0.87这 11 个百分点的差距就是特征泄漏造成的虚高真实水平以去重后的指标为准。5.2 样本不平衡让准确率变成摆设92% 准确率下恶意召回只有 12%现象随机森林在默认参数下准确率 92.3%看起来不错但看混淆矩阵发现 1200 条恶意样本里只捞出 144 条召回率 12%。原因数据集里恶意样本占比不到 8%模型把所有样本都判为良性就能拿到 92% 准确率。准确率对类别不平衡完全不敏感在这个场景下是骗人的指标。解决评估指标换成 F1 和 PR-AUC训练时加 class_weightbalanced_subsample我在同样数据上把召回率从 12% 拉到 67%准确率降到 88%F1 从 0.11 升到 0.76。如果召回率还不够对训练集里恶意样本做 SMOTE 过采样再训一轮。5.3 时间切分错误测试集和训练集有重叠导致模型泛化能力被高估现象按随机打乱划分数据三个模型在测试集上 F1 都在 0.9 左右但在独立的时间外样本上只有 0.6。原因URL 特征是高度时变的。攻击者的域名生成算法每月更新上月的特征分布和本月差异明显。随机打乱把相邻时间的样本混在一起模型能偷看到时间窗口内的演变趋势但这不代表它能预测未来的新攻击模式。解决严格按时间窗口切分我采用的是连续 30 天样本完全不出现在前 90 天训练集中。这个操作会让指标普遍下降 10 到 15 个百分点但那是真实的模型水平。5.4 网格搜索在验证集上反复试探最终参数还不如默认参数现象SVM 网格搜索耗时 6 小时后找到的 C100、gamma1 参数在独立测试集上 F1 反而低于默认的 C1、gammascale。原因网格搜索在同一个验证集上反复评估验证集的信息在搜索过程中被泄露给参数了。搜索范围越大、候选组合越多越容易碰到一个恰好适合这个验证集的参数组合但它在真正没见过的数据上表现并不好。解决把训练集再切出一块子验证集专门做网格搜索或者用嵌套交叉验证把参数选择和模型评估分开。搜索范围控制在 4×4 以内宁可粗一点不要为了提升零点几个点把过拟合风险带进线上。5.5 阈值直接取 0.5误报率高到运营想下线系统现象三个模型都输出概率后直接用 0.5 当阈值每天告警 3000 条其中 2700 条是误报安全运营团队两星期后不再信任告警。原因0.5 阈值只在正负样本均衡、两类误判代价相等时最优。恶意网站检测场景里误报代价极高每一条误报都是一次人工排查成本而漏报代价相对可控真实最优阈值通常显著高于 0.5。解决在验证集上画出 PR 曲线按运营团队能接受的误报率倒推阈值。有一次我把阈值从 0.5 调到 0.92误报率从 3.2% 降到 0.4%召回率只牺牲了 8 个百分点这个交换在任何生产环境里都值得。6. 上线前用 PR 曲线和误报率预算选阈值模型训练完成只是第一步决策阈值才是上线的最后一道闸。安全运营对误报率通常有硬性预算比如“误报率不能超过 0.5%”因为误报太多会让运营人员开始无视告警整个系统的存在意义就消失了。常见做法是在验证集上计算每个样本的预测概率按从高到低遍历阈值统计每个阈值下的误报率和召回率再根据预算选点。from sklearn.metrics import precision_recall_curve # proba 是验证集上的预测概率y_val 是验证集标签 proba rf.predict_proba(X_val)[:, 1] precision, recall, thresholds precision_recall_curve(y_val, proba) # 计算每个阈值下的误报率 fpr_list [] for t in thresholds: fp ((proba t) (y_val 0)).sum() tn ((proba t) (y_val 0)).sum() fpr_list.append(fp / max(fp tn, 1)) # 选满足误报率低于 0.5% 的最高阈值 best_t 0.9 for t, fpr in zip(thresholds, fpr_list): if fpr 0.005: best_t t break print(f选定阈值: {best_t:.4f}, 误报率: {fpr:.4f})逻辑说明这里用验证集而非训练集来算阈值因为模型在训练集上对自身输出过度自信训练集上算出的误报率系统性偏低选出来的阈值放到线上会偏松。验证集要按第 2 章的时间窗口切出来才能代表未来的线上行为。验证集设计我习惯分三层最新一个月的样本单独留作最终测试集只允许测一次倒数第二个月的样本当验证集用于调参和选阈值更早的样本全部进训练集内部再做五折交叉验证检查稳定性。三层数据各司其职训练集产出模型验证集产出阈值测试集只做一次最终确认。如果测试集指标比验证集差很多先怀疑是不是时间分布差异而不是急着调参数。我自己的最后一个习惯是每次重训练后把新模型在历史测试集上的 PR 曲线和当前线上的模型叠在一张图上对比。如果新模型在误报率 0.5% 这个点的召回率没有明显提升这轮改动的收益就不足以支撑一次上线切换。这套流程跑下来绝大多数坑都能在验证阶段暴露而不是等线上告警淹了再回头查。希望帮到你。本文还有配套的精品资源点击获取