简介面向网络安全学习者和Python开发者的钓鱼网站检测系统源码基于启发式特征构建识别方案针对钓鱼网站伪装正规站点、诱导用户提交敏感信息这一典型威胁提供从特征提取到分类判别的完整代码实现。压缩包为zip格式仅7KB包含2个Python文件分别负责URL域名结构、网页内容相似度、关键词匹配等启发式特征的抽取与检测判定。已有180人学习下载。源码涉及requests网页请求、BeautifulSoup页面解析、正则匹配关键词等常用库操作覆盖数据预处理、特征工程、模型训练与预测评估的完整链路完整实现域名关键字、多子域名、URL长度、HTML结构、文本相似度等检测维度并给出朴素贝叶斯、决策树、随机森林等模型训练思路及实时检测与定期更新优化的扩展方向适合网络攻防、信息安全课程设计或入门实践参考。1. 为什么我建议用启发式特征做钓鱼网站检测先想清楚再动手写代码做安全这一行的人迟早会碰上一个尴尬场景手里拿到的域名是刚注册两小时的黑名单里没有威胁情报平台还没收录但钓鱼页面已经上线了。这个时候基于历史样本的检测方案全部失效唯一还能拦住它的就是启发式特征。python基于启发式特征的钓鱼网站检测系统核心思路是把人对钓鱼网站的直觉判断翻译成可计算的规则——URL 长得像不像、页面里有没有登录框、外链指向哪里、域名是不是刚注册的把这些信号加权聚合最终得到一个可疑度分值。这套方案的关键优势在于不依赖外部情报拿到一个 URL 就能独立判断非常适合反欺诈风控、企业内部安全审计、安全服务商做前置拦截这几个场景。受众也很明确写 Python 的安全工程师、做数据分析想转安全的开发者以及需要在本地验证一批 URL 是否可疑的运维人员。接下来我从特征设计讲起逐步落到可运行的 Python 代码上。2. 启发式特征设计拿到一个 URL先看这五组特征启发式检测的第一步不是写代码而是定义可疑长什么样。我拆过几百个钓鱼站点样本发现无论目标怎么变钓鱼 URL 在五个维度上总会出现可观测的异常。把这五个维度量化为特征就等于搭好了整个系统的特征骨架。2.1 域名层特征注册时长、TLD、子域名结构域名是钓鱼攻击绕不开的载体。攻击者为了省钱省事通常会注册新域名、选用低价 TLD、把子域名伪装成知名品牌。这里有三条最常用的特征线索第一条是域名年龄。新注册域名是钓鱼的高发群体一个域名注册时间不到 30 天且出现在登录页场景里可疑度就要上调。检查域名年龄最常见的做法是尝试读取域名的 WHOIS 注册时间。注意直接调用公共 WHOIS 接口容易被限流我一般会在本地维护一份已知常见 TLD 的 WHOIS 服务器映射表用 socket 直连查询超时控制在 5 秒内。拿不到注册时间时特征值设为一个偏可疑的默认值而不是直接判为安全。第二条是 TLD 分布。钓鱼站点显著集中在低价 TLD 上例如 .tk、.ml、.ga、.xyz 等。但这张列表不能写死公网上的免费 TLD 名单每隔几个月就会变动需要定期更新。第三条是子域名层数。正常的业务域名不会平白无故地在主域名前挂三层子域名而钓鱼站为了藏住真实域名经常把目标品牌名放在子域名里例如paypal.login-verify.example.com。这种结构本身就是一个强特征。2.2 URL 路径特征关键词命中与特殊符号密度域名之外URL 路径部分是攻击者最喜欢做手脚的地方。以下特征在实际检测中区分度很高第一是路径中的敏感关键词例如login、signin、verify、account、update、secure、confirm、webscr等。这些词出现在路径里并不代表一定是钓鱼但配合登录页内容出现时可疑度会显著叠加。第二是 URL 中的特殊符号密度包括、-、%、//等。符号会让浏览器忽略它前面的内容真实跳转目标是后面的域名这种伪装用肉眼很难看出来。连续多个%编码通常意味着攻击者对 URL 做了混淆。连字符-在正常域名中很少连续出现但在仿冒域名里被大量使用。第三是 IP 直接代替域名。攻击者有时会直接使用 IP 地址承载钓鱼页面绕过需要域名注册的成本。特征提取时要单独标记URL 主机是否为纯 IP 或 IP 加端口。2.3 页面内容特征登录框、表单提交与页面标题用户访问钓鱼页面最终目的是输入账号密码。因此页面上是否存在登录表单是整个检测链条中权重最大的一项。我会用 Requests 抓取目标 URL 的 HTML第一步检查 HTML 中是否包含form标签和input标签特别是typepassword的输入框。第二步检查表单的action属性是否指向另一个域名跨域提交表单是钓鱼站的典型行为——正常网站登录时会把数据提交给自己域名下的接口只有钓鱼页面会把数据交到攻击者控制的服务器上。页面标题也有参考价值。仿冒页面通常会在标题里写入品牌名例如 Sign In - PayPal 或 Apple ID - Sign In。特征提取时可以把标题中的文字与预置的品牌关键词表做匹配命中越多越可疑。2.4 外链资源特征页面引用的资源域名与主域名是否一致这一步经常被入门者忽略但实战效果很好。钓鱼页面通常是攻击者临时拼凑的它引用的 CSS、JS、图片往往存放在其他域名下。用正则提取 HTML 中的所有链接资源地址解析这些资源所属域名然后计算一个比例资源域名与当前页面域名不一致的数量除以资源总数。这个比例超过一定阈值时页面极大概率是套壳的仿冒站。正常网站的静态资源基本走自己的 CDN 或自有域名就算有外部资源也是少数几家知名 CDN。如果页面上十个链接有八个跨域且其中还有免费图床域名这个页面就非常可疑了。把这个特征量化成一个浮点数配合登录框特征一起使用能明显降低误报。2.5 品牌关键词匹配把看起来像谁变成可计算的数值启发式检测的另一条主线索是品牌仿冒检测。这里需要一个品牌关键词库格式很简单就是一个包含品牌名、常见变体、对应域名后缀的字典。例如针对某支付平台可以记录brand_name: paypal、keywords: [paypal, paypal-security]、domain_suffix: paypal.com、common_tlds: [com]。提取 URL 时先检测域名主体部分是否包含品牌关键词再检测路径和标题中是否包含品牌关键词两边都命中时给出最高权重。注意品牌关键词库需要人工维护不同的业务方向维护成本差异很大。如果是通用安全产品建议只维护 Top 20 的全球品牌如果是垂直行业的安全方案按行业维护关键词表即可不需要追求大而全。3. 把特征变成评分用 Python 实现特征提取与检测主流程特征定义完成后下一步是把这些规则写进 Python 代码。我建议采用加权评分模型而非机器学习模型。原因有两点一是加权评分的每个参数都能解释上线后出现误报时可以迅速定位是哪一个特征权重过大二是以 Python 内置库为主就能实现不需要额外引入 sk-learn部署和维护都轻量。3.1 搭建项目结构与基础工具函数项目建议按以下结构组织方便后续扩展和单独调试各个特征模块phishing_detector/ ├── detector.py # 主检测入口汇总各特征评分 ├── features/ │ ├── domain_features.py # 域名层特征提取 │ ├── url_features.py # URL 路径特征提取 │ ├── page_features.py # 页面内容特征提取 │ └── brand_features.py # 品牌关键词匹配 ├── rules/ │ └── keywords.py # 敏感词、品牌词表 └── utils/ └── helpers.py # URL 解析、HTML 提取等工具先把utils/helpers.py写好。这个模块解决一个隐含问题从 URL 中正确提取主域名和子域名信息。Python 标准库里的urllib.parse只能拿到 hostname无法区分主域名和子域名需要用公共后缀列表来处理。tldextract是完成这个任务的常用第三方库它会把 URL 拆成subdomain、domain、suffix三个部分支撑后续所有域名层特征的计算# utils/helpers.py import tldextract from urllib.parse import urlparse def decompose_url(url): 分解 URL提取主域名、子域名、路径等结构化信息 ext tldextract.extract(url) return { subdomain: ext.subdomain, # 子域名如 login.paypal domain: ext.domain, # 主域名主体如 paypal suffix: ext.suffix, # 顶级域如 com fqdn: ext.fqdn, # 完整域名 } def is_ip_host(hostname): 判断主机名是否为纯 IP 地址 import ipaddress try: ipaddress.ip_address(hostname) return True except ValueError: return False这段代码是整个系统的地基。tldextract的底层数据来自 public suffix list会按期更新比手工维护后缀表可靠得多。is_ip_host用来处理直接使用 IP 承载页面的情况。需要注意tldextract首次运行会下载列表数据离线环境部署时需要提前缓存好列表文件。3.2 域名层特征提取实现在features/domain_features.py里计算域名年龄和 TLD 风险分。这里不直接依赖外部 API而是用whois库查询注册时间。考虑到网络环境的不稳定性我把查询失败的情况单独处理不直接中断流程# features/domain_features.py import whois from datetime import datetime, timezone def get_domain_age_days(domain): 获取域名注册年龄返回天数查询失败返回 None try: info whois.whois(domain) creation info.creation_date if isinstance(creation, list): creation creation[0] if creation is None: return None # 统一处理时区信息 if creation.tzinfo is None: creation creation.replace(tzinfotimezone.utc) age_days (datetime.now(timezone.utc) - creation).days return max(age_days, 0) except Exception: return None def tld_risk_score(suffix): 高风险 TLD 命中返回 1否则返回 0 high_risk_suffixes {tk, ml, ga, cf, gq, xyz, top, work} return 1 if suffix in high_risk_suffixes else 0参数说明high_risk_suffixes用集合存储查询效率更高后续更新时直接追加或删除元素即可。整体评分逻辑是如果域名年龄小于 30 天额外加 15 分查询失败时加 5 分但不判死因为 WHOIS 接口本身也有超时问题。子域名层数特征的判断逻辑放在这里最合适。用decompose_url拿到 subdomain 后按点号分割如果层数大于等于 2意味着 URL 结构中有较多前缀但如果前缀里有品牌词又是另一层含义def subdomain_depth_score(subdomain): 子域名层级是否过深 if not subdomain: return 0 parts subdomain.split(.) if len(parts) 3: return 10 elif len(parts) 2: return 5 return 0两层子域名给 5 分三层及以上给 10 分。这个阈值是从大量样本里数出来的正常业务系统极少在主域名前挂两层以上的子域名而钓鱼构造者为了把品牌名塞进子域名很容易堆出三层结构。3.3 URL 路径特征提取实现features/url_features.py负责解析路径中的敏感关键词、特殊符号密度以及 IP 主机判断# features/url_features.py import re from urllib.parse import urlparse, unquote # 登录、验证类敏感路径词 SENSITIVE_PATH_KEYWORDS [ login, signin, verify, account, update, secure, confirm, webscr ] def extract_url_features(url): 提取 URL 层特征并返回特征字典 parsed urlparse(url) path unquote(parsed.path.lower()) # 先解码再匹配 hostname parsed.hostname or keyword_hits [kw for kw in SENSITIVE_PATH_KEYWORDS if kw in path] at_sign_count url.count() dash_count re.findall(r-{2,}, hostname) encoded_chars len(re.findall(r%[0-9a-fA-F]{2}, url)) features { path_keyword_hits: len(keyword_hits), at_sign_count: at_sign_count, double_dash_in_host: len(dash_count), encoding_count: encoded_chars, } return features这里有一个小细节提取符号计数时我直接对完整 URL 做count而不是只看 hostname 部分因为攻击者会把放在 URL 中间来干扰解析。unquote必须先执行否则%2f之类的编码路径里藏着login关键词时匹配不到。urlparse对包含的 URL 有自己的解析规则涉及这类样本时检查原始 URL 字符串更可靠。对应评分逻辑如下def url_path_score(features): URL 特征转评分 score 0 score min(features[path_keyword_hits] * 5, 15) # 上限 15 分 if features[at_sign_count] 0: score 20 # 符号是强特征 if features[double_dash_in_host] 0: score 10 score min(features[encoding_count] * 2, 10) # 编码次数上限 10 分 return score路径敏感词每命中一个加 5 分但上限 15 分避免页面 URL 上堆满关键词时分数失控。直接加 20 分逻辑也简单正常业务 URL 中携带的场景极少出现了就要重点核查。路径关键词命中多个时说明攻击者同时在多个攻击向量上做了堆叠这是比较明确的恶意信号。3.4 页面内容特征提取实现页面内容特征抓取需要小心超时和重定向问题。requests库的常见用法是设置 8 秒超时允许跟随重定向把最终页面的 URL 重新交回特征提取器再跑一遍域名特征。HTML 解析用lxml或正则都可以考虑到鲁棒性建议用lxml.html做结构化解析# features/page_features.py import requests import lxml.html from urllib.parse import urljoin, urlparse def fetch_page_html(url, timeout8): 抓取页面 HTML返回文本内容和最终 URL try: resp requests.get( url, headers{User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)}, timeouttimeout, allow_redirectsTrue, verifyFalse ) return resp.text, resp.url except requests.RequestException: return , url def extract_page_features(html, final_url): 提取页面中的表单与外链特征 features { has_password_input: False, form_cross_domain: False, external_resource_ratio: 0.0, } if not html: return features parsed lxml.html.fromstring(html) # 检测密码输入框 inputs parsed.xpath(//input[typepassword]) features[has_password_input] len(inputs) 0 # 检测表单提交目标是否跨域 for form in parsed.xpath(//form): action form.get(action) if not action: continue abs_action urljoin(final_url, action) if urlparse(abs_action).netloc ! urlparse(final_url).netloc: features[form_cross_domain] True break # 统计外部资源占比 resources [] for tag, attr in [(script, src), (img, src), (link, href)]: for node in parsed.xpath(f//{tag}[{attr}]): resource_url node.get(attr) if resource_url: resources.append(urljoin(final_url, resource_url)) if resources: external sum( 1 for r in resources if urlparse(r).netloc ! urlparse(final_url).netloc ) features[external_resource_ratio] external / len(resources) return features页面特征评分时有一个重要原则页面中存在密码输入框是强信号单这一项最高可给 25 分但只有密码框本身并不能判定为钓鱼很多正常业务页面都有登录功能。表单跨域提交给 15 分配合密码框一起出现时合计可达 40 分。外部资源跨域比例按 0 到 1 归一化直接乘 10 分用于辅助排序不设为否决项。3.5 汇总评分与判定逻辑顶层检测入口detector.py把所有模块串起来。阈值设置上我倾向于把默认总分设为 50 分总分大于等于 70 判定为可疑40 到 69 分为观察区40 分以下认为安全。这个阈值不是固定的上线后要结合实际流量负样本的回放结果调整# detector.py from utils.helpers import decompose_url, is_ip_host from features.domain_features import get_domain_age_days, tld_risk_score from features.domain_features import subdomain_depth_score from features.url_features import extract_url_features, url_path_score from features.page_features import fetch_page_html, extract_page_features def detect_phishing(url): 返回字典格式的检测结果 parsed_url decompose_url(url) base_score 0 reasons [] # 1. 域名层评分 age_days get_domain_age_days(parsed_url[fqdn]) if age_days is not None: if age_days 30: base_score 15 reasons.append(domain_age_lt_30d) else: base_score 5 reasons.append(domain_age_unknown) base_score tld_risk_score(parsed_url[suffix]) * 10 base_score subdomain_depth_score(parsed_url[subdomain]) # 2. URL 层评分 url_feat extract_url_features(url) base_score url_path_score(url_feat) # 3. 页面层评分抓取失败不中断 html, final_page_url fetch_page_html(url) if html: page_feat extract_page_features(html, final_page_url) if page_feat[has_password_input]: base_score 25 reasons.append(has_password_input) if page_feat[form_cross_domain]: base_score 15 reasons.append(form_cross_domain) base_score round(page_feat[external_resource_ratio] * 10, 2) else: base_score 5 reasons.append(page_fetch_failed) # 4. 分级判定 if base_score 70: level high elif base_score 40: level medium else: level low return { url: url, score: base_score, level: level, reasons: reasons, } if __name__ __main__: test_url http://paypal.login-verify.example.com/signin/ result detect_phishing(test_url) print(result)代码说明detect_phishing的返回值里包含reasons列表这是整个系统最容易调试的地方——每次检测完后都知道为什么给了这个分数在误报分析时可以直接看到触发项。page_fetch_failed单独加 5 分而不是给 0 分是因为抓取失败可能是目标服务器对爬虫做过反制这类站点本身比普通站点可疑度略高。整个流程抓取失败也不会退出可以当成一个可降级的检测服务使用。4. 部署与维护避坑规则阈值、编码和误报的典型翻车现场启发式检测系统的核心矛盾始终是误报率。特征设计完成并不等于工作完成实际部署时各种边界情况会让规则改变表现。以下几类问题是我在不同环境中反复遇到的。4.1 WHOIS 查询导致的程序阻塞现象系统刚上线时检测模块每隔几个请求就会卡住十几秒吞吐量骤降。排查后发现是查询 WHOIS 信息时部分域名对应的服务器响应极慢socket 连接长期不返回页面检测流程被完全阻塞。原因whois库默认不设置超时时间。部分海外 WHOIS 服务器在遇到不存在的域名时会长时间无响应而 Python 脚本按顺序执行一个请求卡住后续所有检测都被拖住。解决在whois.whois()调用前设置 socket 超时并限制单域名查询总时长。常见做法是先socket.setdefaulttimeout(5)再用concurrent.futures做并行查询或直接降级为缓存查询。更重要的一点是每次查询结果都要写入本地 SQLite 缓存同域名二次检测时直接读缓存不重复发起网络请求。缓存的有效期设为 7 天域名注册信息不会在短期内频繁变化。4.2 URL 编码顺序导致的规则绕过现象部分钓鱼样本在通过检测系统后又被人工确认为恶意。复盘时发现这些 URL 路径中包含大量%2f、%25等编码字符敏感关键词被拆散路径关键词匹配完全没触发。原因urlparse拿到的是原始 URL路径中带编码。例如%6cogin解码后是login但直接对原始路径做关键词匹配时匹配不到形成了绕过。解决在特征提取前先对路径做完整 URL 解码再执行关键词匹配。在extract_url_features函数中先调用unquote(path)同时记录解码前后路径是否发生变化变化越大说明混淆程度越高增加到独立特征里加分。这里还有一个额外收获某些攻击者会对路径做双重编码解码一次仍然不安全。比较有效的方法是循环解码最多三次若解码后内容与解码前不同标记为可疑。4.3 外链资源比例特征导致的误报现象某大型企业站点连续被系统标记为可疑人工复核后确认是正常业务页面。问题出现在外链资源比例这一项上页面引用了第三方统计脚本、客服系统、广告联盟的资源比例轻松超过 60%。原因现在正常商业站点的外链资源占比已经非常高尤其内容型网站大量接入第三方分析工具这一特征单独使用时区分度已经下降。解决把外链资源特征降权并引入白名单机制。我的做法是维护一份主流第三方资源域名列表包括常见的 CDN、统计平台、客服系统计算比例时先把这些域名过滤掉再统计。同时把这一特征从 10 分降为 5 分让它只影响排序不直接触发判定。4.4 页面上存在登录框但无 action 属性现象检测系统对某企业邮箱登录页面发出告警判断依据是页面存在密码输入框、表单 action 指向外部域名。但实际页面的登录是通过 JavaScript 异步提交的表单里根本没有 action 属性造成误判。原因当前判断逻辑是找到 action 属性才处理没有考虑纯前端提交的模式。现代 Web 应用大量使用 XHR 或 fetch表单的action是空的或直接写在 JS 文件里。解决处理表单时增加几个分支。表单存在action且跨域时直接加满action为空时继续寻找页面内 JS 文件中的登录接口关键词例如login、auth、signin等命中则标记为可疑 JS 提交。更稳妥的做法是同时抓取页面对应的 JS 文件内容解析fetch或XMLHttpRequest中的 URL 做同源判断。这一步会显著增加抓取耗时适合在检测精度要求高于吞吐量的场景下开启。4.5 非标准端口与短链接混淆现象一批通过检测的 URL 在人工复核中发现是钓鱼站URL 结构上并无异常但整体域名是新注册且使用了非标准端口号。端口特征没有纳入评分规则。原因钓鱼攻击者常使用非标准端口绕过策略性封锁例如https://xxx:8080/login的 8080 端口特征在原始特征集里完全没体现。解决增加端口维度。解析 URL 时提取显式端口非标准端口非 80 或 443时加 10 分。短链接服务也值得单独识别提取路径时若发现路径长度小于 8 且无明显的资源特征标记为跳转风险。这类补充性特征不需要权重太高放在排序层足够。5. 阈值调优与规则迁移用回放日志把误报率降下来检测系统上线不意味着工作结束频繁出现的误报会让运营团队快速失去对系统的信任。因此我的最后一个建议是把系统设计成可调参数、可回放的框架持续用真实流量日志调优。5.1 用历史日志做阈值回放第一步是准备测试集。从企业 DNS 解析日志或代理日志中随机抽取 1000 条真实访问 URL标记出其中确定为安全站点的样本。然后编写一个简单的回放脚本用这 1000 条数据跑一遍检测函数输出每个 URL 的分数分布。import json log_urls load_access_log(sample_1000_urls.txt) results [] for url in log_urls: result detect_phishing(url) results.append({url: url, score: result[score]}) # 按分数排序取中位数观察整体分布 results_sorted sorted(results, keylambda x: x[score], reverseTrue) with open(score_distribution.json, w) as f: json.dump(results_sorted, f, ensure_asciiFalse, indent2)分数分布出来后观察误报集中在哪个区间。经验值是从高于 70 分的样本开始人工复核识别出共同的业务特征后针对性地加白名单或降权。重复几轮后阈值会逐渐收敛到业务可接受的水平。回放这一步建议持续做因为业务站点的页面结构也在持续变化。5.2 把规则设计成可配置项参数调整最忌讳改代码后重新发布整个服务。我会把各类特征权重放在一个 JSON 配置文件中检测服务启动时读取热更新时直接修改文件并触发重载函数。核心权重字段包括{ weight_domain_age_lt_30d: 15, weight_tld_high_risk: 10, weight_subdomain_depth_2: 5, weight_subdomain_depth_3: 10, weight_at_sign: 20, weight_login_keyword_hit: 5, weight_password_input: 25, weight_cross_domain_form: 15, threshold_high: 70, threshold_medium: 40 }一旦出现误报先看触发原因再调整对应权重数值重启后直接生效。这个做法能让规则在运行中不断贴近业务实际而不是一直停留在理论阈值上。调低某个权重后建议保留原始值注释方便后续判断调整幅度是否有效。5.3 验证检测效果的量化方法上线后最基础的验证指标是误报率和召回率。有标注数据时可以从实际流量中再抽取 500 条 URL手动标注真实安全性再用检测系统跑一遍计算两项指标。如果误报率高于 5%需要重点排查业务自身的异常页面结构如果召回率偏低优先补充品牌关键词库和敏感路径词库而不是削足适履地降低阈值。对于没有标注数据的场景可以用人工抽检替代。每天固定抽 30 条被标记为可疑的 URL人工复核后记录机器判断与真实情况是否一致积累到 200 条附近就能比较稳定地估算系统的精确率。把整个方案做完后我最大的感受是启发式检测永远在规则覆盖和规则误伤之间来回博弈。因此我会保留每一版权重配置和每一条人工复核记录让系统每月能回过头来重新评估自己的判定习惯逐步压缩不需要的规则分支。这套方法论不复杂但它比任何单次调优都更值得坚持。希望这篇笔记能给你一个可复现的起步框架在实际项目中帮你少踩几个坑。本文还有配套的精品资源点击获取