资讯动态

钓鱼网站与邮件识别:基于机器学习的样本构建与特征工程实战

发布时间:2026/10/9 14:30:34 来源:尧图企业网站定制
简介面向网络安全与机器学习交叉领域的学习者这是一个以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_lenlength, digit_ratioround(digit_ratio, 4), special_ratioround(special_ratio, 4), dot_countdot_count, suspicious_wordssuspicious_words, domain_entropyround(entropy, 4), has_iphas_ip) def email_text_features(raw: bytes) - dict: from email import policy from email.parser import BytesParser msg BytesParser(policypolicy.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(rhttps?://[^\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_lenlen(msg.get(subject, ) or ), body_lenlen(norm_body), url_countlen(urls), reply_mismatchint(reply_domain ! from_domain), has_attachmentint(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_size0.2, stratifydf[label], random_state42) X_url_train url_feat.loc[train_df.index] X_url_test url_feat.loc[test_df.index] tfidf TfidfVectorizer(max_features5000, ngram_range(1, 2), min_df2) 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_estimators300, max_depth12, min_samples_leaf4, class_weightbalanced, n_jobs-1, random_state42) 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_transformTF-IDF 会统计到测试集词频相当于信息泄漏测试评估会虚高。TfidfVectorizer 只对训练集 fit测试集只 transform保证线上推理时词表口径一致。sparse.hstack 把 URL 结构化特征和文本特征拼成稀疏矩阵避免稠密向量内存暴涨。分类报告要重点看宏平均 F1 而不是准确率。如果正常样本占 90%全预测成正常就有 90% 准确率但钓鱼一条都没拦下来。class_weight 设成 balanced让少数类钓鱼样本在损失函数中获得更高权重。random_state42 固定下来保证每次训练结果可复现排查问题时不至于被随机性干扰。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?verifyabc123 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(ftarget_recall{target:.2f}, threshold{thr[idx]:.3f}, precision{prec[idx]:.3f})逻辑是按目标召回率反推阈值。比如要求钓鱼样本召回率不低于 0.95就在验证集上找满足条件的最大的阈值保证召回的同时尽量压低误报。上线时先用这个阈值做影子模式跑几天根据误报数据进行微调不建议直接压到生产环境。6.2 上线第一周盯三个指标指标计算方式调整动作误报率拦截样本中人工复核为正常的比例误报偏高上调阈值漏报率用户举报/事后确认钓鱼中未被拦截的比例漏报偏高下调阈值延宕时长邮件进入系统到给出预测耗时超时检查特征提取瓶颈上线第一周每天抽出 20 条被拦截样本让分析师复核重点看误报。如果误报集中在某个正常业务域名可能是特征误伤记录特征值再决定加白名单还是调阈值。最后说一个个人教训我有一次在模拟项目X里把离线 F1 做到了 0.97上线当天就被业务方投诉误拦了正常营销邮件。查了两天发现推理端正则表达式少了个 raw 前缀导致反斜杠被转义URL 解析路径全变了。从那之后我再也不允许训练和推理各写一份特征代码压缩包里最重要的不是模型权重而是那份统一的特征提取模块。模型可以重训特征口径一旦崩了再好的权重都是摆设。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑