2015年,Joshua Saxe和Konstantin Berlin总结前人探索的经验教训,通过大量研究和实验,给出了使用深度学习检测恶意程序的具体方法,他们发表了论文《Deep Neural Network Based Malware Detection Using Two Dimensional Binary Program Features》。
在论文中他们描述了具体的特征提取方法,并在约40万程序上进行了测试,检出率达到95%,误报率为0.1%。本章的PE文件特征提取方式主要参考该论文实现,主要分为两类:一类是通过PE文件可以直接获取到的特征,比如字节直方图,字节熵直方图和字符串特征等;另一类特征是需要解析PE文件结构,从各个节分析出的特征,比如节头特征,导入和导出表特征,文件头特征等。下面我们将结合代码介绍各个特征的具体提取方式。
1.字节直方图
本质上PE文件也是二进制文件,可以当作一连串字节组成的文件。字节直方图又称为ByteHistogram,它的核心思想是,定义一个长度为256维的向量,每个向量依次为0x00,0x01一直到0xFF,分别代表PE文件中0x00,0x01一直到0xFF对应的个数。
如图8-4所示,假设PE文件对应的二进制流为:
0x01 0x05 0x03 0x01
经过统计,0x01有两个,0x03和0x05对应的各一个,假设直方图维度为8,所以对应的直方图为:
[0,2,0,1,0,0,1,0,0]
从某种程度上来说,字节直方图有点类似我们在文本处理中经常使用的词袋模型。Python中实现自己直方图非常方便,主要是numPy提供了一个非常强大的函数:
numpy.bincount(x, weights=None, minlength=None)
numpy.bincount专门用于以字节为单位统计个数,比如统计0~7出现的个数:
>>> np.bincount(np.array([0, 1, 1, 3, 2, 1, 7])) array([1, 3, 1, 1, 0, 0, 0, 1], dtype=int32)
其中minlength参数用于指定返回的统计数组的最小长度,不足最小长度的会自动补0,比如统计0~7出现的个数,但是指定minlength为10:
>>> np.bincount(np.array([0, 1, 1, 3, 2, 1, 7]),minlength=10) array([1, 3, 1, 1, 0, 0, 0, 1, 0, 0], dtype=int32)
处理PE文件时,本章都假设PE文件已经以字节数组的形式保存在变量bytez中,将bytez转换成numPy的数组便于处理:
np.frombuffer(bytez, dtype=np.uint8)
将bytez转换成字节直方图,由于字节由8位组成,所以指定minlength为256,将bytez按照256维统计次数:
h = np.bincount(np.frombuffer(bytez, dtype=np.uint8), minlength=256)
实际使用时,单纯统计直方图非常容易过拟合,因为字节直方图对于PE文件的二进制特征过于依赖,PE文件增加一个无意义的0字节都会改变直方图;另外PE文件中不同字节的数量可能差别很大,数量占优势的字节可能会大大弱化其他字节对结果的影响,所以需要对直方图进行标准化处理。一种常见的处理方式是,增加一个维度的变量,用于统计PE文件的字节总数,同时原有直方图按照字节总数取平均值,代码如下:
return np.concatenate([ [h.sum()], # total size of the byte stream h.astype(self.dtype).flatten() / h.sum(), # normalized the histogram ])
图8-4 PE文件转换成字节直方图
2.字节熵直方图
字节熵直方图,又称ByteEntropyHistogram,它是在字节直方图的基础上发展而来的。1948年,香农提出了信息熵的概念,解决了对信息的量化度量问题。信息熵这个词是香农从热力学中借用过来的。热力学中的热熵是表示分子状态混乱程度的物理量。香农用信息熵的概念来描述信源的不确定度。在信源中,考虑的不是某一单个符号发生的不确定性,而是要考虑这个信源所有可能发生情况的平均不确定性。若信源符号有n种取值:U1 ,U2 ,U3 ,…,Un ,对应概率为:P1 ,P2 ,P3 ,…,Pn ,且各种符号的出现彼此独立。这时,信源的信息熵定义如下:
其中,log通常底取2。
PE文件同样可以使用字节的信息熵来当作特征。我们把PE文件当作一个字节组成的数组,如图8-5所示,在这个数组上以2048字节为窗口,以1024字节为步长计算墒。
图8-5 以2048字节为窗口,以1024字节为步长计算墒
在Python中实现这种窗口滑动,有现成的代码实现,我们使用一个例子来说明。假设我们有个长度为16的数组,初始化内容分别为0~15:
x = np.arange(16) print(x) [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15]
定义滑动函数,其中滑动窗口为window:
def rolling_window(a, window): shape = a.shape[:-1] + (a.shape[-1] - window + 1, window) strides = a.strides + (a.strides[-1],) return np.lib.stride_tricks.as_strided(a, shape=shape, strides=strides)
遍历返回的窗口列表,其中步长为1,窗口大小为2:
blocks = rolling_window(x, 2)[::1, :] for block in blocks: print(block)
遍历窗口的内容举例如下,同样我们只要改变步长和窗口大小就可以实现对PE文件的窗口滑动处理。
[0 1] [1 2] [2 3] [3 4] [4 5] [5 6]
我们定义一个16×16矩阵保存计算的字节熵直方图,并且将PE文件加载到一个字节数组:
output = np.zeros((16, 16), dtype=np.int) a = np.frombuffer(bytez, dtype=np.uint8)
利用我们之前使用的窗口滑动函数,遍历整个PE文件:
shape = a.shape[:-1] + (a.shape[-1] - self.window + 1, self.window) strides = a.strides + (a.strides[-1],) blocks = np.lib.stride_tricks.as_strided( a, shape=shape, strides=strides)[::self.step, :]
我们只计算每个字节的高4位的信息熵,那么字节的值都可以转换成0x00~0x0F,字节熵直方图的横轴代表字节且长度为16,分别代表字节值0x00~0x0F;纵轴代表熵且长度为16,代表信息熵乘以2并取整数位后为0~15的情况。
定义计算一个窗口的字节熵的函数,其中block保存了一个窗口大小的字节数组。将字节全部右移4位,相当于提取了每个字节的高4位处理,把全部字节映射到了0x00~0x0F,然后进行计数,并计算概率。代码如下:
def _entropy_bin_counts(self, block): c = np.bincount(block >> 4, minlength=16) p = c.astype(np.float32) / self.window
计算相应的熵,对数的底使用2,由于字节最多有256种取值,在均匀分布的情况下熵最大,达到8,即log2256。代码如下:
wh = np.where(c)[0] H = np.sum(-p[wh] * np.log2(p[wh])) Hbin = int(H * 2) if Hbin == 16: Hbin = 15
遍历每个窗口块,计算对应的熵,并更新直方图:
for block in blocks: Hbin, c = self._entropy_bin_counts(block) output[Hbin, :] += c
3.文本特征
恶意文件通常在文本特征方面与正常文件有所区分,比如硬编码上线IP和C&C域名等,我们进一步提取PE文件的文本特征,下面我们先总结需要关注的文本特征。
·可读字符串个数
可读字符串指的是由文本文件中常见的字母、数字和符号组成的字符串。如图8-6所示,可读的字符串的组成集中在ASCII码值在0x20~0x7F之间的字符。也可以把可读字符称为可打印字符(printable characters)。我们定义满足以下条件的字符串为可读字符串,其中长度不小于5个字符:
self._allstrings = re.compile(b'[\x20-\x7f]{5,}') allstrings = self._allstrings.findall(bytez)
统计可读字符串的个数:
[len(allstrings)]
·平均可读字符串长度
在获取了全部可读字符串的基础上,计算其平均长度:
string_lengths = [len(s) for s in allstrings] avlength = sum(string_lengths) / len(string_lengths)
·可读字符直方图
与字节直方图原理类似,我们可以统计可读字符串的字符直方图,由于可读字符的个数为96个,所以我们定义一个长度为96的向量统计其直方图:
图8-6 ASCII码表
as_shifted_string = [b - ord(b'\x20') for b in b''.join(allstrings)] c = np.bincount(as_shifted_string, minlength=96) p = c.astype(np.float32) / c.sum()
·可读字符信息熵
把全部可读字符串中的字符求信息熵作为一个维度:
wh = np.where(c)[0] H = np.sum(-p[wh] * np.log2(p[wh]))
·C盘路径字符串个数
恶意程序通常对被感染系统的根目录有一定的文件操作行为,表现在可读字符串中,可能会包含硬编码的C盘路径,我们将这类字符串的个数作为一个维度:
self._paths = re.compile(b'c:\\\\', re.IGNORECASE) [len(self._paths.findall(bytez))]
·注册表字符串个数
恶意程序通常对被感染系统的注册表有一定的文件操作行为,表现在可读字符串中,可能会包含硬编码的注册表值,我们将这类字符串的个数作为一个维度:
self._registry = re.compile(b'HKEY_') [len(self._registry.findall(bytez))]
·URL字符串个数
恶意程序通常从指定URL下载资源,最典型的就是下载者病毒,表现在可读字符串中,可能会包含硬编码的URL,我们将这类字符串的个数作为一个维度:
self._urls = re.compile(b'https?://', re.IGNORECASE) [len(self._urls.findall(bytez))]
·MZ头的个数
MZ头的个数也是一个统计的维度:
self._mz = re.compile(b'MZ') [len(self._mz.findall(bytez))]
最后我们得到了完整的的文本特征:
return np.concatenate([ [[len(allstrings)]], [[avlength]], [p.tolist()], [[H]], [[len(self._paths.findall(bytez))]], [[len(self._urls.findall(bytez))]], [[len(self._registry.findall(bytez))]], [[len(self._mz.findall(bytez))]] ], axis=-1).flatten().astype(self.dtype)
4.文件信息
上面提到的字节直方图、字节熵直方图和文本特征直方图都可以把PE文件当作字节数组处理即可获得。但是有一些特征我们必须按照PE文件的格式进行解析后才能获得,比较典型的就是文件信息。我们定义需要关注的文件信息包括以下几种:
·是否包含debug信息。
·导出函数的个数。
·导入函数的个数。
·是否包含资源文件。
·是否包含信号量。
·是否启用了重定向。
·是否启用了TLS回调函数。
·符号个数。
我们使用lief库解析PE文件:
binary = lief.PE.parse(bytez)
我们定义一个维度为9的向量记录我们关注的文件信息:
return np.asarray([ binary.virtual_size, binary.has_debug, len(binary.exported_functions), len(binary.imported_functions), binary.has_relocations, binary.has_resources, binary.has_signature, binary.has_tls, len(binary.symbols), ]).flatten().astype(self.dtype)
5.文件头信息
PE文件头中的信息也是非常重要的信息,下面我们对关注的内容做出说明。
·PE文件的创建时间
这个时间各种说法都有,有的说是文件生成的时间,有的说是文件编译生成的时间,我在实际工作中发现这个值是编译器编译生成PE文件时打上的,是文件编译生成的时间,文件的复制不会改变这个值。
[binary.header.time_date_stamps]
·机器码
每个CPU拥有唯一的机器码,虽然PE文件把机器码定义为一个WORD类型,即2个字节。一种特征定义方式就是直接把机器码当成一个数字处理;另外一种方式就是类似词袋的处理方式,定义一个固定长度为N的词袋,把机器码转换成一个维度为N的向量,下面我们介绍第二种。Scikit-learn提供了一个非常方便的类FeatureHasher,定义如下:
sklearn.feature_extraction.FeatureHasher(n_features=1048576, input_type='dict', dtype=<class 'numpy.float64'>, alternate_sign=True, non_negative=False)
通过FeatureHasher可以非常方便地处理上述情况。如图8-7所示,FeatureHasher把字典或是字符串变量映射成一个数组,数组中记录对应的键值出现的次数,然后再将该数据标准化,代码实现如下所示:
>>> from sklearn.feature_extraction import FeatureHasher >>> h = FeatureHasher(n_features=6) >>> D = [{'dog': 1, 'cat':2, 'elephant':4},{'dog': 2, 'run': 5}] >>> f = h.transform(D) >>> print f [[ 0. 2. -4. -1. 0. 0.] [ 0. 0. 0. -2. -5. 0.]]
需要强调的是,n_features指定的是生成的向量的维度,如果指定的维度小于键值的个数,会进行压缩处理:
from sklearn.feature_extraction import FeatureHasher h = FeatureHasher(n_features=3) D = [{'dog': 1, 'cat':2, 'elephant':4},{'dog': 2, 'run': 5}] f = h.transform(D) print(f.toarray()) [[-1. 2. -4.] [-2. -5. 0.]]
具体到机器码这个问题,由于机器码是WORD型,所以我们先将机器码转换成字符串类型,然后再使用FeatureHasher转换成一个维度为10的向量:
FeatureHasher(10, input_type="string", dtype=self.dtype).transform( [[str(binary.header.machine)]]).toarray(),
·文件属性
文件头的文件属性中包含大量重要信息,比如文件是否是可运行的状态,是否为DLL文件等。我们采用与机器码类似的处理办法,但是需要注意的是,文件头的文件属性是由多个标记位取与组成的,所以Python的lief库在处理的时候是以列表形式保存而不是像机器码那样使用WORD型保存,所以我们需要把文件属性转换成字符串列表后,再使用FeatureHasher转换成一个维度为10的向量,代码如下:
FeatureHasher(10, input_type="string", dtype=self.dtype).transform( [[str(c) for c in binary.header.characteristics_list]]).toarray()
图8-7 FeatureHasher工作过程
·该PE文件所需的子系统
PE文件所需的子系统与机器码处理方式相同,使用FeatureHasher转换成一个维度为10的向量:
FeatureHasher(10, input_type="string", dtype=self.dtype).transform( [[str(binary.optional_header.subsystem)]]).toarray()
·该PE文件所需的DLL文件的属性。
PE文件所需的DLL文件的属性与机器码处理方式相同,使用FeatureHasher转换成一个维度为10的向量:
FeatureHasher(10, input_type="string", dtype=self.dtype).transform( [[str(c) for c in binary.optional_header.dll_characteristics_lists]]).toarray()
·Magic
Magic与机器码处理方式相同,使用FeatureHasher转换成一个维度为10的向量:
FeatureHasher(10, input_type="string", dtype=self.dtype).transform( [[str(binary.optional_header.magic)]]).toarray()
·映像的版本号
使用其版本号作为两个维度:
[binary.optional_header.major_image_version], [binary.optional_header.minor_image_version]
·链接器的版本号
使用其版本号作为两个维度:
[binary.optional_header.major_linker_version], [binary.optional_header.minor_linker_version]
·所需子系统版本号
使用其版本号作为两个维度:
[binary.optional_header.major_subsystem_version], [binary.optional_header.minor_subsystem_version]
·所需操作系统的版本号
使用其版本号作为两个维度:
[binary.optional_header.major_operating_system_version], [binary.optional_header.minor_operating_system_version]
·代码段的长度
使用代码段的长度作为一个维度:
[binary.optional_header.sizeof_code]
·所有文件头的大小
使用所有文件头的大小作为一个维度:
[binary.optional_header.sizeof_headers]
6.导出表
导出表包含导出函数的入口信息,与文件属性处理方式相同,使用FeatureHasher转换成一个维度为128的向量:
FeatureHasher(128, input_type="string", dtype=self.dtype).transform([binary.exported_functions]).toarray().flatten().astype(self.dt)
7.导入表
导入表中保存的是函数名和其驻留DLL名等动态链接所需的信息,与导出表处理方式类似,我们分别将导入的库文件以及导入的函数使用FeatureHasher转换成维度为256和1024的向量:
libraries = [l.lower() for l in binary.libraries] imports = [lib.name.lower() + ':' + e.name for lib in binary.imports for e in lib.entries] return np.concatenate([ FeatureHasher(256, input_type="string", dtype=self.dtype).transform( [libraries]).toarray(), FeatureHasher(1024, input_type="string", dtype=self.dtype).transform( [imports]).toarray() ], axis=-1).flatten().astype(self.dtype)
其中,为了解决不同库具有同名函数的问题,我们把导入函数和对应的库使用冒号连接成新的字符串,形式类似如下字符串:
kernel32.dll:CreateFileMappingA