ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

基于CNN的KDD99入侵检测源码解析与实战

基于CNN的KDD99入侵检测源码解析与实战

简介:一套基于卷积神经网络的网络入侵检测系统完整实现,面向网络空间安全、人工智能相关专业的学生及毕业设计开发者,解决网络流量分类与异常检测问题。系统利用CNN提取流量数据局部特征,可区分正常与恶意流量,覆盖入侵行为、恶意软件活动及DoS攻击等场景,并包含数据预处理、模型设计、训练、评估与优化全流程。压缩包共16个文件,以Python源码为主(4个py),配备KDDCUP数据集的10%与全量数据(gz格式)、训练日志事件文件、README说明及IDE配置参考,包体约17.52MB。读者可直接运行main.py复现实验,也可对照源码梳理CNN在入侵检测中的落地思路,或基于现有结构替换数据集进行改进。资源已有350人学习浏览,适合需要完整可运行方案、数据支撑和调参参考的入门及进阶学习者。

1. 把 KDD99 喂给 CNN:这套入侵检测源码到底怎么落地

很多人一听到“网络入侵检测”就觉得要上抓包、要搞特征工程、要写一堆规则,直到看到这份基于卷积神经网络的检测系统才发现,原来把网络流量当成“信号序列”直接喂给 CNN,也能把 KDD99 数据集上的准确率干到 99.5%。这套 python 源码包的核心思路很简单:把每个网络连接转换成一条数值特征序列,再让一维卷积神经网络自动提取局部模式,区分正常流量和攻击行为,它不依赖人工写攻击特征,而是让模型自己去学“什么样的流量看起来不对劲”。

这套资源适合两类人:一类是做毕业设计,需要一套能跑通、能出数据、能讲清楚原理的深度学习实战项目;另一类是刚接触用深度学习做安全方向的工程师,想找一个经典数据集和参考实现,搞清楚数据怎么清洗、标签怎么编码、CNN 输入 shape 怎么设计。压缩包里的文件很全,有 KDD99 的原始数据,有训练用的 main_cnn.py,还有处理数据的 handle2.py,外加 TensorBoard 的日志目录,基本不用自己再去找数据集,解压就能开搞。

2. 数据解剖:KDD99 的 41 列特征和五类攻击标签

2.1 每一列特征到底代表什么

KDD99 是入侵检测领域绕不开的基准数据集,它来自模拟军事网络环境采集的 TCP 连接记录,每条记录对应一次完整的连接会话,包含 41 个特征值。这套源码用的是 kddcup.data_10_percent.gz,也就是 10% 的抽样版本,大概有 49 万条连接记录,跑起来内存压力不大,普通笔记本 GPU 或纯 CPU 都能训。

能直接送到 CNN 里的值是数值型特征。比如duration表示连接持续时间(秒),src_bytes和dst_bytes是源到目的、目的到源的字节数,count是过去两秒内与当前连接相同目标的连接数,srv_count是过去两秒内相同服务的连接数。这些数值里藏着攻击行为的关键线索:短时间大量连接、传输字节数异常偏大或偏小、连接失败率突增,都是入侵的典型信号。

但 KDD99 里也有非数值特征,最典型的是protocol_type(tcp/udp/icmp)、service(http/ftp/telnet 等)、flag(连接状态标志,比如 SF 表示正常建立并关闭)。这三个字段必须做编码,不能直接把字符串喂给 TensorFlow,否则模型直接把 categorical 字符串当成数值比较大小,训练出来的模型一点意义都没有。在这个源码里,handle2.py 就是专门干这个事的。

2.2 攻击类型:从 22 种细分到四类大类

KDD99 训练集的攻击标签一共有 22 种,但可以粗分为四大类,加上 normal 就是五分类。源码在训练时常见的做法是把细分类映射到大类,比如smurf、neptune归为 DOS(拒绝服务攻击),ipsweep、portsweep、satan归为 Probe(探测与扫描),guess_passwd、warezmaster归为 R2L(远程到本地的未授权访问),buffer_overflow、rootkit、loadmodule归为 U2R(提权攻击)。这样分的好处是样本数量均衡一些,否则像 U2R 这类样本极少,模型会学得很吃力。

所以在做标签编码时,我一般建议按大类做一个 label_map,映射表大概长这样:

attack_map = { 'normal': 'normal', 'neptune': 'dos', 'smurf': 'dos', 'back': 'dos', 'teardrop': 'dos', 'pod': 'dos', 'land': 'dos', 'ipsweep': 'probe', 'nmap': 'probe', 'portsweep': 'probe', 'satan': 'probe', 'guess_passwd': 'r2l', 'warezmaster': 'r2l', 'imap': 'r2l', 'ftp_write': 'r2l', 'multihop': 'r2l', 'phf': 'r2l', 'buffer_overflow': 'u2r', 'rootkit': 'u2r', 'loadmodule': 'u2r', 'perl': 'u2r' }

这一段代码看起来简单,但决定了整个分类任务的走向。如果你直接用 22 个细分标签做分类,DOS 大类里某些子类样本量差距可能有几百倍,模型容易偏向样本多的类,且评估指标会很丑。归并成五类之后,类别间样本量差距仍然有,但模型在损失函数上不会那么容易被少数类带偏。

2.3 为什么用一维 CNN 而不是全连接网络

网络流量特征在预处理后本质上是一条定长向量,把它看成一维信号、用卷积核滑动提取局部模式,理论上是说得通的。比如count、srv_count、serror_rate这几个特征在位置上相邻,卷积核能捕捉它们之间的局部关联,比如“短期内大量失败连接”这样一个模式,可以跨特征被识别出来,而不是像全连接网络那样把每个特征当成独立输入,重新去学习特征组合。

这个源码的输入 shape 是 41 维特征向量,reshape 成[batch, 41, 1]之后做 Conv1D。1×3 的卷积核扫过相邻特征,等于是每次看三列特征组成的局部模式,这种设计比全连接 Dense 层参数少很多,训练速度快,而且在小数据集上不容易把噪声特征也学进去。虽说 KDD99 本身是 1999 年的数据,拿到今天做真实网络检测意义有限,但拿来练手 CNN 处理一维序列、跑通整套工程流程,依然是最省事的路径。

3. 数据预处理:从原始文本到模型能吃的 NumPy 张量

3.1 解析原始 gz 文件:标称特征和数值特征分离

KDD99 原始数据是文本格式,逗号分隔,最后一位是标签。拿到手之后不能直接pd.read_csv就完事,因为协议类型、服务名、flag 标志这三列是字符串,需要先单独映射成整数,再拼回特征矩阵。源码里的 handle2.py 解决的就是这个问题,它的核心逻辑是先读 Gzip 文件,逐行切分,把符号特征做编号,比如tcp=0, udp=1, icmp=2。

下面是我常写的解析逻辑,和源码实现基本一致:

import gzip import numpy as np import pandas as pd def load_kdd99(path): # 读 gz 压缩的原始数据 with gzip.open(path, 'rt') as f: df = pd.read_csv(f, header=None) # 第 1 列 protocol_type,第 2 列 service,第 3 列 flag # 这三列是标称特征,手动转成整数编号 df[1] = df[1].map({'tcp': 0, 'udp': 1, 'icmp': 2}).fillna(0) df[2] = df[2].astype('category').cat.codes df[3] = df[3].astype('category').cat.codes # 第 41 列是标签(0 起始),其余 1~40 是特征 labels = df[41].values features = df.iloc[:, :41].values.astype(np.float32) return features, labels

注意df.iloc[:, :41]这里取的是前 41 列,但第 41 列是标签,所以实际上取了 0 到 40 列作为特征,labels 单独取第 41 列。KDD99 原始字段索引是 0 到 42,最后一个是标签,中间 41 个是特征,取列的时候差一个索引就容易错位。

fillna(0)是为了兜底:如果协议类型里出现'tcp'之外的值,比如'icmp'能匹配到,但万一原始数据里有大小写差异或多余空格,映射不到的值会变成 NaN,直接填充成 0 保底,不让它把整列数据搞崩。astype('category').cat.codes是把服务名和 flag 这种变量自动编码成整数,编码顺序按出现频率或字母序,不固定但稳定。

3.2 数值特征的归一化:防止量纲把 CNN 带偏

KDD99 的数值特征里,src_bytes可能大到几百万,而serror_rate是 0 到 1 之间的小数,如果不做归一化,卷积核的梯度会被大数值的特征主导,模型训练会变得极其不稳定。这里的常见做法是用 Min-Max 标准化,把每个特征缩放到 [0, 1] 区间,或者用 StandardScaler 做 Z-score 标准化,看训练效果选。

我在这套源码里更推荐 Min-Max,因为 KDD99 的特征分布大多数不服从正态分布,Z-score 对偏态分布不友好。实现上有个细节:归一化的均值和最小值/最大值要只在训练集上拟合,然后用同一套参数去转换验证集和测试集,不能拿整份数据一起 fit,否则会有数据泄漏,测试集的信息提前进入训练过程,评估指标虚高。

from sklearn.preprocessing import MinMaxScaler scaler = MinMaxScaler(feature_range=(0, 1)) # X_train 是切分后的训练集特征 scaler.fit(X_train) X_train_scaled = scaler.transform(X_train) X_test_scaled = scaler.transform(X_test) # 把特征矩阵 reshape 成 [样本数, 41, 1],适配 Conv1D 输入 X_train_cnn = X_train_scaled.reshape(-1, 41, 1) X_test_cnn = X_test_scaled.reshape(-1, 41, 1)

这一段是模型是否能收敛的关键。reshape(-1, 41, 1)的含义是:不管多少条样本,每条样本都是一个 41 步长的单通道序列,1 相当于图像的通道数。很多新手直接拿[batch, 41]喂给 Keras 的 Conv1D,会报维度错误,因为 Conv1D 在 Keras 里的输入形状是(batch, steps, channels),中间必须插一个维度。

3.3 标签的 one-hot 编码:交叉熵损失的标配输出格式

这个源码做的是五分类任务(normal、dos、probe、r2l、u2r),类别是离散的、没有天然顺序,不能直接当回归做。输出层如果用 Dense(5, activation='softmax'),配合categorical_crossentropy损失,标签就必须是 one-hot 形式,也就是每个标签变成一个长度为 5 的向量,对应位置是 1,其余是 0。

to_categorical是 Keras 提供的工具函数,一行就能搞定。但注意它要求类别索引从 0 开始连续编号,当你做完 label_map 映射后,要先用np.unique检查一下标签值是不是 0 到 4,如果有类别是字符串或者编号不连续,也会出问题。

from tensorflow.keras.utils import to_categorical num_classes = 5 y_train_onehot = to_categorical(y_train_mapped, num_classes=num_classes) y_test_onehot = to_categorical(y_test_mapped, num_classes=num_classes)

参数num_classes必须和模型输出层的神经元个数一致,否在训练时会报形状不匹配的错。如果输出层是 Dense(4) 而标签是五类,最后一步在计算损失时维度对不上,整个 fit 直接中断。

4. CNN 模型设计:一维卷积怎么从 41 维向量里抓攻击特征

4.1 网络结构:卷积层 + 池化层 + 全连接层

这套源码的模型结构是典型的 1D-CNN 分类器,核心由两个 Conv1D 层、一个最大池化层和两层全连接组成,输出五分类。具体结构可以概括成:输入层(41×1)→ Conv1D(64, kernel=3, ReLU) → Conv1D(128, kernel=3, ReLU) → MaxPooling1D → Flatten → Dense(128, ReLU) → Dropout(0.5) → Dense(5, Softmax)。

为什么第一层卷积核要选 64 个、卷积核大小选 3?因为 KDD99 的特征维度只有 41,卷积核尺寸太大比如 7 或 11,一次滑窗就把小半条序列盖住了,提取的特征太粗糙;尺寸为 3 是最常见的起步选择,能捕捉相邻三列特征的局部关联,同时也方便堆多层网络。第一层 64 个卷积核负责学低层模式,比如“流量比例突然变大”,第二层 128 个卷积核在低层模式基础上组合出更高层的语义,比如“多个异常特征同时出现”。

池化层用 MaxPooling1D(pool_size=2),作用是把卷积层的输出缩小一半,保留最显著的特征值,降低计算量同时也减少过拟合。Flatten 层把二维特征图拉平成一维向量,并接 Dense 层做非线性组合。

4.2 Keras 实现:每一层参数的含义

from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv1D, MaxPooling1D, Flatten, Dense, Dropout def build_cnn(input_shape=(41, 1), num_classes=5): model = Sequential() model.add(Conv1D(filters=64, kernel_size=3, activation='relu', input_shape=input_shape, padding='same')) model.add(Conv1D(filters=128, kernel_size=3, activation='relu', padding='same')) model.add(MaxPooling1D(pool_size=2)) model.add(Flatten()) model.add(Dense(units=128, activation='relu')) model.add(Dropout(rate=0.5)) model.add(Dense(units=num_classes, activation='softmax')) model.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy']) return model model = build_cnn() model.summary()

padding='same'的作用是让卷积操作不改变序列长度,输入 41 维,卷积后输出还是 41 维(配合步长 1)。如果不做 padding,每过一层卷积序列长度就会减 2,三层下来只剩 35 维,也不是不行,但会丢失边缘特征,对 KDD99 这种每个特征都很关键的数据来说,same padding 更稳。Dropout(0.5) 是防过拟合的核心,训练时随机掐掉一半的神经元连接,测试时全部保留,这能有效对抗 KDD99 训练集和测试集样本量不均匀的问题。

4.3 为什么不用 LSTM:序列长度只有 41 步优先考虑效率和并发

有人会问,网络入侵检测是和时间序列相关的任务,为什么不用 LSTM 或者 GRU?这个问法本身有道理,但对 KDD99 这个场景行不通。KDD99 的每条样本是独立的连接记录,虽然 41 维特征里有过去的统计信息,但它本身不是一个时间序列输入——每行与每行之间没有时序依赖。CNN 在这里的优势是训练速度快,Conv1D 可以并行计算,每个 batch 的不同样本互不干扰,而 LSTM 必须一步步串行处理,训练时间会拉长很多。

其实真正适合 LSTM 的入侵检测场景是把多条连续连接记录按时间窗口拼接,比如一次攻击会话包含哪几条连接,LSTM 在窗口内部做序列建模。但这份源码没走这个路线,它的特点是轻量、可解释性好、训练稳定,作为毕业设计和入门项目非常合适。如果你想深挖,可以在后续换成一个拼接窗口的版本,把 5 条相邻连接记录叠在一起变成一个 205 维的输入,但现在先把当前这套跑通再说。

4.4 超参数调整:从哪里开始改,怎么判断方向

自己动手调参时,第一个要改的不是网络层数,而是 batch size 和初始学习率。KDD99 这个数据集规模不大,常见做法是 batch size 取 128 或 256。如果训练时 loss 振荡很厉害,不收敛,优先把 Adam 学习率从默认的 0.001 往下降到 0.0001,这是最常见的稳定化手段。

如果训练集准确率高但验证集准确率上不去,典型症状是过拟合,这时候加 Dropout 的比例(从 0.3 升到 0.6),或者把第一层卷积的 filters 从 64 降到 32。如果两边准确率都上不去,梯度消失了,先把网络改浅,去掉第二个 Conv1D 层试试。每改一次参数,记录一组 train/val 准确率,你会发现 KDD99 这套数据对网络深度的容忍度很高,加一层不会爆,减一层也不会崩,这是它适合做实验的原因。

5. 模型训练与评估:99.5% 的正确率从哪里来,哪里可能有水分

5.1 早停与模型保存:不能让模型在验证集上慢慢变差

训练 CNN 模型最怕两种情况:一种是不收敛,另一种是收敛过头导致过拟合。第一种调学习率,第二种靠 EarlyStopping。它的原理很简单:监控验证集损失,如果连续若干个 epoch 不下降,就停止训练并把最佳权重还原回来,避免后面越训越差。

from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint callbacks = [ EarlyStopping(monitor='val_loss', patience=5, restore_best_weights=True), ModelCheckpoint('best_model.h5', monitor='val_accuracy', save_best_only=True, verbose=1) ] history = model.fit( X_train_cnn, y_train_onehot, batch_size=256, epochs=50, validation_split=0.2, callbacks=callbacks, verbose=1 )

重要的是patience参数:设为 5 表示验证损失连续 5 个 epoch 都没降到历史最低,就提前终止。KDD99 训练时模型经常在前 10 个 epoch 冲到 98% 以上,但后面提升很慢,如果你不看验证集只死训 50 个 epoch,训练集能到 100%,验证集可能反而掉到 96%,这就是过拟合,EarlyStopping 就是防止这个过程发生。ModelCheckpoint只保存验证集准确率最高的那个模型,后面做测试集评估都用它,别拿最后一个 epoch 的权重直接测。

5.2 不要只看准确率:KDD99 的类别不平衡陷阱

这个源码标题写 99.5% 正确率,说实话在 KDD99 上做到这个数并不稀奇,因为预处理做得好、特征区分度本来就很强。但这不代表模型在所有攻击类型上都表现优秀,U2R 和 R2L 这两类样本极少,准确率很高会被 DOS 类“平均”上去——如果 DOS 类样本占 70% 以上,模型只要学会了识别 DOS,整体准确率就会显得很漂亮,但对 R2L 的识别准确率可能不到 50%。

所以评估时必须打印每一类的 precision、recall、F1 分数,不能只看一条 accuracy。在 Keras 训练完之后,用 scikit-learn 的 classification_report 是最省事的做法,注意 predictions 要先做argmax转回类别索引,再和真实标签对比。

from sklearn.metrics import classification_report, confusion_matrix y_pred = model.predict(X_test_cnn) y_pred_classes = np.argmax(y_pred, axis=1) y_true_classes = np.argmax(y_test_onehot, axis=1) print(classification_report(y_true_classes, y_pred_classes, target_names=['normal', 'dos', 'probe', 'r2l', 'u2r']))

这里一个关键点是axis=1,因为每行是一个样本的五个类别概率,argmax在行方向求最大值索引,才是该样本的预测类别。有些新手写成np.argmax(y_pred),默认是拉平整个数组找全局最大值,结果得到一个标量,后面的对比全乱套。

如果需要同时保存混淆矩阵,可以直接把confusion_matrix的返回值写进 CSV 里,方便论文画图和答辩展示。TensorBoard 日志目录里的events.out.tfevents.*文件就是这个源码训练时自动生成的,如果你重新跑训练,可以用tensorboard --logdir=multi_logs查看训练曲线。

5.3 训练中的避坑记录

现象 1:训练开始后 loss 一直是 NaN原因:特征数据里有 NaN 值没处理干净,或者学习率过大导致梯度爆炸。KDD99 原始数据极少出现缺失值,但预处理时字符串映射如果没兜底,会出现 NaN 悄悄混进数值矩阵。解决:在归一化前对特征矩阵执行np.isnan(features).any()检查;如果确认无 NaN 就把 Adam 学习率降到 0.0001,重新训练。

现象 2:验证集准确率高,但用测试集一测准确率差很远原因:KDD99 的 10% 训练集和 corrected 测试集的攻击分布不完全一样,比如测试集包含训练集没有的攻击子类型,模型没见过这类型就有可能在测试集上失分。解决:不要拿 10% 训练集的一部分当测试集来评估,尽量从完整 kddcup.data.gz 里切分,或者把测试评估标准定为“对已知攻击的识别率”,并在结论里写明。

现象 3:加载kddcup.data_10_percent.gz时 pandas 直接报解析错误原因:原始 txt 文件列数不固定,某些行的字段数量和其他行不一致,通常是末尾标签前的逗号多出来。解决:在pd.read_csv里加error_bad_lines=False(老版本 pandas)或on_bad_lines='skip'(pandas 2.0),把这行坏数据跳过。我在实际处理 KDD99 时经常遇到这种情况,基本每一条坏的记录都出现在数据文件的中后段。

5.4 完整训练脚本的结构建议

源码里有main_cnn.py和cnn_main.py两份入口文件,两者很可能只是命名差异,内容本质类似。我的习惯是训练、预测、评估拆分成三个函数,主函数里按顺序调用,避免全部挤在同一个文件里,改参数时无从下手。推荐一个极简的脚本框架:

def main(): X_train, y_train = load_kdd99('kddcup.data_10_percent.gz') X_train_cnn, y_train_onehot = preprocess_data(X_train, y_train) model = build_cnn(input_shape=(41, 1), num_classes=5) train_model(model, X_train_cnn, y_train_onehot) test_model('best_model.h5', X_test_cnn, y_test_onehot) if __name__ == '__main__': main()

这套结构的好处是:每块逻辑解耦,改数据路径不用动模型,换模型不用碰数据,调参数只动一处,适合反复实验。

6. 从训练到上线:用一维 CNN 做实时单条流量预测

训练完模型之后,真正的考验是拿一条“新”流量记录做推理。如果把这条记录再拆开分析,你会发现整个过程和训练时一样的坑,比如特征顺序不能乱、标称特征要按训练时的映射表编码、数值特征要按同一套 Min-Max 参数缩放,任何一个步骤不一致,预测结果就可能完全不可信。

这里有个最容易被忽视的问题:MinMaxScaler 在训练时保存的data_min_和data_max_数组必须随着模型一起落盘,推理时重新加载后先做一遍 transform,再 reshape。如果一个新样本的src_bytes超过训练集的 max 值,MinMax 会得到 1 以上的值,这不影响模型推理,但说明这个样本和训练集的分布已经有偏差了,预测结果的置信度要打折。

另外要注意 KDD99 的标称特征编码表:如果训练时用.cat.codes自动编码,推理时用同样的操作会遇到“新类别没有编码对应关系”的情况。解决办法是训练完成后把映射关系直接存成 Python 字典或 JSON。如果新流量里出现训练集没见过的 service,比如'http_8080',直接填一个默认编号 0,不要让它报 KeyError,这是实战里很常见的翻车点。

import json import numpy as np from tensorflow.keras.models import load_model def predict_one(raw_record): # raw_record 是逗号分隔的字符串,长度 42 字段 parts = raw_record.strip().split(',') if len(parts) != 42: raise ValueError(f'字段数不对: {len(parts)}') # 标称特征映射(和训练时保持一致) protocol_map = {'tcp': 0, 'udp': 1, 'icmp': 2} service_map = json.load(open('service_map.json')) flag_map = json.load(open('flag_map.json')) features = [] features.append(protocol_map.get(parts[1], 0)) features.append(service_map.get(parts[2], 0)) features.append(flag_map.get(parts[3], 0)) for i in list(range(4, 41)) + [0]: features.append(float(parts[i])) features = np.array(features, dtype=np.float32).reshape(1, 41, 1) scaler = joblib.load('scaler.pkl') features_norm = scaler.transform(features.reshape(1, 41)) features_final = features_norm.reshape(1, 41, 1) model = load_model('best_model.h5') prob = model.predict(features_final, verbose=0)[0] classes = ['normal', 'dos', 'probe', 'r2l', 'u2r'] return classes[np.argmax(prob)], float(np.max(prob))

这段代码里最容易漏掉的是parts[0],也就是duration特征。上面for i in list(range(4, 41)) + [0]的意思是先取第 4 到第 40 个特征,再把第 0 个拼回来,这个顺序看起来别扭,但如果你的特征工程最终排好的列顺序就是这样映射的,推理时就必须按同一套顺序装配。

如果你拿这份源码做实时监控,建议把上面的推理函数套在一个死循环或消息队列消费者里:数据源来自 Zeek 或 Suricata 输出的连接日志,转成 KDD99 格式后逐条送入模型。这套方案对单条推理的延迟基本在 10 毫秒以内,如果瓶颈在数据预处理,可以考虑只取关键特征列(比如官方给的 13 个最重要特征)来缩短reshape前的转换时间。

从误报角度看,CNN 在 KDD99 上的误报率很低,但换到真实网络环境时会有分布偏移,表现可能打折。我每次跑完这套脚本都会用训练时预留的验证集样本和完全随机的向量各测一条,随机向量的预测概率如果超过 0.9,我会立刻检查归一化参数是不是加载错误了,判断不准的时候看分布的置信度差异,而完全不参考模型给的准确率数字。希望这套方法能帮你在自己的数据集上少踩几个坑,快速跑通 CNN 做入侵检测的完整链路。

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

RELATED READING

延伸阅读

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