ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

恶意网站检测:传统机器学习实战与工程优化

恶意网站检测:传统机器学习实战与工程优化

简介:这份资源是面向计算机、人工智能、大数据、数学及电子信息等专业学生的传统机器学习实战项目,聚焦恶意网站检测这一网络安全场景,适合正在做课程设计、期末大作业或毕设的学习者参考,也适合具备一定编程基础、希望理解分类算法落地流程的技术爱好者。压缩包共7个文件,以5个Python源码为主,辅以1个数据压缩包和1份Markdown项目说明,整体约3.31MB,体量轻便,便于快速部署与调试。源码涵盖数据预处理、特征转换、SVM分类、DNN对比及随机森林划分等模块,配合说明文档可帮助读者理清从原始数据到模型评估的完整链路,理解不同传统机器学习算法在恶意网站识别任务中的表现差异与调参思路。目前已有148人学习下载,可作为课程作业或毕设选题的起步模板,也能为后续扩展特征工程与模型融合提供参考。

1. 恶意网站检测为什么还在用传统机器学习:一个被低估的工程选择

打开一个「基于传统机器学习的恶意网站检测算法源码+项目说明.zip」,很多人第一反应是:都什么年代了,怎么不用深度学习?但如果你真在生产环境里跑过 URL 风控,就会发现一个反直觉的事实——在恶意网站检测这个场景里,传统机器学习(LR、随机森林、XGBoost、SVM)依然是主力,深度学习反而是补充。原因很实在:URL 文本极短、特征工程可解释、推理延迟要求毫秒级、样本标注成本高、对抗样本变化快需要快速迭代模型。这些约束叠加起来,传统机器学习几乎是唯一能同时满足「快、准、可解释、好维护」的路线。

这个标题对应的项目,本质是一套完整的恶意网站检测流水线:从 URL/页面特征提取,到特征工程,到模型训练与评估,再到推理接口。它解决的核心问题是——给定一个 URL 或一组页面特征,判断它是不是恶意站点(钓鱼、挂马、诈骗等)。适合谁?适合做风控、安全运营、爬虫反爬、以及机器学习课程设计选题的工程师和学生。热搜里「机器学习项目」「机器学习实战」「机器学习检测」这些词,恰好说明大家要的不是理论,而是能跑起来、能改参数、能上线的完整链路。下面我按「特征怎么来 → 模型怎么选 → 代码怎么跑 → 坑在哪」的顺序,把这条链路拆开讲。

2. 恶意网站检测的特征工程:URL 里到底能榨出什么信息

2.1 为什么特征工程决定了传统机器学习的上限

传统机器学习模型本身不擅长从原始字符串里自动学表示,所以特征工程的质量直接决定检测效果。恶意网站检测的特征大致分四类:URL 词法特征、域名/主机特征、页面内容特征、外部情报特征。词法特征包括 URL 长度、路径深度、参数个数、特殊字符(@、-、_、%、//)出现次数、数字占比、大小写切换次数、是否含 IP 地址、是否含敏感词(login、verify、account、secure、update)。域名特征包括域名长度、子域名层数、顶级域名类型、域名年龄(whois)、DNS 记录数、是否使用免费托管。页面内容特征包括页面标题、表单数量、iframe 数量、外链比例、JS 混淆程度、是否要求输入密码。外部情报特征包括 Alexa 排名、黑名单命中、SSL 证书信息。

这些特征里,URL 词法特征和域名特征是最容易落地、也最稳定的。页面内容特征需要实际抓取页面,延迟高、易被反爬;外部情报特征依赖第三方服务,成本和可用性都是问题。所以一个务实的做法是:以 URL 词法 + 域名特征为主,页面内容特征作为可选增强。热搜里「机器学习 应用流程」这个词,放到这里就是:先定特征清单,再写提取函数,再做特征选择,最后喂给模型。

2.2 用 Python 实现 URL 特征提取的最小代码

下面这段代码是我常用的 URL 特征提取骨架,覆盖了最核心的 12 个特征。你可以直接复制到项目里,再按需增删。

import re from urllib.parse import urlparse def extract_url_features(url): """ 输入: url 字符串 输出: dict, 包含 12 个数值特征 """ features = {} parsed = urlparse(url) host = parsed.netloc path = parsed.path query = parsed.query # 1. URL 总长度 features['url_len'] = len(url) # 2. 主机名长度 features['host_len'] = len(host) # 3. 路径深度(按 / 切分) features['path_depth'] = len([p for p in path.split('/') if p]) # 4. 查询参数个数 features['query_count'] = len(query.split('&')) if query else 0 # 5. 特殊字符 @ 出现次数 features['at_count'] = url.count('@') # 6. 连字符 - 出现次数 features['hyphen_count'] = url.count('-') # 7. 数字字符占比 digits = sum(c.isdigit() for c in url) features['digit_ratio'] = digits / len(url) if url else 0 # 8. 是否包含 IP 地址 ip_pattern = re.compile(r'(\d{1,3}\.){3}\d{1,3}') features['has_ip'] = 1 if ip_pattern.search(host) else 0 # 9. 子域名层数 features['subdomain_count'] = max(0, host.count('.') - 1) # 10. 是否使用 HTTPS features['is_https'] = 1 if parsed.scheme == 'https' else 0 # 11. 敏感词命中数 sensitive_words = ['login', 'verify', 'account', 'secure', 'update', 'confirm'] features['sensitive_count'] = sum(1 for w in sensitive_words if w in url.lower()) # 12. 大小写切换次数 switches = sum(1 for i in range(1, len(url)) if url[i].isupper() != url[i-1].isupper()) features['case_switch'] = switches return features

逻辑说明:urlparse把 URL 拆成 scheme、netloc、path、query 四部分,后续所有特征都基于这四部分计算。has_ip用正则判断主机名是否是 IP 形式,这是钓鱼网站常见特征。sensitive_count统计敏感词命中数,钓鱼页面 URL 里经常出现 login、verify 这类词。case_switch统计大小写切换次数,随机生成的恶意 URL 往往大小写混乱。

参数说明:sensitive_words列表可以根据你的业务场景调整,比如金融场景加上bank、pay,电商场景加上order、refund。path_depth的计算方式对末尾斜杠不敏感,如果你需要严格区分,可以改成path.strip('/').split('/')。digit_ratio用总长度做分母,如果 URL 为空会返回 0,实际使用时建议先过滤空 URL。

2.3 特征选择:别把 200 个特征一股脑塞给模型

特征不是越多越好。我见过一个项目提取了 200 多个特征,结果模型训练慢、过拟合严重、线上推理延迟高。常见做法是先用方差过滤去掉常量特征,再用卡方检验或互信息做初筛,最后用模型自带的特征重要性做二次筛选。对于恶意网站检测,通常保留 20 到 40 个特征就够了。下面是一个用SelectKBest做特征选择的示例:

from sklearn.feature_selection import SelectKBest, chi2 from sklearn.preprocessing import MinMaxScaler # X 是特征矩阵,y 是标签(1 恶意,0 正常) scaler = MinMaxScaler() X_scaled = scaler.fit_transform(X) selector = SelectKBest(score_func=chi2, k=30) X_selected = selector.fit_transform(X_scaled, y) # 查看选中的特征名 selected_mask = selector.get_support() selected_features = [f for f, m in zip(feature_names, selected_mask) if m] print(selected_features)

逻辑说明:chi2卡方检验衡量特征与标签的相关性,适合非负特征。MinMaxScaler把特征缩放到 [0,1],因为卡方检验要求非负。k=30表示保留 30 个得分最高的特征。get_support()返回布尔掩码,用来反查特征名。

参数说明:k的取值需要实验,一般从 20 开始试到 50。如果特征里有负数(比如某些统计量),改用mutual_info_classif代替chi2。MinMaxScaler对异常值敏感,如果特征里有极端值,先用RobustScaler或做截断。

3. 模型选型与训练:LR、随机森林、XGBoost 到底选哪个

3.1 三个模型的适用边界与实测对比

在恶意网站检测这个任务上,我实测过 LR、随机森林、XGBoost 三个模型。结论是:如果特征工程做得好,LR 的 AUC 能到 0.95 以上,随机森林和 XGBoost 能到 0.98 以上。但选型不能只看 AUC,还要看推理速度、可解释性、训练成本、线上更新频率。

模型训练速度推理速度可解释性抗过拟合适合场景
逻辑回归快极快强中特征线性可分、需要解释
随机森林中快中强特征非线性、样本量中等
XGBoost慢中中强追求极致效果、样本量大

逻辑回归的优势是推理极快、系数可解释,适合对延迟敏感的场景。随机森林是「万金油」,几乎不需要调参就能拿到不错的效果。XGBoost 效果最好,但训练慢、调参复杂,适合离线训练、线上推理的架构。热搜里「机器学习算法」「机器学习模型」这些词,落到这里就是:先跑通 LR 做 baseline,再用随机森林或 XGBoost 提升。

3.2 用随机森林跑通训练与评估的完整代码

下面这段代码覆盖了数据加载、划分、训练、评估、保存模型的全流程。假设你的特征已经提取好,存成了 CSV。

import pandas as pd import joblib from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report, roc_auc_score, confusion_matrix # 1. 加载数据 df = pd.read_csv('url_features.csv') feature_cols = [c for c in df.columns if c not in ['url', 'label']] X = df[feature_cols].values y = df['label'].values # 2. 划分训练集和测试集,stratify 保证正负样本比例一致 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) # 3. 训练随机森林 clf = RandomForestClassifier( n_estimators=200, # 树的数量 max_depth=15, # 最大深度,防止过拟合 min_samples_leaf=5, # 叶子节点最小样本数 class_weight='balanced', # 处理类别不平衡 n_jobs=-1, # 用满所有 CPU random_state=42 ) clf.fit(X_train, y_train) # 4. 评估 y_pred = clf.predict(X_test) y_prob = clf.predict_proba(X_test)[:, 1] print(classification_report(y_test, y_pred)) print('AUC:', roc_auc_score(y_test, y_prob)) print('混淆矩阵:\n', confusion_matrix(y_test, y_pred)) # 5. 保存模型 joblib.dump(clf, 'rf_malicious_url.pkl')

逻辑说明:stratify=y保证训练集和测试集里正负样本比例一致,避免测试集里恶意样本过少导致评估失真。class_weight='balanced'自动调整权重,让模型更关注少数类(恶意样本通常较少)。n_jobs=-1用满所有 CPU 核心加速训练。predict_proba返回概率,用于计算 AUC。

参数说明:n_estimators从 100 开始试,200 到 500 通常够用,再大收益递减。max_depth控制树深度,太深容易过拟合,太浅欠拟合,一般 10 到 20 之间。min_samples_leaf防止叶子节点样本过少,5 到 20 之间比较稳。class_weight如果正负样本比例超过 1:10,建议用balanced或手动设置权重。

3.3 类别不平衡与阈值调整:别只看准确率

恶意网站检测的样本通常极度不平衡,正常 URL 可能占 95% 以上。这时候准确率(accuracy)是个骗人的指标——全预测为正常也能到 95%。真正要看的是召回率(recall)、精确率(precision)和 F1。如果业务上「漏判恶意网站」的代价更高,就要优先保召回率,适当降低阈值。

from sklearn.metrics import precision_recall_curve # 获取不同阈值下的精确率和召回率 precision, recall, thresholds = precision_recall_curve(y_test, y_prob) # 找到召回率 >= 0.95 时,精确率最高的阈值 best_threshold = 0 best_precision = 0 for p, r, t in zip(precision, recall, thresholds): if r >= 0.95 and p > best_precision: best_precision = p best_threshold = t print(f'推荐阈值: {best_threshold:.4f}, 对应精确率: {best_precision:.4f}') # 用新阈值重新预测 y_pred_new = (y_prob >= best_threshold).astype(int) print(classification_report(y_test, y_pred_new))

逻辑说明:precision_recall_curve返回不同阈值下的精确率和召回率。遍历这些点,找到满足召回率要求时精确率最高的阈值。这个阈值比默认的 0.5 更适合不平衡场景。

参数说明:r >= 0.95里的 0.95 是业务要求的召回率下限,根据你的场景调整。如果业务要求召回率 0.99,就改成 0.99。阈值调整后要重新评估,确认精确率没有掉得太厉害。

4. 从源码到上线:推理接口与批量检测的落地细节

4.1 用 Flask 封装一个毫秒级推理接口

模型训练完只是第一步,真正落地要封装成接口。下面是一个最小可用的 Flask 推理服务,加载模型后提供/predict接口。

from flask import Flask, request, jsonify import joblib from feature_extractor import extract_url_features # 前面写的特征提取函数 app = Flask(__name__) model = joblib.load('rf_malicious_url.pkl') feature_order = ['url_len', 'host_len', 'path_depth', 'query_count', 'at_count', 'hyphen_count', 'digit_ratio', 'has_ip', 'subdomain_count', 'is_https', 'sensitive_count', 'case_switch'] @app.route('/predict', methods=['POST']) def predict(): data = request.get_json() url = data.get('url', '') if not url: return jsonify({'error': 'url is required'}), 400 features = extract_url_features(url) # 按训练时的特征顺序组装向量 vector = [features.get(f, 0) for f in feature_order] prob = model.predict_proba([vector])[0][1] label = 'malicious' if prob >= 0.5 else 'benign' return jsonify({ 'url': url, 'label': label, 'probability': round(float(prob), 4) }) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000)

逻辑说明:feature_order必须和训练时的特征顺序完全一致,否则模型会拿到错位的特征。features.get(f, 0)处理缺失特征,默认填 0。predict_proba返回概率,阈值 0.5 可以按上一节的方法调整。

参数说明:host='0.0.0.0'让服务监听所有网卡,生产环境建议用 gunicorn 或 uwsgi 部署。port=5000按需修改。如果 QPS 高,建议用joblib加载模型后常驻内存,避免每次请求都加载。

4.2 批量检测与性能优化:一次处理 10 万条 URL

线上经常需要批量检测,比如导入一批 URL 做离线扫描。逐条调用接口太慢,正确做法是批量提取特征后一次性推理。

import pandas as pd import joblib from feature_extractor import extract_url_features model = joblib.load('rf_malicious_url.pkl') feature_order = ['url_len', 'host_len', 'path_depth', 'query_count', 'at_count', 'hyphen_count', 'digit_ratio', 'has_ip', 'subdomain_count', 'is_https', 'sensitive_count', 'case_switch'] def batch_predict(urls, batch_size=10000): results = [] for i in range(0, len(urls), batch_size): batch = urls[i:i+batch_size] vectors = [] for url in batch: feats = extract_url_features(url) vectors.append([feats.get(f, 0) for f in feature_order]) probs = model.predict_proba(vectors)[:, 1] for url, prob in zip(batch, probs): results.append({'url': url, 'probability': round(float(prob), 4)}) return results # 使用示例 urls = pd.read_csv('urls_to_check.csv')['url'].tolist() results = batch_predict(urls) pd.DataFrame(results).to_csv('predictions.csv', index=False)

逻辑说明:分批处理避免一次性加载太多数据导致内存溢出。predict_proba对批量输入返回二维数组,取第二列作为恶意概率。结果存成 CSV 方便后续分析。

参数说明:batch_size=10000根据内存调整,内存大可以调到 50000。如果 URL 量特别大,建议用多进程并行提取特征,因为特征提取是 CPU 密集型操作。

5. 避坑与排查:恶意网站检测项目里最容易翻车的 5 个点

5.1 现象:训练集 AUC 0.99,线上一跑就废

原因:训练集和线上数据的分布不一致。常见情况是训练集里的恶意 URL 来自某个公开黑名单,而线上遇到的恶意 URL 是新型钓鱼站,特征分布差异大。另外,如果训练集里正常 URL 来自 Alexa Top 100 万,而线上正常 URL 来自内部业务,域名特征分布也会不同。

解决:第一,训练集要尽量覆盖线上分布,正常样本不要只用 Alexa,要混入业务真实流量。第二,恶意样本要定期更新,最好每周补充新捕获的样本。第三,上线前用线上真实流量做一次离线评估,不要只看测试集指标。第四,监控线上推理结果的分布,如果恶意率突然飙升或骤降,大概率是分布漂移。

5.2 现象:模型把大量正常 URL 判成恶意

原因:类别不平衡导致模型偏向多数类,或者阈值设得太低。如果训练时没有设置class_weight,模型会倾向于预测多数类(正常),但如果你为了保召回率把阈值调得很低,又会把大量正常判成恶意。

解决:先确认class_weight='balanced'已设置。然后用precision_recall_curve找到平衡点,不要盲目调低阈值。如果业务允许,可以引入人工审核环节,对模型判为恶意但概率在 0.5 到 0.8 之间的 URL 做二次确认。另外,检查特征里是否有「在训练集里区分度很高但线上不适用」的特征,比如某个特定域名后缀。

5.3 现象:特征提取函数在线上报错或返回异常值

原因:线上 URL 格式千奇百怪,比如超长 URL、含特殊字符、编码不规范、空 URL、非 HTTP 协议。特征提取函数如果没有做边界处理,很容易抛异常或返回NaN。

解决:在特征提取函数入口做统一清洗:url = url.strip(),过滤空字符串,限制最大长度(比如超过 2000 字符截断)。所有除法操作检查分母是否为 0。urlparse对畸形 URL 可能返回空 netloc,要单独处理。建议写单元测试,覆盖空 URL、超长 URL、含中文 URL、含@的 URL 等边界情况。

5.4 现象:模型文件加载慢或推理延迟高

原因:模型文件太大(比如随机森林树太多),或者每次请求都重新加载模型,或者特征提取里有耗时的网络请求(比如 whois 查询)。

解决:模型加载放在服务启动时,不要放在请求处理函数里。如果模型文件超过 100MB,考虑用joblib的压缩参数compress=3减小体积。特征提取里避免同步网络请求,whois、DNS 查询这类操作要么异步,要么离线预计算。如果延迟要求极高,可以把随机森林换成逻辑回归,推理速度能快一个数量级。

5.5 现象:模型效果随时间下降,但不知道原因

原因:恶意网站的对抗手段在进化,比如攻击者会刻意控制 URL 长度、避免敏感词、使用 HTTPS,导致原本有效的特征失效。另外,正常网站的技术栈也在变化,比如越来越多的网站使用长 URL 和复杂查询参数。

解决:建立定期评估机制,每周用新样本评估模型,记录 AUC、召回率、精确率的变化。如果指标下降超过 5%,触发重新训练。同时监控单个特征的分布变化,用 PSI(Population Stability Index)检测特征漂移。如果某个特征的重要性持续下降,考虑替换或增加新特征。最后,保留一份「模型版本 + 训练数据时间 + 评估指标」的记录,方便回溯。

6. 进阶技巧:用特征重要性反推攻击手法与模型迭代节奏

模型训练完不是终点,特征重要性(feature importance)是一份被低估的情报。随机森林和 XGBoost 都能输出特征重要性,把排名前 10 的特征拉出来,往往能看出当前恶意网站的攻击偏好。比如某段时间has_ip重要性飙升,说明大量钓鱼站直接用 IP 地址;sensitive_count重要性高,说明攻击者还在用 login、verify 这类词。反过来,如果某个特征重要性持续下降,说明攻击者已经绕过它,这个特征就该考虑淘汰或替换。

我一般会做一张「特征重要性周报」,每周记录 Top 10 特征及其重要性分数。连续三周下降的特征标记为「待观察」,连续五周下降的直接从特征集里移除。同时,如果发现新的攻击模式,比如大量 URL 使用某种罕见顶级域名,就临时增加对应特征,快速训练一版模型验证效果。这套机制比「半年重新训练一次」灵活得多,也更适合对抗性强的场景。

另一个实用技巧是「模型集成 + 阈值分层」。不要只用一个模型,可以同时跑 LR 和随机森林,两个模型都判为恶意且概率都超过 0.8 的,直接拦截;只有一个模型判为恶意的,进入人工审核队列。这样既能保召回率,又能控制误报。阈值分层的关键是找到「高置信区间」和「低置信区间」的分界点,通常用验证集上的精确率-召回率曲线来确定。

最后说一个我踩过的坑:不要用测试集的指标来调阈值。测试集是用来做最终评估的,调阈值要用验证集。我见过有人直接在测试集上找最佳阈值,结果上线后效果差一大截。正确做法是训练集切出验证集,用验证集调阈值和选模型,测试集只在最后用一次。这个习惯看起来简单,但能帮你省下很多「上线后才发现」的后悔药。

希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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