ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于机器学习的钓鱼网站与钓鱼邮件识别模型实战指南

基于机器学习的钓鱼网站与钓鱼邮件识别模型实战指南 简介面向网络安全学习者和反钓鱼系统开发者这份资料包围绕“识别钓鱼网站与钓鱼邮件”提供了完整的机器学习模型实现。项目涵盖数据预处理、词向量构建、特征提取和多种分类模型训练能帮助用户理解从URL和邮件文本中识别欺诈威胁的完整流程。资源共14个文件包括10个Python脚本和4个CSV数据集压缩包仅339KB。Python脚本覆盖TextCNN、LSTMCNN、简单神经网络等模型实现并配有word2vec工具和数据处理脚本CSV文件包含钓鱼与非钓鱼样本可直接用于训练和验证。目前已有241人学习浏览适合有一定Python基础、希望快速搭建反钓鱼分类基线模型的读者。借助该资源用户可以对比不同深度模型的效果学习文本分类任务的调参思路并在此基础上扩展自己的检测方案无论是课程设计还是实际项目预研都能从中获得可运行的基线参考。1. 从钓鱼邮件到钓鱼网站为什么这套识别模型能当显微镜用做邮件网关安全这几年真正难住我的不是垃圾广告而是那种伪装成系统升级的钓鱼邮件。点开链接是高仿官网用户名密码一填就被收割。黑名单规则永远慢半拍因为攻击者可以换域名、换IP但钓鱼页面的结构、邮件正文的措辞里总有一些改不掉的指纹。这份建立识别钓鱼网站邮件的机器学习模型资源就是把这些指纹变成可计算特征训练一个能自动打分的模型。它不依赖单个域名库而是综合看到 URL、邮件头和 HTML 源码里的异常信号。适合正在做安全运营的工程师也适合想入门机器学习应用的新手。跟着复现一遍你能理解特征怎么提取、模型怎么选、上线后为什么翻车以及我踩过的几个真坑。2. 数据准备与特征工程先学会给网页和邮件拍照钓鱼识别本质上是一个二分类问题但模型能看多细取决于你给它什么。我第一次做的模型用了不到 10 个特征效果惨不忍睹。后来把特征拆成三组URL 结构、邮件头验证结果、HTML 邮件正文的内容指纹。下面逐个说。2.1 URL 结构特征别只盯域名后缀大家习惯看域名后缀以为.com的比.xyz的安全实际上钓鱼网站大量使用免费域名和跳转短链后缀根本不够用。我一般会把 URL 拆成以下维度长度、是否用 IP 代替域名、连接符号数量、是否有端口号、路径中是否夹杂关键字、字母数字与特殊符号比例。攻击者为了让 URL 看起来像官方经常在路径里塞入login、verify等单词但域名本身却是一串随机字符这种矛盾就是强信号。import re from urllib.parse import urlparse def extract_url_feature(raw_url): parsed urlparse(raw_url.lower().strip()) host parsed.hostname or path parsed.path or fea { url_len: len(raw_url), host_len: len(host), is_ip: 1 if re.match(r^\d\.\d\.\d\.\d$, host) else 0, special_char_cnt: len(re.findall(r[?_], raw_url)), hyphen_cnt: host.count(-), has_port: 1 if parsed.port else 0, digit_alpha_ratio: len(re.findall(r\d, host)) / (len(re.findall(r[a-zA-Z], host)) 1), path_has_auth_word: 1 if re.search(r(login|signin|verify|account|update), path) else 0 } return fea这里有几个关键点。is_ip判断是否直接用 IP 访问正常网站很少裸 IP钓鱼网站为了省成本经常这么干。digit_alpha_ratio分母加了 1 防止除零同时让纯数字 IP 的特征在分母变小时也稳定。path_has_auth_word抓的是路径里有没有仿真登录页的关键词很多钓鱼 URL 为了让受害者相信是真的会把 path 伪装成/login/check.html。这些代码可以直接用一个函数包起所有 URL变成一条特征字典方便后面转 DataFrame。2.2 邮件头与正文特征从伪造发件人到 HTML 隐藏文字邮件钓鱼的特征更多在协议行为里。最典型的是伪造发件人很多用 Java 或 Python 写的伪造信根本不走正规调用直接在 SMTP 头里把From改成 supportpaypal.com但它在Received、Message-ID等头里会露出马脚。注意不要只提取From字段要分解出from_domain、reply_to_domain、return_path_domain三者是否一致。同时要关注Authentication-Results头里spf、dkim的验证结果这是邮件接收服务器已经替我们做过一轮判断的中间结果。import email from email import policy from email.parser import BytesParser def extract_email_header_features(raw_eml: bytes): msg BytesParser(policypolicy.default).parsebytes(raw_eml) from_domain msg.get(From, ).split()[-1].strip().lower() reply_domain msg.get(Reply-To, ).split()[-1].strip().lower() return_path msg.get(Return-Path, ).split()[-1].strip().lower() auth_header msg.get(Authentication-Results, ) return { from_reply_consistent: 1 if from_domain reply_domain else 0, from_returnpath_consistent: 1 if from_domain return_path else 0, has_auth_missing: 1 if Authentication-Results not in msg else 0, spf_fail: 1 if spffail in auth_header.lower() else 0, dkim_fail: 1 if dkimfail in auth_header.lower() else 0, }这段代码处理的是eml原始文件适合从客户那边导出的邮件样本。policy.default可以正确解析嵌套和编码避免用老式stdlib的Parser压掉中文邮件头导致报错。这里我把has_auth_missing单独列为特征因为内网测试邮件往往根本没有认证结果这个缺失本身就是一个可疑信号。但后面在避坑章我会讲spffail不能直接用很多正常企业没配 SPF 会误杀。HTML 正文的特征我建议单独做不要和纯文本混在一起。钓鱼邮件最常用的手法是在 HTML 里把链接地址显示成安全文本但href指向危险网站。需要提取所有a标签的href和锚文本计算两者域名不一致的数量。同时要统计邮件 HTML 里是否有编码混乱的中文比如把账 户中间插空格来绕过分词。我在实战里一般用 BeautifulSoup 解析因为发来的邮件 HTML 很多是残缺的lxml会报错html.parser更能容错。2.3 数据集怎么选公开样本和自己标注的边界拿到这个资源包之后第一步不是跑模型而是确认训练数据。公开的钓鱼样本库有 PhishTank、SpamAssassin 邮件集但注意这些样本的时效性钓鱼网站平均生存时间只有几天两周前的样本特征可能已经过时。另一方面企业内网的正常邮件和公开的enron语料风格差异巨大。我一般建议按 3:1 的比例混用公开样本和你自己标注的近期邮件。自己标注归自己标注这里必须做交叉验证。我的标注流程是先拉取最近 10 天被 SpamTitan 或类似的网关判杀的邮件再抽样人工确认只保留那些确定是钓鱼的垃圾广告单独归一类。别把垃圾广告当钓鱼标二者的链接密度和正文措辞差别很大混在一起会让模型学到错误边界。为了省事可以把整个数据集按下面表格的样子管理起来。特征维度原始字段处理方式典型钓鱼信号URL 结构主机名、路径、端口按 2.1 代码提取IP直连、路径含login、符号过多邮件头验证SPF/DKIM/DMARC 结果解析 Authentication-Results三者全失败或缺失发件人伪装From / Reply-To / Return-Path域名一致性比较显示名称与域名无关HTML 内容a 标签、图片链接比较 href 与锚文本锚文本是官方域名href 是另一个域名数据时效样本入库时间按天打权重超过两周降权表格里的数据时效是我后来加上去的特征不是直接用日期而是把样本入库时间和当前时间做差得到一个年龄。因为攻击者重复使用同一套模板的频率很高老样本对新变种的贡献有限给旧样本降权能让模型更敏感。这个观点不一定写在任何教科书里但我在多个线上模型上试过效果稳定。3. 建模选型与训练随机森林为什么比深度学习更适合你很多人一看钓鱼识别就觉得得上 BERT、LSTM我的看法是如果你的样本量只有几万条且特征已经手工提取传统机器学习足够而且部署成本低、推理快。深度学习在图像识别上有优势但钓鱼 URL 和邮件是强结构化数据文本又是短文本随机森林和梯度提升树能打得很好。3.1 小样本下的模型选择深度学习未必有优势钓鱼样本的获取成本很高。一个企业安全团队每天新增的钓鱼样本可能只有几十到几百条去掉误报后更少。用深度学习训练 100 万条数据可能有效但给你 1 万条训练集LSTM 很容易过拟合而且调参工作量巨大。相比之下RandomForest和XGBoost在几千维特征上就能稳定工作。更重要的是安全运营者需要解释为什么拦截这封邮件。随机森林可以用特征重要度回答而神经网络是个黑匣子出了问题难以向领导解释。所以资源包里的默认训练脚本选了随机森林作为基线。3.2 特征矩阵构建与训练脚本以随机森林为例在拿到特征表后下一步是把它拼成训练矩阵。常见问题是特征里有NaN或字符串随机森林吃不下字符串必须先把离散特征做编码。这里有两条路一条是sklearn的OrdinalEncoder另一条是把字符串变成哈希。我强烈建议用DictVectorizer直接把每个样本的特征字典膨胀成稀疏矩阵简单不易出错。import pandas as pd from sklearn.ensemble import RandomForestClassifier from sklearn.feature_extraction import DictVectorizer from sklearn.model_selection import train_test_split, GridSearchCV from sklearn.metrics import classification_report # samples [dict1, dict2, ...] 每条样本都是 {feature_name: value} vec DictVectorizer(sparseTrue) X vec.fit_transform(samples) y labels # 0 正常1 钓鱼 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy ) model RandomForestClassifier( n_estimators300, max_depth10, class_weightbalanced, random_state42 ) model.fit(X_train, y_train) print(classification_report(y_test, model.predict(X_test)))注意几个参数的含义。class_weightbalanced是处理正负样本不平衡最简单的手段它会根据类别频率自动给少数类放大权重比手动上采样省事。max_depth10限制深度防止树对噪声过拟合如果样本量再大可以把深度调到 15。test_size0.2并且用了stratifyy确保测试集里正负样本比例和原始一致不然模型把全部样本判为正常时准确率也能到 90%你会误以为打赢了。3.3 评估指标不要只看准确率安全场景里准确率是骗人的。假设钓鱼邮件占比 5%模型把所有邮件都判为正常准确率就是 95%但它一个钓鱼都没拦截。我们至少要看精确率和召回率再结合业务确定偏重哪一边。指标含义安全运维偏好精确率 Precision被判为钓鱼的里面有多少是真钓鱼追求少误报时调高召回率 Recall真钓鱼里有多少被拦下追求不漏报时调高F1两者调和平均默认均衡使用假阳性率正常邮件被错杀比例尽量控制在 0.1% 以下对安全运营来说漏报比误报更危险。账号被盗后的损失远超一封正常邮件进垃圾箱的代价。所以我在调参时优先保证召回率在 95% 以上在这个前提下再去压假阳性。可以在训练后用predict_proba的值画 PR 曲线选一个让齐全率的点作为阈值。阈值不要用默认 0.5后面部署章节会详细讲。4. 避坑指南钓鱼样本里最常见的 4 个陷阱这套流程我完整跑过三遍每次都在不同环节翻车。有些坑是特征设计本身的问题有些是线上环境与训练环境不一致造成的。下面四个坑按踩到频率排序每一个都是真实教训。4.1 坑一样本不平衡导致模型懒得抓鱼现象训练完成后测试集准确率很高但classification_report里钓鱼类的召回率只有 20%。模型把绝大多数样本判为正常少数钓鱼样本被淹没在正常邮件的海洋里。原因正常样本通常是钓鱼样本的 20 倍以上随机森林在训练时看到大量正常样本会让叶子节点纯度偏向于多数类。没有设置class_weight或不做重采样时模型学到的经验是只要猜正常错误率就很低。解决在RandomForestClassifier里显式设置class_weightbalanced。如果样本量更大我还会用imbalanced-learn的SMOTE生成少量合成样本但要注意 SMOTE 对稀疏特征矩阵会出警告先做tsne看看是否值得。最省事的是先设class_weight然后看召回率变化。4.2 坑二特征编码不一致线上加载模型直接报错或得分异常现象训练时特征向量维度是 152部署时对新的 URL 提特征predict_proba返回的结果全是一半甚至报ValueError: X has different number of features。原因DictVectorizer的特征名集合是训练时定下的线上如果用了不同的 URL 提取逻辑比如多了一个特征或少了一个向量维度就对不上。比如训练时提取了has_port线上代码里搞漏了后面的特征顺序全部错位。解决绝对不要自己手工拼特征列表。训练完就把vec和model一起保存部署时加载同一个vec来 transform。我会用joblib.dump(vec, vec.joblib)和joblib.dump(model, model.joblib)加载后禁止改动特征工程代码。这个教训让我养成了每次特征工程都写单测的习惯。4.3 坑三HTML 邮件里的隐藏文字和图片链接骗过了模型现象模型对纯文本钓鱼邮件准确率很高但遇到外层套了合法企业模板的邮件就漏——攻击者把官方免责声明放进去页面上看不见的字堆满下方模型提取的都是这些影子文本。原因只提取了可见文本用BeautifulSoup.get_text()会把 HTML 里的所有文字都提出来包括 CSS 隐藏的display:none的内容。这些内容往往是正常公司声明导致特征被稀释。更隐讳的是图片里可能嵌着钓鱼诱导文字模型完全看不见。解决一方面在提取文本前先删除所有带styledisplay:none或hidden属性的节点另一方面不要只信文本要统计img标签中src指向的域名分布。如果一个邮件中有超过一半的图片来自陌生域名应该把img_foreign_ratio作为一个强特征加入。类似href与锚文本不一致也要对src做同样的域名一致性检查。4.4 坑四SPF 验证结果当成强特征正常域名被大量误杀现象上线第一周模型拦截率很高但用户投诉暴增。一看拦截记录很多是外贸公司通过自建邮件服务器发的正常营销信它们的 SPF 记录根本没配置Authentication-Results里全是spffail。原因我把spf_fail这个特征赋予了过高的权重随机森林只要看到这个特征就容易判钓鱼。实际上小型企业的邮件系统很多不规范SPF 缺失不代表恶意。解决把spf_fail改成SPF 记录是否存在且失败的复合特征如果 SPF 记录不存在标记为 0如果存在但失败标记为 1如果通过标记为 2。用分类特征而不是二值特征模型能学到中间状态。同时我加入了发件人域名的年龄如果一个域名是最近 7 天新建的即便 SPF 失败也可疑如果域名已经用了 5 年SPF 失败的影响就稀释。这个调整让误报率降了一半多。5. 把模型部署到邮件网关或浏览器插件阈值、API 与规则降级模型在 Jupyter Notebook 里跑出好分数只是开始真正拿到生产环境用还要处理实时打分、阈值选择、与已有规则系统的配合。5.1 模型导出与阈值设定训练完的随机森林模型可能有一百多个树但文件也就几十兆。用joblib打包后可以挂到网关旁边的打分服务上。阈值不能直接取 0.5这是我一开始犯的错。钓鱼邮件的概率分布通常不是均匀的大量垃圾信得分在 0.2 以下真钓鱼集中到 0.8 以上。直接在验证集上画 PR 曲线选一个召回率 95% 对应的阈值作为拦截线。在业务上这个阈值对应的是宁可多拦 5% 的疑似邮件也不漏掉一个钓鱼。import joblib from sklearn.metrics import precision_recall_curve vec, model joblib.load(phishing_model.joblib), joblib.load(phishing_vec.joblib) # 假设 X_val 是验证集特征矩阵 proba model.predict_proba(X_val)[:, 1] precision, recall, thresholds precision_recall_curve(y_val, proba) # 找第一个召回率 0.95 的阈值 for p, r, th in zip(precision, recall, thresholds): if r 0.95: print(取阈值:, th, 精确率:, p) break这里有一个关键点precision_recall_curve返回的阈值是变化的选点要从后往前找保证当前召回率第一次超过 0.95。选完之后把这个阈值写进配置不放进模型本身方便后续调整。5.2 用 Flask 起一个实时打分 API网关收到 URL 或者邮件原文后需要把它变成模型能识别的特征。我一般用一个 Flask 接口来封装这样网关只要POST一行 JSON 就能拿到得分。from flask import Flask, request, jsonify import email from email.parser import BytesParser from email import policy app Flask(__name__) app.route(/score_url, methods[POST]) def score_url(): data request.get_json() raw_url data[url] fea extract_url_feature(raw_url) # 2.1 的函数 fea_vec vec.transform([fea]) score float(model.predict_proba(fea_vec)[0, 1]) return jsonify({score: score}) app.route(/score_eml, methods[POST]) def score_eml(): raw_eml request.data msg BytesParser(policypolicy.default).parsebytes(raw_eml) fea extract_email_header_features(raw_eml) # 2.2 的函数 fea.update(extract_html_features(msg)) # 2.2 里提到的 HTML 特征 fea_vec vec.transform([fea]) score float(model.predict_proba(fea_vec)[0, 1]) return jsonify({score: score}) if __name__ __main__: app.run(host0.0.0.0, port8080)extract_html_features我留了个空壳实际要根据你自己的解析器来写。注意一个容易忽视的点网关请求这个接口时score_url用 JSONscore_eml用的是原始字节流。因为邮件是二进制格式用 JSON 做 base64 传输会有多余开销直接传application/octet-stream更稳。打分服务的超时要设置成 500ms 以内邮件网关的队列等待不能太久。5.3 与现有 SPF/RBL 规则配合先杀高置信度再让模型决定灰名单生产环境不指望模型单打独斗。我习惯把拦截逻辑分成三层。第一层是 SPF/DKIM/DMARC 三个全失败且发件人域名新注册直接拒收第二层是 RBL 黑名单命中进入隔离区第三层才是模型它只处理前两层不判定的中间地带。这样做的好处是模型可以承受一定的误报因为它影响的只是需要人工复核的灰名单。在邮件网关的面板上我会把模型的得分映射成三个动作得分小于 0.3 直接放行0.3 到 0.7 之间给标题加上 [可能钓鱼]; 超过 0.7 自动进入隔离区。对于浏览器插件的场景score_url接口可以在用户点击链接前用a标签拦截逻辑请求接口返回风险提示。插件的难点不在接口而在浏览器扩展的权限限制需要用户授权webRequest权限才能看到访问的 URL。6. 验证模型不翻车回放测试与对抗样本自检模型上线后不能只看监控面板的拦截率我每个季度会做一次回放测试。把过去 90 天所有被标记为钓鱼的邮件重新跑一遍模型看能拦下多少同时抽样正常的邮件计算误报率。更实战的做法是把模型对新样本的预测结果和人工标审计结果对照建立样本漂移指标。如果新样本的特征分布和训练集差异越来越大说明模型已经老化需要重新训练。我一般会从邮件网关导出一批eml和 URL 日志自己写一个回放脚本import os from torch.hub import load # 仅供示范实际不用 # 实际回放脚本很简单遍历目录对每个 eml 调用 /score_eml 接口收集得分 def replay_eml_dir(eml_dir, label_dict): for fname in os.listdir(eml_dir): with open(os.path.join(eml_dir, fname), rb) as f: score call_score_api(f.read(), eml) true_label label_dict.get(fname, 0) if (score THRESHOLD) ! bool(true_label): print(误判:, fname, score)对抗样本自检是我后来才补的。攻击者会利用特征缺失来绕过比如把邮件全部用base64编码让关键词提取失效。我的习惯是用现有训练好的模型手工构造一批变异样本把 URL 里的点换成中文句号、把http改成hxxp、把邮件正文里的敏感词拆成字母加空格。模型如果对这些变异样本的得分从 0.9 跌到 0.2就说明特征依赖太死板需要增加对相似字符的归一化处理。从那以后我每次更新模型都强制走一遍回放 对抗样本自检确认回归指标不降才允许部署。这个习惯帮我躲过了两次因为特征编码更新带来的模型失效希望也能帮你在上线前堵住漏洞。本文还有配套的精品资源点击获取
返回列表