ARTICLE DETAIL

资讯详情

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

从启发式特征到机器学习:构建钓鱼网站检测系统实战

从启发式特征到机器学习:构建钓鱼网站检测系统实战 简介面向网络安全初学者与Python开发者这套基于启发式特征的钓鱼网站检测系统以域名结构、URL关键字、网页内容相似度等可量化特征为切入点结合朴素贝叶斯、随机森林等分类算法为识别钓鱼站点提供了可落地的代码方案。压缩包共2个Python文件整体仅7KB代码结构紧凑覆盖requests网页获取、BeautifulSoup结构解析、正则匹配关键词及scikit-learn建模等关键环节适合直接运行或移植到实际检测流程中。已有180人学习下载文件按数据收集、特征工程、模型训练、预测评估与实时检测的顺序组织读者可从中掌握启发式规则设计、特征向量构建、模型调参与效果评估的完整思路。这套方案尤其适合用于网络安全课程设计、毕业设计或企业安全防护的入门参考能够帮助快速搭建一个轻量级钓鱼网站识别原型。1. 用启发式特征做钓鱼检测为什么比黑名单更值得投入很多人一提到钓鱼网站检测第一反应是维护黑名单、拉取威胁情报库。但黑名单天然有滞后性——一个新注册的钓鱼域名平均存活时间不到24小时等情报商更新、规则下发攻击者早就打完收工了。基于启发式特征的检测系统走的是另一条路不去匹配“已知的坏”而是从URL结构、域名信息、页面内容里提取一组可计算的信号把“看起来像钓鱼”这件事变成机器学习模型能读懂的数值。这也是为什么这类项目在一线安全运营和风控场景里越来越常见——它能捕捉未知样本。这套系统适合三类人一是刚接触Web安全的Python开发者想做一个能跑通完整闭环的项目二是安全运维人员需要在内网或本地搭一个自助检测入口三是准备做安全方向毕设或作品集的学生。核心不复杂抓取URL和页面特征喂给分类模型输出置信度。但把准确率从“能用”做到“敢用”中间有不少细节值得讲清楚。2. 启发式特征到底在提取什么从URL到页面内容的信号拆解2.1 URL结构特征长度、特殊字符与数字密度启发式特征的第一层来源是URL本身。钓鱼链接最常见的伪装手段是把可信域名塞进URL前缀或路径里比如http://apple.com.example-login.com/verify这种结构肉眼难辨但对程序来说规律很明显。你需要从URL里抽出类似这样的特征URL总长度、域名部分的长度、路径深度、是否包含IP地址、是否使用非标准端口、敏感关键词出现次数比如login、verify、account、secure。还可以统计数字字符在域名中的占比——合法门户极少把一串数字塞进主域名而很多钓鱼域名是随机生成的。2.2 域名与WHOIS特征注册时间、注册商、TTL第二层是域名信息。这里包含两个信息源一是WHOIS数据包括域名年龄、注册商、注册邮箱是否来自免费服务商二是DNS数据比如CNAME记录指向、权威NS是否属于免费二级域名服务商。钓鱼站点的域名注册时间普遍很短80%以上在30天以内很多还挂在.tk、.ml这类免费域名或动态DNS服务下。注意查询WHOIS和DNS是网络交互单条耗时可能到几百毫秒所以这类特征通常不在实时检测路径上做而是放进后台离线打分。2.3 页面内容特征表单、重定向与敏感标签第三层是HTML内容。钓鱼页面不管怎么包装最终目的是骗用户提交凭证所以页面里必然有表单、输入框、密码框或者一整段用JavaScript渲染的虚假登录界面。常见做法是用正则或解析库提取页面里form标签数量、password类型输入框数量、外部脚本引用数量、iframe是否嵌入了第三方页面、是否有meta refresh或JavaScript location跳转。把一个页面从str变成一组数值向量这一步是整个系统的预处理地基。2.4 特征池的完整定义一份可扩展的Schema前三个小节说的是选取特征的逻辑。为了能落地跑起来我一般会把特征池定义成结构化的Python字典每种特征一个是名字、一个提取函数、一个类型标注。这样做的好处是后续加特征不需要改模型代码只要往字典里追加一组键值对即可。# features_pool.py # 每个特征都是一个 dictname 表示列名func 是提取函数kind 区分数值/类别 FEATURES_POOL [ {name: url_len, func: lambda u: len(u.geturl()), kind: num}, {name: domain_len, func: lambda u: len(u.hostname or ), kind: num}, {name: digit_ratio_in_domain, func: lambda u: sum(c.isdigit() for c in u.hostname or ) / max(len(u.hostname or ), 1), kind: num}, {name: path_depth, func: lambda u: len([p for p in u.path.split(/) if p]), kind: num}, {name: has_suspicious_word, func: lambda u: 1 if any(w in u.geturl().lower() for w in [secure, login, verify, account]) else 0, kind: num}, {name: is_ip_url, func: lambda u: 1 if re.match(r^\d\.\d\.\d\.\d$, u.hostname or ) else 0, kind: num}, {name: has_at_sign, func: lambda u: 1 if in u.geturl() else 0, kind: num}, ]这里的关键点url_len和domain_len直接量纲不同前者可能到几百后者一般不到三十。后面喂给模型前需要做标准化或归一化否则决策树类模型尚可接受逻辑回归会明显被大数值特征带偏。digit_ratio_in_domain用一个max(len(...), 1)防止除零这是最容易翻车的细节空hostname会让整个特征矩阵出现nan。2.5 抽样数据怎么标注半自动化的标签策略有监督模型需要正负样本钓鱼样本可以来自PhishTank这类公开库的公开URL列表正常样本可以从Alexa或国内站长之家Top榜爬取。但一味依赖外部情报启发式系统的优势就丢了。我建议在标注上做一点半自动化先用现有黑名单把命中URL打上钓鱼标签再把白名单站点打上正常标签对既不在黑名单也不在名单里的URL用规则初判踢出训练集只保留高置信度样本。这样手动复核量能压到很小训练数据纯度也够。注意标注过程不要引入数据泄露。页面内容特征里的has_form等字段必须在访问页面时提取不能拿提交检测时用户自己输入的校验结果当标签。否则模型会在训练集上得分虚高上线后立刻崩溃。3. 构建训练流水线从特征提取到分类模型落地3.1 抓取与解析requests BeautifulSoup的最小实现先做一个能单条跑通的采集模块。它负责下载页面、提取features_pool.py里定义的所有特征、和后端存储交互。这里请求头要尽量模拟真实浏览器很多钓鱼页面会针对非浏览器UA返回空内容或302跳转。此外要设置超时和重试机制单条URL的下载时间不能超过5秒否则整个批处理任务会被拖垮。import requests from urllib.parse import urlparse from bs4 import BeautifulSoup def fetch_and_extract(url, timeout5): # 模拟浏览器 UA避免被服务器识别为爬虫返回空页 headers {User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0} try: resp requests.get(url, headersheaders, timeouttimeout, allow_redirectsTrue, verifyFalse) except Exception: return None soup BeautifulSoup(resp.text[:200_000], html.parser) # 提取页面层面的启发式信号 forms soup.find_all(form) inputs soup.find_all(input) pwd_inputs [i for i in inputs if i.get(type) password] parsed urlparse(resp.url) # 注意用最终跳转后的 URL return { final_url: resp.url, has_form: 1 if forms else 0, input_count: len(inputs), pwd_input_count: len(pwd_inputs), has_external_iframe: 1 if soup.find_all(iframe) else 0, page_len: len(resp.text), # URL 层面特征继续复用 features_pool 中的函数 **{f[name]: f[func](parsed) for f in FEATURES_POOL if url in f[name] or domain in f[name]} }有几个容易踩的点。resp.url必须取重定向后的最终地址因为攻击者往往把恶意页挂在跳转链末端如果拿原始URL做特征提取等于分析了一半链路。resp.text需要截断长度有些钓鱼页面会把垃圾字符串塞满整个响应体来干扰检测限制响应体大小既能保护内存也避免把无效长文本串进特征。verifyFalse是因为部分钓鱼站点证书自签但这一步要确认公司安全策略允许关闭校验。3.2 把特征向量转成DataFrame特征对齐是头号工程问题批量抓取完成后所有样本要拼成一个二维表。这里最麻烦的不是下载而是特征对齐。比如某个样本的WHOIS查询超时或者某个URL解析异常导致某些列缺失。处理缺失值最忌讳直接drop行本来就少见的钓鱼样本可能没几条drop完模型直接没偏置了。我一般采用数值特征填该列中位数或-1类别特征填大多数类然后在样本权重上做一点降权。import pandas as pd # raw_results 是 fetch_and_extract 返回的多条字典这里拼成 DataFrame df pd.DataFrame(raw_results) # 数值列统一填充 num_cols df.select_dtypes(include[float64, int64]).columns df[num_cols] df[num_cols].fillna(df[num_cols].median()) # 把缺失标记也作为一个特征有的模型能从中学到“查不到本身就是异常” df[is_whois_missing] df[whois_age_days].isna().astype(int) # 标准化仅对数值类特征做 Z-score from sklearn.preprocessing import StandardScaler scaler StandardScaler() df_scaled pd.DataFrame(scaler.fit_transform(df[num_cols]), columnsnum_cols) df_final pd.concat([df_scaled, df[[label]]], axis1)填充中位数是一个比较稳妥的默认做法。但还有更精细的手段若某个特征在钓鱼样本里的缺失率显著高于正常样本例如WHOIS信息查不到说明“缺失”本身就是一个强信号这时把is_whois_missing单独作为一个特征喂给模型往往比填充值更有效。3.3 模型选择为什么默认先跑随机森林面对这种特征空间不大、特征含义明确的检测任务我默认先跑随机森林。理由有三一是它对特征量纲不敏感标准化做不做都能出结果二是能输出feature_importances_方便分析哪些启发式信号在起作用三是对类别特征和数值特征的混合支持好。相比之下逻辑回归的系数可以直接解释但对非线性关系表达弱XGBoost精度高但要调的参数多适合放在有调参余地的二期。from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report X df_final.drop(columns[label]) y df_final[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_depth10, min_samples_leaf5, class_weightbalanced, # 钓鱼样本占少数自动均衡权重 random_state42, n_jobs-1 ) model.fit(X_train, y_train) y_pred model.predict(X_test) print(classification_report(y_test, y_pred, target_names[legit, phishing]))class_weightbalanced这一个参数抵得上十行过采样代码——它根据类别频数自动给少数类样本加权能明显拉高召回。min_samples_leaf5防止树在少数类样本上过拟合出单点规则。max_depth10限制单棵树规模让模型泛化到从未见过的变体。3.4 阈值不是默认的0.5用预测概率校准取舍predict默认在概率超过0.5时输出钓鱼。但在真实运营里0.5这个阈值往往不理想漏报一条钓鱼的成本账号被盗、业务被仿冒远高于误报一条正常站的成本。所以我在系统中保留predict_proba的输出让运营人员通过阈值滑杆动态调节。这也是启发式检测系统有别于规则引擎的核心优势——规则是硬逻辑模型是软决策。# 输出概率而不是硬分类方便下游做置信度分级 y_proba model.predict_proba(X_test)[:, 1] # 分桶高风险/中风险/低风险 df_result pd.DataFrame({ url: X_test.index, # 具体到数据时为每条记录保存原始URL phishing_proba: y_proba }) df_result[risk_level] pd.cut( df_result[phishing_proba], bins[0, 0.3, 0.7, 1.0], labels[low, mid, high] )3.5 精度不够时优先加什么从失败样本反推特征如果第一个版本的模型跑出来F1不好我不建议急着换模型或调参。先把测试集里被误判的样本捞出来逐个人眼看一遍。这类样本往往暴露同一类信息缺口比如某条钓鱼URL因为用了HTTPS证书导致模型产生“安全”的错觉比如页面内容被JavaScript完全加密爬虫抓到的HTML里没有表单特征。把这类缺口抽象成新特征比调参有效得多。启发式特征系统的迭代本质就是不断把人类经验翻译成数值维度。4. 钓鱼检测系统的避坑手册5个让模型翻车的真实案例4.1 坑一WHOIS查询把批处理拖成死循环现象抓取2000条URL跑了一小时还没结束看日志发现大量超时重试。 原因WHOIS服务基于TCP 43端口很多顶级域名的WHOIS服务器响应极慢或直接拒连部分注册信息提供商对批量查询有速率限制短时间高频请求会触发封IP。 解决给WHOIS查询单独设置更短超时比如2秒把查询失败标记为缺失特征同时增加本地TTL缓存同域名24小时内不重复查询。调通后再看完整预处理流程单条耗时能从3秒降到100毫秒以下。4.2 坑二label与特征提取逻辑打架出现数据泄露现象训练集AUC高达0.99测试集F1只有0.6。 原因在构造训练集时直接用“页面里是否有登录表单”作为标注口径同时又把这个字段作为模型特征。模型等于直接抄答案一上线面对真实URL就瞎了。 解决标注必须与特征构建完全解耦。训练样本的标签只能来源于外部情报举报平台、人工复核不能把待检特征当标签。代码把has_form这类字段一分为二作为特征的特征版本和作为标注的运维版本。4.3 坑三把URL中的随机子域名误判为可信来源现象模型对https://shop-random-string.taobao.com/...输出低风险。 原因url_len和domain_len只看到了总长度和主域名没有区分“子域名层级数”和“子域名是否可疑”。攻击者会专门注册高仿子域名来欺骗只查主域名的模型。 解决增加两条特征subdomain_levels子域名层级数和subdomain_has_random_word子域名中是否包含数字小写字母混合段。这两条对识别xxx-abc123.weixin-app.com这类结构有奇效。4.4 坑四钓鱼页面检测不能只认HTMLJS渲染的页面抓不到表单现象召回率在带location.href跳转的页面上掉到不足20%。 原因爬虫拿到的是首包HTML真正钓鱼表单由JavaScript动态写入DOMBeautifulSoup静态解析拿不到。这在仿冒登录页里非常普遍。 解决引入无头浏览器渲染而不是直接解析静态HTML。虽然这会让单条样本耗时增加2-3秒但只对“已通过初筛疑似”的URL做二次渲染即可不必批处理全量渲染。4.5 坑五模型上线后只测了历史数据没测在线推理耗时现象接口响应时间从100ms涨到800ms网关直接超时。 原因predict_proba单条推理很快但特征提取阶段如果做了同步的WHOIS查询或无头浏览器渲染整个链路就从计算开销变成了IO开销。 解决把系统拆成异步两段——轻特征URL结构、域名统计、静态HTML走在线同步推理重特征WHOIS、JS渲染走离线异步补齐在线接口使用轻特征版本输出的置信度结合离线特征再校正一次。这也是工程落地中一个常见且务实的分层设计。5. 把模型封装成可用服务FastAPI接口与误报收敛策略5.1 接口设计单条检测与批量检测分开系统最终要给人用也需要给别的系统调。我用FastAPI写一个轻量接口/detect负责单条URL检测/batch_detect接收URL列表。单条接口走同步返回置信度批量接口提交后返回任务ID由后台Worker逐条处理。分开的原因很直接单条要低延迟批量要吞吐两者对特征提取策略的要求不一样。from fastapi import FastAPI from pydantic import BaseModel app FastAPI(doc_url/docs, redoc_urlNone) class UrlIn(BaseModel): url: str class UrlBatchIn(BaseModel): urls: list[str] app.post(/detect) def detect(item: UrlIn): # 在线推理只用轻特征控制整体延迟在300ms以内 light_features extract_light_features(item.url) proba model.predict_proba([light_features])[0][1] return {url: item.url, phishing_proba: round(proba, 4)} app.post(/batch_detect) def batch_detect(item: UrlBatchIn): # 批量任务入队后异步处理支持重特征 task_id queue.put(item.urls) return {task_id: task_id, status: accepted}批处理队列我用Redis做任务存储Worker消费时逐条提取重特征再推理。去平台的独立部署方案是起一个容器跑API再起两个容器跑WorkerRedis做任务分发。整套服务的资源开销控制在4核心8GB内存以内普通物理机就能扛。5.2 误报收敛置信度分级怎么融入安全运营流程检测系统不能只输出一个分数就结束一线运营人员每天面对海量告警最怕的就是狼来了。我的做法是把输出分三个等级。低风险proba0.3直接放行不产生告警中风险0.3-0.7进入观察名单定时二次复核如果48小时内域名新增了WHOIS记录或页面结构大改则重新检测高风险proba0.7直接进入人工复核队列并附带特征快照——包括URL、域名年龄、命中哪些可疑特征方便运营人员快速判断。这个分级的另一个好处是你可以按周统计每个等级的误报率倒推模型哪里需要调。如果中风险区间里正常网站占比过高说明特征池里缺少能区分“新兴但合法”的特征比如企业ICP备案信息。5.3 写一份能交接的模型卡模型训练完成后尽量顺手产出一份模型卡内容包括特征池版本号、训练样本量、正负比例、模型参数、各风险分段的精确率召回率、以及已知失效场景比如对纯JS渲染页面有盲区。这一份文档决定团队在三个月后还能不能看懂这个模型也决定你离职交接时会不会被骂。6. 进阶技巧用每日新样本做持续学习让模型越用越准前面的版本里模型是离线训练一次然后永久部署。但钓鱼站点的形态变化太快今天的特征池能捕捉的套路三个月后可能就失效了。所以系统运行一段时间后要进入持续学习模式。具体做法是每周导出上周被人工复核为“钓鱼”的URL和运营确认无误的“正常”URL组成增量训练集用这些增量样本对原模型做微调更新而非完全重新训练。增量更新的技巧在于样本量控制。每周新增样本通常在几百到一千之间直接和原始训练集合并重训会改变历史样本的权重分布导致模型遗忘已经学会的规律。我一般是用warm_startTrue的方式在原有树结构上继续拟合增量数据并调大learning_rate对应的权重让新样本影响更明显。如果你用逻辑回归则可以把增量训练当成在线梯度下降来做每一步沿着新损失方向走一小步旧数据的影响通过partial_fit自然衰减。持续学习有一个必须处理的坑反馈回路。如果系统输出的低风险URL从不被人工复核那模型永远学不到“漏报”样本只会越来越偏。我现在的做法是每天自动从低风险样本里随机抽5%加进人工复核队列保证每个风险段都有持续的反馈流。这既能防止模型偏置也能让运营团队对低风险段的误判有感知。最后提醒一点凡是要部署到公网环境的检测服务日志里不要保存完整的URL参数。钓鱼URL常常会携带攻击者注入的追踪参数或受害者的会话标识直接存原始URL等于把敏感信息写进日志。我习惯在入库前对URL做哈希只保留特征列。这个习惯被同事念叨过很多次但用过一次就知道它能省多少麻烦。希望这篇笔记能帮你在自己的环境里把启发式钓鱼检测跑通并让它真正承担起防线的一环。本文还有配套的精品资源点击获取
返回列表