简介一项面向计算机相关专业毕业设计的URL恶意性检测完整项目基于开源URL字符串特征进行机器学习分类适合本科毕设、课程设计或入门进阶。压缩包共24个文件包含11个Python源码特征提取、数据划分、模型训练与预测等、8个CSV实验数据含钓鱼样本与热门域名等、2个TXT词表/名单、2张统计图及1份README说明整体约48.41MB结构清晰便于直接运行与二次开发。已有266人学习下载代码均测试运行成功可直接复现从URL字符串特征提取到sklearn机器学习分类的完整流程。配套文档与实验数据齐全便于理解恶意URL检测的设计思路、特征工程细节及实验结果也可作为课程设计、毕业答辩或项目初期演示的参考。1. 字符串特征就能识破恶意 URL这个毕设选题的底层逻辑做恶意 URL 检测很多人的第一反应是“得把页面下载下来看内容”。但本科毕设的体量撑不起这套重方案你得处理反爬、渲染 JS、维护代理池最后模型还没训练光数据清洗就把时间耗完了。而基于开源 URL 数据字符串特征的恶意性检测走的是一条轻量路线——不访问页面、不解析 HTML只看 URL 这个字符串本身的长相、结构和统计规律。攻击者的 URL 和正常 URL 在字符串层面存在可量化的差异恶意链接更常出现随机域名、超长路径、多次重定向、特殊编码字符。这个差异足够训练一个分类器也足够撑起一篇能答辩、能演示、能写清楚创新点的本科毕业设计。它解决的痛点很具体在流量入口处做第一道快速过滤把明显可疑的请求挡在业务系统之前而不是等用户点进去再追责。适合的读者是正在选毕设方向、或者想用最少资源做出一个完整安全检测系统的同学。2. 特征设计先行URL 字符串里到底藏着哪些恶意线索2.1 词法特征长度、层级与特殊字符背后的攻击规律URL 本身是结构化字符串协议、域名、路径、参数被分隔符切开。恶意 URL 在这些结构上经常表现出可观测的异常。我一般会把词法特征分成三组来提取。第一组是基础结构特征。包括 URL 总长度、域名长度、路径深度按/切分后的段数、参数数量、是否使用 IP 直连而非域名。攻击者用 IP 直连是为了省去注册域名的成本这在正常业务里比例很低。总长度和路径深度也很有区分度钓鱼页面为了模拟真实站点往往在路径里堆叠大量关键词而短链接服务又走向另一个极端——路径极短、域名随机。from urllib.parse import urlparse def extract_lexical_features(url: str) - dict: parsed urlparse(url) path_segments [s for s in parsed.path.split(/) if s] features { url_length: len(url), domain_length: len(parsed.netloc), path_depth: len(path_segments), num_params: len(parsed.query.split()) if parsed.query else 0, has_ip: 1 if parsed.hostname and parsed.hostname.replace(., ).isdigit() else 0, has_port: 1 if parsed.port else 0, path_avg_seg_len: sum(len(s) for s in path_segments) / max(len(path_segments), 1), } return features这里用urlparse做结构拆分避免自己写正则去匹配 URL。注意has_ip的判断replace(., )之后如果全是数字说明 hostname 本身就是 IPv4 地址。path_avg_seg_len用来捕捉路径里塞长串无意义字符的情况这类字符串常见于混淆后的恶意跳转链接。参数数量这个特征对跟踪型恶意链接有效因为攻击者要在参数里塞 extra 信息来源、点击 ID 等而正常业务链接的参数数量相对稳定。第二组是特殊字符统计。恶意 URL 里、%、-、.的分布和正常 URL 差异明显。出现在 URL 中间是经典的欺骗手法——浏览器会忽略前面的部分直接访问后面的域名。%是 URL 编码的标志恶意链接经常用编码绕过过滤规则。import re from collections import Counter def extract_char_features(url: str) - dict: char_counts Counter(url) suspicious_ratio 0.0 for ch in [%, , -, _, ., , ?]: suspicious_ratio char_counts.get(ch, 0) return { num_at: char_counts.get(, 0), num_percent: char_counts.get(%, 0), num_hyphen: char_counts.get(-, 0), num_digits_ratio: sum(c.isdigit() for c in url) / max(len(url), 1), num_letters_ratio: sum(c.isalpha() for c in url) / max(len(url), 1), suspicious_char_ratio: suspicious_ratio / max(len(url), 1), has_multiple_dots: 1 if url.count(.) 3 else 0, }num_digits_ratio和num_letters_ratio这两个比例特征非常有用。正常业务域名多是字母为主而自动化生成的恶意域名经常是数字和字母混杂。has_multiple_dots针对的是用多级子域名伪装的情况比如www.paypal.com.secure-login.xyz这里出现了 4 个点明显违反正常域名的书写习惯。suspicious_char_ratio是一个归一化的聚合特征把特殊字符出现频率压成一个值方便后续直接喂给模型。第三组是敏感关键词命中。这里用开源词表做匹配不需要自己从零造词库。从公开的钓鱼 URL 数据集中提取出现频率最高的品牌名、登录相关词、支付相关词做成一个白名单式的敏感词集合。sensitive_words [ login, signin, verify, account, secure, update, confirm, wallet, bank, paypal, apple, microsoft, weixin, alipay, free, bonus, win, prize ] def extract_word_features(url: str) - dict: url_lower url.lower() hit_count 0 hit_words [] for word in sensitive_words: if word in url_lower: hit_count 1 hit_words.append(word) return { sensitive_hit_count: hit_count, sensitive_hit_density: hit_count / max(len(url), 1), has_sensitive_pay: 1 if any(w in url_lower for w in [paypal, wallet, bank, alipay]) else 0, hit_words: |.join(hit_words[:5]), }敏感词命中不能单独作为判定依据——正常的登录页面也会带login和signin。但这个特征和前面两组特征组合起来效果显著正常 URL 里敏感词通常出现在域名主体而恶意 URL 里敏感词经常堆在路径或参数里。has_sensitive_pay单独作为一个布尔特征是因为支付类关键词在钓鱼链接中的区分度远高于通用登录词。2.2 统计特征与信息熵把随机性变成数值字符串层面的统计特征最核心的是信息熵。正常 URL 的域名和路径有语义字符分布不均匀熵值相对低而恶意 URL 尤其是自动化生成的域名字符接近均匀分布熵值高。用香农熵公式计算 URL 串的信息熵import math from collections import Counter def shannon_entropy(text: str) - float: if not text: return 0.0 counts Counter(text) total len(text) entropy 0.0 for count in counts.values(): prob count / total entropy - prob * math.log2(prob) return entropy def extract_entropy_features(url: str) - dict: parsed urlparse(url) hostname parsed.hostname or full_entropy shannon_entropy(url) host_entropy shannon_entropy(hostname) path_entropy shannon_entropy(parsed.path) # 连续辅音字母比例DGA 域名常见特征 consonant_seq re.findall(r[bcdfghjklmnpqrstvwxyz]{4,}, hostname) max_consonant_len max((len(s) for s in consonant_seq), default0) return { full_entropy: round(full_entropy, 4), host_entropy: round(host_entropy, 4), path_entropy: round(path_entropy, 4), max_consonant_run: max_consonant_len, }熵值计算有个边界情况要注意短字符串的熵值天然偏低因为字符种类少、重复多。所以在实际使用中我会同时保留原始 URL 长度作为一个特征让模型自己学习长度和熵之间的交叉关系。max_consonant_run是 DGA域名生成算法检测里的经典特征——算法生成的域名为了凑长度经常出现长串辅音而正常英文单词里连续辅音很少超过 3 个。这个特征单独拿出来的区分度很高。2.3 把特征组装成向量标准化与特征选择特征提取完下一步是组装成模型能吃的数值向量。这一步有三个坑特征量纲不一致、稀疏特征处理、过拟合风险。import pandas as pd from sklearn.preprocessing import StandardScaler def build_feature_matrix(urls: list) - pd.DataFrame: rows [] for url in urls: row {} row.update(extract_lexical_features(url)) row.update(extract_char_features(url)) row.update(extract_word_features(url)) ent extract_entropy_features(url) row[full_entropy] ent[full_entropy] row[host_entropy] ent[host_entropy] row[path_entropy] ent[path_entropy] row[max_consonant_run] ent[max_consonant_run] # 移除字符串类型的辅助特征 row.pop(hit_words, None) rows.append(row) df pd.DataFrame(rows) # 填充可能出现的 NaN例如空域名场景 df df.fillna(0) return df # 使用示例 # df build_feature_matrix(url_list) # scaler StandardScaler() # X_scaled scaler.fit_transform(df)hit_words是字符串型辅助特征用于调试查看命中情况但模型训练时要移除否则 pandas 会把它当成类别特征或者直接报错。StandardScaler做标准化是必要的——url_length的量级是几百num_at的量级是 0 到 1如果不标准化梯度下降和距离计算都会被大数值特征主导。特征选择上我建议先用全部特征跑一次随机森林看feature_importances_把重要性接近 0 的特征剔除。通常num_letters_ratio和num_digits_ratio高度相关保留一个即可。3. 开源数据集与标注模型吃什么样的数据决定了你的上限3.1 公开可用的 URL 数据集选型与对比做恶意 URL 检测数据来源和标注质量直接决定毕设能不能收尾。目前公开可用的数据源大致有四种我按可用性和工作量排个序。数据源内容标注方式工作量PhishTank 公开导出钓鱼 URL 黑名单社区投票标注有验证状态低直接下载 CSVURLhaus API恶意 URL 实时库按攻击类型分类标注低需要请求 APIBenign 样本Alexa Top 1M正常网站域名按排名粗略认定为正常中需要拼接完整 URLMalware-Traffic 公开 pcap恶意流量中的 URL从流量里提取高需要解析 pcap常见做法是 PhishTank 导出恶意样本 Alexa Top 域名构造正常样本两者做 1:1 合并。PhishTank 的 CSV 里有url字段和valid字段valid标记这条记录是否仍然有效——无效记录可能是误报或者已经下线的域名建议只用valid1的数据。Alexa Top 域名本身只有域名没有完整 URL需要拼接协议和路径一般拼成https:// 域名 /即可因为检测的是字符串特征页面是否存在不影响。import csv import random def load_and_merge_samples(phish_file: str, benign_domains: list, output_file: str): malicious [] with open(phish_file, r, encodingutf-8, errorsignore) as f: reader csv.DictReader(f) for row in reader: if row.get(valid) 1 and row.get(url): malicious.append(row[url].strip()) benign [] for domain in random.sample(benign_domains, min(len(malicious), len(benign_domains))): benign.append(fhttps://{domain}/) random.shuffle(benign) random.shuffle(malicious) return malicious[:5000], benign[:5000]合并样本时有两个细节。第一是去重——PhishTank 里同一个钓鱼 URL 可能被多个用户提交用set()去重后再切分。第二是比例控制——不建议用 1:9 甚至更极端的比例做训练虽然真实场景恶意样本比例很低但严重的样本不均衡会让模型倾向于把所有样本都判成正常因为这样准确率也能到 90%。入门阶段先做 1:1 的均衡分类把 F1 分数做上去答辩时再说明为什么不均衡采样会导致模型退化。3.2 数据清洗URL 规范化与去噪的四个操作开源数据集的原始样本非常脏不洗的话特征提取出来的都是噪声。我总结的清洗流程四步走。from urllib.parse import urlparse, unquote import re def clean_url(raw_url: str) - str or None: # 1. 去除首尾空白和引号 url raw_url.strip().strip().strip() if not url: return None # 2. 统一协议为小写 url re.sub(r^[a-zA-Z][a-zA-Z0-9.-]*://, lambda m: m.group(0).lower(), url) # 3. 对路径和参数做一次 URL 解码保留域名不解码 parsed urlparse(url) try: decoded_path unquote(parsed.path) decoded_query unquote(parsed.query) except Exception: return None # 4. 过滤明显无效的样本 if not parsed.hostname: return None if len(url) 10 or len(url) 2048: return None rebuilt f{parsed.scheme}://{parsed.netloc}{decoded_path} if decoded_query: rebuilt f?{decoded_query} return rebuiltunquote是最关键的一步——把%20、%3A这类编码还原成原始字符。如果不解码特征提取时%的计数会虚高且https%3A%2F%2F这种编码串会把域名特征完全打乱。但注意parsed.netloc不解码因为域名里的编码字符极少解码反而可能破坏正常域名的格式。长度过滤 2048 是因为一些超长日志里截断了 URL截断的字符串有大量重复后缀会影响熵值计算的真实性。3.3 数据集打散与交叉验证别让你的模型记住顺序清洗完的样本需要打散而且必须按照「同一域名前缀的样本不能同时出现在训练集和测试集」的原则切分。这个约束很多人会忽略PhishTank 里同一个攻击团伙的钓鱼域名经常挂在同一个注册商下域名结构高度相似。如果不按域名去重直接随机切分模型学到的可能是“记住这种风格的域名”而不是“理解恶意 URL 的通用特征”。from sklearn.model_selection import GroupShuffleSplit def extract_domain_group(url: str) - str: return urlparse(url).netloc # 按域名分组的训练/测试切分 gss GroupShuffleSplit(n_splits1, test_size0.2, random_state42) groups [extract_domain_group(u) for u in all_urls] train_idx, test_idx next(gss.split(all_urls, labels, groupsgroups))GroupShuffleSplit保证同一个netloc下的所有 URL 只会出现在同一侧这能防止模型利用“同一个域名重复出现”的捷径。做完分组切分后建议再验证一下训练集和测试集的类别比例确保两边的正负样本比例接近否则测试集的评估分数会失真。4. 检测模型选型与训练从基线模型到能答辩的性能指标4.1 为什么先用机器学习而不是深度学习本科毕设做 URL 检测模型选型上我强烈建议先从机器学习模型入手。逻辑很简单你的特征是手工设计的 20 个左右数值特征这个数据规模用随机森林或梯度提升树就能拟合得很好没必要上 LSTM 或 Transformer。深度学习确实能自动学习特征但你需要海量原始 URL 文本做词嵌入训练计算资源和调试时间对毕设来说不划算。常见做法是先跑逻辑回归建立基线再跑随机森林看上限。逻辑回归的系数可以直接解释每个特征的方向性——比如num_percent的系数为正说明编码字符越多越可能是恶意——这个解释性在毕业答辩时非常好用。随机森林则能捕捉特征间的非线性交互比如“短 URL 高熵 敏感词命中”这种组合判断。from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report def train_rf_model(X_train, y_train, X_test, y_test): model RandomForestClassifier( n_estimators200, max_depth10, min_samples_leaf5, class_weightbalanced, random_state42, n_jobs-1 ) model.fit(X_train, y_train) y_pred model.predict(X_test) print(classification_report(y_test, y_pred, target_names[benign, malicious])) # 输出特征重要性 importances sorted(zip(model.feature_names_in_, model.feature_importances_), keylambda x: x[1], reverseTrue) for name, imp in importances[:10]: print(f{name}: {imp:.4f}) return model随机森林的参数设置有几个讲究。max_depth10限制单棵树深度防止过拟合min_samples_leaf5强制叶子节点至少 5 个样本减少极端分支class_weightbalanced在样本不均衡时自动调整权重。n_estimators不需要太大200 棵树在 5000 条样本上已经足够稳定再大只会增加训练时间而收益极小。n_jobs-1让所有 CPU 核心并行训练。训练完先看classification_report里的 precision 和 recall 是否均衡——如果 precision 高但 recall 低说明模型保守只敢判那些明显特征极端的样本反过来则误报率高。答辩时这两个指标比 accuracy 重要得多。4.2 阈值调优从概率输出到可配置的判定策略随机森林的predict默认用 0.5 作为判定阈值。但真实场景中你可能需要不同的误报容忍度。比如在安全网关的场景里宁可多拦几个正常链接也不放跑恶意链接阈值就该往 0.3 调而在用户侧弹窗提醒的场景里误报会打扰用户阈值该往 0.7 调。import numpy as np from sklearn.metrics import precision_recall_curve def tune_threshold(model, X_val, y_val): y_proba model.predict_proba(X_val)[:, 1] precisions, recalls, thresholds precision_recall_curve(y_val, y_proba) # 找 F1 最大的阈值 f1_scores 2 * precisions * recalls / np.maximum(precisions recalls, 1e-10) best_idx np.argmax(f1_scores[:-1]) best_threshold thresholds[best_idx] print(fBest F1 threshold: {best_threshold:.3f}) print(fPrecision at threshold: {precisions[best_idx]:.3f}) print(fRecall at threshold: {recalls[best_idx]:.3f}) # 也可以找误报率最低且召回率超过 90% 的阈值 for i, (p, r) in enumerate(zip(precisions, recalls)): if r 0.9 and p 0.9: print(fPractical threshold: {thresholds[i]:.3f}) break return best_thresholdprecision_recall_curve返回的thresholds长度比precisions少一个因为最后一个点是纯正例的极端情况没有对应阈值代码里用precisions[:-1]对齐。调阈值这个步骤对毕设来说是加分项——它展示了你不只是会调包而是理解判定边界和业务需求之间的关系。答辩被问到“你的模型误报时怎么办”你就可以直接说阈值可调并现场演示。4.3 集成一个轻量的在线判定模块训练完模型后需要写一个能接收单个 URL、返回判定结果的在线推理模块。这个模块是毕设系统的核心注意不要用DataFrame去接单个 URL——build_feature_matrix([url])这种方式有 DataFrame 创建的固定开销虽然单个请求感知不到但在性能测试时会被问到。import joblib class MaliciousURLDetector: def __init__(self, model_path: str, scaler_path: str, threshold: float 0.5): self.model joblib.load(model_path) self.scaler joblib.load(scaler_path) self.threshold threshold def predict(self, url: str) - dict: # 先清洗 cleaned clean_url(url) if cleaned is None: return {url: url, verdict: invalid, prob: None} # 再提取特征 features build_feature_matrix([cleaned]) scaled self.scaler.transform(features) prob self.model.predict_proba(scaled)[0][1] verdict malicious if prob self.threshold else benign return { url: cleaned, verdict: verdict, prob: round(prob, 4), threshold: self.threshold, features: features.iloc[0].to_dict(), }joblib保存和加载 scikit-learn 模型是标准做法。clean_url的结果为None时直接返回invalid这是因为清洗阶段已经识别出无域名或超长度的异常 URL这些无需再进模型。features字段返回具体特征值方便你在演示时解释“为什么这条 URL 被判恶意”——可以直接指出host_entropy4.2过高或num_at1触发了异常。这个透明度在答辩演示时很加分比一个黑匣子式的判定结果更有说服力。5. 避坑指南URL 特征检测最常见的 5 个翻车现场5.1 URL 解码不彻底导致特征漂移现象训练时验证集 F1 有 0.95但上线后测试真实 URL 经常误判而且误判的都是正常链接。原因真实流量里的 URL 有一部分带了 URL 编码%E4%B8%AD这种中文编码模型训练时数据里编码 URL 比例很低。如果特征提取时不解码num_percent会异常偏高熵值也被编码串拉高模型看到真实编码 URL 就会判恶意。解决在clean_url阶段统一调用unquote做路径和参数的解码并且训练数据里也刻意混入 5% 左右的编码 URL——可以从数据集里随机挑一些 URL 手动编码部分路径段让模型见过这种形态。我在清洗阶段吃过这个亏后来养成了「先解码再提特征」的顺序习惯。5.2 脏数据把模型学歪现象训练曲线很漂亮但测试集表现一塌糊涂尤其是 precision 特别低。原因PhishTank 这类社区标注数据集的噪声比想象中高。有些钓鱼 URL 被标注时页面已经改版成了正常内容有些提交者为了凑数量提交了大量相似的变体。如果不做去重和域名分组模型会记住这些冗余样本的模式。解决切分数据前先按netloc分组。我一般还会做一步简单的手动审计——从训练集里随机抽 50 条恶意样本、50 条正常样本打印出来看一遍确认标注明显错误的直接删掉。这步虽然原始但能避免你在答辩时被问到“你确定你的标签是真的吗”时卡壳。5.3 特征被 URL 缩短服务绕过现象模型对t.cn/abc123、bit.ly/xxxx这类短链接几乎全部判恶意误报率飙升。原因短链接域名极短、路径随机、无法从字符串看到目标 URL熵值又偏高模型自然把它们归入恶意区间。但现实中大量正常分享行为也用短链接。解决短链这种场景不该硬刚字符串特征。常见做法是在特征矩阵里加一个is_shortener字段——维护一个已知短链域名黑名单bit.ly、t.cn、goo.gl等命中后走另一条宽松判定路径或者直接跳过检测。毕设层面可以在文档里说明这个边界系统设计上预留一个「短链放行/递归解析」的模块接口。5.4 评估指标被准确率骗了现象模型准确率 0.97答辩老师问 recall 是多少答不上来。原因恶意 URL 数据集中正常样本占多数时模型全判为正常准确率也能到 90% 以上。只看 accuracy 是完全错误的评估方式。解决评估时同时打印precision、recall、F1-score和AUC。对恶意检测场景recall 的优先级高于 precision——漏掉一个恶意 URL 的成本远高于误拦一个正常 URL。我在毕设里专门做了一个评估脚本输出绘制 PR 曲线精确率-召回率曲线和 ROC 曲线答辩时直接展示曲线下面积比单个数字有说服力得多。5.5 模型文件体积过大部署时被老师质疑现象随机森林 500 棵树、每棵树深度 20模型文件 300MB答辩现场演示加载要卡好几秒。原因模型参数太多且特征矩阵里存了不必要的中间结果。hit_words这个辅助特征占内存、固定随机森林的冗余树也占空间。解决剪枝到n_estimators150, max_depth8用joblib压缩存储compress3模型通常能压到几 MB 到十几 MB。现场演示时的加载时间最好控制在 1 秒内超出这个范围会给评委留下“不工程化”的印象。6. 从能跑到能答辩验证模型有效性的三个方法最后一个环节也是最容易被忽略却决定毕设评分的一环——证明你的模型真的有效而不是碰巧在某个测试集上表现好。我自己习惯用三个方法来验证顺序按照工作量和说服力递增。第一个方法是混淆矩阵的可视化。调用sklearn.metrics里的confusion_matrix和ConfusionMatrixDisplay把测试集预测结果画成 2x2 矩阵图导出 PNG。同时标注出模型判错的样本——随机挑 10 条 false positive、10 条 false negative 打印原始 URL 和特征值逐条分析错误原因。这个过程能让你在答辩时说出「我看了所有错误样本主要误判原因是 xx」这种有底气的结论而不是一句笼统的「模型还有待优化」。第二方法是与现有开源检测工具的对比实验。找 VirusTotal 的 API 或者本地的开源检测库在同一个测试集上对比你的模型和它们的检测率。不用追求超过它们只要你的模型在延迟上有优势纯字符串检测可以做到毫秒级或对特定攻击类型如 DGA 域名、编码混淆有互补性就是你的创新点。第三个方法是特征消融实验。把特征分组——词法组、字符组、熵值组、敏感词组——然后每次去掉一组重新训练记录 F1 分数的变化幅度。如果去掉熵值特征后 F1 掉了 0.15说明熵值特征贡献巨大如果去掉某组特征分数没变甚至略微上升说明该组存在冗余。这个实验直接支撑你文档里的「特征有效性分析」章节也是答辩老师最可能追问的内容。一个值得做的进阶方向是把训练好的模型封装成 HTTP 接口用 Flask 起一个最小服务提交 URL 返回 JSON 判定结果。不用做前端页面——这会让毕设重心偏移到 Web 开发而非安全检测。接口足够演示项目结构也清晰data/存数据集features/存特征提取代码models/存训练脚本和产物docs/放说明文档。最后说一个我的习惯训练完模型后我会留出 200 条样本不参与任何训练和调优作为最终验收集。只有验收集的表现才是「真实」的因为它从未影响过模型参数的选择。每次发现自己忍不住根据验证集反复调阈值时我就提醒自己小数上的提升没有意义稳定的泛化能力才是检测系统的命脉。这套流程走完你的毕设就不只是「跑通了一个模型」而是一套经得起追问的完整检测方案。希望帮到你。本文还有配套的精品资源点击获取