资讯详情

启发式特征驱动钓鱼网站检测:从URL解析到随机森林实战

📅 2026/10/2 14:23:52 | 华诺云谱 👁 阅读
启发式特征驱动钓鱼网站检测:从URL解析到随机森林实战
简介面向Python安全方向学习者的钓鱼网站检测小型示例项目可基于URL结构、域名特征、关键词等启发式指标识别可疑站点适用于课程设计、毕业设计或安全入门练习。资源共3个文件压缩包仅17KB包含2个Python脚本和1个HTML页面Python脚本承载特征提取、评分计算与检测主流程HTML页面提供简洁的结果展示入口运行环境要求不高方便直接阅读和二次开发。目前已有569人学习下载说明这类轻量检测主题有一定的关注度。通过源码可以掌握启发式特征打分、阈值判定、规则组合等常见思路代码结构清晰便于定位关键逻辑适合在现有框架上替换特征项、调整阈值或加入新的检测规则扩展成更完善的钓鱼网站识别模型也能为后续研究提供可复用的基础代码。1. 钓鱼网站检测为什么要走启发式特征这条路传统黑名单拦截只能封已经上报的域名遇到刚上线、还没被收录的钓鱼页面基本等于裸奔纯深度学习方案又像黑匣子安全运营根本不敢直接信。而基于启发式特征的钓鱼网站检测系统是用一套人工设计的规则和统计量把「可疑」拆成可计算的分数——URL 结构反常、页面里塞密码框、域名刚注册、外链指向怪异每一项都能落到一个特征值上。这套方案用 Python 落地成本最低特征可解释、可调参适合安全团队做日志告警也适合刚接触安全 AI 的新手用来练手。接下来我会从特征设计、代码实现到模型调参与踩坑把整条链路完整讲一遍。2. 先立特征体系启发式检测到底在算什么2.1 特征从哪里来三类信号决定一个页面是否可疑启发式特征的核心假设是「钓鱼页面为了骗过人必须在某些维度上做出牺牲」。比如它要快速上线所以域名注册时间普遍短它要诱导输入所以页面结构必然有表单和密码框它要躲避追踪所以 URL 里会塞 IP 直连、符号、多层跳转参数。站在攻击者的对立面我们把一个页面拆成三个可观测的层次特征就顺着这三层去抠。第一层是 URL 与域名层。这一层不需要请求页面就能算速度最快、误伤最少也最稳定。我们去看 URL 长度、是否含 IP、是否含 、子域名层级、whois 注册时长、DNS 是否还在生效。第二层是 HTML 内容层。拿到页面源码后统计表单数量、输入框类型、敏感词密度、外链比例、iframe 使用情况、JS 混淆特征。第三层是证书与交互层HTTPS 证书的颁发者是否为知名 CA、有效期有多长、提交地址是否和当前域名一致、是否有重定向掩码。这三层合在一起就是一套完整的启发式特征基线。很多公开的钓鱼检测数据集特征维度也不过二三十个关键在于每个特征的口径要定义清楚。比如「URL 长度超过多少算可疑」这种问题必须结合统计分布来定阈值不能拍脑袋。2.2 把特征拆细我常用的 16 个启发式特征与计算口径参考常见的公开研究和安全社区的做法我一般会把特征拆成下面这张表。每个特征名后面直接给出计算方式和判定方向方便你照着实现。特征名计算口径判定方向url_lengthlen(完整URL)超过 75 时风险上升host_contains_iphostname 是否为点分十进制是则高危host_contains_athostname 中是否含 是则高危num_dotshostname 中的点数超过 4 个可疑num_hyphenhostname 中的连字符数超过 2 个可疑url_has_portURL 是否带了非常用端口是则高危path_levelspath 被 / 切分的段数超过 4 段可疑suspicious_words全文是否命中 login/verify/update/confirm 等词命中越多越高危has_password_input是否存在 typepassword 的 input是则高危has_form是否存在 form 标签是则高危input_count所有 input 标签总数超过 3 个危险external_link_ratio外链数 / 总链接数超过 0.5 可疑has_iframe是否存在 iframe是则可疑title_lengthtitle 文本长度过短或为空可疑has_https是否为 https否则风险升高cert_daysSSL 证书剩余有效天数少于 90 天可疑这 16 个特征是纯规则层面的。它们的好处是每一个都能独立解释告警的时候你可以直接输出「域名含 IP 页面含密码框 外链占比 0.7」给运营同事看。对新手来说先手算一遍这 16 个特征再谈模型比直接上随机森林要靠谱得多。2.3 特征分箱与归一化别让模型被个别极端值带偏原始特征直接塞进模型会有两个问题。一是量纲不一致比如 url_length 可能到几百而 has_form 只有 0 或 1树模型不受影响但逻辑回归会偏二是极端值问题某个页面 URL 特别长可能让模型对这个特征的特殊取值过度敏感。常见的做法是分箱。我一般会把 url_length、input_count、external_link_ratio 这三个连续特征做等频分箱把数值映射成 0 到 4 的等级。例如 external_link_ratio 可以按五档分小于 0.2 为 00.2 到 0.4 为 10.4 到 0.6 为 20.6 到 0.8 为 3大于 0.8 为 4。这样既保留了区分度又不会因为一个异常 URL 长度让整条样本的向量空间被拉偏。对于要上逻辑回归的场景再做一次标准化即可。如果只用树模型分箱后的特征完全够用标准化反而不是必须的。整个特征处理的顺序先分箱、再标准化后续调参才能把锅分清。3. 落地提取用 Python 把特征从 URL 和页面里抠出来3.1 样本从哪来公开数据源与手工标注的搭配做检测系统可以先跑通流程再扩充数据训练集不一定要一开始就搞到几十万条。常见做法是从 PhishTank、OpenPhish 这类公开平台拉取已确认的钓鱼 URL 列表再从 Alexa 或国内常用站排行榜里随机抽一批正常站点做反例。拉取时注意保存 URL 和对应标签别只存一个总数。如果只想本地验证我建议手工收集 200 条正例和 200 条反例就够跑通第一版模型。正例从公开平台取反例从自己日常访问的网站里挑注意覆盖电商、银行、社交、论坛等不同类型。这个阶段的核心目的是验证特征提取代码没有 bug以及标签本身做得对不对。3.2 URL 与域名特征提取先用 requests 和 whois 把最稳的一层拿下URL 层特征不碰页面 HTML只做字符串解析代码写起来很直接。whois 查询需要额外装 python-whois 库如果个别域名查询超时会拖慢整体速度后面避坑章节再展开。import re import socket from urllib.parse import urlparse def extract_url_features(url: str) - dict: parsed urlparse(url) host parsed.hostname or feats {} feats[url_length] len(url) feats[host_length] len(host) feats[num_dots] host.count(.) feats[num_hyphen] host.count(-) # 直接使用点分十进制 IP 的域名钓鱼场景高发 ip_pattern re.compile(r^\d{1,3}(\.\d{1,3}){3}$) feats[host_contains_ip] 1 if ip_pattern.match(host) else 0 # 会让浏览器忽略前面的伪域名只解析后面部分 feats[host_contains_at] 1 if in url else 0 # 非常用端口可能指向同一 IP 上的隐蔽服务 if parsed.port and parsed.port not in (80, 443): feats[url_has_port] 1 else: feats[url_has_port] 0 # path 层级越多站点结构越可疑 path parsed.path.strip(/) feats[path_levels] len([seg for seg in path.split(/) if seg]) return feats这段代码的核心思路是「不发起网络请求也能判一批特征」。url_length 和 path_levels 直接由字符串计算host_contains_ip 用正则匹配点分十进制的 IPv4 地址注意这里的匹配不检验每段是否在 0 到 255 之间实际使用中建议补一个段位范围检查num_dots 和 num_hyphen 统计 hostname 里的符号数量因为攻击者常用连字符拼出形似合法域名的字符串url_has_port 排除 80 和 443 两个默认端口其它端口都会触发风险信号。域名年龄的查询要用 python-whois下面这段代码注意设置超时否则遇到 whois 服务无响应的域名会卡很久。import whois from datetime import datetime, timedelta def extract_domain_features(domain: str, timeout: int 3) - dict: feats {} try: info whois.whois(domain, timeouttimeout) if info.creation_date: # 有些返回 list取最早时间 if isinstance(info.creation_date, list): creation min(info.creation_date) else: creation info.creation_date # 兼容 datetime 和 date 两种类型 if isinstance(creation, datetime): age_days (datetime.now() - creation).days else: age_days (datetime.now().date() - creation).days feats[domain_age_days] max(age_days, 0) else: feats[domain_age_days] -1 # 查询不到视为未知 if info.expiration_date: if isinstance(info.expiration_date, list): exp min(info.expiration_date) else: exp info.expiration_date if isinstance(exp, datetime): remain_days (exp - datetime.now()).days else: remain_days (exp - datetime.now().date()).days feats[cert_days] max(remain_days, 0) else: feats[cert_days] -1 except Exception: # 统一置 -1交给后续模型处理空值 feats[domain_age_days] -1 feats[cert_days] -1 return feats这里有个关键参数whois 查询的超时。python-whois 底层的 rdap 和 whois 协议响应速度差别很大冷门域名经常要等十几秒。超时设 3 秒比较合适批量提取 1000 条数据时能省下大量时间。domain_age_days 和 cert_days 查询失败都置为 -1后续模型训练时需要单独处理这个取值不能简单当 0 用因为 -1 的语义是「未知」而不是「刚注册」。3.3 页面内容特征提取把 HTML 拆成敏感信号拿到页面源代码后特征提取要落在 BeautifulSoup 的选择器上。这一层最容易翻车的地方是编码和 JS 渲染前者用 requests 拿响应的 apparent_encoding 做兜底后者要尽量避开。import requests from bs4 import BeautifulSoup from urllib.parse import urlparse def extract_html_features(url: str, timeout: int 5) - dict: feats {} headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } try: resp requests.get(url, headersheaders, timeouttimeout, verifyFalse) resp.encoding resp.apparent_encoding soup BeautifulSoup(resp.text, html.parser) feats[has_form] 1 if soup.find(form) else 0 # 密码输入框是钓鱼页面识别用户凭证的关键 password_input soup.find(input, {type: password}) feats[has_password_input] 1 if password_input else 0 inputs soup.find_all(input) feats[input_count] len(inputs) # 外链占比能识别伪装成站内页面的钓鱼站 links soup.find_all(a, hrefTrue) total_links len(links) external_links 0 base_host urlparse(url).hostname or for link in links: link_host urlparse(link[href]).hostname if link_host and link_host ! base_host: external_links 1 feats[external_link_ratio] external_links / total_links if total_links 0 else 0 feats[has_iframe] 1 if soup.find(iframe) else 0 title soup.title.string if soup.title and soup.title.string else feats[title_length] len(title.strip()) # 敏感词命中word in text 这种简单匹配即可 page_text soup.get_text( , stripTrue).lower() sensitive_words [login, verify, update, confirm, password, signin] feats[suspicious_words] sum(1 for w in sensitive_words if w in page_text) except Exception: # 请求失败时返回空特征由外层统一填充 feats.update({ has_form: -1, has_password_input: -1, input_count: -1, external_link_ratio: -1, has_iframe: -1, title_length: -1, suspicious_words: -1 }) return feats这段代码里verifyFalse 是因为很多钓鱼站证书本身就不规范用 requests 默认的 SSL 校验会直接抛异常实际部署时如果想保留证书检测维度可以改成捕获 ssl.SSLError 后把证书状态单独记一个特征。敏感词匹配用的是最简单的子串包含不引入分词因为钓鱼页面经常故意把词拆成字符或加空格更精细的做法是正则匹配中加一些变形写法。html 特征都有一个共同的边界坑页面请求失败时所有特征值都被置为 -1。这个语义要和 whois 查询失败保持一致训练时统一把 -1 当作缺失值处理否则模型会学到「页面打不开就是安全的」这种错误规律。3.4 组装训练集串起提取流程落成 CSV把 URL 层、域名层、HTML 层的特征拼到一行外加一个 label 列就形成一条训练样本。批量跑的时候建议先做一层本地缓存把已提取的 URL 记录在一个集合里断点续跑时不重复请求既省时间又降低被封风险。import pandas as pd import json def build_dataset(urls_with_labels: list, output_path: str) - None: rows [] for url, label in urls_with_labels: try: row {} row.update(extract_url_features(url)) domain urlparse(url).hostname row.update(extract_domain_features(domain)) row.update(extract_html_features(url)) row[label] label rows.append(row) except Exception as exc: # 单条失败不中断整个数据集构建 print(ffailed: {url}, reason: {exc}) continue df pd.DataFrame(rows) df.to_csv(output_path, indexFalse)这里我刻意在循环里包了一层 try原因是批量抓取时总有几条页面超时或域名解析失败单条失败不应该让整个数据集作废。print 出来的 url 和失败原因留作排查证据等数据集构建完统一回看失败样本是否集中在某个反例来源。4. 让规则学会组合模型训练与阈值取舍4.1 先跑一个白名单基线简单规则打分函数拿到特征数据后不要一上来就训练随机森林。先用一个手写的打分函数把规则跑通看它在测试数据上的表现这个白名单基线能帮你后续判断模型到底带来了多少增量。def rule_score(row: dict) - float: score 0.0 if row[host_contains_ip] 1: score 3 if row[host_contains_at] 1: score 3 if row[url_length] 75: score 1 if row[num_dots] 4: score 1 if row[num_hyphen] 2: score 1 if row[has_password_input] 1: score 3 if row[has_form] 1: score 1 if row[external_link_ratio] 0.6: score 2 if row[suspicious_words] 3: score 2 if row[domain_age_days] 0 and row[domain_age_days] 90: score 2 return score这个打分函数是启发式检测最原始的形态每条规则的权重都是人工经验。跑完你会看到它对明显的钓鱼页面识别能力很强但对那些特征不明显的样本无能为力这正是后续模型要解决的问题。用这个分数在数据集上画 ROC 曲线得到的 AUC 就是规则基线的水平模型必须超过它才有意义。4.2 随机森林上桌参数选择与特征重要性解读随机森林是钓鱼检测里很合适的模型。它能处理 -1 这类缺失值、对特征量纲不敏感、不容易过拟合且能输出特征重要性。逻辑回归虽然可解释性更强但对缺失值和极端值要求高需要先做填充和标准化不推荐第一版就这么干。import pandas as pd from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report, roc_auc_score df pd.read_csv(train.csv) X df.drop(columns[label]) y df[label] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.25, random_state42, stratifyy ) model RandomForestClassifier( n_estimators300, max_depth12, min_samples_leaf2, class_weightbalanced, n_jobs-1, random_state42 ) model.fit(X_train, y_train) y_pred model.predict(X_test) y_proba model.predict_proba(X_test)[:, 1] print(classification_report(y_test, y_pred)) print(fAUC: {roc_auc_score(y_test, y_proba)}) importance pd.Series(model.feature_importances_, indexX.columns).sort_values(ascendingFalse) print(importance.head(10))这里几个参数值得留意。class_weightbalanced 是因为钓鱼正样本往往远少于正常站点不均衡会导致模型把多数类全猜对但正样本全漏。max_depth12 控制单棵树的复杂度防止某个特征被反复切成碎片。min_samples_leaf2 是最小叶子样本数设 1 容易记住单个样本里的噪声。n_jobs-1 让所有 CPU 核并行300 棵树在小数据集上也就几秒。跑完先看 classification_report 里的 recall 和 precision。安全场景默认优先保 recall因为漏掉一个钓鱼页面的代价比误报一个正常页面更高但 recall 拉高的代价是运营团队被误报淹死所以后面的阈值要靠 ROC 曲线来定。4.3 找最优判定阈值不要迷信默认的 0.5随机森林的 predict 默认按 0.5 概率切分类但在安全检测里这个阈值几乎不会是最优的。你需要根据自己的代价函数去找阈值常见做法是遍历 0.1 到 0.9 的阈值在每个阈值下计算 precision、recall 和 F1选一个业务上可接受的操作点。import numpy as np def find_best_threshold(y_true, y_proba): best_f1 0 best_thr 0.5 for thr in np.linspace(0.1, 0.9, 81): y_pred (y_proba thr).astype(int) precision y_pred.sum() 0 and (y_pred[y_true 1].sum() / y_pred.sum()) recall y_true.sum() 0 and (y_pred[y_true 1].sum() / y_true.sum()) if precision 0 and recall 0: f1 2 * precision * recall / (precision recall) if f1 best_f1: best_f1 f1 best_thr thr return best_thr, best_f1这个函数暴力遍历阈值简单直接。实际部署时阈值还要结合运营人力来定人力充足就把阈值调低换取召回人力紧缺就调高阈值保住 precision。另一个容易忽略的坑是测试集和真实流量中的正样本比例不同如果你在测试集上把 F1 调到最高线上分布一变就可能翻车所以阈值上线前要在留存的时间段数据上再验证一次。4.4 特征重要性的用法反向修正启发式规则随机森林输出的 feature_importances 不仅能让你知道模型在靠什么做判断还能反过来检验你的启发式规则是否合理。比如你发现 external_link_ratio 的重要性排第一但你的打分函数里只给了它 2 分那说明这条规则权重可以调高如果 host_length 的重要性排在末位规则里却给了它较多权重那就考虑削减。这一步就是用模型反哺规则的关键做法。规则模型并不是互斥的两套方案随机森林抓到的非线性组合最终要翻译回人能看懂的规则安全运营才敢用。5. 避坑指南启发式检测在真实场景里的 5 个翻车现场5.1 页面还没抓到反爬先封了你的 IP现象批量提取时跑了 2000 条后 requests 开始大量超时随后自己的 IP 被目标站点拒绝访问连正常网站也打不开。原因并发采集频率太高且未设置请求间隔。很多正常站点有反爬机制短时间高频请求直接触发封禁策略。解决在采集循环里加一个 random sleep间隔取 1 到 3 秒随机值。同时把 requests 的 Session 复用起来保持连接池。如果采集规模大用 scrapy 这类支持并发控制的框架会更省心。唯一的后悔药是在早期设计时就做好断点续跑不然被封后重跑整个数据集时间成本直接翻倍。5.2 特征值缺得不成样子页面打不开和「安全」是两回事现象数据集中很多样本的 has_form、input_count 等 HTML 特征全是 -1模型训练完对这类样本一律给低分认为它们安全。原因requests 请求失败时我把所有特征置 -1但没有在训练时单独处理 -1 这个取值。随机森林把 -1 当成普通数值学到的规律变成了「页面打不开的网站更可能是正常站」。解决在特征组装时把 -1 单独做一列 is_missing 标志比如 has_form_missing 为 1 代表该特征缺失。模型可以学习缺失模式本身而不是被迫把 -1 当真实数值。缺失比例过高的特征要考虑直接丢弃比如某个特征 40% 样本都是 -1说明采集环节不稳定先修采集再去修模型。5.3 whois 查询慢到训练根本跑不完现象1000 条样本whois 查询耗时占了整个数据集构建时长的 80%单条查询经常要等十几秒甚至超时。原因python-whois 的 rdap 服务响应慢冷门域名注册信息少部分顶级域的服务商响应不稳定。解决把 whois 查询超时从默认值改到 3 秒查询不到直接记 -1。域名年龄只影响部分钓鱼场景不值得让整条链路被它拖死。另一个方法是加内存缓存同一域名只查一次重复 URL 直接复用结果。5.4 训练集分布和线上流量分布脱节现象模型在测试集上 AUC 0.95上线后误报率翻了一倍大量正常站被判成钓鱼。原因训练集正样本来自公开钓鱼库反例来自知名网站排行榜。线上流量里有大量个人博客、中小企业官网、备案信息不完整的站点这些页面域名年龄短、外链少、无 HTTPS特征和训练集中的「正常站」长得根本不一样。解决上线前从历史访问日志里抽一批真实流量作为反例参与训练。没有历史日志的至少要把保留时间段数据单独拿出来当验证集用时间切分而不是随机切分来评估模型这样才接近真实的漂移情况。5.5 纯静态特征抓不到前端渲染的钓鱼页现象部分钓鱼站点击进去只有一个加载动画HTML 里什么都没有特征全部为空模型给了低分但浏览器打开后页面通过 JavaScript 动态渲染出完整的登录表单。原因requests 拿到的 HTML 是原始源码不执行 JS。现在很多攻击者把关键内容放在前端脚本里服务端只返回一个空壳。解决第一版先接受这个局限在特征里加一条 js_density统计 script 标签内容长度占整个 HTML 的比例超过 0.4 就标记为可疑。更彻底的方案是用 Playwright 或 Selenium 做无头浏览器渲染但这种方案采集成本高通常在第二版再做。这一条踩坑记录也说明了启发式检测的边界它适合大批量粗筛精筛还是得上行为侧和渲染侧的方案。6. 用留存回测与特征反馈迭代检测系统最后一步不做总结说一个我养成的习惯每次模型更新前都要先跑一遍留存回测。具体做法是把历史数据按时间切成两段比如前 70% 做训练后 30% 做验证保证验证集里的样本在时间上严格晚于训练集。这么切能暴露模型在新样本上的真实表现比随机切分可靠得多。回测之后要做的事是打印特征重要性拿它反向修正你的规则打分函数。实操中你会发现很多你认为重要的特征在数据分布里其实区分度很低反而是外部链接占比、域名年龄这类特征反复出现在重要性排名前列。每次迭代我都习惯把重要性前五的特征和规则权重对照一遍如果模型已经在用某个特征做高权重判断而规则没给它分就说明启发式规则该更新了。另外建议给检测系统加一个「预测日志」功能把每次预测的 URL、概率最高的五个特征贡献值一起落盘。这样就算误报了也能回看模型当时到底因为什么给了高分运营人员不用把模型当黑匣子去猜。这套从特征设计、数据采集、模型训练到回测迭代的完整闭环就是启发式钓鱼检测系统最值得投入的地方。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑