简介:《建立识别钓鱼网站(邮件)的机器学习模型》面向网络安全初学者及希望入门内容安全检测的开发者,围绕钓鱼URL与钓鱼邮件的特征提取、文本向量化和分类模型训练展开,帮助读者理解从数据预处理到模型评估的完整流程。压缩包共14个文件,以10个Python脚本和4个CSV数据表为主,脚本覆盖数据处理、词向量工具、基础网络及CNN/LSTM等模型训练,数据表用于承载样本;整体约339KB,体量轻便,便于本地复现和小规模实验。已有244人学习,说明该主题具有一定关注度。读者可从中获得URL与邮件特征构造思路、TF-IDF/Word2Vec编码示例、CNN/LSTM组合模型参考,以及交叉验证、召回率/F1/AUC-ROC评估、样本不平衡与超参数调优等实践线索,适合作为课程设计或安全检测入门的参考包。
1. 钓鱼识别不是跑个分类器:先把威胁模型定下来
第一次给内部邮件网关做钓鱼邮件的识别,我把它当成一个普通二分类:有 URL、有标签、丢进模型、离线准确率 0.99,就以为发现了一条捷径。上线两周告警队列被误报灌满,真正伪造成登录页的钓鱼网站从模型眼皮底下溜过去。问题不在算法,在于我从一开始就没定清楚:这条样本在什么时刻、由谁判定的、模型看到的信息和线上能拿到的信息是不是同一批。很多人搜「钓鱼网站识别 机器学习模型」,最后卡住的地方几乎都不是调参,而是样本口径和特征可用性。这篇按我实际搭这套模型的顺序讲:样本怎么采、特征怎么拆、模型怎么选、阈值怎么定、上线后怎么盯。适合准备自己动手做一个可上线的钓鱼识别模型的人,也适合已经跑通 demo、但指标一上线就垮的人。
2. 样本从哪来:钓鱼数据集的三个来源与标签口径
模型上限由数据决定,这句话在钓鱼识别里格外应验。URL 字符串本身就那么几十个字符,能挖的信息有限,真正拉开差距的是样本覆盖了多少种伪造手法、标签是不是干净。我前后换过三版数据集,前两版的毛病都出在来源单一和口径混乱上。
2.1 公开样本源、自建收集与内部告警日志的取舍
公开钓鱼样本源是最快的冷启动路径:量大、模板多、覆盖各种伪冒页面。但它有三个坑。第一是老化快,你拿到的 URL 大多已经关停,抓不到页面内容,只能退回纯字符串特征;第二是分布偏,公开源里短命域名和批量注册域名的比例远高于真实流量;第三是没有负样本,你得自己去配。
自建收集是指在自己的邮件网关上挂一个举报入口,让用户点「这是钓鱼」,再人工确认。这条路慢,但质量最高,因为它天然带着真实流量的分布,而且标签是业务方认可的。我一般会让它跑满一个月再进训练集,样本量不大但极其值钱。
内部告警日志指的是已有规则引擎命中的记录。它的好处是接近线上真实分布,坏处是标签噪声大:规则命中的不一定是钓鱼,规则漏掉的也可能全是钓鱼。直接拿它当监督信号,模型学到的其实是「规则长什么样」。
我的做法是三层拼:公开源做冷启动和模板扩充,占比六成左右;内部举报 + 人工确认做精标集,占比两成,专门用来做最终评估;规则告警日志只用来做负样本采样和无监督的特征分布分析,不直接进监督训练。
2.2 标签口径:一条正样本到底算什么
这是我踩过的最贵的一个坑。最早我把「URL 出现在公开源列表里」直接标成正样本。结果发现同一批样本里,有相当一部分抓取时页面已经被换成正常内容,或者跳转到了一个正常的推广页。模型学到的是「这个域名长得像被举报过的域名」,而不是「这个页面在骗凭证」。
后来我把标签定义收紧成三条同时满足:抓取时刻页面存在且可访问;页面上存在凭证输入框或者明显的诱导性文案;目标品牌与当前域名不匹配。邮件侧的标签同理,不能只看规则命中,必须有人工确认的结论,而且要记录确认时间。
时间戳这件事一定要记。钓鱼页面的生命周期经常只有几小时到几天,如果你不知道样本是几点抓的,后面做时间切分就无从谈起,模型评估会直接失真。
还有一类灰样本必须单独留一档:正常的企业营销邮件、SaaS 服务的登录提醒、密码重置通知。它们和钓鱼邮件在文本上高度相似,是最主要的误报来源。我一般把它们标成 -1 而不是 0,训练时不参与,但评估时必须单独跑一遍,看模型在这批样本上的误报率。
2.3 负样本比正样本难找
新手容易觉得负样本到处都是,随便采点正常 URL 就行。恰恰相反,负样本的质量直接决定误报率,而误报是这套系统上线后最容易被投诉的指标。
我采负样本的原则是跟着真实流量走:从邮件网关的正常投递日志里按天采样,覆盖企业内网系统、常见的第三方 SaaS 登录页、CDN 分发的静态资源、带长查询参数的追踪链接。每一条都要保证访问时页面确实是正常的,别把已经下线的域名当成负样本,那等于教模型「打不开的页面是正常的」。
正负比例我一般控制在 1:5 到 1:20 之间。比例再高,模型会偏向少数类,误报上去;比例太低,召回上不来。这个区间是靠误报预算反推的,不是拍脑袋。
2.4 去重与同源分组:这一步不做,后面全白干
公开源里最常见的现象是:同一个钓鱼活动,注册了几十个域名,每个域名下路径只差一个随机串。如果你按行随机切分训练集和测试集,同一批活动的样本会同时出现在两边,模型只要记住域名后缀就能拿高分,离线指标能到 0.99,线上一塌糊涂。
所以我给每条样本打两个分组键:一个是注册域(把子域名剥掉之后的部分),一个是活动标识。活动标识靠 HTML 近重复检测来聚,用 simhash 或者 minhash 算页面结构的相似度,阈值设在 0.85 左右,聚出来的簇给一个统一的 campaign_id。切分的时候按 campaign_id 分组,保证同一个活动的样本只出现在一边。
这一步做完,我第一版模型的离线准确率从 0.992 掉到了 0.91。数字难看,但这次的 0.91 才是真的。
3. 特征怎么拆:URL 与邮件的可计算信号清单
特征工程的本质是把「像不像钓鱼」这个人的直觉,翻译成模型能读的数。翻译得越贴近攻击者的实际手法,模型越省力。我按 URL 侧、邮件侧、文本侧三块拆,每一块都配一张可用性表。
3.1 URL 侧特征:从字符串统计到结构信号
字符串统计类特征便宜好用:URL 总长度、host 长度、路径层数、查询参数个数、连字符数量、数字字符占比、点号数量。这些特征单看没什么意思,但组合起来能反映批量生成的痕迹,比如机器拼出来的域名往往数字多、连字符多、长度偏长。
结构类特征更有判别力,我列在下面这张表里,重点是最后一列「线上可得性」,这列决定了特征能不能真的用。
| 特征 | 计算方式 | 判别逻辑 | 线上可得性 |
|---|---|---|---|
| IP 直连 | host 是否为点分十进制 | 正常站点极少用 IP 做入口 | 高,纯字符串 |
| 用户信息符 | URL 中是否含 @ | 浏览器会忽略 @ 前的部分 | 高,纯字符串 |
| 子域层数 | 按点号切分后统计 | 伪冒常用三级以上子域塞品牌词 | 高,纯字符串 |
| 品牌词错位 | 品牌词出现在子域或路径,但不在注册域 | 伪冒最典型的手法 | 高,需品牌词表 |
| 高风险顶级域 | 注册域后缀是否在历史滥用比例高的集合里 | 只能作为弱先验 | 高,需维护名单 |
| 同形异义字符 | 检测非 ASCII 与 punycode 前缀 | 视觉伪冒 | 高,纯字符串 |
| HTTPS 与否 | 协议头 | 已经几乎没有判别力 | 高 |
| 域名年龄 | 注册时间距今 | 批量注册的域名普遍很新 | 低,依赖外部查询 |
| 页面表单目标 | 解析 HTML 中 form 的 action | 指向第三方域即为强信号 | 中,需抓取 |
表里有一个反直觉的点:HTTPS 现在基本没有判别力。早年大家把「没有小锁」当成钓鱼标志,如今批量签发的证书让这个信号彻底失效,把它留在特征里只会给模型添噪。我第一版留了它,模型给了它不低的权重,后来直接删掉,召回反而涨了一点。
域名年龄这类特征判别力很强,但线上可得性差:要么依赖外部查询接口(有延迟、有配额),要么缓存滞后。我在离线评估时会单独跑一版带年龄特征的模型,看提升有多少,如果提升不到两个点,就不值得为它引入一个外部依赖。
3.2 邮件侧特征:头部字段与 HTML 正文解析
邮件比纯 URL 多了很多结构化信号,头部字段几乎白送。
认证结果是第一梯队:SPF、DKIM、DMARC 三项的校验结论,通常已经在邮件头里写好了,直接解析就行。注意别只取一个布尔值,要区分「通过」「失败」「无记录」「软失败」这几种状态,它们的含义差别很大。
身份不一致是第二梯队:From 显示地址的域名、Return-Path 的域名、Reply-To 的域名、以及 Received 链路里最早一跳的域名,这四个如果对不上,是很强的信号。正常的企业邮件这四者通常收敛在同一个域或者已知的第三方投递域上。
链路跳数是第三梯队:Received 头的条数异常少,往往意味着邮件是直连投递的,绕过了正常的中间转发节点。这一条要小心,不同邮件系统的记录习惯不一样,最好按发件域分别统计基线,别用全局阈值。
正文侧的关键是 HTML 解析。我至少要拿三类东西:一是锚文本和 href 不一致的链接数量,这是最经典的伪冒手法,显示的是正规地址,点进去是别的地方;二是页面里的表单 action 指向哪里,如果指向一个和发件域无关的第三方,权重给高;三是隐藏元素,display:none 或者字号极小的文本块,正常营销邮件也会用,所以只能作为组合特征之一,不能单独定罪。
附件维度单独统计:可执行文件、带宏的文档、带密码的压缩包,这三类在正常商务往来里占比极低,命中就给强信号。
3.3 文本特征:字符级 n-gram 与 TF-IDF 的取舍
文本特征我走过弯路。一开始用分词加词袋,中文英文混着来,效果一般。后来换成字符级 n-gram,三到五 gram,配 TF-IDF,效果明显好一截。
原因是钓鱼文本的判别信息很多藏在字符层面:故意插空格的品牌词、全角半角混写、拼写变形、随机后缀。分词会把这些打散,字符级 n-gram 反而能保住这些模式。
代价是维度爆炸。三到五 gram 在几十万条样本上能轻松搞出上百万维。我的处理是先限制在 host 和 path 两段文本上做,不把整个 URL 扔进去,然后用哈希技巧压到 2 的 18 次方维左右。压完维度对召回的影响在半个点以内,但训练时间和内存占用降了一个数量级。
TF-IDF 的 idf 一定要在训练集上单独 fit,然后固化下来给线上用。线上实时算 idf 是典型的训练线上不一致,这个坑后面单独讲。
3.4 特征可用性审查:线上拿不到的特征等于没有
我在特征清单定稿前会做一次强制审查,逐条问三个问题:这个特征线上请求时刻能不能拿到?拿到它的耗时是多少?拿不到的时候填什么?
三条里有一条答不上来,这个特征就不进主模型,最多进离线的辅助分析。
举个具体的例子。页面截图特征在离线评估里非常好用,但线上要实时渲染页面再截图,延迟从毫秒级变成秒级,邮件网关根本等不起。这类特征的正确用法是放进二阶段复核,而不是放进首屏判定。
同理,任何依赖外部查询的特征,都要准备好降级路径:查不到就填一个特殊的缺失值,并且让模型知道这是缺失,而不是填 0。填 0 等于告诉模型「这个域名年龄是 0 天」,会直接把大量正常的新域名打成钓鱼。
4. 模型怎么选:从逻辑回归基线到树模型的取舍
模型这块我反而不太纠结,因为钓鱼识别是个特征工程占大头、模型占小头的任务。但选型仍然有讲究,尤其是可解释性和线上推理成本这两条,在很多团队里比准确率更重要。
4.1 先跑一个逻辑回归基线
不管最后上什么模型,我都会先跑一个逻辑回归。原因有三个。
第一,它是下限。如果逻辑回归在合理的特征上能到 0.88 的 PR-AUC,那复杂模型至少要高出三到五个点才值得引入。如果复杂模型只高一个点,我直接用逻辑回归,因为后面排查问题能省太多事。
第二,它的系数能直接看。哪几个特征在起作用、方向对不对,一眼就能看出来。我靠这个抓出过一个很隐蔽的问题:某个特征的系数符号和直觉完全相反,顺着查下去发现是标签口径错了,那批「正样本」其实是已经关停的正常页面。
第三,它推理快。邮件网关是同步链路,单封邮件的判定预算通常在几十毫秒,逻辑回归加稀疏特征在这个预算里绰绰有余。树模型在特征维度高的时候,推理延迟会明显上去,尤其是几百棵树的集成。
我的逻辑回归配置很朴素:L2 正则,正则强度在验证集上粗扫一遍,类别权重按样本比例反比设置。就这。
4.2 树模型与类别不平衡处理
基线跑通之后,我上梯度提升树。它在特征交互上确实强:比如「子域层数多」单独看不算什么,「子域层数多 且 域名年龄小 且 表单指向第三方」这三个一起出现,基本可以定性。这种交互逻辑逻辑回归要靠人工交叉特征才能表达,树模型能自动学。
不平衡处理我用两种手段,一般不叠加。一是类别权重,按正负比例的倒数设置,简单直接;二是负样本下采样,把比例压到 1:5 左右。两者叠加容易把模型推向过拟合少数类,误报会明显上去。我一般先试权重,权重方案在验证集上误报超标再考虑下采样。
调参上我不做大规模搜索。树的数量、深度、学习率这三个粗扫一遍就够了,钓鱼任务的边际收益衰减得很快。真正值得花时间的是特征和阈值,不是超参。
4.3 阈值不是 0.5:按误报成本定
这是很多 demo 和真实系统之间最大的一道坎。sklearn 的 predict 默认 0.5,但 0.5 这个数在你的业务里可能完全没有意义。
正确的做法是先定误报预算。比如邮件网关每天处理十万封邮件,业务方能接受的人工复核量是每天两百条,那误报率上限就是千分之二。然后在这个约束下,找召回最高的那个阈值。这是一个带约束的最优化问题,不是调参。
我通常会把输出分成三档,而不是一刀切:
| 概率区间 | 处置动作 | 说明 |
|---|---|---|
| 大于 0.9 | 直接拦截并告警 | 高置信,误报可接受 |
| 0.5 到 0.9 | 打标进人工复核队列 | 由人最终判定 |
| 小于 0.5 | 放行,但加风险标记 | 供后续溯源分析 |
分档的好处是把「模型判错」的代价从「误拦正常邮件」降级成「多一条复核工单」。业务方对这个设计的接受度比单一阈值高得多。
4.4 训练脚本骨架
下面这段是我这套流程的骨架,重点是分组切分和评估口径,不是调参。
import pandas as pd from sklearn.model_selection import GroupShuffleSplit from sklearn.pipeline import Pipeline from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.metrics import average_precision_score # 必需列: text(host+path 拼接), label(0/1), campaign_id(同源分组键), ts(抓取时间) df = pd.read_parquet("samples.parquet") # 按活动分组切分,避免同模板样本同时落入训练与测试 gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, test_idx = next(gss.split(df, groups=df["campaign_id"])) train, test = df.iloc[train_idx], df.iloc[test_idx] pipe = Pipeline([ ("tfidf", TfidfVectorizer(analyzer="char_wb", ngram_range=(3, 5), max_features=2**18, min_df=3, sublinear_tf=True)), # 字符级 n-gram 抗变形 ("clf", LogisticRegression(C=1.0, max_iter=2000, class_weight="balanced")), # 不平衡用权重而非重采样 ]) pipe.fit(train["text"], train["label"]) # 不平衡数据看 PR-AUC,ROC-AUC 会被大量真负样本抬高 proba = pipe.predict_proba(test["text"])[:, 1] print("PR-AUC:", round(average_precision_score(test["label"], proba), 4))几个参数值得说明。analyzer="char_wb"是按词边界切字符,比纯字符切分在 URL 这种长串上更稳;ngram_range=(3,5)是我实测下来在召回和维度之间的平衡点,再高维度受不了,再低丢掉变形模式;min_df=3过滤掉只出现一两次的碎片,主要是压噪声;class_weight="balanced"按比例反比加权,这一项对召回的影响比调 C 大得多。
GroupShuffleSplit里的groups必须是活动标识,不能是注册域,也不能是行号。用行号切就是最经典的泄漏,指标会虚高一到两个数量级。
跑完这段之后,我会再做一次时间切分验证:用前三个月训练,后一个月测试。两个结果差距在三个点以内,我才认为模型可以往线上走。差距大,说明模型在吃时间相关的捷径。
5. 五条返工记录:钓鱼模型的排查清单
下面这五条都是我实际返工过的,按踩到的先后顺序排。每条按现象、原因、解决写,遇到类似症状可以对着查。
5.1 离线 0.99,上线打回原形
现象:离线评估准确率 0.992,上线后人工抽查的准确率不到 0.7,同一个钓鱼模板换个域名就能绕过。
原因:按行随机切分,同一次钓鱼活动的样本同时进了训练集和测试集。模型实际学到的是「这个域名后缀见过」,而不是攻击手法的通用模式。
解决:引入活动标识做分组切分,同时用页面结构的近重复检测把同模板聚成一簇。改完后离线准确率降到 0.91,但这个数字和线上表现基本对齐了。我现在做任何带分组结构的样本,第一件事就是先想清楚分组键是什么。
5.2 训练时有域名年龄,线上全是缺失值
现象:离线模型里域名年龄的权重很高,上线后发现有相当比例的请求拿不到这个特征,模型在这些样本上的表现明显差一截。
原因:典型的训练线上不一致。离线把外部查询结果当成必有字段,线上查询超时或者配额用完就填了 0,而 0 在训练集里代表「刚注册的域名」,直接被推向钓鱼判定。
解决:缺失值显式编码成一个单独的标记值,让模型学到「未知」是一档独立状态;同时保证训练时也按同样的比例随机屏蔽这个特征,让模型见过缺失的情况。这套做法叫特征缺失增强,做和不做,线上表现差得很明显。
5.3 只看准确率,忽略了极低的钓鱼占比
现象:模型报告 99.5% 的准确率,业务方很满意,但实际拦截量少得可怜。
原因:真实流量里钓鱼邮件的占比通常在千分之几。一个把所有样本都判成正常的模型,准确率也能到 99.5%。在这个分布下,准确率几乎是个废指标。
解决:换成 PR-AUC 作为主指标,同时固定误报率去算召回。比如「每天每万封邮件允许 2 封误报」这个约束下,召回是多少。这个数字才是业务方能理解、也才敢拿去上线的。
5.4 短链接和跳转把所有字符串特征绕过去了
现象:一批用短链服务的钓鱼邮件全部漏检,模型给出的概率都在 0.1 以下。
原因:只解析了原始 URL 的字符串特征。短链的 host 本身是正规服务,路径是一串随机字符,所有「像不像钓鱼」的统计特征在这里全部失效。真正的目标地址藏在跳转之后。
解决:加一步受限的跳转跟随,最多跟三层,记录最终落地域的注册域和路径,把落地域的特征也拼进特征向量。同时把「跳转层数」本身作为一个特征,正常业务链接跳转层数很少超过两层。跳转跟随要做超时和域名黑名单保护,别让它变成任意请求的入口。
5.5 上线三个月后召回缓慢下滑
现象:没有改模型、没有改特征,召回从 0.89 一路掉到 0.76,每周掉一点,很难察觉。
原因:概念漂移。攻击方换了模板、换了域名注册渠道、换了投递手法,而模型的训练数据还停在三个月前。这类下滑是渐进的,单看每天的指标看不出来。
解决:两件事。一是固定每周重训,训练窗口滚动保留最近三个月;二是监控特征分布的偏移,对每个特征算训练集和线上近七天分布的差异指标,超过阈值就告警。偏移告警往往比指标下滑早一到两周出现,是一个很有用的提前量。
6. 上线之后:阈值分档与漂移监控的落地做法
模型上线只是开始。我现在的做法是把「判定」和「处置」拆开:模型只负责输出概率,处置逻辑按业务方的误报预算单独配置。这样做的好处是,业务方想收紧或放宽,改配置就行,不用重新训练模型。
阈值这块,我一般按周统计一次人工复核队列的结论,反推当前阈值下的实际精确率。如果复核队列里确认是钓鱼的比例连续两周低于六成,说明阈值偏松,要往上抬;如果漏检投诉变多,说明偏紧。这个反馈环比任何离线指标都真实。
漂移监控我只盯三样东西,多了看不过来。一是模型输出的概率分布,正常情况下一周内的分布形状应该变化很小,突然整体右移意味着有大规模的新手法;二是人工复核队列的确认率,它是最直接的业务信号;三是特征缺失率,任何一个特征的缺失率突然跳变,基本都是上游数据源出了问题,这时候模型给什么都不用信。
再补一个实操上的细节:所有判定结果都要把当时的特征快照存下来,保留至少三十天。模型判错的时候,你手里有当时的输入,才能复盘是特征错了、阈值错了还是标签错了。没有快照,复盘就只能靠猜,那种感觉比调参调到半夜还难受。
这份资源里的脚本和特征清单可以直接拿去改:把样本路径换成你自己的数据,把品牌词表换成你业务里真正在用的那批,先跑通一个能出 PR-AUC 的基线,再往上叠特征。别一上来就追求高指标,先让离线指标和线上表现对得上,这件事比涨两个点重要得多。
我现在的习惯是:任何一个新模型进评估之前,强制走一遍分组切分、时间切分、误报预算三件事,缺一件就不看指标。这个习惯是被前面那五条返工逼出来的,希望帮到你。
本文还有配套的精品资源,点击获取