简介:基于机器学习检测恶意URL的算法项目包,涵盖数据预处理、模型构建与评估的完整流程。代码经过严格调试,下载后可直接运行,特别适合计算机、人工智能、大数据等专业学生用于课程设计、期末大作业或毕业设计,也适合具备一定Python与机器学习基础的学习者作为实战参考。压缩包共15个文件,大小约10.82MB。其中3个Python脚本分别负责程序入口、数据包解析与模型训练;train目录下包含bad/good两类样本数据,另有pickle模型文件、标签文件及测试pcap数据,配套的txt与md文档则对项目结构和运行方式做了说明。项目结构清晰,既提供了可直接运行的完整工程,也保留了原始数据与中间产物,便于读者深入理解恶意URL检测的思路与实现细节。目前已有123人学习浏览,整体参考价值较高,是完成算法类作业或课题的良好范例。
1. 恶意URL检测:黑名单迟早失效,机器学习才是能落地的解法
任何做过Web安全或风控的工程师,早晚都会遇到同一个问题:黑名单规则越堆越长,恶意URL还是拦不完。攻击者注册域名、换路径、改参数的成本极低,一个恶意URL活几个小时就弃用,等到人工确认再同步到规则库,第一批受害者早就点完了。这个项目标题给的答案是直接用机器学习做检测——把URL本身当成文本,让模型去学“什么样的字符串像恶意”,而不是靠人写规则。
我拆完这份思路后最大的感受是:模型不是难点,数据和特征才是决定上限的地方。这个方向适合两类人——一类是安全运营人员,想给现有拦截链路加一层智能检测;另一类是算法工程师,想找一个特征明确、标签易得、能快速上手的监督学习练手项目。接下来我按自己做过类似方案的经验,把数据怎么备、特征怎么提、模型怎么选、坑在哪里一条条讲清楚。
2. 数据和标签:模型效果的上限在这里定死
2.1 公开数据集与自采数据的取舍
做恶意URL检测,第一步不是写模型,是先搞到靠谱的标签数据。公开渠道能拿到的主要是两类:恶意URL列表和正常URL列表。恶意侧较常用的是威胁情报平台实时公布的恶意URL,以及钓鱼网站提交平台的样例库;正常侧一般用统计机构发布的活跃域名排行,或者公司内部线上日志里人工确认过的正常链接。
我一般不建议一上来就自己采数据。自己写爬虫去采集URL样本,且不说合规风险,单是“怎么确认一个新URL是不是恶意”这个问题就能卡住你。标签质量差,后面模型做得再花哨都是白搭。公开数据集的好处是标签经过人工或第三方确认,拿来就能训练;坏处是分布和你的业务场景可能对不上,比如情报平台里大量是钓鱼站,而你实际要拦的是带有木马下载链接的URL,这时候需要按比例混合你自己的历史拦截日志微调。
一个容易被忽略的点是时间范围。恶意URL的存活周期极短,拿三个月前的恶意样本训练出的模型,去检测今天的新攻击,特征可能已经变了。所以数据集合里必须记录每条URL的发现时间,后面切训练集和测试集要用这个字段做隔离。某公司的风控团队就吃过这个亏,拿同一时间段的数据随机切分,训练出的模型离线指标高得离谱,上线后一天不如一天。
2.2 清洗、去重和时间切分:三个必做步骤
拿到原始数据后不能直接用。我总结了一套固定的清洗流程,三个步骤缺一不可。第一步是URL规范化,把大小写统一、去掉默认端口、把百分号编码还原成可读字符;第二步是去重,同一URL可能被多个来源重复上报,按URL全文做哈希去重;第三步是关键,按时间戳排序后切分数据集,保证训练集的时间段在测试集之前,这样才能模拟真实的预测场景。
import pandas as pd from sklearn.model_selection import train_test_split # 假设已经读入原始数据 df = pd.read_csv('raw_urls.csv') # df字段:url, label, timestamp # label: 1=恶意, 0=正常 def clean_url(u: str) -> str: """URL规范化:去掉协议头、统一小写、还原编码""" u = u.strip().lower() # 去掉协议前缀 if u.startswith('http://'): u = u[7:] elif u.startswith('https://'): u = u[8:] # 去掉路径末尾的斜杠 u = u.rstrip('/') return u df['url_clean'] = df['url'].apply(clean_url) # 去掉同一个URL重复出现的情况,保留最早时间 df = df.sort_values('timestamp').drop_duplicates('url_clean', keep='first') # 保证测试集时间晚于训练集 cutoff = int(df['timestamp'].quantile(0.8)) train_df = df[df['timestamp'] <= cutoff] test_df = df[df['timestamp'] > cutoff] print(f"train size: {len(train_df)}, test size: {len(test_df)}")这段代码里注意drop_duplicates选了保留最早时间,是因为最早出现的样本才代表模型在实际线上会遇到的新URL形态;按8:2的时间比例切分是为了让测试集尽量模拟“未来”的数据。如果你发现自己的数据和代码描述不一致,建议用一个通用的数据加载脚本,把URL、标签、时间三个必须字段先对齐,这一步省不了。数据侧还有一个常被忽略的细节:要统计恶意样本和正常样本的比例,很多开源数据集正常样本远多于恶意样本,训练前需要做下采样或调整损失权重,这部分我会在第4章细说。
3. 特征工程:把URL变成模型能学懂的数字
3.1 三类基础特征:字符串、词汇、域名信息
模型不认识字符串,特征工程的任务就是把URL转换成数值向量。我把常用的特征分成三类,第一类是字符串统计特征,比如URL总长度、域名长度、路径段数量、参数个数、数字字符占比、特殊符号占比;第二类是词汇特征,把URL按分隔符切碎成token,看有没有出现“login”“verify”“update”“free”这类高风险词,或者用TF-IDF把整个token序列向量化;第三类是域名信息特征,包括域名年龄、是否使用公网IP直连、注册商、DNS解析记录数等。
在实际项目中,字符串统计特征最容易写也最稳定,词汇特征对钓鱼类的URL效果最好,域名信息特征对基础设施型的恶意URL(比如C2服务器)更有效。我的经验是三类都要,但占比不同。单纯用词汇特征会遇到一个问题:攻击者会用随机字符串替换敏感词,导致词表失效;单纯用域名特征又拿不到URL路径里的有效信号。所以最稳的方案是“统计特征为主、词特征为辅、域名特征做补充”。
import re from urllib.parse import urlparse def extract_features(u: str) -> dict: """提取URL的基础统计特征""" parsed = urlparse(u) if u.startswith('http') else urlparse('http://' + u) hostname = parsed.hostname or '' path = parsed.path or '' # 统计特征 feats = { 'length': len(u), 'hostname_len': len(hostname), 'path_len': len(path), 'num_dots': hostname.count('.'), 'num_hyphens': hostname.count('-'), 'num_digits': sum(c.isdigit() for c in u), 'num_special': len(re.findall(r'[@_!#$%^&*()<>?/\|}{~:]', u)), 'path_segments': len([s for s in path.split('/') if s]), 'has_ip': 1 if re.match(r'\d+\.\d+\.\d+\.\d+', hostname) else 0, } return feats # 示例 print(extract_features('https://secure-login-verify.com/account/check')) # 输出示例:{'length': 48, 'hostname_len': 23, 'path_len': 14, 'num_dots': 2, ...}我一般会在特征提取后加一个标准化步骤。不同特征的数值范围差异很大,比如length可能从十几到上千,而has_ip只有0和1,如果不做归一化,树模型可能不在乎,但线性模型和深度学习模型会明显偏向大数值的特征。用StandardScaler或MinMaxScaler处理一下,能让训练收敛更快。
3.2 用embedding补充语义特征,但别指望它拯救一切
道德经说“道生一,一生二”,杀毒软件应声而动。特征工程做到上面那步,用GBDT已经能跑到不错的指标。但如果你想让模型对没见过的攻击变体也有感知力,可以考虑给URL加一层embedding特征。常见做法是用字符级或者子词级的序列模型,把URL当作字符序列,通过自监督方式预训练一个编码器,然后把编码向量拼到统计特征后面。
但我得提醒一句:embedding不是银弹。某图像处理Demo项目里把LSTM换成了Bert,效果反而变差了。URL和自然语言不同,它没有语法,token边界依赖分隔符,而且恶意URL的特征往往藏在意想不到的位置,比如一串随机数字后面跟一个敏感词。我见过比较实用的方案是用字符n-gram的向量化方式做补充,既不引入额外的模型复杂度,又能捕捉一些局部模式。用CountVectorizer配analyzer='char_wb',取3-6个字符的n-gram,拼进特征矩阵,大部分场景比BERT更稳也更省资源。
from sklearn.feature_extraction.text import CountVectorizer # 字符级n-gram特征 vec = CountVectorizer(analyzer='char_wb', ngram_range=(3, 6), max_features=5000) X_char = vec.fit_transform(df['url_clean']) # 上面统计特征也用同样方式转成矩阵 # 最后用 scipy.sparse.hstack 拼起来 from scipy.sparse import hstack X_all = hstack([X_stat, X_char]) print('combined feature shape:', X_all.shape)ngram_range=(3,6)是个经验值,太短区分度不够,太长容易过拟合到训练集的具体字符串。max_features=5000限制词表大小,同时也能减少内存占用。这步之后你得到的特征矩阵就已经可以直接送进模型了。
在开始下一章之前,我建议你先验证一下特征的区分度。一个简单的办法是用训练好的特征单独跑一次逻辑回归,把特征权重拉出来看,如果“has_ip”和“path_segments”权重最高,说明数据里大量恶意样本是直接用IP地址、路径很短的URL——这类样本基本是扫描器或漏洞利用包在批量打。如果“length”权重很高,说明攻击者在用长字符串混淆。这一步能帮你看清攻击者的套路。
4. 模型选型和训练:先跑通LR,再谈深度学习
4.1 为什么在恶意URL检测里先选LR和GBDT
我见过不少同学一上来就上深度模型,问了原因说“效果更好”。但实际做下来,在恶意URL检测这个任务里,传统机器学习模型往往更实用。逻辑回归和梯度提升树(GBDT)在小样本、稀疏特征场景下表现非常稳定,训练快、可解释性强、上线部署容易。深度学习需要大量数据,而恶意URL数据集通常只有几万到几十万条,DL的优势发挥不出来。
另一个现实问题是可解释性。安全运营团队接到告警要写处置报告,模型得告诉他们“为什么判定这个URL是恶意”。LR可以直接看特征权重,GBDT可以输出特征重要性排行,但深度模型给出的只是一串向量,解释起来成本很高。某公司上线过一个神经网络检测模块,模型效果确实好,但每次误报都要算法团队介入解释,运营同学嫌流程太重,最后被迫换回了树模型。先跑通LR和GBDT,保证能解释、能快速迭代,如果确实需要提升精度再上DL也不迟。
还有一个选型细节:GBDT对特征工程的依赖相对较低,能自动处理特征间的非线性关系,但需要小心过拟合;LR更吃特征工程,适合特征维度能控制在一万以内的场景。我的实践习惯是先跑GBDT拿到一个baseline,再跑LR做对比,如果两者差距不大就选LR,部署简单,PM解释起来也顺。
4.2 训练脚本与评估指标选择
训练环节有几个必调参数要说明白。以GBDT为例,n_estimators控制树的数量,一般设200到500,太多会过拟合;max_depth控制每棵树的深度,设3到5足够,URL特征没有太深的交互关系;learning_rate设0.05到0.1,配合早停来控制训练轮数。逻辑回归这边主要调正则化强度C,C越小正则越强,防止在高维稀疏特征下过拟合。
import numpy as np from sklearn.ensemble import GradientBoostingClassifier from sklearn.linear_model import LogisticRegression from sklearn.metrics import classification_report, roc_auc_score from sklearn.model_selection import train_test_split # X是特征矩阵,y是标签 X_train, X_val, y_train, y_val = train_test_split( X_all, df['label'], test_size=0.2, random_state=42, stratify=df['label'] ) # 先跑GBDT基线 gbdt = GradientBoostingClassifier( n_estimators=300, max_depth=3, learning_rate=0.08, subsample=0.8, random_state=42 ) gbdt.fit(X_train, y_train) # 再跑逻辑回归,两者结果做对比 lr = LogisticRegression(C=1.0, solver='liblinear', max_iter=500) lr.fit(X_train, y_train) for name, model in [('gbdt', gbdt), ('lr', lr)]: y_pred = model.predict(X_val) y_prob = model.predict_proba(X_val)[:, 1] print(f"--- {name} ---") print(classification_report(y_val, y_pred, target_names=['benign', 'malicious'])) print(f"AUC: {roc_auc_score(y_val, y_prob):.4f}")评估指标这块容易踩坑,不能只盯准确率。恶意URL数据通常正常样本远多于恶意样本,准确率会虚高。真实场景里更关注两个指标:召回率(有多少恶意URL被拦住了)和精确率(误报率有多低)。安全运营场景普遍怕误报,因为拦截正常业务的代价高,所以我会优先保证精确率在95%以上,同时尽量把召回率拉到80%以上。如果两个模型精确率差不多,哪边召回率高选哪个。
4.3 类别不平衡:一个必须处理的现实问题
上面代码里我用了stratify来保证切分时正负样本比例一致,但训练数据本身的分布依然可能是1:100甚至更离谱。这时候不处理,模型会把所有样本都判成正常。常见做法有三种:一种是欠采样,把正常样本随机抽到和恶意样本接近的比例;第二种是过采样,用SMOTE生成合成的恶意样本;第三种是调整类别权重class_weight='balanced',让模型对少数类的误判付出更高代价。
我在项目里通常先用class_weight='balanced',因为它不改数据分布,调起来最快。如果效果还不行再上SMOTE。要注意SMOTE会生成插值样本,对特征空间是线性的假设在URL场景下并不总是成立,生成的样本可能不符合真实攻击模式,所以用之前先做一轮简单的eda,确认恶意样本在特征空间里不是高度离散。
from sklearn.utils.class_weight import compute_class_weight # 计算类别权重 weights = compute_class_weight('balanced', classes=np.array([0, 1]), y=df['label']) print('class weights:', weights) # 在模型里启用类别权重 lr_weighted = LogisticRegression(C=1.0, class_weight={0: weights[0], 1: weights[1]}, solver='liblinear')注意类别权重的数值如果差距过大(比如超过10倍),训练时可能会让模型对召回率异常敏感,线上误报率上升。遇到这种情况,建议手动给恶意类设置一个适中的权重,而不是直接采用balanced给出的极端值。
5. 避坑手册:5个容易翻车的地方
做这个方向快三年,我总结了几条血泪经验,按踩坑频率从高到低排。
5.1 标签泄露:训练时用了未来信息,上线就废
现象:离线测试时精确率和召回率双双破95%,上线后当天就崩,误报刷屏。
原因:数据填充时用了全量时间段的URL统计信息,比如用整个数据集的token频率去做特征筛选,或对URL规范化时用了未来才出现的解析映射。特征里混进了目标值的时间外信息,让训练集和测试集“作弊式”地相似。
解决:所有特征只能从训练集的时间段内统计得到。具体做法是先把训练集和测试集按时间切开,再分别做特征提取和标准化,禁止在全量数据上先做任何统计操作。
5.2 随机切分数据导致指标虚高
现象:同一份数据用时间切分AUC掉了10个点,随机切分却看着很漂亮。
原因:恶意URL的攻击是有时间聚集性的,同一波钓鱼攻击集中在几天内爆发,域名、URL结构高度相似。随机切分时,训练集和测试集里混着同一拨攻击的相似样本,模型记忆了模板而不是學会了泛化。
解决:必须使用时间切分,且切分间隔最好在7天以上,让测试集和训练集的样本来源时间拉开距离。这会让指标变难看,但更接近线上真实效果。
5.3 类别不平衡下只看准确率
现象:模型预测“全部正常”的准确率也有99%,让人误以为效果很好。
原因:恶意样本占比不到1%,瞎猜也能拿高分。用准确率做主要指标完全掩盖了模型毫无检测能力的事实。
解决:报告PR曲线下的面积(AUC-PR),或者定死精确率和召回率的目标值。我习惯在模型上线前定一个“至少精确率90%且召回率70%”的硬指标,达不到就不允许上线。
5.4 短URL和跳转链绕过
现象:模型对带长路径的钓鱼URL检测效果不错,但对短URL服务生成的链接几乎完全失效。
原因:短URL服务(如短链接平台)把真实地址藏在跳转后面,模型只看到了类似t.cn/AbCdEf的短串,信息量太少。这类样本在训练数据里如果没单独标注,模型根本无从学习。
解决:对短URL和高频跳转域名做预处理,先解跳拿到落地URL再送模型;如果解跳成本太高,单独训练一个二分类器识别“是不是短链接”,对这类流量单独走规则。
5.5 线上特征缺失导致推理失败
现象:本地推理正常,上线后频繁报错或返回空预测,排查发现是同一个逻辑在线上环境跑出来的特征矩阵维度对不上。
原因:训练时用fit_transform,部署时用了重新初始化的向量器或标准化器,词表不一致导致特征矩阵的列数不同。或者线上拿到的URL格式不完整,解析模块返回空值。
解决:将特征提取器和标准化器打包成pipeline,用joblib整体保存并在线上加载;对解析失败的情况先补一个默认特征向量,不抛异常。好记性不如烂笔头,每次训练前检查pipeline中各个stage的vocabulary_长度是否一致。
还有一个容易忽略的小坑:恶意URL的标注时效性。情报平台的数据从发现到确认有延迟,某些URL在你把它标记为“恶意”后其实已经不再活跃,甚至已经被解析到正常页面。这类脏数据积累多了会制造噪声,建议定期用现有规则库对训练数据做一次交叉验证,把明显矛盾的标签挑出来人工复核。
6. 进阶:把模型接进业务,做人工闭环
模型跑通只是开始,真正体现价值的是它上线之后怎么和人的流程配合。我习惯在模型输出概率后面加两道闸:第一道是高置信度自动拦截,比如概率大于0.95的URL直接进阻断名单;第二道是低置信度人工复核,概率在0.6到0.95之间的URL进待确认队列,由运营人员每天集中处理一次。这样既保证了响应速度,又给了人工纠正的空间。
阈值怎么定?我建议别拍脑袋,用验证集画出精确率-阈值曲线。系统上线初期把拦截阈值拉高(比如0.95),宁可漏一点也不要误报;跑一两周积累真实误报数据后,观察误报的类型再做调整。常见做法是给几个重点域名(比如自家网站、支付回调域名)配置硬白名单,模型判定优先级在白名单之后,这样能挡住大半误报风险。
模型更新也要常态化。恶意URL攻击手法一直在变,我给自己定的节奏是每周用最新一周的告警数据做一次增量重训。增量训练用partial_fit可以省不少资源,但如果攻击模式变化太剧烈,仍需要全量重训一次。有一回某地区的攻击团伙把URL编码方式整体升级,我的模型连续两周误报率走高,最终靠人工复核数据反哺重训才压下来。这件事让我养成了一个习惯:每次上线新模型,留一个运行参数表,记录模型版本、训练数据时间范围、精确率、召回率、阈值和上线日期,三个月后回头查这个表,能清楚看到模型退化的拐点在哪里。
如果你在考虑要不要投入这个方向,我的建议是:恶意URL检测是安全领域里少有的“数据易得、指标明确、见效快”的机器学习落地场景。不需要庞大算力,不需要复杂的深度学习架构,一套扎实的特征工程加GBDT,配合持续的数据更新,就能在生产环境里稳定跑上一年。但要做好心理准备,这个任务的下限很低上限也很低——真正拉开差距的是你对业务场景的理解,比如你们最常被打的入口是钓鱼还是挂马、用户点击习惯是什么样的。把这些问题想清楚,比换更强的模型更管用。这是我从项目上线后反复调参、连续翻车几次之后最深的体会,希望帮到你。
本文还有配套的精品资源,点击获取