资讯动态

恶意URL检测改进版:特征工程与对抗增强实战

发布时间:2026/10/22 9:47:42 来源:尧图企业网站定制
简介这份资源是机器学习检测恶意URL的改进版源码项目面向计算机、人工智能、大数据、数学、电子信息等专业正在做课程设计、期末大作业或毕业设计的学生以及希望上手安全检测算法的技术学习者。项目围绕恶意URL识别这一典型安全场景提供从数据处理、特征构建到模型训练与测试的完整实现思路适合具备一定编程基础、想深入理解分类算法落地流程的读者参考。压缩包共15个文件约10.82MB包含Python源码、txt说明、pickle模型文件、label标签、pcap流量包、png示意图与md文档等覆盖训练数据、模型文件与测试样本等关键环节目录结构清晰便于按模块查阅。目前已有64人学习下载。读者可从中获得可运行的完整源码、恶意URL检测的改进方案、模型训练与测试脚本以及配套说明文档便于快速理解项目结构、复现实验流程并在此基础上做二次开发或论文撰写。1. 恶意 URL 检测为什么总在真实流量里翻车做安全方向的人大多碰过这个场景离线测试集上准确率 99%一上线就被绕过攻击者把?id1改成?id1%20模型立刻判成正常。恶意 URL 检测这件事难的不是训一个分类器而是让它在对抗性输入、短链接跳转、编码混淆、域名快速轮换这些真实条件下还能稳住。这个源码项目说明围绕的就是「改进版」三个字——在基础字符级模型之上补上特征工程、对抗样本增强和在线误报抑制这几块短板。它适合两类人一类是手里已经有基础检测脚本、想把它推到能用的程度另一类是安全运营或风控岗需要理解为什么现成方案在自家流量上水土不服。整条链路的核心矛盾始终是同一个——URL 文本太短、信息密度太低模型很容易学到「域名长度」「是否含 IP」这类表面特征一旦攻击者换手法这些特征全部失效。后面几章我会把特征怎么选、模型怎么改、阈值怎么定、坑在哪一条条拆开讲。2. 从 URL 字符串到特征向量改进版到底改了什么基础版恶意 URL 检测通常只做一件事把 URL 当字符序列喂给模型。这条路能跑通但天花板很低。改进版的核心思路是「多视角特征融合」——同一个 URL从字符、词法、结构、上下文四个角度各抽一组特征再拼成统一向量。下面把每个视角讲清楚并给出可直接抄的抽取代码。2.1 字符级特征为什么不能只做 one-hot字符级特征是最容易上手的但直接 one-hot 会带来两个问题维度爆炸和稀疏。改进版一般用「字符频率 字符转移概率」的组合。字符频率统计 URL 里每个字符出现的比例转移概率则捕捉//、..、%%这类可疑组合。相比 one-hot这种表示维度固定在 128 左右且对短 URL 更友好。import numpy as np from collections import Counter # 常见可疑字符组合出现即加分 SUSPICIOUS_PAIRS [//, .., %%, , \\\\, xn--, 0x] def char_level_features(url: str) - np.ndarray: 抽取字符频率 可疑组合命中特征 url url.lower() # 1. 字符频率按 ASCII 范围归一化到 128 维 counter Counter(url) freq np.zeros(128, dtypenp.float32) for ch, cnt in counter.items(): idx ord(ch) if idx 128: freq[idx] cnt / max(len(url), 1) # 2. 可疑组合命中次数归一化 pair_hits [url.count(p) for p in SUSPICIOUS_PAIRS] pair_feat np.array(pair_hits, dtypenp.float32) / max(len(url), 1) return np.concatenate([freq, pair_feat])这段代码里freq是 128 维字符频率pair_feat是 7 维可疑组合命中率拼起来 135 维。参数上唯一需要调的是SUSPICIOUS_PAIRS列表——不同业务场景下可疑组合不一样比如电商场景要额外关注redirect、url这类参数。注意归一化用的是len(url)而不是固定值否则长 URL 会天然占优。2.2 词法特征把 URL 拆成有意义的 tokenURL 不是自然语言但它的结构有规律协议、域名、路径、查询参数、片段。词法特征就是把这些部分拆开分别统计。改进版一般会抽这几类域名层级数、路径深度、参数个数、参数值里是否含编码字符、顶级域是否在常见白名单里。from urllib.parse import urlparse, parse_qs # 常见顶级域白名单不在里面的加分 COMMON_TLDS {com, cn, net, org, edu, gov, io} def lexical_features(url: str) - np.ndarray: 抽取 URL 结构层面的词法特征 parsed urlparse(url) host parsed.hostname or path parsed.path or query parsed.query or # 域名层级按点分割 host_parts [p for p in host.split(.) if p] tld host_parts[-1] if host_parts else # 路径深度 path_depth len([p for p in path.split(/) if p]) # 参数个数 params parse_qs(query) param_count len(params) # 参数值里编码字符比例 encoded_ratio sum(v.count(%) for vals in params.values() for v in vals) / max(len(query), 1) # 是否含 IP 形式主机 is_ip 1.0 if host.replace(., ).isdigit() else 0.0 # 顶级域是否在白名单 tld_known 1.0 if tld in COMMON_TLDS else 0.0 return np.array([ len(host_parts), path_depth, param_count, encoded_ratio, is_ip, tld_known ], dtypenp.float32)这 6 维特征看着简单但在实际对抗里非常管用。is_ip能抓住直接用 IP 当域名的低级攻击encoded_ratio能抓住编码混淆tld_known能过滤掉一批用冷门顶级域注册的钓鱼站。参数上COMMON_TLDS要根据你的业务流量调整——如果业务本身面向海外白名单里得加上对应国家的顶级域否则误报会飙升。2.3 结构特征域名和路径的统计量结构特征和词法特征容易混区别在于词法看「有没有」结构看「分布」。比如域名里数字占比、连字符个数、最长连续辅音长度、路径里大写字母比例。这些统计量对识别 DGA 生成的域名特别有效。import re def structural_features(url: str) - np.ndarray: 抽取域名与路径的统计分布特征 parsed urlparse(url) host (parsed.hostname or ).lower() path parsed.path or # 域名里数字占比 digit_ratio sum(c.isdigit() for c in host) / max(len(host), 1) # 连字符个数 hyphen_count host.count(-) # 最长连续辅音DGA 域名常见特征 consonants re.findall(r[bcdfghjklmnpqrstvwxyz], host) max_consonant max((len(c) for c in consonants), default0) # 路径里大写字母比例 upper_ratio sum(c.isupper() for c in path) / max(len(path), 1) # 域名长度 host_len len(host) return np.array([ digit_ratio, hyphen_count, max_consonant, upper_ratio, host_len ], dtypenp.float32)max_consonant这个特征值得单独说正常域名里连续辅音很少超过 4 个而 DGA 生成的随机域名经常出现xkqjdf这种串。参数上digit_ratio和host_len需要做标准化否则量纲差异会让树模型偏向长度大的特征。我一般会在拼完所有特征后统一做一次StandardScaler。2.4 上下文特征短链接和跳转链怎么处理前面三类都是单 URL 特征但真实攻击里大量用短链接。改进版必须加一层「跳转解析」对短链域名先做一次 HEAD 请求拿到最终地址再对最终地址抽特征。这一步有性能开销所以一般只对命中短链域名列表的 URL 做。import requests SHORTENER_HOSTS {bit.ly, t.cn, tinyurl.com, goo.gl} def resolve_short_url(url: str, timeout: float 2.0) - str: 对短链接做一次 HEAD 请求拿到最终 URL parsed urlparse(url) if parsed.hostname not in SHORTENER_HOSTS: return url try: resp requests.head(url, allow_redirectsTrue, timeouttimeout) return resp.url except requests.RequestException: # 解析失败时返回原 URL并标记异常 return url这里timeout设 2 秒是经验值——再长会拖慢整体吞吐再短容易误判。注意allow_redirectsTrue会跟随所有跳转如果攻击者做了多级跳转这里拿到的就是最终地址。实际部署时这一步要加缓存同一个短链 5 分钟内不重复请求否则 QPS 一上来外部请求会成为瓶颈。3. 模型选型与训练为什么改进版偏爱树模型加轻量神经网络特征工程做完下一步是选模型。这里有个反直觉的结论在恶意 URL 检测这个任务上精心调过的梯度提升树GBDT经常打得过深度神经网络。原因是特征维度不高几百维、样本量中等几十万到几百万树模型对特征交互的捕捉能力足够且训练快、可解释。改进版一般用「GBDT 轻量 MLP」的融合方案下面讲怎么落地。3.1 为什么不是纯深度学习纯深度学习方案比如字符级 CNN 或 Transformer在论文里好看但落地时有三个硬伤一是推理延迟高单条 URL 过一遍 Transformer 在 CPU 上要几十毫秒风控场景扛不住二是可解释性差误报时说不清为什么三是对抗样本脆弱攻击者加几个无关字符就能让预测翻转。树模型在这三点上都更稳。import lightgbm as lgb from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.metrics import roc_auc_score # 假设 X 是拼好的特征矩阵y 是标签1 恶意0 正常 X_train, X_val, y_train, y_val train_test_split( X, y, test_size0.2, stratifyy, random_state42 ) # 树模型对量纲不敏感但标准化对后续融合有帮助 scaler StandardScaler() X_train_s scaler.fit_transform(X_train) X_val_s scaler.transform(X_val) params { objective: binary, metric: auc, learning_rate: 0.05, num_leaves: 63, min_data_in_leaf: 50, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 5, verbose: -1 } dtrain lgb.Dataset(X_train_s, labely_train) dval lgb.Dataset(X_val_s, labely_val, referencedtrain) model lgb.train( params, dtrain, num_boost_round500, valid_sets[dval], callbacks[lgb.early_stopping(30), lgb.log_evaluation(50)] ) pred model.predict(X_val_s) print(AUC:, roc_auc_score(y_val, pred))参数上重点看三个num_leaves控制模型复杂度63 是中等规模数据的常用值数据量上百万可以提到 127min_data_in_leaf设 50 是为了防过拟合恶意样本往往有噪声标注叶子太小会学到噪声early_stopping(30)表示验证集 30 轮不提升就停避免无效训练。feature_fraction和bagging_fraction都设 0.8是标准的防过拟合组合。3.2 轻量 MLP 怎么和树模型融合树模型擅长处理数值特征但对高维稀疏的字符 n-gram 特征不如神经网络。改进版的做法是树模型吃结构化的统计特征MLP 吃字符 n-gram 的嵌入最后把两个模型的输出拼接再过一个逻辑回归。import torch import torch.nn as nn class CharMLP(nn.Module): def __init__(self, vocab_size: int, embed_dim: int 32, hidden: int 64): super().__init__() self.embed nn.Embedding(vocab_size, embed_dim) self.fc nn.Sequential( nn.Linear(embed_dim, hidden), nn.ReLU(), nn.Dropout(0.3), nn.Linear(hidden, 1) ) def forward(self, x): # x: (batch, seq_len) 字符 id 序列 emb self.embed(x).mean(dim1) # 平均池化 return self.fc(emb).squeeze(-1)这个 MLP 只有两层参数量几千推理延迟在毫秒级。embed_dim32是字符级任务的常用值再大收益递减Dropout(0.3)是防过拟合的关键字符级模型很容易记住训练集里的特定域名。融合时把树模型的输出和 MLP 的输出各取 logit拼成 2 维向量喂给逻辑回归权重用验证集学出来。3.3 训练集怎么构造才不偏恶意 URL 检测最大的坑是训练集分布和线上不一致。公开数据集里的恶意样本大多是几年前的攻击手法早就变了。改进版必须做两件事一是从自家线上流量里采样正常 URL保证正常样本分布一致二是对恶意样本做增强包括编码变换、参数打乱、域名替换。import random from urllib.parse import quote def augment_malicious(url: str) - str: 对恶意 URL 做轻度增强模拟攻击者变形 choice random.random() if choice 0.3: # 对部分字符做百分号编码 return quote(url, safe:/?) elif choice 0.6: # 随机插入无害参数 sep if ? in url else ? return f{url}{sep}ref{random.randint(1, 9999)} else: # 大小写变换 return .join(c.upper() if random.random() 0.3 else c for c in url)增强比例控制在 30% 左右太多会让模型学到增强模式本身。quote的safe参数要保留:/?否则整个 URL 结构会被破坏增强出来的样本反而不像真实攻击。4. 避坑与排查上线后误报飙升的五个真实原因这一章是我踩过的坑里最有代表性的五条每条按「现象 → 原因 → 解决」写。新手照着排查能省几天时间熟手可以对照自己的系统看看有没有中招。4.1 现象上线第一天误报率 5%全是正常业务 URL原因通常是特征里的tld_known白名单太窄。很多公司内部系统用.local、.internal这类非标准顶级域模型没见过就判恶意。解决方法是把白名单从「常见顶级域」改成「业务已知顶级域」并且加一条规则如果 URL 的域名在内部资产清单里直接放行不走模型。4.2 现象模型对带中文参数的 URL 全部误判原因是字符频率特征只覆盖了 ASCII 128 维中文参数被ord(ch) 128过滤掉导致这些 URL 的特征向量大部分是零模型只能靠长度等少数特征判断。解决办法是把字符表扩展到 Unicode 常用区间或者对非 ASCII 字符单独做一类特征统计。我一般会在预处理阶段先把 URL 做一次unquote把编码后的中文还原出来再抽特征。4.3 现象短链接解析超时导致整体 QPS 掉一半原因是resolve_short_url是同步阻塞调用短链服务响应慢时整个请求链路被拖住。解决办法有三层第一层加本地缓存同一个短链 5 分钟内不重复请求第二层设超时上限超过 2 秒直接放弃解析用原 URL 特征第三层把解析做成异步先返回模型基于原 URL 的判断解析结果回来后再做二次修正。4.4 现象模型在验证集 AUC 0.99线上 AUC 只有 0.85这是典型的分布偏移。验证集是从训练集随机切的但线上流量里正常 URL 的分布和训练集采样时不一样。解决办法是定期用线上流量重新采样正常样本做增量训练。频率上业务变化快的场景每周一次稳定的场景每月一次。注意增量训练时要用新样本和旧样本混合纯新样本会让模型遗忘旧模式。4.5 现象攻击者加几个随机字符就能绕过原因是模型过度依赖精确的字符组合特征缺乏鲁棒性。解决办法是在训练时加入对抗样本增强同时对特征做平滑处理——比如字符频率不按精确字符统计而是按字符类别字母、数字、符号统计。另外可以在推理时对同一个 URL 做多次轻微变形取预测均值这种「测试时增强」能显著提升对抗鲁棒性。5. 阈值调优与在线验证把模型从能用推到好用模型训完只是开始真正决定落地效果的是阈值和在线验证机制。这一章讲两个具体技巧怎么用业务指标反推阈值以及怎么用影子模式验证新模型。5.1 用业务指标反推阈值而不是拍脑袋定 0.50.5 是数学上的默认阈值但业务上几乎从来不是最优。正确做法是先确定业务能接受的误报率上限再在验证集上找对应阈值。比如风控场景要求误报率不超过 0.1%那就把验证集里正常样本按预测分排序取第 0.1% 分位对应的分数作为阈值。import numpy as np def find_threshold_by_fpr(y_true, y_pred, max_fpr: float 0.001): 根据最大可接受误报率反推阈值 normal_scores y_pred[y_true 0] # 取正常样本分数的 (1 - max_fpr) 分位 threshold np.quantile(normal_scores, 1 - max_fpr) # 验证实际召回 recall (y_pred[y_true 1] threshold).mean() return threshold, recall threshold, recall find_threshold_by_fpr(y_val, pred, max_fpr0.001) print(f阈值: {threshold:.4f}, 召回率: {recall:.4f})这个函数的逻辑是误报率上限决定了正常样本里允许多少比例被误判对应到分数分布上就是取高分位。max_fpr设多少取决于业务——拦截类场景可以设 0.001告警类场景可以放宽到 0.01。注意这个阈值要定期重算因为线上流量分布会漂移。5.2 影子模式新模型上线前必须跑一周新模型直接替换旧模型风险太大标准做法是影子模式新模型和旧模型同时跑新模型的判断只记录不生效一周后对比两者的误报、漏报和延迟。对比时重点看三个指标新模型在旧模型误报的样本上表现如何、新模型在旧模型漏报的样本上表现如何、新模型的 P99 延迟是否可接受。指标旧模型新模型判定标准误报率0.12%0.08%新模型不高于旧模型漏报率3.5%2.1%新模型不高于旧模型P99 延迟8ms12ms不超过 20ms覆盖率95%97%新模型不低于旧模型这张表是我一般会看的核心指标。如果新模型在误报和漏报上都更优但延迟涨了 50%就要权衡——风控场景延迟敏感可能得砍掉 MLP 分支只保留树模型。影子模式跑满一周再切切的时候保留旧模型作为兜底出问题能快速回滚。5.3 一个容易被忽略的技巧分场景设阈值不同业务场景对误报的容忍度完全不同。登录接口的 URL 误报会导致用户登不上容忍度极低而日志分析场景的误报只是多几条告警容忍度高。改进版一般会按场景维护多套阈值用 URL 的来源接口或业务标签做路由。SCENE_THRESHOLDS { login: 0.95, # 登录场景阈值高宁可漏报不误报 payment: 0.90, # 支付场景平衡 log_analysis: 0.70 # 日志分析阈值低宁可误报不漏报 } def predict_with_scene(model, features, scene: str) - int: score model.predict(features.reshape(1, -1))[0] threshold SCENE_THRESHOLDS.get(scene, 0.85) return int(score threshold)这套机制的关键是场景标签要准。如果 URL 来源接口拿不到可以用 URL 路径里的关键词做粗分类比如含/login的归到登录场景。阈值本身要定期用各场景的线上数据重新校准不能设完就不管。我自己的习惯是每次模型更新都重新跑一遍分场景阈值校准哪怕特征没变——因为线上流量分布一直在动上个月的最优阈值这个月可能就偏了。这套流程跑顺之后恶意 URL 检测的线上误报能压到千分之一以下漏报也能控制在可接受范围。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑