ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于启发式特征的钓鱼检测系统:URL与页面异常分析实战

基于启发式特征的钓鱼检测系统:URL与页面异常分析实战

简介:钓鱼网站通过伪装合法站点诱导用户提交敏感信息,启发式特征检测是识别此类威胁的有效手段,也是网络安全实践中的经典课题。这份基于Python的检测系统压缩包面向网络安全初学者、Web开发人员及毕业设计学生,提供了从数据收集、特征工程到模型训练与评估的完整示例代码。资源共2个文件,均为.py脚本,总大小仅7KB,代码结构紧凑、注释清晰,可直接运行并在此基础上扩展功能。已有180人学习下载,适合作为课程设计、安全工具开发或论文复现的参考。借助requests、BeautifulSoup、scikit-learn等库,脚本覆盖域名关键字、URL长度、HTML结构、文本相似度等核心特征,并演示朴素贝叶斯、决策树、随机森林等模型的训练与评估流程,帮助读者快速理解启发式检测的工程实现与优化思路。

1. 启发式钓鱼检测:为什么黑名单挡不住,就看 URL 和页面“像不像”

你收到一条“Apple 账户异常,请 24 小时内登录 xxxxx.secure-login.tk 验证”的消息,点进去的登录页几乎以假乱真,但黑名单里偏偏查不到这个域名——因为它三小时前刚注册,反钓鱼库还没来得及收录。启发式特征检测就是解决这类“新钓、短命钓”的:不依赖外部名单,直接从 URL 和页面内容里提取异常数值,用评分或模型判断它“像不像”钓鱼。下面按一套 Python 实现的最小系统,把特征设计、模型训练、参数调整和生产部署拆开讲,命令和代码都可以直接照着复现。这条路线适合安全方向毕设、Web 渗透测试辅助和企业上网行为管理三类人落地。

2. 启发式特征体系与 Python 提取:先从 URL、域名和页面内容里挖出异常

2.1 特征分层:单看 URL 不够,要三层配合

钓鱼站点的本质是“伪装成可信目标的不可信站点”,启发式特征就是围绕伪装留下的痕迹做量化。为什么一定要分层?因为单看 URL 常常给出错误答案:短链接、跳转域名、图片型钓鱼都没法在 URL 层定案。域名层和页面内容层能补上最后一环,但网络抓取有代价,whois 查询也不一定每次都成功。所以分层有三个目的:按成本决定特征是否默认开启,按可信度给不同层的判定赋予不同权重,以及当某一层数据缺失时,另外两层还能兜住。

我一般会把特征分成三层来设计。URL 层最便宜,一条字符串能算出十几个数值;域名层反映注册者动机,但依赖第三方接口;页面内容层信息量最大,却最容易受反爬和超时影响。三层独立提取、合并成一条特征向量,训练和推理都走同一份代码,这是后面所有讨论的前提。

2.2 URL 与域名层特征:长度、IP 直连、特殊符号、子域名层级

URL 层特征是成本最低的信号来源,很多钓鱼站点根本不提供“正常”的 URL 形态,要么堆长参数,要么用 IP 直连,要么靠@符号把真实域名藏在后面。这里列一张常用特征表,后面训练和推理都用同一套字段名。

特征提取方式钓鱼侧含义
URL 长度len(url)正常站点 URL 短,钓鱼常堆参数和路径
主机是否 IP正则匹配数字点分正常站点极少用裸 IP 提供服务
是否含 @'@' in url@ 前的域名会被浏览器忽略,常用于伪装
子域名数量host 按点拆分用多层子域名模仿合法域名
敏感词login/verify/account 等模仿登录、验证场景
是否 httpsurlparse 取 scheme钓鱼也开始用 https,不能当成安全信号
host 是否含连字符'-' in netloc连字符在品牌域名里少见,在仿冒域名里常见
import re from urllib.parse import urlparse SENSITIVE_WORDS = ['login', 'signin', 'verify', 'account', 'update', 'secure', 'webmail', 'bank', 'paypal', 'appleid'] def extract_url_features(url: str) -> dict: parsed = urlparse(url) netloc = parsed.netloc.lower() features = {} features['url_length'] = len(url) features['host_length'] = len(netloc) features['is_ip_host'] = 1 if re.match( r'^\d{1,3}(\.\d{1,3}){3}(:\d+)?$', parsed.netloc) else 0 features['has_at_sign'] = 1 if '@' in url else 0 features['dot_count'] = netloc.count('.') # 粗糙算法:默认 TLD 是单段;遇到 co.uk 这种会虚高 features['subdomain_count'] = max(len(netloc.split('.')) - 2, 0) features['has_sensitive_word'] = int(any( w in url.lower() for w in SENSITIVE_WORDS)) features['scheme_https'] = 1 if parsed.scheme == 'https' else 0 features['has_hyphen_in_host'] = 1 if '-' in netloc else 0 return features

这段代码有几个参数值得注意。subdomain_count用“点分段数减 2”来估算,遇到www.apple.com返回 1,遇到login.apple.com.secure-login.tk返回 3,基本够用;但碰到example.co.uk会把真实域名误算成带 1 级子域名,想要精确需要引入 tldextract 这类库,代价是部署时多装一个包。SENSITIVE_WORDS直接影响召回和误报,做企业钓鱼演练时通常会把品牌词换成自家域名,做通用检测则保留这些高频词。正则里的\d{1,3}会匹配 999.999.999.999 这种非法 IP,但作为“像不像 IP”的特征已经足够,不需要追求完全合法。

2.3 页面内容特征:表单、外链、标题的异常组合

URL 层解决“域名可疑”,页面内容层解决“到底想骗什么”。钓鱼站点的最终目的是诱导输入,所以页面里一定有表单、密码框或诱导按钮;这些元素在正经页面也会出现,但组合往往是异常的——表单 action 指向第三方域名、页面标题自称银行官网、页面里却到处是其他站点的外链。

页面抓取本质上是一个带超时的爬虫,必须先设置 timeout,requests 默认不超时会卡死整个检测线程。下面这段代码把 HTML 解析成内容特征:

import requests from bs4 import BeautifulSoup def fetch_html(url: str, timeout: int = 3) -> str: try: resp = requests.get(url, timeout=timeout, headers={'User-Agent': 'Mozilla/5.0'}) return resp.text if resp.status_code == 200 else None except Exception: return None def extract_page_features(html: str, base_url: str) -> dict: soup = BeautifulSoup(html, 'html.parser') base_host = urlparse(base_url).netloc forms = soup.find_all('form') password_inputs = soup.find_all('input', attrs={'type': 'password'}) external_links, total_links = 0, 0 for a in soup.find_all('a', href=True): total_links += 1 link_host = urlparse(a['href']).netloc if link_host and link_host != base_host: external_links += 1 title = soup.title.string if soup.title else '' features = { 'has_form': 1 if forms else 0, 'has_password_input': 1 if password_inputs else 0, 'external_link_ratio': round(external_links / max(total_links, 1), 4), 'title_length': len(title.strip() or ''), 'has_favicon': 1 if soup.find('link', attrs={'rel': 'icon'}) else 0, } return features

页面特征里最有用的是external_link_ratio。一个仿冒 Apple 的钓鱼页,往往把大量外链指向真实 Apple 站点或 SEO 农场,用来冲搜索引擎收录;正经的登录页外链占比通常很低。has_password_input是强信号,但注意它单独出现不足以定罪,网银登录页也有密码框。fetch_html里的timeout=3是给单条 URL 的抓取预算,网络差时宁可放弃内容特征,也不能让整个检测链路变慢。

提示:抓取页面务必设置 timeout,并捕获所有异常。反爬站点返回 403 时 html 为 None,这种情况要当成“内容层缺失”处理,不能直接判正常,后面避坑章会专门讲。

2.4 特征提取工具与性能:别让检测比钓鱼活得还慢

整个特征提取依赖四个工具:requests/urllib 做抓取,re 做正则,BeautifulSoup 或 lxml 做解析,python-whois 可选做域名年龄查询。python-whois 能给出注册时间、注册商、域名状态,对识别“刚注册三天就拿来仿冒”的域名很有帮助,但它是同步阻塞调用,whois 服务器响应慢时会拖死后台任务,我一般只在离线分析时开,在线接口里不加。

另外一个必须提前想清楚的问题是“共性代码复用”。特征提取函数必须写成独立模块,训练脚本、离线回测、线上接口 import 同一个函数。很多人训练时手写一份特征逻辑,上线时又复制一份改了两行,最后特征分布对不上,模型在线上表现和离线 AUC 完全不是一回事。这个坑后面还会细说。

性能上,单条 URL 的检测耗时几乎全在网络抓取,3 秒超时基本够用。如果检测量大,可以用ThreadPoolExecutor做并发抓取,但并发数别超过 10,否则目标站点会把你当爬虫拉黑,连正常页面都抓不回来。

3. 特征变成分数:评分规则、权重训练与标签数据准备

3.1 先选路线:专家固定权重还是机器学习模型

拿到特征之后,怎么把它们变成“钓鱼概率”?常见两条路线。第一条是专家规则,自己给每个特征定权重,比如is_ip_host加 30 分、has_password_input加 40 分,总分超过 100 判钓鱼。优点是冷启动快、完全可解释,适合特征只有五六个、需求是“明天就要上线”的场景。缺点是特征一多,人工调权重基本靠玄学:你很难说清楚external_link_ratio到底该值 0.3 还是 0.6。

第二条路线是机器学习模型,给一批标注好的 URL 让逻辑回归、随机森林自动学权重。特征超过 10 个之后,我基本不手工调权,直接让模型学。两者的选择标准可以看下面这张表:

对比项专家固定权重机器学习模型
冷启动速度当天可上线需要先攒标注数据
可解释性每分都能说清树模型可解释,LR 系数可排序
特征多了之后调参困难自动学交互关系
维护成本每换场景改权重定期重训,数据要干净

实际项目最稳的做法是“先用 LR 打通流程,再决定要不要上树模型”。逻辑回归权重稳定、系数能排序、算力要求低,作为第一版完全够用。

3.2 标签数据怎么来:正样本用公开情报,负样本自己攒

模型的天花板由数据决定,我这里踩过好几次坑。正样本可以从 PhishTank、OpenPhish 拿已提交并验证的钓鱼 URL,但必须带时间戳过滤。钓鱼域名的存活周期经常以小时计算,三个月前的正样本现在打开可能已经是垃圾广告页,特征分布和现网完全对不上。负样本反而容易被忽视:只拿 Alexa Top 当负样本,模型会学到“短域名=正常”,把同样短域名的新注册钓鱼站直接放行。负样本应当混合常用站点、个人博客、企业官网,尽量覆盖正常站点的多样性。

数据集不平衡也是必然的,正常 URL 数量远大于钓鱼 URL。处理办法有两个,一个是切分时用stratify=y保持正负比例一致,另一个是在模型里开class_weight='balanced',让少数类别获得更高的惩罚权重。

3.3 用逻辑回归做第一版:特征标准化与训练代码

特征量纲差异很大:url_length可能到一两百,external_link_ratio只在 0 和 1 之间,is_ip_host是 0/1。逻辑回归对量纲敏感,训练前必须先标准化。下面这段是完整的第一版训练流程:

import pandas as pd from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler from sklearn.metrics import classification_report, roc_auc_score df = pd.read_csv('labeled_features.csv') feature_cols = [c for c in df.columns if c not in ('url', 'label')] X = df[feature_cols].values y = df['label'].values scaler = StandardScaler() X_scaled = scaler.fit_transform(X) X_train, X_test, y_train, y_test = train_test_split( X_scaled, y, test_size=0.2, random_state=42, stratify=y ) model = LogisticRegression(C=0.1, max_iter=1000, class_weight='balanced') model.fit(X_train, y_train) y_prob = model.predict_proba(X_test)[:, 1] print(classification_report(y_test, y_prob >= 0.5)) print('AUC:', round(roc_auc_score(y_test, y_prob), 4))

训练代码里的关键参数有三个。C=0.1是正则化强度的倒数,值越小正则越强,能压住小数据集上的过拟合;如果特征多、样本少,建议从C=0.01开始试。class_weight='balanced'自动调整类别权重,前提是你没在数据里做过重的过采样,否则会双重放大少数类。标准化这一步必须放在切分之前完成吗?不一定,更规范的做法是先切分再fit_transform(X_train),再transform(X_test),避免测试集信息泄漏到训练过程。训练完要同时保存模型和 scaler,推理时要加载同一份 scaler。

import pickle with open('model.pkl', 'wb') as f: pickle.dump(model, f) with open('scaler.pkl', 'wb') as f: pickle.dump(scaler, f)

3.4 特征重要性与参数调整:先砍不靠谱特征,再调阈值

LR 训练完不要急着上线,先看特征系数排序。逻辑回归系数经过标准化后,绝对值大小能反映特征在整体判断里的贡献:

importance = sorted( zip(feature_cols, model.coef_[0]), key=lambda x: abs(x[1]), reverse=True ) for name, coef in importance[:5]: print(name, round(coef, 4))

如果发现某个特征系数接近 0,说明它对分类没有贡献,可以直接从feature_cols里剔除再重训。如果系数为正且很大,比如has_password_input,说明“页面有密码框”在模型里是强钓鱼信号,这时候就要警惕样本偏差:如果负样本里很少出现带密码框的页面,模型会把所有带登录框的页面都推向钓鱼侧,这就是误报的主要来源。阈值默认是 0.5,但业务上我一般不放 0.5,放到 0.6 左右会更保守,具体放到多少,要看你能接受的误报率有多高。

4. 跑通一个最小检测系统:代码结构、阈值参数与 HTTP 接口

4.1 系统模块怎么拆:特征、模型、预测、接口四段

有了特征函数和训练好的模型,就可以组装成可用的检测系统了。模块划分我一般做成四段:features 模块负责提取特征,model 模块负责加载模型和 scaler,detector 类负责把两者拼起来,最外层是 HTTP 接口或命令行脚本。这样拆的好处是,换模型只要替换 pkl 文件,换特征只要改 features 模块,四个模块不互相纠缠。

这四段里最容易写歪的是“特征入口”。训练时用什么顺序拼接特征,推理时必须完全一样;Python 3.7 以后 dict 保序,但如果你在训练时手工排过列名,推理时也必须按同样的列名顺序构造数组,否则 feature 对不上,模型输出变成随机分数。

4.2 检测器核心类:把训练好的模型包成可复用对象

我先定义extract_all_features,把 URL 层和页面层合并成一条完整特征。这里已经有对抓取失败的兜底逻辑:

def extract_all_features(url: str) -> dict: feats = extract_url_features(url) html = fetch_html(url, timeout=3) if html is None: feats.update({ 'fetch_success': 0, 'has_form': 0, 'has_password_input': 0, 'external_link_ratio': 0.5, }) else: feats.update({'fetch_success': 1}) feats.update(extract_page_features(html, url)) return feats

注意external_link_ratio在抓取失败时填的 0.5 是中性值,既不是“没有外链”的 0,也不是“全是外链”的 1。这样设计是为了让缺失内容特征的样本不被模型强推到“正常”一侧,后面避坑章会展开讲为什么不能直接填 0。

然后是检测器类:

import pickle class PhishingDetector: def __init__(self, model_path: str, scaler_path: str, threshold: float = 0.6): with open(model_path, 'rb') as f: self.model = pickle.load(f) with open(scaler_path, 'rb') as f: self.scaler = pickle.load(f) self.threshold = threshold self.feature_names = None def predict(self, url: str) -> dict: feats = extract_all_features(url) # 保存特征名,方便日志排查是哪一项拉高了分数 if self.feature_names is None: self.feature_names = list(feats.keys()) x = self.scaler.transform([list(feats.values())]) prob = self.model.predict_proba(x)[0, 1] return { 'url': url, 'phishing_prob': round(float(prob), 4), 'is_phishing': prob >= self.threshold, 'features': feats, }

这段代码里self.scaler.transform用的是训练时保存的 scaler,不是重新 fit。记住:推理时任何一步都不能重新计算均值和方差,否则线上特征分布偏移,模型会认为所有 URL 都是异类。threshold=0.6是我在第一版常用的值,比默认 0.5 更“怀疑”,代价是漏报率略微上升,但对业务侧更友好。

4.3 提供 HTTP 接口:Flask 几行代码上线

检测器能用了,怎么暴露给业务?常见做法是用 Flask/FastAPI 包一个 POST 接口,输入 URL,输出概率和判定结果:

from flask import Flask, request, jsonify app = Flask(__name__) detector = PhishingDetector('model.pkl', 'scaler.pkl') @app.route('/api/check', methods=['POST']) def check(): payload = request.get_json(silent=True) or {} url = payload.get('url', '') if not url: return jsonify({'error': 'url required'}), 400 return jsonify(detector.predict(url)) if __name__ == '__main__': app.run(host='0.0.0.0', port=8080)

get_json(silent=True)在解析失败时返回 None,不会直接抛 400 让客户端看到堆栈。生产环境还应该在接口前面加限流,并按调用方做白名单,否则检测接口被刷会白白消耗你的抓取带宽。对于不可达 URL 的响应也要落日志,方便排查是目标站点挂了还是被反爬拦了。

4.4 参数配置:阈值、超时、并发怎么配

最小系统的核心参数就四项,配置之前先想清楚自己的场景:

参数第一版推荐值说明
threshold0.6调高降误报,调低提召回
fetch timeout3 秒内容层抓取预算,网络差可放宽到 5
并发数10 线程再大会被目标站点拉黑
日志记录每个特征的分数用于误报回灌和问题排查

批量检测时用ThreadPoolExecutor控制并发:

from concurrent.futures import ThreadPoolExecutor urls = ['https://example.com', 'http://phishing-test.com'] with ThreadPoolExecutor(max_workers=10) as pool: results = list(pool.map(detector.predict, urls))

pool.map按输入顺序返回结果,方便和原始 URL 对应。并发数 10 是经验值,内网检测可以放到 20,公网环境建议 5 到 10,毕竟目标站点不是你建的压测平台。

5. 常见问题与避坑:四个让检测系统翻车的坑

5.1 特征不一致:同一 URL 训练时判钓鱼、上线后判正常

现象:离线测试 AUC 0.95,模型上线后同一批测试 URL 重新检测,分数全变了,甚至把训练集里标的“正常”样本判成了钓鱼。

原因:训练脚本里对 URL 做了预处理——strip()去空格、lower()转小写、rstrip('/')去尾部斜杠、剥掉www.前缀;而接口代码里拿原始 URL 直接算特征,长度、子域名数、敏感词匹配结果全都不一样。特征不一致是最隐蔽的坑,代码不报错,分数悄悄漂移。

解决:把 URL 归一化收敛成一个函数,训练、回测、线上接口都 import 同一个:

def normalize_url(raw: str) -> str: url = raw.strip().lower() if url.startswith('www.'): url = 'http://' + url if url.endswith('/'): url = url.rstrip('/') return url

然后在extract_all_features第一行调用url = normalize_url(url)。这样谁改预处理流程,所有环节都同步变。上线前再跑一遍“训练集首尾各抽 100 条,对比离线特征和线上特征是否完全一致”的回归测试。

5.2 数据集时效性:拿失效的钓鱼样本当正样本

现象:模型对一个月前的钓鱼域名记得很牢,对新出现的钓鱼手法反而漏报,误报率还高。

原因:钓鱼 URL 的平均存活时间极短,你从 PhishTank 下载的 CSV 里,大量样本是几周甚至几个月前提交的,现在打开已经变成广告页或空页面。把这些“死样本”当正样本,模型学到的是历史特征分布,和现网对不上。

解决:下载数据时用submit_time字段过滤,只保留最近 30 天的样本;训练前随机抽 10% 重新抓取验证,状态码不是 200或页面内容为空的,从正样本里剔除。如果验证时抓取失败,不要简单标成“仍钓鱼”,而是先人工看一眼,有可能只是你的抓取 IP 被反爬拦了。

5.3 抓取失败填 0:被反爬直接“静默绕过”

现象:目标站点了 WAF 或反爬,requests 返回 403,extract_page_features拿不到 HTML;代码里把所有内容特征填 0,结果模型把这类站点判成“正常”。攻击者只要加一点点反爬手段,整个检测就被绕过了。

原因:内容层特征里has_form、has_password_input、external_link_ratio都是“钓鱼倾向”的正向信号,缺失时统一填 0,等于告诉模型这是一个“没有表单、没有密码框、外链比例 0”的完美页面,模型自然倾向判正常。

解决:加一个fetch_success独立特征,抓取失败时置 0,成功置 1;内容特征不填 0,填中性值或训练集的中位数。我前面extract_all_features里已经把external_link_ratio填成 0.5,就是为这个坑准备的。同时,抓取失败的样本在返回结果里标记低置信度,让上层策略既不直接放行,也不直接阻断,而是降级到二次验证。

注意:fetch_success必须参与训练,不能上线时才加上去,否则模型没见过这个特征,推理时特征维度对不上。

5.4 阈值一刀切:高召回把正常业务也拦了

现象:阈值放到 0.5,钓鱼检出率很好看,但企业客户投诉“正常登录也被拦”“支付页被提示风险”,每天误报上百条,安全运营被告警淹没。

原因:钓鱼检测的误报成本和漏报成本在不同场景下完全不对称。登录、支付场景下误拦截代价很高;但在内容浏览场景下,漏报钓鱼的代价更高。一个固定阈值没法同时照顾两端。

解决:把判定做成多档,而不是 0/1 二值。prob >= 0.9直接阻断;0.7 <= prob < 0.9弹二次验证;0.5 <= prob < 0.7只在页面上降权提示,不妨碍访问。同时把每一条拦截结果写到反馈表,运营人员可以直接标记误报,这些标记过的样本是后面进阶里最有价值的训练数据。

6. 进阶技巧:误报回灌与三层阈值,让检测系统越用越准

6.1 误报回灌:把业务侧“平反”的样本变成训练数据

系统上线只是开始。运营人员每天在反馈表里标记的误报,是最真实、最干净的负样本。我习惯每周做一次回灌:把本周确认误报的 URL 重新跑一遍特征提取,拼上label=0追加到训练集;再把本周新增的钓鱼情报也跑一遍特征,拼上label=1。重训前必须先跑一次 A/B:新旧模型对最近 7 天数据的 AUC 和误报率做对比,新模型没有明显提升就不替换。这个流程看起来慢,但能让系统在两周后明显比刚上线时更贴合你的业务流量。

6.2 三层阈值:给“可疑”留一段缓冲带

前面避坑里提到了多档判定,落地时我一般维护三档阈值。0.9 以上直接阻断,拦截日志里要带 feature 明细,方便出问题回溯;0.7 到 0.9 弹验证码或双重认证,考验它是不是真人;0.5 到 0.7 只做降权提示。另外,概率落在 0.4 到 0.6 之间的样本是误报最密集的区域,每周抽出一部分人工复审,你会发现很多是短链接、内网地址、个人站这类“长得像钓鱼但不是”的流量。这些复审结果比模型预测值更有价值,直接进下一轮回灌。

6.3 我踩过的一次回灌教训

刚做这套系统时,我连续两周把误报样本直接追加进训练集,结果第三周误报率反而翻倍。查了半天发现,原因是回灌时只加了 URL 和 label,没有重跑特征提取,旧样本用的还是老特征口径,和新特征混在一起,模型学出了一套自相矛盾的权重。后来我养成了一个习惯:任何样本进入训练集之前,必须从原始 URL 重新跑一遍extract_all_features,并跟当前特征列名做逐列对齐。这个习惯帮我避掉了后面好几次回灌翻车。如果这套思路能帮你在做安全检测项目时少踩一个坑,那我就没白写——希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进