☰
加密流量识别实战:融合LeNet、AlexNet与GAP的CNN源码解析
2026/10/10 9:44:59 网站建设 项目流程

简介:深度学习驱动的加密流量识别模型源码包,融合LeNet、AlexNet与全局平均池化(GAP)结构,面向网络安全研究人员、AI爱好者及流量分析开发者,可直接用于加密流量分类、网络异常检测等场景,模型设计兼顾准确率与效率。压缩包共29个文件,其中20个Python源文件涵盖模型构建、数据预处理、训练验证、指标计算与实用工具等模块;其余为配置文档、版本控制忽略文件、许可证及说明文本,整体仅73KB,轻量易部署。目前已有638人学习下载,适合希望以深度学习提升网络安全管理能力的中高级开发者。项目代码按AlexNet、GAP等子网络清晰组织,便于对比不同结构的融合效果,同时附有数据划分与评估脚本,读者可在此基础上扩展特征工程、调整网络参数,或将其迁移至自己的流量识别项目中,是理解深度网络在安全领域落地的实用参考资料。

1. 加密流量识别为什么回头看 LeNet、AlexNet 和 GAP

抓回来的流量里七成是 TLS,深度包检测一看到加密载荷基本瞎火。这时候最务实的做法不是直接上大模型,而是把流字节按顺序排成一张灰度图,让 CNN 去学结构。这类方案里,LeNet 负责轻量低层特征,AlexNet 提供 ReLU、Dropout 这些训练稳定性组件,全局平均池化(GAP)把全连接层压缩成一层线性输出,三样拼起来就是一套可以离线训练、边缘推理的加密流量识别模型源码骨架。本文把这套源码拆开讲:数据怎么变成图、模型怎么拼、参数怎么调、哪些坑必须躲。适合正在做流量分类或用深度学习做协议识别的工程师,也适合拿公开加密流量数据集复现对比实验的入门者。

2. 让原始流量变成模型能吃的“图”:预处理 3 个关键决定

直接把 pcap 喂给 CNN 是不可能的。绝大多数加密流量识别项目的第一步,都是把网络流变成二维矩阵,也就是灰度图。这里有两个容易搞反的点:第一,一张图只应该装一条双向流;第二,图的大小不是越大越好。

2.1 流切分:一张图只装一条双向流

常见做法是用五元组分桶:源 IP、源端口、目的 IP、目的端口、协议号。收到一个包先算五元组,再往对应的 flow 里追加 payload。这里有个容易被忽略的细节:五元组有方向性,客户端发起连接和服务器返回响应的方向不同,但同一个握手过程是双向的。TLS 握手里客户端随机数、服务器证书、密钥交换数据分散在两个方向,所以分桶之后不能只保留“客户端到服务器”那半边,必须把双向包按时间戳排序后拼成一条字节流。

我习惯在每条流里给每个包前面加一个方向标记字节:0 表示上行,1 表示下行。这样模型至少有机会学到“哪个方向先出现什么字段”。如果你只是做粗粒度的应用类型分类,不加方向也能跑;但如果要判断恶意加密流量,方向标记往往比多一层卷积更有用。

流切分的另一个关键是超时。同一个五元组如果间隔超过 30 秒,通常会重新建连或复用连接,这时候还硬拼在同一条流里,一张图就会混入两条不同会话的内容,特征被平均掉。所以我一般用“五元组 + 最后包时间戳”作为分桶状态,超过超时阈值就开新 flow。

2.2 图像尺寸:64×64 是比 32×32 和 224×224 更稳的折中

加密流量转图像时,输入尺寸直接决定模型能看到多少信息。LeNet 原始设计是 32×32,AlexNet 用 224×224,但流量图不是 ImageNet,不是越大越准。

一张 64×64 的灰度图等于 4096 字节。如果每个包截取 128 字节,那么 32 个包正好填满。64×64 既能覆盖大多数 TLS 握手的头部区域,又不会让 padding 比例高到离谱。32×32 只有 1024 字节,通常只够看到 ClientHello 和 ServerHello,信息太少;224×224 需要 50176 字节,很多短流根本填不满,大批量补零反而制造虚假特征。

输入尺寸字节量模型结构实际建议
32×321024很轻只适合快速基线
64×644096LeNet+AlexNet 混合最推荐
96×969216较深 AlexNet 风格数据量大时再试
224×22450176重模型大多数场景没必要

我一般建议先固定单包截断 128 字节、固定 32 个包,生成 64×64 图。这个配置在抓包、内存开销和模型输入之间比较平衡。

2.3 预处理代码:从 pcap 到灰度图的 20 行核心逻辑

下面这段代码把 Scapy 读到的 pcap 转成训练用的张量。生产环境可以用 dpkt 或 C 解析器提速,但思路完全一致。

import numpy as np import torch from scapy.all import rdpcap def get_l4_payload(pkt): # 只取 TCP/UDP 层之后的载荷,不含以太网和 IP 头 if pkt.haslayer("TCP"): return bytes(pkt["TCP"].payload) if pkt.haslayer("UDP"): return bytes(pkt["UDP"].payload) return b"" def canonical_flow_key(pkt): # 五个元组统一排序,保证双向包进同一个桶 ip = pkt["IP"] if pkt.haslayer("IP") else None if pkt.haslayer("TCP"): l4, proto = pkt["TCP"], "TCP" elif pkt.haslayer("UDP"): l4, proto = pkt["UDP"], "UDP" else: return None src = (ip.src, l4.sport) dst = (ip.dst, l4.dport) key = (src[0], src[1], dst[0], dst[1], proto) if src <= dst: return key return (dst[0], dst[1], src[0], src[1], proto) def flow_to_tensor(pkts, width=64, height=64, max_packets=32, per_packet=128): raw = bytearray() for pkt in pkts[:max_packets]: raw += get_l4_payload(pkt)[:per_packet] if len(raw) >= width * height: break # 不足部分补零,保证形状固定 if len(raw) < width * height: raw += b"\x00" * (width * height - len(raw)) arr = np.frombuffer(raw, dtype=np.uint8).reshape(height, width) return torch.from_numpy(arr.astype(np.float32) / 255.0).unsqueeze(0)

代码逻辑是:先按五元组分桶,分桶内的包按时间排序,再逐包截取 L4 层载荷,前 4096 字节作为灰度图的像素值。reshape(height, width)必须保证字节数正好等于 64×64,所以先用len(raw) >= width * height截断,再用补零兜底。返回结果直接是[1, 64, 64]张量,不落盘 PNG,训练时不用反复做 IO。

这里最需要警惕的是补零。补零区域在训练数据里几乎恒定,模型很容易把它当背景特征学进去。后面避坑章节会专门讲这个问题。

3. 融合 LeNet、AlexNet、GAP 的模型源码:结构设计与参数量

模型部分是整套源码的核心。题目里说的 LeNet、AlexNet、GAP,不是把三个网络并联,而是把结构单元按阶段组合起来。先讲清楚为什么这样拼,再贴完整源码。

3.1 GAP 的作用:参数砍半,特征更稳

传统 LeNet 和 AlexNet 的末尾是展平加全连接。特征图如果是 8×8×128,展平后是 8192 维,再接一层全连接到分类数,参数量立刻上来。加密流量数据集往往只有几千条流,这种参数量很容易过拟合。

GAP 的做法是:每一张特征图直接做全局平均池化,变成 1 个标量。128 张特征图池化后就是 128 维向量,再送进分类器。这样分类器的权重从 8192×num_classes 变成 128×num_classes,参数量下降一个数量级。另一个附带好处是:GAP 之后,每个类别的分类权重可以直接和特征图相乘,生成类似热力图的解释结果。这一点最后一章会用到。

3.2 LeNet 和 AlexNet 的结构单元怎么融合

LeNet 给我留下最深印象的是 5×5 卷积核和逐级下采样。前两阶段用 5×5 卷积,在输入尺寸还比较大的时候捕获局部结构;AlexNet 的价值在于 ReLU、Dropout 和多个 3×3 卷积堆叠。融合不是简单抄名字,而是按阶段取长补短:

先复刻 LeNet 的低层结构:1 通道输入,Conv1 用 5×5,配合 BatchNorm 和 MaxPool;再复刻 AlexNet 的高层结构:后面叠两层 3×3 卷积,加深抽象特征,最后接 MaxPool 把特征图压到 8×8。在分类头之前插入 GAP。整体输入是 64×64,三层池化后特征图尺寸正好是 8×8,信息量足够又不至于太大。

很多同学会直接调torchvision.models.alexnet(pretrained=True),我一般不建议。ImageNet 预训练权重期望 3 通道自然图像,而流量图是单通道字节矩阵,数值分布完全不一样,迁移过来的收益远不如随机初始化加好好调参。

3.3 源码实现与参数核对

下面是一份可以直接跑的 PyTorch 模型源码。

import torch import torch.nn as nn class EncryptedFlowNet(nn.Module): def __init__(self, num_classes=12, dropout=0.3): super().__init__() self.features = nn.Sequential( # LeNet 风格:5x5 卷积,逐级下采样 nn.Conv2d(1, 32, kernel_size=5, padding=2), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=5, padding=2), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), # AlexNet 风格:3x3 卷积堆叠,加深特征 nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.Conv2d(128, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) # GAP:每张特征图收缩到 1 个标量 self.gap = nn.AdaptiveAvgPool2d((1, 1)) self.dropout = nn.Dropout(p=dropout) self.fc = nn.Linear(128, num_classes) def forward(self, x, need_feature_map=False): fmaps = self.features(x) # [B, 128, 8, 8] pooled = self.gap(fmaps) # [B, 128, 1, 1] flat = torch.flatten(pooled, 1) # [B, 128] logits = self.fc(self.dropout(flat)) if need_feature_map: return logits, fmaps return logits

代码里need_feature_map=True是为了让 CAM 热力图拿到池化前的特征图。如果不需要可解释性,直接返回 logits 即可。

各层输出尺寸和参数量如下表:

模块输出尺寸说明
Conv1 + BN + ReLU + MaxPool32×32×325×5 卷积,学习局部字节组合
Conv2 + BN + ReLU + MaxPool16×16×645×5 卷积,加深通道
Conv3 + Conv4 + BN + ReLU16×16×1283×3 卷积堆叠,AlexNet 风格
MaxPool8×8×128下采样到 8×8
GAP128每张特征图取均值
Dropout + FCnum_classes128 维到分类数

总参数量大约在 0.3M 左右,比 AlexNet 原始版本小一个量级。对加密流量这种小样本任务,这个容量是合理的。如果数据量特别大,可以再加一组 3×3 卷积;如果数据量只有几百条流,建议把第一层通道降到 16,进一步压缩模型。

4. 训练与验证:小样本加密流量分类最容易翻车的 3 个点

模型结构定下来之后,训练策略直接决定能不能复现出一个能用的结果。加密流量分类的样本量通常不大,类别分布也通常不均匀,这一章讲三个最关键的调试点。

4.1 类别权重:先解决不平衡,再谈准确率

公开加密流量数据集里,视频、网页、文件传输这些类别的样本数量往往差很多。如果不做任何处理,模型会把多数类学得很好,少数类全被牺牲。最简单的方法是给 CrossEntropyLoss 传入类别权重。

import torch import torch.nn as nn counts = torch.bincount(labels) # 每一类样本数 weights = 1.0 / counts.float() weights = weights / weights.mean() # 归一化到均值 1 附近 weights = weights.clamp(max=10.0) # 防止少数类权重过大 criterion = nn.CrossEntropyLoss(weight=weights.to(device))

这段代码的含义很直接:样本越少的类别,损失权重越高。归一化到均值 1 附近是为了让整体损失量级和普通交叉熵接近,不至于因为权重过大把梯度带偏。clamp是防止极端不平衡下某个类别的权重变成几十,导致训练震荡。

如果你同时做应用类型分类和恶意流量检测,两个任务的 loss 直接相加很容易被大 loss 任务主导。常见做法是先用loss = loss_cls + 0.5 * loss_mal起步,再根据各自收敛速度调整比例。多任务里调整多个 loss 之间的比例是个单独话题,但不要一上来就上复杂的不确定性加权,先用手调系数跑两版再说。

4.2 学习率、L2 正则、batch size 的默认值

加密流量识别不是大模型预训练,学习率一般不用太大。我常用的默认值是:

参数默认值理由
学习率1e-4小样本下 1e-3 容易过拟合
batch size32太小 BN 不稳定,太大收敛慢
weight_decay1e-4等效 L2 正则,压住多余参数
优化器Adam对流数据这类非平滑梯度比较省心
早停耐心值8防止在小数据集上硬跑满 epoch

weight_decay在 PyTorch 里直接写在优化器里就是 L2 正则。这里多说一句:BatchNorm 层有自己的缩放参数,L2 作用在卷积核上比作用在 BN 缩放系数上更安全,所以一般不对全模型统一加太大 weight_decay,1e-4 是个稳妥起点。

4.3 训练、早停与宏平均 F1 的验证骨架

只盯准确率在小样本分类里是典型翻车姿势。比如某个类占 90%,模型全猜这个类准确率也有 90%,但实际上一点用都没有。我一般用宏平均 F1 作为主指标,再输出混淆矩阵看每个类的表现。

from sklearn.metrics import f1_score def evaluate(model, loader): model.eval() preds, trues = [], [] with torch.no_grad(): for x, y in loader: logits = model(x) preds.extend(logits.argmax(1).cpu().tolist()) trues.extend(y.cpu().tolist()) return f1_score(trues, preds, average="macro") model = EncryptedFlowNet(num_classes=num_classes) optimizer = torch.optim.Adam(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode="max", factor=0.5, patience=3 ) best_f1, patience = 0.0, 0 for epoch in range(50): model.train() for x, y in train_loader: optimizer.zero_grad() out = model(x) loss = criterion(out, y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() val_f1 = evaluate(model, val_loader) scheduler.step(val_f1) if val_f1 > best_f1: best_f1 = val_f1 patience = 0 torch.save(model.state_dict(), "best_encflow.pt") else: patience += 1 if patience >= 8: break

训练循环里做了三件重要的事:梯度裁剪、ReduceLROnPlateau、早停。梯度裁剪控制在 5.0,可以避免个别异常样本把参数更新推飞;学习率随验证 F1 停滞减半;早停兜底防止过拟合。验证 F1 上升时才保存模型,这是最朴素也最有效的后悔药。

5. 加密流量识别实战避坑:5 个值得写进笔记的问题

这一章是血泪经验。很多加密流量识别模型训练时指标不错,一换环境就崩,问题往往不在模型结构,而在数据处理和验证方式。

5.1 按包切分数据集造成的信息泄漏

现象:训练准确率和验证准确率都接近 95%,但拿到另一天抓的流量一试,掉到 60%。

原因:数据按包,而不是按流划分。同一条 TLS 流的握手包既出现在训练集,又出现在验证集,模型等于提前看到了同一个会话的上下文,验证指标虚高。

解决:先按五元组分桶,再按流 ID 划分训练集和验证集,保证同一条流的所有包只进一个集合。

flow_ids = list(flows.keys()) train_ids = set(np.random.choice( flow_ids, size=int(len(flow_ids) * 0.8), replace=False )) val_ids = set(flow_ids) - train_ids

这个泄漏问题在公开数据集上尤其隐蔽,因为很多数据集直接给的是包,不给流标签。拿到数据第一件事应该是重建会话,而不是直接拆包。

5.2 一条流被拆成多张图或者多张图合成一张

现象:模型对某些类别的分类极不稳定,同一个应用前后两次预测结果不一致。

原因:五元组在长连接或连接复用场景下没有边界。有人把 30 分钟的 TCP 长连接全部塞进一张图,也有人因为中间某个包超过超时阈值就错误地拆成了多条流。

解决:定义空闲超时。一般 TLS 会话空闲超过 30 秒就认为连接已失效,后续同五元组流量应该开新 flow。同时,采样时固定包数,只取每条流的前 32 个包,这样既覆盖握手阶段,又避免长连接把图片撑爆。

5.3 加了 GAP 后收敛变慢,不一定是结构的问题

现象:同样的骨干网络,把全连接换成 GAP 后,loss 下降变慢,甚至卡在某个平台期不动。

原因:GAP 把所有空间位置的激活值做平均,梯度回传时被均摊稀释,尤其是最后一层卷积没有接 BatchNorm 的时候,特征分布不稳定,池化后信号被磨平。

解决:在最后一组卷积后加 BatchNorm 和 ReLU,确保 GAP 输入的特征分布在一个稳定的尺度上。还有一个细节是初始化:用 Kaiming 初始化卷积层,避免某些通道一开始就是全 0 激活,导致 GAP 输出为 0,梯度直接消失。

这个坑也说明一个道理:手写网络不能当黑匣子,每换一层结构,都要看中间特征图的均值和方差。

5.4 零填充成了“假特征”,跨集泛化直接崩

现象:训练集和验证集都是同一个数据集里切出来的,F1 不错;换到另一个时间窗口重新抓的流量,指标掉了 10 个点以上。

原因:短流填不满 4096 字节,右侧和下侧大片全零区域。模型学到的不完全是流量特征,而是“哪里是全零”这种背景特征。换一批流量后,不同样本的 padding 区域形状变了,模型自然失灵。

解决:先看训练集里每条流平均有效字节数。如果很多样本有效字节低于 2048,64×64 可能偏大,缩到 48×48 或 32×32 更稳妥;如果数据充足,直接丢弃 padding 占比过高的样本,让模型专注在真实字节区域上。

5.5 训练数据时间太旧,模型学到的是旧版本 TLS 的皮

现象:用半年前抓的 TLS 1.2 流量训练,线上环境已经切换到 TLS 1.3,准确率明显下滑。

原因:TLS 版本升级后,握手包结构变化很大。模型如果过度依赖旧版本里的某个固定字段,比如记录头类型、版本号、随机数长度,新版本一替换就失效。

解决:训练数据里混合不同 TLS 版本;如果只能拿到单一版本,尽量把特征重心从“固定字段精确值”挪到“长度分布和时序模式”上,这类特征跨版本更稳定。做恶意加密流量检测时,也不建议只依赖握手头几个字节,还要结合流长度、包间隔这些统计特征。

6. 用 GAP 自带的可解释性做热力图验证:模型到底在看什么

最后一章给一个验证技巧:用 GAP 做类别热力图,把模型的黑匣子打开看一眼。这个技巧在流量分类里非常实用,因为模型学到的特征到底合不合理,不能只看指标。

6.1 生成类别热力图:10 行代码

因为分类头是fc,GAP 之后的每一维对应一个特征图通道的平均值。想反推“模型关注哪个区域”,只需要把类别权重和池化前的特征图相乘。

import torch.nn.functional as F import numpy as np def show_cam(model, x, class_idx=0): model.eval() logits, fmaps = model(x.unsqueeze(0), need_feature_map=True) fmap = fmaps[0] # [128, 8, 8] weight = model.fc.weight[class_idx] # [128] cam = torch.einsum("c,hw->hw", weight, fmap.flatten(1)) cam = cam.reshape(fmap.shape[1], fmap.shape[2]) cam = F.relu(cam) # 只保留正向激活 cam = F.interpolate( cam.unsqueeze(0).unsqueeze(0), size=(64, 64), mode="bilinear", align_corners=False ) return cam.squeeze().detach().cpu().numpy()

这段代码的关键在torch.einsum("c,hw->hw"):把每个通道的权重乘到对应通道的特征图上,再按通道累加。由于 GAP 的存在,这个权重意义很直接,就是“这个通道对某个类别的影响力”。F.relu 是为了忽略负激活,因为负激活通常代表抑制区域,热力图上只看正向贡献。

6.2 三种典型的热力图结论怎么判断模型是否可用

拿到热力图后,不要急着调参,先判断模型在看什么。

第一种,热力区集中在图像左上角或头部区域,这是比较理想的情况,说明模型学到的是 TLS 握手前段的协议字段。第二种,热力点分散在整张图但没有明显规律,说明模型可能在依靠长度、密文分布这类统计特征,这种情况下需要额外检查跨数据集泛化能力。第三种,热力区集中在补零区域,这种模型不能上线,因为它把 padding 当特征了,回到避坑章节的 5.4 继续处理。

我现在每次训练完都会先抽 20 个验证样本把热力图打出来,用眼睛扫一遍再做结论。这个习惯帮我挡掉至少两次“测试集准确率 92% 但线上完全不可用”的翻车。说白了,GAP 给的可解释性就是后悔药,不看白不看。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询