资讯动态

识别钓鱼网站/邮件的机器学习模型落地:特征工程与XGBoost实战

发布时间:2026/10/9 14:52:51 来源:尧图企业网站定制
简介资源包围绕“钓鱼网站与钓鱼邮件识别”提供了一整套可运行的机器学习建模方案适合网络安全方向的学生、算法工程师以及需要快速落地检测原型的研究者。压缩包共14个文件包含10个Python脚本与4个CSV数据文件整体仅339KB其中脚本覆盖数据处理、Word2Vec词向量工具以及TextCNN、LSTM、CNN、SimpleNN等不同网络结构的训练与预测流程数据文件则用于模型训练与验证。目前已有241人学习使用这份资源。通过阅读代码可理解从原始文本/URL到特征构造、模型搭建、训练评估的完整链路尤其适合希望对比多种深度学习文本分类模型在钓鱼检测任务上效果的读者。整套代码结构清晰、模块化程度高便于在此基础上扩展特征或调整参数。1. 真正把“识别钓鱼网站(邮件)的机器学习模型”落地成生产服务绕不开三个现实问题多数安全团队的第一版钓鱼检测靠的是关键词黑名单和域名信誉库命中“login”“verify”就加分域名第一次见就拦截。这套规则能挡住最蠢的一批攻击但当攻击者把字母 o 换成数字 0、把短链接包在正常域名后面、用一次性域名每天换新马甲时规则库的更新速度永远追不上攻击节奏。我做过两轮这类系统的重构最后的结论都一样规则负责解释统计模型负责泛化两者必须结合。这篇文章要讲的就是围绕“识别钓鱼网站(邮件)的机器学习模型”这套方案从零搭起来的过程——特征体系怎么定、训练集怎么造、XGBoost 怎么调、线上怎么接、哪些坑一定会踩。适合正在做反钓鱼、反垃圾邮件或 URL 安全检测的从业者也适合准备把检测能力从规则升级到模型的团队照着走一遍。2. 先解决“喂什么”钓鱼样本的特征体系与训练集构建方法2.1 模型输入到底长什么样统一 URL 和邮件两条数据线钓鱼网站检测和钓鱼邮件检测很多人当成两个项目做但落到最后都是“给定一段原始文本输出 0/1 标签”的二分类问题。网站侧输入是一条 URL邮件侧输入是一封原始邮件通常叫 EML 格式我们需要把这两类异构输入归一化成同一套特征向量。我一般把原始数据按行存成 JSONL每个样本带类型标记方便后面分流处理# sample_schema.jsonl 的单行示例 {sample_id: 20240511_0001, type: url, timestamp: 2024-05-11 09:30:15, source: user_report, content: https://apple-icloud-verify.com/login?sessionabc} {sample_id: 20240511_0002, type: email, timestamp: 2024-05-11 09:31:02, source: mail_gateway, content: From: supportverify-now.net\nTo: victimcorp.com\nSubject: Your account has been locked\n\nPlease click the link below to verify your password...}这里要注意一个细节URL 样本的 content 就是完整 URL邮件样本的 content 是原始邮件文本特征提取时要先把 header 和 body 拆开。source 字段很重要它既是样本溯源依据也是后面做时间切片验证集时避免数据泄漏的参考键。2.2 URL 特征工程从域名到路径的 24 个可计算特征我把 URL 特征分成四组结构特征、域名特征、关键词特征、混淆特征。结构特征直接从字符串算域名特征需要依赖 WHOIS 或证书信息关键词特征用预置词表做匹配混淆特征针对字符替换攻击。下面这张表是我在项目里实际用到的最小特征集其中带星号的是可以离线算、不需要外部接口的特征名类型说明与计算方式url_length*数值URL 总长度钓鱼链接普遍偏长domain_length*数值主域名长度不含协议和路径subdomain_count*数值点分隔后的子域数量钓鱼常用多层子域伪装path_segment_count*数值路径段数量has_ip*布尔URL 中是否直接使用 IP 地址has_port*布尔是否带了非常规端口uses_https*布尔是否使用 HTTPS注意钓鱼站也有证书cert_valid_days数值证书剩余有效天数钓鱼证书通常很短domain_age_days数值域名首次出现距今的天数新域名风险高domain_is_tld*布尔域名是否只由顶级后缀构成entropy*数值域名字符熵随机生成的钓鱼域名熵值偏高keyword_black_count*数值命中 login/verify/account/password 等词的数量keyword_white_count*数值命中合法高频词product、news的数量homoglyph_score*数值字符替换后的视觉相似度如 apple 与 app1ebrand_sim_max*数值与常见品牌域名的最小编辑距离url_redirect_count数值重定向跳转次数需要请求解析final_url_diff*数值最终 URL 与初始 URL 的字符串差异度tld_suspicious*布尔是否使用 .top/.xyz/.icu 等高危后缀homoglyph_score 和 brand_sim_max 是两个容易被忽略但非常有效的特征。攻击者把 apple 中间的 p 换成数字 1肉眼几乎看不出但用编辑距离一算它与真实品牌的差异可能只有 1 到 2 个字符。实现上可以维护一个品牌白名单词典包含常用电商、银行、社交平台域名对 URL 主域名做模糊匹配。之所以把证书和域名年龄单独拆出来是因为这两个特征在真实标注里区分度很高。一个刚注册 3 天的域名、证书有效期只有 90 天、又挂着 6 层子域几乎可以断定是钓鱼设施。但这里有个边界条件域名年龄需要历史数据支撑离线冷启动阶段没有这个字段我通常会先把它置为默认值模型训练时不参与分裂。2.3 邮件特征工程从邮件头到正文的三层结构邮件样本比 URL 多一层信息维度发件人身份、认证结果和正文内容。这层信息对判断“是不是钓鱼”极其重要因为大量钓鱼邮件会伪装成系统通知或同事来信。第一层是认证结果特征。邮件经过网关后会追加认证头包含 SPF、DKIM、DMARC 的 pass/fail 状态我直接把这些状态编码成三个布尔特征和一个综合分。核心思想是发件人域名认证失败但正文里出现的链接域名与发件人域名不一致这是钓鱼邮件的经典组合。第二层是正文特征。统计正文里出现的链接数量、链接域名是否与发件人域名一致、是否包含“立即登录”“账号异常”“密码过期”等诱导词。这里要特别提一个我踩过的坑单纯统计关键词命中次数会误伤大量正常营销邮件。所以我在实现里加了“关键词命中率”而不是“关键词命中数”分母是正文总词数这样长邮件里出现一两次关键词不会拉高分数。第三层是附件特征。钓鱼邮件经常带一个伪装成 PDF 的 HTML 文件或带宏的 Office 文档。附件特征我一般取文件扩展名、MIME 类型、是否压缩包、文件名是否包含“invoice”“report”等词。文件内容本身不送进模型先交给沙箱跑沙箱的结果作为离线特征回填。def extract_email_features(raw_email: str) - dict: header, body parse_email(raw_email) # 自定义解析函数 auth parse_authentication_results(header) # 读取 SPF/DKIM/DMARC links extract_links(body) # 提取正文所有 URL return { spf_pass: int(auth.get(spf) pass), dkim_pass: int(auth.get(dkim) pass), dmarc_pass: int(auth.get(dmarc) pass), link_count: len(links), link_domain_mismatch: int(any(link_domain(link) ! sender_domain(header) for link in links)), urgent_keyword_rate: count_urgent_words(body) / max(len(body.split()), 1), has_attachment: int(attachment in header.lower()), attachment_ext_exe: int(.exe in header.lower() or .scr in header.lower()), }这段代码的逻辑顺序是先解析再提取最后产出 8 个特征。其中 link_domain_mismatch 是专门处理“伪装成同事邮件但链接指向外部域名”这种情况的。attachment_ext_exe 只做快速标记如果邮件系统不允许发带 exe 的文件这个特征也可以直接移除。2.4 训练集的正确比例时间切片比数据数量更关键反钓鱼模型与传统分类任务最大的区别在于数据的时效性极强。今年 1 月的钓鱼样本特征分布到 5 月可能已经变化了 30%因为攻击者会根据检测规则调整攻击手法。训练集的正负样本比例我一般控制在 1:5 到 1:10 之间。正样本来自威胁情报平台和用户举报的钓鱼链接负样本来自公司内部邮件网关放行的正常邮件和一批准许访问的业务网站 URL。负样本数量太少模型会倾向于把什么都判成钓鱼负样本太多正样本的召回率又会掉得厉害。构建训练集时有个强制要求按时间切分而不是随机切分。随机切分会让训练集和验证集里出现大量来自同一个攻击域的链接模型相当于见过答案再考试。正确做法是按周切import pandas as pd df pd.read_json(dataset.jsonl, linesTrue) df[week] pd.to_datetime(df[timestamp]).dt.isocalendar().week train_df df[df[week] 20].copy() val_df df[df[week] 20].copy()这段脚本的逻辑很直白拿 timestamp 解析出自然周前 19 周的数据做训练第 20 周及之后的数据做验证。这种切分能真实反映“模型用过去预测未来”的生产场景。我见过不少项目在这里图省事直接 random_split结果线下 AUC 0.98线上一上线误报率直接崩到 30%数据泄漏是罪魁祸首。3. 用 XGBoost 把特征变成可上线的检测模型训练脚本与核心参数3.1 特征提取模块离线训练和在线推理必须共用的同一套代码训练和推理必须共用特征提取代码这是整条链路里最容易翻车也最容易修复的一条原则。很多团队离线用 Python 脚本跑特征、线上用 Java 重新实现一遍两个实现里 URL 解析规则稍有不同结果就是模型在离线评估集上表现优异、一到线上就失灵。我习惯把特征提取封装成独立模块里面只依赖标准库和 pandas不依赖训练框架import re import pandas as pd from urllib.parse import urlparse def extract_url_features(url: str) - dict: parsed urlparse(url) host parsed.hostname or segments [s for s in parsed.path.split(/) if s] return { url_length: len(url), domain_length: len(host), subdomain_count: host.count(.), path_segment_count: len(segments), has_ip: int(re.match(r^\d\.\d\.\d\.\d$, host) is not None), uses_https: int(parsed.scheme https), entropy: len(set(host)) / max(len(host), 1), keyword_count: sum(k in url.lower() for k in [login, verify, account, password]), tld_suspicious: int(host.endswith((.top, .xyz, .icu, .tk))), } def build_feature_matrix(samples_df: pd.DataFrame) - pd.DataFrame: rows [] for _, row in samples_df.iterrows(): if row[type] url: f extract_url_features(row[content]) else: f extract_email_features(row[content]) f[label] row[label] rows.append(f) return pd.DataFrame(rows)这里 extract_url_features 返回 9 个基础特征extract_email_features 在上一节已经给出。build_feature_matrix 做了两件事按样本类型分流处理、把结果聚合成 DataFrame。注意每行都保留了原始 label特征提取不负责采样和清洗职责单一后续改特征时只改这一个文件。3.2 训练脚本与 XGBoost 参数从默认参数到可用模型的四个改动训练脚本我直接基于 XGBoost 的 sklearn 接口写方便接 pandas DataFrameimport xgboost as xgb from sklearn.model_selection import StratifiedKFold df build_feature_matrix(pd.read_json(dataset.jsonl, linesTrue)) X df.drop(columns[label]) y df[label] model xgb.XGBClassifier( n_estimators300, max_depth5, learning_rate0.1, subsample0.8, colsample_bytree0.8, scale_pos_weightlen(y[y 0]) / max(len(y[y 1]), 1), eval_metricaucpr, random_state42, ) model.fit(X, y)这里最值得展开的是 scale_pos_weight 这个参数。反钓鱼数据正负比通常在 1:8 左右如果不调整类别权重模型会倾向于把所有样本预测为“正常邮件”来压低损失。scale_pos_weight 取负样本数量除以正样本数量等于给正样本的误差惩罚加了约 8 倍权重XGBoost 会因此更努力地去学钓鱼样本的特征模式。max_depth 设 5 而不是默认的 6是我对比了多次实验结果后的选择。钓鱼检测的特征数量不多也就是二十几个树太深容易把这二十几个特征之间的偶然组合学进参数变成过拟合。learning_rate 0.1 配合 300 棵树是稳妥的组合如果训练时间紧张可以把 n_estimators 降到 200、learning_rate 提到 0.15效果差距不大。eval_metric 选 aucpr 而不是默认的 logloss因为这类任务的评估核心是正样本排序能力PR 曲线比 ROC 曲线更贴近“钓鱼样本占比很低”的真实场景。3.3 模型评估别只看准确率误报率和召回率才是线下的生死线钓鱼检测模型的线下评估分析师最关注的永远是误报率。一个被误判的钓鱼邮件进入拦截队列只是浪费人工审核时间一个被判成“正常”的钓鱼邮件进入收件箱才是安全事故。因此我会同时打印三组指标from sklearn.metrics import classification_report, precision_recall_fscore_support y_pred model.predict(val_X) print(classification_report(val_y, y_pred, target_names[normal, phishing])) # 重点看 phishing 行的 recall 和 normal 行的 precision # recall 决定漏网之鱼比例normal 的 precision 决定误报率通常我会要求验证集上钓鱼类别的召回率不低于 0.92正常类别的精确率不低于 0.998。注意正常类别只有 0.2% 的误报看起来很低但换算到每天几百万封邮件的企业网关就是几千封被拦截的合法邮件。所以在评估时我还会按业务口径统计每百万封合法邮件被误判的数量这个数字比 PRF 指标更容易跟业务方对齐。4. 钓鱼模型避坑记录五个真实踩过的坑现象、原因与解决4.1 坑一验证集随机切分导致 AUC 虚高 10 个百分点现象某版本的模型在训练集上 AUC 0.96验证集也 0.95团队都以为可以上线。结果部署到测试环境跑了三天发现大量来自同一钓鱼域名的历史样本被重复计分去重后验证集 AUC 直接掉到 0.84。原因数据收集阶段把用户举报的同一链接重复入库了多次随机切分时同一个钓鱼链接同时出现在训练集和验证集里。模型在训练时已经见过这条链接的字符串特征验证时再遇到它就相当于开卷考试分数虚高。解决把验证集改成按时间窗口切分并且在做样本去重时以“规范化后的 URL 主域名 路径”为唯一键。规范化包括去掉大小写差异、默认端口、末尾斜杠。这样同一域名的变种链接不会泄漏到验证集评估结果才可信。4.2 坑二关键词命中把正常生日提醒邮件当成了钓鱼现象某企业内部系统的密码到期提醒邮件每天有几百封被钓鱼模型标成高风险全部进入隔离队列。运营一看这些邮件正文确实有“password expire”“click to keep your password”这类词但发件人域名和收件人域名是同一家企业域而且是正常系统邮件。原因模型把正文中的关键词当成了强特征而邮件认证结果特征没有起到应有的压制作用。 SPF/DKIM/DMARC 三项如果全部通过按理说是很强的“非钓鱼”信号但初始模型里这些特征的权重没有压制住关键词特征。解决我在训练前加了一条规则性硬约束SPF、DKIM、DMARC 三项全部 pass 的邮件模型分数上限设 0.4不进入拦截队列。这条约束看起来是回归到规则但实际是把领域知识注入模型边界有效降低了误报。同时把关键词特征从“命中数”改成“命中率”从特征定义上削弱了正文长度带来的偏差。4.3 坑三短链接服务把所有特征都吸走了现象模型上线后短链接类钓鱼样本的检测率只有 30% 左右。人工看曲线发现凡是短链接域名url_length、domain_length、path_segment_count 这几个特征几乎全部落在正常区间模型完全无感。原因短链接的 URL 本身信息量太少域名是专用的短链域路径是一串随机字符没有钓鱼关键词没有可疑子域。模型拿到的特征本身是“空的”再强的算法也算不出结果。解决在特征提取前做一次重定向解析把短链接展开成最终 URL再对最终 URL 做特征提取。重定向解析是标准库 requests 的一个功能设置为不跟随重定向、手动循环拿 Location 头最多跟 5 跳。展开后的 URL 特征基本恢复完整原来的特征体系就重新可用了。4.4 坑四新注册域名钓鱼样本在冷启动阶段全量漏报现象冷启动阶段模型在验证集上的召回率 0.95但上线第一周面对真实攻击时漏掉了 60% 的新域名钓鱼样本。复盘发现漏掉的都是域名注册时间不足 7 天的新账号。原因训练集里域龄特征的中位数是 200 天模型几乎没有见过域龄 7 天以内的正样本自然学不到“新域名倾向恶意”的规律。冷启动阶段样本积累不够不是模型参数能解决的。解决冷启动阶段不依赖单一模型而是模型加规则的并联决策。域名年龄小于 7 天且包含登录关键词、正文带链接的邮件直接走高风险通道年龄小于 24 小时且任何认证失败直接拦截。这个规则池随着模型月度重训逐步缩减权重等模型见过足够多的新域名样本后再完全交给模型。4.5 坑五在线推理与离线特征不一致导致分数漂移现象同一个模型文件离线回放测试时对某条样本评分 0.87在线推理服务中评分只有 0.42相差巨大。原因离线代码里 URL 解析用的是 Python 的 urllib在线服务用了另一种方式去处理 URL 字符串两者对“一次性使用的短链”和“带大小写混淆路径”的处理方式不一样导致三个特征取值不同。解决把特征提取模块做成完全独立的包离线训练和在线推理全部 import 同一个模块禁止两处各自实现。并建立一个 golden set里面放 100 条标注过的特征输入输出对每次改动代码后跑回归对比特征输出不一致就不允许上线。5. 从离线模型到业务接入在线评分、分级处置与可解释证据闭环5.1 服务化封装把训练产物变成可调用的评分接口模型训练完成后的交付物我习惯按一个固定目录结构打包方便后续维护和回溯phishing_model_package/ ├── model.xgb # 序列化模型文件 ├── feature_schema.json # 特征名清单顺序必须与训练一致 ├── extractor.py # 特征提取模块训练与推理共用 ├── train_data_meta.json # 训练数据的时间范围和样本量说明 └── inference_service.py # 在线评分服务入口这种打包方式的价值在于模型文件、特征清单和特征代码放进同一个 ZIP 包部署时直接解压、加载、启动服务不依赖训练环境里的任何临时变量。我看过太多项目只交付一个 model.xgb结果一个月后没人知道这个模型吃的特征到底是什么想重新推理也没有入口。评分服务我用 Flask 封装成一个轻量接口关键实现如下from flask import Flask, request, jsonify app Flask(__name__) model load_model(model.xgb) feature_names json.load(open(feature_schema.json)) app.route(/score, methods[POST]) def score(): payload request.get_json() url payload.get(url) email payload.get(email) if url: feats extract_url_features(url) elif email: feats extract_email_features(email) else: return jsonify({error: no content}), 400 import pandas as pd X pd.DataFrame([feats], columnsfeature_names) prob model.predict_proba(X)[0][1] return jsonify({score: round(prob, 4)})这段代码最需要留意的是 X 的列顺序。XGBoost 在训练时会记住每个特征的位置预测时传进去的特征矩阵列顺序如果和训练时不一致预测结果会完全错乱。所以我用 feature_schema.json 保存训练时的列顺序推理时按这个顺序构造 DataFrame。5.2 分级处置同一个模型分数对应三种业务动作模型输出的 0 到 1 分数不能直接映射成“拦还是不拦”业务上通常分成三档分数区间风险等级处置动作0.90 以上高直接拦截进入隔离区并告警0.65 到 0.90中进入待审核队列邮件正文加警告标记0.65 以下低放行仅记录日志供后续分析阈值不是拍脑袋定的我会参考验证集上正常类别的累计分布选一个阈值让合法样本误判率低于 0.1%再看此时钓鱼样本的召回率是多少。如果召回率太低说明模型能力不够而不是阈值的问题需要回到特征和训练数据去提升。这里还有一个容易被忽略的业务细节高风险拦截区不能直接永久删除邮件而是放入隔离区保留 30 天。钓鱼邮件也是取证材料一旦误拦了某封正常邮件能恢复、能追溯这是安全运营的基本素养。5.3 可解释性让安全运营看得懂模型为什么拦反钓鱼模型面对的内部用户是安全运营团队他们每天要处理大量被判为“高危”的样本。如果模型只给一个分数不说理由运营会完全无法判断是放行还是上报只能继续把所有样本转给更高级别的分析人员整个拦截流程反而变得更慢。XGBoost 的好处是天然支持特征重要性分析我每次训练完都会输出 Top 10 特征贡献度和每个样本的分裂路径摘要import xgboost as xgb booster model.get_booster() importance booster.get_score(importance_typegain) top_features sorted(importance.items(), keylambda kv: kv[1], reverseTrue)[:10] for name, gain in top_features: print(f{name}: {gain:.2f})get_score 里的 importance_type 参数gain 表示特征作为分裂点时带来的平均增益。实际使用中我发现 url_length、keyword_count、link_domain_mismatch 这类特征贡献度最高而 domain_length 贡献度反而低。这说明攻击者已经意识到短域名更隐蔽我们的特征更新也要跟着攻击手法的变化走。运营拿到分数和 Top 特征后就能在工单里写出一段人话“该邮件因发件人域名认证失败、链接域名与发件域名不一致、正文含紧急登录关键词评分 0.95判定为钓鱼”。这段解释是处置合法性的基础。6. 上线之后怎么让模型一直好用滚动重训与半自动反馈闭环模型上线只是开始真正决定长期检测能力的是反馈闭环。我经历过一个典型的退化过程模型上线第一个月效果惊艳第二个月误报率开始缓慢上升第三个月钓鱼召回率从 0.93 跌到 0.85。原因不是模型坏了而是攻击手法在三个月里完成了迭代新的钓鱼域名不再带明显的 long URL正文诱导词从“password expired”变成了“scan QR code to verify”。我现在的做法是把重训周期压到每两周一次每次重训只替换最近两周新增的已标注样本而不是把全部历史数据倒进来重新训练。增量训练的好处是训练时间短、模型对近期攻击手法的适应性更强代价是需要严格控制样本标注质量和数量。新增样本里如果混入一批误报数据模型会迅速被劣质数据带偏。每周做一次分数漂移监控把所有被拦截和待审核样本的分数分布拉出来看。如果某一周模型给“正常邮件”的中位数分数比上一周高了 0.1说明数据分布正在变化预警团队需要检查是不是落在了新的攻击模式上。一个值得推荐的技巧是设置“灰色带回接”机制把分数在 0.4 到 0.65 之间的样本单独导出一个队列让安全运营每周花半小时快速过一遍标记确认结果。这些样本是模型最没有把握的部分也是标注价值最高的部分。拿到这批人工确认结果后直接进入下一轮增量训练。我自己的习惯是把验证集的召回率基准也纳入监控每次重训完记录当天的钓鱼召回率和误报率形成一张趋势表。如果某次重训后召回率反而下降优先检查是不是新增样本污染了标注而不是回滚模型。没有这份趋势记录任何一次模型迭代都会变成碰运气。这个领域的攻击者和防守者始终在拉锯没有一劳永逸的模型只有持续跟进的检测闭环。希望这篇文章的思路能帮你少走几步弯路把自己的钓鱼检测从规则时代推进到“规则 模型 反馈”的稳定状态。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑