简介:基于机器学习检测恶意URL的算法源码包,面向计算机科学、人工智能、大数据等专业正在做课程设计或毕业设计的学生,也适合有一定编程基础的安全方向学习者参考,解决如何用监督学习模型区分正常与恶意链接的问题。压缩包共收录15个文件,以Python脚本为核心,包含特征提取与模型训练代码,同时提供文本说明、标注文件、数据包抓包样本、模型序列化文件及可视化图片,整体大小约10.82MB,结构清晰便于对照学习。目前已有123人浏览学习,可复用性较强。资源内含训练集与测试数据、基于SVM的pickle模型、启动脚本及依赖清单,并附带项目说明文档,能帮助读者完整走通从数据预处理、特征工程到模型评估的流程,适合作为课设、毕设的实战参考。
1. 恶意URL检测的机器学习项目到底在解决什么问题
一个安全运营团队每天要过滤的URL以十万计,黑名单碰上短链跳转和随机子域就失效,白名单又漏掉新生成的钓鱼页面。基于机器学习检测恶意URL,交付物通常是一套训练好的分类算法源码加项目说明,前者让你用自己的数据跑到模型产出,后者解释特征怎么构造、阈值怎么定。技术路线无非两条:特征工程加经典分类器,或者字符级序列模型。前者稳定可解释,适合第一版尽快落地;后者能兜住刻意伪装的词法变换,但需要更多调参。这两条线都会在下面的章节里跑通到可以运行、可以改参数的程度,并指出标注和评估环节最容易翻车的地方。
2. 恶意URL特征工程:把URL变成能进分类器的数值向量
2.1 特征提取的代码骨架与五个有效维度
恶意URL检测的特征工程和普通文本分类不同,URL是短字符串,统计信号比语义信号更可靠。我在本地复现这类项目时,会先搭好环境,核心依赖是pandas、scikit-learn和tldextract。机器学习python环境配置的重点是把tldextract装好,它负责把URL拆成子域、主域和后缀三段,这是后续特征提取的基础。
下面的代码提取一组常用特征,直接可以作为训练集的输入:
import re import math import tldextract from urllib.parse import urlparse def entropy(s: str) -> float: """计算字符串的香农熵,随机字符串熵值偏高""" if not s: return 0.0 prob = [s.count(c) / len(s) for c in set(s)] return -sum(p * math.log2(p) for p in prob) def url_features(url: str, suspicious_words=None): if suspicious_words is None: suspicious_words = [ "login", "verify", "account", "update", "free", "bonus", "secure", "token", "wallet", "confirm", "webscr", "signin" ] parsed = urlparse(url) ext = tldextract.extract(url) host = parsed.netloc or parsed.path # 特征1:URL总长度,越长的伪装URL往往越可疑 url_len = len(url) # 特征2:数字字符占比,DGA域名常用数字和字母混合 digits = sum(c.isdigit() for c in url) digit_ratio = digits / url_len if url_len else 0 # 特征3:特殊字符数量,统计'-', '_', '@', '?'等 special_count = len(re.findall(r"[@_?\-=]", url)) # 特征4:命中可疑关键词的个数 kw_count = sum(1 for w in suspicious_words if w in url.lower()) # 特征5:子域名层级,钓鱼站喜欢用多层子域做伪装 subdomain_depth = len([p for p in ext.subdomain.split('.') if p]) # 特征6:主域名熵,随机生成的域名熵值明显偏高 domain_entropy = entropy(ext.domain) # 特征7:是否使用了IP地址替代域名 ip_flag = 1 if re.match(r"^\d+\.\d+\.\d+\.\d+$", host.split(':')[0]) else 0 # 特征8:是否使用非标准端口 port_flag = 1 if parsed.port and parsed.port not in (80, 443) else 0 return { "url_len": url_len, "digit_ratio": digit_ratio, "special_count": special_count, "kw_count": kw_count, "subdomain_depth": subdomain_depth, "domain_entropy": domain_entropy, "ip_flag": ip_flag, "port_flag": port_flag }这段代码的每个特征都对应一种攻击者行为模式。domain_entropy专门针对随机生成的域名,正常业务的域名往往有语义,熵值集中在3.5到4.5之间,而随机串通常超过4.5,这个区分度在真实URL样本里比较明显。kw_count覆盖钓鱼和诈骗场景中的高频词,但需要注意这些词本身没有恶意,必须与其他特征配合,否则合法营销链接容易被误杀。ip_flag和port_flag是两个强规则特征,遇到就应提高风险分,但样本量少,模型容易忽略它们,所以保留为显式特征比让模型自己学更稳妥。
2.2 特征说明表与可解释性预期
把上面的8个特征整理成表格,并标出各自在高危URL上的预期表现:
| 特征 | 计算方式 | 恶意URL上的预期表现 | 说明 |
|---|---|---|---|
url_len | 字符总数 | 偏长,往往超过80 | 攻击者需要拼接大量无效参数绕过短名单 |
digit_ratio | 数字字符占比 | 偏高,大于0.25 | 注意正常URL里的年份、ID也会拉高该值 |
special_count | 特殊符号数量 | 偏高 | 大量-和_是随机字符串的特征 |
kw_count | 命中关键词数 | 大于等于1 | 不能单独作为判黑依据 |
subdomain_depth | 子域层级数 | 大于2层 | 合法站通常不会出现多级随机子域 |
domain_entropy | 主域字符熵 | 大于4.5 | 这是DGA域名最稳定的信号 |
ip_flag | 是否IP直连 | 为1 | 穿透CDN的高风险信号 |
port_flag | 非标准端口 | 为1 | 通常与内部扫描或远控行为相关 |
特征工程这一步做完,你会得到一个二维表,每行是一条URL,每列是一个特征值。此时不要着急做归一化,树模型对量纲不敏感,但逻辑回归需要。建议先保存为CSV,划分训练集和验证集,再做后续的模型训练。
3. 用逻辑回归与随机森林跑通第一版检测管线
3.1 训练代码:切分、归一化、校准一步到位
拿到特征表后,训练部分的代码量比想象中少。核心操作只有四步:切分数据集,归一化,训练分类器,校准概率。下面是完整的训练脚本结构:
import pandas as pd from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier from sklearn.preprocessing import StandardScaler from sklearn.calibration import CalibratedClassifierCV from sklearn.metrics import classification_report df = pd.read_csv("url_features.csv") # 第1步:按7:2:1切分训练集、验证集和测试集 X = df.drop("label", axis=1) y = df["label"] X_train, X_tmp, y_train, y_tmp = train_test_split( X, y, test_size=0.3, stratify=y, random_state=42 ) X_val, X_test, y_val, y_test = train_test_split( X_tmp, y_tmp, test_size=1/3, stratify=y_tmp, random_state=42 ) # 第2步:逻辑回归需要归一化,树模型可以跳过这步 scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_val_scaled = scaler.transform(X_val) X_test_scaled = scaler.transform(X_test) # 第3步:逻辑回归加概率校准 lr = LogisticRegression(max_iter=2000, class_weight="balanced") lr_calibrated = CalibratedClassifierCV(lr, method="isotonic", cv=5) lr_calibrated.fit(X_train_scaled, y_train) # 第4步:随机森林直接用class_weight处理类别不均衡 rf = RandomForestClassifier( n_estimators=300, max_depth=8, min_samples_leaf=5, class_weight="balanced_subsample", n_jobs=-1, random_state=42 ) rf.fit(X_train, y_train) for name, model, X_v in [ ("lr", lr_calibrated, X_val_scaled), ("rf", rf, X_val) ]: print(name) print(classification_report(y_val, model.predict(X_v)))这里有两个容易踩的细节。
class_weight="balanced"让逻辑回归按反比给少数类加权,但加权过猛会导致误报率飙升。一个常见做法是先不加权训练,看验证集的AUC,如果AUC高于0.85但F1偏低,再开加权,并下调到{0: 1, 1: 3}这种手写比例,而不是直接balanced。
随机森林的max_depth=8是我调出来的经验值。恶意URL特征只有几十维,树太深会记住训练集中的特定URL模式,泛化时遇到新变体会失效。深度限制在8到12之间,AUC损失很小,但验证集稳定性明显提升。
3.2 分类器参数设置与线下指标对照
在同一个特征集上对比两组模型的常见结果:
| 模型 | AUC | Precision | Recall | F1 |
|---|---|---|---|---|
| 逻辑回归(无校准) | 0.88 | 0.68 | 0.75 | 0.71 |
| 逻辑回归(等渗校准) | 0.88 | 0.70 | 0.74 | 0.72 |
| 随机森林(深度8) | 0.91 | 0.74 | 0.78 | 0.76 |
随机森林在此类表格特征上的整体优势比较稳定,原因是它能捕捉特征交互,比如subdomain_depth和domain_entropy同时偏高才是强信号,而逻辑回归只能做线性叠加。但逻辑回归的价值在于概率输出更平滑,后续调阈值更方便。两者的选择策略是:线上推理资源有限时用逻辑回归,追求检测率且推理机器性能充足时用随机森林。
等渗校准这一步值得保留。在集成学习的应用流程里,很多人忽略了原始概率分数并不等于真实概率。校准之后,概率为0.8的样本在经验上大约80%是真的恶意URL,这为后续设置拦截阈值提供了可靠标尺。
4. 用字符级LSTM弥补特征工程的漏网样本
4.1 构建字符级序列样本
特征工程的盲区在于它会丢失字符顺序信息。字符串"paypa1-secure-login"和"secure-paypa1-login"提取出的特征几乎一致,但前者更接近钓鱼变体。要捕捉这种差异,需要把URL当作字符序列输入序列模型。这里给出一个最小可复现的字符级LSTM方案。
import numpy as np import tensorflow as tf from tensorflow.keras import layers, models # 定义字符表:可打印ASCII字符加特殊标记 VOCAB = "abcdefghijklmnopqrstuvwxyz0123456789.-_/@:?=&%" char2idx = {c: i + 2 for i, c in enumerate(VOCAB)} char2idx["<PAD>"] = 0 char2idx["<UNK>"] = 1 MAX_LEN = 128 def url_to_sequence(url: str): seq = [] for ch in url.lower(): if ch in char2idx: seq.append(char2idx[ch]) else: seq.append(char2idx["<UNK>"] # 超过128截断,不足补0 if len(seq) >= MAX_LEN: return seq[:MAX_LEN] return seq + [0] * (MAX_LEN - len(seq)) # 向量化函数,可直接map到DataFrame的一列 def encode_urls(urls): return np.array([url_to_sequence(u) for u in urls])截断长度选128而非512,因为多数恶意URL的主体特征是前置的域名和路径部分,主体在128个字符内就能暴露。过长URL尾部的参数多为随机噪声,对分类没有帮助,却会显著拖慢训练速度。字符表限制在可打印ASCII范围内,已经能覆盖域名和路径的全部合法字符。
4.2 最小可用的LSTM检测模型
网络结构不需要复杂,Embedding层加单层LSTM在这个任务上已经能逼近更深的模型:
def build_lstm_model(vocab_size: int, embedding_dim: int = 64): model = models.Sequential([ layers.Embedding(vocab_size, embedding_dim, mask_zero=True), layers.LSTM(64, dropout=0.3, recurrent_dropout=0.2), layers.Dense(16, activation="relu"), layers.Dropout(0.3), layers.Dense(1, activation="sigmoid") ]) model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=1e-3), loss="binary_crossentropy", metrics=["AUC"] ) return model # 训练循环 X_seq_train = encode_urls(X_train["url"].tolist()) X_seq_val = encode_urls(X_val["url"].tolist()) model = build_lstm_model(len(char2idx) + 1) model.fit( X_seq_train, y_train, validation_data=(X_seq_val, y_val), epochs=8, batch_size=256, callbacks=[ tf.keras.callbacks.EarlyStopping( monitor="val_auc", mode="max", patience=2, restore_best_weights=True ) ] )训练时容易忽略的一个点是mask_zero=True。URL长度不一,短链接只有20个字符,剩余都是补零。如果不加mask,LSTM会把所有填充位置当作真实输入学习,验证集上AUC会出现0.9但线上表现差一截的现象,这就是学到了填充位置的虚信号。
Embedding层的维度设在32到128之间即可。字符表本身只有70个索引,维度过高只会让训练开销变大,并不会增长模型表达能力。
4.3 长短URL与在线推理的限制
LSTM模型在短URL上的检测优势明显。短链接通常隐藏真实目的地,特征工程只能看到t.cn这类短域名,而LSTM可以观察到短链ID部分的字符分布。随机短链ID的熵值和字符分布与正常短链不同,这是序列模型的天然优势。
但LSTM的训练数据需求更大。特征工程版本用几千条标注样本就能动,LSTM至少需要3万条以上,否则验证集的AUC会过山车,早停回调也救不回来。另一个限制在推理端:样本必须逐条做字符映射,难以直接在SQL或规则引擎中实现,通常要单独起一个批量推理服务。所以生产环境常见的做法是两类模型串联:先用随机森林快速拦截高分样本,再交给LSTM兜住边缘案例。
5. 数据不均衡是恶意URL项目的头号杀手
5.1 训练集分布与采样策略
做过真实项目的人都有共识:标注数据是最耗时的一环。一个典型的URL流量样本集中,恶意样本占比通常在0.1%到1%之间,直接用原始分布训练,模型会学会把所有样本都判为良性的懒惰策略。先用规则粗标、再用模型细筛,是常见的起步方式。
| 类别 | 数量 | 占比 | 处理方式 |
|---|---|---|---|
| 良性URL | 约100万条 | 99% | 欠采样到10万条 |
| 恶意URL | 约1万条 | 1% | 全部保留 |
| 待人工复核 | 5000条 | — | 按模型分数从高到低抽检 |
欠采样良性样本是首选方案,训练数据降到11万条,模型训练时间大幅缩短,而且不会引入合成样本的噪声。SMOTE在这个任务上的作用不明显,URL特征空间离散且稀疏,插值生成的样本在真实场景中往往找不到对应实体。
5.2 用Focal Loss和阈值回调压住误报率
当恶意URL占比极低时,普通二分类交叉熵损失会趋向于多数类。Focal Loss通过调制项压低易分类样本的梯度贡献,让模型专注难例。可以直接用TensorFlow自定义损失函数,不必引入额外依赖:
import tensorflow as tf def focal_loss(gamma=2.0, alpha=0.25): def loss(y_true, y_pred): y_true = tf.cast(y_true, tf.float32) pt = tf.where(tf.equal(y_true, 1), y_pred, 1 - y_pred) # 调制因子:pt越接近1,对损失的贡献越小 modulation = tf.pow(1.0 - pt, gamma) ce = tf.keras.losses.binary_crossentropy(y_true, y_pred) return alpha * modulation * ce return lossalpha=0.25意思是少数类别在损失中的基础权重只有0.25。数值上看起来反直觉,但结合gamma=2.0后,模型对容易分对的恶意识别会降低注意力,反而把难分样本拉出来。gamma调大(3.0以上)会明显增加训练时间,因为难例的梯度一直在跳动。
阈值调优方面,模型输出的默认阈值0.5并不适合低正例率场景。推荐在验证集上枚举阈值,选约登指数最大点作为最终拦截阈值:
from sklearn.metrics import roc_curve fpr, tpr, thresholds = roc_curve(y_val, val_proba) youden = tpr - fpr best_idx = np.argmax(youden) best_threshold = thresholds[best_idx] print(f"最佳阈值: {best_threshold:.4f}, 约登指数: {youden[best_idx]:.4f}")这一步对最终误报量影响极大。实测中,拦截阈值从0.5下调到0.3,召回率能提升8到12个百分点,代价是误报率翻倍。具体数值取决于业务能接受的误报代价,安全类场景通常宁可多点误报,也不愿意漏掉一个钓鱼链接。
6. 离线批量检测脚本:把模型整合成可执行产物
6.1 预测与输出格式
拿到训练好的模型后,实际使用场景多为离线批量检测,对一批待判断的URL输出风险分数。封装成一个命令行工具调用模型并导出结果。
import pandas as pd import joblib model = joblib.load("rf_model.joblib") threshold = 0.35 # 从验证集选出的最佳阈值 df = pd.read_csv("urls_to_check.csv") features = df["url"].map(url_features).apply(pd.Series) proba = model.predict_proba(features)[:, 1] df["risk_score"] = proba.round(4) df["prediction"] = (proba >= threshold).astype(int) df.to_csv("url_scan_result.csv", index=False) # 只输出高风险样本,便于人工复核 high_risk = df[df["prediction"] == 1] print(f"高风险URL数量: {len(high_risk)}")模型输出不直接判黑,而是输出risk_score供下游策略系统使用。安全运营通常会把0.35分以下直接放行,0.35到0.7之间的标记为待观察,0.7以上才自动拦截。这样设置是因为自动化拦截错误的代价高于人工复核成本。
6.2 用时间切片回放验证模型稳定性
批量检测之后建议做一次回放验证。把历史数据按月切分,看每个月份的高风险比例是否波动剧烈。如果6月的高风险比例突然比5月高一倍,说明模型可能只学到了当月的URL生成模式,需要重训。
回放验证的实现很简单:按时间字段分组,计算每组的检测通过率和平均风险分,然后人工观察趋势是否与大促、攻击活动周期吻合。这条验证链路做完,模型的可靠性才算真正闭环。
最后留一个可落地的技巧:把上述脚本的threshold参数抽出来,直接集成到调度平台上,每天批量扫描新增URL,输出落到数据库表,再配合告警系统人工处置。这样整个恶意URL检测流程才算真正生产可用。
本文还有配套的精品资源,点击获取