ARTICLE DETAIL

资讯详情

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

启发式特征与机器学习:钓鱼网站检测系统实战解析

启发式特征与机器学习:钓鱼网站检测系统实战解析 简介基于启发式特征的钓鱼网站检测系统是一份可以直接运行的 Python 项目源码面向网络安全学习者、Web 开发者及对反欺诈技术感兴趣的初学者完整呈现了从启发式特征提取到机器学习模型构建的检测链路。压缩包内共 2 个 py 文件总大小仅 7KB代码精简、注释直观方便快速阅读核心实现也适合在此基础上二次扩展。已有 180 人学习下载。项目在特征工程层面覆盖域名关键词、URL 长度、HTML 标签结构、TF-IDF 文本相似度等多个判断维度并结合 requests、BeautifulSoup、scikit-learn 等库演示数据采集、特征处理、模型训练与预测评估流程同时还涉及朴素贝叶斯、决策树、随机森林等算法的选型对比以及数据更新与模型优化思路。通过学习这一项目读者可以掌握钓鱼网站自动识别的基本原理与 Python 实现要点为后续构建更完整、更可靠的反钓鱼安全工具打下扎实基础。1. 钓鱼网站检测启发式特征这条路为什么值得走钓鱼网站检测最常见的做法是维护黑名单把已知的恶意域名存进一张表里来一个请求匹配一次。这样做见效快但钓鱼者换个域名、改个路径黑名单还没更新用户就已经中招了。基于启发式特征的检测系统思路不一样它不看“这个域名以前干没干过坏事”而是分析“这个页面长没长坏人的样子”。域名结构、URL 长度、HTML 结构、文本相似度这些特征从页面本身提取出来交给机器学习模型打分未知域名也能直接判。这套系统适合两类人一类是安全方向的学生想拿真实项目练手另一类是刚接触网站安全的产品或运维需要低成本搭一个检测模块跑通从特征提取到模型预测的完整链路。2. 启发式检测原理与数据集准备先搞清楚在检测什么再动手抓数据2.1 启发式特征和黑名单、白名单的本质差别黑名单是“我认识坏人”启发式是“我认得坏人的习惯”。黑名单系统的逻辑是精确匹配如果 URL 命中已知钓鱼库直接拦截。优点是误报率极低缺点是钓鱼域名存活时间短、更新太快黑名单永远有滞后性。白名单正好反过来只放行经过验证的域名但互联网上的合法站点数量太大白名单维护成本不现实。启发式检测走的是另一条路。它把钓鱼网站和正常网站当成两类样本提取能区分两者的统计特征。好比警察认人不看名字看作案手法。这类系统的核心假设是钓鱼网站在设计上必须模仿正常登录页面、必须引导用户输入信息所以它的域名结构、URL 特征、页面元素分布一定会留下痕迹。不需要知道这个站点曾经干过什么只要特征分布像钓鱼就按钓鱼处理。了解这一点对后续开发很重要因为特征工程的方向就来自这个假设。你不是在找“某个具体网站”而是在找“一类可疑行为的共同模式”。2.2 数据集收集爬虫抓取与公开数据源搭配要训练模型先得有标注好的两类数据钓鱼网站样本和正常网站样本。钓鱼样本可以使用 PhishTank 这类公开平台的导出数据里面有大量经过人工确认的钓鱼 URL。正常样本可以从 Alexa 排名靠前的站点里抽样或者用常见目录站点列表。注意这里说的是训练数据来源实际抓取时要控制并发、设置延时不要对目标站点造成压力。写爬虫时我的做法是用 requests 抓 HTML配合 User-Agent 伪装成浏览器避免被简单的反爬策略挡掉。import requests from fake_useragent import UserAgent headers { User-Agent: UserAgent().random, Accept-Language: zh-CN,zh;q0.9 } def fetch_html(url, timeout10): try: resp requests.get(url, headersheaders, timeouttimeout, allow_redirectsTrue) if resp.status_code 200: return resp.text except requests.exceptions.RequestException as e: print(f[抓取失败] {url} - {e}) return 逻辑说明这段代码的作用是稳健抓取页面 HTML。fake_useragent库每次请求随机生成一个浏览器 UA降低被封概率。timeout10表示超过 10 秒就放弃这次请求防止个别慢站点拖垮整个数据收集流程。allow_redirectsTrue默认跟随重定向因为很多钓鱼站点会做跳转不跟随可能只拿到一个空壳页面。参数说明如果你要在大规模数据上跑timeout 可以适当放宽到 15 或 20但并发数一定要控制住常见做法是ThreadPoolExecutor配合max_workers5左右别一口气开几十个线程不然很容易触发目标站点的防护。2.3 数据标注与目录结构每个样本都要能追溯抓回来的原始数据不能直接丢给模型。我的习惯是建一个标准化目录每个 URL 对应一个文件文件名用 URL 的 MD5 哈希做标识避免 URL 里的特殊字符破坏文件系统结构。标注信息统一放在一个 CSV 里字段包括url、label、html_pathlabel 用 0 表示正常、1 表示钓鱼。project_root/ ├── data/ │ ├── raw/ │ │ ├── phishing/ # 存放抓取的钓鱼页面 HTML │ │ ├── legitimate/ # 存放正常页面 HTML │ ├── processed/ │ │ ├── features.csv # 特征矩阵每行一个样本 │ ├── labels.csv # url 与 label 的对应关系 ├── src/ │ ├── feature_extraction.py │ ├── train_model.py │ ├── predict.py标注这一步最容易被新手跳过但恰恰是最重要的。最好把每个样本的原始 URL 和 HTML 文件都保留下来模型训练完做错误分析时你要能回到原始页面去看模型为什么判错。如果只留特征向量不留原始样本发现问题时想排查都没有依据。3. 特征工程实战把 URL 和 HTML 变成模型能直接吃的向量3.1 域名结构特征长度、子域名数量、敏感关键词钓鱼网站特征在域名层面是最明显的。正常站点域名通常简洁、有品牌辨识度钓鱼域名为了模仿或蹭热度往往会加一堆无关字符。这里我提取三个核心特征域名总长度、子域名数量、是否包含敏感关键词。from urllib.parse import urlparse import re SENSITIVE_KEYWORDS [login, signin, verify, account, secure, webscr] def extract_domain_features(url): parsed urlparse(url) domain parsed.netloc # 去掉端口号 if : in domain: domain domain.split(:)[0] parts domain.split(.) # 子域名数量去掉顶级域和二级域之后剩下的部分 subdomain_parts parts[:-2] if len(parts) 2 else [] # 域名总长度 domain_length len(domain) # 子域名数量 subdomain_count len(subdomain_parts) # 敏感词命中次数 domain_lower domain.lower() keyword_hits sum(1 for kw in SENSITIVE_KEYWORDS if kw in domain_lower) return { domain_length: domain_length, subdomain_count: subdomain_count, keyword_hits: keyword_hits }逻辑说明这里用urlparse拆出 netloc域名端口部分再做三层处理。第一层去掉端口号第二层按.分割域名第三层判断子域名数量——把最后两段当作主域名比如example.com剩下的都是子域名。关键词列表覆盖了钓鱼页面最常用的几个英文单词。参数说明subdomain_parts[:-2]这段逻辑对不同域名后缀的适用性有差异。像xxx.co.uk这种二级国家顶级域最后的co.uk其实应该算顶级域严格处理需要引入公共后缀列表。如果只是做演示项目按最后两段切分已经够用但你要知道这是在简化。3.2 URL 结构特征路径深度、特殊字符、是否使用 IP 地址钓鱼网站喜欢在 URL 路径里做文章。有的用超长路径模仿正规站点有的在路径里塞大量数字还有直接使用 IP 地址代替域名的。这些特征在正常站点里很少出现。def extract_url_features(url): parsed urlparse(url) path parsed.path query parsed.query # 路径深度按 / 切分后去掉空元素的数量 path_segments [seg for seg in path.split(/) if seg] path_depth len(path_segments) # 特殊字符数量 special_chars sum(1 for c in path query if c in ?#%_-) # 是否使用 IP 地址作为域名 netloc parsed.netloc is_ip bool(re.match(r^\d{1,3}(\.\d{1,3}){3}(:\d)?$, netloc)) # URL 总长度 url_length len(url) return { path_depth: path_depth, special_chars: special_chars, is_ip: int(is_ip), url_length: url_length }逻辑说明path_depth统计路径里有多少个有效层级比如example.com/a/b/c.html深度是 3。special_chars统计路径和查询参数里的特殊字符钓鱼 URL 往往在这些位置堆砌额外参数。is_ip用正则判断 netloc 是否是 IPv4 地址格式如果域名位置出现纯 IP这本身就是一个强信号。这里有个细节值得注意url_length单独用意义不大但和path_depth、special_chars组合使用效果很好。因为正常站点的短路径 URL 长度通常也比较短钓鱼站点要模仿真实链接结构只能靠加长后面的路径来“凑数”。3.3 HTML 内容特征表单数量、外部链接占比、标题文本页面内容层面的特征更能反映本质。钓鱼页面几乎必然包含表单而且表单的action属性常常指向另一个域名。正常网站不会把登录表单提交到外域。另外钓鱼页面的外部链接占比通常很高因为它是临时搭建的壳子大量资源直接引用原站点。from bs4 import BeautifulSoup def extract_html_features(html, base_domain): if not html: return { form_count: 0, external_link_ratio: 0.0, title_length: 0, has_password_input: 0 } soup BeautifulSoup(html, html.parser) # 表单数量 form_count len(soup.find_all(form)) # 外部链接占比 all_links [a.get(href, ) for a in soup.find_all(a)] external_links [ link for link in all_links if link.startswith(http) and base_domain not in link ] link_ratio len(external_links) / len(all_links) if all_links else 0.0 # 标题长度 title_tag soup.find(title) title_length len(title_tag.get_text(stripTrue)) if title_tag else 0 # 是否存在密码输入框 password_input len(soup.find_all(input, {type: password})) return { form_count: form_count, external_link_ratio: round(link_ratio, 4), title_length: title_length, has_password_input: password_input }逻辑说明form_count直接统计页面里有多少个form标签钓鱼页面通常至少有一个。external_link_ratio是外部链接除以总链接数的比例0 表示全是站内链接1 表示所有链接都指向外部。has_password_input用来判断页面是否包含密码输入框这是登录页面的核心组件。3.4 合并特征矩阵pandas 串联所有特征把三组特征合并成一行特征向量是所有样本统一格式的关键一步。我用 pandas 做拼接最后输出成一个 CSV 文件。import pandas as pd from urllib.parse import urlparse def build_feature_matrix(urls, htmls): rows [] for url, html in zip(urls, htmls): parsed urlparse(url) base_domain parsed.netloc.replace(:, ) row {} row.update(extract_domain_features(url)) row.update(extract_url_features(url)) row.update(extract_html_features(html, base_domain)) # 最终标记 rows.append({**row, url: url}) df pd.DataFrame(rows) return df # 假设你已经有了 urls 和 htmls 两个列表 # df build_feature_matrix(urls, htmls) # df.to_csv(data/processed/features.csv, indexFalse)逻辑说明row.update()把三组特征的字典逐个合并到同一行数据里。zip保证了 URL 和 HTML 是按顺序一一对应的。最后用pd.DataFrame把列表转成表格结构直接落盘。参数说明特征合并的顺序会影响最终列名顺序但对模型没有影响。不过为了后续调试方便建议每次运行都保持同样的特征顺序不要随机调换。4. 模型训练与评估随机森林为主、朴素贝叶斯为辅的选择逻辑4.1 数据集划分训练集、验证集、测试集三层拆分布局数据量不大时最常见的错误是只分训练集和测试集调参时反复看测试集结果最后模型把测试集“背”下来了。正确做法是拆成三份训练集用来训练验证集用来调参测试集只在最终评估时用一次。from sklearn.model_selection import train_test_split # 假设 df 是上一步生成的完整特征矩阵 X df.drop(columns[url, label]) y df[label] # 先拆出训练集和临时集 X_train, X_temp, y_train, y_temp train_test_split( X, y, test_size0.3, random_state42, stratifyy ) # 再把临时集拆成验证集和测试集 X_val, X_test, y_val, y_test train_test_split( X_temp, y_temp, test_size0.5, random_state42, stratifyy_temp ) print(f训练集: {X_train.shape}, 验证集: {X_val.shape}, 测试集: {X_test.shape})逻辑说明test_size0.3先拿出 30% 的数据再把 30% 里的 50% 分成验证集和测试集所以最终比例是训练集 70%、验证集 15%、测试集 15%。stratifyy让切分时保持正负样本比例不变避免某个子集里恰好全是钓鱼样本或全是正常样本。4.2 随机森林训练核心参数与调参思路选择随机森林而不是线性模型是因为启发式特征里存在大量非线性关系。比如is_ip1这个特征单独看已经很强但和form_count组合起来才更可靠——钓鱼 IP 域名加登录表单基本可以断定是恶意页面。随机森林能自动捕捉这种特征组合关系。from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report model RandomForestClassifier( n_estimators200, max_depth10, min_samples_split5, min_samples_leaf2, class_weightbalanced, random_state42, n_jobs-1 ) model.fit(X_train, y_train) # 验证集评估 y_pred model.predict(X_val) print(classification_report(y_val, y_pred))逻辑说明n_estimators200表示随机森林里包含 200 棵决策树树越多模型越稳定但超过一定数量后收益递减。max_depth10限制树的深度防止单棵树过度拟合。min_samples_split5表示节点分裂时至少要有 5 个样本这个参数能有效控制过拟合。class_weightbalanced自动调整正负样本权重钓鱼和正常样本数量不均衡时这个参数能让模型更关注少数类。参数说明第一次跑的时候先用默认参数看基线效果不要一上来就调参。基线模型如果在验证集上有 0.85 以上的 F1后面只需要微调。如果低于 0.80问题大概率出在特征工程上调参解决不了根本问题。4.3 评估指标准确率不能代表一切钓鱼检测这类任务里准确率是参考指标但更要关注的是召回率和精确率。召回率低意味着有钓鱼网站漏掉了这对用户是直接威胁精确率低意味着大量正常网站被误伤用户会烦到关掉检测功能。from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, confusion_matrix acc accuracy_score(y_val, y_pred) prec precision_score(y_val, y_pred) rec recall_score(y_val, y_pred) f1 f1_score(y_val, y_pred) print(f准确率: {acc:.4f}) print(f精确率: {prec:.4f}) print(f召回率: {rec:.4f}) print(fF1: {f1:.4f}) cm confusion_matrix(y_val, y_pred) print(混淆矩阵:) print(cm)逻辑说明混淆矩阵的四象限对应实际正负、预测正负的四种组合。我记得刚开始跑这个项目时准确率看着很高但召回率只有 0.6仔细一看发现是因为数据集里钓鱼样本太少模型把所有样本都判成正常也能拿高分。class_weightbalanced解决了一部分问题但数据本身如果过于失衡还是需要想办法扩充少数类样本。5. 避坑与排查启发式钓鱼检测项目里最容易翻车的五个点5.1 数据泄漏特征里混进了未来信息现象训练时 F1 达到 0.99一上测试集就跌到 0.8 以下。原因特征工程里包含了对整个数据集的统计信息。比如之前有人提取“URL 出现的平均频率”作为特征这个频率是用全体样本算出来的相当于把测试集信息泄漏进了训练集。模型记住了“这个 URL 很罕见”而不是“钓鱼网站长什么样”。解决所有特征只依赖单个 URL 和单个 HTML 页面的信息不允许跨样本统计。如果确实需要全局统计特征必须在特征提取之前先把数据切分开只对训练集部分计算统计量再应用到其他数据集。5.2 HTML 编码问题导致 BeautifulSoup 解析乱码现象特征是跑完了但中文页面的标题长度总是 0文本内容全是乱码特征值分布明显异常。原因requests 抓回来的 HTML 是 bytes 类型直接用text属性获取内容时requests 会根据 HTTP 头猜测编码但很多站点在 HTML 的meta标签里声明了编码或者根本没有声明。按错编码解析后字符全变成乱码关键词匹配全部失效。解决抓取时把原始 bytes 保存下来解析前用chardet或BeautifulSoup自带的编码检测机制处理。import requests from bs4 import BeautifulSoup resp requests.get(url, timeout10) # 先拿原始字节再让 BeautifulSoup 自己检测编码 html resp.content soup BeautifulSoup(html, html.parser) # 或者显式指定编码 # soup BeautifulSoup(html, html.parser, from_encodinggb18030)逻辑说明resp.content是未经解码的原始字节BeautifulSoup 会参考 HTTP 头、meta 标签内容以及字节内容自动推断编码。从resp.text拿到乱码后再用html.parser重新解析是无效的因为乱码已经发生无法逆向恢复。5.3 URL 规范化不统一导致特征失真现象同一个站点的不同 URL 被算成不同的特征分布比如example.com和example.com/路径深度一个为 0 一个为 1但它们实际上指向同一个页面。原因没有做 URL 规范化。有些 URL 末尾有斜杠有些没有有些带utm_sourcexxx追踪参数有些不带。这些差异会干扰模型学习。解决特征提取前用urlparse做一次统一化处理去掉末尾斜杠、去掉常见追踪参数。from urllib.parse import urlparse, parse_qsl, urlencode def normalize_url(url): parsed urlparse(url) path parsed.path.rstrip(/) # 去掉末尾斜杠 # 过滤掉 utm 开头的追踪参数 params [(k, v) for k, v in parse_qsl(parsed.query) if not k.startswith(utm_)] normalized_query urlencode(params) return parsed._replace(pathpath, querynormalized_query).geturl()逻辑说明rstrip(/)把路径末尾的斜杠全部去掉parse_qsl把查询参数解析成列表再过滤掉utm_开头的追踪参数最后用urlencode重新拼回去。这样入口不同但指向同一页面的 URL 会归一到同一条记录上。5.4 爬虫被反爬机制拦截导致数据集里全是 403 页面现象正常样本特征里title_length0、form_count0数据分布和钓鱼样本完全重叠。原因目标站点对高频率请求做了拦截返回的 403 页面内容很简单特征提取出来全是空值。这些页面混在数据集里实际上等于给模型喂了大量噪声。解决爬取时控制请求频率每次请求随机延时 1 到 3 秒。如果目标站点返回 403立即跳过并记录日志。之后检查日志里失败率超过 20% 就降低并发或者换 IP 池。5.5 特征重复提取导致训练推理不一致现象模型在验证集上表现很好上线跑新的 URL 时预测结果完全乱套。原因训练时的特征提取代码和线上推理的特征提取代码不是同一份比如线上版本漏掉了某个字段或者字段顺序排列不同模型输入的特征空间和训练时不一致。解决训练前把特征提取代码封装成独立模块训练和推理都从同一个模块导入函数不要复制粘贴代码。必要的话保存特征列名列表推理时对比列名是否完全一致。import joblib # 保存列名和模型 joblib.dump({model: model, feature_columns: list(X_train.columns)}, detector.pkl) # 推理时加载 saved joblib.load(detector.pkl) loaded_model saved[model] expected_columns saved[feature_columns] # 检查当前输入是否包含全部特征列 missing [col for col in expected_columns if col not in current_df.columns] if missing: raise ValueError(f缺少特征列: {missing})逻辑说明joblib.dump把模型和特征列名打包存成文件推理时先加载列名做校验。这个习惯是从一次线上事故养成的——那时我手改了一个特征名称训练和推理脚本各改一半结果模型上线后对所有请求都返回同一个预测值排查了半天才发现是特征空间错位。6. 实时检测与模型更新把训练好的模型封装成可调用的检测服务训练阶段搞定后下一个要解决的问题是别人怎么用这个检测系统。直接丢一个训练脚本让人家跑是不现实的正确做法是把模型导出再封装成一个闹钟服务——给一个 URL返回钓鱼/正常的判定和置信度。import joblib from flask import Flask, request, jsonify app Flask(__name__) # 启动时加载模型和特征列 saved joblib.load(detector.pkl) model saved[model] expected_columns saved[feature_columns] def extract_features_for_prediction(url): html fetch_html(url, timeout8) parsed urlparse(url) base_domain parsed.netloc.replace(:, ) row {} row.update(extract_domain_features(url)) row.update(extract_url_features(url)) row.update(extract_html_features(html, base_domain)) return pd.DataFrame([row]) app.route(/detect, methods[POST]) def detect(): data request.get_json() url data.get(url, ) if not url: return jsonify({error: url 参数不能为空}), 400 row extract_features_for_prediction(url) # 对齐列名 row row.reindex(columnsexpected_columns, fill_value0) prob model.predict_proba(row)[0][1] label 1 if prob 0.5 else 0 return jsonify({ url: url, is_phishing: label, confidence: round(prob, 4) }) if __name__ __main__: app.run(host0.0.0.0, port5000)逻辑说明这个接口收到 POST 请求后从 JSON 里取出 URL重新走一遍特征提取流程再用reindex(columnsexpected_columns)确保输入特征列的顺序和训练时完全一致。predict_proba返回的二维数组里第二列是“钓鱼”类别的概率阈值默认取 0.5。运行起来后可以用一行 curl 请求直接验证curl -X POST http://127.0.0.1:5000/detect \ -H Content-Type: application/json \ -d {url: http://example.com/login}预期返回结果类似{ url: http://example.com/login, is_phishing: 0, confidence: 0.1234 }这里confidence越小说明模型越确定这是正常网站而不是模型在瞎猜。模型更新方面我的习惯是设置两个触发条件每周定时重新训练一次或者当检测到的钓鱼 URL 在当天出现超过某个阈值时额外触发一次。重新训练前把当天新确认的钓鱼样本和正常样本追加进训练集然后重新跑完整的特征工程和训练脚本。由于这个过程是离线完成的不会影响线上服务所以可以直接把新模型文件替换旧的再重启 Flask 服务即可。从那以后我每次跑完训练都会顺手把特征列名、训练日期、数据源版本一起存档这样模型上线后用了一个月回头还能查它是在哪批数据上训练的。这个习惯帮我省掉了好几次“模型怎么突然不准了”的排查时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表