8.3 PE文件特征提取

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