简介:针对恶意网址识别任务,压缩包提供一套基于机器学习的完整检测实现,主要面向安全方向学生、科研人员以及希望快速构建分类原型的开发者,可应用于网络钓鱼、恶意软件分发、跨站脚本等场景的恶意网址初筛。方案以网址字符串为原始输入,通过特征工程提取长度、域名、路径、参数等特征,并借助sklearn库完成模型训练与分类判别,配套实验数据存放于data文件夹。压缩包共24个文件、约48.4MB,核心为11个Python脚本和8个CSV数据文件,另有2个TXT词表、2张PNG可视化图及1个Markdown说明文档。脚本覆盖特征提取、数据划分、模型训练、单条测试、皮尔逊相关性分析、WHOIS查询、VirusTotal校验等环节;CSV包含正常与恶意网址样本及Fishtank等公开来源数据,TXT提供黑白名单关键词,PNG展示词云与相关性图谱,MD为项目说明。目前已有285人浏览学习,适合直接复现实验,也便于在此基础上扩展新特征或尝试不同分类算法。
1. 基于机器学习的URL恶意性检测:这份资源到底能帮你做什么
如果你正在做 URL 恶意性检测,你会发现最难受的不是模型选型,而是拿到一批 URL 时,不知道怎么在不访问页面的前提下快速判断它是不是恶意。传统黑名单滞后,人工分析太慢,而机器学习给了另一条路:直接对 URL 字符串做特征提取,然后交给 sklearn 分类器。这套资源就是干这个的,它自带实验数据(bad_urls.csv、fishtank+train.csv、popular_web.txt 等),还有完整的特征提取、模型训练、单条检测脚本,以及相关性分析和词云可视化。对想入门安全机器学习的人,它是一份能直接跑通闭环的样本;对已经在做威胁情报的工程师,也能拿来当特征工程参考。
2. 特征提取:把URL字符串变成模型能看懂的数字
恶意 URL 检测的第一步不是你想象中那些高端 AI 模型,而是把 URL 变成一行行数字。这里有个关键区别:我们不做页面内容分析,只基于 URL 字符串本身。因此特征设计的质量,基本决定了模型的上限。很多人在这一步偷懒,结果后面调参再久也没用。
2.1 原始数据长什么样:bad_urls.csv 与 popular_web.txt 的区别
打开data目录,你会发现几类文件:恶意样本、正常样本、辅助特征表和外部工具脚本。先说样本文件,理解它们比直接跑代码更重要。
| 文件 | 内容 | 在我的工作流里的用途 |
|---|---|---|
bad_urls.csv | 已知恶意 URL 列表 | 核心负样本,通常含 URL 和标签 |
fishtank+train.csv | 从 PhishTank 获取的钓鱼 URL | 补充恶意样本,丰富攻击模式 |
popular_web.txt | 常见正常网站域名 | 正样本来源,注意可能只有域名 |
World_Top_500.csv | 全球访问量前 500 网站 | 另一份正常样本,缓解不平衡 |
test_data/data.csv | 测试用 URL 集合 | 模拟线上新数据的评估集 |
从这些文件看,正样本来源是"知名网站",负样本来源是"已知恶意库"。这带来一个隐含问题:恶意样本往往有更长的路径和参数,而正常样本很多是短域名,模型很容易学到"短 URL=正常"这种表面规律。我一般会先观察两类样本的url_length分布,如果重叠太少,就说明数据太理想化,后面要小心过拟合。
2.2 feature_extraction.py 里的特征设计思路
从命名和常见工程习惯来看,这个脚本的核心逻辑可以概括为:解析 URL,提取结构特征、统计特征和词表命中特征。这里给出一个常见的最小实现:
import re from urllib.parse import urlparse, unquote def extract_features(url: str) -> dict: decoded_url = unquote(unquote(url)) # 先解码,避免 %XX 干扰 parsed = urlparse(decoded_url) features = {} # 结构特征 features['url_length'] = len(decoded_url) features['domain_length'] = len(parsed.netloc) features['path_length'] = len(parsed.path) features['path_depth'] = len([x for x in parsed.path.split('/') if x]) features['domain_num_digits'] = sum(c.isdigit() for c in parsed.netloc) # 统计特征 features['special_chars'] = len(re.findall(r'[?=&%]', decoded_url)) features['has_ip'] = 1 if re.match(r'^\d{1,3}(\.\d{1,3}){3}$', parsed.netloc) else 0 features['suspicious_words'] = sum( 1 for w in ['login', 'verify', 'secure', 'account', 'free'] if w in decoded_url.lower() ) return features这里urlparse会把 URL 拆成scheme、netloc、path、query等部分,unquote则是处理 URL 编码的关键。has_ip特征很重要,因为直接使用 IP 地址的 URL 在合法站点里很少见,却常见于攻击场景。special_chars统计?、=、&、%的数量,反映 URL 中参数和编码的复杂度。suspicious_words是词表命中,词表可以后续用词云图去扩充。
实际项目里我会加入更多特征:比如顶级域是否为常见钓鱼域(.tk、.ml、.ga 等)、路径中是否包含随机十六进制串、域名是否包含连字符等。但要注意:特征不要一味追求多,而是每加一个都做对比实验。我自己最常用的特征集大约 15 个,再多反而容易让模型过拟合到训练集的历史模式上。
2.3 数据切分:data_split.py 如何保证训练集和测试集不串样本
有了特征,下一步不是直接训练,而是先切分数据。data_split.py的目标是把原始 CSV 按比例分成splited_data/train.csv和splited_data/test.csv。这一步看起来简单,但有个常见错误:直接random.sample,结果正负样本比例失衡,测试集不能反映真实分布。
更稳的做法是分层切分。假设你已经把标签列变成了 1 和 0:
import pandas as pd from sklearn.model_selection import train_test_split df = pd.read_csv('data/bad_urls.csv') X = df['url'] y = df['label'] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, stratify=y, random_state=42 )stratify=y是关键参数:它让训练集和测试集中的恶意样本比例和原始数据集一致。random_state=42保证实验可复现。
但还有一个隐患:恶意样本经常是同一批域名下的不同路径,单纯随机切分可能让同一个域名的不同 URL 同时出现在训练和测试里,造成评估虚高。我在自己做威胁检测项目时,会额外加一步按域名去重:
from urllib.parse import urlparse df['domain'] = df['url'].apply(lambda u: urlparse(u).netloc) # 按域名分组,保证同一域名只出现在一个集合 unique_domains = df['domain'].unique() train_domains, test_domains = train_test_split( unique_domains, test_size=0.3, random_state=42 ) train_df = df[df['domain'].isin(train_domains)] test_df = df[df['domain'].isin(test_domains)]这种做法会牺牲一些训练样本,但换来的是评估结果可信。你可以在切分前先检查一下splited_data里是否存在跨集的域名,如果发现大量重叠,就说明这个项目原本的切分没有考虑域名级去重,复现时要自己修正。
3. 模型训练与评估:use_sklearn.py 的完整流程
特征准备完,就到了项目名字里的主角:机器学习分类器。use_sklearn.py是训练入口。它用的不是深度学习,而是 sklearn 库里的经典算法。这一点对入门者是好事:sklearn 模型接口统一,调参空间清晰,CPU 上就能跑,数据量在几千到几万条时完全够用。
3.1 为什么选 sklearn:逻辑回归 vs 随机森林
在 sklearn 里能做二分类的算法很多,逻辑回归、决策树、随机森林、SVM 都行。在这个项目里,我优先推荐先尝试逻辑回归和随机森林。
逻辑回归可解释性极强,你能直接看到每个特征的权重。对于安全运营场景,有时候需要向领导解释"为什么这条 URL 被判恶意",权重比黑盒模型好讲。随机森林能捕捉非线性关系,比如某个特征单独看没什么异常,但组合起来很可疑。它对特征量纲不敏感,不需要做标准化,对异常值也相对鲁棒。
如果不确定用哪个,我的习惯是先跑一个逻辑回归作为基线,再跑随机森林,看测试集上的恶意样本召回率提升是否超过 2%。如果超过了,就上随机森林;没超过,优先用逻辑回归,因为部署简单,线上推理也更快。
3.2 训练脚本的解读:从特征矩阵到分类报告
use_sklearn.py的流程大致是:读取切分好的训练数据 → 调用feature_extraction.py把每条 URL 变成特征向量 → 组装成特征矩阵 → 训练模型 → 在测试集上输出分类报告。
import joblib import pandas as pd from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report from feature_extraction import extract_features train_df = pd.read_csv('data/splited_data/train.csv') test_df = pd.read_csv('data/splited_data/test.csv') X_train = train_df['url'].apply(extract_features).apply(pd.Series) y_train = train_df['label'] X_test = test_df['url'].apply(extract_features).apply(pd.Series) y_test = test_df['label'] clf = RandomForestClassifier( n_estimators=300, max_depth=12, min_samples_leaf=2, class_weight='balanced', random_state=42 ) clf.fit(X_train, y_train) y_pred = clf.predict(X_test) print(classification_report(y_test, y_pred)) joblib.dump(clf, 'url_clf.joblib')apply(pd.Series)把特征字典拆成多列,形成 DataFrame。这里要注意:特征函数返回的键顺序就是列顺序,只要保证训练和预测用同一个函数,顺序就不会乱。n_estimators=300是决策树数量,太小容易欠拟合,太大训练时间成倍增加。max_depth=12限制树的深度,防止模型记住训练集中的噪声。min_samples_leaf=2要求叶节点至少 2 个样本,同样是为了降低方差。class_weight='balanced'是处理正负样本不平衡的偏方,它会给少数类更高的权重,在恶意 URL 场景里几乎必开,因为恶意样本往往不到总量的 20%。
3.3 实验结果怎么看:准确率、召回率、AUC 的陷阱
classification_report输出 precision、recall、f1-score 等指标。很多人只盯准确率,看到 0.95 就觉得模型很牛。但在恶意 URL 检测里,准确率高可能是假象:如果恶意样本只占 10%,模型把所有 URL 都判为正常,准确率也是 90%。这时候召回率才是关键——恶意样本有多少被抓出来了。
我的优先级排序是:恶意类召回率 > 正常类召回率 > 准确率。漏报一个恶意 URL 可能导致真实安全事件,而误报一个正常 URL 最多是用户烦一点。如果恶意类召回率很低,先不要调参,回到特征上看分布,很可能是特征本身没有包含足够的信息。
如果想更全面,可以加一个混淆矩阵和 AUC:
from sklearn.metrics import confusion_matrix, roc_auc_score print(confusion_matrix(y_test, y_pred)) print('AUC:', roc_auc_score(y_test, clf.predict_proba(X_test)[:, 1]))AUC 对样本类别不平衡不那么敏感,能综合反映排序能力。但 AUC 高不代表阈值选得好,实际拦截还是需要根据业务设定概率阈值,这个在下一章会讲。
4. 单条URL检测与闭环验证:single_test.py 和外部工具
训练只是第一步,真正能用起来的是单条检测。single_test.py把"加载模型 → 特征提取 → 预测"串成一条线,也是你最终部署时可以平滑改造成 API 的最小原型。
4.1 single_test.py 的用法:加载模型、提取特征、预测
常见用法是python single_test.py https://example.com/path?param=1。脚本内部逻辑如下:
import sys import joblib import pandas as pd from feature_extraction import extract_features def predict(url: str): clf = joblib.load('url_clf.joblib') features = extract_features(url) X = pd.DataFrame([features]) prob = clf.predict_proba(X)[0, 1] pred = clf.predict(X)[0] return pred, prob if __name__ == '__main__': url = sys.argv[1] pred, prob = predict(url) print(f"预测结果: {'恶意' if pred == 1 else '正常'},恶意概率: {prob:.3f}")这里我刻意用了predict_proba。实际部署时,不建议直接用predict输出二值,而是设一个业务阈值。比如恶意概率大于 0.7 判恶意,0.3 到 0.7 之间标记为"待人工审核"。安全场景里,宁可多给分析师看一眼,也不要把恶意样本悄悄放走。我在多个项目里验证过,阈值哪怕只是提高 0.1,误报率都可能降一个量级,代价是少量边界样本进入人工队列。
4.2 用 VirusTotal 和 WHOIS 做交叉验证
single_test.py给的是模型判断,但模型会有误报。为了验证一条 URL 到底是不是钓鱼,项目里还放了virus_total_check.py和whois_info.py。
virus_total_check.py调用 VirusTotal 的 API,查询这条 URL 被多少安全厂商标记为恶意。VirusTotal 聚合了 70 多家安全厂商的检测结果,是安全圈公认的交叉验证渠道。如果你是批量验证,注意免费 API 有频率限制,大概一分钟只能查 4 次。我一般会在循环里加time.sleep(15),同时用try/except捕获超时,避免因为单条失败中断整个流程。
whois_info.py查询域名的注册信息。恶意域名经常是刚注册的,注册时长很短,注册者信息模糊。WHOIS 信息可以作为特征,但这里更推荐作为人工研判的辅助。当模型输出恶意概率 0.6 且 WHOIS 显示域名注册不到一个月时,基本可以判定是一起早期钓鱼。
4.3 完整流程串起来:从训练到单条检测的工程习惯
当你把data_split.py、use_sklearn.py、single_test.py串起来,就形成了一个完整的闭环:原始数据 → 切分 → 特征提取 → 训练 → 保存模型 → 单条预测。这个流程本身很健康。
但我建议在这个闭环上再加两步:第一步,把single_test.py封装成一个纯函数,输入 URL 输出概率,方便以后接 Flask 或 FastAPI。第二步,每训练一个新模型,就在一个固定的验证集上跑一遍并记录结果,防止模型悄悄退化。我自己会把验证集单独放在test_data/目录,训练时永远不碰它。
5. 避坑指南:URL编码、样本不平衡、特征泄漏与其它常见问题
这个部分是从多次复现这个项目时最容易翻车的地方。我挑了 5 个几乎每个人都会遇到的坑,按"现象 → 原因 → 解决"写清楚。
5.1 URL 解码问题
现象:有些 URL 看起来很长很可疑,但模型把它判成了正常。尤其你拿dps://p?url=https%3a%2f%2f...这种经过百分号编码的链接去测,模型基本反应不过来。
原因:特征提取时直接对原始字符串操作,%3a、%2f这些编码字符被当成普通字符统计,导致 URL 的真实路径隐藏在编码后面。攻击者很擅长这么做,把https://evil.com编码成https%3a%2f%2fevil.com,或者嵌套多层编码。
解决:在特征提取前先做标准解码。常见做法是用urllib.parse.unquote解码一次,如果字符串里还有%再解一次,最多解码两层。注意训练和测试必须使用同一个解码流程,不能训练时解码而预测时不解码。我在extract_features的第一步固定写decoded_url = unquote(unquote(url)),这样恶意样本的召回率通常有肉眼可见的提升。
5.2 样本不平衡导致模型"全猜安全"
现象:训练完,准确率 0.97,但看分类报告,恶意类的召回率只有 0.05。也就是说,模型根本没学会识别恶意样本,只是把所有样本都猜成了正常。
原因:训练集里恶意样本占比太低。比如bad_urls.csv只有几百条,而正常样本有上万条,决策树为了降低整体损失,会偏向多数类。accuracy在这个场景下会骗人。
解决:先用value_counts()检查标签分布。如果恶意样本占比低于 20%,可以做三件事:一是收集更多恶意样本来源,比如把fishtank+train.csv合并进来;二是在RandomForestClassifier里设置class_weight='balanced';三是采样,用resample从正常样本中抽取和恶意样本相同数量的样本,但注意不要对测试集做同样的操作。我最推荐先加class_weight,因为简单且不容易引入偏差。
5.3 特征泄漏:来自"未来信息"的假高分
现象:模型在测试集上跑出 0.99 的 AUC,你兴高采烈地部署上线,结果线上误报率高得离谱,过两天就被人投诉。
原因:你可能在特征里加了一些来自"未来"的信息。比如 WHOIS 注册时间——如果训练数据用的是某一天的快照,但线上预测时用的是当前注册信息,分布完全不同。更隐蔽的是,如果你用整个数据集计算某个统计量,等于把测试集的信息也泄漏到了特征里。
解决:严格区分时间切片。训练数据只使用某个时间点之前的信息,测试数据使用之后的信息。对于popular_web.txt这类外部列表,它本身也会变化,最好定期更新并记录版本。如果不确定有没有泄漏,做一个实验:把标签随机打乱,再重新训练,如果新模型的分数依然很高,说明特征里有泄漏——打乱标签后 AUC 应该接近 0.5。
5.4 测试集里包含训练集的 URL
现象:模型评估指标很好,但换一批全新 URL 就不行了。
原因:切分数据时直接用train_test_split,同一个 URL(或同一个域名下的多个路径)同时出现在训练集和测试集里。模型记住了 URL 本身,而不是它的模式。这在数据收集过程中很常见,恶意样本可能被多个源重复收录。
解决:切分前先按 URL 去重,更进一步按注册域名去重。把同一域名的所有样本放到同一个集合,这样测试集才是真正"没见过"的样本。你可以自己写一个域名分组切分的函数,替换掉默认的train_test_split。
5.5 编码与特殊字符的坑
现象:single_test.py检测中文参数或国际化域名时报错,或者直接误判。
原因:URL 里有非 ASCII 字符,比如https://恶意.com/xx,Python 的urlparse在这些字符上表现不稳定,正则匹配也可能因编码混乱出错。另外,浏览器地址栏显示中文,但实际传输的是 punycode。
解决:在处理 URL 前先用idna编码转换域名部分。urlparse得到netloc后,如果包含非 ASCII 字符,用encode('idna').decode()转成 ASCII。路径部分用urllib.parse.quote统一编码。这样特征提取就在一个标准格式上操作。我会写一个normalize_url函数,把纯 IP、国际化域名、大小写差异全部规整,再交给特征提取,这是性价比最高的预处理步骤。
6. 进阶:用皮尔逊相关性分析与词云迭代特征
到这里,基础闭环已经通了。但如果你想把这个项目做得更好,比如用于论文或面试展示,强烈建议看pearson_correlation.py和badword_cloud.png。它们不是炫技,而是给你一套迭代特征的依据。
6.1 pearson_correlation.py 怎么用
pearson_correlation.py计算每个特征与标签之间的皮尔逊相关系数。相关系数绝对值越高,说明该特征与"是否恶意"的线性关系越强。输出会是一张热力图或表格:
| 特征名 | 与标签相关系数 |
|---|---|
| url_length | 0.37 |
| has_ip | 0.29 |
| suspicious_words | 0.22 |
| domain_length | 0.08 |
看到has_ip和url_length相关性高,就知道这两类特征值得保留;如果某个特征系数接近 0,比如scheme是否https,单独看没意义。但注意:皮尔逊相关系数只衡量线性关系,非线性信号它看不到。相关性低不代表特征没用,只能说明它不适合线性分类器。我用这个表做排除,而不是做最终决策。
6.2 badword_cloud.png 告诉你的信息
词云图把恶意 URL 里出现频率高的词放大展示。如果login、secure、verify、account这些词非常大,说明攻击者喜欢把这类词嵌在钓鱼 URL 里。这些词可以直接扩充到suspicious_words特征表里。词云还可以给你灵感:比如出现free、click,就可以把它们加进特征函数,然后跑对比实验。
6.3 特征迭代的通用套路:从简单到复杂,每次只变一个变量
不要一口气加 20 个特征。我的做法是:拿现有特征跑基线,记录恶意类召回率和误报率;然后每次只加一个特征或调整一个特征定义,比如把url_length从原始长度改为对数值,再跑一次,比较结果。有提升就保留,没提升就回退。这样你最终留下的每一个特征都是经过验证的。
我在做类似项目时,会准备一个结果记录表,包含日期、特征列表、模型参数、测试集 ID、召回率、误报率。这个习惯帮过大忙:有一次我发现某个特征在 7 月有效但 9 月完全失效,一查才发现词云对应的攻击团伙已经换了套路。从那以后,我每次迭代特征都强制走一遍"先记录基线,再改一个变量"的流程,再也没有被表面上的模型优化骗过。希望这套流程和这些坑,能帮你在 URL 恶意性检测上少走几步弯路。
本文还有配套的精品资源,点击获取