2019年十月份左右,迫于学业上的压力开始入坑机器学习,到现在大约有小半年的时间了。这期间从数据挖掘分析到机器学习的原理和算法等等看了很多东西,但是一直感觉它们只是被迫塞进了脑子里并没有进行消化吸收。这个时候最需要自己给自己找点事来做了:)由于之前对Web安全有一点点了解,就决定从Web应用产生的HTTP数据流量进行一次机器学习的实战。
GitHub地址:https://github.com/zambery/Machine-Learning-on-CSIC-2010
1 数据集及预处理
实验使用 HTTP CSIC 2010 数据集。该数据集由西班牙最高科研理事会 CSIC 在论文 _Application of the Generic Feature __Selection Measure in Detection of Web _Attacks 中作为附件给出的,是一个电子商务网站的访问日志,包含 36000 个正常请求和 25000 多个攻击请求。异常请求样本中包含 SQL 注入、文件遍历、CRLF 注入、XSS、SSI 等攻击样本。
数据集下载链接:http://www.isi.csic.es/dataset/
根据观察,该数据集除路径(URI)和参数外其他 Header 无任何攻击 Payload,具有很多冗余信息。因此对该数据集进行格式化,只保留 HTTP 方法、路径和参数。
import urllib.parse
normal_file_raw = 'normalTrafficTraining/normalTrafficTraining.txt'
anomalous_file_raw = 'anomalousTrafficTest/anomalousTrafficTest.txt'
normal_file_pre = 'normal.txt'
anomalous_file_pre = 'anomalous.txt'
def pre_file(file_in, file_out=None):
with open(file_in, 'r', encoding='utf-8') as f_in:
lines = f_in.readlines()
res = []
for i in range(len(lines)):
line = lines[i].strip()
# 提取 GET类型的数据
if line.startswith("GET"):
res.append("GET " + line.split(" ")[1])
# 提取 POST类型的数据
elif line.startswith("POST") or line.startswith("PUT"):
method = line.split(' ')[0]
url = line.split(' ')[1]
j = 1
# 提取 POST包中的数据
while True:
# 定位消息正文的位置
if lines[i + j].startswith("Content-Length"):
break
j += 1
j += 2
data = lines[i + j].strip()
url += '?'+data
res.append(method + ' ' + url)
with open(file_out, 'w', encoding='utf-8') as f_out:
for line in res:
line = urllib.parse.unquote(line, encoding='ascii', errors='ignore').replace('\n', '').lower()
f_out.writelines(line + '\n')
print("{}数据预提取完成 {}条记录".format(file_out, len(res)))
if __name__ == '__main__':
pre_file(normal_file_raw, normal_file_pre)
pre_file(anomalous_file_raw, anomalous_file_pre)
格式化后的数据如下:异常流量下载:anomalous.txt
正常流量下载:normal.txt
2 特征提取
经过上面的处理我们有了格式化的文本数据,但是如果想要运用机器学习的方法,单单做到这里是不够的,我们还需要进一步地对这里的文本数据进行特征提取。一个 Web 访问记录的成分是比较固定的,每个部分(方法、路径、参数、HTTP 头、Cookie 等)都有比较好的结构化特点,因此可以把 Web 攻击识别任务抽象为文本分类任务。这里我们采用两钟方法进行特征提取。
2.1 TF-IDF
对文本数据进行分类,常用的方法有词袋模型、TF-IDF、词向量化等方法。由于只是一次简单的知识梳理实验,这里就不对其进行比较验证了,直接选择TF-IDF对文本数据进行向量化处理。
在进行向量化处理之前,先对文本数据进行整合、打标签的操作
def load_data(file):
with open(file, 'r', encoding='utf-8') as f:
data = f.readlines()
result = []
for d in data:
d = d.strip()
if len(d) > 0:
result.append(d)
return result
normal_requests = load_data('normal.txt')
anomalous_requests = load_data('anomalous.txt')
all_requests = normal_requets + anomalous_requets
y_normal = [0] * len(normal_requests)
y_anomalous = [1] * len(anomalous_requests)
y = y_normal + y_anomalous
之后采用TF-IDF进行特征提取
from sklearn.feature_extraction.text import TfidfVectorizer
vectorizer = TfidfVectorizer(min_df=0.0, analyzer="word", sublinear_tf=True)
X = vectorizer.fit_transform(all_requests)
我们查看下一提取的特征向量:可以看到特征维度为33550,这样一个维度对于很多机器算法来说计算复杂度太高,也很容易产生过拟合的情况。
当然遇到这种情况,应该采用相应的手段对其进行降维操作,但是由于机器限制在保留90%信息的情况下得出所需的维度需要相当长的时间,故直接采用这一维度进行实验。
2.2 自定义特征向量
在上面用TF-IDF进行特征提取时,遇到的问题是数据的维度太高,虽然我们还没有用机器学习算法进行实际的训练但是这样高的维度让我们应该考虑一些备用方案,比如尝试进行提取自定义的特征。
这里我们不妨对最初的文本数据进行如下的特征提取:
- URL长度
- 请求类型
- 参数部分长度
- 参数的个数
- 参数的最大长度
- 参数的数字个数
- 参数值中数字所占比例
- 参数值中字母所占比例
- 特殊字符个数
- 特殊字符所占比例
def vector(data):
feature = []
for i in range(len(data)):
# 采用 split("?", 1)是为了处理形如 http://127.0.0.1/?open=?123的URL
s = data[i].split("?", 1)
if len(s) != 1:
url_len = len(data[i])
method = data[i].split(" ")[0]
if method == "get":
url_method = 1
elif method == "post":
url_method = 2
else:
url_method = 3
query = s[1]
parameter_len = len(query)
parameters = query.split("&")
parameter_num = len(parameters)
parameter_max_len = 0
parameter_number_num = 0
parameter_str_num = 0
parameter_spe_num = 0
par_val_sum = 0
for parameter in parameters:
try:
# 采用 split("=", 1)是为了处理形如 open=123=234&file=op的参数
[par_name, par_val] = parameter.split("=", 1)
except ValueError as err:
# 处理形如 ?open 这样的参数
print(err)
print(data[i])
break
par_val_sum += len(par_val)
if parameter_max_len < len(par_val):
parameter_max_len = len(par_val)
parameter_number_num += len(re.findall("\d", par_val))
parameter_str_num += len(re.findall(r"[a-zA-Z]", par_val))
parameter_spe_num += len(par_val) - len(re.findall("\d", par_val)) - len(
re.findall(r"[a-zA-Z]", par_val))
try:
parameter_number_rt = parameter_number_num / par_val_sum
parameter_str_rt = parameter_str_num / par_val_sum
parameter_spe_rt = parameter_spe_num / par_val_sum
feature.append([url_len, url_method, parameter_len, parameter_num,
parameter_max_len, parameter_number_num, parameter_str_num,
parameter_spe_num, parameter_number_rt, parameter_str_rt,
parameter_spe_rt])
except ZeroDivisionError as err:
print(err)
print(data[i])
continue
return feature
自定义好后的数据用 numpy
形式保存,方便以后直接使用。
向量化的正常数据:vector_normal.txt
向量化的异常数据:vector_anomalous.txt
同样地,我们也需要整合已经向量化好的数据,并进行打标签操作,划分测试集和训练集。
# 整合数据,打标签
import numpy as np
normal = np.loadtxt("vector_normal")
anomalous = np.loadtxt("vector_anomalous")
all_requests = np.concatenate([normal, anomalous])
X = all_requests
y_normal = np.zeros(shape=(normal.shape[0]), dtype='int')
y_anomalous = np.ones(shape=(anomalous.shape[0]), dtype='int')
y = np.concatenate([y_normal, y_anomalous])
# 划分测试集和训练集
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=666)
3 模型训练
这部分我们主要比较TF-IDF提取的特征和自定义提取的特征在模型训练中的不同
3.1 k近邻算法
在开始之前先对数据进行归一化操作,进一步提高模型训练结果的正确性:
from sklearn.preprocessing import StandardScaler
# 数据归一化
standardScalar = StandardScaler()
standardScalar.fit(X_train)
X_train = standardScalar.transform(X_train)
X_test_std = standardScalar.transform(X_test)
在进行模型训练时,我们将采用网格搜索的方法,使用交叉验证的方式来评估超参数的所有可能的组合:
# 网格搜索的参数
param_grid = [
{
'weights': ['uniform'],
'n_neighbors': [i for i in range(2, 11)] #从1开始容易过拟合
},
{
'weights': ['distance'],
'n_neighbors': [i for i in range(2, 11)],
'p': [i for i in range(1, 6)]
}
]
# cv其实也是一个超参数,一般越大越好,但是越大训练时间越长
grid_search = GridSearchCV(KNeighborsClassifier(), param_grid, n_jobs=-1, cv=5)
基于上面的设置可以得到如下表所示结果,其中TF-IDF使用无法得出结果(内存爆表死机),只进行了单个模型训练,模型参数如下:
KNeighborsClassifier(algorithm='auto', leaf_size=30, metric='minkowski',
metric_params=None, n_jobs=None, n_neighbors=5, p=2,
weights='uniform')
自定义特征 | TF-IDF | |
---|---|---|
训练时间 | 47.1 s | 60 ms |
最佳结果 | 'n_neighbors': 10<br />'p': 3,<br />'weights': 'distance' | |
grid_search.best_score_ | 0.8759619101163076 | |
测试集score | 0.8733661278988053 | 0.9233603537214443 |
精准率 | 0.8785353535353535 | 0.9194262813752373 |
召回率 | 0.8922800718132855 | 0.8872379401587625 |
F1-Score | 0.8853543707850872 | 0.9030453697949038 |
3.2 逻辑回归
# 网格搜索的参数
param_grid = [
{
'C': [0.1, 1, 3, 5, 7],
'penalty': ['l1', 'l2']
}
]
grid_search = GridSearchCV(LogisticRegression(), param_grid, n_jobs=-1, cv=5)
自定义特征 | TF-IDF | |
---|---|---|
训练时间 | 4min 11s | 1min 31s |
最佳结果 | 'C': 7<br />'penalty': 'l1' | 'C': 7<br />'penalty': 'l2' |
grid_search.best_score_ | 0.6869882989563935 | 0.9680463440596087 |
测试集score | 0.6929023190442727 | 0.9737165315647262 |
精准率 | 0.7455587392550144 | 0.9922813036020584 |
召回率 | 0.6673506027186458 | 0.941990637085284 |
F1-Score | 0.7042901610502098 | 0.9664821969301451 |
上面的结果还是有点出乎意料的
- 逻辑回归模型的训练时间竟然比k近邻还长,这当然可能跟网格搜索所设置的参数有关系,但是这一点与理论冲突,仍有待后期验证
- 由TF-IDF所提取的特征向量不仅训练时间快,模型的准确率也比自定义的特征提取高出不少,这里有些反直觉,具体原因也不明确
- 对于自定义特征的在逻辑回归上的训练结果,可以考虑增加多项式特征来进行优化
3.3 决策树
对于决策树来说可以直接使用原始数据而不必进行特征缩放
# 网格搜索的参数
param_grid = [
{
'max_depth':[i for i in range(1, 10)],
'min_samples_leaf':[i for i in range(1, 20)],
'min_samples_split':[i for i in range(10, 30)],
}
]
自定义特征 | TF-IDF | |
---|---|---|
训练时间 | 3min 46s | 1h 7min 6s |
最佳参数 | 'max_depth': 9<br />'min_samples_leaf': 1<br />'min_samples_split': 27 | 'max_depth': 9<br />'min_samples_leaf': 19<br />'min_samples_split': 10 |
grid_search.best_score_ | 0.7973224638954285 | 0.8979775648898715 |
测试集score | 0.8042164441321152 | 0.90084336362892 |
精准率 | 0.7658039881204921 | 0.951904296875 |
召回率 | 0.9258784303667608 | 0.7936087929981681 |
F1-Score | 0.8382677348194589 | 0.8655788655788657 |
从上面的结果可以看出由于TF-IDF所提取的特征维度太大,因此训练时间达到了一个小时。
3.4 SVM
SVM对特征缩放较为敏感,因此我们在进行模型训练之前需要对数据进行归一化处理
# 网格搜索的参数
param_grid = [
{
'kernel': ["poly"],
'degree': [1, 2, 3],
'C': [0.1, 1, 3, 5]
}
]
grid_search = GridSearchCV(SVC(), param_grid, n_jobs=-1, cv=5)
此外由于SVM的对于数据量较多的数据集训练时间较慢,此处TF-IDF的特征提取结果没有进行网格搜索寻找最佳参数,其模型参数如下:
SVC(C=1.0, cache_size=200, class_weight=None, coef0=0.0,
decision_function_shape='ovr', degree=3, gamma='auto_deprecated',
kernel='rbf', max_iter=-1, probability=False, random_state=None,
shrinking=True, tol=0.001, verbose=False)
自定义特征 | TF-IDF | |
---|---|---|
训练时间 | 8min 26s | 10min 22s |
最佳参数 | 'C': 5<br />'degree': 3<br />'kernel': 'poly' | |
grid_search.best_score_ | 0.7022734460100496 | |
测试集score | 0.7114546732255798 | 0.9619258167526407 |
精准率 | 0.7404898384575299 | 0.9623700623700624 |
召回率 | 0.7289048473967684 | 0.9421941787095461 |
F1-Score | 0.7346516737753651 | 0.9521752545510646 |
3.5 随机森林
由于随机森林模型所需的训练时间较长,这里就不采用网格搜索的方式寻找最佳参数了,进行训练的随机森林模型参数如下:
RandomForestClassifier(bootstrap=True, class_weight=None, criterion='gini',
max_depth=None, max_features='auto', max_leaf_nodes=None,
min_impurity_decrease=0.0, min_impurity_split=None,
min_samples_leaf=1, min_samples_split=2,
min_weight_fraction_leaf=0.0, n_estimators=500,
n_jobs=-1, oob_score=True, random_state=666, verbose=0,
warm_start=False)
自定义特征 | TF-IDF | |
---|---|---|
训练时间 | 3.77 s | 2min 50s |
测试集score | 0.947013352073085 | 0.9647916154916892 |
精准率 | 0.9471813103098019 | 0.9618792499484855 |
召回率 | 0.956655552705822 | 0.9501323020557704 |
F1-Score | 0.9518948577261708 | 0.9559696907638747 |
4 总结
这次实验分别使用了自定义的特征向量和TF-IDF提取的特征向量进行模型训练,我们不妨从以下几个方面来回顾一下模型的训练结果
实验结果分析
- 训练时间:
从训练时间上看,用自定义特征进行模型训练所花费的时间总体要比TF-IDF提取特征进行模型训练所花费的时间短。当然由于有些使用了网格搜索寻找最佳参数,有些没有进行网格搜索,这里无法进行定量的分析。因为TF-IDF的特征维度—33550,远远超过自定义的特征维度—11,自定义特征的模型训练时间短也是可以预见的。
但在逻辑回归中,基于相同的网格搜索参数,自定义特征的模型训练时间:4min 11s、TF-IDF提取特征的模型训练时间:1min 31s。自定义特征的模型训练时间较长,这一点目前无法解释。
- 训练结果:
自定义特征的模型(蓝色为最大值,绿色为次大值)
k近邻 | 逻辑回归 | 决策树 | SVM | 随机森林 | |
---|---|---|---|---|---|
测试集score | 0.8733661278988053 | 0.6929023190442727 | 0.8042164441321152 | 0.7114546732255798 | 0.947013352073085 |
精准率 | 0.8785353535353535 | 0.7455587392550144 | 0.7658039881204921 | 0.7404898384575299 | 0.9471813103098019 |
召回率 | 0.8922800718132855 | 0.6673506027186458 | 0.9258784303667608 | 0.7289048473967684 | 0.956655552705822 |
F1-Score | 0.8853543707850872 | 0.7042901610502098 | 0.8382677348194589 | 0.7346516737753651 | 0.9518948577261708 |
提取特征的模型训练(蓝色为最大值,绿色为次大值)
k近邻 | 逻辑回归 | 决策树 | SVM | 随机森林 | |
---|---|---|---|---|---|
测试集score | 0.9233603537214443 | 0.9737165315647262 | 0.90084336362892 | 0.9619258167526407 | 0.9647916154916892 |
精准率 | 0.9194262813752373 | 0.9922813036020584 | 0.951904296875 | 0.9623700623700624 | 0.9618792499484855 |
召回率 | 0.8872379401587625 | 0.941990637085284 | 0.7936087929981681 | 0.9421941787095461 | 0.9501323020557704 |
F1-Score | 0.9030453697949038 | 0.9664821969301451 | 0.8655788655788657 | 0.9521752545510646 | 0.9559696907638747 |
- 通过上面两张表的对比可以看出,随机森林作为一种集成学习算法其在两种特征提取方法种均有较好的表现,但其单个模型的训练时间均较长。
- 由于TF-IDF提取的特征维度为33550,所以上述每个训练模型都取得较好结果;但是自定义特征仅仅选取了11个维度,如果添加更多的特征维度其模型训练的性能和效果将会超过TF-IDF。
- 在特征维度较大的情况下,逻辑回归不论在性能和效果上仍然有较好的表现,这一点值得进行后续的探讨。
- 在自定义的特征维度下,即特征维度仅为11时,k近邻算法的表现最好,预测结果不具有可解释性。
心得体会
通过上面的实验也可以看出,数据对机器学习的算法影响很大,选择什么样的方法进行特征提取,对后续的模型训练至关重要。
以我自己目前的理解和水平,后续一段时间进行机器学习这一领域的探索可以从以下三个方面来入手:
- 一是针对数据进行更好特征提取,比如这次实验中的数据,对HTTP攻击的理解越深入我们越能对其进行特征描述,最终高度刻画HTTP的攻击流量。此外,不限于HTTP的攻击流量,如果针对网络流量包进行深入的刻画、分析,并能总结出一种类似TF-IDF的特征提取模型也是极好的。
- 二是深入理解现有的机器算法。以这次的实验为例,逻辑回归为什么在特征维度极大的数据集中仍然有较好的表现?针对现有的特征向量如何结合模型的设计原理寻找最佳参数,而不是通过网格搜索漫无目的地遍历?这些问题都依赖于对机器算法更深入的了解。
- 三是探索更高阶的机器学习算法,比如深度学习。从本次的实验可以看出,传统的机器学习方法依赖于特征工程,需要人为地进行特征提取;但是深度学习可以自动地找出这个分类问题所需要的重要特征,因此探索深度学习的算法也可以作为后续的学习方向。