
简介基于支持向量机、随机森林与深度神经网络三种机器学习算法的恶意网站检测系统实现代码包面向网络安全实践与高校人工智能课程教学解决黑白名单网站自动分类识别问题。压缩包整合了11个文件含5个Python脚本、3个备份文件、2个压缩包及1个说明文档整体约3.32MB。其中Python脚本用于特征向量提取与特征分布图表生成压缩包内为核心数据集配套Excel特征对照表为模型训练提供标注依据。算法层面涵盖支持向量机超平面分类、随机森林多决策树集成、深度神经网络多层感知机非线性学习三种模型均通过交叉验证准确率达95%以上。目前已有50人学习附带的说明文档与备份文件便于对照调试适合作为机器学习综合实验或课程设计参考。1. 恶意网站检测不是算法竞赛先解决数据集与特征问题做恶意网站检测很多人一上来就纠结 SVM、随机森林和 DNN 哪个模型更强结果训练出来的系统在测试集上 F1 值高达 0.97一上线就被真实流量打回原形。原因很简单恶意网站检测的难点从来不在模型选择而在特征工程和数据集质量。URL 里的字符分布、域名的注册时长、页面脚本的调用模式这些特征提得不好再强的算法也只是在噪声里拟合出一套自欺欺人的规律。这个标题看起来是三个算法的对比实验实际落地时却是一个完整的系统工程数据标注、特征提取、类别不平衡处理、模型调参与上线部署。适合正在做安全产品原型、或者需要在甲方环境里快速交付一个检测模块的工程师阅读照着做能把一套可用的系统真正跑起来而不是停在 Notebook 里。2. 从 URL 到特征向量恶意网站检测的特征工程怎么做2.1 为什么恶意网站检测依赖特征工程而不是端到端学习图像识别可以靠卷积神经网络直接从像素学到语义但恶意网站检测很难走同一条路。网站是一个结构化对象里面既有文本内容、又有网络行为属性还有域名的外部信誉信息这些信息形态差异极大。DNN 虽然能做端到端特征学习但在小样本、强噪声的场景下它学到的往往是数据集的采集偏差而不是恶意行为的本质规律。相比之下手工特征加传统分类器的组合在许多安全任务里反而更稳定。我在实际项目中一般把特征分成三类。第一类是 URL 字符串特征包括 URL 长度、域名中数字的占比、特殊字符出现的次数、路径层级数等这类特征对钓鱼网站尤其有效因为攻击者常生成随机域名和长路径。第二类是域名属性特征比如域名年龄、WHOIS 隐私保护是否开启、DNS 解析是否指向 CDN 或动态 IP这些数据通过 whois 库和 DNS 请求就能拿到。第三类是页面内容特征需要爬取页面后分析比如页面里表单数量、外链脚本数量、是否包含可疑 iframe、页面标题与品牌的相似度等。2.2 特征提取代码用 Python 把原始 URL 转成数值向量下面这段代码是我常用的特征提取起点它以一条 URL 为输入输出一个包含数值和布尔值的特征字典后续再拼成模型可用的特征矩阵。注意这里的核心思路是每个特征都必须有明确的恶意行为假设不要为了凑维度而加无意义的列。import urllib.parse import re import whois from datetime import datetime def extract_url_features(url): features {} parsed urllib.parse.urlparse(url) host parsed.netloc path parsed.path # URL 基本形态特征 features[url_length] len(url) features[host_length] len(host) features[num_digits_in_host] sum(c.isdigit() for c in host) features[path_depth] len([seg for seg in path.split(/) if seg]) features[num_special_chars] len(re.findall(r[?!$%], url)) # 域名特征是否用 IP 直接访问、是否包含可疑关键字 features[is_ip] 1 if re.match(r^\d\.\d\.\d\.\d$, host) else 0 suspicious_words [login, verify, secure, account, update, free] features[num_suspicious_words] sum(1 for w in suspicious_words if w in url.lower()) return features这段代码的逻辑很直白urllib 负责解析 URL 结构正则负责统计字符模式。我在参数设定上有一个经验值——域名中数字占比超过三成、URL 长度超过 200 个字符的样本在钓鱼网站里的比例远高于正常网站所以这两个特征在后续模型中的重要度排序非常靠前。页面内容特征的做法通常是写一个爬虫用 requests 拿到 HTML 后用 BeautifulSoup 解析统计 iframe 标签数量、脚本外链占比、表单 action 指向的外部域名数量等。这个环节是最耗时的一步但也最值得投入因为真实恶意网站的躲藏手段大多体现在页面结构上而不是 URL 文本里。2.3 特征矩阵拼接与保存单条 URL 的特征只是字典要喂给模型需要把所有样本拼成统一的特征矩阵。这里有一个常见错误是忘记处理缺失值——比如 whois 查询可能因为隐私保护拿不到域名年龄这时不能直接丢弃这一行而应该填充为 -1 或一个明显越界的值让模型自己学会忽略它。import pandas as pd def build_feature_matrix(urls): records [] for url in urls: try: rec extract_url_features(url) # whois 年龄失败时填 -1 作为缺失标记 try: domain_info whois.whois(url) creation domain_info.creation_date if isinstance(creation, list): creation creation[0] rec[domain_age_days] (datetime.now() - creation).days if creation else -1 except Exception: rec[domain_age_days] -1 records.append(rec) except Exception: continue df pd.DataFrame(records) df.fillna(-1, inplaceTrue) df.to_csv(features.csv, indexFalse) return df这里有两个参数值得说明。domain_age_days 用 -1 表示 whois 查询失败这样特征分布里就会形成一个独立的取值簇SVM 和随机森林都能正确把它当作一个特殊类别处理。另一个是 fillna(-1)它保证了最终特征矩阵没有任何空值避免模型训练时报错。特征矩阵的列顺序必须固定后面训练和推理都要用同一顺序读取否则会出现特征错位这种极难排查的问题。3. 放数据集与预处理SVM、随机森林和 DNN 各自要什么格式的输入3.1 公开数据集与自建数据集的三条取舍恶意网站检测没有标准的公共数据集常用的做法是混合多个来源PhishTank 和 OpenPhish 提供钓鱼网站列表Alexa 或 Common Crawl 提供正常网站样本。我一般按 1:1 的正负样本比构造训练集但真实场景要按实际恶意流量比例做测试集这样才能看到系统上线后的真实表现。数据集的构建有几个要特别注意的事。第一是时间一致性训练集和测试集必须来自不同的时间段否则会出现时间穿越——模型学到的是那个时段特有的攻击模式换个时间窗口就失效。第二是去重同一个恶意域名下的几千条 URL 看起来样本很多实际只有一个攻击源不做去重会让模型对这单个攻击源过拟合。第三是标签噪声PhishTank 上的标签有一定比例的误报直接全信会污染训练集我会在训练前用简单规则对明显异常的样本做二次确认。3.2 归一化与数据切分为什么 DNN 对尺度敏感而随机森林无所谓特征尺度差异在这个任务里非常明显URL 长度可能是几位到几百位域名年龄可能是几天到几千天而特殊字符计数只有个位数。SVM 使用距离度量特征尺度不均会让大数值特征的梯度完全压制小数值特征DNN 的激活函数对输入尺度也极其敏感。随机森林是树模型只关心特征取值的分裂点不受尺度影响所以可以不做归一化。from sklearn.preprocessing import StandardScaler from sklearn.model_selection import train_test_split # 假设 df 已经是由 build_feature_matrix 生成的特征矩阵 X df.drop(columns[url, label]) y df[label] # 归一化只用于 SVM 和 DNN scaler StandardScaler() X_scaled scaler.fit_transform(X) # 关键参数stratify 保证正负样本在训练集和测试集中的比例与原始数据一致 X_train, X_test, y_train, y_test train_test_split( X_scaled, y, test_size0.2, random_state42, stratifyy ) # 随机森林用原始未归一化的特征效果通常更好 X_train_raw, X_test_raw, _, _ train_test_split( X, y, test_size0.2, random_state42, stratifyy )random_state42 不是随便写的它保证每次运行得到相同的数据切分这是模型对比实验的前提条件——三个模型必须在同一份训练集上训练评估才有意义。stratify 参数在恶意网站检测里尤其重要因为正负样本的绝对量级本来就悬殊简单随机切分很可能让测试集里恶意样本太少F1 值看起来很高但置信区间极宽。我见过不少项目因为少了这个参数在 5% 恶意样本比例的测试集上得到虚高结果上线后精准率跌了一半。3.3 类别不平衡处理不做处理的 DNN 会输出一个永远说正常的模型恶意网站检测的数据天然不平衡实际流量中正常网站占比往往超过 95%如果直接训练模型的损失函数会被多数类主导最后的预测结果可能全部偏向正常类别。三种模型对这个问题的敏感度不同。随机森林通过 class_weightbalanced 就能处理它在分裂时会按类别反比调整权重。SVM 可以用 class_weight 参数做同样的事但效果受核函数影响较大。DNN 最麻烦需要同步调整损失函数权重和输出层阈值。# 在训练脚本里给三个模型统一的类别权重 from sklearn.utils.class_weight import compute_class_weight classes np.array([0, 1]) weights compute_class_weight(class_weightbalanced, classesclasses, yy_train) print(f类别权重正常{weights[0]:.2f}, 恶意{weights[1]:.2f})类别权重的作用相当于告诉模型分错一个恶意样本的代价是分错一个正常样本的权重倍。在 DNN 中我通常把这个权重直接传入损失函数同时不在数据层面做随机过采样因为过采样容易让模型对重复样本死记硬背损害泛化能力。另一个更稳妥的做法是用 SMOTE 生成合成样本但它对特征维度和分布假设有一定要求恶意网站特征多为偏态分布生成样本可能落在特征空间里的不毛之地实际收益有限。4. 三模型训练与调参SVM 核函数、随机森林深度与 DNN 结构的落地配置4.1 SVM核函数与 C、gamma 三个参数怎么配合SVM 在恶意网站检测任务里是一个老牌的强基线。它的优势是样本量不大时泛化能力好决策边界干净适合特征维度几十到几百的中小规模场景。但 SVM 有三个参数直接影响效果核函数、正则化系数 C 和 RBF 核的 gamma。核函数的选择决定了特征空间的映射方式——线性核只在原始空间划边界RBF 核则能把特征映射到高维空间处理非线性关系。from sklearn.svm import SVC # RBF 核 SVMC 控制误分类惩罚强度 svm_model SVC( kernelrbf, C1.0, gammascale, class_weightbalanced, probabilityTrue, random_state42 ) svm_model.fit(X_train, y_train) y_pred_svm svm_model.predict(X_test)C 这个参数的实际含义是C 越大模型对训练集误分类的容忍度越低越容易过拟合C 越小边界越平滑但欠拟合风险上升。我的经验值是先用 gammascale 起步它让 sklearn 根据特征方差自动计算 gamma比手工设一个固定值在多数场景下都更稳。概率估计 probabilityTrue 会显著增加训练耗时但它是后面做阈值调优的前提不能省。在几万样本规模下训练时间可以接受如果样本量到几十万SVM 会变得很慢那时我会改用 LinearSVC 或直接跳到随机森林。4.2 随机森林n_estimators 和 max_depth 怎么找到性能拐点随机森林是最不容易翻车的模型。它对特征尺度不敏感不需要归一化能处理缺失值还能输出特征重要度用于后续特征筛选。在恶意网站检测这个场景里随机森林通常能在不调参的情况下达到 85% 以上的 F1 值对大多数项目已经够用。关键参数就两个树的数量和每棵树的最大深度。from sklearn.ensemble import RandomForestClassifier rf_model RandomForestClassifier( n_estimators200, max_depth20, min_samples_leaf4, class_weightbalanced, random_state42 ) rf_model.fit(X_train_raw, y_train) y_pred_rf rf_model.predict(X_test_raw) # 查看特征重要度前几位通常是过滤维度时的候选 for name, imp in zip(feature_names, rf_model.feature_importances_): print(f{name}: {imp:.4f})n_estimators 从 100 提升到 200效果通常还有小幅收益但再往上提升就非常有限了训练时间却成倍增加。max_depth20 在几十个特征维度下已经够用过深的树容易记住训练集中的个别噪声样本min_samples_leaf4 能防止叶子节点过纯。我在决定树的数量时有一个常用手法跑一组 100、200、400、800 的实验画出 OOB 误差曲线选曲线开始变平的点。OOB 误差不需要额外验证集就能算是一个节省样本的好工具。4.3 DNN网络结构、Dropout 与 EarlyStopping 三层保险DNN 在三者里拥有最强的拟合能力但也最容易被数据集里的噪声带偏。恶意网站的特征维度通常只有几十个层数太深不仅提升有限还会加剧过拟合。我常用的结构是三到四层全连接网络每一层后面接 BatchNormalization 和 Dropout。损失函数用二元交叉熵优化器选 Adam学习率默认 0.001 起步。import tensorflow as tf from tensorflow.keras.callbacks import EarlyStopping model_dnn tf.keras.Sequential([ tf.keras.layers.Input(shape(X_train.shape[1],)), tf.keras.layers.Dense(128, activationrelu), tf.keras.layers.BatchNormalization(), tf.keras.layers.Dropout(0.3), tf.keras.layers.Dense(64, activationrelu), tf.keras.layers.BatchNormalization(), tf.keras.layers.Dropout(0.2), tf.keras.layers.Dense(1, activationsigmoid) ]) model_dnn.compile( optimizertf.keras.optimizers.Adam(learning_rate0.001), losstf.keras.losses.BinaryCrossentropy(), metrics[accuracy] ) early_stop EarlyStopping(monitorval_loss, patience10, restore_best_weightsTrue) model_dnn.fit( X_train, y_train, validation_split0.2, epochs100, batch_size64, callbacks[early_stop], verbose1 )这里每个组件都有明确用途。Dropout 让网络不依赖某个单一特征BatchNormalization 稳定各层的激活值分布EarlyStopping 在验证集 loss 连续 10 轮不下降时自动回滚到最优权重等于给了模型一剂后悔药。DNN 的“参数”不是哪一个值而是整个组合策略层宽低于 64 时模型容量不够高于 256 时在这个特征维度下纯属浪费。训练时如果 loss 在验证集上降不下去我一般先调 Dropout 而不是加深网络。4.4 三个模型的指标对比应该看哪张表模型选型的最终依据不是测试集准确率而是综合精确率、召回率、F1 值和推理耗时。恶意网站检测场景里漏报一个恶意站点的代价远高于误报一个正常站点因此召回率通常排在精确率前面。我的观察是SVM 的精确率最高但召回率偏低因为 RBF 边界对分布在决策边界附近的恶意样本容易漏判随机森林的 F1 均衡、训练快、调参成本最低DNN 的上限最高但对特征归一化和训练稳定性要求高适合作为最终线上模型的候选。模型精确率召回率F1训练耗时推理耗时SVM (RBF)高中中中中随机森林中中高高低低DNN中高高高高低这张表是我基于特征数在 30~50 维、训练样本 5 万左右的场景得到的经验值不同数据集上结果会有偏移但相对排序基本稳定。推理耗时只在实时检测场景才值得关注离线批量检测可以忽略。5. 训练翻车现场三个模型常见的过拟合与数据集问题排查5.1 现象训练集 F1 高达 0.99测试集却跌破 0.70这是我在恶意网站检测项目里见过最多的翻车现象。原因是多数特征在训练集上恰好能区分两类样本但到新时间段的数据上就失效了。比如某段时间钓鱼网站大量使用“免费”作为诱饵文字训练集里这个特征权重很高但下一波攻击换成了“发票”主题模型立刻失灵。排查分三步走。第一步检查数据集是否做了时间切分如果训练集和测试集来自同一时段拿到的评估结果天然虚高。第二步查看特征重要度排名排名前列的特征往往揭示了模型走偏的方向——比如域名年龄如果是第一重要特征那可能是训练集里新域名恰好多数是恶意样本。第三步用验证集做时间回测拿训练集之后一个月的样本喂给模型看 F1 掉多少。这个过程能真实反映模型的时间稳定性。5.2 现象DNN 训练到第 50 轮时验证集 loss 突然飙升DNN 训着训着验证集 loss 不降反升这是典型的过拟合信号模型开始记住训练集里的噪声模式了。很多人第一反应是调低学习率但学习率解决不了容量过剩的问题。更有效的做法是加大 Dropout 比例到 0.40.5或者减少神经元数量。另一个容易忽略的原因是特征里混入了未来信息——比如用了页面加载时间特征但采样的时间窗口和标注时间有重叠等于把答案提前告诉了模型。5.3 现象SVM 训练完发现所有样本都被预测为正常类SVM 全预测成多数类通常有三层原因。第一是类别不平衡处理没有生效SVM 自带的 class_weight 参数对默认的 RBF 核有效但在某些版本上对特定核可能会失效。第二是特征尺度差异过大SVM 的距离度量在个别大数值特征上被支配我遇到过一次 URL 长度特征在数据里有一批异常长样本几乎把其他所有特征都压没了。第三是参数设置问题gamma 设置过大时 RBF 核的决策边界会变成一个个围绕着支持向量的小“孤岛”对远离支持向量的样本全部输出同一类别。排查手段是先输出决策分数看分布再用标准化后的特征加线性核做一轮对照实验能快速定位是数据问题还是核函数参数问题。5.4 现象随机森林特征重要度排名全是 URL 字符串特征这个现象很坑因为特征重要度看起来合理实际上却不合理。URL 字符串特征提取成本极低在样本集里往往有大量可复用的字符串规律而域名年龄这类外部特征需要逐一查询经常有缺失值被填充成 -1重要度自然偏低。但真实恶意网站行为里页面内容和域名属性的区分能力往往比 URL 字符串更强模型只是因为特征质量不好而放弃了这些强信号。解决方法是优化特征的数值分布而不是强行给 URL 特征降权。6. 把模型接进检测系统阈值设定、模型对比与在线检测验证6.1 用决策分数代替硬分类三个模型融合的落地方案三个模型各自给出 0 或 1 的二分类结果直接投票融合是一种做法但太粗糙。更好的方式是让每个模型输出连续的概率值或决策分数再做加权融合。SVM 开启 probabilityTrue 后可以得到概率估计随机森林用 predict_proba 得到叶节点的概率均值DNN 的 sigmoid 输出天然就是概率。融合时两个关键参数是各个模型的权重和各模型内部的判定阈值。import numpy as np # 三个模型输出概率值按经验权重融合 weighted_score ( 0.3 * svm_model.predict_proba(X_test)[:, 1] 0.4 * rf_model.predict_proba(X_test_raw)[:, 1] 0.3 * model_dnn.predict(X_test).flatten() ) # 阈值从 0.5 调整为 0.3优先提升召回率 final_pred (weighted_score 0.3).astype(int)融合权重的确定不要拍脑袋。我一般用验证集做一次网格搜索候选权重组合按 0.1 的步长在 0.20.5 之间扫一遍选 F1 最高的一组。阈值调整在恶意网站场景中意义重大默认 0.5 偏保守适合误报代价高的场景如果安全运营人员人力充足、漏报代价更高可以降到 0.3用误报换漏报。阈值最终定多少需要和业务流程对齐而不是完全由数据决定。6.2 置信度回看机制把低置信度样本交给人工复核我最后的实战习惯是给系统加一个置信度回看机制。当融合分数落在 0.25 到 0.45 这个灰色区间时不直接判定为恶意而是把这批样本落库。每周抽出这批样本做一次人工复核把确认的恶意样本和误判的正常样本放回训练集做增量训练。这个机制看起来简单却是整个系统持续有效的核心——恶意网站的攻击模式迭代速度远超模型重训练的周期不回看就等于模型只能应对上一次攻击浪潮。这个项目的价值不在于把某个模型的准确率做到多高而在于建立一套能从数据到决策闭环运转的检测链路。我自己的感受是特征工程和阈值调优加起来决定了系统八成的效果模型选型反而排在后头。每次拿到一批新的恶意样本我会先跑一遍随机森林看特征重要度确认这批攻击的主要特征后再决定要不要动 DNN 的结构或换核函数。这套工作流我沿用了很多个项目稳定到已经不太依赖某个具体框架的版本更新了。希望这些实操细节能帮你在做同类系统时少走几段弯路把精力留在真正影响结果的地方。本文还有配套的精品资源点击获取