基于机器学习的webshell检测(一)

基于机器学习的webshell检测(一)

本篇主要讲述,如何使用机器学习的方法来对网络安全中常见的风险点:webshell进行检测

本篇会使用LR ,XGB两种模型进行测试,

下一篇将会使用深度学习方法来解决该问题

(1)首先我们简单介绍一下什么是webshell:

webshell,是一种基于互联网web程序以及web服务器而存在的一种后门形式,主要通过网页脚本程序和服务器容器所支持的后端程序,在web服务器及其中间件中进行运行。

一个成熟的webshell主要具有以下两个特点:隐蔽性强以及功能强大。

而隐蔽性强又包含:该webshell在上传时可以绕过服务器可能存在的安全检测工具以及避开网络应用防火墙(WAF)的查杀;在后门程序运行,与攻击者传输数据包时,其行为不易被检测或侦察;在进行文件静态检测或特征码检测是,由于加密,压缩,动态调用等多种方法使得其不容易被人工或机器检测;具有良好的反计算机取证分析及溯源能力。

Webshell通常针对web开放端口,并使用脚本语言编写一般一个webshell主程序整体是一个正向的链接,可以被攻击者主动访问。在攻击链模型中,一次针对webshell攻击的过程主要分为以下步骤:踩点,组装,投送,攻击,植入,控制以及行为。

目前国内外媒体经常爆出大型的网站,金融平台,组织机构,甚至网络供应服务商(ISP),政府部门都曾被检测出在其重要系统包含有webshell存在。成千上万的敏感信息以及用户数据被泄露。大量高流量及政务网站均存在挂有webshell的情况存在。

本文旨在使用一种机器学习的方法,结合笔者在安全方面的经验,通过机器学习建模方式达到检测恶意webshell的目的。

(2)上代码

话不多说,先导入以下老朋友,包括:numpy(一个强大的python大规模矩阵计算库) pandas(一个强大的结构化数据处理库) matplotlib(一个强大的图形显示库)

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import sklearn
import os
%matplotlib inline
import warnings

warnings.filterwarnings('ignore')

接下来,我们先看一下我们这个项目的目录结构长啥样子

其中 php-common 文件夹中存放着正常php文件数据集,其中主要来源为大型的php开源项目,包括 phpcms,yii,thinkphp,wordpress等等(代码审计,挖通用也是很爽的)

php-webshell文件夹中存放着笔者收集而来的大量webshell样本并将它们通过md5检验去重

总共包含正样本(webshell)约2500以及负样本5000个

以下block中的代码将正负样本数据集文件枚举出来

files_webshell = os.listdir("/webshell/project/php-webshell/")
files_common = os.listdir("/webshell/project/php-common")

以下代码用于根据文件夹决定其中文件的label标签,如果是webshell文件夹下的文件,则置label=1 否则 label=0

labels_webshell = []
labels_common = []
for i in range(0,len(files_webshell)):
    labels_webshell.append(1)
for i in range(0,len(files_common)):
    labels_common.append(0)

以下代码用于构建结构化数据,也就是pandas.DataFrame,以下简称df

初始的df包含两列,其中第一列为label 也就是每个php文件的标签 第二列为file 也就是文件的路径

for i in range(0,len(files_webshell)):
    files_webshell[i] = "/webshell/project/php-webshell/" + files_webshell[i]
for i in range(0,len(files_common)):
    files_common[i] = "/webshell/project/php-common/" + files_common[i]

files = files_webshell + files_common
labels = labels_webshell + labels_common

datadict = {'label':labels,'file':files}
df = pd.DataFrame(datadict,columns=['label','file'])

输出df的前五行看一下长啥样 如下block:

并使用df.info()方法 看一下数据类型,并且看一下有没有异常的缺失

我们可以看到 label 和 file 都是7789个

其中label是int64类型的数据(因为他只有0,1)

而file是object类型的数据

接下来 就是机器学习的核心,特征选择和特征工程

先想一想webshell和正常的php文件会有哪些区别

依照安全方面的经验,很多php大马,需要实现非常多的功能,比如说连接数据库,目录遍历,文件查看,文件修改,执行shell命令,提权等等,而正常的php文件比如db.php可能包含数据库连接信息,config.php可能包含全局配置文件,index.php可能就是一些简单配置和路由

那么可不可能普遍来说,webshell的文件大小会比正常文件大一些呢

我们尝试将其当作一个特征

以下block定义一个函数,用于读取文件,并计算文件的长度,将计算结果作为返回值,添加到df中,作为全新的一列 len

def getfilelen(x):
    length = 0
    with open(x,'r',encoding='ISO-8859-1') as f:
        content = f.readlines()
        for i in content:
            length = length + len(i)
        f.close()
    return length

df['len'] = df['file'].map(lambda x:getfilelen(x)).astype(int)

粗略看了一下,貌似webshell具有上万文件长度的文件,而正常样本多集中在几百至几千的范围,当然我们不能肯定,因为才看了10条数据23333

我们这里导入一个新的库 叫做seaborn 它可以看作是matplotlib的一个高级实现接口
我们使用kdeplot函数进行画图,分别画出在label=1和label=0时,文件长度的分布情况
我们可以看到,虽然大体趋势差不多,但是在label=1情况下 有更多的文件表现出具有较大的长度,而label=0情况下 文件长度绝大多数在2000范围以内
如下图所示

import seaborn as sns
plt.style.use('seaborn')
sns.set(font_scale=2)
pd.set_option('display.max_columns', 500)

sns.kdeplot(df.len[df.label == 1].values, color="b", shade=True)
sns.kdeplot(df.len[df.label == 0].values, color="b", shade=True)

下一步要想一想还有什么特征 信息学中有一个很重要的概念,叫熵(虽然它最早出自化学和物理学2333),在信息学中,它用于衡量一个系统的混乱程度,简而言之,对于一个随机字符串,他就代表着字符串中字母出现以及分布的混乱程度,打个比方: str1 = 'aaabbb' str2 = 'asdfgh' 两者长度相同,可是看上去str1比str2更整齐,更有规律不少,而str2更像是用脸滚键盘滚出来的,熵主要用于判断器混乱程度,不同于我们用肉眼判断,她有一个著名的公式,叫香农熵公式:

G = -ΣLog(P(Xi)) * P(Xi)

其中P(Xi)是字符出现的概率 比如 str1中 P('a') = 3 / (3+3) = 0.5 str2中 P('a') = 1 / (6) = 0.166667

那么由于某些webshell需要规避一些防御机制的检测,可能他会使用混淆或者加密技术来使得代码看上去一团乱麻,但是同时一些检测方法由于无法匹配到特征库而将其标识为正常文件

因此,或许可以将熵作为其中的一个特征维度

以下代码封装了一个函数,通过读取文件并计算整个文件的熵,返回计算结果

def getfileshan(x):
    length = 0
    word = {}
    p = 0
    sum = 0
    with open(x,'r',encoding='ISO-8859-1') as f:
        content = f.readlines()
        for i in content:
            for j in i:
                if j != '\n' and j != ' ':
                    if j not in word.keys():
                        word[j] = 1
                    else:
                        word[j] = word[j] + 1
                else:
                    pass
        f.close()
    for i in word.keys():
        sum = sum + word[i]
    for i in word.keys():
        p = p - float(word[i])/sum * math.log(float(word[i])/sum,2)
    return p

df['shan'] = df['file'].map(lambda x:getfileshan(x)).astype(float)

通过可视化,我们可以看出来:

正常文件的熵主要分布在4-6之间,形如正态分布,峰值在5左右,而webshell的分布更多的位于5-6之间

接下来 依据安全方面的经验 看一下还会有什么其他的特征

比如说 你一个木马,小到一句话,大到什么什么组大马 都需要shell功能吧,需要命令执行吧

因此,我们看一看文件中是否包含命令执行类的函数

常见的如 assert() eval() system() cmd_shell() shell_exec()

同时为了尽可能准确,依据经验,我们统计文件中出现这类函数的次数,防止正常文件也需要进行执行命令或者断言等操作,而仅仅判断是否出现过更容易造成误判

依照这个思想,我们还可以选区常见的:

(1)文件,目录操作类函数

(2)解码编码类函数

(3)文件压缩类函数

(4)字符编码转换类函数

(5)字符替换类函数

(6)动态函数类等其他敏感不常见操作

同时,还有一个重要的特征,最长的单词长度,这个主要是针对进行编码加密的文件,他可能把符合php愈发的文件加密的乱七八糟

我们依次统计这些维度,并返回成一个tuple

import re
def getfilefunc(x):
    content = ''
    content_list = []
    with open(x,'r',encoding='ISO-8859-1') as f:
        c = f.readlines()
        for i in c:
            content = content + i.strip('\n')
        f.close()
    content_list = re.split(r'\(|\)|\[|\]|\{|\}|\s|\.',content)
    max_length = 0
    for i in content_list:
        if len(i) > max_length:
            max_length = len(i)
        else:
            pass
    #print(content_list)
    count_exec = 0
    count_file = 0
    count_zip = 0
    count_code = 0
    count_chr = 0
    count_re = 0
    count_other = 0
    for i in content_list:
        if 'assert' in i or 'system' in i or 'eval' in i or 'cmd_shell' in i or 'shell_exec' in i:
            count_exec = count_exec + 1
        if 'file_get_contents' in i or 'fopen' in i or 'fwrite' in i or 'readdir' in i or 'scandir' in i or 'opendir' in i or 'curl' in i:
            count_file = count_file + 1
        if 'base64_encode' in i or 'base64_decode' in i:
            count_code = count_code + 1
        if 'gzcompress' in i or 'gzuncompress' in i or 'gzinflate' in i or 'gzdecode' in i:
            count_zip = count_zip + 1
        if 'chr' in i or 'ord' in i:
            count_chr + count_chr + 1
        if 'str_replace' in i or 'preg_replace' in i or 'substr' in i:
            count_re = count_re + 1
        if 'create_function' in i or 'pack' in i:
            count_other = count_other + 1
    #print(x)
    return (max_length,count_exec,count_file,count_zip,count_code,count_chr,count_re,count_other)

由于是demo,代码写得比较丑,但是个人感觉容易理解23333

df['maxlen'] = df['func'].map(lambda x:x[0])
df['exec'] = df['func'].map(lambda x:x[1])
df['file'] = df['func'].map(lambda x:x[2])
df['zip'] = df['func'].map(lambda x:x[3])
df['code'] = df['func'].map(lambda x:x[4])
df['chr'] = df['func'].map(lambda x:x[5])
df['re'] = df['func'].map(lambda x:x[6])
df['other'] = df['func'].map(lambda x:x[7])

至此 特征选择部分完成

下一步:归一化

所谓归一化,就是不数据的范围放缩至一个相同的范围以避免数据跨度或量级的巨大差别而导致训练上的困难或者结果上的偏差 sklearn给出了丰富强大的接口来帮你

from sklearn import preprocessing
from sklearn.utils import shuffle
from sklearn.svm import SVC
from sklearn.linear_model.logistic import LogisticRegression

不得不说python真的太舒服了

scaler = preprocessing.StandardScaler()

len_scale_param = scaler.fit(df['len'].values.reshape(-1,1))
df['len_scaled'] = scaler.fit_transform(df['len'].values.reshape(-1,1),len_scale_param)

其他特征也依次作如上处理

之后,我们选取需要用到的特征带入模型,并将其打乱

train_pre = df.filter(regex = 'label|len_scaled|shan_sclaed|maxlen_sclaed|exec_sclaed|zip_sclaed|code_sclaed')
train_pre = shuffle(train_pre)

以下block用于将pandas.DataFrame格式的数据转化为numpy.ndarray格式 也就是通俗意义上的矩阵

算法的本质就是矩阵的计算和参数的优化过程

train_pre =train_pre.as_matrix()

至此特征工程部分完成

接下来 我们将数据集分为两个部分,分别作训练集和测试集

由于是简单demo 我们使用前7000数据作为训练集,后700数据做测试

y_train = train_pre[0:7000,0]
x_train = train_pre[0:7000,1:]
y_test = train_pre[7000:,0]
x_test = train_pre[7000:,1:]

由于我们的特征维度较少,并且数据量很少(不到一万)

因此logistic 回归也许是一个不错的选择

以下代码调用sklearnd提供的模型接口,使用fit()方法训练数据,并使用predict()方法进行测试

计算最终的准确率

print ('now training')
lr = LogisticRegression().fit(x_train,y_train)
print ('training finished')
model = lr.predict(x_test)

from sklearn import metrics
from sklearn.metrics import accuracy_score
accuracy_score(model,y_test)

OUT:0.8010139416983524

可以看到准确率只有80.1%,差强人意

我也使用了svm等算法建模,准确率均再80%上下

最后尝试非常好用的xgboost

xgboost时一种提升方法,他和lgbm是目前算法竞赛重用的最广泛的机器学习模型(

我们可以使用python的 xgboost库进行建模或者使用接口XGBClassifier

笔者使用了这两种方法测试

以下为XGBClassifier的结果

model = xgb.XGBClassifier(max_depth=3, n_estimators=10000, learn_rate=0.01)

这一行代码就是调用接口的方法,参数max_depth指定树的深度 n_estimators指定迭代次数 ,learn_rate指定学习率

最终准确率为94.6%

本方法再工程上具有非常大的提升空间,如使用交叉验证的方法来选择模型,做更加精细的特征工程,特征组合

由于数据集较小,又担心训练集熵较好的效果会导致过拟合而降低泛化能力。

读者可按照自己想法进行拓展和优化

params = {
    'booster': 'gbtree',
    'num_class': 2,               # 类别数,与 multisoftmax 并用
    'gamma': 0.1,                  # 用于控制是否后剪枝的参数,越大越保守,一般0.1、0.2这样子。
    'max_depth': 12,               # 构建树的深度,越大越容易过拟合
    'lambda': 2,                   # 控制模型复杂度的权重值的L2正则化项参数,参数越大,模型越不容易过拟合。
    'subsample': 0.7,              # 随机采样训练样本
    'colsample_bytree': 0.7,       # 生成树时进行的列采样
    'min_child_weight': 3,
    'silent': 1,                   # 设置成1则没有运行信息输出,最好是设置为0.
    'eta': 0.007,
    'seed': 1000,
    'nthread': 4,
}

params['eval_metric'] = 'error'
num_round = 200
dtest = xgb.DMatrix( x_test, label=y_test)
evallist  = [(dtest,'test'), (dtrain,'train')]
model = xgb.XGBClassifier(max_depth=3, n_estimators=10000, learn_rate=0.01)
model.fit(x_train, y_train)
test_score = model.score(x_test, y_test)

最终得分

test_score
OUT:0.9467680608365019

编辑于 2019-03-08