基于机器学习检测恶意URL:样本标签、特征工程与模型实践
发布时间:2026/10/13 14:10:31 锦皓数字建站

简介这是一份面向计算机相关专业学生与机器学习初学者的恶意URL检测项目资源适合用于课程设计、期末大作业或毕业设计参考。项目基于机器学习算法构建URL分类模型尤其包含基于支持向量机SVM的实现覆盖数据抓取、特征处理、模型训练与评估的完整流程。压缩包提供Python源码、预训练模型、样本数据及项目说明文档下载后经调试即可运行可帮助读者理解从原始数据到最终分类结果的整体工程实现也便于针对不同算法进行对比与改进。资源共15个文件涵盖py脚本、txt说明、pickle模型、pcap网络数据样本及可视化图片等类型整体大小约10.82MB目录结构清晰方便按模块检索学习。目前已有123人学习浏览无论用于算法复现、实验扩展还是作为毕设功能模块都能提供扎实的参考起点适合具备一定编程和机器学习基础的学习者深入研读。1. 基于机器学习检测恶意URL先别急着拆算法先拆样本与标签「基于机器学习检测恶意URL算法源码项目说明.zip」这个压缩包在课程设计、毕设和安全团队POC的语境里非常常见。拿到包的人第一反应都是解压、跑模型、看准确率但真正决定这个项目能不能交差、能不能上线的往往不是算法部分而是样本标签和特征口径这两件事。标题背后的方案本质上是把一条URL字符串作为输入输出「恶意/良性」二分类。它不做流量还原也不爬网页正文只吃URL本身和能从URL低成本拿到的信息。适用场景很明确邮件网关前置过滤、聊天软件链接风控、运营后台的URL审核这类「先筛一道再决定要不要深入分析」的环节。适合三类人拿它做课程设计或毕设的学生被分配做安全平台POC的工程师以及想评估这个方向值不值得投入的技术负责人。下面我从数据到上线验证把这条路完整过一遍重点讲坑在哪。2. 构建训练数据恶意URL从哪里来白样本又如何不骗你2.1 恶意样本的三类来源公开情报源、二手数据和主动捕获恶意URL的样本来源我经手的项目里主要就三类。第一类是公开恶意URL情报源像PhishTank、URLhaus这类社区维护的恶意URL分享平台会持续收录被举报的钓鱼、木马下载链接。它们的优点是免费、更新快、格式相对规整缺点是时效性极强——你现在拉下来的URL可能三天后就已经失效但作为训练样本依然有效。另一个问题是配额很多公开源对单日拉取量有限制不是一次能拖几百万条那种。我一般会写一个定时任务每天增量拉取落库时记录「首次出现日期」和「最后确认日期」。第二类是二手数据。不少安全文章、应急响应报告会附带IoC列表里面有恶意URL、域名和IP。它的优点是覆盖了真实攻击事件缺点是格式混乱需要大量清洗而且来源偏向某个行业或地区会让样本分布产生偏差。比如说你从金融行业报告里收集的样本占比过高模型会对金融相关的词特别敏感产出过拟合。第三类是主动捕获。生产环境里常见做法是从蜜罐日志、钓鱼邮件样本里提取URL或者与已有的威胁情报平台对接取内部确认过的恶意链接。这条路对个人复现者门槛较高但如果你是团队内做POC这反而是最靠谱的来源因为标签是自家分析师确认过的。这里有个原则要记住不要只依赖单一来源。多源交叉验证是最低成本的质量保障。拉回来的URL先落原始数据再合标签别上来就洗。2.2 白样本不是随手抓纯净度、地域和时间漂移良性URL样本看起来好拿网站目录、公开站点榜单、各类导航页一抓一大把但实际上坑比恶意样本还多。第一个坑是纯净度。很多开放目录里混着灰色站点赌博、色情、盗版下载站都有。你拿这些当良性样本模型学到的是「这些词对应的站点是安全的」上线之后这类站点会被误判成良性。我踩过一次一个导航页列表里大概混进3%的灰色站点训练出来的模型对「bet」「casino」这类词的URL给出极高置信度的良性评分发现后整套数据要重做。处理办法是过一遍基础信誉库把有不良记录的域名直接剔除再随机抽几百条人工复核。第二个坑是地域分布。如果你的白样本大部分来自某个特定区域的站点模型的泛化能力会受影响。URL检测要面对的是全球范围的链接样本应该在域名语言、TLD分布、站点类型上尽量铺开。第三个坑是时间分布这最隐蔽。恶意样本可能是上个月的白样本是今天抓的模型学到的不一定是「恶意和良性的区别」而可能只是「旧URL和新URL的区别」。攻击者的域名生命周期普遍很短旧样本里恶意占比高你等于是在让模型用时间戳做分类。解决方法是把样本的时间窗口对齐——训练集、验证集、测试集都来自同一个时间区间或者明确记录每条样本的采集时间后面做时间切分时用得上。白样本我建议至少准备恶意样本的1.5到2倍因为URL检测实际业务里恶性样本占比通常很低你的训练集比例可以稍微平衡一点但评估阶段一定要按真实比例来。2.3 多源合并与标签清洗产出第一份可训练的TSV数据装载和打标签这一步我一般用一段Python脚本搞定。假设你已经有两个CSV一列是url来源分别是恶意源和良性源import pandas as pd # 两个来源的原始URL列表列名统一为 url mal_df pd.read_csv(sources/malicious.csv) ben_df pd.read_csv(sources/benign.csv) # 打基础标签 mal_df[label] 1 ben_df[label] 0 # 合并并去重同一URL多源重复时优先保留恶意标签 raw pd.concat([mal_df, ben_df], ignore_indexTrue) raw raw.sort_values(label, ascendingFalse) raw raw.drop_duplicates(subset[url], keepfirst) # 统一清洗去空白、剥离协议头和锚点host部分小写 raw[url] raw[url].str.strip() raw[host_part] raw[url].str.replace(r^https?://, , regexTrue) raw[host_part] raw[host_part].str.split(/).str[0] raw[host_part] raw[host_part].str.lower().str.split(#).str[0] # 过滤明显异常的空记录 raw raw[raw[host_part].str.len() 3] raw.to_csv(data/train_raw.tsv, sep\t, indexFalse)这段脚本的逻辑是先给两个来源分配标签合并后按标签排序再对同一URL去重保证同一URL如果同时出现在恶意和良性来源里优先保留恶意标签。清洗时只对host部分做小写化path部分保留原样——因为路径大小写在很多Web服务器里是敏感的而且恶意URL的路径大小写本身可能是特征不能一刀切。去重这里有一个关键选择按完整URL去重还是按注册域去重。攻击者很喜欢用同一个域名生成大量随机路径和随机参数的变体URL这些URL在字符串层面完全不同但背后的主机是同一个。如果你按完整URL去重得到的样本里会有一大堆同一域名的重复模式如果你按注册域去重又会把路径和参数层面的信息丢掉。我的做法是训练集主键用完整URL但加一列注册域用于后面的分组评估。提取注册域用tldextract包import tldextract def get_registered_domain(url): ext tldextract.extract(url) return ..join(part for part in ext[-2:] if part) raw[registered_domain] raw[url].apply(get_registered_domain)tldextract能把URL拆成subdomain、domain、suffix三段取最后两段拼起来就是注册域。对恶意URL检测来说注册域是对抗URL随机化最重要的聚合粒度也是后面评估模型会不会「作弊」的抓手。标签清洗还有一道工序是时间标注。给每条样本加上首次发现日期和标签确认日期保存成字段而不是只留在文件名里。这样后面做时间切分时才能做到「训练集的时间窗口严格早于测试集」避免时间穿越。3. URL特征工程把一条字符串拆成机器能读的30个维度3.1 为什么不做词袋短文本的语义陷阱很多从自然语言处理转过来的同学会顺手把URL当成普通文本直接上TF-IDF或者Word2Vec。这个思路在长文本上没问题在URL上非常坑。URL不是自然语言。它由host、path、query、fragment组成每段有各自的语法规则。攻击者可以随意生成无意义的随机字符串让词表分布完全失稳。今天训练集里出现的token明天线上可能一个都不出现词袋模型的特征空间会出现大量稀疏甚至全零列。另外词袋模型丢掉了结构信息——「login」出现在host里和出现在path里语义是完全不同的词袋处理不了这种位置差异。因此这个方向的标准做法是手工构造特征分三组词法特征、结构特征、统计特征有条件的再加外部情报特征。手工特征的优势不仅仅是可解释更重要的是特征口径由你自己控制训练和推理容易保持一致。3.2 核心特征函数词法、结构与统计三个维度一起算下面这个函数是我常用的特征提取起点输入原始URL字符串输出特征字典。它不依赖额外特征库只有标准库里的urlparse和re复制过去就能跑import math import re from urllib.parse import urlparse SUSPICIOUS_WORDS [login, verify, update, confirm, webscr, secure, signin] def extract_url_features(raw_url: str) - dict: raw_url raw_url.strip() if :// not in raw_url: raw_url http:// raw_url p urlparse(raw_url) host p.hostname or path p.path or # 词法特征 url_len len(raw_url) host_len len(host) digits sum(c.isdigit() for c in raw_url) special_count len(re.findall(r[%\\-_.], raw_url)) # 结构特征 host_parts host.split(.) subdomain_count max(len(host_parts) - 2, 0) path_depth len([seg for seg in path.split(/) if seg]) is_ip_host int(bool(re.match(r^\d\.\d\.\d\.\d$, host))) # 统计特征host 的 Shannon 熵 freq {} for ch in host: freq[ch] freq.get(ch, 0) 1 host_entropy 0.0 if host: host_entropy -sum( v / len(host) * math.log2(v / len(host)) for v in freq.values() ) return { url_len: url_len, host_len: host_len, digits_ratio: digits / max(url_len, 1), special_count: special_count, suspicious_word_hit: int(any(w in raw_url.lower() for w in SUSPICIOUS_WORDS)), subdomain_count: subdomain_count, path_depth: path_depth, is_ip_host: is_ip_host, host_entropy: round(host_entropy, 4), }逻辑上分三段词法特征关注字符串本身的形态比如长度、数字占比、特殊字符数量——恶意URL普遍比正常URL更长、数字占比更高、和%这类字符出现更频繁结构特征关注主机和路径的组织方式正常站点的host层级通常很少超过三级而钓鱼URL大量使用深层子域名来伪装可信域名统计特征里我目前只取了host熵它能反映host字符串的随机程度攻击者注册的域名经常是无意义的随机字母组合熵值偏高。参数说明里有几个细节容易踩。digits_ratio的除数用了整个URL长度而不是host长度这样在某些极端场景下数值不会爆炸。suspicious_word_hit的词表要控制得窄一些只放钓鱼场景里出现频率高且相对稳定的词词放多了会把正常登录页面误伤。host_entropy这个特征对纯数字IP的host没有太大区分度因为IP字符串熵值固定所以它要和is_ip_host配合使用。3.3 外部情报特征whois、DNS与风险标签的取舍上面的特征全是从URL字符串本身算出来的还有一类特征来自URL之外的情报常见的有whois注册时长、DNS解析IP的归属地、ASN、证书信息、历史信誉评分。这类特征对检测「新注册域名」「动态IP」这类短时攻击非常有效但引入它们的代价需要在项目一开始就想清楚。第一是延迟。whois实时查询一次要几十到几百毫秒DNS解析也依赖网络环境。如果你的检测服务要支撑每秒几百条URL的打分实时查这些外部信息会直接把吞吐拖垮。第二是可得性。很多部署环境是内网DNS解析和whois查询根本出不去。你在训练环境里跑得通不代表线上也能跑通。第三是一致性。训练时用的外部特征是你「当时查到」的快照上线时模型查询到的是「当前值」。攻击者更换了DNS解析、whois信息被更新模型拿到的特征分布和训练时完全不一样输出来自然不可信。我的习惯是把外部特征放在后置过滤器里而不是塞进主模型。主模型只吃字符串派生特征输出「可疑但不确定」的URL再触发外部信息二次判定。如果一定要进主模型那就在训练时模拟缺失——随机把一部分样本的外部特征置为缺失值让模型学会在外部情报不可用的时候应该更保守。3.4 特征一致性训练和推理必须同一套源码这个坑我在前面提过但值得单独强调。URL特征提取看着简单实际上一旦训练脚本和线上打分用的是两套代码就一定会出现口径漂移。你训练时用的正则表达式是[a-zA-Z0-9]线上重写成了\w看起来差不多但处理Unicode字符时的行为完全不同特征值就变了。正确做法是把特征提取函数封装成一个独立模块训练和推理都导入同一个文件。如果线上用了不同语言重写就要做一致性回归测试准备1000条覆盖边界情况的URL样本分别用两套实现跑一遍要求输出特征字典完全一致才能放行。4. 模型选型与训练从随机森林到逻辑回归的落地路径4.1 为什么传统机器学习在URL检测里够用在动手选模型之前先把期望摆正。URL二分类问题的样本量通常在几万到几十万级别特征是离散和连续混合的结构化数据。这种规模下深度学习带来的收益微乎其微。CNN需要把URL当成序列建模却拿不到足够长的上下文Transformer类模型需要大语料预训练训练和推理成本都高产出却不一定比随机森林高而且很难解释。传统机器学习在这个场景里反而是最优解。随机森林和梯度提升树对混合型特征天然友好不需要归一化能处理特征之间的非线性关系还能输出特征重要性。逻辑回归虽然线性但训练快、模型小、推理延迟低适合部署在资源受限的环境里。我经手的类似项目里基线模型几乎都是这两个效果稳定排查方便。很多期末项目和课程设计止步于「跑通一个模型」但评估环节才是拉开差距的地方。模型能不能上线看的不是测试集AUC而是「换了新样本还灵不灵」和「误伤的良性URL用户会不会投诉」。4.2 训练流程与超参起点先用随机森林跑通再考虑别的训练前先做数据切分。普通的随机切分在这个场景里有严重的泄漏风险我后面会详细讲。这里先给一个完整的训练代码框架import pandas as pd from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import GroupShuffleSplit, GridSearchCV from sklearn.pipeline import Pipeline # df: 特征表包含特征列和目标列registered_domain 来自特征工程阶段 X df[feature_cols] y df[label] groups df[registered_domain] # 按注册域分组切分避免同一域名的变体URL同时出现在训练集和测试集 split GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, test_idx next(split.split(X, y, groups)) X_train, X_test X.iloc[train_idx], X.iloc[test_idx] y_train, y_test y.iloc[train_idx], y.iloc[test_idx] # 随机森林class_weight 缓解样本不平衡 pipe Pipeline([ (clf, RandomForestClassifier( n_estimators500, max_depth12, min_samples_leaf5, class_weightbalanced, n_jobs-1, random_state42 )) ]) gs GridSearchCV( pipe, {clf__max_depth: [8, 12, 16], clf__min_samples_leaf: [3, 5, 10]}, scoringf1, cv3, verbose1 ) gs.fit(X_train, y_train) print(gs.best_params_)这段代码的关键不在GridSearchCV而在GroupShuffleSplit。groups参数传入了注册域保证同一个域名下的所有URL只会出现在训练集或测试集的一侧这是防止「同源泄漏」的最低限度措施。参数方面给一个参考起点参数推荐起点调参方向过拟合信号n_estimators500超过500边际收益低优先调其他参数训练集F1接近1但验证集明显低max_depth12特征多可以到16数据少保持8深度越大训练集分数越高验证集不涨min_samples_leaf5数据量大可降到3噪声大提到10叶子太小方差大特征重要性不稳定class_weightbalanced恶意占比低于10%时使用没有明显过拟合但正样本召回偏低训练结束第一件事不是看准确率而是把所有特征的值域和缺失率打出来看一眼。我见过太多训练脚本跑完才发现某列特征因为解析失败全为0模型等于在一个假特征上做了无用功。4.3 评估指标accuracy是最有欺骗性的数字URL检测场景里正负样本比例天然悬殊。即使训练集做了平衡线上恶意URL的真实占比可能只有千分之一。模型把所有URL都判为良性准确率也有99.9%但这个模型毫无价值。所以评估的焦点要放在恶意URL的召回率、以及这个召回率水平下的误报率上。我一般会盯三个数字恶意URL的召回率、白样本的误报率、以及F1。召回率低恶意URL漏过去安全事件升级误报率高用户被拦了正常链接投诉潮会淹了运营群。这两个数字此消彼长用F1做综合指标还不够最好直接画出P-R曲线然后根据业务承受力选一个工作点。评估粒度也要注意。URL级的评估往往虚高因为攻击者会用同一套基础设施生成成千上万个变体URL你模型只要认识其中一条剩下的全都能判对。所以除了URL级指标我还习惯按注册域做一次聚合评估一个域名只要有一条恶意URL被放行这个域名就算漏报。域名级的指标才是安全运营真正关心的。还有一个容易忽略的验证方式是跨时间验证。训练集用三个月前的数据测试集用最近一周的数据中间留一段空白期。这样做能模拟模型上线后的真实体验比随机切分靠谱得多。如果跨时间验证的F1比随机切分低一大截说明模型已经记住了训练集里的时间模式而不是真正学会了区分恶意和良性。5. 恶意URL检测翻车点排查五条血泪经验5.1 同源样本泄漏测试集99分线上直接崩训练跑完测试集AUC接近0.99大家都觉得稳了结果内测的时候拿最新一周的钓鱼URL一测召回率不到一半。这种翻车我见过不止一次原因几乎都是同源的。现象背后是数据切分方式的问题。如果直接用train_test_split做随机切分攻击者同一域名下的几十个变体URL会被随机分到训练集和测试集两边。模型在训练集里见过这个域名的模式测试集里的变体自然会被正确分类——看起来是泛化能力其实是记住了域名。解决方法是前面代码里已经用过的GroupShuffleSplit按注册域分组切分。更严格的做法是按基础设施分组把同一注册商、同一DNS IP的域名也归到一组但这种分组依赖外部数据成本较高按注册域分组是性价比最高的第一道防线。5.2 解析器静默失败训练集里藏着一堆全零特征检查特征分布时发现is_ip_host这一列99%都是0而恶意样本里有大量IP直连的URL。最后定位到问题出在urlparse的行为上部分恶意URL带端口号或者畸形格式hostname解析结果为空后面的正则匹配全部落空特征静默置零。这类问题最坑的地方在于它不报错。如果脚本报异常你还有机会去处理它只是返回空值整行特征变成统一的值模型稀里糊涂地学完你完全感知不到。解决方法是写一个统一的解析包装函数无论解析成功还是失败都返回一个结构化结果解析失败时把parse_error标志位设为1同时把其他特征置为一个明确的值而不是0。保留失败模式本身作为特征至少模型能学到「这条URL格式不正常」这个信号。另外对包含中文域名或Unicode字符的URL先做punycode编码再进特征提取否则正则匹配行为不可预期。5.3 外部特征取不到训练时特征齐全线上全是缺失训练数据的构建是离线的whois信息、DNS解析结果在离线环境里都能查到特征表长得漂亮模型分数也好看。但线上打分环境一跑whois查询超时、DNS解析被内网策略拦掉那几个外部特征字段全是空值模型输出的置信度立刻失真。这个问题的本质是「训练分布和推理分布不一致」。解决的优先级我一般是这样的外部特征尽量不进主模型放在后置过滤环节如果必须进就在训练时人为注入缺失——随机把20%到30%样本的外部特征置为缺失值让模型能够学习「特征缺失时的应对策略」。上线后还要加一个监控统计外部特征的实际缺失率如果发现缺失率持续高于训练时的比例触发告警而不是让模型裸奔。5.4 指标对不上复现结果和项目说明差了一大截源码包的项目说明里写了AUC 0.97你按说明跑出来只有0.91第一反应是代码是不是漏了。其实大概率不是代码问题而是样本版本、切分种子或者正负样本比例对不上。恶意URL检测这个方向非常「数据敏感」同样的代码样本差一周指标就能差三五个点。复现者拿到源码时如果原始数据时间窗不同或者切分随机种子的版本不同结果对不上是常态。解决方式是交付时把可复现性做到位原始样本记录采集日期、来源源、标签版本训练脚本固定random_state并把数据集的哈希指纹写入项目说明。别人复现的目标不该是「比你跑得高」而是「能跑出跟你一致的基线」。能做到这一点项目说明才算真正合格。5.5 把URL当普通文本做词袋解释不了也提不了有一版方案我试过直接对URL做字符级TF-IDF然后丢给逻辑回归效果比随机构造的特征差不少而且完全无法解释哪条URL为什么被判恶意。词袋模型把URL的结构信息全部打平login出现在host里还是path里、域名是IP还是字母这些关键信号全部丢失。正确的做法是把手工构造的词法、结构、统计特征作为主体词袋可以作为一个补充视角——用字符级n-gram而不是词级n-gram因为URL的「单词」不是自然语言的词。即便要用向量化手段也优先考虑基于域名片段训练的轻量字符embedding而不是拿通用文本预训练模型硬套。这类模型的词汇表里根本没有URL形态的token嵌入向量都是随机初始化。6. 模型上线前验证特征消融、冷启动测试与规则兜底6.1 上线前的两个针对性测试模型训练完我会先加做两个测试这两个测试比调参更能反映真实上线表现。第一个是特征消融测试。保留全特征F1作为基线然后每次去掉一组特征——词法、结构、统计、外部情报——重新训练并记录F1变化。哪组特征去掉后F1掉得最多那组就是模型的核心信号哪组特征去掉后F1反而上涨说明那组是噪声可以考虑在下一版里直接删掉。这个测试还有一个额外的收获它能告诉你这个模型的解释方向。如果去掉结构特征后召回率骤降那你面对运营质疑时能明确说出「这个判定主要依靠域名结构异常」这句话。第二个是冷启动测试。用三个月前的标注数据做训练集用今天新抓取的URL做测试集中间留一段空白期。如果冷启动的F1比随机切分的F1低很多说明模型有严重的时效性依赖上线后需要高频重训如果差距不大说明模型学到了稳定的判别模式可以使用低频更新策略。这个测试结果直接决定你后续的运维成本。6.2 规则兜底与误报回流让模型只做它擅长的事最后说一个习惯生产环境里我很少让模型做唯一裁决。恶意URL的攻击模式变化太快新的手法出现时标注数据还没有跟上模型必然有一段「失明期」。弥补的办法是保留一个轻量规则层命中「IP直连加非标准端口」「敏感路径词加异常host结构」等强信号时直接进人工审核池模型分数只用来排优先级。同时要建立误报回流机制。运营反馈某条URL被误判这条样本要带着当时的特征快照回流到训练集参与下一轮重训。安全模型和业务模型不太一样误报样本往往是性价比极高的训练信号因为它们代表了模型判定边界附近最容易被混淆的样本。有一阵子我只看AUC觉得模型达到0.98就足够强了直到一次内测时发现一个正常登录页面被批量拦截用户侧的反馈直接把运营群炸了我才意识到指标和真实体感之间存在多大距离。现在每次训练前我都会先问三个问题样本的时间窗是什么切分方式会不会泄漏特征在线上能不能稳定拿到这三个问题比任何调参技巧都值钱。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。