简介:面向网络安全与机器学习交叉领域的学习者,这是一个以Python实现的钓鱼网站与邮件识别模型构建项目,重点应对仿冒网页和恶意邮件的威胁识别,覆盖从数据准备到模型训练的完整流程。压缩包内共14个文件,包含10个.py脚本和4个.csv数据文件,整体仅339KB,结构紧凑。脚本覆盖了数据预处理、Word2Vec词向量构建、TextCNN、LSTM_CNN、CNN_LSTM及simpleNN等多种模型训练流程,可供对比不同架构在文本分类任务中的效果;CSV文件则提供了可直接用于训练和测试的样本集。通过阅读代码,可以系统理解特征工程、交叉验证、超参数调优以及样本不平衡处理(如过采样、SMOTE)等关键环节,并能获得一套可扩展的模型训练框架,便于针对真实场景二次开发。目前该资源已有241人学习,项目实战性强,适合需要快速上手钓鱼威胁检测模型的算法工程师或学生参考。
1. 识别钓鱼网站和钓鱼邮件:为什么机器学习模型要从样本和特征讲起
识别钓鱼网站和钓鱼邮件,本质上是训练一个机器学习模型,对 URL 和邮件文本做二分类。这个方向看起来入门门槛低,很多团队一上来就套深度学习,结果反而不如把 URL 静态特征和邮件文本特征做扎实的随机森林分类器效果好。拿到标题里这个 .zip 项目之后,最先要搞清楚的不是模型结构,而是样本从哪来、特征怎么对齐、告警阈值怎么定。
这篇笔记面向安全工程师、运维人员和刚接触威胁检测的开发者,目标是讲清楚一套完整落地路径:怎么拿到干净样本、怎么构造特征、怎么训练基线模型、怎么把模型接进现有告警流程。先说结论:钓鱼识别模型本身不复杂,复杂的是特征口径一致性,以及样本的时间有效性。后面每一章都围绕这两个点展开。
2. 数据与标签:从零构建钓鱼样本集的可行路径与三类公开来源
打开 .zip 之后,先别急着跑训练代码。一个识别钓鱼网站和钓鱼邮件的机器学习项目,数据质量决定模型上限,特征工程决定下限。市面上很多项目源码本身没问题,翻车都翻在样本集上:标签错、比例失衡、样本太旧。这一章把构建样本集的完整路径拆开讲。
2.1 URL 和邮件两类样本的特征格式差异
钓鱼网站样本和钓鱼邮件样本,表面上都是“判断恶意”,特征格式却完全不同。钓鱼网站的输入通常是一条 URL,短文本、强结构化,能从域名、路径、参数里提取大量统计特征。钓鱼邮件的输入是 MIME 格式的原文,长文本、半结构化,除了正文内容还要解析邮件头、附件类型、路由信息。
在实践中我不建议把两类样本硬塞进同一个模型。URL 分类器和邮件分类器分开训练,是常见做法;邮件里的链接可以先抽出来交给 URL 模型二次判断,两个模型各管一段。混在一起训练的问题在于特征空间不匹配:URL 的“域名点数”和邮件的“正文 TF-IDF 词向量”拼到一个向量里,很多树模型虽然能处理,但特征重要度会被分散,调参和解释都变难。
数据字段上也对不上。URL 样本一条记录就是协议、域名、路径、参数;邮件样本一条记录则是 subject、from、reply-to、正文、附件列表、原始头。feature 表结构不一致,训练脚本就得写两套。另外,钓鱼 URL 平均存活时间非常短,几个小时到几天就会更换域名,而钓鱼邮件的模板往往复用周期更长;这也意味着两类模型的重训节奏不一样,后文模型更新机制里会展开说。
2.2 自建样本集的流程与正负样本配比
样本来源有三类:公开威胁情报源的恶意 URL 列表、主动探测或蜜罐邮箱收到的钓鱼样本、内部邮件网关脱敏后的正常邮件与正常流量日志。其中第二类最接近真实攻击,但量少;第三类量大但需要严格脱敏。钓鱼样本和正常样本的比例,我一般控制在 1:5 到 1:10,通过下采样正常样本来实现。
完整流程是:收集原始数据 → 清洗解析 → 字段脱敏 → 去重(同一域名变体算一条)→ 人工/规则标注 → 按时间或按域切分训练测试集。整个过程能用脚本自动化,不需要全部人工。关键词是“按时间切分”:钓鱼样本必须按时间顺序切分,用旧样本训练、新样本测试,才能评估模型对未知域名是否有效。如果随机切分,同一个攻击域名的多个变体会同时出现在训练集和测试集里,评估指标虚高。
还要注意标签噪声。公开 URL 库的标签是社区举报生成的,本身存在误报;邮件样本里“营销邮件”和“钓鱼邮件”的边界在业务侧经常模糊。面对无法确定的标签,我的处理方式是直接丢弃而不是强行打标,让模型学干净边界。正负样本配比表如下:
| 样本类型 | 典型来源 | 规模量级 | 备注 |
|---|---|---|---|
| 恶意 URL | 公开威胁情报导出 | 中等 | 时效性强,需去重 |
| 恶意邮件 | 蜜罐邮箱、历史攻击告警 | 较少 | 最贴近真实攻击 |
| 正常 URL | 业务网关访问日志 | 大 | 需强烈脱敏 |
| 正常邮件 | 邮件网关历史流量 | 大 | 注意隐私合规 |
2.3 特征怎么设计:从 URL 静态特征到邮件文本特征
URL 侧的特征是纯静态的,不需要访问目标站点。常见特征包括:URL 长度、域名长度、数字字符占比、点号数量、特殊符号(@、-、_)出现次数、可疑关键词(login、verify、account、update、signin、secure、click)命中数、域名字符熵、路径层数。之所以这些特征有效,是因为攻击者为了降低成本,倾向于使用短域名、随机数字串、伪装成登录页的长路径,这些模式在正常业务 URL 里很少出现。
域名字符熵是一个容易被忽略的特征。正常业务域名是品牌词拼音或缩写,字符分布有规律;钓鱼域名则大量使用随机字符串,熵值显著偏高。如果域名部分是纯 IP 字面量,也要重点标记,正常业务极少直接用 IP 对外提供服务。
邮件侧的特征分三层:头字段层、正文层、附件与链接层。头字段层看 from 域与 reply-to 域是否一致、subject 是否包含紧急/通知类词;正文层用 TF-IDF 做文本向量化,保留 n-gram 可以抓到“您的账户已被限制”这类高频套路;附件与链接层记录附件类型、可执行文件的个数、正文内 URL 数量。对钓鱼邮件来说,文本刻意短,词表高度集中,TF-IDF 已经足够,不需要上词向量,后者训练慢而且在小样本上不稳定。
3. 训练一个可复现的基线模型:特征提取代码、随机森林参数与评估口径
进入代码环节。整个压缩包里最值钱的部分不是训练入口,而是特征提取函数。模型权重丢了可以重训,特征提取逻辑不一致会导致线上全部预测失效。下面按特征提取、训练、参数说明三段组织,可以直接抄。
3.1 特征提取函数:URL 结构化特征与邮件文本特征
import re import math from urllib.parse import urlparse def extract_domain(addr: str) -> str: m = re.search(r"@([^>\s]+)", addr or "") return m.group(1).lower() if m else "" def url_features(url: str) -> dict: url = (url or "").strip().lower() parsed = urlparse(url) netloc = parsed.netloc length = len(url) digit_ratio = sum(ch.isdigit() for ch in url) / max(length, 1) special_ratio = sum(ch in "@-_!*&" for ch in url) / max(length, 1) dot_count = netloc.count(".") suspicious_words = sum(1 for kw in ["login", "verify", "account", "secure", "update", "signin"] if kw in url) entropy = 0.0 for ch in set(netloc): p = netloc.count(ch) / max(len(netloc), 1) entropy -= p * math.log2(p) has_ip = int(bool(re.match(r"\d+\.\d+\.\d+\.\d+", netloc))) return dict(url_len=length, digit_ratio=round(digit_ratio, 4), special_ratio=round(special_ratio, 4), dot_count=dot_count, suspicious_words=suspicious_words, domain_entropy=round(entropy, 4), has_ip=has_ip) def email_text_features(raw: bytes) -> dict: from email import policy from email.parser import BytesParser msg = BytesParser(policy=policy.default).parsebytes(raw) text_body = "" for part in msg.walk(): if part.get_content_type() == "text/plain": text_body += part.get_content() or "" urls = re.findall(r"https?://[^\s>)\"']+", text_body) reply_domain = extract_domain(str(msg.get("reply-to", ""))) from_domain = extract_domain(str(msg.get("from", ""))) norm_body = re.sub(r"\s+", " ", text_body).lower() return dict(subject_len=len(msg.get("subject", "") or ""), body_len=len(norm_body), url_count=len(urls), reply_mismatch=int(reply_domain != from_domain), has_attachment=int(any(part.get_content_disposition() == "attachment" for part in msg.walk())))url_features 里先做 strip 和小写,再用 urlparse 切出 netloc,字符熵只对域名部分计算,避免路径里的重复关键词拉高熵值。digit_ratio 和 special_ratio 用长度做分母,让不同长度的 URL 可比。has_ip 是强信号,命中一个基本可以直接进高风险队列。email_text_features 里用 policy.default 保留原始头信息,提取 attachment 时要遍历所有 MIME part,否则嵌套 multipart 会漏掉附件。
需要说明的是,这里 get_content() 在个别邮件编码异常时会抛异常,正式工程里要包一层 try-except,线上解析邮件不能因为一条坏数据崩溃。
3.2 训练脚本:随机森林基线、类别权重与评估指标
import joblib import pandas as pd from scipy import sparse from sklearn.ensemble import RandomForestClassifier from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics import classification_report from sklearn.model_selection import train_test_split df = pd.read_csv("samples.csv") df = df.dropna(subset=["url", "cleaned_body", "label"]) url_feat = df["url"].apply(url_features).apply(pd.Series).fillna(0) train_df, test_df = train_test_split( df, test_size=0.2, stratify=df["label"], random_state=42) X_url_train = url_feat.loc[train_df.index] X_url_test = url_feat.loc[test_df.index] tfidf = TfidfVectorizer(max_features=5000, ngram_range=(1, 2), min_df=2) X_body_train = tfidf.fit_transform(train_df["cleaned_body"]) X_body_test = tfidf.transform(test_df["cleaned_body"]) X_train = sparse.hstack([sparse.csr_matrix(X_url_train.values), X_body_train]).tocsr() X_test = sparse.hstack([sparse.csr_matrix(X_url_test.values), X_body_test]).tocsr() clf = RandomForestClassifier(n_estimators=300, max_depth=12, min_samples_leaf=4, class_weight="balanced", n_jobs=-1, random_state=42) clf.fit(X_train, train_df["label"]) print(classification_report(test_df["label"], clf.predict(X_test))) joblib.dump(clf, "phishing_model.joblib") joblib.dump(tfidf, "tfidf_vec.joblib")先构造结构化特征表,再做 train/test 切分,最后 fit TfidfVectorizer;顺序不能反。如果在切分前对整个数据集做 fit_transform,TF-IDF 会统计到测试集词频,相当于信息泄漏,测试评估会虚高。TfidfVectorizer 只对训练集 fit,测试集只 transform,保证线上推理时词表口径一致。sparse.hstack 把 URL 结构化特征和文本特征拼成稀疏矩阵,避免稠密向量内存暴涨。
分类报告要重点看宏平均 F1 而不是准确率。如果正常样本占 90%,全预测成正常就有 90% 准确率,但钓鱼一条都没拦下来。class_weight 设成 balanced,让少数类(钓鱼样本)在损失函数中获得更高权重。random_state=42 固定下来,保证每次训练结果可复现,排查问题时不至于被随机性干扰。
3.3 调参顺序:先调样本、再调特征、最后调模型
调参别一上来就 GridSearch。我的习惯顺序是:先确认样本时间分布,再检查特征重要度,最后才动模型参数。第一步,如果新样本预测效果差,先看训练集是不是都是三个月前的数据,是的话先补新样本,调参数没用。第二步,训练完打印 clf.feature_importances_,如果某个伪造强特征排在倒数,优先检查特征提取函数,而不是换模型。第三步,再调 n_estimators、max_depth、min_samples_leaf。
随机森林的三个关键参数,n_estimators 设 200 到 500 就够了,再多训练时间翻倍收益很小;max_depth 控制在 10 到 16,钓鱼特征维度不高,树太深直接过拟合到旧域名的随机符号上;min_samples_leaf 设 4 到 8,强制每个叶子至少有多个样本,减少噪声样本影响。如果换 XGBoost,需要额外调 learning_rate 和 subsample,但对特征尺度敏感度更高,作为基线没有随机森林省心。
4. 从模型到可用系统:推理脚本、告警阈值与模型更新机制
训练文件能跑通只算完成一半。模型要真正产生价值,必须接进邮件网关或者 URL 检测链路。这一章讲推理脚本怎么写、阈值怎么定、模型怎么更新。
4.1 推理脚本:输入一封新邮件返回预测结果与概率
import joblib import re from scipy import sparse def build_features(url: str, body: str) -> sparse.csr_matrix: url_feat = [url_features(url)] vec = tfidf.transform([body]) return sparse.hstack([sparse.csr_matrix(url_feat), vec]).tocsr() clf = joblib.load("models/phishing_model.joblib") tfidf = joblib.load("models/tfidf_vec.joblib") def predict_one(url: str, body: str): X = build_features(url, body) prob = clf.predict_proba(X)[0][1] return ("phishing" if prob >= 0.5 else "normal", round(prob, 4)) if __name__ == "__main__": sample_url = "http://example.com/login?verify=abc123" sample_body = "请立即点击链接验证您的账户,否则将暂停使用" print(predict_one(sample_url, sample_body))推理脚本与训练脚本共用同一套 build_features,这是避免“训练好、线上崩”的最有效手段。特征提取函数单独放一个模块,训练和推理都 import 它,而不是各写一份。载入模型用 joblib,和训练时保存对应;注意保存路径中 tfidf_vec.joblib 也必须载入,否则文本向量化词表对不上,维度直接报错。
predict_proba 返回的是二维数组,[0][1] 取正样本概率。生产环境里建议把概率值原样记录下来,而不是只存 0/1 标签,后续调阈值和审计都用得上。
4.2 告警阈值与影子模式:把阈值定在召回率上而不是默认 0.5
很多团队上线时直接用预测概率 0.5 当阈值,这是不对的。安全场景下,漏掉一封钓鱼邮件的代价远大于多拦截一封正常邮件需要人工复核的代价,所以阈值通常要往低调。我一般先在旁路做影子模式:模型正常接收邮件、计算概率、写入日志,但不拦截任何邮件,持续七到十天,再把概率超过 0.5 的样本拉出来人工复核,统计误报情况。
| 场景 | 建议阈值区间 | 说明 |
|---|---|---|
| 分析师人工复核能力强 | 0.5 - 0.7 | 宁可多拦,漏报风险低 |
| 用户体量大、拦截影响明显 | 0.85 - 0.95 | 避免大量客诉,漏报靠其他规则兜底 |
| 影子模式验证期 | 记录全量概率 | 下探到 0.3,观察误报分布 |
影子模式的数据是调阈值最可靠的依据,比任何离线评估都有说服力。
4.3 模型更新机制:为什么钓鱼特征会漂移
钓鱼域名存活时间短、模板迭代快,模型必须定期重训。常见做法是每周重训一次,训练数据窗口保留最近 30 天,新样本按时间加权的比例参与训练。这里的关键是:过期样本的权重随时间衰减,而不是让三个月前的旧样本和新样本在训练集里各占一半。
重训流程建议写成定时任务:从公开威胁情报拉到新增恶意样本和蜜罐新增邮件 → 和正常样本按 1:5 配比 → 自动跑特征提取与训练 → 在保留的新样本集上评估召回率和误报率 → 效果不低于当前线上模型则发布,否则告警让工程师介入。模型发布同样走版本号,每个模型文件对应一份特征提取代码版本。只更新权重不更新代码,或者反过来,都会让线上行为不可控。
5. 避坑排查:钓鱼识别模型落地中的 5 个踩坑记录与修复方法
这一章全部是实操中遇到过的真实问题,按“现象 → 原因 → 解决”记录,希望能帮你少走弯路。
5.1 上线一周后召回率明显下滑
现象:模型上线头三天效果很好,一周后漏报开始变多,安全团队开始质疑模型能力。
原因:训练样本大多是几个月前的历史数据,钓鱼攻击的域名和模板一周内就会换一批;模型学到的是“旧域名的样子”而不是“钓鱼的本质”。另外,公开威胁情报库的样本本身有滞后,今天拉到的恶意样本通常是几天前的。
解决:改成周级重训,训练数据限定为最近 30 天;重训后必须用最近三天的样本评估,离线指标不过关就不发布。如果重训成本高,至少要做到两周一次。同时保留一份“新鲜样本集”单独验证,判断模型到底是在记忆还是泛化。
5.2 URL 编码绕过导致模型失效
现象:同一个钓鱼链接,加上一段 URL 编码参数或者把域名大小写换一下,模型就拦不住了。
原因:特征提取没有做归一化,urlparse 之前没有先解码。模型学到的是字面特征,攻击者稍微编码、大小写变换就能绕过。
解决:在 url_features 入口先做完整解码,再统一小写,最后才提取特征。解码顺序不能反,要先处理百分号编码,再提取。另外可以把“编码字符出现次数”加成一个新特征,正常业务 URL 基本不会出现多次百分号编码。
5.3 图片型钓鱼邮件正文特征全为零
现象:一封钓鱼邮件正文只有一张图片,TF-IDF 向量全是 0,模型直接判成正常。
原因:钓鱼内容整个放在图片里,文本模型完全没有输入。
解决:先做 HTML 附件解析,把 alt 文本、内嵌文本捞出来;图片型钓鱼需要 OCR 识别,成本高,我的建议是先接受这个边界,把它交给其他规则或图墙检测。与其硬上 OCR 拖慢全链路,不如在系统里标记“图片型邮件需人工复核”,让规则接手。
5.4 准确率 98%,实际却一封都没拦住
现象:评估报告准确率很高,业务方觉得模型不错,但实际钓鱼一封没拦下来。
原因:正常邮件占 95% 以上,模型把所有邮件判为正常就能拿到 95% 准确率。准确率在类别极不平衡时是误导性指标。
解决:改用精确率、召回率、F1 值做评估指标。对钓鱼识别场景,重点看召回率,也就是所有钓鱼样本里到底拦住了多少。上线后统计漏报数,而不是只看准确率和整体拦截量。
5.5 训练和推理特征不一致导致维度报错
现象:模型上线后 predict 报维度不匹配,或者概率结果怪异。
原因:训练脚本和推理脚本各写了一套特征提取函数,两边对同一字段的解析规则不一致,可能是编码处理方式不同,也可能是正则习惯不同。
解决:特征提取函数收敛到独立模块,训练和推理共用;保存模型时同时保存特征列名清单,加载后校验当前输入列名和保存的列名完全一致,不一致直接拒绝预测并告警。模型可以重训,特征口径一旦分裂,整个黑匣子就没法修了。
6. 上线前最后一步:用阈值校准和短期验证给模型兜底
6.1 阈值搜索脚本:按目标召回率反推阈值
import numpy as np from sklearn.metrics import precision_recall_curve probs = clf.predict_proba(X_test)[:, 1] prec, rec, thr = precision_recall_curve(test_df["label"], probs) for target in [0.90, 0.95, 0.98]: idx = np.argmax(rec >= target) print(f"target_recall={target:.2f}, threshold={thr[idx]:.3f}, precision={prec[idx]:.3f}")逻辑是按目标召回率反推阈值。比如要求钓鱼样本召回率不低于 0.95,就在验证集上找满足条件的最大的阈值,保证召回的同时尽量压低误报。上线时先用这个阈值做影子模式跑几天,根据误报数据进行微调,不建议直接压到生产环境。
6.2 上线第一周盯三个指标
| 指标 | 计算方式 | 调整动作 |
|---|---|---|
| 误报率 | 拦截样本中人工复核为正常的比例 | 误报偏高,上调阈值 |
| 漏报率 | 用户举报/事后确认钓鱼中未被拦截的比例 | 漏报偏高,下调阈值 |
| 延宕时长 | 邮件进入系统到给出预测耗时 | 超时检查特征提取瓶颈 |
上线第一周每天抽出 20 条被拦截样本让分析师复核,重点看误报。如果误报集中在某个正常业务域名,可能是特征误伤,记录特征值再决定加白名单还是调阈值。
最后说一个个人教训:我有一次在模拟项目X里把离线 F1 做到了 0.97,上线当天就被业务方投诉误拦了正常营销邮件。查了两天,发现推理端正则表达式少了个 raw 前缀,导致反斜杠被转义,URL 解析路径全变了。从那之后,我再也不允许训练和推理各写一份特征代码,压缩包里最重要的不是模型权重,而是那份统一的特征提取模块。模型可以重训,特征口径一旦崩了,再好的权重都是摆设。希望帮到你。
本文还有配套的精品资源,点击获取