简介:一套用于构建钓鱼网站与钓鱼邮件识别模型的完整机器学习工程包,面向网络安全初学者、数据挖掘学习者以及需要快速搭建反钓鱼系统的开发人员。资源涵盖从数据预处理、文本向量化到多种深度模型训练与评估的完整流程,重点展示CNN、LSTM、TextCNN等算法的实际应用方式。压缩包共14个文件,包含10个Python脚本和4个CSV数据集文件,整体仅339KB,脚本按功能模块拆分,数据集可直接用于模型训练与验证。目前已有241人学习下载,适合作为课程设计、毕业设计或安全竞赛的参考实现。通过阅读源码与运行实验,可掌握URL文本特征提取、Word2Vec词嵌入、交叉验证及超参数调优等关键环节,并理解朴素贝叶斯、支持向量机等传统方法与深度学习在钓鱼检测中的性能差异。资源代码结构清晰、注释明确,便于二次开发与迁移到真实邮件或浏览器检测场景。
1. 识别钓鱼网站和钓鱼邮件,机器学习模型到底解决什么问题
钓鱼检测这个方向最反直觉的地方在于:黑名单规则在日常防护里能挡住九成攻击,但真正让人头疼的,是那些存活时间以小时甚至分钟计算的钓鱼页面。一个新注册的仿冒域名,从被钓鱼者点击到目标页面下线,留给防御方的时间窗口非常短。人工标注跟不上这种速度,规则引擎又只能识别已经入库的 URL 和邮件模板。建立识别钓鱼网站和钓鱼邮件的机器学习模型,本质上就是把这个「从发现到响应」的时间差压到最小:用特征让模型在域名还没上黑名单之前就判断出危险倾向。
这个方案适合谁?如果你在企业里负责邮件网关、Web 访问审计或安全运营,手头有历史邮件和 URL 访问日志,那这个方向可以直接复用你已有的数据。它不适合当作一个纯离线研究项目来玩,因为模型需要持续接收新样本才能跟上攻击者换模板的速度。后面几章我会按样本构造、特征工程、模型训练、评估调参、踩坑复盘这条路径,把一套能真正落地的做法讲清楚。
2. 先解决样本问题:钓鱼 URL 和钓鱼邮件训练集怎么构造才不像玩具
2.1 钓鱼 URL 的标签怎么打:黑名单之外还有四种弱监督来源
钓鱼模型的第一道坎不是特征,是标签。很多入门做法是拉一份公开黑名单域名,把对应 URL 全标为 1,再随便抓一些正常流量标为 0,训练集看起来很大,但模型学到的其实是「某个域名列表」,而不是「什么样的 URL 像钓鱼」。公开黑名单当然要用,但它只是起点。我一般会叠加四种弱监督来源让正样本长出新意。
第一种是主动爬取已下线的钓鱼页。很多钓鱼攻击用的是短链接服务或临时域名,攻击结束后域名会释放,但历史网页快照里还留着完整的页面结构。抓这些快照,等于拿到了攻击者当时的渲染结果,特征千奇百怪,正好打破黑名单的固定格式。第二种是品牌仿冒域名挖掘,把常见品牌名和易混淆字符组合成一个候选词表,比如用 1 替换 i、用 0 替换 o,再通过证书透明度日志每天拉新增的相似域名。跑出来的候选列表里真正钓鱼的命中率不高,但每命中一个都是高质量正样本。
第三种是蜜罐邮箱。在内部环境里放几个不对外公开的信箱地址,收到的邮件几乎可以默认是垃圾或钓鱼。第四种来自已有告警事件沉淀。SOC 团队过去一年人工确认过的钓鱼 URL 回收进训练集,这部分数量少,但标签最可信。四种来源合并后,再用一个去重逻辑:同一主域名下的 URL 只保留能代表不同页面结构的样本,防止某个域名刷屏。
2.2 邮件侧:HTML 邮件、附件与回信地址的表征
邮件钓鱼和网站钓鱼的样本形态不同。网站一侧拿到 URL 就能开始提特征,邮件一侧必须先处理原始邮件文件。真实场景里,企业邮箱导出的邮件通常是 eml 或 msg 格式,里面包含完整的 MIME 结构、SMTP 头和正文。不要嫌这些文件占用空间大,直接转成 Excel 或 CSV 会丢失掉最关键的结构信息。
HTML 邮件是钓鱼邮件的重灾区。攻击者用 HTML 排版把邮件做得和正常通知几乎一致,正文里嵌一个「立即查看」按钮,按钮的真实 href 指向仿冒域名。这种邮件的特征不在文字里,而在链接锚文本和 URL 的对应关系里。还有一类鱼叉式钓鱼的典型场景:邮件伪装成学术期刊的审稿邀请,主题写着 manuscript review request,正文用词非常正式,但链接指向的是仿冒的投稿系统登录页。这类样本如果只做纯文本清洗,模型就丢了最核心的判定线索。
发件人侧的表征也要单独处理。Java 语言生态里有不少邮件库允许直接设置显示名和 Return-Path,不经过身份认证阶段也能把一封邮件伪装成来自运维团队。所以邮件头的 Return-Path、Reply-To、Message-ID 这组字段,必须和认证结果分开建模。光看头字段是一种特征组合,头字段加上 SPF、DKIM、DMARC 的判定结果又是另一组更有区分力的信号。
2.3 拿到样本的第一步:跑一条最小化的 URL 特征提取流水线
样本攒够一批之后,不要立刻去调模型,先做一次特征提取的冒烟测试。下面是一段极简的 URL 特征提取脚本,我用它能快速确认手里的 URL 样本长什么样,也为后面扩展特征打个底。
from urllib.parse import urlparse import tldextract import re def extract_url_features(url: str) -> dict: parsed = urlparse(url) ext = tldextract.extract(url) features = {} # 完整路径长度:钓鱼页喜欢把参数堆得很长来干扰人工校验 features['path_len'] = len(parsed.path) # 查询参数个数:参数名和参数值都是攻击者用来混淆目标的 features['num_query_args'] = len(parsed.query.split('&')) if parsed.query else 0 # 是否直接用 IP 做 host,正常业务很少这么干 features['has_ip'] = 1 if re.match(r'^\d{1,3}(\.\d{1,3}){3}$', parsed.netloc) else 0 # 子域名数量:仿冒站点爱用多层子域制造合法假象 features['subdomain_count'] = len(ext.subdomain.split('.')) if ext.subdomain else 0 # 钓鱼敏感词:login、verify、account 这类词在正常 URL 里也常见, # 但和上述结构特征组合后区分力明显上升 sensitive = re.findall(r'(login|verify|account|secure|update|confirm)', parsed.path.lower()) features['sensitive_count'] = len(sensitive) # 协议类型:钓鱼站不一定都是 http,带 https 的反而更具迷惑性 features['is_https'] = 1 if parsed.scheme == 'https' else 0 return features sample_urls = [ "http://58.219.123.45/login.php?user=admin&redirect=verify", "https://account-secure-login.example-corp.com/auth/update.do", "https://www.example-corp.com/account/login", ] for url in sample_urls: print(url, extract_url_features(url))这段脚本有几个值得刻意设计的点。第一个是 tldextract,它能把 www.example.com 拆成 subdomain=www、domain=example、suffix=com,比直接用 urlparse 拿 netloc 再手动分域名要可靠得多。第二个 is_https 这个特征,新手容易默认写成「https 就是安全」,但钓鱼站为了降低受害者戒心也会配证书,这个特征的价值在于和其他特征一起参与非线性组合,而不是单独打分。第三个 sensitive_count 正则里的词表需要根据你自己的流量调整,跑完一批数据统计一下,哪些词在正样本里频次高,哪些在负样本里也高,自己心里要有数。
3. 特征工程与模型选型:从 URL 构成到 SMTP 头,特征怎么落到模型输入上
3.1 URL 特征:完整路径比域名更能暴露钓鱼意图
很多团队的第一版钓鱼模型把主域名当唯一特征,这是最容易被攻击者绕过的做法。钓鱼者注册一个可疑域名,但真正决定一访问就要人输入账密的,是路径和参数里的行为信号。用一个具体例子来说:https://apple-id-verify.some-new-domain.xyz/login.do?redirect=account这个 URL 里,域名部分是一串随机字符,但路径里有apple-id-verify、login.do、redirect=account。这些词组合在一起,说明这是一个模仿 Apple 登录流程的页面。
URL 侧的特征我一般分四组落地。第一组是结构特征,包括路径长度、查询参数个数、可疑子域名层级。第二组是字符特征,包括域名和路径里数字与字母的替换模式,比如用paypa1替代paypal。第三组是词频特征,统计 login、verify、secure、signin 等高频行动词在路径里的出现次数。第四组是第三方情报特征,比如域名的注册时长、注册商是不是知名服务商、域名的 DNS 解析是否指向动态 IP。这组特征在本地没有数据源时可以先不加,但等模型上线后它往往是召回率提升最明显的增量。
3.2 邮件头特征:Return-Path、Message-ID、Authentication-Results 三件套
邮件侧的特征工程和 URL 侧完全不同,因为攻击者可以伪造头字段。正常业务邮件可能不会严格对齐 Return-Path 和 From,但 SPF 和 DKIM 的判定结果通常能间接反映这个对齐关系。钓鱼邮件用 Java 邮件伪造发件人时,只要不经过发件服务器的身份认证,认证结果基本都是 fail 或 temperror,这两类邮件和认证结果为 pass 的邮件要当成不同特征空间去看。
具体落地时我会提取三组邮件头字段。第一组是基础身份字段,Return-Path、Reply-To、From 的域名部分是否一致。第二组是认证结果字段,从 Authentication-Results 头里解析出 spf、dkim、dmarc 三个结果值。第三组是邮件语义骨架,包括主题里是否带 urgency 或 account 词、正文里的 URL 数量和第三方域名占比、附件扩展名是否为 html 或 pdf、邮件是否包含可执行文件。这些特征组合起来,模型才能在「显示名正常但认证全失败且正文只有一个链接」的邮件里捕捉到危险信号。
3.3 模型选型:为什么第一版先上梯度提升树而不是 BERT
钓鱼检测的特征类型以数值和低基分类变量为主,样本量通常也只有几十万级,所以我的建议是第一版直接上梯度提升树,不要一上来就上大规模预训练语言模型。梯度提升树对特征尺度不敏感,不需要做标准化,能直接用分类值做划分,而且训练完还能输出特征重要性,方便向领导解释模型在靠什么做判断。BERT 当然能在 HTML 邮件的语义理解上做得更深,但你需要大量带标签的邮件正文和更贵的推理资源,这个投入适合在树模型已经跑通、误报率压不下去的第二个阶段再考虑。
3.4 训练脚本:用梯度提升树跑通第一个可解释模型
我一般用 LightGBM 作为主力模型。它训练速度快,并且在分类问题上不需要做复杂的调参就能拿到还不错的基线。下面这段代码是一套可以直接跑的流程,前提是前面特征工程产出的 DataFrame 已经传进来。
import lightgbm as lgb from sklearn.model_selection import train_test_split # df 里至少要有 label 列,以及上一步特征提取得到的所有 feature 列 feature_cols = [ 'path_len', 'num_query_args', 'has_ip', 'subdomain_count', 'sensitive_count', 'is_https', # 邮件侧特征和 URL 侧特征合表后都放在这里 ] df = load_merged_dataset() # 从本地 parquet / csv 读入 train_df, test_df = train_test_split( df, test_size=0.2, random_state=42, stratify=df['label'] ) model = lgb.LGBMClassifier( n_estimators=300, learning_rate=0.05, max_depth=5, num_leaves=31, scale_pos_weight=sum(train_df['label'] == 0) / max(sum(train_df['label'] == 1), 1), random_state=42, verbosity=-1, ) model.fit( train_df[feature_cols], train_df['label'], eval_set=[(test_df[feature_cols], test_df['label'])], eval_metric='auc', )参数里最值得说明的是 scale_pos_weight。钓鱼样本占比通常只有百分之几,如果不调整正负样本权重,模型会倾向于把几乎所有样本都预测为正常,看起来准确率很高,其实对钓鱼完全没识别能力。把负样本数除以正样本数作为权重,相当于人为把少数类的错判代价抬高。另一个是 max_depth 和 num_leaves,这两个参数一起控制树的复杂度,数据量到几十万级时 depth=5、leaves=31 是个不容易过拟合的起点,后续可以用交叉验证再微调。
训练完成后不要直接看准确率。用model.feature_importances_看一下哪些特征排在前面,如果is_https或has_ip的贡献异常高,先检查是不是特征提取阶段有数据泄漏。特征重要性和业务直觉不一致不一定是错的,但要能解释原因,解释不了就只当参考。
4. 评估与阈值:为什么精确率 0.95 不能直接上线拦截
4.1 用混淆矩阵找误杀与漏放
钓鱼检测模型的评估指标和普通分类任务不一样。普通场景里准确率够高就能上线,但钓鱼检测里正常样本和钓鱼样本的数量极不平衡,而且两类错误代价完全不同。漏放一封钓鱼邮件,意味着有一个账密可能被偷走;误杀一封正常业务邮件,意味着有用户在投诉网关把自己客户发来的询盘挡掉了。用一个真实一点的数字来说明:训练集里正常邮件占 98.5%,钓鱼占 1.5%,模型把所有样本都预测为正常,准确率是 98.5%,但钓鱼召回率是 0%。所以严格来说,准确率在这个场景里没有参考价值。
我上线的第一版模型在测试集上是 95.2% 的精确率和 68% 的召回率,当时觉得数据很好看。真正放到邮件网关上去做归档测试才发现,每天约五万封邮件流量里,正常邮件基数太大,3% 的误判率就意味着约 1400 封正常邮件被丢进隔离区。所以评估时第一件事就是把混淆矩阵打印出来,把 FN(漏放的钓鱼)和 FP(误杀的正常)分别拉出样例,人工过一遍。看样例时重点不是数字,而是那些被误杀的正常邮件长什么样、被漏放的钓鱼邮件又长什么样,这两类样本决定了下一次特征迭代的方向。
4.2 分类阈值怎么调:寻找 precision-recall 平衡点
梯度提升树输出的不是二分类标签,而是样本属于钓鱼类的概率,默认阈值取 0.5 对极端不平衡场景并不合适。正确做法是把阈值也当作一个在线可调参数。我一般会在测试集上绘制 precision-recall 曲线,再根据业务容忍度选一个操作点。如果团队能接受每天误杀 30 封邮件,就按这个约束去找阈值;如果运维人力只够每天人工复核 10 封隔离邮件,阈值就要再抬高。
from sklearn.metrics import precision_recall_curve import numpy as np # model.predict_proba 返回的是两列矩阵,取第二列作为正类概率 pred_proba = model.predict_proba(test_df[feature_cols])[:, 1] precision, recall, thresholds = precision_recall_curve(test_df['label'], pred_proba) # 找一个业务可接受的阈值:例如要求 recall 不低于 0.85,同时看 precision 能到多少 target_recall = 0.85 valid_mask = recall[:-1] >= target_recall # thresholds 比 precision/recall 少一位 if valid_mask.any(): best_idx = np.where(valid_mask)[0][0] print(f"threshold={thresholds[best_idx]:.3f}, precision={precision[best_idx]:.3f}, recall={recall[best_idx]:.3f}") else: print("没有满足目标召回率的阈值")这段代码的核心是 PR 曲线上的操作点选择。ROC 曲线在对类别不平衡问题不敏感,看起来 AUC 很高,但落到具体阈值上可能没法用;PR 曲线把 precision 和 recall 的直接权衡画出来,更适合这个场景。实际调的时候不要只盯一个操作点,我会同时输出三个候选阈值对应的误杀邮件数量,换算成业务量后再做决定。给模型调完阈值,等于给拦截策略上了一道安全阀。
5. 钓鱼检测模型的五个经典翻车现场与排查手段:从训练集到特征泄漏
5.1 现象:随机抽样导致训练集里钓鱼占比失真
第一版训练的时候,我用 train_test_split 的默认随机切分方式把训练集和测试集分开,结果测试集上精确率 96%,上线后真实流量里误杀率比测试时高了三倍。随机抽样假设样本之间相互独立,但钓鱼攻击是有爆发周期的,同一波攻击释放的批量域名可能都被分进了测试集或训练集。模型在训练时见过这批相似样本,测试时也见过,分数自然虚高。
原因不在模型,在抽样逻辑。解决方法是按时间序列切分而不是随机切分。用样本首次出现时间排序,前 80% 的时间窗口作为训练集,后 20% 作为测试集。这样能模拟模型上线后的真实分布变化。另外要从 URL 维度和邮件模板维度做分组去重,同一波攻击留下的变体只留少量代表样本,防止某一种攻击方式在训练集里刷屏。
5.2 现象:只清洗文本不保留链接锚文本,模型学的是「文字垃圾」
另一个经常踩的坑是在预处理 HTML 邮件时直接转纯文本,然后只对文本做词频统计。这样模型确实能学到一些关键词,比如 urgent、login 等,但学到的是浅层文字特征。攻击者只要把「urgent」换成「immediate」,邮件就能绕过这类特征。真正的结构特征,比如锚文本写的是品牌名而 href 指向可疑域名,在纯文本转换过程中被彻底丢掉了。
原因在于把 HTML 解析和文本提取混为一谈。解決方案是保留 HTML 的 DOM 结构,用 BeautifulSoup 解析出每个 a 标签的锚文本和 href,并记录两者之间的文本相似度。这个「锚文本与目标 URL 是否一致」的特征,比任何词频统计都稳定,攻击者要绕过它必须真的把链接改成合法域名,成本和收益都会恶化。
5.3 现象:伪造发件人字段太多,模型只看 Return-Path 不看认证结果
还有一种常见现象是邮件侧模型的排序靠前特征没有认证结果相关的字段。细查之后发现特征提取脚本里没有解析 Authentication-Results 头,只提取了 Return-Path 和 From。钓鱼者用 Java 生态的邮件库设置伪造显示名和 Return-Path 时,这两字段可以被改得看起来很真实,但认证结果是邮件服务器写在邮件头里的独立信息,伪造不了。
解决方法是头字段特征和认证结果特征分层建模。把 spf、dkim、dmarc 的 pass、fail、temperror 三档结果单独编码成特征,并把「认证结果 fail 且 Return-Path 与 From 域名不一致」这个组合单独做成一个高权重信号。模型没有权限决定是否信任某一头字段,但这个组合特征的加入让模型有机会学到规则引擎长期依赖的业务逻辑。
5.4 现象:域名特征反复出现在同一批样本里,模型记住的是域名不是行为
有些团队会在特征列表里加入主域名本身,或者把域名做了哈希编码。模型在训练时直接记住了一批钓鱼域名,测试时碰到没见过的域名就靠猜。这会让离线测试分数异常高,实际流量里新域名的召回率惨不忍赌。攻击者注册一个全新域名只需要几分钟,域名维度的记忆几乎没有泛化能力。
这个问题的解决方向通常是把特征从「域名」转向「域名的属性」:用域名年龄、注册商、是否使用隐私保护、解析 IP 是否属于动态号段等外部属性来表征。如果外部数据源暂时拿不到,另一个简单办法是把域名泛化成模式位,比如提取域名长度、包含的字符类型种类、是否全部为随机字符,尽量不让模型直接面对一个具体的字符串 ID。
5.5 现象:用太容易识别的测试集压测,模型的指标再高也撑不住真实流量
需要警惕的最后一种现象和测试基准有关。有人采集了一批已经上过黑名单的老钓鱼样本做测试集,模型跑出 99% 的准确率,就以为可以交付了。但老样本的攻击手法可能已经过时,正常业务邮件也在演化,模型只在「历史已知攻击」上表现良好,一碰到新形态钓鱼就回归黑名单水平。
可靠的验证方式是做「未来的数据」测试:严格按时间切出最新一周的数据,任何训练样本都不允许混进去,然后看模型在这批新样本上的表现。这一步是检验训练集是否泄漏的唯一标准。另外一个有效手段是把模型输出和规则引擎输出做并集比对,模型能捞到多少规则引擎漏掉的样本,这个数字比任何精确率指标都更能说明模型的价值。
6. 从离线模型到实时拦截:用回放与 SHAP 值给模型装上「后悔药」
6.1 用历史邮件回放给每次模型迭代压测
线上模型的迭代不能直接改配置,我一般会先做一轮离线回放。把最近一个季度的邮件流量全部保存在归档队列里,当新模型训练完成时,让它跑一遍这批历史数据,统计新增误杀邮件数量和新增检出数量。这里的「新增」不是你自己调出来的指标,而是拿它和上一版模型的预测结果逐封对比,找出那些被新模型反转结论的邮件,一封封人工确认。
这个回放过程本质上是对模型做「邮件测压」。把过去真的骗过业务用户的钓鱼邮件,以及从正常邮件里挑出来的极难判断的样本打成一组压力集,每一版新模型都要在这组样本上跑完,再看是否通过测试。跑回放时有一个容易被忽略的细节:要按邮件来源域名的纬度去统计误杀,如果一个域名下集中出现误杀,说明模型可能对某个特定行业模板敏感,而不是对不同邮件做了公平判断。
6.2 SHAP 值解释:让每次拦截都说出理由
模型给出「钓鱼」这个结果之后,安全运营人员需要知道为什么。梯度提升树可以直接用 feature_importances_ 看全局排序,但对单封邮件的预测没有解释能力。SHAP 值是目前比较实用的方案,它能算出每一条特征对单次预测结果的正向或负向贡献,并且用 TreeExplainer 可以跑得很快。
import shap explainer = shap.TreeExplainer(model) shap_values = explainer.shap_values(test_df[feature_cols]) # 查看第 7 条样本的特征归因 shap.initjs() shap.force_plot(explainer.expected_value, shap_values[6], test_df[feature_cols].iloc[6])在生产环境里我不会把 force_plot 的可视化图直接塞给运营人员,而会取每个样本的 top3 正向贡献特征,渲染成一段文字。比如「URL 路径长度异常 + 子域名层级过多 + 认证结果 fail」这样三条具体描述,追加到告警事件详情里。运营人员不需要懂模型,只需要看到一个能被理解和复核的原因列表。
6.3 把模型的「后悔药」留在工程上
模型上线一段时间后,你一定会发现某类正常邮件被长期误杀,或者某类新型钓鱼被漏掉。改完特征重新训练后,千万别急着全量替换线上的版本,先去回放数据里看一眼这次改动把上一版模型救回来的数量够不够多。另一个习惯是给线上模型的结果做个快照,把每个样本的预测概率、特征值和模型版本号一起落进日志表。等运营人员反馈「有一封被误判的邮件」时,你翻日志就能定位当时模型是改哪个特征导致的。
我现在的习惯是每次改完特征,都会先跑一遍回放,再看 SHAP 的 top 特征有没有从行为信号退化成域名记忆。这个习惯帮我挡住了好几次想当然的优化,比如某一版我把邮件标题长度加进特征后,回放发现误杀量翻了一倍,赶紧回滚。钓鱼检测模型永远不可能做到零误杀,但有一份能回溯的日志,每个决策都有后悔药可吃。希望这个方向能帮你在实际流量里少趟几个坑。
本文还有配套的精品资源,点击获取