恶意软件的数据科学-全-

恶意软件的数据科学(全)

原文:zh.annas-archive.org/md5/b29d9d44c0fc6e5e2e818e14b32b4b17

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

image

如果你从事安全工作,可能会发现你比以往更频繁地使用数据科学,即使你可能没有意识到这一点。例如,你的防病毒软件使用数据科学算法来检测恶意软件。你的防火墙供应商可能使用数据科学算法来检测可疑的网络活动。你的安全信息和事件管理(SIEM)软件可能也使用数据科学来识别数据中的可疑趋势。无论是否显而易见,整个安全行业正朝着将更多数据科学融入安全产品的方向发展。

高级 IT 安全专业人员正将自己的定制机器学习算法融入工作流程中。例如,在最近的会议演讲和新闻文章中,Target、Mastercard 和 Wells Fargo 的安全分析师都描述了开发定制数据科学技术,并将其作为安全工作流程的一部分。¹ 如果你还没有加入数据科学的行列,现在正是升级技能、将数据科学融入你的安全实践的最佳时机。

什么是数据科学?

数据科学 是一组不断发展的算法工具,允许我们通过统计学、数学和精美的统计数据可视化来理解数据并做出预测。虽然有更为具体的定义,但一般来说,数据科学包含三个子领域:机器学习、数据挖掘和数据可视化。

在安全领域,机器学习算法通过训练数据来学习检测新威胁。这些方法已被证明能有效检测那些传统检测技术(如签名)难以捕捉的恶意软件。数据挖掘算法会从安全数据中搜索有趣的模式(如威胁行为者之间的关系),这些模式可能帮助我们识别针对我们组织的攻击活动。最后,数据可视化将枯燥的表格数据转化为图形格式,使人们更容易发现有趣和可疑的趋势。本书将深入探讨这三个领域,并展示如何应用它们。

数据科学为何对安全至关重要

数据科学对网络安全的未来至关重要,原因有三:首先,安全是与数据密切相关的。当我们试图检测网络威胁时,我们分析的是文件、日志、网络包和其他数据形式。传统上,安全专业人员并没有使用数据科学技术基于这些数据源进行检测。相反,他们使用文件哈希、定制编写的规则(如签名)和手动定义的启发式方法。尽管这些技术各有优点,但它们需要为每种类型的攻击手工制作技术,导致需要大量人工工作,难以跟上不断变化的网络威胁格局。近年来,数据科学技术已成为增强我们检测威胁能力的关键。

其次,数据科学对网络安全的重要性在于,互联网的网络攻击数量已急剧增加。以恶意软件地下世界的增长为例,2008 年,安全社区已知的独特恶意软件可执行文件约为 100 万个。而到了 2012 年,这一数字增至 1 亿个。到 2018 年本书出版时,安全社区已知的恶意可执行文件超过 7 亿个(* www.av-test.org/en/statistics/malware/*),这一数字可能还会继续增长。

由于恶意软件的数量庞大,手动检测技术(如签名)已不再是检测所有网络攻击的合理方法。因为数据科学技术自动化了大量检测网络攻击的工作,并大大减少了检测此类攻击所需的内存,它们在防御网络和用户免受网络威胁的过程中具有巨大的潜力。

最后,数据科学对安全至关重要,因为它是本十年的技术趋势,无论是在安全行业内外,它都可能在下一个十年内继续占主导地位。事实上,您可能已经在各个领域看到了数据科学的应用——如个人语音助手(亚马逊 Echo、Siri 和 Google Home)、自动驾驶汽车、广告推荐系统、网络搜索引擎、医学图像分析系统以及健身追踪应用等。

我们可以预见,数据科学驱动的系统将在法律服务、教育等领域产生重大影响。由于数据科学已成为技术领域的关键推动力,大学、大型公司(如谷歌、Facebook、微软和 IBM)以及政府正在投资数十亿美元来改进数据科学工具。得益于这些投资,数据科学工具将变得更加熟练于解决复杂的攻击检测问题。

将数据科学应用于恶意软件

本书聚焦于数据科学在恶意软件领域的应用,我们将恶意软件定义为具有恶意意图编写的可执行程序,因为恶意软件仍然是威胁行为者在网络上获取立足点并随后实现其目标的主要手段。例如,在近年来出现的勒索病毒灾难中,攻击者通常会通过发送带有恶意附件的电子邮件,下载勒索病毒可执行文件(恶意软件)到用户的计算机上,这些恶意软件会加密用户的数据并要求用户支付赎金以解密数据。尽管为政府工作的一些技术高超的攻击者有时会避免使用恶意软件,以躲避检测系统的侦测,但恶意软件仍然是如今网络攻击中的主要技术手段。

本书专注于安全数据科学的一个具体应用,而不是广泛覆盖安全数据科学,旨在更深入地展示数据科学技术如何应用于重大安全问题。通过理解恶意软件数据科学,你将更好地将数据科学应用于安全的其他领域,如检测网络攻击、钓鱼邮件或可疑用户行为。实际上,本书中你将学到的几乎所有技术都适用于构建数据科学检测和情报系统,而不仅限于恶意软件。

谁应该阅读本书?

本书面向那些对如何将数据科学应用于计算机安全问题感兴趣的安全专业人士。如果计算机安全数据科学对你来说是全新的,你可能需要查阅一些术语来帮助自己理解背景,但你仍然可以成功阅读本书。如果你只对数据科学感兴趣,而不关心安全,本书可能不适合你。

关于本书

本书的第一部分由三章组成,涵盖了理解本书后续讨论的恶意软件数据科学技术所需的基础逆向工程概念。如果你是恶意软件领域的新手,建议先阅读前三章。如果你已经是恶意软件逆向工程的老手,可以跳过这些章节。

  • 第一章:基础静态恶意软件分析 涵盖了静态分析技术,帮助分析恶意软件文件并发现它们如何在计算机上实现恶意目的。

  • 第二章:超越基础静态分析:x86 反汇编 简要概述了 x86 汇编语言以及如何反汇编和逆向工程恶意软件。

  • 第三章:动态分析简要介绍 通过讨论动态分析来结束本书的逆向工程部分,动态分析涉及在受控环境中运行恶意软件,以了解其行为。

本书的接下来的两章,第四章和第五章,重点介绍恶意软件关系分析,涉及通过比较一系列恶意软件的相似性和差异性,识别针对你所在组织的恶意软件攻击活动,如由一群网络犯罪分子控制的勒索软件攻击,或是针对你组织的精心策划的定向攻击。这些独立的章节适合那些不仅对检测恶意软件感兴趣,还希望提取有价值的威胁情报,了解谁在攻击其网络的读者。如果你对威胁情报不太感兴趣,而更关注数据科学驱动的恶意软件检测,你可以跳过这些章节。

  • 第四章:使用恶意软件网络识别攻击活动 向你展示如何基于共享属性(如恶意软件程序联系的主机名)分析和可视化恶意软件。

  • 第五章:共享代码分析 解释了如何识别和可视化恶意软件样本之间的共享代码关系,这有助于你判断一组恶意软件样本是来自一个还是多个犯罪团伙。

接下来的四章涵盖了理解、应用和实现基于机器学习的恶意软件检测系统所需的所有知识。这些章节还为将机器学习应用于其他安全场景提供了基础。

  • 第六章:理解基于机器学习的恶意软件检测器 是对基本机器学习概念的易于理解、直观且非数学化的介绍。如果你有机器学习的背景,本章将为你提供一个方便的复习。

  • 第七章:评估恶意软件检测系统 向你展示了如何使用基本的统计方法评估机器学习系统的准确性,以便选择最佳的检测方法。

  • 第八章:构建机器学习检测器 介绍了你可以用来构建自己机器学习系统的开源工具,并解释了如何使用它们。

  • 第九章:可视化恶意软件趋势 讲解了如何使用 Python 可视化恶意软件威胁数据,以揭示攻击活动和趋势,以及如何将数据可视化集成到你日常分析安全数据的工作流中。

最后三章介绍了深度学习,这是机器学习的一个高级领域,涉及更多的数学知识。深度学习是安全数据科学中的一个热门发展领域,这些章节提供了足够的内容来帮助你入门。

  • 第十章:深度学习基础 介绍了深度学习的基本概念。

  • 第十一章:使用 Keras 构建神经网络恶意软件检测器 解释了如何使用 Python 和开源工具实现基于深度学习的恶意软件检测系统。

  • 第十二章:成为数据科学家 通过分享成为数据科学家的不同路径和成功所需的品质来总结本书。

  • 附录:数据集和工具概览 介绍了本书附带的数据和示例工具实现。

如何使用示例代码和数据

没有示例代码供你自己动手尝试和扩展的编程书籍是不完整的。本书的每一章都有配套的示例代码和数据,并在附录中进行了详细描述。所有代码都针对 Linux 环境中的 Python 2.7 进行开发。要访问代码和数据,你可以下载一个包含代码、数据和支持的开源工具都已设置好的 VirtualBox Linux 虚拟机,并在你自己的 VirtualBox 环境中运行它。你可以在www.malwaredatascience.com/下载本书配套的数据,并且你可以在www.virtualbox.org/wiki/Downloads免费下载 VirtualBox。代码已在 Linux 上进行了测试,但如果你更喜欢在 Linux 虚拟机之外的环境中工作,同样的代码应该在 MacOS 上几乎同样有效,在 Windows 机器上效果稍差一些。

如果你更愿意在自己的 Linux 环境中安装代码和数据,你可以在这里下载:www.malwaredatascience.com/。你会在可下载的压缩包中找到每一章的目录,并且在每一章的目录下都有code/data/文件夹,里面分别包含对应的代码和数据。代码文件与章节列表或某些小节相对应,具体取决于当前应用的需求。一些代码文件与列表完全相同,而另一些则略微修改过,以便你可以更容易地玩转参数和其他选项。代码目录中包含 pip 的requirements.txt文件,其中列出了本章代码运行所依赖的开源库。要在你的机器上安装这些库,只需在每一章的code/目录中输入 pip -r requirements.txt。

现在你已经可以访问本书的代码和数据了,接下来让我们开始吧。

第一章:基础静态恶意软件分析

image

本章我们将探讨静态恶意软件分析的基础。静态分析通过分析程序文件的反汇编代码、图像、可打印字符串和其他磁盘上的资源来进行。它指的是在不实际运行程序的情况下进行逆向工程。虽然静态分析技术有其局限性,但它能帮助我们理解各种恶意软件。通过细致的逆向工程,你将能够更好地理解恶意软件二进制文件在攻击者控制目标后所带来的好处,以及攻击者如何隐藏和持续攻击已感染的机器。正如你所看到的,本章结合了描述和示例。每一节都介绍一种静态分析技术,并通过实际分析中的应用来进行说明。

我将本章的开始部分用来描述大多数 Windows 程序使用的可移植执行文件(PE)格式,然后介绍如何使用流行的 Python 库 pefile 来解析一个真实的恶意软件二进制文件。接着我将描述如导入分析、图像分析和字符串分析等技术。在所有案例中,我都会展示如何使用开源工具将分析技术应用于实际的恶意软件。最后,在本章结束时,我会介绍恶意软件如何让恶意软件分析师感到困扰,并讨论一些缓解这些问题的方法。

你将在本章示例中使用的恶意软件样本,存储在本书数据目录下的 /ch1 文件夹中。为了演示本章讨论的技术,我们使用 ircbot.exe,这是一个用于实验目的的互联网中继聊天(IRC)机器人,作为常见恶意软件的示例。该程序的设计目的是在目标计算机上保持常驻,并与 IRC 服务器连接。ircbot.exe 控制了目标后,攻击者可以通过 IRC 控制目标计算机,执行诸如开启摄像头捕获并偷偷提取目标物理位置的视频流、截取桌面截图、从目标计算机中提取文件等操作。在本章中,我将展示如何通过静态分析技术揭示这些恶意软件的功能。

微软 Windows 可移植执行文件格式

要进行静态恶意软件分析,你需要了解 Windows PE 格式,该格式描述了现代 Windows 程序文件(如 .exe.dll.sys 文件)的结构,并定义了它们存储数据的方式。PE 文件包含 x86 指令、如图像和文本等数据,以及程序运行所需的元数据。

PE 格式最初是为了完成以下目的而设计的:

告诉 Windows 如何将程序加载到内存中 PE 格式描述了文件的哪些部分应该加载到内存中,以及它们的加载位置。它还告诉你 Windows 应该从程序代码的哪个位置开始执行程序,以及哪些动态链接代码库应该加载到内存中。

提供运行程序在执行过程中可能使用的媒体(或资源) 这些资源可以包括字符字符串,如图形用户界面对话框或控制台输出中的字符,也可以是图像或视频。

提供安全数据,如数字代码签名 Windows 使用这些安全数据来确保代码来自受信任的来源。

PE 格式通过利用图 1-1 中显示的一系列结构来实现这一切。

image

图 1-1:PE 文件格式

如图所示,PE 格式包括一系列头部,告诉操作系统如何将程序加载到内存中。它还包括一系列包含实际程序数据的节区。Windows 将这些节区加载到内存中,使得它们的内存偏移量对应于它们在磁盘上出现的位置。让我们更详细地探索这一文件结构,从 PE 头部开始。我们将跳过对 DOS 头部的讨论,因为它是 1980 年代微软 DOS 操作系统的遗物,仅仅为了兼容性而存在。

PE 头部

如图 1-1 所示,在 DOS 头部 ➊ 之上,是 PE 头部 ➋,它定义了程序的一般属性,如二进制代码、图像、压缩数据和其他程序属性。它还告诉我们程序是为 32 位还是 64 位系统设计的。PE 头部为恶意软件分析师提供了基本但有用的上下文信息。例如,头部包含一个时间戳字段,可以揭示恶意软件作者编译文件的时间。当恶意软件作者忘记将此字段替换为虚假值时,就会出现这种情况,而他们经常这样做。

可选头部

可选头部 ➌ 实际上在今天的 PE 可执行程序中无处不在,尽管它的名字可能给人带来误解。它定义了程序在 PE 文件中的入口点的位置,指的是程序加载后首次运行的指令。它还定义了 Windows 加载 PE 文件时将加载到内存中的数据大小、Windows 子系统、程序目标(如 Windows GUI 或 Windows 命令行)以及程序的其他高级细节。此头部中的信息对逆向工程师来说至关重要,因为程序的入口点告诉他们从哪里开始逆向工程。

节区头部

节头 ➍ 描述了 PE 文件中包含的数据节。PE 文件中的是一个数据块,它要么在操作系统加载程序时被映射到内存中,要么包含有关如何将程序加载到内存中的指令。换句话说,节是磁盘上的字节序列,这些字节序列要么会变成内存中的连续字节串,要么会向操作系统报告加载过程中的某些方面。

节头还告诉 Windows 应该授予各个节哪些权限,例如它们是否在程序执行时应当是可读、可写或可执行的。例如,包含 x86 代码的.text节通常会被标记为可读和可执行,但不可写,以防程序代码在执行过程中意外修改自身。

一些节,如 .text.rsrc,如图 1-1 所示。这些节在执行 PE 文件时会被映射到内存中。其他特殊节,如 .reloc 节,则不会被映射到内存中。我们也会讨论这些节。让我们一起回顾一下图 1-1 中显示的各个节。

.text 节

每个 PE 程序至少包含一个标记为可执行的 x86 代码节,这些节几乎总是命名为 .text ➎。我们将在第二章进行程序反汇编和逆向工程时,反汇编 .text 节中的数据。

.idata 节

.idata节 ➏,也叫导入节,包含导入地址表(IAT),该表列出了动态链接的库及其函数。IAT 是在初次分析 PE 二进制文件时最重要的结构之一,因为它揭示了程序所调用的库函数,这反过来可能揭示恶意软件的高级功能。

数据节

PE 文件中的数据节可以包括 .rsrc.data.rdata 等节,它们存储程序使用的项目,如鼠标光标图像、按钮皮肤、音频和其他媒体。例如,图 1-1 中的 .rsrc 节 ➐ 包含程序用来渲染文本字符串的可打印字符字符串。

.rsrc(资源)节中的信息对恶意软件分析师来说至关重要,因为通过检查 PE 文件中的可打印字符字符串、图像和其他资源,他们可以获得有关文件功能的重要线索。在第 7 页的《检查恶意软件图像》中,您将学习如何使用 icoutils 工具包(包括 icotoolwrestool)从恶意软件二进制文件的资源节中提取图像。然后,在第 8 页的《检查恶意软件字符串》中,您将学习如何从恶意软件的资源节中提取可打印字符串。

.reloc 节

一个 PE 二进制文件的代码不是 位置无关的,这意味着如果它从预定的内存位置移动到新的内存位置,它将无法正确执行。.reloc 节通过允许代码移动而不破坏其功能来解决这个问题。它告诉 Windows 操作系统,在 PE 文件的代码被移动后,翻译内存地址,以确保代码仍能正确运行。这些翻译通常涉及对内存地址添加或减去偏移量。

尽管 PE 文件的 .reloc 节可能包含你在恶意软件分析中需要使用的信息,但本书不会进一步讨论它,因为我们的重点是将机器学习和数据分析应用于恶意软件,而不是进行涉及查看重定位的硬核逆向工程。

使用 pefile 剖析 PE 格式

由 Ero Carerra 编写和维护的 pefile Python 模块,已成为业界标准的恶意软件分析库,用于剖析 PE 文件。在本节中,我将向你展示如何使用 pefile 来剖析 ircbot.exeircbot.exe 文件可以在本书随附的虚拟机中找到,路径为 ~/malware_data_science/ch1/data。列表 1-1 假设 ircbot.exe 位于你当前的工作目录中。

输入以下命令来安装 pefile 库,以便我们可以在 Python 中导入它:

$ pip install pefile

现在,使用 列表 1-1 中的命令启动 Python,导入 pefile 模块,并使用 pefile 打开并解析 PE 文件 ircbot.exe

$ python
>>> import pefile
>>> pe = pefile.PE("ircbot.exe")

列表 1-1:加载 pefile 模块并解析 PE 文件(ircbot.exe)

我们实例化了 pefile.PE,这是 PE 模块实现的核心类。它解析 PE 文件,以便我们检查其属性。通过调用 PE 构造函数,我们加载并解析指定的 PE 文件,本示例中是 ircbot.exe。现在我们已经加载并解析了文件,运行 列表 1-2 中的代码,提取 ircbot.exe 的 PE 字段信息。

# based on Ero Carrera's example code (pefile library author)
for section in pe.sections:
  print (section.Name, hex(section.VirtualAddress),
    hex(section.Misc_VirtualSize), section.SizeOfRawData )

列表 1-2:遍历 PE 文件的各个节并打印相关信息

列表 1-3 显示了输出结果。

('.text\x00\x00\x00', ➊'0x1000', ➋'0x32830', ➌207360)
('.rdata\x00\x00', '0x34000', '0x427a', 17408)
('.data\x00\x00\x00', '0x39000', '0x5cff8', 10752)
('.idata\x00\x00', '0x96000', '0xbb0', 3072)
('.reloc\x00\x00', '0x97000', '0x211d', 8704)

列表 1-3:使用 Python 的 pefile 模块提取 ircbot.exe 的节数据

如 列表 1-3 中所示,我们从 PE 文件的五个不同节中提取了数据:.text.rdata.data.idata.reloc。输出结果显示为五个元组,每个元组对应一个提取的 PE 节。每行的第一个条目标识了 PE 节(你可以忽略 \x00 一系列的空字节,它们只是 C 风格的空字符串终结符)。剩余的字段告诉我们每个节加载到内存后将占用的内存量以及加载后在内存中的位置。

例如,0x1000 ➊ 是这些部分将被加载的基础虚拟内存地址。可以将其视为该部分的基础内存地址。0x32830 ➋ 在虚拟大小字段中指定了该部分加载后所需的内存量。207360 ➌ 在第三个字段中表示该部分将在该内存块中占用的数据量。

除了使用pefile解析程序的各个部分外,我们还可以使用它列出二进制文件将加载的 DLL,以及它将在这些 DLL 中请求的函数调用。我们可以通过转储 PE 文件的 IAT 来实现这一点。列表 1-4 展示了如何使用pefile转储ircbot.exe的 IAT。

$ python
pe = pefile.PE("ircbot.exe")
for entry in pe.DIRECTORY_ENTRY_IMPORT:
    print entry.dll
    for function in entry.imports:
        print '\t',function.name

列表 1-4:提取 ircbot.exe 中的导入

列表 1-4 应生成列表 1-5 所示的输出(为简洁起见已被截断)。

KERNEL32.DLL
      GetLocalTime
      ExitThread
      CloseHandle
    ➊ WriteFile
    ➋ CreateFileA
      ExitProcess
    ➌ CreateProcessA
      GetTickCount
      GetModuleFileNameA
--snip--

列表 1-5:ircbot.exe 的 IAT 内容,显示了此恶意软件使用的库函数

正如列表 1-5 所示,这些输出对于恶意软件分析非常有价值,因为它列出了恶意软件声明并将引用的丰富函数。例如,输出的前几行告诉我们,恶意软件将使用WriteFile ➊ 写入文件,使用CreateFileA 调用 ➋ 打开文件,并使用CreateProcessA ➌ 创建新进程。虽然这只是关于恶意软件的一些基本信息,但它为我们更详细地了解恶意软件的行为提供了一个起点。

检查恶意软件图像

为了理解恶意软件如何被设计来攻击目标,我们来看一下它在 .rsrc 部分中包含的图标。例如,恶意软件二进制文件常常伪装成 Word 文档、游戏安装程序、PDF 文件等,以欺骗用户点击它们。你还会在恶意软件中发现一些图像,表明这些程序可能是攻击者感兴趣的工具,如网络攻击工具和用于远程控制被攻击机器的程序。我甚至见过一些二进制文件包含圣战分子的桌面图标、看起来邪恶的赛博朋克卡通角色图像和卡拉什尼科夫步枪的图像。为了进行样本图像分析,我们可以考虑一个由安全公司 Mandiant 识别的恶意软件样本,它被认为是由一个中国国家支持的黑客组织制作的。你可以在本章的数据目录中找到这个恶意软件样本,文件名为fakepdfmalware.exe。这个样本使用 Adobe Acrobat 图标来欺骗用户,使其误以为这是一个 Adobe Acrobat 文档,而实际上它是一个恶意的 PE 可执行文件。

在使用 Linux 命令行工具wrestoolfakepdfmalware.exe二进制文件中提取图像之前,我们首先需要创建一个目录来存放我们提取的图像。列表 1-6 展示了如何完成这一操作。

$ mkdir images
$ wrestool –x fakepdfmalware.exe –output=images
$ icotool –x –o images images/*.ico

列表 1-6:从恶意软件样本中提取图像的 shell 命令

我们首先使用mkdir images命令创建一个目录,用来存放提取出的图像。接下来,我们使用wrestoolfakepdfmalware.exe中提取图像资源(-x),保存到/images目录中,然后使用icotool提取(-x)并转换(-o)任何 Adobe .ico图标格式的资源为.png图像,这样我们就可以使用标准的图像查看工具查看它们。如果你的系统中没有安装wrestool,你可以在www.nongnu.org/icoutils/下载它。

一旦你使用wrestool将目标可执行文件中的图像转换为 PNG 格式,你应该能够在你喜欢的图像查看器中打开它们,并查看不同分辨率下的 Adobe Acrobat 图标。正如我这里的示例所演示的那样,从 PE 文件中提取图像和图标相对简单,可以迅速揭示有关恶意软件二进制文件的有趣和有用的信息。同样,我们也可以轻松地从恶意软件中提取可打印字符串以获取更多信息,接下来我们就会这样做。

检查恶意软件字符串

字符串是程序二进制文件中可打印字符的序列。恶意软件分析师通常依赖恶意样本中的字符串来快速了解其中可能发生的事情。这些字符串通常包含 HTTP 和 FTP 命令,用于下载网页和文件,IP 地址和主机名,指示恶意软件连接的地址等等。有时,甚至用于编写字符串的语言也能提示恶意二进制文件的来源国,尽管这可以被伪造。你甚至可能会在某些字符串中找到用“黑话”解释恶意二进制文件目的的文本。

字符串还可以揭示关于二进制文件的更多技术信息。例如,你可能会找到关于用来创建该文件的编译器的信息,二进制文件使用的编程语言,嵌入的脚本或 HTML 等。尽管恶意软件作者可以对这些痕迹进行混淆、加密或压缩,但即使是高级恶意软件作者通常也会留下至少一些暴露的痕迹,这使得在分析恶意软件时检查strings转储尤为重要。

使用 strings 程序

查看文件中所有字符串的标准方法是使用命令行工具strings,其语法如下:

$ strings filepath | less

该命令将文件中的所有字符串逐行打印到终端。添加| less到命令末尾可以防止字符串直接在终端中滚动显示。默认情况下,strings命令会查找所有长度最短为 4 字节的可打印字符串,但你可以设置不同的最小长度并更改其他参数,具体内容可以参考命令手册页。我建议直接使用默认的最小字符串长度 4,但你也可以使用–n选项来更改最小字符串长度。例如,strings –n 10 filepath 将只提取长度最短为 10 字节的字符串。

分析你的字符串转储

现在我们已经提取了恶意软件程序的可打印字符串,挑战是理解这些字符串的含义。例如,假设我们将ircbot.exe的字符串转储到ircbotstring.txt文件中,正如我们在本章前面使用pefile库探索ircbot.exe时所做的那样,操作如下:

$ strings ircbot.exe > ircbotstring.txt

ircbotstring.txt的内容包含数千行文本,但其中一些行应该特别引人注意。例如,列表 1-7 显示了一堆从字符串转储中提取出来的行,这些行以DOWNLOAD这个词开头。

[DOWNLOAD]: Bad URL, or DNS Error: %s.
[DOWNLOAD]: Update failed: Error executing file: %s.
[DOWNLOAD]: Downloaded %.1fKB to %s @ %.1fKB/sec. Updating.
[DOWNLOAD]: Opened: %s.
--snip--
[DOWNLOAD]: Downloaded %.1f KB to %s @ %.1f KB/sec.
[DOWNLOAD]: CRC Failed (%d != %d).
[DOWNLOAD]: Filesize is incorrect: (%d != %d).
[DOWNLOAD]: Update: %s (%dKB transferred).
[DOWNLOAD]: File download: %s (%dKB transferred).
[DOWNLOAD]: Couldn't open file: %s.

列表 1-7: strings 输出,显示证据表明恶意软件可以将攻击者指定的文件下载到目标机器上

这些行表示ircbot.exe将尝试将攻击者指定的文件下载到目标机器上。

让我们尝试分析另一个例子。列表 1-8 中的字符串转储显示ircbot.exe可以充当一个 Web 服务器,在目标机器上监听来自攻击者的连接。

➊ GET
➋ HTTP/1.0 200 OK
   Server: myBot
   Cache-Control: no-cache,no-store,max-age=0
   pragma: no-cache
   Content-Type: %s
   Content-Length: %i
   Accept-Ranges: bytes
   Date: %s %s GMT
   Last-Modified: %s %s GMT
   Expires: %s %s GMT
   Connection: close
   HTTP/1.0 200 OK
➌ Server: myBot
   Cache-Control: no-cache,no-store,max-age=0
   pragma: no-cache
   Content-Type: %s
   Accept-Ranges: bytes
   Date: %s %s GMT
   Last-Modified: %s %s GMT
   Expires: %s %s GMT
   Connection: close
   HH:mm:ss
   ddd, dd MMM yyyy
   application/octet-stream
   text/html

列表 1-8: strings 输出,显示恶意软件有一个 HTTP 服务器,攻击者可以连接到该服务器

列表 1-8 展示了多种由ircbot.exe用来实现 HTTP 服务器的 HTTP 样板代码。很可能,这个 HTTP 服务器允许攻击者通过 HTTP 连接到目标机器,以发出命令,例如截取受害者桌面截图并将其发送回攻击者。我们可以在整个列表中看到 HTTP 功能的证据。例如,GET方法 ➊ 请求从网络资源获取数据。HTTP/1.0 200 OK ➋ 是一个 HTTP 字符串,返回状态码200,表示 HTTP 网络交易正常完成,而Server: myBot ➌ 表明 HTTP 服务器的名称是myBot,这表明ircbot.exe内置了一个 HTTP 服务器。

所有这些信息对于理解和阻止特定恶意软件样本或恶意活动都非常有用。例如,知道某个恶意软件样本拥有一个 HTTP 服务器,当你连接到它时,它会输出某些字符串,这使你能够扫描网络并识别受感染的主机。

总结

本章中,你对静态恶意软件分析有了一个高层次的概述,这种方法涉及在不实际运行恶意软件的情况下对其进行检查。你了解了定义 Windows .exe.dll文件的 PE 文件格式,并且学习了如何使用 Python 库pefile来剖析真实世界的恶意软件ircbot.exe二进制文件。你还使用了静态分析技术,如图像分析和字符串分析,从恶意软件样本中提取更多信息。第二章继续讨论静态恶意软件分析,重点分析可以从恶意软件中恢复的汇编代码。

第二章:超越基础静态分析:X86 反汇编

image

为了彻底理解一个恶意程序,我们常常需要超越对其各个部分、字符串、导入项和图像的基础静态分析。这涉及到对程序汇编代码的逆向工程。事实上,反汇编和逆向工程是深入静态分析恶意软件样本的核心所在。

由于逆向工程既是一种艺术,也是一项技术工艺和科学,彻底的探索超出了本章的范围。我的目标是向你介绍逆向工程,以便你可以将其应用于恶意软件数据科学。理解这一方法论对于成功将机器学习和数据分析应用于恶意软件至关重要。

在本章中,我将从你需要理解 x86 反汇编的概念开始。接下来,我会展示恶意软件作者如何尝试绕过反汇编,并讨论如何减轻这些反分析和反检测手段的影响。但首先,让我们回顾一些常见的反汇编方法以及 x86 汇编语言的基础知识。

反汇编方法

反汇编是将恶意软件的二进制代码转换为有效的 x86 汇编语言的过程。恶意软件作者通常使用像 C 或 C++ 这样的高级语言编写恶意程序,然后使用编译器将源代码编译成 x86 二进制代码。汇编语言是这种二进制代码的人类可读表示。因此,将恶意程序反汇编成汇编语言是理解其核心行为的必要步骤。

不幸的是,反汇编并不是一件简单的事,因为恶意软件作者经常使用各种技巧来阻挠逆向工程师。事实上,在故意混淆的情况下,完美的反汇编仍然是计算机科学中的一个未解之谜。目前,针对这类程序的反汇编方法仅限于近似的、易出错的手段。

例如,考虑自我修改代码的情况,即在执行过程中修改自身的二进制代码。正确地反汇编这种代码的唯一方法是理解代码修改自身的程序逻辑,但这可能会异常复杂。

由于完美的反汇编目前无法实现,我们必须使用不完美的方法来完成这项任务。我们将使用的方法是 线性反汇编,它涉及识别可移植执行文件(PE 文件)中与其 x86 程序代码对应的连续字节序列,然后对这些字节进行解码。这种方法的关键局限性在于,它忽视了在程序执行过程中 CPU 如何解码指令的细微差别。此外,它也没有考虑恶意软件作者有时使用的各种混淆技术,这些技术使得程序更难以分析。

其他逆向工程方法(我们在这里不讨论)是工业级反汇编器(如 IDA Pro)使用的更复杂的反汇编方法。这些更先进的方法实际上模拟或推理程序执行,以发现程序可能由于一系列条件分支而达到的汇编指令。

尽管这种反汇编方法比线性反汇编方法更准确,但它比线性反汇编方法更加占用 CPU 资源,因此在数据科学应用中不太适用,因为数据科学的重点是反汇编成千上万甚至百万个程序。

然而,在开始使用线性反汇编进行分析之前,您需要先回顾汇编语言的基本组成部分。

x86 汇编语言基础

汇编语言是针对特定架构的最低级别人类可读编程语言,它与特定 CPU 架构的二进制指令格式紧密对应。每一行汇编语言几乎总是等同于一条 CPU 指令。由于汇编语言的低级性质,通常可以通过使用正确的工具轻松从恶意软件的二进制文件中提取出它。

获得基本的反汇编恶意软件 x86 代码阅读能力比你想象的要容易。这是因为大多数恶意软件汇编代码大多数时间通过 Windows 操作系统的动态链接库(DLLs)调用操作系统,而 DLL 会在运行时加载到程序内存中。恶意软件程序使用 DLL 完成大部分实际工作,例如修改系统注册表、移动和复制文件、建立网络连接以及通过网络协议进行通信等。因此,跟踪恶意软件汇编代码通常需要理解从汇编语言中如何进行函数调用,并理解各种 DLL 调用的作用。当然,事情可能变得更加复杂,但知道这些基本情况可以揭示恶意软件的很多信息。

在接下来的章节中,我将介绍一些重要的汇编语言概念。我还将解释一些抽象的概念,如控制流和控制流图。最后,我们将反汇编ircbot.exe程序,并探索其汇编和控制流如何为我们提供关于其目的的洞察。

x86 汇编语言有两种主要的方言:Intel 和 AT&T。在本书中,我使用的是 Intel 语法,它可以从所有主要的反汇编器中获得,并且是 x86 CPU 官方文档中使用的语法。

让我们从查看 CPU 寄存器开始。

CPU 寄存器

寄存器是 x86 CPU 上执行计算的小型数据存储单元。由于寄存器位于 CPU 内部,因此寄存器访问比内存访问快得多。这也是为什么核心计算操作,如算术运算和条件测试指令,都针对寄存器的原因。寄存器也是 CPU 用来存储关于正在运行的程序状态的信息的地方。尽管许多寄存器对有经验的 x86 汇编程序员来说是可用的,但我们这里只关注其中几个重要的寄存器。

通用寄存器

通用寄存器对于汇编程序员来说就像是临时存储空间。在 32 位系统上,每个寄存器都包含 32 位、16 位或 8 位的空间,我们可以在其上执行算术运算、按位运算、字节顺序交换等操作。

在常见的计算工作流程中,程序将数据从内存或外部硬件设备加载到寄存器中,对这些数据进行某些操作,然后将数据返回内存进行存储。例如,排序一个长列表时,程序通常会从内存中的数组中提取列表项,在寄存器中进行比较,然后将比较结果写回内存。

要理解 Intel 32 位架构中通用寄存器模型的一些细节,看看图 2-1。

image

图 2-1:x86 架构中的寄存器

垂直轴显示了通用寄存器的布局,水平轴显示了 EAX、EBX、ECX 和 EDX 的细分情况。EAX、EBX、ECX 和 EDX 是 32 位寄存器,它们内部包含更小的 16 位寄存器:AX、BX、CX 和 DX。正如图中所示,这些 16 位寄存器可以进一步细分为上下 8 位寄存器:AH、AL、BH、BL、CH、CL、DH 和 DL。尽管有时在 EAX、EBX、ECX 和 EDX 中的细分非常有用,但你通常会看到直接引用 EAX、EBX、ECX 和 EDX。

栈和控制流寄存器

栈管理寄存器存储着关于程序栈的关键信息,程序栈负责存储函数的局部变量、传递给函数的参数以及与程序控制流相关的控制信息。我们来逐一了解一些这些寄存器。

简单来说,ESP 寄存器指向当前执行函数的栈顶,而 EBP 寄存器指向当前执行函数的栈底。这对于现代程序至关重要,因为这意味着通过引用相对于栈的数据,而不是使用它的绝对地址,过程式和面向对象代码可以更优雅、高效地访问局部变量。

尽管在 x86 汇编代码中你不会看到对 EIP 寄存器的直接引用,但它在安全分析中非常重要,尤其是在漏洞研究和缓冲区溢出利用开发的上下文中。这是因为 EIP 包含当前执行指令的内存地址。攻击者可以利用缓冲区溢出漏洞间接破坏 EIP 寄存器的值,并控制程序的执行。

除了在利用中的作用外,EIP 在恶意代码分析中也非常重要。使用调试器,我们可以在任何时候检查 EIP 的值,这帮助我们理解恶意软件在特定时间执行的代码。

EFLAGS 是一个状态寄存器,包含 CPU 标志,这些标志是存储当前执行程序状态信息的位。EFLAGS 寄存器在执行 x86 程序中的 条件跳转 中起着至关重要的作用,条件跳转是根据 if/then 风格的程序逻辑的结果改变执行流的过程。具体来说,每当 x86 汇编程序检查某个值是否大于或小于零,然后根据此测试的结果跳转到一个函数时,EFLAGS 寄存器就发挥了作用,详细描述见 “基本块与控制流图” 第 19 页。

算术指令

指令 作用于通用寄存器。你可以通过算术指令对通用寄存器进行简单计算。例如,addsubincdecmul 都是你在恶意软件逆向工程中常见的算术指令。 表 2-1 列出了基本指令及其语法的一些示例。

表 2-1: 算术指令

指令 描述
add ebx, 100 将 100 加到 EBX 中的值,并将结果存储回 EBX
sub ebx, 100 从 EBX 中的值减去 100,并将结果存储回 EBX
inc ah 将 AH 中的值加 1
dec al 将 AL 中的值减 1

add 指令将两个整数相加,并将结果存储在第一个操作数指定的位置,无论该位置是内存位置还是寄存器,具体语法如下。请记住,只有一个参数可以是内存位置。sub 指令与 add 类似,只是它进行整数的减法运算。inc 指令将寄存器或内存位置的整数值递增,而 dec 指令则递减寄存器或内存位置的整数值。

数据移动指令

x86 处理器提供了一组强大的指令,用于在寄存器和内存之间移动数据。这些指令提供了允许我们操作数据的基础机制。基础的内存移动指令是 mov 指令。表 2-2 展示了如何使用 mov 指令来移动数据。

表 2-2: 数据移动指令

指令 描述
mov ebx,eax 将 EAX 寄存器中的值移动到 EBX 寄存器
mov eax, [0x12345678] 将内存地址 0x12345678 处的数据移动到 EAX 寄存器
mov edx, 1 将值 1 移动到 EDX 寄存器
mov [0x12345678], eax 将 EAX 中的值移动到内存位置 0x12345678

mov 指令相关,lea 指令将指定的绝对内存地址加载到用于获取指针的寄存器中。例如,lea edx, [esp-4] 从 ESP 中的值减去 4,并将结果值加载到 EDX 中。

堆栈指令

在 x86 汇编中,堆栈 是一种数据结构,允许你将值推入堆栈或从堆栈中弹出。这类似于你如何在一堆盘子上添加或移除盘子。

由于在 x86 汇编中,控制流通常通过 C 风格的函数调用来表示,而且这些函数调用使用堆栈来传递参数、分配局部变量并记住函数执行完毕后返回程序的哪一部分,因此堆栈和控制流需要一起理解。

push 指令在程序员希望将寄存器值保存到堆栈时,将值推入程序堆栈,而 pop 指令则从堆栈中删除值并将其放入指定的寄存器。

push 指令使用以下语法执行操作:

push 1

在这个例子中,程序将堆栈指针(寄存器 ESP)指向一个新的内存地址,从而为值(1)腾出空间,当前该值被存储在堆栈顶部的位置。然后,它将参数中的值复制到 CPU 刚刚为堆栈顶部位置腾出的内存位置。

让我们与 pop 做对比:

pop eax

程序使用 pop 从堆栈中弹出顶部值,并将其移动到指定的寄存器。在这个例子中,pop eax 将堆栈顶部的值弹出,并将其移动到 eax 中。

关于 x86 程序堆栈,一个不直观但很重要的细节是,它在内存中向下增长,因此堆栈中的最高值实际上存储在堆栈内存中的最低地址。当你分析引用堆栈中存储数据的汇编代码时,这一点非常重要,因为如果不了解堆栈的内存布局,可能会很容易产生混淆。

由于 x86 栈在内存中是向下生长的,当 push 指令为新值在程序栈上分配空间时,它会减少 ESP 的值,使其指向内存中较低的位置,然后将目标寄存器中的值复制到该内存位置,从栈顶地址开始向上增长。相反,pop 指令实际上会将栈顶的值弹出,然后增加 ESP 的值,使其指向较高的内存位置。

控制流指令

一个 x86 程序的 控制流 定义了程序可能执行的指令序列网络,取决于程序可能接收到的数据、设备交互以及其他输入。控制流指令定义了程序的控制流。它们比栈指令复杂,但仍然非常直观。因为控制流通常通过 C 风格的函数调用在 x86 汇编中表达,栈和控制流是紧密相关的。它们也有关联,因为这些函数调用使用栈来传递参数、分配局部变量,并记住函数执行完毕后程序应返回到哪个部分。

callret 控制流指令是 x86 汇编中调用函数和返回函数后最重要的指令。

call 指令用于调用函数。可以将其理解为在 C 语言等高级语言中编写的一个函数,目的是让程序在 call 指令被调用且函数执行完毕后返回到 call 指令之后的指令。你可以使用以下语法来调用 call 指令,其中地址表示函数代码开始的内存位置:

call address

call 指令完成了两件事情。首先,它将函数调用返回后将要执行的指令地址压入栈顶,这样程序就知道在被调用函数执行完毕后应该返回到哪个地址。其次,call 用地址操作数指定的值替换当前的 EIP 值。然后,CPU 开始在 EIP 指向的新内存位置执行指令。

就像 call 发起一个函数调用一样,ret 指令完成它。你可以单独使用 ret 指令,而不需要任何参数,如下所示:

ret

当被调用时,ret 指令从栈顶弹出一个值,我们预计这个值是 call 指令在调用时压入栈中的程序计数器值(EIP)。然后,它将弹出的程序计数器值放回 EIP 中,并恢复执行。

jmp指令是另一种重要的控制流结构,其操作比call指令简单。jmp并不需要关心保存 EIP,简单地告诉 CPU 跳转到指定的内存地址并从那里开始执行。例如,jmp 0x12345678告诉 CPU 在下一条指令时,从内存地址 0x12345678 开始执行程序代码。

你可能会想知道如何使jmpcall指令以条件方式执行,例如“如果程序收到了网络数据包,则执行以下函数。”答案是,x86 汇编语言没有像 if、then、else、else if 这样的高级构造。相反,程序跳转到某个地址通常需要两条指令:一条cmp指令,用于检查某个寄存器中的值与测试值的关系,并将测试结果存储在 EFLAGS 寄存器中;另一条是条件分支指令。

大多数条件分支指令以j开头,允许程序跳转到某个内存地址,且后面附加了表示测试条件的字母。例如,jge表示如果大于或等于时跳转。这意味着,测试的寄存器中的值必须大于或等于测试值。

cmp指令使用以下语法:

cmp register, memory location, or literal, register, memory location, or
literal

如前所述,cmp将指定通用寄存器中的值与目标值进行比较,然后将比较结果存储在 EFLAGS 寄存器中。

各种条件jmp指令的调用方式如下:

j* address

如你所见,我们可以在任何条件测试指令前加上* j*。例如,要在测试值大于或等于寄存器中的值时跳转,可以使用以下指令:

jge address

注意,与callret指令不同,jmp指令系列永远不会涉及程序栈。事实上,在jmp系列指令的情况下,x86 程序负责跟踪自己的执行流,并可能保存或删除关于它已访问过哪些地址的信息,以及在执行完某些指令序列后应该返回到哪里。

基本块与控制流图

尽管当我们在文本编辑器中滚动查看 x86 程序时,它们看起来是顺序执行的,但实际上它们包含循环、条件分支和无条件分支(控制流)。所有这些使得每个 x86 程序都有一个网络结构。让我们用示例 2-1 中的简单汇编程序来看看这如何工作。

   setup: # symbol standing in for address of instruction on the next line
➊ mov eax, 10
   loopstart: # symbol standing in for address of the instruction on the next
   line
➋ sub eax, 1
➌ cmp 0, eax
   jne $loopstart
   loopend: # symbol standing in for address of the instruction on the next
   line
   mov eax, 1
   # more code would go here

示例 2-1:用于理解控制流图的汇编程序

如你所见,程序首先将计数器初始化为值 10,存储在寄存器 EAX 中➊。接下来,它执行一个循环,在每次迭代时将 EAX 中的值递减 1➋。最后,当 EAX 的值降到 0➌时,程序跳出循环。

在控制流图分析的语言中,我们可以认为这些指令由三个基本块组成。一个 基本块 是一个指令序列,我们知道它们总是会连续执行。换句话说,基本块总是以分支指令或作为分支目标的指令结束,并且总是以程序的第一个指令(称为程序的 入口点)或分支目标开始。

在列表 2-1 中,你可以看到我们简单程序的基本块的开始和结束。第一个基本块由 setup: 下的指令 mov eax, 10 组成。第二个基本块由 loopstart: 下从 sub eax, 1jne $loopstart 的行组成,第三个基本块从 loopend: 下的 mov eax, 1 开始。我们可以通过图 2-2 中的图形来可视化基本块之间的关系。(我们使用术语 网络 同义,在计算机科学中这两个术语可以互换使用。)

image

图 2-2:我们简单汇编程序的控制流图的可视化

如果一个基本块可以流向另一个基本块,我们就将它们连接起来,如图 2-2 所示。图中显示,setup 基本块通向 loopstart 基本块,后者会重复 10 次,然后过渡到 loopend 基本块。现实世界中的程序有这样的控制流图,但它们要复杂得多,包含成千上万个基本块和成千上万个连接。

使用 pefile 和 capstone 反汇编 ircbot.exe

现在你已经对汇编语言的基础有了很好的理解,让我们使用线性反汇编法来反汇编 ircbot.exe 的前 100 字节汇编代码。为此,我们将使用开源 Python 库 pefile(在第一章中介绍)和 capstone,这是一个开源反汇编库,能够反汇编 32 位 x86 二进制代码。你可以使用以下命令通过 pip 安装这两个库:

pip install pefile
pip install capstone

一旦安装了这两个库,我们就可以利用它们使用列表 2-2 中的代码来反汇编 ircbot.exe

#!/usr/bin/python
import pefile
from capstone import *

# load the target PE file
pe = pefile.PE("ircbot.exe")

# get the address of the program entry point from the program header
entrypoint = pe.OPTIONAL_HEADER.AddressOfEntryPoint

# compute memory address where the entry code will be loaded into memory
entrypoint_address = entrypoint+pe.OPTIONAL_HEADER.ImageBase

# get the binary code from the PE file object
binary_code = pe.get_memory_mapped_image()[entrypoint:entrypoint+100]

# initialize disassembler to disassemble 32 bit x86 binary code
disassembler = Cs(CS_ARCH_X86, CS_MODE_32)

# disassemble the code
for instruction in disassembler.disasm(binary_code, entrypoint_address):
    print "%s\t%s" %(instruction.mnemonic, instruction.op_str)

列表 2-2:反汇编 ircbot.exe

这将产生以下输出:

➊ push    ebp
   mov     ebp, esp
   push    -1
   push    0x437588
   push    0x41982c
➋ mov     eax, dword ptr fs:[0]
   push    eax
   mov     dword ptr fs:[0], esp
➌ add     esp, -0x5c
   push    ebx
   push    esi
   push    edi
   mov     dword ptr [ebp - 0x18], esp
➍ call    dword ptr [0x496308]
   --snip--

不必担心理解反汇编输出中的所有指令:这需要对汇编语言有更深入的理解,超出了本书的范围。然而,你应该对输出中的许多指令感到熟悉,并对它们的作用有一些基本的了解。例如,恶意软件将寄存器 EBP 中的值压入栈中 ➊,保存其值。接着,它将 ESP 中的值移动到 EBP 并将一些数值压入栈中。程序将内存中的一些数据移动到 EAX 寄存器中 ➋,并将值-0x5c 加到 ESP 寄存器中的值上 ➌。最后,程序使用call指令调用存储在内存地址 0x496308 的函数 ➍。

因为这不是一本关于逆向工程的书,所以我不会在这里深入讲解代码的含义。我展示的内容是理解汇编语言运作的起点。如果你想了解更多关于汇编语言的信息,我推荐查看英特尔的程序员手册,链接为 www.intel.com/content/www/us/en/processors/architectures-software-developer-manuals.html

限制静态分析的因素

在本章以及第一章中,你学习了多种静态分析技术,可以用来阐明新发现的恶意二进制文件的目的和方法。不幸的是,静态分析存在一些局限性,使得在某些情况下其效果不佳。例如,恶意软件作者可以采用某些攻击策略,这些策略比防御它们要容易得多。让我们来看看这些攻击策略,并看看如何防御它们。

打包

恶意软件的打包是恶意软件作者通过压缩、加密或其他方式对其恶意程序的主要部分进行处理,使其看起来对恶意软件分析师难以理解。当恶意软件运行时,它会自解包并开始执行。绕过恶意软件打包的显而易见方法是将恶意软件实际运行在一个安全环境中,这是我将在第三章中介绍的动态分析技术。

注意

软件打包也被无害的软件安装程序用于合法的目的。无害软件作者使用打包技术来交付他们的代码,因为这可以压缩程序资源,从而减少软件安装程序的下载大小。它还帮助他们抵御竞争对手的逆向工程尝试,并提供了一种方便的方式将多个程序资源打包成一个安装文件。

资源混淆

恶意软件作者使用的另一种反检测、反分析技术是资源混淆。他们通过混淆程序资源(如字符串和图像)在磁盘上的存储方式,然后在运行时解混淆,使恶意程序能够使用这些资源。例如,一种简单的混淆方法是将存储在 PE 资源部分中的所有图像和字符串字节值加 1,然后在运行时从这些数据中减去 1。当然,这里可以使用任何数量的混淆方法,所有这些都会使恶意软件分析师在进行静态分析时难以理解恶意软件的二进制文件。

和打包一样,绕过资源混淆的一种方法是将恶意软件在安全环境中运行。如果这不是一个可行的选项,那么唯一的缓解方法就是实际找出恶意软件混淆资源的方式,并手动解混淆,这正是专业恶意软件分析师经常做的事。

反反汇编技术

恶意软件作者使用的第三类反检测、反分析技术是反反汇编技术。这些技术旨在利用最先进的反汇编技术的固有局限性,隐藏恶意软件代码或让恶意软件分析师误认为磁盘上存储的代码块包含与实际不同的指令。

一种反反汇编技术的示例是跳转到一个内存位置,恶意软件作者的反汇编工具会将其解释为不同的指令,从而本质上隐藏了恶意软件的真实指令,避免了逆向工程师的分析。反反汇编技术具有巨大的潜力,目前还没有完美的防御方法。在实际操作中,对抗这些技术的两种主要防御手段是将恶意软件样本在动态环境中运行,以及手动找出恶意软件样本中反反汇编策略的表现形式,并绕过它们。

动态下载的数据

恶意软件作者使用的最后一类反分析技术涉及外部获取数据和代码。例如,恶意软件样本可能会在启动时从外部服务器动态加载代码。如果是这种情况,静态分析将对这些代码毫无作用。类似地,恶意软件可能会在启动时从外部服务器获取解密密钥,然后使用这些密钥解密将在恶意软件执行过程中使用的数据或代码。

显然,如果恶意软件使用了工业级加密算法,静态分析将无法恢复加密的数据和代码。此类反分析和反检测技术非常强大,唯一的应对方法是通过某种手段获取外部服务器上的代码、数据或私钥,然后在分析恶意软件时使用它们。

总结

本章介绍了 x86 汇编代码分析,并演示了如何使用开源 Python 工具对ircbot.exe进行基于反汇编的静态分析。虽然这不是一本完整的 x86 汇编入门书,但你应该已经足够熟悉,能够找到开始理解给定恶意软件汇编转储的方法。最后,你学习了恶意软件作者如何防御反汇编和其他静态分析技术,以及如何应对这些反分析和反检测策略。在第三章中,你将学习如何进行动态恶意软件分析,以弥补静态恶意软件分析的诸多不足。

第三章:动态分析简要介绍

image

在第二章中,您学习了高级静态分析技术来反汇编从恶意软件中恢复的汇编代码。虽然静态分析可以通过研究恶意软件在磁盘上的不同组件,作为一种高效的方法获取有用的信息,但它无法让我们观察到恶意软件的行为。

在本章中,您将学习动态恶意软件分析的基本知识。与静态分析不同,静态分析侧重于恶意软件在文件形式下的表现,动态分析则包括在一个安全、受限的环境中运行恶意软件,以观察它的行为。这就像是将一种危险的细菌菌株引入一个封闭的环境,以观察它对其他细胞的影响。

通过动态分析,我们可以绕过常见的静态分析障碍,如打包和混淆,并获得更直接的关于某个恶意软件样本目的的洞察。我们从探索基本的动态分析技术、它们与恶意软件数据科学的关联以及它们的应用开始。我们使用开源工具,如malwr.com,来研究动态分析的实际应用示例。请注意,这只是对该主题的简要概述,并不打算全面涵盖。要了解更完整的介绍,请参考《实践恶意软件分析》(No Starch Press,2012)。

为什么使用动态分析?

为了理解动态分析为何重要,让我们考虑打包恶意软件的问题。回想一下,打包恶意软件是指压缩或混淆恶意软件的 x86 汇编代码,以隐藏程序的恶意性质。打包的恶意软件样本在感染目标机器时会自行解包,以便代码得以执行。

我们可以尝试使用第二章中讨论的静态分析工具来反汇编一个打包或混淆的恶意软件样本,但这是一个繁琐的过程。例如,在静态分析中,我们首先需要找到恶意软件文件中混淆代码的位置。然后,我们还需要找到解混淆子程序的位置,这些子程序能够解开这些混淆的代码,以便它可以运行。找到子程序后,我们还需要弄清楚这个解混淆过程是如何工作的,从而能在代码上执行它。只有这样,我们才能开始实际的恶意代码逆向工程过程。

这个过程的一个简单而巧妙的替代方法是将恶意软件执行在一个安全、受限的环境中,称为沙箱。在沙箱中运行恶意软件可以让它像感染真实目标一样自行解包。通过简单地运行恶意软件,我们可以了解特定恶意软件二进制文件连接到哪些服务器,改变了哪些系统配置参数,以及它尝试执行哪些设备 I/O(输入/输出)操作。

恶意软件数据科学中的动态分析

动态分析不仅对恶意软件逆向工程有用,还对恶意软件数据科学有帮助。因为动态分析揭示了恶意软件样本的行为,我们可以将它的操作与其他恶意软件样本的操作进行比较。例如,动态分析显示了恶意软件样本写入磁盘的文件,我们可以利用这些数据,将写入相似文件名的恶意软件样本连接起来。这些线索帮助我们根据共同特征对恶意软件样本进行分类,甚至帮助我们识别出同一组或属于同一活动的恶意软件样本。

最重要的是,动态分析对于构建基于机器学习的恶意软件检测器非常有用。我们可以通过观察恶意软件和良性文件在动态分析中的行为来训练检测器,区分恶意和良性二进制文件。例如,在观察了成千上万的动态分析日志后,机器学习系统可以学习到,当msword.exe启动一个名为powershell.exe的进程时,这个操作是恶意的,而当msword.exe启动 Internet Explorer 时,这通常是无害的。第八章将更详细地讨论如何使用基于静态和动态分析的数据来构建恶意软件检测器。在创建复杂的恶意软件检测器之前,我们先来看看一些用于动态分析的基本工具。

动态分析的基本工具

你可以在线找到一些免费的开源动态分析工具。本节重点介绍* malwr.com * 和 CuckooBox。* malwr.com * 网站提供一个网络界面,可以免费提交二进制文件进行动态分析。CuckooBox 是一个软件平台,允许你设置自己的动态分析环境,从而在本地分析二进制文件。CuckooBox 平台的创建者也运营着* malwr.com ,而且 malwr.com * 在后台运行 CuckooBox。因此,学习如何分析* malwr.com * 的结果将帮助你理解 CuckooBox 的结果。

注意

在印刷时, malwr.com的 CuckooBox 界面正在进行维护。希望等你阅读本节时,网站会恢复。如果没有,章中提供的信息可以应用于你自己 CuckooBox 实例的输出,按照 cuckoosandbox.org/ 上的说明设置即可。*

典型的恶意软件行为

以下是恶意软件样本执行时可能采取的主要操作类别:

修改文件系统 例如,写入设备驱动程序到磁盘,修改系统配置文件,向文件系统添加新程序,以及修改注册表键值以确保程序自动启动

修改 Windows 注册表以更改系统配置 例如,修改防火墙设置

加载设备驱动程序 例如,加载一个记录用户按键的设备驱动程序

网络操作 例如,解析域名和发起 HTTP 请求

我们将通过一个恶意软件样本并分析其在* malwr.com *上的报告,进一步详细检查这些行为。

malwr.com上加载文件

要在* malwr.com 上运行一个恶意软件样本,首先访问 malwr.com/ ,然后点击提交按钮上传并提交二进制文件进行分析。我们将使用一个 SHA256 哈希以d676d95开头的二进制文件,你可以在本章附带的数据目录中找到。我鼓励你将这个二进制文件提交到 malwr.com *并在我们继续时自己查看结果。提交页面如图 3-1 所示。

image

图 3-1:恶意软件样本提交页面

提交样本后,网站应该提示你等待分析完成,这通常需要大约五分钟。当结果加载完成后,你可以查看它们,了解可执行文件在动态分析环境中运行时所做的操作。

malwr.com上分析结果

我们的样本的结果页面应该类似于图 3-2。

image

图 3-2:恶意软件样本结果页面顶部 malwr.com

这个文件的结果展示了动态分析的一些关键方面,我们接下来将深入探讨。

签名面板

你将在结果页面上看到的前两个面板是分析和文件详情。这些面板包含文件运行的时间和文件的其他静态信息。我将在这里关注的是签名面板,如图 3-3 所示。这个面板包含了从文件本身以及它在动态分析环境中运行时的行为中提取的高级信息。接下来让我们讨论这些签名的含义。

image

图 3-3: malwr.com 与我们的恶意软件样本行为匹配的签名

图中显示的前三个签名来自静态分析(即这些结果来自恶意软件文件本身的属性,而不是其行为)。第一条签名告诉我们,流行的病毒扫描引擎聚合器VirusTotal.com上有多个杀毒引擎将此文件标记为恶意软件。第二条表明该二进制文件包含压缩或加密的数据,这是混淆的一种常见迹象。第三条告诉我们,该二进制文件是使用流行的 UPX 压缩工具打包的。尽管这些静态指示符本身并未告诉我们文件的具体行为,但它们确实告诉我们该文件可能是恶意的。(请注意,颜色并不代表静态与动态类别的对应关系;相反,它代表了每条规则的严重性,红色——这里较深的灰色——比黄色更可疑。)

接下来的三条签名是通过动态分析文件得到的。第一条签名表明该程序尝试识别系统的硬件和操作系统。第二条表明该程序利用了 Windows 的一个恶意特性——备用数据流(ADS),它允许恶意软件在磁盘上隐藏数据,从而在使用标准文件系统浏览工具时不可见。第三条签名表明该文件修改了 Windows 注册表,使得系统重启时,会自动执行它指定的程序。这会在用户每次重启系统时重启恶意软件。

如你所见,即使在这些自动触发的签名层级上,动态分析也大大增加了我们对文件预期行为的了解。

截图面板

在签名面板下方是截图面板。该面板显示了恶意软件运行时动态分析环境桌面的截图。图 3-4 展示了这个界面的一个示例。

image

图 3-4:我们恶意软件样本的动态行为屏幕截图

你可以看到,我们正在处理的恶意软件是勒索软件,它是一种加密目标文件并强迫受害者支付赎金才能恢复数据的恶意软件。通过简单地运行我们的恶意软件,我们就能揭示其目的,而无需进行逆向工程。

修改后的系统对象面板

截图下方有一排标题,展示了恶意软件样本的网络活动。我们的二进制文件没有进行任何网络通信,但如果它进行了通信,我们会在这里看到它联系的主机。图 3-5 展示了摘要面板。

image

图 3-5:摘要面板的文件选项卡,展示了我们的恶意软件样本修改了哪些文件

这显示了恶意软件修改了哪些系统对象,如文件、注册表项和互斥体。

查看图 3-6 中的文件选项卡,可以清楚地看到该勒索软件恶意软件确实加密了磁盘上的用户文件。

image

图 3-6:总结面板中的文件路径选项卡,提示我们的样本是勒索软件

每个文件路径后面跟着一个扩展名为.locked的文件,我们可以推测这是替换掉的文件的加密版本。

接下来,我们将查看图 3-7 所示的注册表键选项卡。

image

图 3-7:总结面板中的注册表键选项卡,展示了我们的恶意软件样本修改了哪些注册表键

注册表是 Windows 用于存储配置信息的数据库。配置参数以注册表键的形式存储,这些键有相关的值。类似于 Windows 文件系统中的文件路径,注册表键由反斜杠分隔。Malwr.com展示了我们的恶意软件修改了哪些注册表键。虽然图 3-7 中未显示这一点,但如果你查看* malwr.com *上的完整报告,你应该会看到我们的恶意软件修改的一个显著注册表键是HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Run,这是一个告诉 Windows 在每次用户登录时运行程序的注册表键。很可能我们的恶意软件修改了这个注册表键,指示 Windows 每次系统启动时都重启恶意软件,从而确保恶意软件感染在每次重启后持续存在。

malwr.com报告中的互斥体选项卡包含恶意软件创建的互斥体的名称,如图 3-8 所示。

image

图 3-8:总结面板中的互斥体选项卡,展示了我们的恶意软件样本创建了哪些互斥体

互斥体是锁文件,表示程序已占用某些资源。恶意软件通常使用互斥体来防止自己在系统中重复感染。事实证明,至少有一个互斥体(CTF.TimListCache.FMPDefaultS-1-5-21-1547161642-507921405-839522115-1004MUTEX.DefaultS-1-5-21-1547161642-507921405-839522115-1004 ShimCacheMutex)被安全社区认为与恶意软件相关,可能在此处执行该功能。

API 调用分析

点击* malwr.com *UI 左侧面板中的行为分析选项卡,如图 3-9 所示,应会显示有关我们的恶意软件二进制文件行为的详细信息。

这展示了每个由恶意软件启动的进程所做的 API 调用,以及它们的参数和返回值。浏览这些信息非常耗时,并且需要对 Windows API 有专业知识。虽然详细讨论恶意软件 API 调用分析超出了本书的范围,但如果你有兴趣了解更多,可以查找各个 API 调用,了解它们的影响。

image

图 3-9:我们恶意软件样本的malwr.com报告中的行为分析窗格,显示了动态执行过程中何时进行 API 调用

尽管 malwr.com 是一个出色的资源,可以用于动态分析单个恶意软件样本,但它并不适合对大量样本进行动态分析。在动态环境中执行大量样本对机器学习和数据分析至关重要,因为它能够识别恶意软件样本动态执行模式之间的关系。创建能够基于恶意软件动态执行模式检测恶意软件实例的机器学习系统需要运行数千个恶意软件样本。

除了这一局限性,malwr.com 还不提供像 XML 或 JSON 这样的机器可解析格式的恶意软件分析结果。为了解决这些问题,你必须设置并运行自己的 CuckooBox。幸运的是,CuckooBox 是免费的开源软件,并且提供了逐步的设置指南,可以帮助你搭建属于自己的动态分析环境。我鼓励你通过访问 cuckoosandbox.org/ 来进行设置。现在,既然你了解了如何解读来自 malwr.com 的动态恶意软件结果(该网站在后台使用 CuckooBox),你也将知道如何分析 CuckooBox 的结果,一旦你成功搭建并运行了 CuckooBox。

基础动态分析的局限性

动态分析是一个强大的工具,但它并不是恶意软件分析的万能药。事实上,它有着严重的局限性。一个局限性是恶意软件作者知道 CuckooBox 和其他动态分析框架,并试图通过让恶意软件在检测到运行在 CuckooBox 中时无法执行来规避它们。CuckooBox 的维护者知道恶意软件作者会尝试这样做,所以他们会尽力应对恶意软件绕过 CuckooBox 的尝试。这种猫鼠游戏持续进行,以至于某些恶意软件样本不可避免地会检测到它们正在动态分析环境中运行,并在我们尝试运行它们时无法执行。

另一个局限性是,即使没有任何规避尝试,动态分析也可能无法揭示重要的恶意软件行为。考虑一个恶意软件二进制文件的情况,该文件在执行时会连接到远程服务器,并等待命令发布。这些命令可能会告诉恶意软件样本在受害主机上查找某些类型的文件、记录按键或打开摄像头。在这种情况下,如果远程服务器没有发送任何命令,或者已经无法连接,则这些恶意行为将不会被揭示。由于这些局限性,动态分析并不是解决所有问题的万能方法。事实上,专业的恶意软件分析师结合动态和静态分析,以获得最佳的分析结果。

总结

在本章中,你通过【malwr.com】(http://malwr.com) 对勒索软件恶意样本进行了动态分析以分析结果。你还了解了动态分析的优缺点。现在,你已经掌握了基本的动态分析方法,准备深入学习恶意软件数据科学了。

本书的其余部分将专注于对基于静态分析的恶意软件数据进行恶意软件数据科学分析。我将重点介绍静态分析,因为它相较于动态分析更简单,并且更容易获得好的结果,这使得它成为开始进行恶意软件数据科学的良好起点。然而,在每一章中,我也会解释如何将数据科学方法应用于基于动态分析的数据。

第四章:使用恶意软件网络识别攻击活动

image

恶意软件网络分析可以将恶意软件数据集转化为有价值的威胁情报,揭示对抗性攻击活动、常见的恶意软件战术以及恶意软件样本的来源。此方法包括分析一组恶意软件样本如何通过其共享属性相互连接,无论这些属性是嵌入的 IP 地址、主机名、可打印字符字符串、图像还是类似的内容。

例如,图 4-1 展示了恶意软件网络分析的威力,这是通过本章将要介绍的技术在几秒钟内生成的图表示例。

image

图 4-1:通过共享属性分析揭示的国家级恶意软件的社交网络连接

图中展示了一组国家级恶意软件样本(以椭圆形节点表示)及其“社交”关联(连接节点的线)。这些连接基于恶意软件样本“回连”到相同的主机名和 IP 地址,表明它们是由同一攻击者部署的。正如本章将要介绍的,你可以利用这些连接来帮助区分是针对你组织的协调攻击,还是来自多个犯罪动机攻击者的分散攻击。

到本章结束时,你将学到:

  • 网络分析理论的基础,及其在从恶意软件中提取威胁情报方面的应用

  • 如何利用可视化技术识别恶意软件样本之间的关系

  • 如何使用 Python 和各种开源数据分析与可视化工具包来创建、可视化和提取恶意软件网络中的情报

  • 如何将所有这些知识结合起来,以揭示和分析现实世界恶意软件数据集中的攻击活动

节点和边

在对恶意软件进行共享属性分析之前,你需要理解一些关于网络的基础知识。网络是由相互连接的对象(称为节点)组成的集合。这些节点之间的连接被称为。作为抽象的数学对象,网络中的节点可以代表几乎任何东西,它们的边也是如此。对于我们的目的来说,我们关心的是这些节点和边之间连接的结构,因为这可以揭示关于恶意软件的重要细节。

在使用网络分析恶意软件时,我们可以将每个独立的恶意软件文件视为一个节点的定义,将感兴趣的关系(例如共享代码或网络行为)视为边的定义。类似的恶意软件文件共享边,因此当我们应用力导向网络时,它们会聚集在一起(你将很快看到这一点是如何运作的)。或者,我们可以将恶意软件样本和属性都视为独立的节点。例如,回调 IP 地址也有节点,恶意软件样本同样有节点。每当恶意软件样本回调到某个特定的 IP 地址时,它们就会与该 IP 地址节点连接。

恶意软件的网络可能比仅仅是节点和边的集合更复杂。具体来说,它们可以在节点或边上附加属性,例如两个连接样本共享的代码百分比。一个常见的边属性是权重,较大的权重表示样本之间的连接更强。节点可能有自己的属性,比如它们代表的恶意软件样本的文件大小,但这些通常仅被称为属性。

二分网络

二分网络是指其节点可以分为两个分区(组),且两个分区内没有内部连接的网络。这种类型的网络可用于显示恶意软件样本之间共享的属性。

图 4-2 展示了一个二分网络的示例,其中恶意软件样本节点位于底部分区,样本“回调”的域名(用于与攻击者通信)位于另一个分区。请注意,回调节点从不直接连接到其他回调节点,恶意软件样本也从不直接连接到其他恶意软件样本,这是二分网络的特征。

如你所见,即使是如此简单的可视化也揭示了一个重要的信息:基于恶意软件样本共享的回调服务器,我们可以猜测sample_014可能是与sample_37D由同一攻击者部署的。我们还可以猜测sample_37Dsample_F7F可能是由同一攻击者部署的,sample_014sample_F7F也可能是由同一攻击者部署的,因为它们通过sample_37D连接(事实上,图 4-2 中的所有样本都来自同一个“APT1”中国攻击者组)。

注意

我们要感谢 Mandiant 和 Mila Parkour 整理了 APT1 样本,并将其提供给研究社区。

image

图 4-2:一个二分网络。顶部的节点(属性分区)是回调域名。底部的节点(恶意软件分区)是恶意软件样本。

随着网络中节点和连接的数量急剧增大,我们可能希望查看恶意软件样本之间的关系,而不必仔细检查所有属性连接。我们可以通过创建一个二分网络投影来检查恶意软件样本的相似性,这是一种简化版本的二分网络,其中如果网络一方的节点与另一方(属性)有共同的节点,它们就会相互连接。例如,在图 4-1 所示的恶意软件样本中,我们将创建一个网络,其中恶意软件样本如果共享回调域名就会被连接。

图 4-3 展示了先前提到的整个中国 APT1 数据集的共享回调服务器投影网络。

image

图 4-3:APT1 数据集的恶意软件样本投影,仅在恶意软件样本共享至少一个服务器时显示它们之间的连接。两个大群集用于两次不同的攻击活动。

这里的节点是恶意软件样本,它们之间存在链接,如果它们共享至少一个回调服务器。通过仅在恶意软件样本共享回调服务器时显示它们之间的连接,我们可以开始看到这些恶意软件样本的整体“社交网络”。正如在图 4-3 中所看到的,存在两个大的群体(位于左中心区域的大方形群集和位于右上角的圆形群集),进一步检查后发现,它们分别对应 APT1 小组 10 年历史中的两个不同攻击活动。

恶意软件网络的可视化

在使用网络进行恶意软件共享属性分析时,你会发现你很大程度上依赖于网络可视化软件来创建像目前所展示的这些网络。本节介绍了如何从算法的角度创建这些网络可视化。

关键是,进行网络可视化时的主要挑战是网络布局,即决定在二维或三维坐标空间中渲染每个节点的位置,具体取决于你希望你的可视化是二维的还是三维的。当你在网络中放置节点时,理想的方式是将它们放置在坐标空间中,使得它们之间的视觉距离与它们在网络中之间的最短路径距离成比例。换句话说,相隔两跳的节点可能相隔约两英寸,而相隔三跳的节点可能相隔约三英寸。这样做使得我们能够准确地根据节点的实际关系来可视化相似节点的群集。然而,正如你将在下一节中看到的那样,这往往是很难实现的,特别是当你处理的节点超过三个时。

扭曲问题

事实证明,通常不可能完美解决这个网络布局问题。图 4-4 说明了这一困难。

如你所见,在这些简单的网络中,所有节点都通过相等权重为 1 的边连接到其他所有节点。对于这些连接,理想的布局是将所有节点均匀分布在页面上。但是,正如你所看到的,当我们创建四个或五个节点的网络时,如图 (c) 和 (d) 所示,由于边缘长度不等,我们开始引入更多的失真。不幸的是,我们只能最小化,而不能消除这种失真,而这种最小化成为网络可视化算法的主要目标之一。

image

图 4-4:在现实世界的恶意软件网络中,完美的网络布局通常是不可行的。像 (a) 和 (b) 这样的简单情况允许我们将所有节点均匀分布。然而,(c) 引入了失真(边缘不再是等长的),而 (d) 则显示了更多的失真。

力导向算法

为了尽量减少布局失真,计算机科学家通常使用 力导向 布局算法。力导向算法基于物理模拟的弹簧力和磁力。将网络的边缘模拟为物理弹簧,通常能得到较好的节点位置,因为模拟的弹簧通过推拉作用,试图使节点和边缘之间的长度均匀。为了更好地理解这一概念,考虑弹簧的工作原理:当你压缩或拉伸弹簧时,它会“试图”恢复到其平衡长度。这些特性与我们希望网络中所有边缘长度相等的目标非常吻合。力导向算法是我们在本章重点讨论的内容。

使用 NetworkX 构建网络

现在你已经对恶意软件网络有了基本了解,准备好学习如何使用开源的 NetworkX Python 网络分析库和 GraphViz 开源网络可视化工具包来创建恶意软件关系网络。我将向你展示如何通过编程提取与恶意软件相关的数据,然后使用这些数据构建、可视化并分析网络,以表示恶意软件数据集。

让我们从 NetworkX 开始,它是一个开源项目,由位于洛斯阿拉莫斯国家实验室的团队维护,并且是 Python 的事实上的网络处理库(回想一下,你可以通过进入本章的代码和数据目录并运行 pip install -r requirements.txt 来安装本章中的库依赖项,包括 NetworkX)。如果你了解 Python,应该会觉得 NetworkX 出奇的容易。使用 清单 4-1 中的代码来导入 NetworkX 并实例化一个网络。

#!/usr/bin/python
import networkx

# instantiate a network with no nodes and no edges.
network = networkx.Graph()

清单 4-1:实例化网络

这段代码只需要调用一次 NetworkX 中的 Graph 构造函数,就能在 NetworkX 中创建一个网络。

注意

NetworkX 库有时使用 graph 这个术语来代替 network ,因为这两个术语在计算机科学中是同义的——它们都表示一组通过边连接的节点。

添加节点和边

现在我们已经实例化了一个网络,接下来让我们添加一些节点。NetworkX 中的节点可以是任何 Python 对象。在这里,我展示了如何向我们的网络添加不同类型的节点:

nodes = ["hello","world",1,2,3]
for node in nodes:
    network.add_node(node)

如图所示,我们已经向网络中添加了五个节点:"hello""world"123

然后,要添加边,我们调用add_edge(),如下所示:

➊ network.add_edge("hello","world")
   network.add_edge(1,2)
   network.add_edge(1,3)

在这里,我们通过边连接了这五个节点中的一些。例如,第一行代码➊通过在它们之间创建一条边,将"hello"节点和"world"节点连接在一起。

添加属性

NetworkX 允许我们轻松地为节点和边附加属性。要将属性附加到节点(并在以后访问该属性),你可以在将节点添加到网络时,通过关键字参数添加属性,如下所示:

network.add_node(1,myattribute="foo")

要稍后添加属性,可以使用以下语法访问网络的node字典:

network.node[1]["myattribute"] = "foo"

然后,要访问节点,可以访问node字典:

print network.node[1]["myattribute"] # prints "foo"

与节点一样,你可以在初始添加边时,通过关键字参数向边添加属性,如下所示:

network.add_edge("node1","node2",myattribute="attribute of an edge")

类似地,你可以通过使用edge字典,向已添加到网络中的边添加属性,如下所示:

network.edge["node1"]["node2"]["myattribute"] = "attribute of an edge"

edge字典非常神奇,它允许你反向访问节点属性,无需担心首先引用哪个节点,如在列表 4-2 中所示。

➊ network.edge["node1"]["node2"]["myattribute"] = 321
➋ print network.edge["node2"]["node1"]["myattribute"]  # prints 321

列表 4-2:使用 edge 字典反向访问节点属性,不管顺序如何

如你所见,第一行在连接node1node2的边上设置了myattribute➊,第二行则访问了myattribute,尽管node1node2的引用顺序被调换了➋。

将网络保存到磁盘

为了可视化我们的网络,我们需要将它们以.dot格式从 NetworkX 保存到磁盘——这是网络分析领域常用的一种格式,可以导入到许多网络可视化工具包中。要将网络保存为.dot格式,只需调用 NetworkX 的write_dot()函数,如在列表 4-3 中所示。

#!/usr/bin/python
import networkx
from networkx.drawing.nx_agraph import write_dot

# instantiate a network, add some nodes, and connect them
nodes = ["hello","world",1,2,3]
network = networkx.Graph()
for node in nodes:
    network.add_node(node)
network.add_edge("hello","world")
write_dot(➊network,➋"network.dot")

列表 4-3:使用 write_dot() 将网络保存到磁盘

如你所见,在代码的最后,我们使用write_dot()函数指定了我们要保存的网络➊,以及我们希望保存的路径或文件名➋。

网络可视化与 GraphViz

一旦我们使用 write_dot() NetworkX 函数将网络写入磁盘,我们就可以使用 GraphViz 可视化生成的文件。GraphViz 是目前可用的最佳命令行网络可视化工具。它由 AT&T 的研究人员支持,并且已经成为数据分析师网络分析工具箱中的标准部分。它包含一组命令行网络布局工具,既可以用于布局,也可以用于渲染网络。GraphViz 已预装在本书提供的虚拟机中,也可以在 graphviz.gitlab.io/download/ 上下载。每个 GraphViz 命令行工具都以 .dot 格式读取网络,并可以使用以下语法调用来将网络渲染为 .png 文件:

$ <toolname> <dotfile> -T png –o <outputfile.png>

fdp 是一个 GraphViz 网络可视化工具,采用与其他所有 GraphViz 工具相同的基本命令行界面,如下所示:

$ fdp apt1callback.dot –T png –o apt1callback.png

在这里,我们指定要使用 fdp 工具,并命名我们想要布局的网络 .dot 文件,该文件是 apt1callback.dot,位于本书附带数据的 ~/ch3/ 目录中。我们指定 –T png 来表示我们希望使用的格式(PNG)。最后,我们通过 -o apt1callback.png 指定输出文件的保存位置。

使用参数调整网络绘制

GraphViz 工具包括许多参数,可以用来调整网络绘制的方式。许多这些参数是通过 –G 命令行标志设置的,格式如下:

G<parametername>=<parametervalue>

有两个特别有用的参数是 overlapsplines。将 overlap 设置为 false,告诉 GraphViz 不允许任何节点相互重叠。使用 splines 参数告诉 GraphViz 绘制曲线而非直线,以便更容易地跟踪网络上的边。以下是一些设置 overlapsplines 参数的 GraphViz 示例。

使用以下命令来防止节点重叠:

$ <toolname> <dotfile> -Goverlap=false -T png -o <outputfile.png>

将边绘制为曲线(样条线),以提高网络的可读性:

$ <toolname> <dotfile> -Gsplines=true -T png -o <outputfile.png>

将边绘制为曲线(样条线),以提高网络的可读性,并且不允许节点在视觉上重叠:

$ <toolname> <dotfile> -Gsplines=true –Goverlap=false -T png -o <outputfile.png>

注意,我们只是将一个参数接一个参数列出: -Gsplines=true –Goverlap=false(参数顺序无关),然后是 -T png -o <outputfile.png>。

在下一部分,我将介绍一些最有用的 GraphViz 工具(除了 fdp 之外)。

GraphViz 命令行工具

这是我发现最有用的 GraphViz 工具的一些介绍,以及在何时使用每个工具的一些建议。

fdp

在之前的例子中,我们使用了fdp布局工具,它帮助我们创建了力导向布局,正如在第 40 页的“力导向算法”中所描述的。当你创建节点少于 500 个的恶意软件网络时,fdp能够在合理的时间内很好地揭示网络结构。但当节点数量超过 500 个,尤其是节点间的连接复杂时,你会发现fdp的速度会迅速变慢。

要在图 4-3 中显示的 APT1 共享回调服务器网络上尝试fdp,请在本书随附数据的ch4目录中输入以下命令(你必须安装 GraphViz):

$ fdp callback_servers_malware_projection.dot -T png -o fdp_servers.png –Goverlap=false

此命令将创建一个.png文件(fdp_servers.png),显示类似于图 4-5 中的网络。

image

图 4-5:使用 fdp 工具绘制的 APT1 样本布局

fdp布局使得图中显现出许多主题。首先,两个大样本集群之间高度关联,这在图的右上角和左下角清晰可见。其次,一些样本对是相关的,这可以在右下角看到。最后,许多样本之间没有明显的关系,也没有与其他节点连接。需要记住的是,这一可视化是基于节点之间共享回调服务器的关系。未连接的样本可能通过其他类型的关系(如共享代码关系)与图中的其他样本相关联——这种关系我们将在第五章中探讨。

sfdp

sfdp工具使用与fdp类似的布局方法,但它具有更好的扩展性,因为它创建了一个简化层次结构,称为粗化,其中节点根据其邻近度合并成超级节点。在完成粗化后,sfdp工具会对合并后的图形进行布局,这些图形包含的节点和边要少得多,这大大加速了布局过程。通过这种方式,sfdp能够进行更少的计算来找到网络中的最佳位置。因此,sfdp能够在典型的笔记本电脑上布局成千上万个节点,成为布置非常大规模恶意软件网络的最佳算法。

然而,这种可扩展性是有代价的:sfdp生成的布局有时不如在fdp中相同大小网络的布局清晰。例如,比较我使用sfdp创建的图 4-6 与使用fdp创建的网络,后者显示在图 4-5 中。

image

图 4-6:使用 sfdp 命令绘制的 APT1 样本共享回调服务器网络布局

如你所见,图 4-6 中每个集群上方略微有更多噪声,这使得观察网络情况变得稍微困难。

为了创建这个网络,进入本书附带数据的ch4目录,然后输入以下代码生成图 4-6 所示的sfdp_servers.png图像文件:

$ sfdp callback_servers_malware_projection.dot -T png -o sfdp_servers.png –Goverlap=false

注意,代码中的第一个项指定了我们使用的是工具sfdp,而不是之前的fdp。其他部分相同,唯一不同的是输出文件名。

neato

neato工具是 GraphViz 实现的另一种基于力导向网络布局算法,它在所有节点(包括未连接的节点)之间创建模拟弹簧,帮助将各个元素推到理想的位置,但代价是增加了计算量。很难知道neato何时能为给定的网络生成最佳布局:我的建议是,你可以尝试它,并与fdp一起使用,看看哪个布局你更喜欢。图 4-7 展示了neato布局在 APT1 共享回调服务器网络中的效果。

image

图 4-7:使用 neato 布局的 APT1 共享回调服务器网络布局

如你所见,在这种情况下,neato生成的网络布局与fdpsfdp生成的类似。然而,对于某些数据集,你会发现neato生成的布局可能更好或更差——你只需要在你的数据集上尝试一下,看看效果如何。要尝试neato,请从本书附带数据的ch4目录中输入以下内容;这将生成图 4-7 所示的neato_servers.png网络图像文件:

$ neato callback_servers_malware_projection.dot -T png -o neato_servers.png –Goverlap=false

为了创建这个网络,我们只需要修改之前用来创建图 4-6 的代码,指定我们要使用工具neato,然后将输出保存为.png格式的neato_servers.png。现在你已经知道如何创建这些网络可视化,接下来我们来看一下如何改进它们。

为节点和边添加视觉属性

除了决定整体网络布局外,能够指定如何渲染各个节点和边也非常有用。例如,你可能希望根据两个节点之间连接的强度来设置边的粗细,或者根据每个恶意软件样本节点关联的妥协设置节点的颜色,这样可以更好地可视化恶意软件的聚类。NetworkX 和 GraphViz 使得这一切变得简单,只需为节点和边分配属性值即可。我在接下来的章节中仅讨论一些这样的属性,但这个话题足够深奥,可以写成一本完整的书。

边宽

若要设置 GraphViz 绘制的节点边框宽度,或绘制边时的线条宽度,可以将节点和边的penwidth属性设置为你选择的数字,如清单 4-4 所示。

   #!/usr/bin/python
   import networkx
   from networkx.drawing.nx_agraph import writedot

➊ g = networkx.Graph()
   g.add_node(1)
   g.add_node(2)
   g.add_edge(1,2,➋penwidth=10) # make the edge extra wide
   write_dot(g,'network.dot')

清单 4-4:设置 penwidth 属性

在这里,我创建了一个简单的网络 ➊,其中两个节点通过一条边连接,并将边的penwidth属性设置为 10 ➋(默认值为 1)。

运行此代码后,你应该看到一个与图 4-8 相似的图像。

image

图 4-8:一个简单的网络,边的 penwidth 为 10

正如你在图 4-8 中看到的,penwidth 为 10 会导致边变得非常粗。边的宽度(或者如果你设置了节点的penwidth,则是节点边框的厚度)与penwidth属性的值成比例,因此需要根据情况进行选择。例如,如果你的边强度值从 1 到 1000 不等,但你希望能够看到所有的边,可以考虑基于边强度值的对数缩放来为每条边分配penwidth属性。

节点与边的颜色

要设置节点边框或边的颜色,使用color属性。清单 4-5 展示了如何操作。

#!/usr/bin/python

import networkx
from networkx.drawing.nx_agraph import write_dot

g = networkx.Graph()
g.add_node(1,➊color="blue") # make the node outline blue
g.add_node(2,➋color="pink") # make the node outline pink
g.add_edge(1,2,➌color="red") # make the edge red
write_dot(g,'network.dot')

清单 4-5:设置节点和边的颜色

在这里,我创建了与清单 4-4 中相同的简单网络,其中包含两个节点和一个连接它们的边。对于每个创建的节点,我都设置了其color值(➊ 和 ➋)。在创建边时,我还设置了边的color值 ➌。

图 4-9 展示了清单 4-5 的结果。如预期所示,你应该看到第一个节点(边)和第二个节点各自具有唯一的颜色。有关你可以使用的颜色的完整列表,请参见www.graphviz.org/doc/info/colors.html

image

图 4-9:一个简单的网络,演示了如何设置节点和边的颜色

颜色可以用来表示不同类别的节点和边。

节点形状

要设置节点的形状,使用shape属性并指定一个形状字符串,如在www.GraphViz.org/doc/info/shapes.html中定义的那样。常用的值包括boxellipsecircleeggdiamondtrianglepentagonhexagon。清单 4-6 展示了如何设置节点的shape属性。

#!/usr/bin/python

import networkx
from networkx.drawing.nx_agraph import write_dot

g = networkx.Graph()
g.add_node(1,➊shape='diamond')
g.add_node(2,➋shape='egg')
g.add_edge(1,2)

write_dot(g,'network.dot')

清单 4-6:设置节点形状

类似于设置节点颜色的方式,我们仅需在add_node()函数中使用shape关键字参数来指定我们希望每个节点采用的形状。在这里,我们将第一个节点设置为菱形 ➊,第二个节点设置为蛋形 ➋。此代码的结果如图 4-10 所示。

image

图 4-10:一个简单的网络,展示了如何设置节点形状

结果显示了一个菱形节点和一个蛋形节点,反映了我们在清单 4-6 中指定的形状。

文本标签

最后,GraphViz 还允许你使用 label 属性为节点和边添加标签。虽然节点会根据其分配的 ID 自动生成标签(例如,作为123添加的节点标签为 123),你也可以通过 label=<my label attribute> 来指定标签。与节点不同,边默认不会有标签,但你可以使用 label 属性为其指定标签。清单 4-7 显示了如何创建我们现在熟悉的两个节点网络,并为两个节点和连接边都附加了 label 属性。

#!/usr/bin/python

import networkx
from networkx.drawing.nx_agraph import write_dot

g = networkx.Graph()
g.add_node(1,➊label="first node")
g.add_node(2,➋label="second node")
g.add_edge(1,2,➌label="link between first and second node")

write_dot(g,'network.dot')

清单 4-7:为节点和边添加标签

我们分别将节点标记为 first node ➊ 和 second node ➋。我们还将连接它们的边标记为 link between first and second node ➌。图 4-11 显示了我们期望的图形输出。

image

图 4-11:一个简单的网络,展示了我们如何为节点和边添加标签

现在你已经知道如何操作节点和边的基本属性,接下来你可以开始从零构建网络了。

构建恶意软件网络

我们将通过重现并扩展 图 4-1 中展示的共享回调服务器示例,开始讨论构建恶意软件网络,然后研究共享的恶意软件图像分析。

以下程序从恶意软件文件中提取回调域名,然后构建恶意软件样本的二分网络。接下来,它对网络进行一次投影,显示哪些恶意软件样本共享相同的回调服务器,然后再进行一次投影,显示哪些回调服务器被共同的恶意软件样本调用。最后,程序将三个网络—原始的二分网络、恶意软件样本投影和回调服务器投影—保存为文件,以便用 GraphViz 进行可视化。

我将逐步带你走过这个程序。完整的代码可以在本书附带的文件中找到,路径为 ch4/callback_server_network.py

清单 4-8 显示了如何通过导入所需模块开始。

#!/usr/bin/python

import pefile➊
import sys
import argparse
import os
import pprint
import networkx➋
import re
from networkx.drawing.nx_agraph import write_dot
import collections
from networkx.algorithms import bipartite

清单 4-8:导入模块

我们导入的必需模块中,最引人注目的是 pefile PE 解析模块 ➊,我们用它来解析目标 PE 二进制文件,以及 networkx 库 ➋,我们用它来创建恶意软件属性网络。

接下来,我们通过添加 清单 4-9 中的代码来解析命令行参数。

args = argparse.ArgumentParser("Visualize shared DLL import relationships
between a directory of malware samples")
args.add_argument(➊"target_path",help="directory with malware samples")
args.add_argument(➋"output_file",help="file to write DOT file to")
args.add_argument(➌"malware_projection",help="file to write DOT file to")
args.add_argument(➍"resource_projection",help="file to write DOT file to")
args = args.parse_args()

清单 4-9:解析命令行参数

这些参数包括 target_path ➊(我们正在分析的恶意软件所在目录的路径)、output_file ➋(我们写入完整网络的路径)、malware_projection ➌(我们写入简化版本图形的路径,并显示哪些恶意软件样本共享属性),以及 resource_projection ➍(我们写入简化版本图形的路径,并显示在恶意软件样本中哪些属性是一起出现的)。

现在我们准备进入程序的核心部分。清单 4-10 展示了用于创建程序网络的代码。

   #!/usr/bin/python

   import pefile
➊ import sys
   import argparse
   import os
   import pprint
   import networkx
   import re
   from networkx.drawing.nx_agraph import write_dot
   import collections
   from networkx.algorithms import bipartite

   args = argparse.ArgumentParser(
   "Visualize shared hostnames between a directory of malware samples"
   )
   args.add_argument("target_path",help="directory with malware samples")
   args.add_argument("output_file",help="file to write DOT file to")
   args.add_argument("malware_projection",help="file to write DOT file to")
   args.add_argument("hostname_projection",help="file to write DOT file to")
   args = args.parse_args()
   network = networkx.Graph()

   valid_hostname_suffixes = map(
   lambda string: string.strip(), open("domain_suffixes.txt")
   )
   valid_hostname_suffixes = set(valid_hostname_suffixes)
➋ def find_hostnames(string):
      possible_hostnames = re.findall(
      r'(?:a-zA-Z0-9?\.)+[a-zA-Z]{2,6}',
      string)
      valid_hostnames = filter(
              lambda hostname: hostname.split(".")[-1].lower() \
              in valid_hostname_suffixes,
              possible_hostnames
      )
      return valid_hostnames

  # search the target directory for valid Windows PE executable files
  for root,dirs,files in os.walk(args.target_path):
      for path in files:
          # try opening the file with pefile to see if it's really a PE file
          try:
              pe = pefile.PE(os.path.join(root,path))
          except pefile.PEFormatError:
              continue
          fullpath = os.path.join(root,path)
          # extract printable strings from the target sample
        ➌ strings = os.popen("strings '{0}'".format(fullpath)).read()

          # use the search_doc function in the included reg module 
          # to find hostnames
        ➍ hostnames = find_hostnames(strings)
          if len(hostnames):
              # add the nodes and edges for the bipartite network
              network.add_node(path,label=path[:32],color='black',penwidth=5,
              bipartite=0)
          for hostname in hostnames:
            ➎ network.add_node(hostname,label=hostname,color='blue',
                 penwidth=10,bipartite=1)
              network.add_edge(hostname,path,penwidth=2)
          if hostnames:
              print "Extracted hostnames from:",path
              pprint.pprint(hostnames)

清单 4-10:创建网络

我们首先通过调用networkx.Graph()构造函数 ➊ 创建一个新的网络。然后我们定义函数find_hostnames(),该函数从字符串中提取主机名 ➋。不要太担心这个函数的细节:它本质上是一个正则表达式和一些字符串过滤代码,尽力识别域名。

接下来,我们遍历目标目录中的所有文件,检查它们是否为 PE 文件,方法是查看pefile.PE类是否能加载它们(如果不能,我们就不分析这些文件)。最后,我们通过首先从文件中提取所有可打印的字符串 ➌,然后在这些字符串中搜索嵌入的主机名资源 ➍,来提取当前文件中的主机名属性。如果找到了任何主机名,我们将它们作为节点添加到网络中,然后从当前恶意软件样本的节点添加边到主机名资源节点 ➎。

现在我们准备结束程序,正如清单 4-11 所示。

  # write the dot file to disk
➊ write_dot(network, args.output_file)
➋ malware = set(n for n,d in network.nodes(data=True) if d['bipartite']==0)
➌ hostname = set(network)-malware

  # use NetworkX's bipartite network projection function to produce the malware
  # and hostname projections
➍ malware_network = bipartite.projected_graph(network, malware)
  hostname_network = bipartite.projected_graph(network, hostname)

  # write the projected networks to disk as specified by the user
➎ write_dot(malware_network,args.malware_projection)
  write_dot(hostname_network,args.hostname_projection)

清单 4-11:将网络写入文件

我们从将网络写入磁盘开始,写入的位置由命令行参数指定 ➊。接着,我们创建两个简化的网络(即本章前面提到的“投影”),分别展示恶意软件关系和主机名资源关系。首先,我们创建一个 Python 集合来包含恶意软件节点的 ID ➋,然后创建另一个 Python 集合来包含资源节点的 ID ➌。接下来,我们使用 NetworkX 特定的projected_graph()函数 ➍,获取恶意软件和资源集合的投影,并将这些网络写入指定的位置 ➎。

就这样!你可以在本书中的任何恶意软件数据集上运行此程序,查看嵌入文件中的共享主机名资源之间的恶意软件关系。你甚至可以在自己的数据集上使用它,看看通过这种分析方式能够获得哪些威胁情报。

构建共享图像关系网络

除了根据共享回调服务器分析恶意软件,我们还可以根据它们使用共享图标和其他图形资产进行分析。例如,图 4-12 展示了ch4/data/Trojans中发现的特洛伊木马的共享图像分析结果的一部分。

image

图 4-12:多个特洛伊木马共享图像资产网络的可视化

你可以看到,这些木马程序伪装成归档文件,并使用相同的归档文件图标(如图中间所示),尽管它们实际上是可执行文件。它们使用完全相同的图像作为欺骗用户的手段,这表明它们可能来自同一个攻击者。我通过将这些恶意软件样本传递给卡巴斯基杀毒引擎来确认这一点,结果它们都被分配了相同的家族名称(ArchSMS)。

接下来,我将向你展示如何生成图 4-12 中显示的那种可视化图,以便查看恶意软件样本之间共享图像的关系。为了从恶意软件中提取图像,我们使用辅助库images,它依赖于wrestool(在第一章中讨论)来创建image_network.py程序。请记住,wrestool从 Windows 可执行文件中提取图像。

让我们一步步地走过创建共享图像网络的过程,从列表 4-12 中显示的代码的第一部分开始。

   #!/usr/bin/python

   import pefile
   import sys
   import argparse
   import os
   import pprint
   import logging
   import networkx
   import collections
   import tempfile
   from networkx.drawing.nx_agraph import write_dot
   from networkx.algorithms import bipartite

   # Use argparse to parse any command line arguments

   args = argparse.ArgumentParser(
   "Visualize shared image relationships between a directory of malware samples"
   )
   args.add_argument("target_path",help="directory with malware samples")
   args.add_argument("output_file",help="file to write DOT file to")
   args.add_argument("malware_projection",help="file to write DOT file to")
   args.add_argument("resource_projection",help="file to write DOT file to")
   args = args.parse_args()
   network = networkx.Graph()

➊ class ExtractImages():
      def __init__(self,target_binary):
          self.target_binary = target_binary
          self.image_basedir = None
          self.images = []

      def work(self):
          self.image_basedir = tempfile.mkdtemp()
          icondir = os.path.join(self.image_basedir,"icons")
          bitmapdir = os.path.join(self.image_basedir,"bitmaps")
          raw_resources = os.path.join(self.image_basedir,"raw")
          for directory in [icondir,bitmapdir,raw_resources]:
              os.mkdir(directory)
          rawcmd = "wrestool -x {0} -o {1} 2> \
                   /dev/null".format(
                   self.target_binary,raw_resources
                   )
          bmpcmd = "mv {0}/*.bmp {1} 2> /dev/null".format(
          raw_resources,bitmapdir
          )
          icocmd = "icotool -x {0}/*.ico -o {1} \
                    2> /dev/null".format(
                    raw_resources,icondir
                    )
          for cmd in [rawcmd,bmpcmd,icocmd]:
              try:
                  os.system(cmd)
              except Exception,msg:
                  pass
          for dirname in [icondir,bitmapdir]:
              for path in os.listdir(dirname):
                  logging.info(path)
                  path = os.path.join(dirname,path)
                  imagehash = hash(open(path).read())
                  if path.endswith(".png"):
                      self.images.append((path,imagehash))
                  if path.endswith(".bmp"):
                      self.images.append((path,imagehash))
      def cleanup(self):
          os.system("rm -rf {0}".format(self.image_basedir))

   # search the target directory for PE files to extract images from
   image_objects = []
   for root,dirs,files in os.walk(args.target_path):➋
      for path in files:
          # try to parse the path to see if it's a valid PE file
          try:
              pe = pefile.PE(os.path.join(root,path))
          except pefile.PEFormatError:
              continue

列表 4-12:解析初始参数和文件加载代码,在我们的共享图像网络程序中

程序的开始与我们刚才讨论的主机名图形程序(从列表 4-8 开始)非常相似。它首先导入多个模块,包括pefilenetworkx。然而,在这里我们还定义了ExtractImages辅助类 ➊,我们用它来提取目标恶意软件样本中的图形资产。然后,程序进入一个循环,在该循环中我们迭代所有目标恶意软件二进制文件 ➋。

现在我们进入了循环,接下来是从目标恶意软件二进制文件中提取图形资产,使用的是ExtractImages类(该类本质上是对第一章中讨论的icoutils程序的封装)。列表 4-13 展示了执行这一操作的代码部分。

           fullpath = os.path.join(root,path)
        ➊ images = ExtractImages(fullpath)
        ➋ images.work()
           image_objects.append(images)

           # create the network by linking malware samples to their images
        ➌ for path, image_hash in images.images:
               # set the image attribute on the image nodes to tell GraphViz to
               # render images within these nodes
               if not image_hash in network:
                ➍ network.add_node(image_hash,image=path,label='',type='image')
               node_name = path.split("/")[-1]
               network.add_node(node_name,type="malware")
            ➎ network.add_edge(node_name,image_hash)

列表 4-13:从目标恶意软件中提取图形资产

首先,我们将目标恶意软件二进制文件的路径传递给ExtractImages类 ➊,然后调用生成实例的work()方法 ➋。这会导致ExtractImages类创建一个临时目录,用于存储恶意软件图像,然后将包含每个图像数据的字典存储在images类属性中。

现在我们已经从ExtractImages中提取了图像列表,我们开始迭代它 ➌,如果我们之前没有见过该图像的哈希值,就为该图像创建一个新的网络节点 ➍,并将当前处理的恶意软件样本链接到该图像的网络中 ➎。

现在我们已经创建了一个将恶意软件样本与其包含的图像关联的网络,接下来我们准备将图形写入磁盘,如列表 4-14 所示。

   # write the bipartite network, then do the two projections and write them
➊ write_dot(network, args.output_file)
   malware = set(n for n,d in network.nodes(data=True) if d['type']=='malware')
   resource = set(network) - malware
   malware_network = bipartite.projected_graph(network, malware)
   resource_network = bipartite.projected_graph(network, resource)

➋ write_dot(malware_network,args.malware_projection)
   write_dot(resource_network,args.resource_projection)

列表 4-14:将图形写入磁盘

我们以与清单 4-11 中完全相同的方式进行操作。首先,我们将完整的网络写入磁盘 ➊,然后将两个投影(恶意软件投影和图像投影,这里我们称之为资源)写入磁盘 ➋。

你可以使用image_network.py分析本书中任何恶意软件数据集中的图形资产,或者从你选择的恶意软件数据集中提取情报。

总结

在本章中,你了解了执行共享属性分析所需的工具和方法,适用于你自己的恶意软件数据集。具体来说,你了解了网络、二分网络和二分网络投影如何帮助识别恶意软件样本之间的社交连接,为什么网络布局在网络可视化中至关重要,以及如何实现基于力导向的网络。你还学会了如何使用 Python 和开源工具(如 NetworkX)创建和可视化恶意软件网络。在第五章中,你将学习如何根据样本之间的共享代码关系构建恶意软件网络。

第五章:共享代码分析

image

假设你在网络上发现了一个新的恶意软件样本。你会如何开始分析它?你可以将它提交给一个多引擎的病毒扫描器,如 VirusTotal,来了解它属于哪个恶意软件家族。然而,这样的结果往往不清晰且含糊,因为引擎通常使用“代理”等通用术语来标记恶意软件,而这些术语没有实际意义。你也可以通过 CuckooBox 或其他恶意软件沙箱运行该样本,以获得该恶意软件样本回调服务器和行为的有限报告。

当这些方法没有提供足够的信息时,你可能需要对样本进行逆向工程。在这个阶段,共享代码分析可以显著改善你的工作流程。通过揭示新恶意软件样本与哪些之前分析过的样本相似,从而揭示它们共享的代码,共享代码分析使你能够在新恶意软件上重用以前的分析结果,避免从头开始。了解这些之前见过的恶意软件来自哪里,也有助于你弄清楚是谁可能部署了这些恶意软件。

共享代码分析,也叫相似性分析,是通过估算两个恶意软件样本共享的预编译源代码的百分比来比较它们的过程。它不同于共享属性分析,后者是根据恶意软件样本的外部属性(例如,它们使用的桌面图标或它们调用的服务器)进行比较的。

在逆向工程中,共享代码分析有助于识别可以一起分析的样本(因为它们是由相同的恶意软件工具包生成的,或是同一恶意软件家族的不同版本),这可以判断是否是同一开发者可能负责了一组恶意软件样本。

考虑清单 5-1 中的输出,这来自于你将在本章稍后构建的一个程序,用于说明恶意软件共享代码分析的价值。它显示了与新样本可能共享代码的以前见过的样本,以及对那些旧样本所做的评论。

image

清单 5-1:基本共享代码分析的结果

给定一个新样本,共享代码估算可以让我们在几秒钟内看到该样本可能与哪些样本共享代码,并且了解我们已知的这些样本的信息。在这个例子中,它揭示了一个非常相似的样本来自一个已知的 APT(高级持续性威胁),从而为这个新恶意软件提供了即时的背景信息。

我们还可以使用网络可视化来展示样本共享代码关系,这在第四章中你已学到。例如,图 5-1 展示了一个先进持续威胁数据集中的样本共享代码关系网络。

如你所见,从可视化中可以看出,自动化共享代码分析技术可以快速揭示恶意软件家族的存在,而这些家族通过手动分析可能需要几天或几周才能发现。在本章中,你将学习使用这些技术来完成以下任务:

  • 识别来自相同恶意软件工具包或由相同攻击者编写的新恶意软件家族。

  • 确定一个新样本与之前见过的样本之间的代码相似性。

  • 可视化恶意软件关系,更好地理解恶意软件样本之间的代码共享模式,并将你的结果传达给他人。

  • 使用我为本书开发的两个概念验证工具,这些工具实现了这些想法,允许你查看恶意软件共享的代码关系。

image

图 5-1:本章中你将学习创建的一种可视化示例,展示了一些 APT1 样本之间共享的代码关系

首先,我介绍一下你将在本章中使用的测试恶意软件样本,它们是来自第四章的 PLA APT1 样本以及一组犯罪软件样本。然后,你将了解数学相似性比较和Jaccard 指数的概念,这是一种基于集合论的方法,用于根据共享特征比较恶意软件样本。接下来,我介绍特征的概念,展示如何将它们与 Jaccard 指数结合使用,以近似两个恶意软件样本之间共享的代码量。你还将学习如何根据它们的有用性来评估恶意软件特征。最后,我们通过利用你在第四章中学习的网络可视化知识,创建多个尺度上的恶意软件代码共享可视化,如图 5-1 所示。

本章使用的恶意软件样本

在本章中,我们使用了现实世界中的恶意软件家族,这些家族之间有大量共享的代码,来进行实验。这些数据集得益于 Mandiant 和 Mila Parkour,他们策划了这些样本并将其提供给研究社区。然而,实际上,你可能不知道一个恶意软件样本属于哪个家族,或者你的新恶意软件样本与之前见过的样本的相似性有多大。但通过使用我们已经知道的示例进行练习会是一个很好的方法,因为这样可以验证我们自动化的样本相似性推断是否与我们关于哪些样本实际上属于同一组的知识一致。

第一个样本来自我们在第四章中使用的 APT1 数据集,展示了共享资源分析。其他样本则由成千上万的犯罪软件样本组成,这些样本由犯罪分子开发,用于窃取人们的信用卡,将他们的计算机转变为僵尸主机并加入到僵尸网络中,等等。这些是来自商业恶意软件数据源的现实世界样本,这些服务是付费提供给威胁情报研究人员的。

为了确定它们的家族名称,我将每个样本输入到卡巴斯基的杀毒引擎中。卡巴斯基成功地将这些样本中的 30,104 个样本进行了稳健的层次分类(例如,trojan.win32.jorik.skor.akr,表示jorik.skor家族),将 41,830 个样本归类为“未知”,并将剩余的 28,481 个样本分配了通用标签(例如,通用的“win32 Trojan”)。

由于卡巴斯基标签的不一致性(某些卡巴斯基标签分组,如 jorik 家族,代表着范围非常广泛的恶意软件,而其他标签,如 webprefix,则代表特定的一组变种),以及卡巴斯基经常漏掉或误标恶意软件,我选择了卡巴斯基能够高置信度检测到的七个恶意软件类别。具体来说,这些类别包括 dapato、pasta、skor、vbna、webprefix、xtoober 和 zango 家族。

通过提取特征为比较做准备

我们如何开始思考估算两个恶意二进制文件在被攻击者编译之前可能共享的代码量呢?有许多方法可以考虑解决这个问题,但在成百上千篇已发布的计算机科学研究论文中,出现了一个共同的主题:为了估算二进制文件之间共享的代码量,我们先将恶意软件样本分组为“特征袋”,然后进行比较。

特征是指我们在估算样本之间代码相似性时,可能考虑的任何恶意软件属性。例如,我们使用的特征可以是从二进制文件中提取的可打印字符串。与其将样本视为一个相互关联的功能系统、动态库导入等,我们更倾向于从数学便利的角度将恶意软件看作一个由独立特征组成的集合(例如,从恶意软件中提取的一组字符串)。

特征袋模型如何工作

为了理解特征袋如何工作,考虑一下两个恶意软件样本之间的维恩图,如图 5-2 所示。

这里,样本 A 和样本 B 被显示为特征集合(特征在维恩图中用椭圆表示)。我们可以通过检查两个样本之间共享的特征来进行比较。计算两个特征集合之间的重叠非常快速,可以用于根据我们定义的任意特征比较恶意软件样本的相似性。

例如,在处理打包的恶意软件时,我们可能希望使用基于恶意软件动态运行日志的特征,因为将恶意软件运行在沙箱中是一种让恶意软件自解包的方法。在其他情况下,我们可能会使用从静态恶意软件二进制文件中提取的字符串来执行比较。

image

图 5-2:用于恶意软件代码共享分析的“特征袋”模型示意图

在动态恶意软件分析中,我们可能不仅希望基于它们共享的行为来比较样本,还希望根据它们表达行为的顺序来比较,或者我们所称之为它们的行为序列。将序列信息纳入恶意软件样本比较的一种常见方法是扩展特征集模型,以适应使用 N-grams 的顺序数据。

什么是 N-Grams?

N-gram是指具有某个特定长度N的事件子序列,它是从一个更大的事件序列中提取出来的。我们通过在顺序数据上滑动窗口来提取这个子序列。换句话说,我们通过迭代序列,在每一步记录从事件索引i到事件索引i + N - 1 的子序列,如图 5-3 所示。

在图 5-3 中,整数序列(1,2,3,4,5,6,7)被转换为五个不同的长度为 3 的子序列:(1,2,3),(2,3,4),(3,4,5),(4,5,6),(5,6,7)。

当然,我们可以对任何顺序数据执行此操作。例如,使用 N-gram 的词长为 2,句子“how now brown cow”会生成以下子序列:“how now”,“now brown”和“brown cow”。在恶意软件分析中,我们会提取恶意软件样本所做的 API 调用的 N-grams。然后,我们将恶意软件表示为一组特征,并使用 N-gram 特征将恶意软件样本与其他恶意软件样本的 N-grams 进行比较,从而将序列信息纳入特征集比较模型中。

image

图 5-3:一个可视化的解释,展示我们如何从恶意软件的汇编指令和动态 API 调用序列中提取 N-grams,其中N= 3

在我们比较恶意软件样本时,包含序列信息有其优缺点。优点是当顺序在比较中很重要时(例如,当我们关心 API 调用 A 发生在 API 调用 B 之前,而 B 又发生在 API 调用 C 之前时),它允许我们捕捉到顺序,但当顺序是多余的(例如,恶意软件在每次运行时随机化 API 调用 A、B 和 C 的顺序时),它实际上可能使我们对共享代码的估计变得更差。是否在恶意软件共享代码估计中包含顺序信息,取决于我们处理的是哪种恶意软件,并且需要进行实验。

使用 Jaccard 指数量化相似度

一旦你将恶意软件样本表示为一组特征,你就需要衡量该样本的特征集与其他样本特征集之间的相似度。为了估计两个恶意软件样本之间的代码共享程度,我们使用一个相似度函数,该函数应具有以下属性:

  • 它产生一个归一化值,使得所有恶意软件样本之间的相似度比较可以置于一个共同的尺度上。按照惯例,该函数的结果应在 0(没有代码共享)到 1(样本共享 100%的代码)之间。

  • 该函数应帮助我们准确估计两个样本之间的代码共享情况(我们可以通过实验经验性地确定这一点)。

  • 我们应该能够轻松理解为什么该函数能够很好地建模代码相似性(它不应是一个复杂的数学黑箱,需要大量精力才能理解或解释)。

Jaccard 指数是一个简单的函数,具有这些特性。事实上,尽管在安全研究领域曾尝试过其他数学方法来估计代码相似性(例如余弦距离、L1 距离、欧几里得[L2]距离等),但 Jaccard 指数已成为最广泛采用的——并且有充分的理由。它简单直观地表达了两组恶意软件特征之间的重叠程度,给出了两组中共同的独特特征的百分比,经过归一化后,再与任何一组中存在的独特特征的百分比相比较。

图 5-4 展示了 Jaccard 指数值的示例。

image

图 5-4:Jaccard 指数背后的概念的视觉示意图

该图展示了从四对恶意软件样本中提取的四组恶意软件特征。每张图展示了两组之间共享的特征、未共享的特征以及给定恶意软件样本对和相关特征的 Jaccard 指数。你可以看到,样本之间的 Jaccard 指数就是共享特征的数量除以 Venn 图中绘制的特征总数。

使用相似度矩阵评估恶意软件共享代码估计方法

让我们讨论四种确定两个恶意软件样本是否来自同一家族的方法:基于指令序列的相似性、基于字符串的相似性、基于导入地址表的相似性和基于动态 API 调用的相似性。为了比较这四种方法,我们将使用相似度矩阵可视化技术。我们的目标是比较每种方法在揭示样本之间共享代码关系方面的相对优缺点。

首先,让我们了解一下相似度矩阵的概念。图 5-5 使用相似度矩阵对四个虚拟恶意软件样本进行了比较。

image

图 5-5:一个假设的相似度矩阵示意图

这个矩阵允许你看到所有样本之间的相似性关系。你可以看到这个矩阵中有一些空间被浪费了。例如,我们不关心阴影框中表示的相似性,因为这些条目只是比较了一个样本与其自身的相似性。你还可以看到阴影框两侧的信息是重复的,因此你只需要查看其中一个即可。

图 5-6 展示了一个真实世界的恶意软件相似度矩阵示例。请注意,由于图中展示了大量恶意软件样本,每个相似度值是通过阴影像素表示的。我们没有渲染每个样本的名称,而是沿着横轴和纵轴渲染了每个样本所属的家族名称。一个完美的相似度矩阵应该像从左上角到右下角的一串白色方块,因为每个家族的行和列会被分组在一起,我们期望同一家族的所有成员彼此相似,但不同家族的样本之间不会有相似性。

image

图 5-6:计算七个恶意软件家族的真实世界恶意软件相似度矩阵

在图 5-6 给出的结果中,你可以看到一些家族区域完全是白色的——这些是好结果,因为家族区域内的白色像素表示推测的同一家族样本之间的相似性关系。有些区域则要暗得多,这意味着我们没有检测到强的相似性关系。最后,有时会出现家族区域外的像素线条,这些要么是相关恶意软件家族的证据,要么是误报,意味着尽管这些家族本质上是不同的,我们仍然检测到了它们之间的代码共享。

接下来,我们将使用相似度矩阵可视化,如图 5-6,来比较四种不同代码共享估算方法的结果,首先从基于指令序列的相似性分析描述开始。

基于指令序列的 xSimilarity

比较两个恶意软件二进制文件共享代码量的最直观方式是通过比较它们的 x86 汇编指令序列,因为共享指令序列的样本很可能在编译前共享了实际的源代码。这需要通过例如在第二章中介绍的线性反汇编技术对恶意软件样本进行反汇编。然后,我们可以使用我之前讨论过的 N-gram 提取方法,按照它们在恶意软件文件的 .text 部分出现的顺序提取指令序列。最后,我们可以使用指令 N-gram 计算样本之间的 Jaccard 相似度指数,来估计它们共享的代码量。

在 N-gram 提取中,我们使用的 N 值取决于我们的分析目标。N 值越大,提取的指令子序列就越长,因此恶意软件样本的序列匹配就越困难。将 N 设置为较大数值有助于仅识别出那些高度可能共享代码的样本。另一方面,你也可以将 N 设置得较小,以便寻找样本之间的细微相似性,或者如果你怀疑样本使用了指令重排序来掩盖相似性分析。

在图 5-7 中,N 值设置为 5,这是一个激进的设置,使得样本之间的匹配更加困难。

image

图 5-7:使用指令 N-gram 特征生成的相似性矩阵。使用 N = 5,我们完全错过了许多家族之间的相似关系,但在 webprefix 和 pasta 上表现得很好。

图 5-7 中的结果并不十分有说服力。虽然基于指令的相似性分析正确地识别出一些家族之间的相似性,但它在其他家族中无法识别(例如,它在 dapato、skor 和 vbna 中几乎没有检测到相似关系)。然而,需要注意的是,这种分析中的假阳性很少(即错误地推断出来自不同家族样本之间的相似性,而不是正确地推断出同一家族样本之间的相似性)。

如你所见,指令子序列共享代码分析的局限性在于,它可能会错过样本之间许多共享代码的关系。这是因为恶意软件样本可能被打包,以至于它们的大部分指令只有在我们执行恶意软件样本并让其解包后才会变得可见。如果不解包恶意软件样本,指令序列共享代码估计方法可能无法很好地工作。

即使我们解包恶意软件样本,这种方法也可能存在问题,因为源代码编译过程会引入噪声。事实上,编译器可以将相同的源代码编译成完全不同的汇编指令序列。例如,以下是用 C 语言编写的一个简单函数:

int f(void) {
    int a = 1;
    int b = 2;
  ➊ return (a*b)+3;
}

你可能会认为,无论使用什么编译器,函数都会被编译成相同的汇编指令序列。但实际上,编译过程不仅高度依赖于所使用的编译器,还依赖于编译器的设置。例如,使用 clang 编译器并按照其默认设置编译此函数时,源代码中➊行对应的汇编指令如下:

movl    $1, -4(%rbp)
movl    $2, -8(%rbp)
movl    -4(%rbp), %eax
imull   -8(%rbp), %eax
addl    $3, %eax

相比之下,使用–O3标志编译相同的函数,告诉编译器优化代码以提高速度,编译出的汇编指令如下所示:

movl    $5, %eax

这种差异的原因在于,在第二个例子中,编译器预先计算了函数的结果,而不是像第一个编译例子那样显式地计算它。这意味着,如果我们根据指令序列比较这些函数,它们看起来根本不相似,尽管实际上它们是从完全相同的源代码编译而来的。

除了当我们查看汇编指令时,相同的 C 和 C++代码看起来非常不同的问题外,当我们根据汇编代码比较二进制文件时,还会出现一个额外的问题:许多恶意软件二进制文件现在是用像 C#这样的高级语言编写的。这些二进制文件包含标准的样板汇编代码,它只是解释这些高级语言的字节码。因此,尽管用相同高级语言编写的二进制文件可能共享非常相似的 x86 指令,但它们的实际字节码可能反映了它们来自完全不同源代码的事实。

基于字符串的相似性

我们可以通过提取样本中的所有连续可打印字符序列,然后根据它们共享的字符串关系,计算恶意软件样本之间的 Jaccard 指数,从而计算基于字符串的恶意软件相似度。

这种方法绕过了编译器的问题,因为从二进制文件中提取的字符串通常是由程序员定义的格式字符串,编译器通常不会对其进行转换,无论恶意软件作者使用的是哪种编译器,或者他们给编译器设置了什么参数。例如,从恶意软件二进制文件中提取的典型字符串可能是:“在%s 上启动键盘记录器,时间为%s。”不管编译器设置如何,这个字符串在多个二进制文件中往往看起来是相同的,它与是否基于相同的源代码库有关。

图 5-8 展示了基于字符串的代码共享度量如何在犯罪软件数据集中识别正确的代码共享关系。

image

图 5-8:使用字符串特征生成的相似度矩阵

初看之下,这种方法在识别恶意软件家族方面比基于指令的方法表现得更好,准确地恢复了所有七个家族的大部分相似性关系。然而,与基于指令的相似性方法不同,仍然有一些误报,因为它错误地预测了 xtoober 和 dapato 共享某些代码。此外,值得注意的是,这种方法未能检测到某些家族样本之间的相似性,在 zango、skor 和 dapato 家族上表现特别差。

基于导入地址表的相似性

我们可以通过比较恶意软件二进制文件的 DLL 导入来计算我所称的 “导入地址表–基础相似性”。这种方法背后的思想是,即使恶意软件作者已经重新排序了指令、混淆了恶意软件二进制文件的初始化数据段,并实施了反调试和反虚拟机反分析技术,他们可能仍然保留了相同的导入声明。导入地址表方法的结果如 图 5-9 所示。

image

图 5-9:使用导入地址表特征生成的相似性矩阵

该图显示,导入地址表方法在估计 webprefix 和 xtoober 样本之间的相似性关系方面优于任何前述方法,并且总体表现非常好,尽管它错过了许多 skor、dapato 和 vbna 关系。值得注意的是,这种方法在我们的实验数据集中给出的假阳性较少。

基于动态 API 调用的相似性

本章我介绍的最后一种比较方法是动态恶意软件相似性。比较动态序列的优势在于,即使恶意软件样本经过了极度混淆或打包,只要它们源自相同的代码或相互借用代码,它们在沙箱虚拟机中执行的动作序列通常会非常相似。为了实现这种方法,你需要在沙箱中运行恶意软件样本,记录它们所做的 API 调用,从动态日志中提取 API 调用的 N-gram,并最终通过计算它们的 N-gram 包之间的 Jaccard 指数来比较样本。

图 5-10 显示了动态 N-gram 相似性方法在大多数情况下与导入方法和字符串方法的表现相当。

image

图 5-10:使用动态 API 调用 N-gram 特征生成的相似性矩阵

这里的不完美结果表明,这种方法并不是万能的。仅仅在沙箱中运行恶意软件并不足以触发它的许多行为。例如,一种命令行恶意软件工具的不同变种可能启用或不启用一个重要的代码模块,因此会执行不同的行为序列,尽管它们可能共享大部分代码。

另一个问题是,一些样本能够检测到它们正在沙箱中运行,然后迅速退出执行,导致我们几乎无法获得任何信息来进行比较。总之,像我所概述的其他相似性方法一样,动态 API 调用序列相似性并不完美,但它能为样本之间的相似性提供令人印象深刻的洞察。

构建相似性图

现在你已经理解了识别恶意软件代码共享方法背后的概念,让我们构建一个简单的系统,针对恶意软件数据集进行此分析。

首先,我们需要通过提取我们想要使用的特征来估算样本共享的代码量。这些特征可以是之前描述的任何特征,例如基于导入地址表的函数、字符串、指令的 N-gram 或动态行为的 N-gram。在这里,我们将使用可打印字符串特征,因为它们表现良好,并且易于提取和理解。

一旦我们提取了字符串特征,我们需要遍历每一对恶意软件样本,使用 Jaccard 指数比较它们的特征。然后,我们需要构建一个代码共享图。为此,我们首先需要决定一个阈值,定义两个样本共享多少代码——我在研究中使用的标准值是 0.8。如果给定的恶意软件样本对的 Jaccard 指数高于该值,我们就会为它们创建一个可视化链接。最后一步是研究该图,看看哪些样本通过共享的代码关系连接在一起。

列表 5-2 到 5-6 包含我们的示例程序。由于列表较长,我将其拆分为几部分,并在每一部分讲解。列表 5-2 导入了我们将使用的库,并声明了 jaccard() 函数,该函数计算两个样本特征集之间的 Jaccard 指数。

#!/usr/bin/python

import argparse
import os
import networkx
from networkx.drawing.nx_pydot import write_dot
import itertools

def jaccard(set1, set2):
    """
    Compute the Jaccard distance between two sets by taking
    their intersection, union and then dividing the number
    of elements in the intersection by the number of elements
    in their union.
    """
    intersection = set1.intersection(set2)
    intersection_length = float(len(intersection))
    union = set1.union(set2)
    union_length = float(len(union))
    return intersection_length / union_length

列表 5-2:导入和一个帮助函数,用于计算两个样本之间的 Jaccard 指数

接下来,在 列表 5-3 中,我们声明了两个附加的实用函数:getstrings(),该函数查找我们将要分析的恶意软件文件中的可打印字符串序列集合;以及 pecheck(),该函数确保目标文件确实是 Windows PE 文件。我们将在后续进行目标恶意软件二进制文件的特征提取时使用这些函数。

def getstrings(fullpath):
    """
    Extract strings from the binary indicated by the 'fullpath'
    parameter, and then return the set of unique strings in
    the binary.
    """
    strings = os.popen("strings '{0}'".format(fullpath)).read()
    strings = set(strings.split("\n"))
    return strings

def pecheck(fullpath):
    """
    Do a cursory sanity check to make sure 'fullpath' is
    a Windows PE executable (PE executables start with the
    two bytes 'MZ')
    """
    return open(fullpath).read(2) == "MZ"

列表 5-3:声明我们将在特征提取中使用的函数

接下来,在 列表 5-4 中,我们解析用户的命令行参数。这些参数包括存放我们将要分析的恶意软件的目标目录、我们将写入共享代码网络的输出 .dot 文件,以及 Jaccard 指数阈值,该阈值决定了两个样本之间的 Jaccard 指数需要达到多少,程序才会判断它们共享相同的代码基础。

If __name__ == "__main__":
    parser = argparse.ArgumentParser(
        description="Identify similarities between malware samples and build similarity graph"
    )

    parser.add_argument(
        "target_directory",
        help="Directory containing malware"
    )

    parser.add_argument(
        "output_dot_file",
        help="Where to save the output graph DOT file"
    )

    parser.add_argument(
        "--jaccard_index_threshold", "-j", dest="threshold", type=float,
        default=0.8, help="Threshold above which to create an 'edge' between samples"
    )

    args = parser.parse_args()

列表 5-4:解析用户的命令行参数

接下来,在 列表 5-5 中,我们使用之前声明的帮助函数来执行程序的主要工作:在目标目录中找到 PE 二进制文件、从中提取特征,并初始化一个网络,用来表示二进制文件之间的相似性关系。

malware_paths = []  # where we'll store the malware file paths
malware_features = dict()  # where we'll store the malware strings
graph = networkx.Graph()  # the similarity graph

for root, dirs, paths in os.walk(args.target_directory):
    # walk the target directory tree and store all of the file paths
    for path in paths:
        full_path = os.path.join(root, path)
        malware_paths.append(full_path)

# filter out any paths that aren't PE files
malware_paths = filter(pecheck, malware_paths)

# get and store the strings for all of the malware PE files
for path in malware_paths:
    features = getstrings(path)
    print "Extracted {0} features from {1} ...".format(len(features), path)
    malware_features[path] = features

    # add each malware file to the graph
    graph.add_node(path, label=os.path.split(path)[-1][:10])

列表 5-5:从目标目录中的 PE 文件提取特征并初始化共享代码网络

在从目标样本中提取特征之后,我们需要遍历每一对恶意软件样本,使用 Jaccard 指数比较它们的特征。我们在清单 5-6 中执行了这个操作。我们还构建了一个代码共享图,当样本的 Jaccard 指数高于某个用户定义的阈值时,它们会被连接在一起。我在我的研究中发现,0.8 是效果最好的阈值。

# iterate through all pairs of malware
for malware1, malware2 in itertools.combinations(malware_paths, 2):

    # compute the jaccard distance for the current pair
    jaccard_index = jaccard(malware_features[malware1], malware_features[malware2])

    # if the jaccard distance is above the threshold, add an edge
    if jaccard_index > args.threshold:
        print malware1, malware2, jaccard_index
        graph.add_edge(malware1, malware2, penwidth=1+(jaccard_index-args.threshold)*10)

# write the graph to disk so we can visualize it
write_dot(graph, args.output_dot_file)

清单 5-6:在 Python 中创建代码共享图

清单 5-2 到 5-6 中的代码,在应用于 APT1 恶意软件样本时,生成的图表如图 5-11 所示。要查看图表,你需要使用fdp Graphviz 工具(在第四章中讨论过)输入命令fdp -Tpng network.dot -o network.png

image

图 5-11:APT1 样本的完整基于字符串的相似度图

这个输出的惊人之处在于,几分钟之内,我们就重新生成了 APT1 原始分析师在其报告中所做的许多手动、艰苦的工作,识别出了这些国家级攻击者使用的许多恶意软件家族。

我们知道我们的方法与这些分析师执行的手动逆向工程工作相比是准确的,因为节点上的名称是 Mandiant 分析师赋予它们的名称。你可以从图 5-11 中的网络可视化中看到,具有相似名称的样本会聚集在一起,例如位于中央圆圈中的“STARSYPOUN”样本。因为我们的网络可视化中的恶意软件自动以与这些家族名称一致的方式分组,我们的方法似乎与 Mandiant 的恶意软件分析师“达成一致”。你可以扩展清单 5-2 到 5-6 中的代码,并将其应用于你自己的恶意软件,以获得类似的情报。

扩展相似度比较

尽管清单 5-2 到 5-6 中的代码对于小型恶意软件数据集效果良好,但对于大量恶意软件样本则效果不佳。这是因为在数据集中比较所有恶意软件样本的每一对时,计算量随着样本数量的增加而呈二次增长。具体来说,下面的方程给出了计算 Jaccard 指数所需的计算次数,以计算一个大小为n的数据集的相似度矩阵:

image

例如,让我们回到图 5-5 中的相似性矩阵,看看我们需要计算多少个 Jaccard 指数来比较四个样本。乍一看,你可能会说是 16(4²),因为这就是矩阵中单元格的数量。然而,由于矩阵的下三角包含矩阵上三角的重复项,我们不需要重复计算这些内容。这意味着我们可以从总计算次数中减去 6 次。此外,我们不需要将恶意软件样本与自身进行比较,因此我们可以消除矩阵中的对角线,从而再减去 4 次计算。

所需的计算次数如下:

image

这似乎是可以管理的,直到我们的数据集增长到例如 10,000 个恶意软件样本,这将需要 49,995,000 次计算。一个有 50,000 个样本的数据集将需要 1,249,975,000 次 Jaccard 指数计算!

为了扩展恶意软件相似性比较,我们需要使用随机化的比较近似算法。基本思想是允许我们在计算比较时出现一些误差,从而减少计算时间。对于我们的目的,称为minhash的近似比较方法非常合适。Minhash 方法允许我们使用近似计算 Jaccard 指数,以避免在低于某个预定义相似性阈值的情况下计算不相似的恶意软件样本之间的相似性,这样我们就可以分析数百万样本之间的共享代码关系。

在你阅读关于 minhash 为何有效之前,请注意这是一个复杂的算法,可能需要一些时间才能理解。如果你决定跳过“Minhash 深入分析”部分,只需阅读“Minhash 概述”部分并使用提供的代码,你应该可以顺利扩展代码共享分析。

Minhash 概述

Minhash 通过k个哈希函数对恶意软件样本的特征进行哈希处理。对于每个哈希函数,我们只保留计算出的所有特征的哈希值中的最小值,这样恶意软件特征集就会减少到一个固定大小的k个整数的数组,我们称之为 minhash。为了基于这些 minhash 数组计算两个样本之间的近似 Jaccard 指数,你现在只需要检查有多少个k个 minhash 值是匹配的,然后将其除以k

奇妙的是,这些计算出来的数字接近任何两个样本之间的真实 Jaccard 指数。使用 minhash 而不是直接计算 Jaccard 指数的好处是,它计算速度要快得多。

实际上,我们甚至可以利用 minhash 巧妙地在数据库中对恶意软件进行索引,这样我们只需要计算那些哈希值至少有一个匹配的恶意软件样本之间的比较,从而显著加速恶意软件数据集内相似性计算的速度。

Minhash 深入分析

现在让我们深入讨论 minhash 背后的数学原理。图 5-12 展示了两个恶意软件样本的特征集合(由阴影圆圈表示),它们是如何哈希化并根据哈希值排序的,以及它们是如何基于每个列表的第一个元素的值进行最终比较的。

image

图 5-12:minhash 背后思想的插图

第一个元素匹配的概率等于样本之间的 Jaccard 指数。这一原理超出了本书的讨论范围,但正是这个偶然的事实使得我们可以通过哈希来近似估算 Jaccard 指数。

当然,仅仅执行这个哈希、排序和检查第一个元素的操作,如果我们只做一次,是无法给我们提供太多信息的——哈希值要么匹配,要么不匹配,我们也无法根据这一次的匹配准确地推测底层的 Jaccard 指数。为了更好地估算这个底层值,我们必须使用 k 个哈希函数,并且重复执行这个操作 k 次,然后通过将这些第一个元素匹配的次数除以 k 来估算 Jaccard 指数。我们估算 Jaccard 指数时的预期 误差 定义如下:

image

所以,我们执行这个过程的次数越多,我们就越能够确定(我通常将 k 设置为 256,这样估算值平均偏差为 6%)。

假设我们为一个包含一百万个样本的恶意软件数据集中的每个恶意软件样本计算一个 minhash 数组。那我们如何使用这些 minhash 来加速在数据集中搜索恶意软件家族呢?我们可以遍历数据集中每对恶意软件样本,并比较它们的 minhash 数组,这样就会进行 499,999,500,000 次比较。尽管比较 minhash 数组比计算 Jaccard 指数要快,但这仍然是现代硬件上需要进行的比较次数,太多了。我们需要某种方式利用 minhash 来进一步优化比较过程。

解决这个问题的标准方法是结合草图和数据库索引的方法,创建一个只比较我们已经知道高度相似的样本的系统。我们通过将多个 minhash 哈希值一起进行哈希,来生成一个草图。

当我们得到一个新样本时,我们会检查数据库中是否包含任何与新样本的草图匹配的样本。如果有,那么就用它们的 minhash 数组将新样本与匹配的样本进行比较,从而近似估算新样本与旧样本之间的 Jaccard 指数。这避免了将新样本与数据库中的所有样本进行比较,而是只与那些与新样本的 Jaccard 指数很可能较高的样本进行比较。

构建一个持久化的恶意软件相似性搜索系统

现在你已经了解了使用各种恶意软件特征类型来估计恶意软件样本之间共享代码关系的优缺点。你还了解了 Jaccard 指数、相似性矩阵,以及 minhash 如何使得在非常大的数据集中计算恶意软件样本之间的相似性变得可行。掌握了这些知识后,你理解了构建一个可扩展的恶意软件共享代码搜索系统所需的所有基本概念。

列表 5-7 到 5-12 展示了一个简单系统的示例,在该系统中,我根据恶意软件样本的字符串特征对其进行索引。在你的工作中,你应该有信心修改此系统,使用其他恶意软件特征,或扩展它以支持更多的可视化功能。由于列表很长,我将其分成了几个部分,我们将逐一讨论每个小节。

首先,列表 5-7 导入了我们程序所需的 Python 包。

#!/usr/bin/python

import argparse
import os
import murmur
import shelve
import numpy as np
from listings_5_2_to_5_6 import *

NUM_MINHASHES = 256
SKETCH_RATIO = 8

列表 5-7:导入 Python 模块并声明与 minhash 相关的常量

在这里,我导入了像 murmurshelvesim_graph 这样的包。例如,murmur 是一个哈希库,我们用它来计算我刚才讨论的 minhash 算法。我们使用 shelve,这是一个包含在 Python 标准库中的简单数据库模块,用来存储有关样本及其 minhash 的信息,这些信息用于计算相似性。我们使用 listings_5_2_to_5_6.py 来获取计算样本相似性的函数。

我们还在列表 5-7 中声明了两个常量:NUM_MINHASHESSKETCH_RATIO。这两个常量分别对应我们为每个样本计算的 minhash 数量和 minhash 与草图的比率。请记住,使用的 minhash 和草图越多,我们的相似性计算越准确。例如,256 个 minhash 和 8:1 的比率(32 个草图)就足以在低计算成本下提供足够的准确性。

列表 5-8 实现了我们用来初始化、访问和删除用于存储恶意软件样本信息的shelve数据库的数据库功能。

➊ def wipe_database():
       """
       This problem uses the python standard library 'shelve' database to persist
       information, storing the database in the file 'samples.db' in the same
       directory as the actual Python script. 'wipe_database' deletes this file
       effectively reseting the system.
       """
       dbpath = "/".join(__file__.split('/')[:-1] + ['samples.db'])
       os.system("rm -f {0}".format(dbpath))

➋ def get_database():
       """
       Helper function to retrieve the 'shelve' database, which is a simple
       key value store.
       """
       dbpath = "/".join(__file__.split('/')[:-1] + ['samples.db'])
       return shelve.open(dbpath,protocol=2,writeback=True)

列表 5-8:数据库辅助函数

我们定义了 wipe_database() ➊ 来删除程序的数据库,以便在我们想要清除存储的样本信息并重新开始时使用。然后,我们定义了 get_database() ➋ 来打开数据库,如果数据库尚不存在,则创建它,并返回一个数据库对象,允许我们存储和检索有关恶意软件样本的数据。

列表 5-9 实现了共享代码分析的核心部分:minhash。

def minhash(features):
    """
    This is where the minhash magic happens, computing both the minhashes of
    a sample's features and the sketches of those minhashes. The number of
    minhashes and sketches computed is controlled by the NUM_MINHASHES and
    NUM_SKETCHES global variables declared at the top of the script.
    """
    minhashes = []
    sketches = []
  ➊ for i in range(NUM_MINHASHES):
        minhashes.append(
          ➋ min([murmur.string_hash(`feature`,i) for feature in features])
        )
  ➌ for i in xrange(0,NUM_MINHASHES,SKETCH_RATIO):
      ➍ sketch = murmur.string_hash(`minhashes[i:i+SKETCH_RATIO]`)
        sketches.append(sketch)
    return np.array(minhashes),sketches

列表 5-9:获取样本的 minhash 和草图

我们循环 NUM_MINHASHES 次 ➊,并附加一个最小哈希值。每个最小哈希值通过哈希所有特征并取最小哈希值来计算。为了执行这个计算,我们使用 murmur 包的 string_hash() 函数对特征进行哈希,然后通过调用 Python 的 min() 函数 ➋ 获取哈希值列表中的最小值。

string_hash 的第二个参数是一个种子值,这会导致哈希函数根据种子值映射到不同的哈希值。因为每个最小哈希值需要一个唯一的哈希函数,以确保我们所有的 256 个最小哈希值不相同,所以在每次迭代时,我们使用计数器值 istring_hash 函数进行初始化,这样每次迭代时,特征就会映射到不同的哈希值。

然后,我们遍历计算出的最小哈希值,并利用这些最小哈希值来计算草图 ➌。回想一下,草图是多个最小哈希值的哈希值,我们使用这些草图对恶意软件样本进行数据库索引,以便通过查询数据库快速检索可能相似的样本。在接下来的代码中,我们以步长 SKETCH_RATIO 遍历所有样本的最小哈希值,在此过程中对每个哈希块进行哈希以获取我们的草图。最后,我们使用 murmur 包的 string_hash 函数将最小哈希值一起哈希 ➍。

列表 5-10 使用了 列表 5-8 中的 get_database()、我们导入的 sim_graph 模块中的 getstrings() 函数,以及 列表 5-9 中的 minhash() 函数,创建了一个将样本索引到我们系统数据库中的函数。

def store_sample(path):
    """
    Function that stores a sample and its minhashes and sketches in the
    'shelve' database
    """
  ➊ db = get_database()
  ➋ features = getstrings(path)
  ➌ minhashes,sketches = minhash(features)
  ➍ for sketch in sketches:
        sketch = str(sketch)
      ➎ if not sketch in db:
            db[sketch] = set([path])
        else:
            obj = db[sketch]
          ➏ obj.add(path)
            db[sketch] = obj
        db[path] = {'minhashes':minhashes,'comments':[]}
        db.sync()

    print "Extracted {0} features from {1} ...".format(len(features),path)

列表 5-10:通过使用草图作为键将样本的最小哈希值存储到 shelve 数据库中

我们调用 get_database() ➊、getstrings() ➋ 和 minhash() ➌,然后从 ➍ 开始遍历样本的草图。接下来,为了将样本索引到数据库中,我们使用一种叫做 倒排索引 的技术,这使我们可以根据样本的 草图值 而非 ID 存储样本。更具体地说,对于每个样本的 32 个草图值,我们在数据库中查找该草图的记录,并将样本的 ID 附加到与该草图关联的样本列表中。这里,我们使用样本的文件系统路径作为其 ID。

你可以看到这是如何在代码中实现的:我们遍历为样本➍计算出的草图,如果草图尚未存在,则为其创建记录(同时将我们的样本与草图关联起来)➎,最后,如果草图的记录已经存在,我们将样本路径添加到草图的关联样本路径集合中 ➏。

列表 5-11 展示了两个重要函数的声明:comment_sample()search_sample()

➊ def comment_sample(path):
      """
      Function that allows a user to comment on a sample.  The comment the
      user provides shows up whenever this sample is seen in a list of similar
      samples to some new samples, allowing the user to reuse their
      knowledge about their malware database.
      """
      db = get_database()
      comment = raw_input("Enter your comment:")
      if not path in db:
          store_sample(path)
      comments = db[path]['comments']
      comments.append(comment)
      db[path]['comments'] = comments
      db.sync()
      print "Stored comment:", comment

➋ def search_sample(path):
      """
      Function searches for samples similar to the sample provided by the
      'path' argument, listing their comments, filenames, and similarity values
      """
      db = get_database()
      features = getstrings(path)
      minhashes, sketches = minhash(features)
      neighbors = []

    ➌ for sketch in sketches:
          sketch = str(sketch)

          if not sketch in db:
              continue

        ➍ for neighbor_path in db[sketch]:
              neighbor_minhashes = db[neighbor_path]['minhashes']
              similarity = (neighbor_minhashes == minhashes).sum() 
              / float(NUM_MINHASHES)
              neighbors.append((neighbor_path, similarity))

      neighbors = list(set(neighbors))
    ➎ neighbors.sort(key=lambda entry:entry[1], reverse=True)
      print ""
      print "Sample name".ljust(64), "Shared code estimate"
      for neighbor, similarity in neighbors:
          short_neighbor = neighbor.split("/")[-1]
          comments = db[neighbor]['comments']
          print str("[*] "+short_neighbor).ljust(64), similarity
          for comment in comments:
              print "\t[comment]",comment

列表 5-11:声明允许用户评论样本并搜索与查询样本相似样本的函数

如预期的那样,comment_sample() ➊ 会将用户定义的评论记录添加到样本的数据库记录中。这项功能非常有用,因为它允许程序的用户将反向工程过程中获得的见解存储到数据库中,以便当他们看到与他们有评论的样本相似的新样本时,他们可以利用这些评论更快速地了解新样本的来源和目的。

接下来,search_sample() ➋ 利用 minhash 查找与查询样本相似的样本。为此,我们首先从查询样本中提取字符串特征、minhash 和草图。然后,我们遍历样本的草图,查找数据库中也具有该草图的样本 ➌。对于每个与查询样本共享草图的样本,我们使用其 minhash 计算其近似的 Jaccard 指数 ➍。最后,我们向用户报告与查询样本最相似的样本,并附带与这些样本相关的评论(如果有的话) ➎。

Listing 5-12 通过实现程序的参数解析部分,完成了我们的程序代码。

if __name__ == '__main__':
    parser = argparse.ArgumentParser(
        description="""
Simple code-sharing search system which allows you to build up 
a database of malware samples (indexed by file paths) and
then search for similar samples given some new sample
"""
    )

    parser.add_argument(
        "-l", "--load", dest="load", default=None,
        help="Path to malware directory or file to store in database"
    )

    parser.add_argument(
        "-s", "--search", dest="search", default=None,
        help="Individual malware file to perform similarity search on"
    )

    parser.add_argument(
        "-c", "--comment", dest="comment", default=None,
        help="Comment on a malware sample path"
    )

    parser.add_argument(
        "-w", "--wipe", action="store_true", default=False,
        help="Wipe sample database"
    )

    args = parser.parse_args()
  ➊ if args.load:
        malware_paths = []  # where we'll store the malware file paths
        malware_features = dict()  # where we'll store the malware strings
        for root, dirs, paths in os.walk(args.load):
            # walk the target directory tree and store all of the file paths
            for path in paths:
                full_path = os.path.join(root,path)
                malware_paths.append(full_path)

        # filter out any paths that aren't PE files
        malware_paths = filter(pecheck, malware_paths)

        # get and store the strings for all of the malware PE files
        for path in malware_paths:
            store_sample(path)

  ➋ if args.search:
        search_sample(args.search)

  ➌ if args.comment:
        comment_sample(args.comment)
  ➍ if args.wipe:
        wipe_database()

Listing 5-12: 基于用户命令行参数执行相似度数据库更新和查询

在这里,我们允许用户将恶意软件样本加载到数据库中,这样当用户搜索相似样本时,这些样本将与新恶意软件样本进行比较 ➊。接下来,我们允许用户搜索与用户提供的样本相似的样本 ➋,并将结果打印到终端。我们还允许用户对数据库中已有的样本进行评论 ➌。最后,我们允许用户清除现有数据库 ➍。

运行相似度搜索系统

一旦实现了这段代码,您就可以运行相似度搜索系统,该系统由四个简单的操作组成:

加载 将样本加载到系统中,会将它们存储在系统数据库中,以便以后进行代码共享搜索。您可以单独加载样本,或指定一个目录,系统会递归地搜索该目录下的 PE 文件并将其加载到数据库中。您可以在本章的代码目录中运行以下命令来将样本加载到数据库中:

python listings_5_7_to_5_12.py –l <path to directory or individual malware
sample>

评论 对样本进行评论非常有用,因为它允许您将有关该样本的知识存储起来。此外,当您看到新的与该样本相似的样本时,对这些样本进行相似度搜索时,会显示您在旧样本上的评论,从而加速您的工作流程。您可以使用以下命令对恶意软件样本进行评论:

python listings_5_7_to_5_12.py –c <path to malware sample>

搜索 给定一个恶意软件样本,搜索功能会识别数据库中所有相似的样本,并按相似度降序排列。同时,您可能对这些样本所做的任何评论也会被显示。您可以使用以下命令搜索与给定样本相似的恶意软件样本:

python listings_5_7_to_5_12.py –s <path to malware sample>

清除 清除数据库只是简单地清除系统数据库中的所有记录,您可以使用以下命令进行操作:

python listings_5_7_to_5_12.py –w

列表 5-13 展示了我们将 APT1 样本加载到系统中的结果。

mds@mds:~/malware_data_science/ch5/code$ python listings_5_7_to_5_12.py -l ../
data
Extracted 240 attributes from ../data/APT1_MALWARE_FAMILIES/WEBC2-YAHOO/WEBC2-
YAHOO_sample/WEBC2-YAHOO_sample_A8F259BB36E00D124963CFA9B86F502E ...
Extracted 272 attributes from ../data/APT1_MALWARE_FAMILIES/WEBC2-YAHOO/WEBC2-
YAHOO_sample/WEBC2-YAHOO_sample_0149B7BD7218AAB4E257D28469FDDB0D ...
Extracted 236 attributes from ../data/APT1_MALWARE_FAMILIES/WEBC2-YAHOO/WEBC2-
YAHOO_sample/WEBC2-YAHOO_sample_CC3A9A7B026BFE0E55FF219FD6AA7D94 ...
Extracted 272 attributes from ../data/APT1_MALWARE_FAMILIES/WEBC2-YAHOO/WEBC2-
YAHOO_sample/WEBC2-YAHOO_sample_1415EB8519D13328091CC5C76A624E3D ...
Extracted 236 attributes from ../data/APT1_MALWARE_FAMILIES/WEBC2-YAHOO/WEBC2-
YAHOO_sample/WEBC2-YAHOO_sample_7A670D13D4D014169C4080328B8FEB86 ...
Extracted 243 attributes from ../data/APT1_MALWARE_FAMILIES/WEBC2-YAHOO/WEBC2-
YAHOO_sample/WEBC2-YAHOO_sample_37DDD3D72EAD03C7518F5D47650C8572 ...
--snip--

列表 5-13:将数据加载到本章实现的相似性搜索系统中的示例输出

而列表 5-14 展示了我们执行相似性搜索时的结果。

mds@mds:~/malware_data_science/ch5/code$ python listings_5_7_to_5_12.py –s \ 
../data/APT1_MALWARE_FAMILIES/GREENCAT/GREENCAT_sample/GREENCAT_sample_AB20\
8F0B517BA9850F1551C9555B5313
Sample name                                                      Shared code estimate
[*] GREENCAT_sample_5AEAA53340A281074FCB539967438E3F             1.0
[*] GREENCAT_sample_1F92FF8711716CA795FBD81C477E45F5             1.0
[*] GREENCAT_sample_3E69945E5865CCC861F69B24BC1166B6             1.0
[*] GREENCAT_sample_AB208F0B517BA9850F1551C9555B5313             1.0
[*] GREENCAT_sample_3E6ED3EE47BCE9946E2541332CB34C69             0.99609375
[*] GREENCAT_sample_C044715C2626AB515F6C85A21C47C7DD             0.6796875
[*] GREENCAT_sample_871CC547FEB9DBEC0285321068E392B8             0.62109375
[*] GREENCAT_sample_57E79F7DF13C0CB01910D0C688FCD296             0.62109375

列表 5-14:本章实现的相似性搜索系统的示例输出

请注意,我们的系统正确地判断出查询样本(一个“greencat”样本)与其他 greencat 样本共享代码。如果我们没有事先知道这些样本属于 greencat 家族,我们的系统将为我们节省大量的逆向工程工作。

这个相似性搜索系统只是一个小示例,展示了生产环境中的相似性搜索系统会如何实现。但你应该不会有任何问题,能够使用到目前为止学到的知识,为系统添加可视化功能,并扩展它以支持多种相似性搜索方法。

总结

在本章中,你学会了如何识别恶意软件样本之间的共享代码关系,计算成千上万的恶意软件样本之间的代码共享相似性,从而识别新的恶意软件家族,确定新的恶意软件样本与成千上万的先前样本之间的代码相似性,并可视化恶意软件之间的关系,以了解代码共享的模式。

现在,你应该能够自如地将共享代码分析添加到你的恶意软件分析工具箱中,这将使你能够快速获取大量恶意软件的情报,并加速你的恶意软件分析工作流程。

在第六章、第七章和第八章中,你将学习如何构建用于检测恶意软件的机器学习系统。将这些检测技术与您已经学到的知识结合起来,将帮助您发现其他工具无法捕捉的高级恶意软件,并分析它与其他已知恶意软件的关系,从而获得关于谁部署了恶意软件以及他们的目标是什么的线索。

第六章:理解基于机器学习的恶意软件检测器

image

借助现今开源的机器学习工具,你可以构建自定义的基于机器学习的恶意软件检测工具,无论是作为主要检测工具,还是作为补充商业解决方案的工具,而且所需的工作量相对较少。

但是,为什么要构建自己的机器学习工具,而市面上已有商业杀毒解决方案呢?当你能够获得特定威胁的样本时,比如某个攻击团伙针对你网络使用的恶意软件,构建自己的基于机器学习的检测技术可以帮助你捕捉到这些威胁的新样本。

相比之下,商业杀毒引擎可能无法识别这些威胁,除非它们已经包含相关的签名。商业工具也是“黑箱”——也就是说,我们不一定知道它们是如何工作的,并且我们调节它们的能力有限。当我们构建自己的检测方法时,我们知道它们的工作原理,并且可以根据需要进行调整,以减少误报或漏报。这很有帮助,因为在某些应用中,你可能愿意接受更多的误报,以换取更少的漏报(例如,当你在网络中查找可疑文件,以便人工检查它们是否恶意时),而在其他应用中,你可能愿意接受更多的漏报,以换取更少的误报(例如,如果你的应用在判断程序恶意时阻止其执行,那么误报会对用户造成干扰)。

在本章中,你将学习如何从高层次开发自己的检测工具。我首先会解释机器学习背后的大致思想,包括特征空间、决策边界、训练数据、欠拟合和过拟合。然后,我会重点讲解四种基础方法——逻辑回归、k-最近邻、决策树和随机森林——以及如何应用这些方法进行检测。

然后,你将使用本章学到的知识,学习如何在第七章中评估机器学习系统的准确性,并在第八章中用 Python 实现机器学习系统。让我们开始吧。

构建基于机器学习的检测器步骤

机器学习与其他类型的计算机算法有一个根本性的区别。传统算法告诉计算机该做什么,而机器学习系统通过示例学习如何解决问题。例如,机器学习安全检测系统不是简单地从一组预配置的规则中提取,而是通过学习良好和恶意文件的示例来判断一个文件是好是坏。

机器学习系统在计算机安全中的承诺是自动化创建签名的工作,并且它们有潜力在恶意软件检测上比基于签名的方法更准确,特别是对于新的、之前未见过的恶意软件。

本质上,我们构建任何基于机器学习的检测器(包括决策树)的工作流程都可以归结为以下步骤:

  1. 收集恶意软件和良性软件的示例。我们将使用这些示例(称为训练示例)来训练机器学习系统以识别恶意软件。

  2. 提取每个训练示例的特征,将示例表示为一个数字数组。这一步还包括研究设计好的特征,帮助你的机器学习系统做出准确的推断。

  3. 训练机器学习系统以识别恶意软件,使用我们提取的特征。

  4. 测试该方法在一些未包含在我们训练示例中的数据上,以查看我们的检测系统效果如何。

让我们在接下来的章节中详细讨论这些步骤。

收集训练示例

机器学习检测器的生死取决于提供给它们的训练数据。你的恶意软件检测器识别可疑二进制文件的能力在很大程度上依赖于你提供的训练示例的数量和质量。构建基于机器学习的检测器时,准备好花费大量时间收集训练示例,因为你给系统提供的示例越多,它的准确性就越高。

训练示例的质量也非常重要。你收集的恶意软件和良性软件应当能反映你期望检测器在判断新文件是否为恶意或良性时可能会遇到的恶意软件和良性软件。

例如,如果你想检测来自特定威胁演员组的恶意软件,你必须收集尽可能多的该组恶意软件用于训练你的系统。如果你的目标是检测广泛类别的恶意软件(如勒索软件),那么收集尽可能多的此类别的代表性样本至关重要。

同理,你为系统提供的良性训练示例应当与部署后让检测器分析的良性文件类型相吻合。例如,如果你正在进行大学网络中的恶意软件检测,你应该用学生和大学员工使用的各种良性软件进行训练,以避免假阳性。这些良性示例包括计算机游戏、文档编辑器、大学 IT 部门编写的自定义软件以及其他类型的非恶意程序。

举个现实生活中的例子,在我目前的工作中,我们建立了一个检测恶意 Office 文档的检测器。我们在这个项目上花了大约一半的时间收集训练数据,包括收集了超过一千名公司员工生成的良性文档。通过使用这些例子来训练我们的系统,显著减少了我们的误报率。

提取特征

为了将文件分类为好文件或坏文件,我们通过展示软件二进制文件的特征来训练机器学习系统;这些文件属性将帮助系统区分好文件和坏文件。例如,以下是我们可能用来判断文件好坏的特征:

  • 是否经过数字签名

  • 格式错误的头部的存在

  • 是否包含加密数据

  • 是否已在超过 100 台网络工作站上出现

为了获得这些特征,我们需要从文件中提取它们。例如,我们可能编写代码来判断一个文件是否经过数字签名、是否有格式错误的头部、是否包含加密数据等等。在安全数据科学中,我们经常在机器学习检测器中使用大量特征。例如,我们可能会为每个 Win32 API 的库调用创建一个特征,若二进制文件调用了对应的 API,那么它就会拥有该特征。在第八章中,我们将重新讨论特征提取,在那里我们会讲解更高级的特征提取概念,并介绍如何使用它们在 Python 中实现机器学习系统。

设计良好的特征

我们的目标应当是选择能够产生最准确结果的特征。本节提供了一些应遵循的通用规则。

首先,在选择特征时,选择那些代表你最好的猜测,认为它们可能帮助机器学习系统区分坏文件和好文件的特征。例如,“包含加密数据”这一特征可能是恶意软件的一个良好标记,因为我们知道恶意软件通常包含加密数据,而我们猜测良性软件更少包含加密数据。机器学习的美妙之处在于,如果这个假设是错误的,即良性软件和恶意软件一样频繁地包含加密数据,系统将或多或少忽略这个特征。如果我们的假设是对的,系统将学习利用“包含加密数据”这一特征来检测恶意软件。

其次,不要使用过多的特征,以至于你的特征集相对于训练样本的数量过大。这就是机器学习专家所说的“维度诅咒”。例如,如果你有一千个特征,而只有一千个训练样本,那么很可能你没有足够的训练样本来教会你的机器学习系统每个特征到底能告诉你关于某个二进制文件的信息。统计学告诉我们,相对于你拥有的训练样本数量,给系统提供少量特征会更好,这样系统可以形成关于哪些特征真正能指示恶意软件的有根据的判断。

最后,确保你的特征能够代表关于什么构成恶意软件或良性软件的一系列假设。例如,你可能选择构建与加密相关的特征,如文件是否使用了加密相关的 API 调用或公钥基础设施(PKI),但也要确保使用与加密无关的特征,以降低风险。这样,如果你的系统基于某种特征未能检测到恶意软件,它仍然可以通过其他特征来检测。

训练机器学习系统

在你从训练二进制文件中提取特征后,接下来就是训练你的机器学习系统了。算法上具体的训练过程完全取决于你使用的机器学习方法。例如,训练决策树方法(我们稍后会讨论)涉及的学习算法与训练逻辑回归方法(我们也会讨论)是不同的。

幸运的是,所有机器学习检测器都提供相同的基本接口。你需要为它们提供包含样本二进制文件特征的训练数据,以及相应的标签,告诉算法哪些二进制文件是恶意的,哪些是良性软件。然后,算法会学习判断新的、以前未见过的二进制文件是恶意的还是良性的。我们将在本章后面更详细地介绍训练过程。

注意

在本书中,我们专注于一类被称为监督机器学习算法的机器学习算法。要使用这些算法训练模型,我们会告诉它们哪些示例是恶意的,哪些是良性的。另一类机器学习算法,无监督算法,则不需要我们知道训练集中哪些示例是恶意的,哪些是良性的。这些算法在检测恶意软件和恶意行为方面的效果要差得多,我们在本书中不会涵盖这些算法。

测试机器学习系统

一旦你训练好你的机器学习系统,你需要检查它的准确性。你可以通过在系统没有接受过训练的数据上运行该系统,并观察它判断二进制文件是恶意还是良性的效果来做到这一点。在安全领域,我们通常会使用我们收集到的一些二进制文件来训练系统,然后测试系统在此后看到的二进制文件,以衡量我们的系统检测新恶意软件的效果,并衡量系统在处理新良性文件时避免产生误报的能力。大多数机器学习研究涉及数千次的迭代,过程大致如下:我们创建一个机器学习系统,测试它,然后调整它,重新训练,再次测试,直到我们对结果满意为止。我将在第八章中详细介绍机器学习系统的测试。

现在让我们讨论各种机器学习算法是如何工作的。这是本章的难点,但如果你花时间去理解它,它也是最有收获的部分。在这个讨论中,我将讲述这些算法背后的统一思想,然后详细介绍每种算法。

理解特征空间和决策边界

两个简单的几何概念可以帮助你理解所有基于机器学习的检测算法:几何特征空间的概念和决策边界的概念。特征空间是由你选择的特征定义的几何空间,决策边界是在该空间中划定的几何结构,使得在该边界一侧的二进制文件被定义为恶意软件,而在边界另一侧的二进制文件被定义为良性文件。当我们使用机器学习算法将文件分类为恶意或良性时,我们提取特征以便将样本放入特征空间中,然后检查样本处于决策边界的哪一侧,以确定这些文件是恶意软件还是良性文件。

这种几何方式理解特征空间和决策边界对于在一维、二维或三维(特征)的特征空间中操作的系统是准确的,但它同样适用于具有数百万维的特征空间,尽管我们无法想象或可视化百万维空间。在本章中,我们将使用二维的示例以便于可视化,但请记住,现实世界中的安全机器学习系统几乎总是使用数百、数千甚至数百万维的特征空间,而我们在二维背景下讨论的基本概念同样适用于拥有超过二维的实际系统。

让我们创建一个玩具恶意软件检测问题,以便澄清特征空间中决策边界的概念。假设我们有一个训练数据集,其中包含恶意软件和良性软件样本。现在假设我们从每个二进制文件中提取以下两个特征:文件中看似被压缩的部分的百分比,以及每个二进制文件导入的可疑函数的数量。我们可以如图 6-1 所示可视化我们的训练数据集(请注意,我在图中人工创建了数据,供示例使用)。

image

图 6-1:我们将在本章中使用的样本数据集的图示,其中灰色点为良性软件,黑色点为恶意软件

图 6-1 中显示的二维空间是由我们的两个特征定义的,它是我们样本数据集的特征空间。你可以看到一个明显的模式,黑色的点(恶意软件)通常位于空间的右上部分。一般来说,这些点的导入函数调用比良性软件更多,压缩数据也更多,而良性软件主要分布在图表的左下部分。假设在查看完这个图表后,你被要求仅根据我们在这里使用的两个特征来创建一个恶意软件检测系统。基于数据,似乎很明显,你可以制定如下规则:如果一个二进制文件同时具有大量压缩数据和大量可疑导入函数调用,它是恶意软件;如果既没有大量可疑导入调用,也没有太多压缩数据,它就是良性软件。

从几何学角度来看,我们可以将这个规则可视化为一条对角线,将恶意软件样本与良性软件样本在特征空间中分开,使得具有足够压缩数据和导入函数调用的二进制文件(定义为恶意软件)位于线的上方,而其余的二进制文件(定义为良性软件)位于线的下方。图 6-2 展示了这样一条线,我们称之为决策边界。

image

图 6-2:通过我们样本数据集绘制的决策边界,用于定义恶意软件检测规则

从这条线可以看出,大多数黑色(恶意软件)点位于边界的一侧,大多数灰色(良性软件)样本位于决策边界的另一侧。请注意,无法绘制一条将所有样本完全分开的线,因为这个数据集中的黑色和灰色云团是重叠的。但是通过查看这个例子,我们可以看出我们绘制的这条线在大多数情况下会正确分类新的恶意软件样本和良性软件样本,前提是它们遵循图中数据所显示的模式。

在图 6-2 中,我们手动绘制了一个决策边界。那么如果我们想要一个更精确的决策边界,并希望以自动化的方式进行处理呢?这正是机器学习所做的事情。换句话说,所有的机器学习检测算法都会查看数据,并使用自动化过程来确定理想的决策边界,以便最大限度地提高正确检测新数据、之前未见数据的机会。

让我们来看一下一个常见的机器学习算法如何识别在图 6-3 中显示的样本数据中的决策边界。这个示例使用了一种名为逻辑回归的算法。

image

图 6-3:通过训练逻辑回归模型自动创建的决策边界

请注意,我们使用的是之前图表中相同的样本数据,其中灰点表示良性软件,黑点表示恶意软件。图中通过中心的线是逻辑回归算法通过观察数据学习到的决策边界。在线的右侧,逻辑回归算法将二进制文件是恶意软件的概率评估为大于 50%,而在线的左侧,则将二进制文件是恶意软件的概率评估为小于 50%。

现在请注意图表中的阴影区域。深灰色阴影区域是逻辑回归模型对文件是恶意软件非常有信心的区域。任何逻辑回归模型看到的新文件,只要其特征落在该区域内,应该具有较高的恶意软件概率。随着我们越来越接近决策边界,模型对文件是否是恶意软件或良性软件的信心越来越低。逻辑回归允许我们根据想要多积极地检测恶意软件,轻松地将决策边界向深色区域移动或向浅色区域移动。例如,如果我们将它向下移动,我们会捕捉到更多的恶意软件,但会增加误报。如果我们将它向上移动,我们会捕捉到更少的恶意软件,但会减少误报。

我想强调的是,逻辑回归和所有其他机器学习算法都可以在任意高维的特征空间中运行。图 6-4 展示了逻辑回归在稍高维度的特征空间中的工作原理。

在这个高维空间中,决策边界不再是直线,而是一个平面,它将三维空间中的点分开。如果我们转到四维或更多维度,逻辑回归将创建一个超平面,它是一个n维的平面状结构,将高维空间中的恶意软件点和良性软件点分开。

image

图 6-4:逻辑回归创建的通过假设的三维特征空间的平面决策边界

由于逻辑回归是一个相对简单的机器学习算法,它只能创建简单的几何决策边界,如直线、平面和更高维度的平面。其他机器学习算法可以创建更复杂的决策边界。举个例子,考虑图 6-5 所示的决策边界,它是由 k 最近邻算法(我稍后会详细讨论)给出的。

image

图 6-5:由 k 最近邻算法创建的决策边界

如你所见,这个决策边界不是平面,而是一个高度不规则的结构。同时需要注意的是,一些机器学习算法可以生成不相交的决策边界,这些边界将特征空间中的某些区域定义为恶意区,而某些区域定义为良性区,即使这些区域不是连续的。图 6-6 展示了一个具有这种不规则结构的决策边界,使用了一个具有更复杂恶意软件和良性软件模式的不同样本数据集。

image

图 6-6:由 k 最近邻算法创建的不相交决策边界

尽管决策边界是不连续的,但在机器学习术语中,通常仍将这些不相交的决策边界称为“决策边界”。你可以使用不同的机器学习算法来表达不同类型的决策边界,这种表达能力的差异也是我们在某些项目中选择一种机器学习算法而非另一种的原因。

现在我们已经探讨了核心的机器学习概念,如特征空间和决策边界,接下来让我们讨论机器学习从业者所称的过拟合与欠拟合。

模型好坏的决定因素:过拟合与欠拟合

我不能过于强调过拟合与欠拟合在机器学习中的重要性。避免这两种情况是定义一个好的机器学习算法的标准。优秀、准确的检测模型能够捕捉到训练数据中关于区分恶意软件与良性软件的总体趋势,而不会被异常值或那些证明规则的例外所干扰。

欠拟合的模型忽略了异常值,但未能捕捉到总体趋势,导致在新数据和以前未见过的二进制文件上准确性较差。过拟合的模型被异常值干扰,方式与总体趋势无关,因此在以前未见过的二进制文件上也会产生较差的准确性。构建机器学习恶意软件检测模型的核心在于捕捉区分恶意与良性的软件的总体趋势。

我们通过图 6-7、6-8 和 6-9 中的欠拟合、拟合良好和过拟合模型的例子来说明这些术语。图 6-7 展示了一个欠拟合的模型。

image

图 6-7:欠拟合的机器学习模型

在这里,你可以看到黑色的点(恶意软件)聚集在图的右上方区域,而灰色的点(良性软件)聚集在左下方。然而,我们的机器学习模型仅仅将这些点从中间划分,粗略地分隔数据,而没有捕捉到斜向的趋势。由于模型没有捕捉到整体趋势,我们称之为欠拟合。

还需要注意的是,模型在图的所有区域中仅给出两种确定性:要么阴影是深灰色,要么是白色。换句话说,模型要么完全确定特征空间中的点是恶意的,要么完全确定它们是良性的。无法正确表达确定性也是该模型欠拟合的一个原因。

让我们对比图 6-7 中的欠拟合模型与图 6-8 中的良拟合模型。

image

图 6-8:良拟合的机器学习模型

在这种情况下,模型不仅捕捉到了数据的整体趋势,还根据其对特征空间中哪些区域是明确恶意、明确良性或处于灰色区域的估计,创建了一个合理的确定性模型。

请注意,从图的顶部到底部的决策线。模型有一个简单的理论来区分恶意软件和良性软件:在图的中间有一条垂直线和一个对角切口。同时请注意图中的阴影区域,它告诉我们模型仅确定图右上方的区域是恶意软件,并且仅确定图左下角的二进制文件是良性软件。

最后,让我们对比下图中的过拟合模型图 6-9 与图 6-7 中看到的欠拟合模型,以及图 6-8 中的良拟合模型。

图 6-9 中的过拟合模型未能捕捉到数据的整体趋势。相反,它过度关注数据中的例外情况,包括发生在灰色点(良性训练样本)聚集中的少量黑点(恶意软件训练样本),并围绕它们画出决策边界。同样,它也关注在恶意软件聚集中的少数良性软件样本,并围绕它们画出边界。

这意味着当我们看到新的、以前未见过的二进制文件,并且它们恰好具有将它们置于这些异常值附近的特征时,机器学习模型会认为它们是恶意软件,而它们几乎肯定是良性软件,反之亦然。实际上,这意味着该模型的准确性不会达到它应有的水平。

image

图 6-9:过拟合的机器学习模型

主要类型的机器学习算法

到目前为止,我已经用非常概括的方式讨论了机器学习,涉及了两种机器学习方法:逻辑回归和 k 近邻。在本章的剩余部分,我们将更深入地探讨逻辑回归、k 近邻、决策树和随机森林算法。我们在安全数据科学社区中经常使用这些算法。这些算法比较复杂,但它们背后的理念是直观且简单的。

首先,我们来看一下我们用来探索每种算法优缺点的样本数据集,见图 6-10。

我创建了这些数据集作为示例。在左侧,我们有我们的简单数据集,我已经在图 6-7、6-8 和 6-9 中使用过。在这种情况下,我们可以用简单的几何结构(如一条线)将黑色训练示例(恶意软件)与灰色训练示例(良性软件)分开。

右侧的数据集,我在图 6-6 中已经展示过,它之所以复杂,是因为我们无法仅用一条简单的线将恶意软件与良性软件区分开。但数据中仍然存在明显的模式:我们只需要使用更复杂的方法来创建决策边界。让我们看看不同算法在这两个样本数据集上的表现如何。

image

图 6-10:我们在本章中使用的两个样本数据集,黑点表示恶意软件,灰点表示良性软件

逻辑回归

如你之前所学,逻辑回归是一种机器学习算法,它创建一条线、一个平面或一个超平面(取决于你提供的特征数量),在几何上将训练数据中的恶意软件与良性软件分开。当你使用训练好的模型来检测新的恶意软件时,逻辑回归会检查一个先前未见过的二进制文件是否位于恶意软件的一侧还是良性软件的一侧,从而判断它是恶意的还是良性的。

逻辑回归的一个局限性是,如果你的数据无法通过简单的一条线或超平面分开,那么逻辑回归就不是正确的解决方案。你是否可以使用逻辑回归来解决你的问题,取决于你的数据和特征。例如,如果你的问题有许多单独的特征,而这些特征本身就是恶意性(或“良性”)的强指示器,那么逻辑回归可能是一个有效的方法。如果你的数据需要使用特征之间的复杂关系来判断文件是否是恶意软件,那么其他方法,比如 k 近邻、决策树或随机森林,可能会更合适。

为了说明逻辑回归的优缺点,我们来看看逻辑回归在我们的两个示例数据集上的表现,如图 6-11 所示。我们看到,逻辑回归在我们的简单数据集(左侧)上能有效地区分恶意软件和良性软件。相比之下,逻辑回归在我们复杂数据集(右侧)上的表现则不够理想。在这种情况下,逻辑回归算法变得困惑,因为它只能表示线性决策边界。你可以看到,线上两侧都有这两种二元类型,而阴影部分的灰色置信带与数据相比并没有多大意义。对于这个更复杂的数据集,我们需要使用一种能够表达更多几何结构的算法。

image

图 6-11:使用逻辑回归绘制的决策边界,基于我们的示例数据集

逻辑回归背后的数学原理

现在让我们看看逻辑回归如何检测恶意软件样本背后的数学原理。清单 6-1 展示了用于计算一个二进制文件是否是恶意软件的逻辑回归的 Python 伪代码。

def logistic_regression(compressed_data, suspicious_calls, learned_parameters): ➊
compressed_data = compressed_data * learned_parameters["compressed_data_weight"] ➋
    suspicious_calls = suspicious_calls * learned_parameters["suspicious_calls_weight"]
score = compressed_data + suspicious_calls + bias ➌
    return logistic_function(score)

def logistic_function(score): ➍
    return 1/(1.0+math.e**(-score))

清单 6-1:使用逻辑回归计算概率的伪代码

让我们逐步分析代码,理解这意味着什么。我们首先定义logistic_regression函数➊及其参数。其参数是代表二进制特征(compressed_datasuspicious_calls)的特征,分别表示压缩数据的量和它所发出的可疑调用次数,learned_parameters参数表示通过在训练数据上训练逻辑回归模型所学到的逻辑回归函数元素。我将在本章稍后讨论这些参数是如何被学习的;现在,先接受它们是从训练数据中得出的。

然后,我们取compressed_data特征➋并将其乘以compressed_data_weight参数。这个权重根据逻辑回归函数判断该特征对恶意软件的指示性来调整特征的大小。请注意,权重也可以是负数,这表示逻辑回归模型认为该特征是文件为良性指示的标志。

在下方的那一行,我们对suspicious_calls参数执行相同的步骤。然后,我们将这两个加权特征相加 ➌,再加上一个称为bias的参数(也是从训练数据中学习到的)。总的来说,我们取compressed_data特征,按其与恶意性的相关性进行缩放,添加suspicious_calls特征,也按其与恶意性的相关性进行缩放,再加上bias参数,后者表示逻辑回归模型认为我们应该对文件的可疑性有多大的警觉性。通过这些加法和乘法操作,我们得到一个score,它表示一个给定文件是恶意的可能性。

最后,我们使用logistic_function ➍将我们的可疑性得分转换为概率。图 6-12 直观地展示了这个函数是如何工作的。

image

图 6-12:逻辑回归中使用的逻辑函数的图示

在这里,逻辑函数接受一个分数(显示在 x 轴上),并将其转换为一个介于 0 和 1 之间的值(即概率)。

数学原理如何运作

让我们回到图 6-11 中看到的决策边界,看看这个数学是如何在实践中运作的。回想一下我们是如何计算我们的概率的:

logistic_function(feature1_weight * feature1 + feature2_weight*feature2 + bias)

例如,如果我们在图 6-11 中展示的特征空间的每个点上绘制结果概率,使用相同的特征权重和bias参数,我们最终会得到该图中显示的阴影区域,这些区域展示了模型“认为”恶意样本和良性样本的位置以及它的置信度。

如果我们设置一个阈值为 0.5(记住,当概率超过 50%时,文件被定义为恶意的),那么在图 6-11 中,线条就会作为我们的决策边界。我鼓励你实验我的示例代码,输入一些特征权重和偏差项,亲自试试。

注意

逻辑回归并不局限于仅使用两个特征。实际上,我们通常使用逻辑回归来处理数十、数百甚至数千个特征。但数学原理并没有改变:我们只需按照以下方式计算任何数量特征的概率:

logistic_function(feature1 * feature1_weight + feature2 * feature2_weight +
feature3 * feature3_weight ... + bias)

那么,逻辑回归是如何根据训练数据将决策边界放置在正确的位置的呢?它使用一种基于微积分的迭代方法,称为梯度下降。我们在本书中不会深入探讨这种方法的细节,但基本的思路是:无论使用的是线、平面还是超平面(这取决于你使用的特征数量),它都会通过迭代调整,最大化逻辑回归模型在训练集中的数据点是否为恶意样本或良性样本时给出正确答案的概率。

你可以训练逻辑回归模型,调整逻辑回归学习算法,以便得出关于什么构成恶意软件和良性软件的更简单或更复杂的理论。这些训练方法超出了本书的范围,但如果你有兴趣了解这些有用的方法,我鼓励你在谷歌上搜索“逻辑回归与正则化”,并在线阅读相关解释。

何时使用逻辑回归

逻辑回归相对于其他机器学习算法有明显的优缺点。逻辑回归的一个优点是可以轻松解释逻辑回归模型认为构成良性软件和恶意软件的标准。例如,我们可以通过查看模型的特征权重来理解一个给定的逻辑回归模型。权重较高的特征是模型认为恶意的特征,负权重的特征则是模型认为良性的特征。逻辑回归是一种相对简单的方法,当你所处理的数据中包含明确的恶意指示时,它可以很好地工作。但当数据更复杂时,逻辑回归往往会失败。

现在让我们探讨另一种简单的机器学习方法,它可以表示更复杂的决策边界:k-近邻算法。

K-近邻算法

k-近邻算法是一种基于这样一个思想的机器学习算法:如果一个二进制文件在特征空间中接近其他恶意的二进制文件,那么它就是恶意的;如果它的特征使得它接近良性二进制文件,那么它必须是良性的。更准确地说,如果与一个未知二进制文件最接近的k个二进制文件中的大多数是恶意的,那么该文件就是恶意的。请注意,k表示我们自己定义的邻居数量,取决于我们认为在确定样本是否是恶意或良性时,应该考虑多少个邻居。

在现实世界中,这个方法是直观的。例如,如果你有一个包含篮球运动员和乒乓球运动员的体重和身高数据集,篮球运动员的体重和身高很可能彼此更接近,而不是和乒乓球运动员的数据接近。类似地,在安全领域,恶意软件通常与其他恶意软件具有相似的特征,而良性软件则通常与其他良性软件有相似的特征。

我们可以将这个思想转化为 k-近邻算法,使用以下步骤计算二进制文件是否是恶意的或良性的:

  1. 提取二进制文件的特征,并在特征空间中找到与其最接近的k个样本。

  2. 将接近样本的恶意软件样本数量除以k,以获得最近邻中恶意的比例。

  3. 如果足够多的样本是恶意的,则将该样本定义为恶意的。

图 6-13 展示了 k-近邻算法的高层次工作原理。

image

图 6-13:k-最近邻用于检测之前未见过的恶意软件的示意图

我们看到左上角是一组恶意软件的训练样本,右下角是一组良性软件的样本。我们还看到一个新的未知二进制文件,它与其三个最近的邻居相连。在这种情况下,我们将 k 设置为 3,这意味着我们正在查看未知二进制文件的三个最近邻居。由于这三个最近邻居都是恶意的,我们会将这个新二进制文件分类为恶意的。

K-最近邻背后的数学原理

现在让我们讨论一下使我们能够计算新未知二进制特征与训练集中的样本之间距离的数学原理。我们使用一个距离函数来实现这一点,它告诉我们新样本与训练集样本之间的距离。最常见的距离函数是欧几里得距离,即我们特征空间中两点之间最短路径的长度。列表 6-2 显示了我们样本二维特征空间中欧几里得距离的伪代码。

image

列表 6-2:编写 euclidean_distance 函数的伪代码

让我们一起来看看这段代码中的数学是如何工作的。列表 6-2 通过计算一对样本之间基于特征差异的距离来工作。首先,调用者传入二进制特征 ➊,其中 compression1 是第一个样本的压缩特征,suspicious_calls1 是第一个样本的 suspicious_calls 特征,compression2 是第二个样本的压缩特征,suspicious_calls2 是第二个样本的可疑呼叫特征。

然后我们计算每个样本的压缩特征之间的平方差 ➋,并计算每个样本的可疑呼叫特征之间的平方差 ➌。我们不会在此讨论为什么使用平方距离,但需要注意的是,结果差异始终为正数。最后,我们计算这两个差异的平方根,它就是两个特征向量之间的欧几里得距离,并将其返回给调用者 ➍。虽然也有其他计算样本之间距离的方法,但欧几里得距离是 k-最近邻算法中最常用的,它在安全数据科学问题中表现良好。

选择投票的邻居数量

现在让我们看看 k-最近邻算法在本章中使用的样本数据集上产生的决策边界和概率。在图 6-14 中,我将 k 设置为 5,因此允许 5 个最接近的邻居进行“投票”。

image

图 6-14:当 k 设置为 5 时,k-最近邻创建的决策边界

但是在图 6-15 中,我将 k 设置为 50,允许 50 个最接近的邻居进行“投票”。

image

图 6-15:当 k 设置为 50 时,k 近邻算法创建的决策边界

请注意,模型在不同邻居投票数的情况下差异明显。图 6-14 中的模型展示了一个复杂、曲折的决策边界,适用于两个数据集,这个模型是过拟合的,因为它在异常值周围画出了局部的决策边界,但又是欠拟合的,因为它未能捕捉到简单的、普遍的趋势。相比之下,图 6-15 中的模型非常适合这两个数据集,因为它没有被异常值干扰,能够清晰地识别出一般趋势。

正如你所看到的,k 近邻算法能够生成比逻辑回归更复杂的决策边界。我们可以通过调整k(即投票判断样本是恶意还是良性的邻居数目)来控制决策边界的复杂度,从而防止过拟合和欠拟合。而图 6-11 中的逻辑回归模型完全判断错误,k 近邻算法则能很好地区分恶意软件和良性软件,尤其是当我们让 50 个邻居参与投票时。由于 k 近邻算法不受线性结构的限制,它只是通过查看每个点的最近邻居来做出决策,因此它能够创建具有任意形状的决策边界,从而更有效地建模复杂的数据集。

何时使用 K 近邻算法

K 近邻算法是一个值得考虑的好算法,尤其是当你的数据中,特征无法直接映射到可疑性概念时,但接近恶意样本却能强烈指示恶意性。例如,如果你试图将恶意软件按代码相似度划分为不同的家族,k 近邻算法可能是一个不错的选择,因为你希望将一个恶意软件样本分类到某个家族中,如果它的特征与该家族中的已知成员相似的话。

使用 k 近邻算法的另一个原因是,它能够清楚地解释为什么它做出了某个分类决策。换句话说,它很容易识别和比较样本与未知样本之间的相似性,从而弄清楚算法为什么将其分类为恶意软件或良性软件。

决策树

决策树是另一种常用于解决检测问题的机器学习方法。决策树通过训练过程自动生成一系列问题,以决定某个二进制文件是否为恶意软件,类似于“二十个问题”游戏。图 6-16 展示了我通过对本章使用的简单数据集进行训练,自动生成的决策树。让我们按照树中逻辑的流程来分析。

image

图 6-16:为我们的简单数据集示例学习到的决策树

决策树的流程开始时,我们将从一个新的、之前未见过的二进制文件中提取的特征输入到树中。然后,树定义出一系列问题来询问这个二进制文件的特征。树顶端的框,我们称之为节点,提出第一个问题:树中的可疑调用次数是否小于或等于 40.111?请注意,决策树在这里使用浮动小数点数字,因为我们已将每个二进制文件中的可疑调用次数归一化到 0 到 100 之间的范围。如果答案是“是”,我们接着问另一个问题:文件中压缩数据的百分比是否小于或等于 37.254?如果答案是“是”,我们继续问下一个问题:二进制文件中的可疑调用次数是否小于或等于 33.836?如果答案是“是”,我们就到达了决策树的终点。此时,二进制文件是恶意软件的概率为 0%。

图 6-17 展示了这个决策树的几何解释。

image

图 6-17:决策树为我们的简单数据集示例创建的决策边界

在这里,阴影区域表示决策树认为样本是恶意的地方。较亮的区域表示决策树认为样本是良性的地方。由图 6-16 中的一系列问题和答案所分配的概率应该与图 6-17 中的阴影区域相对应。

选择一个好的根节点

那么我们如何使用机器学习算法从训练数据中生成像这样的决策树呢?基本的想法是,决策树从一个叫做根节点的初始问题开始。最好的根节点是那种对于一种类型的大部分(如果不是全部)样本,能得到“是”的答案,对于另一种类型的大部分(如果不是全部)样本,能得到“否”的答案。例如,在图 6-16 中,根节点问题询问一个之前未见过的二进制文件是否有 40.111 次或更少的调用。(请注意,这里每个二进制文件的调用次数已归一化为 0 到 100 的范围,使得浮动小数点值是有效的。)从图 6-17 中的垂直线可以看到,大部分良性数据的调用次数少于这个数字,而大部分恶意数据的调用次数则超过这个数目,这使得这个问题成为一个很好的初始问题。

选择后续问题

选择了根节点之后,可以使用类似选择根节点时的方法来选择下一个问题。例如,根节点让我们将样本分成了两组:一组可疑调用次数小于或等于 40.111(负特征空间),另一组可疑调用次数大于 40.111(正特征空间)。为了选择下一个问题,我们只需要提出那些能够进一步区分每个特征空间区域内的恶意和良性训练样本的问题。

我们可以从图 6-16 和 6-17 中看到这一点。例如,图 6-16 显示,在我们提出有关可疑调用二进制文件数量的初始“根”问题后,我们会询问关于二进制文件中压缩数据量的问题。图 6-17 显示了我们为什么这么做的原因:在我们提出关于可疑函数调用的第一个问题之后,图中有一个粗略的决策边界,将大多数恶意软件与大多数良性软件分开。我们如何通过提出后续问题来进一步细化决策边界呢?从图中可以清楚地看到,接下来最好的问题是关于二进制文件中压缩数据量的问题,它将进一步细化我们的决策边界。

何时停止提问

在我们创建决策树的过程中,某个时刻我们需要决定决策树何时停止提问,并仅根据我们对答案的确信程度来判断一个二进制文件是良性还是恶意的。一种方法是简单地限制决策树可以提出的问题数量,或者限制其深度(即我们可以对任何二进制文件提出的最大问题数)。另一种方法是允许决策树继续生长,直到我们完全确定训练集中每个示例是恶意软件还是良性软件。

限制树的大小的优势在于,如果树更简单,我们就有更大的机会得出正确的答案(想想奥卡姆剃刀——越简单的理论越好)。换句话说,如果我们保持树的简洁,决策树就更不容易过拟合训练数据。

相反,如果我们欠拟合训练数据,允许树长到最大尺寸可能会很有用。例如,允许树进一步生长会增加决策边界的复杂度,而如果我们欠拟合的话,这正是我们想要的。一般来说,机器学习从业者通常会尝试多个深度,或者允许树在以前未见过的二进制文件上达到最大深度,并重复这个过程,直到获得最准确的结果。

使用伪代码探索决策树生成算法

现在让我们来看看一个自动化的决策树生成算法。你已经了解,这个算法背后的基本思想是通过找到最能增加我们对训练样本是否为恶意或良性的确信度的问题来创建树的根节点,然后再找到后续的问题,以进一步增加我们的确信度。一旦算法对训练样本的确信度超过了我们事先设定的某个阈值,它就应该停止提问并做出决策。

在程序上,我们可以递归地执行这一过程。清单 6-3 中的类 Python 伪代码以简化形式展示了构建决策树的完整过程。

   tree = Tree()
   def add_question(training_examples):
    ➊ question = pick_best_question(training_examples)
    ➋ uncertainty_yes,yes_samples=ask_question(question,training_examples,"yes")
    ➌ uncertainty_no,no_samples=ask_question(question,training_examples,"no")
    ➍ if not uncertainty_yes < MIN_UNCERTAINTY:
          add_question(yes_samples)
    ➎ if not uncertainty_no < MIN_UNCERTAINTY:
          add_question(no_samples)
➏ add_question(training_examples)

清单 6-3:构建决策树算法的伪代码

伪代码递归地向决策树添加问题,从根节点开始,一直到算法确信决策树可以提供一个高度确定的答案,来判断一个新文件是良性还是恶意。

当我们开始构建树时,我们使用pick_best_question()来选择我们的根节点 ➊(暂时不必担心这个函数如何工作)。然后,我们查看对于那些我们对初始问题回答“是”的训练样本,现在我们有多少不确定性 ➋。这将帮助我们决定是否需要继续对这些样本提问,或者我们是否可以停止,并预测这些样本是恶意的还是良性的。我们对那些我们对初始问题回答“否”的样本做同样的事情 ➌。

接下来,我们检查对于那些我们回答“是”(uncertainty_yes)的样本,其不确定性是否足够低,以决定它们是恶意的还是良性的 ➍。如果我们此时能确定它们是恶意的还是良性的,我们就不再提出其他问题。但是如果不能,我们会再次调用add_question(),使用yes_samples(即我们回答“是”的样本数量)作为输入。这是递归的经典例子,递归是一种调用自身的函数。我们使用递归对训练样本的子集执行与根节点相同的过程。接下来的if语句对我们的“否”样本执行相同的操作 ➎。最后,我们对我们的训练样本调用决策树构建函数 ➏。

pick_best_question()的具体工作原理涉及一些超出本书范围的数学内容,但其思路很简单。为了在决策树构建过程中任何时刻选择最佳问题,我们查看我们仍然不确定的训练样本,列举出可以对它们提出的所有问题,然后选择最能减少我们对这些样本是恶意软件还是良性软件的不确定性的问题。我们使用一种叫做信息增益的统计度量来衡量这种不确定性的减少。这种选择最佳问题的简单方法效果出奇地好。

注意

这是一个简化的示例,展示了真实世界中决策树生成机器学习算法的工作方式。我省略了计算给定问题如何增加我们对文件是否为恶意的确定性的数学部分。

现在让我们看看在本章中使用的两个示例数据集上决策树的表现。图 6-18 展示了决策树检测器学习到的决策边界。

image

图 6-18:我们示例数据集通过决策树方法生成的决策边界

在这种情况下,我们不是为树设置最大深度,而是允许它们生长到没有假阳性或假阴性,相对于训练数据而言,每个训练样本都能被正确分类的位置。

请注意,决策树只能在特征空间中绘制水平和垂直线,即使当看起来曲线或对角线可能更合适时也是如此。这是因为决策树仅允许我们在单个特征上表达简单的条件(例如,大于或等于、小于或等于),这总是导致水平或垂直线。

你还可以看到,尽管这些示例中的决策树成功地将良性软件与恶意软件分开,但决策边界看起来非常不规则,并且存在奇怪的伪影。例如,恶意软件区域以奇怪的方式扩展到良性软件区域,反之亦然。从积极的一面来看,决策树在为复杂数据集创建决策边界方面远远优于逻辑回归。

现在让我们将图 6-18 中的决策树与图 6-19 中的决策树模型进行比较。

image

图 6-19:由有限深度决策树生成的示例数据集的决策边界

图 6-19 中的决策树使用了与图 6-18 中相同的决策树生成算法,唯一的区别是我将树的深度限制为五个节点。这意味着,对于任何给定的二叉树,我最多可以问五个关于其特征的问题。

结果非常显著。虽然图 6-18 中显示的决策树模型明显是过拟合的,专注于异常值并绘制出过于复杂的边界,未能捕捉到总体趋势,图 6-19 中的决策树则更加优雅地拟合了数据,在两个数据集中识别出了一个普遍的模式,而没有专注于异常值(唯一的例外是简单数据集右上方较窄的决策区域)。正如你所看到的,选择一个合适的最大决策树深度对你的基于决策树的机器学习检测器有很大影响。

何时使用决策树

由于决策树既具有表现力又简单,它们可以通过简单的是或否问题来学习简单的以及高度不规则的边界。我们还可以设置最大深度,以控制它们对恶意软件与良性软件的理解应该有多简单或多复杂。

不幸的是,决策树的缺点是它们通常无法生成非常准确的模型。造成这种情况的原因很复杂,但与决策树表达不规则决策边界的事实有关,这些边界不能很好地拟合训练数据,也不能很好地推广到先前未见过的示例。

同样,决策树通常不会在其决策边界周围学习到准确的概率。我们可以通过检查图 6-19 中决策边界周围的阴影区域来看到这一点。衰减并不自然或渐进,且并没有在它应该发生的区域——恶意软件和良性软件样本重叠的地方发生。

接下来,我将讨论随机森林方法,它结合了多棵决策树以获得更好的结果。

随机森林

尽管安全社区在恶意软件检测中大量依赖决策树,但他们几乎从不单独使用它们。相反,成百上千棵决策树会联合起来,通过一种叫做随机森林的方法进行检测。我们不是训练一棵决策树,而是训练许多,通常是一百棵或更多,但我们以不同的方式训练每棵决策树,使其对数据有不同的看法。最后,为了决定一个新的二进制文件是恶意还是良性,我们让这些决策树进行投票。一个二进制文件是恶意软件的概率是正投票数除以总树数。

当然,如果所有的决策树完全相同,它们将会做出相同的投票,随机森林将简单地复制个别决策树的结果。为了解决这个问题,我们希望决策树对什么构成恶意软件和良性软件有不同的看法,我们使用接下来要讨论的两种方法,将这种多样性引入我们的决策树集合中。通过引入多样性,我们在模型中产生了“集体智慧”的动态,通常会导致一个更准确的模型。

我们使用以下步骤来生成随机森林算法:

  1. 训练:对于我们计划生成的每棵树(通常是 100 棵或更多)

    • 从我们的训练集中随机抽取一些训练样本。

    • 从随机样本中建立一棵决策树。

    • 对于我们建立的每棵树,每次我们考虑“提问”时,只考虑向少数特征提问,忽略其他特征。

  2. 对一个以前未见过的二进制文件进行检测

    • 对每棵树在二进制文件上进行检测。

    • 根据投票“是”的树的数量决定二进制文件是否为恶意软件。

为了更详细地理解这一点,让我们查看使用随机森林方法在我们两个示例数据集上生成的结果,如图 6-20 所示。这些结果是使用 100 棵决策树生成的。

image

图 6-20:使用随机森林方法创建的决策边界

与图 6-18 和图 6-19 中显示的单个决策树结果相比,随机森林能够为简单和复杂的数据集表达更平滑、更直观的决策边界。实际上,随机森林模型对训练数据集的拟合非常干净,没有任何锯齿边缘;该模型似乎已经学到了关于“恶意与良性”构成的良好理论,适用于这两个数据集。

此外,阴影区域是直观的。例如,离良性或恶意样本越远,随机森林对于样本是恶意还是良性的判断越不确定。这对随机森林在未见过的二进制文件上的表现预示着积极的前景。事实上,正如你将在下一章看到的,随机森林是所有本章讨论的方法中在未见过的二进制文件上表现最好的模型。

为了理解为什么随机森林相比单个决策树能够画出如此干净的决策边界,让我们思考一下这 100 棵决策树在做什么。每棵树只看到大约三分之二的训练数据,并且每当它做出关于要问什么问题的决策时,只会考虑一个随机选择的特征。这意味着在幕后,我们有 100 个不同的决策边界,这些边界被平均以创建示例中的最终决策边界(以及阴影区域)。这种“集体智慧”的动态创造了一个能够识别数据趋势的汇聚意见,比单个决策树能够以更复杂的方式识别数据趋势。

总结

在本章中,你获得了基于机器学习的恶意软件检测的高层次介绍,以及机器学习的四种主要方法:逻辑回归、k 近邻、决策树和随机森林。基于机器学习的检测系统可以自动化编写检测签名的工作,并且在实践中,它们通常比手写签名表现得更好。

在接下来的章节中,我将向你展示这些方法在实际恶意软件检测问题中的表现。具体而言,你将学习如何使用开源的机器学习软件构建机器学习检测器,以准确地将文件分类为恶意或良性,并且如何使用基本统计学来评估你的检测器在之前未见过的二进制文件上的表现。

第七章:评估恶意软件检测系统

image

在上一章中,你学习了机器学习如何帮助你构建恶意软件检测器。在本章中,你将学习预测恶意软件检测系统性能所需的基本概念。你在这里学到的概念对于改进你构建的任何恶意软件检测系统至关重要,因为如果没有衡量系统性能的方法,你将无法知道如何改进它。请注意,虽然本章致力于介绍基本的评估概念,第八章将继续这一话题,介绍诸如交叉验证等重要评估概念。

首先,我介绍了检测准确性评估的基本概念,然后我介绍了在评估系统性能时,关于你部署系统的环境的更高级的概念。为此,我将带你通过一个假设的恶意软件检测系统的评估。

四种可能的检测结果

假设你在一个软件二进制文件上运行恶意软件检测系统,并得到系统关于该二进制文件是否恶意的“判断”。如图 7-1 所示,可能出现四种结果。

image

图 7-1:四种可能的检测结果

这些结果可以定义如下:

真阳性 二进制文件是恶意软件,系统说它是恶意软件。

假阴性 二进制文件是恶意软件,但系统说它不是恶意软件。

假阳性 二进制文件不是恶意软件,但系统说它是恶意软件。

真阴性 二进制文件不是恶意软件,系统也说它不是恶意软件。

如你所见,恶意软件检测系统可能产生不准确结果的两种情况:假阴性和假阳性。实际上,真阳性和真阴性结果是我们希望得到的,但通常很难获得。

你将在本章中看到这些术语。事实上,大多数检测评估理论都是建立在这一简单词汇基础上的。

真阳性和假阳性率

现在假设你想使用一组良性软件和恶意软件来测试检测系统的准确性。你可以在每个二进制文件上运行检测器,并记录整个测试集中检测器给出的四种可能结果。在这一点上,你需要一些总结统计量,以便整体了解系统的准确性(即,系统生成假阳性或假阴性的可能性有多大)。

其中一个总结统计量是检测系统的真阳性率,你可以通过将测试集中真阳性的数量除以测试集中恶意软件样本的总数来计算它。因为这计算了系统能够检测到的恶意软件样本的百分比,因此它衡量了系统在“看到”恶意软件时识别恶意软件的能力。

然而,仅仅知道检测系统在看到恶意软件时会触发警报,仍然不足以评估其准确性。例如,如果你只使用真正正例率作为评估标准,一个简单的函数,针对所有文件都说“是的,这是恶意软件”,将会产生一个完美的真正正例率。检测系统的真正考验在于它是否在看到恶意软件时说“是的,这是恶意软件”,而在看到良性软件时说“不是,这不是恶意软件”。

为了衡量一个系统判断某个文件是否为恶意软件的能力,你还需要衡量系统的假正例率,即系统在看到良性软件时错误地触发恶意软件警报的频率。你可以通过将系统标记为恶意软件的良性样本数量除以测试的所有良性样本的总数来计算系统的假正例率。

真正正例率与假正例率之间的关系

在设计一个检测系统时,你希望尽可能降低假正例率,同时尽可能提高真正正例率。除非你构建一个真正完美的恶意软件检测系统,它永远不会出错(但考虑到恶意软件的不断演化,这实际上是不可能的),否则在追求高真正正例率和低假正例率之间总会存在矛盾。

为了理解为什么会这样,假设有一个检测系统,在决定某个二进制文件是否是恶意软件之前,首先会将所有表明该二进制文件是恶意软件的证据加起来,从而为该二进制文件生成一个可疑性评分。我们将这个假设的可疑性评分生成系统称为 MalDetect。图 7-2 展示了 MalDetect 可能为 12 个示例二进制文件输出的值,其中圆圈代表各个软件二进制文件。二进制文件距离右侧越远,MalDetect 给出的可疑性评分越高。

image

图 7-2:假设的 MalDetect 系统为个别软件二进制文件输出的可疑性评分

可疑性评分具有信息性,但为了计算 MalDetect 在我们文件上的真正正例率和假正例率,我们需要将 MalDetect 的可疑性评分转换为关于某个软件二进制文件是否为恶意软件的“是”或“否”答案。为此,我们使用一个阈值规则。例如,我们决定,如果可疑性评分大于或等于某个数字,则该二进制文件会触发恶意软件警报。如果评分低于阈值,则不会触发。

这种阈值规则是将可疑性评分转换为二进制检测选择的标准方法,但我们应该将阈值设置在哪里呢?问题在于没有正确答案。图 7-3 展示了这一困境:我们设置的阈值越高,假正例的可能性越低,但假负例的可能性则越高。

image

图 7-3:决定阈值时假阳性率与真阳性率之间关系的示意图

例如,我们考虑图 7-3 中显示的最左侧阈值,在这个阈值左侧的二进制文件被分类为良性,而右侧的则被分类为恶意软件。由于这个阈值较低,我们得到了很高的真阳性率(正确分类了 100%的恶意软件样本),但假阳性率却很差(错误地将 33%的良性样本分类为恶意)。

我们的直觉可能是提高阈值,使只有具有较高可疑性得分的样本才会被认为是恶意软件。这种解决方案由图 7-3 中的中间阈值给出。在这里,假阳性率下降到 0.17,但不幸的是,真阳性率也下降到 0.83。如果我们继续将阈值向右移动,如最右侧的阈值所示,我们消除了所有假阳性,但只检测到 50%的恶意软件。

如你所见,实际上并不存在完美的阈值。一个能够产生低假阳性率(好)的检测阈值通常会漏掉更多的恶意软件,从而导致较低的真阳性率(不好)。相反,使用一个具有较高真阳性率(好)的检测阈值也会提高假阳性率(不好)。

ROC 曲线

检测系统的真阳性率与假阳性率之间的权衡是所有检测器面临的普遍问题,不仅仅是恶意软件检测器。工程师和统计学家经过长时间的思考,提出了接收器操作特性(ROC)曲线来描述和分析这一现象。

注意

如果你对“接收器操作特性”(Receiver Operating Characteristic)这个词感到困惑,不用担心——这个词的确令人困惑,它与 ROC 曲线最初被开发时的背景有关,即基于雷达的物体探测。

ROC 曲线通过绘制不同阈值设置下的假阳性率与其相关的真阳性率,来表征检测系统。这有助于我们评估假阳性率与真阳性率之间的权衡,从而确定适合我们情况的“最佳”阈值。

例如,针对我们假设的 MalDetect 系统,在图 7-3 中,当假阳性率为 0 时(低阈值),系统的真阳性率为 0.5;当假阳性率为 0.33 时(高阈值),系统的真阳性率为 1.00。

图 7-4 展示了这个过程的更详细情况。

image

图 7-4:ROC 曲线的含义及其构建方式的示意图

为了构建 ROC 曲线,我们从图 7-3 中使用的三个阈值开始,绘制它们产生的假阳性率和真阳性率,这些数据展示在图 7-3 的左半部分。图 7-4 右侧的图显示了相同的内容,但涵盖了所有可能的阈值。正如你所看到的,假阳性率越高,真阳性率也越高。同样,假阳性率越低,真阳性率也越低。

ROC 曲线的“曲线”是二维 ROC 图中的一条线,表示我们认为检测系统在所有可能的假阳性值下的真阳性率表现,以及我们认为检测系统在所有可能的真阳性值下的假阳性率表现。生成这种曲线的方法有很多,但超出了本书的范围。

然而,一种简单的方法是尝试许多阈值,观察相应的假阳性率和真阳性率,绘制它们并用线连接这些点。这个连接的线,如图 7-4 右侧的图所示,便是我们的 ROC 曲线。

在评估中考虑基准率

正如你所看到的,ROC 曲线可以告诉你系统在将恶意二进制文件判定为恶意的速率(真阳性率)和将无害二进制文件判定为恶意的速率(假阳性率)方面的表现。然而,ROC 曲线并不能告诉你系统报警中有多少比例会是实际的真阳性,这就是我们所说的系统的精确度。系统的精确度与系统遇到的二进制文件中实际是恶意软件的比例相关,这就是我们所说的基准率。下面是每个术语的详细解释:

精确度 系统检测报警中真实阳性(即实际检测到的恶意软件)所占的比例。换句话说,精确度是检测系统的真实阳性 /(真实阳性 + 假阳性),当它在一些二进制文件集上进行测试时的表现。

基准率 系统接收到的数据中符合我们需求的质量比例。在我们的例子中,基准率指的是实际恶意软件的二进制文件所占的百分比。

我们将在下一节讨论这两个指标之间的关系。

基准率如何影响精确度

尽管检测系统的真阳性率和假阳性率在基准率变化时不会改变,但系统的精确度会受到恶意软件基准率变化的影响——通常影响非常显著。为了理解这一点,我们来看以下两个案例。

假设 MalDetect 的假阳性率为 1%,真阳性率为 100%。现在假设我们将 MalDetect 部署在一个我们已经知道没有恶意软件的网络上(可能这个网络刚刚在实验室中创建)。因为我们事先知道网络中没有恶意软件,所以 MalDetect 发出的每一个警报都必定是一个假阳性,因为 MalDetect 遇到的唯一二进制文件将是良性软件。换句话说,精度将是 0%。

相比之下,如果我们将 MalDetect 运行在一个完全由恶意软件组成的数据集上,那么它的警报将永远不会是假阳性:由于数据集中没有良性软件,MalDetect 永远没有机会产生假阳性。因此,精度将是 100%。

在这两种极端情况下,基本比率对 MalDetect 的精度产生了巨大影响,或者说,它的警报是误报的概率。

在部署环境中估算精度

现在你知道,根据测试数据集中恶意软件的比例(基本比率),你的系统将产生非常不同的精度值。如果你想根据你部署环境中基本比率的估计来估算系统的精度,你只需使用你部署环境的估计基本比率来估算精度公式中的变量:真阳性 / (真阳性 + 假阳性)。你需要三个数字:

  • 真阳性率 (TPR),即系统正确检测到的恶意软件样本的比例

  • 假阳性率 (FPR),即系统错误报警的良性样本的比例

  • 二进制文件的基本比率 (BR),即你将使用该系统的二进制文件的比例(例如,你预计从盗版网站下载的二进制文件中有多少比例会是恶意软件,如果这是你使用系统的场景)

精度公式的分子——真阳性的数量——可以通过真阳性率 × 基本比率来估算,从而得出系统将正确检测到的恶意软件的比例。同样,公式的分母——即(真阳性 + 假阳性)——可以通过真阳性率 × 基本比率 + 假阳性率 × (1 – 基本比率)来估算,从而得出系统将报警的所有二进制文件的比例,通过计算将正确检测到的恶意软件二进制文件和产生假阳性的良性软件二进制文件的比例。

总结来说,你可以通过以下方式计算系统的预期精度:

image

让我们考虑另一个例子,看看基准率如何对检测系统的性能产生深远的影响。例如,假设我们有一个检测系统,它的真实正例率为 80%,假正例率为 10%,并且我们运行的 50%的软件二进制文件预计是恶意软件。这将导致预期精度为 89%。但是,当基准率为 10%时,我们的精度降至 47%。

如果我们的基准率非常低,会发生什么呢?例如,在现代企业网络中,实际上很少有软件二进制文件是恶意软件。使用我们的精度公式,如果假设基准率为 1%(100 个二进制文件中有 1 个是恶意软件),我们得到的精度大约为 7.5%,这意味着我们系统的 92.5%的警报将是误报!如果假设基准率为 0.1%(1000 个二进制文件中有 1 个可能是恶意软件),我们得到的精度为 1%,这意味着 99%的警报将是误报!最后,在基准率为 0.01%(10000 个二进制文件中有 1 个可能是恶意软件——这可能是企业网络中最现实的假设)时,我们的预期精度降至 0.1%,这意味着我们系统的大部分警报将是误报。

从这一分析中可以得出的一个结论是,具有高假正例率的检测系统在企业环境中几乎永远不会有用,因为它们的精度太低。因此,构建恶意软件检测系统的一个关键目标是最小化假正例率,使得系统的精度合理。

另一个相关的结论是,当你进行本章前面介绍的 ROC 曲线分析时,如果你正在开发一个将在企业环境中部署的系统,你应该有效地忽略假正例率超过 1%的情况,因为任何更高的假正例率都可能导致系统的精度低到无法使用的程度。

总结

在本章中,你学习了基本的检测评估概念,包括真实正例率、假正例率、ROC 曲线、基准率和精度。你了解了最大化真实正例率和最小化假正例率在构建恶意软件检测系统中的重要性。由于基准率对精度的影响,如果你想在企业环境中部署你的检测系统,减少假正例率尤其重要。

如果你对这些概念还不完全熟悉,不用担心。你将在下一章中获得更多的练习,在那时你将从头开始构建和评估一个恶意软件检测系统。在这个过程中,你将学习更多与机器学习相关的评估概念,这将帮助你改进基于机器学习的检测器。

第八章:构建机器学习检测器

image

如今,得益于高质量的开源软件,它处理了实现机器学习系统的繁重数学工作,任何了解基本 Python 并掌握关键概念的人都可以使用机器学习。

在本章中,我将向你展示如何使用scikit-learn(我认为最受欢迎且最好的开源机器学习包)构建机器学习恶意软件检测系统。本章包含了大量的示例代码。主要的代码块可以在目录malware_data_science/ch8/code中访问,相应的示例数据可以在本书附带的代码和数据(以及虚拟机)中的目录malware_data_science/ch8/data中找到。

通过跟随文本、检查示例代码,并尝试提供的示例,你应该能够在本章结束时自信地构建和评估自己的机器学习系统。你还将学会构建一个通用的恶意软件检测器,并使用必要的工具为特定恶意软件家族构建恶意软件检测器。你在这里获得的技能将有广泛的应用,使你能够将机器学习应用于其他安全问题,比如检测恶意邮件或可疑的网络流量。

首先,你将学习在使用scikit-learn之前需要了解的术语和概念。接下来,你将使用scikit-learn实现一个基于你在第六章中学到的决策树概念的基本决策树检测器。然后,你将学习如何将特征提取代码与scikit-learn集成,构建一个使用真实世界特征提取和随机森林方法检测恶意软件的实际恶意软件检测器。最后,你将学习如何使用scikit-learn评估机器学习系统,并通过示例的随机森林检测器进行验证。

术语与概念

首先让我们回顾一下术语。开源库scikit-learn(简称sklearn)在机器学习社区中变得非常流行,因为它既强大又易于使用。许多数据科学家在计算机安全社区以及其他领域使用该库,许多人将其作为执行机器学习任务的主要工具箱。尽管sklearn不断更新,引入新的机器学习方法,但它提供了一个一致的编程接口,使得使用这些机器学习方法变得简单。

像许多机器学习框架一样,sklearn 需要以 向量 形式提供训练数据。向量是数字数组,其中数组的每个索引对应训练示例软件二进制文件的单个特征。例如,如果我们的机器学习检测器使用的软件二进制文件的两个特征是 是否压缩是否包含加密数据,那么训练示例二进制文件的特征向量可能是 [0,1]。在这里,向量中的第一个索引表示二进制文件是否压缩,零表示“否”,第二个索引表示二进制文件是否包含加密数据,1 表示“是”。

向量的使用可能会有些麻烦,因为你需要记住每个索引映射到的特征。幸运的是,sklearn 提供了辅助代码,可以将其他数据表示形式转换为向量形式。例如,你可以使用 sklearnDictVectorizer 类将训练数据的字典表示(例如 {"is compressed":1,"contains encrypted data":0})转换为 sklearn 操作的向量表示形式,比如 [0,1]。之后,你可以使用 DictVectorizer 恢复向量索引与原始特征名称之间的映射关系。

要训练基于 sklearn 的检测器,您需要将两个独立的对象传递给 sklearn:特征向量(如前所述)和标签向量。标签向量 每个训练示例包含一个数字,在我们的例子中,这个数字表示示例是恶意软件还是良性软件。例如,如果我们将三个训练示例传递给 sklearn,然后传递标签向量 [0,1,0],我们就在告诉 sklearn 第一个样本是良性软件,第二个样本是恶意软件,第三个样本是良性软件。根据惯例,机器学习工程师使用大写的 X 变量表示训练数据,使用小写的 y 变量表示标签。大小写的区别反映了数学中大写变量表示矩阵(我们可以将其视为向量数组),小写变量表示单个向量的惯例。你将在在线机器学习示例代码中看到这种惯例,在本书的后续部分,我也会使用这种惯例,以帮助你熟悉它。

sklearn框架使用了你可能会觉得陌生的其他术语。sklearn并不称机器学习基础的检测器为“检测器”,而是称其为“分类器”。在这个上下文中,术语分类器仅指一个将事物归类为两个或更多类别的机器学习系统。因此,检测器(这是我在本书中始终使用的术语)是分类器的一种特殊类型,将事物归入两个类别,比如恶意软件和良性软件。此外,sklearn的文档和 API 通常不使用训练这个术语,而是使用fit。例如,你会看到类似“使用训练示例拟合机器学习分类器”这样的句子,这相当于说“使用训练示例训练机器学习检测器”。

最后,在分类器的上下文中,sklearn并未使用detect这个术语,而是使用了predict这个术语。这个术语在sklearn的框架中,以及在更广泛的机器学习社区中被使用,当一个机器学习系统用于执行某个任务时,无论是预测一周后股票的价值,还是检测某个未知二进制文件是否为恶意软件。

构建一个基于决策树的玩具检测器

现在你已经熟悉了sklearn的技术术语,让我们创建一个简单的决策树,按照我们在第六章中讨论的内容,使用sklearn框架来实现。回想一下,决策树就像是在玩“20 个问题”的游戏,它会对输入向量提出一系列问题,以决定这些向量是恶意的还是良性的。我们将一步一步地构建一个决策树分类器,然后探讨一个完整程序的示例。清单 8-1 展示了如何从sklearn导入所需的模块。

from sklearn import tree
from sklearn.feature_extraction import DictVectorizer

清单 8-1:导入 sklearn 模块

我们导入的第一个模块是tree,这是sklearn的决策树模块。第二个模块是feature_extraction,它是sklearn的辅助模块,我们从中导入了DictVectorizer类。DictVectorizer类方便地将以可读字典形式提供的训练数据转换为sklearn所需的向量表示,以实际训练机器学习检测器。

在我们从sklearn导入所需模块之后,我们实例化了所需的sklearn类,如清单 8-2 所示。

classifier = ➊tree.DecisionTreeClassifier()
vectorizer = ➋DictVectorizer(sparse=➌False)

清单 8-2:初始化决策树分类器和向量化器

我们实例化的第一个类是DecisionTreeClassifier ➊,它代表了我们的检测器。尽管sklearn提供了许多参数来精确控制我们的决策树如何工作,但在这里我们没有选择任何参数,因此我们使用的是sklearn的默认决策树设置。

接下来我们实例化的类是DictVectorizer ➋。我们在构造函数中将sparse设置为False ➌,告诉sklearn我们不希望使用稀疏向量,尽管稀疏向量节省内存但操作复杂。由于sklearn的决策树模块不能使用稀疏向量,我们关闭了这个功能。

现在我们已经实例化了我们的类,可以初始化一些示例训练数据,如列表 8-3 所示。

   # declare toy training data
➊ training_examples = [
   {'packed':1,'contains_encrypted':0},
   {'packed':0,'contains_encrypted':0},
   {'packed':1,'contains_encrypted':1},
   {'packed':1,'contains_encrypted':0},
   {'packed':0,'contains_encrypted':1},
   {'packed':1,'contains_encrypted':0},
   {'packed':0,'contains_encrypted':0},
   {'packed':0,'contains_encrypted':0},
   ]
➋ ground_truth = [1,1,1,1,0,0,0,0]

列表 8-3:声明训练向量和标签向量

在这个例子中,我们初始化了两个结构——特征向量和标签向量——它们一起构成了我们的训练数据。特征向量被赋值给training_examples变量 ➊,以字典形式给出。如你所见,我们使用了两个简单的特征。第一个是packed,表示一个文件是否被打包,第二个是contains_encrypted,表示文件是否包含加密数据。标签向量被赋值给ground_truth变量 ➋,表示每个训练示例是恶意的还是良性的。在本书中,并且通常在安全数据科学家中,0 总是代表良性,1 总是代表恶意。在这种情况下,标签向量声明前四个特征向量是恶意的,后四个是良性的。

训练你的决策树分类器

现在我们已经声明了训练向量和标签向量,让我们通过调用决策树类实例的fit方法来训练我们的决策树模型,如列表 8-4 所示。

   # initialize the vectorizer with the training data
➊ vectorizer.fit(training_examples)
   # transform the training examples to vector form
➋ X = vectorizer.transform(training_examples)
   y = ground_truth # call ground truth 'y', by convention

列表 8-4:用训练数据初始化 向量化器

列表 8-4 中的代码首先初始化了我们在列表 8-2 中初始化的vectorizer类,通过调用fit方法 ➊。这里,fit方法告诉sklearn创建packed特征和contains_encrypted特征与向量数组索引之间的映射。然后,我们通过调用vectorizer类的transform方法 ➋,将基于字典的特征向量转换为数值向量形式。回想一下,我们将特征向量赋值给一个名为X的变量,将标签向量赋值给一个名为y的变量,这是机器学习领域中的命名约定。

现在我们的训练数据已经准备好,我们可以通过在决策树分类器实例上调用fit方法来训练我们的决策树检测器,如下所示:

# train the classifier (a.k.a. 'fit' the classifier)
classifier.fit(X,y)

正如你所看到的,训练sklearn检测器就这么简单。但在背后,sklearn正在通过算法过程来识别一个好的决策树,以便准确地检测新软件是恶意的还是良性的,这与我们在上一章讨论的算法思路相符。

现在我们已经训练好了检测器,接下来使用列表 8-5 中的代码来检测一个二进制文件是恶意的还是良性的。

   test_example = ➊{'packed':1,'contains_encrypted':0}
   test_vector = ➋vectorizer.transform(test_example)
➌ print classifier.predict(test_vector) # prints [1]

列表 8-5:判断二进制文件是否恶意

在这里,我们为一个假设的软件二进制文件实例化一个基于字典的特征向量➊,使用我们在代码中之前声明的vectorizer➋将其转换为数值向量形式,然后运行我们构建的决策树检测器➌,以确定该二进制文件是否恶意。当我们运行代码时,你会看到分类器“认为”该新二进制文件是恶意的(因为它的输出为“1”),当我们可视化决策树时,你将看到为什么会这样。

决策树的可视化

我们可以如清单 8-6 所示,直观地展示sklearn基于我们的训练数据自动创建的决策树。

# visualize the decision tree
with open(➊"classifier.dot","w") as output_file:
  ➋ tree.export_graphviz(
        classifier,
        feature_names=vectorizer.get_feature_names(),
        out_file=output_file
    )

import os
os.system("dot classifier.dot -Tpng -o classifier.png")

清单 8-6:使用 GraphViz 创建决策树图像文件

在这里,我们打开一个名为classifier.dot的文件➊,并使用sklearntree模块提供的export_graphviz()函数将决策树的网络表示写入该文件。然后,我们调用tree.export_graphviz➋将一个 GraphViz 的.dot文件写入classifier.dot,该文件将决策树的网络表示写入磁盘。最后,我们使用 GraphViz 的dot命令行程序创建一个图像文件,以可视化决策树,形式与第六章中学习的决策树相符。当你运行这个程序时,你应该会得到一个名为classifier.png的输出图像文件,像图 8-1 一样。

image

图 8-1:决策树可视化

尽管这个决策树的可视化在第六章中应该很熟悉,但它包含了一些新的词汇。每个框的第一行包含该节点提问的特征名称(在机器学习术语中,我们说节点“根据”该特征进行分裂)。例如,第一个节点根据特征“packed”进行分裂:如果二进制文件没有被打包,我们沿左箭头走;否则,我们沿右箭头走。

每个框中第二行的文本表示该节点的基尼指数,它衡量与该节点匹配的恶意软件和良性软件训练样本之间的不平等程度。基尼指数越高,匹配该节点的样本越倾向于恶意软件或良性软件。这意味着每个节点中较高的基尼指数是好的,因为训练样本越倾向于恶意软件或良性软件,我们就越确信新的测试样本是恶意的还是良性的。每个框中的第三行只给出了与该节点匹配的训练样本数量。

你会注意到,在决策树的叶节点中,框中的文本有所不同。这些节点不是“提问”,而是提供了“这个二进制文件是恶意的还是良性的?”这个问题的答案。例如,在最左边的叶节点中,我们看到“value = [2. 1.]”,这意味着两个良性训练样本与此节点匹配(没有打包且没有加密),一个恶意软件训练样本与此节点匹配。也就是说,如果我们到达这个节点,我们会给这个二进制文件分配 33%的概率是恶意软件(1 个恶意样本 / 3 个样本总数 = 33%)。这些框中的基尼值显示了在我们根据直接引导到这些节点的问题进行拆分时,关于二进制文件是恶意软件还是良性软件所获得的信息量。如你所见,检查sklearn生成的决策树可视化图可以帮助我们理解决策树是如何做出检测的。

完整示例代码

列表 8-7 展示了我迄今为止描述的决策树工作流的完整代码。既然我们已经逐步解析过这段代码,它现在应该对你来说非常易读。

#!/usr/bin/python

# import sklearn modules
from sklearn import tree
from sklearn.feature_extraction import DictVectorizer

# initialize the decision tree classifier and vectorizer
classifier = tree.DecisionTreeClassifier()
vectorizer = DictVectorizer(sparse=False)

# declare toy training data
training_examples = [
{'packed':1,'contains_encrypted':0},
{'packed':0,'contains_encrypted':0},
{'packed':1,'contains_encrypted':1},
{'packed':1,'contains_encrypted':0},
{'packed':0,'contains_encrypted':1},
{'packed':1,'contains_encrypted':0},
{'packed':0,'contains_encrypted':0},
{'packed':0,'contains_encrypted':0},
]
ground_truth = [1,1,1,1,0,0,0,0]

# initialize the vectorizer with the training data
vectorizer.fit(training_examples)

# transform the training examples to vector form
X = vectorizer.transform(training_examples)
y = ground_truth # call ground truth 'y', by convention

# train the classifier (a.k.a. 'fit' the classifier)
classifier.fit(X,y)

test_example = {'packed':1,'contains_encrypted':0}
test_vector = vectorizer.transform(test_example)
print `classifier.predict(test_vector)` # prints [1]

#visualize the decision tree
with open("classifier.dot","w") as output_file:
    tree.export_graphviz(
        classifier,
        feature_names=vectorizer.get_feature_names(),
        out_file=output_file
    )

import os
os.system("dot classifier.dot -Tpng -o classifier.png")

列表 8-7:完整的决策树工作流示例代码

我们刚刚探讨的示例机器学习恶意软件检测器展示了如何开始使用sklearn的功能,但它缺少一些构建真实世界恶意软件检测器所需的基本特性。现在,让我们探讨一下真实世界恶意软件检测器需要具备哪些内容。

构建真实世界的机器学习检测器(使用 sklearn)

要构建一个真实世界的检测器,你需要使用软件二进制文件的工业级特性,并编写代码从软件二进制文件中提取这些特性。工业级特性是指能够反映二进制文件内容复杂性的特性,这意味着我们需要使用数百或数千个特性。所谓“提取”特性,我指的是你需要编写代码来识别这些特性在二进制文件中的存在。你还需要使用数千个训练样本,并在大规模上训练一个机器学习模型。最后,你需要使用sklearn的更高级的检测方法,因为我们刚刚探讨的简单决策树方法无法提供足够的检测精度。

真实世界的特性提取

我之前使用的示例特征,如is packedcontains encrypted data,是简单的示例特征,这两个特征单独使用永远无法成为一个有效的恶意软件检测器。正如我之前提到的,现实世界中的恶意软件检测系统使用数百、数千甚至数百万个特征。例如,基于机器学习的检测器可能会使用软件二进制文件中的数百万个字符字符串作为特征。或者,它可能会使用软件二进制文件的可移植可执行文件(PE)头部的值、某个特定二进制文件导入的函数,或者这些特征的某种组合。尽管我们在本章中只讨论字符串特征,但让我们花一点时间探讨机器学习驱动的恶意软件检测中常用的特征类别,从字符串特征开始。

字符串特征

软件二进制文件的字符串特征是文件中所有至少达到最小长度的可打印字符的连续字符串(在本书中,最小长度设定为五个字符)。例如,假设一个二进制文件包含以下可打印字符序列:

["A", "The", "PE executable", "Malicious payload"]

在这种情况下,我们可以用作特征的字符串是"PE executable""Malicious payload",因为这两个字符串包含的字符超过了五个。

为了将字符串特征转换为sklearn能够理解的格式,我们需要将它们放入一个 Python 字典中。我们通过使用实际的字符串作为字典的键,并将它们的值设置为 1,以表示该二进制文件包含该字符串。例如,前面提到的示例二进制文件将得到一个特征向量{"PE executable": 1, "Malicious payload": 1}。当然,大多数软件二进制文件中包含的可打印字符串不止两个,而是成百上千个,而且这些字符串可以包含关于程序行为的丰富信息。

事实上,字符串特征与基于机器学习的检测方法非常契合,因为它们能够捕获有关软件二进制文件的大量信息。如果二进制文件是一个被打包的恶意软件样本,那么它可能包含的信息性字符串很少,这本身就是文件恶意的一个线索。另一方面,如果文件的资源部分没有被打包或混淆,那么这些字符串就能揭示文件的行为。例如,如果相关二进制程序发出 HTTP 请求,通常可以在文件的字符串集中看到类似"GET %s"的字符串。

然而,字符串特征也有一些局限性。例如,它们无法捕捉到二进制程序的实际逻辑,因为它们不包含实际的程序代码。因此,尽管字符串特征在打包的二进制文件中仍然可能是有用的,但它们并不能揭示打包二进制文件的实际行为。因此,基于字符串特征的检测器并不适合用于检测打包恶意软件。

可移植可执行文件(PE)头部特征

PE 头部特征是从每个 Windows .exe.dll文件中的 PE 头部元数据提取的。有关这些头部格式的更多信息,请参阅第一章。要从静态程序二进制文件中提取 PE 特征,可以使用该章节中提供的代码,然后将文件特征编码为 Python 字典形式,其中头部字段名称是字典的键,字段值是对应于每个键的值。

PE 头部特征与字符串特征互补。例如,尽管字符串特征通常能很好地捕捉程序的函数调用和网络传输,比如"GET %s"的例子,PE 头部特征则捕捉程序二进制文件的编译时间戳、PE 部分的布局,以及哪些部分被标记为可执行且它们在磁盘上的大小。它们还捕捉程序启动时分配的内存量,以及许多其他字符串特征无法捕捉的程序二进制的运行时特性。

即使在处理被打包的二进制文件时,PE 头部特征仍然能够有效区分恶意软件和良性软件。这是因为,尽管我们无法看到被混淆后的二进制代码,但我们仍然可以看到代码在磁盘上占用的空间,以及二进制文件如何在磁盘上布局,或如何在多个文件部分中被压缩。这些细节能够帮助机器学习系统区分恶意软件和良性软件。缺点是,PE 头部特征并不能捕捉程序在运行时实际执行的指令或它调用的函数。

导入地址表(IAT)特征

导入地址表(IAT),你在第一章中学习到的,也是机器学习特征的重要来源。IAT 包含了软件二进制文件从外部 DLL 文件导入的函数和库的列表。因此,IAT 包含有关程序行为的重要信息,可以用来补充上一节中描述的 PE 头部特征。

要将 IAT 作为机器学习特征的来源,你需要将每个文件表示为一个特征字典,其中导入的库和函数的名称是键,键的值为 1,表示该文件包含特定的导入项(例如,键为"KERNEL32.DLL:LoadLibraryA",其中KERNEL32.DLL是 DLL,LoadLibraryA是函数调用)。通过这种方式计算 IAT 特征后,得到的特征字典可能是{ KERNEL32.DLL:LoadLibraryA: 1, ... },其中我们将 1 分配给二进制文件中出现的任何键。

在我构建恶意软件检测器的经验中,我发现 IAT 特征单独使用时很少能发挥良好的效果——尽管这些特征能够捕捉到有关程序行为的有用高级信息,但恶意软件经常会混淆 IAT,使其看起来像良性软件。即使恶意软件没有进行混淆,它通常也会导入与良性软件相同的 DLL 调用,这使得仅凭 IAT 信息很难区分恶意软件和良性软件。最后,当恶意软件被打包(被压缩或加密,直到恶意软件执行并解压或解密后才能看到真正的恶意代码)时,IAT 只包含打包器使用的导入,而不是恶意软件使用的导入。也就是说,当你将 IAT 特征与其他特征,如 PE 头特征和字符串特征结合使用时,它们可以提高系统的准确性。

N-grams

到目前为止,你已经学习了不涉及顺序概念的机器学习特征。例如,我们讨论了字符串特征来检查二进制文件是否包含特定字符串,但没有讨论特定字符串在二进制文件布局中是否位于另一个字符串之前或之后。

但有时顺序很重要。例如,我们可能会发现一个重要的恶意软件家族只导入常用的函数,但它们按非常特定的顺序导入,这样当我们观察这些函数的顺序时,就知道我们看到的是该恶意软件家族,而不是良性软件。为了捕捉这种顺序信息,你可以使用一个机器学习概念,叫做 N-gram。

N-gram 听起来比实际要复杂:它们只需要按照特征出现的顺序排列,然后将长度为 n 的窗口滑过序列,在每一步中将窗口内的特征序列视为一个整体特征。例如,如果我们有序列 ["how", "now", "brown", "cow"],并且我们想从这个序列中提取长度为 2(n = 2)的 N-gram 特征,那么我们的特征就是 [("how","now"), ("now","brown"), ("brown","cow")]

在恶意软件检测的背景下,有些数据最自然的表示方式是 N-gram 特征。例如,当你将一个二进制文件反汇编成其组成指令时,如["inc", "dec", "sub", "mov"],使用 N-gram 方法来捕捉这些指令序列是很有意义的,因为表示指令序列对于检测特定的恶意软件实现非常有用。或者,当你执行二进制文件以检查其动态行为时,你可以使用 N-gram 方法来表示二进制文件中 API 调用或高级行为的序列。

我建议在使用基于机器学习的恶意软件检测系统时,针对那些包含某种序列类型的数据,实验使用 N-gram 特征。通常需要通过一些试验和错误来确定 n 应设置为多少,这决定了你的 N-gram 的长度。这个试验过程包括变动 n 值,看看哪个值能在你的测试数据上获得最佳的准确度。一旦找到了合适的值,N-gram 就可以成为捕捉程序二进制文件实际顺序行为的强大特征,从而提高系统的准确性。

为什么不能使用所有可能的特征

既然你已经了解了不同类别特征的优缺点,你可能会想知道为什么不能同时使用所有这些特征来构建最强的检测器。事实上,使用所有可能的特征并不是一个好主意,原因有几个。

首先,提取我们刚才探讨的所有特征需要很长时间,这会影响系统扫描文件的速度。更重要的是,如果你在机器学习算法中使用太多特征,你可能会遇到内存问题,且系统训练时间过长。因此,在构建系统时,我建议尝试不同的特征,并集中精力选择那些对你要检测的恶意软件有效的特征(以及对你想避免产生假阳性结果的良性软件)。

不幸的是,即使你聚焦于某一类特征,比如字符串特征,你通常会遇到比大多数机器学习算法能够处理的更多的特征。使用字符串特征时,你必须为训练数据中出现的每一个独特字符串准备一个特征。例如,如果训练样本 A 包含字符串 "hello world",而训练样本 B 包含字符串 "hello world!",那么你需要将 "hello world""hello world!" 视为两个独立的特征。这意味着当你处理成千上万的训练样本时,你很快会遇到成千上万的独特字符串,系统最终将使用这么多的特征。

使用哈希技巧压缩特征

为了解决特征过多的问题,你可以使用一种流行且简单的解决方案,称为 哈希技巧,也叫 特征哈希。其原理如下:假设你的训练集中有一百万个独特的字符串特征,但你使用的机器学习算法和硬件只能处理全体训练集中 4,000 个独特的特征。你需要某种方法将一百万个特征压缩成一个只有 4,000 个条目的特征向量。

哈希技巧通过将每个特征哈希到 4,000 个索引中的一个,使这些数百万个特征适应于一个 4,000 维的特征空间。然后,你将原始特征的值加到该索引位置的 4,000 维特征向量中的数字上。当然,由于特征值会在相同维度上相加,这种方法经常会导致特征碰撞。这可能会影响系统的准确性,因为你使用的机器学习算法再也无法“看到”单个特征的值。但在实践中,这种准确性下降通常非常小,而通过压缩特征表示所获得的好处远远超过了由于压缩操作带来的轻微准确性下降。

实现哈希技巧

为了使这些概念更加清晰,我将通过示例代码来演示哈希技巧的实现。这里我展示这段代码以说明算法的工作原理;稍后,我们将使用sklearn对该功能的实现。我们的示例代码从一个函数声明开始:

def apply_hashing_trick(feature_dict, vector_size=2000):

apply_hashing_trick()函数接受两个参数:原始特征字典和应用哈希技巧后我们将特征存储在其中的较小特征向量的大小。

接下来,我们使用以下代码创建新的特征数组:

    new_features = [0 for x in range(vector_size)]

new_features数组存储了应用哈希技巧后的特征信息。接着,我们在for循环中执行哈希技巧的关键操作,如清单 8-8 所示。

    for key in ➊feature_dict:
        array_index = ➋hash(key) % vector_size
        new_features[array_index] += ➌feature_dict[key]

清单 8-8:使用 for 循环执行哈希操作

在这里,我们使用for循环遍历特征字典中的每个特征 ➊。为此,首先我们对字典的键进行哈希(对于字符串特征,这些键对应软件二进制文件中的各个字符串),并对vector_size取模,使得哈希值介于 0 和vector_size – 1之间 ➋。我们将此操作的结果存储在array_index变量中。

仍然在for循环中,我们将原始特征数组中某个特征的值加到new_feature数组中对应array_index位置的值 ➌。对于字符串特征,当我们的特征值被设置为 1 以表示该软件二进制文件包含某个特定字符串时,我们会将该项的值加一。对于 PE 头特征,其中的特征值有一个范围(例如,表示 PE 段所占用的内存量),我们会将该特征值加到对应项中。

最后,在for循环之外,我们简单地返回new_features字典,如下所示:

    return new_features

此时,sklearn可以使用仅包含数千个而非数百万个唯一特征的new_features进行操作。

哈希技巧的完整代码

清单 8-9 展示了哈希技巧的完整代码,现在你应该已经熟悉它了。

def apply_hashing_trick(feature_dict,vector_size=2000):
    # create an array of zeros of length 'vector_size'
    new_features = [0 for x in range(vector_size)]

    # iterate over every feature in the feature dictionary
    for key in feature_dict:

        # get the index into the new feature array
        array_index = hash(key) % vector_size

        # add the value of the feature to the new feature array
        # at the index we got using the hashing trick
        new_features[array_index] += feature_dict[key]

    return new_features

清单 8-9:实现哈希技巧的完整代码

正如你所看到的,特征哈希技巧容易自己实现,这样做能够确保你理解其工作原理。然而,你也可以直接使用sklearn的实现,它既易于使用又经过了优化。

使用 sklearn 的 FeatureHasher

要使用sklearn的内置实现而不是自己实现哈希解决方案,首先需要导入sklearnFeatureHasher类,如下所示:

from sklearn.feature_extraction import FeatureHasher

接下来,实例化FeatureHasher类:

hasher = FeatureHasher(n_features=2000)

为此,你需要声明n_features为应用哈希技巧后生成的新数组的大小。

然后,要将哈希技巧应用于一些特征向量,只需将它们传入FeatureHasher类的transform方法:

features = [{'how': 1, 'now': 2, 'brown': 4},{'cow': 2, '.': 5}]
hashed_features = hasher.transform(features)

结果实际上与我们自定义实现的特征哈希技巧相同,如清单 8-9 所示。不同之处在于这里我们仅仅使用了sklearn的实现,因为使用一个维护良好的机器学习库要比自己编写代码更为便捷。完整的示例代码见清单 8-10。

from sklearn.feature_extraction import FeatureHasher
hasher = FeatureHasher(n_features=10)
features = [{'how': 1, 'now': 2, 'brown': 4},{'cow': 2, '.': 5}]
hashed_features = hasher.transform(features)

清单 8-10:实现 FeatureHasher

在我们继续之前,有几点需要注意的特征哈希内容。首先,正如你可能猜到的那样,特征哈希通过简单地基于特征值哈希到相同的桶来混淆你传入机器学习算法的特征信息。这意味着,通常情况下,你使用的桶越少(或者将更多特征哈希到某些固定数量的桶中),算法的表现会越差。令人惊讶的是,即使使用了哈希技巧,机器学习算法仍然能够良好运作,因为我们根本无法在现代硬件上处理数百万甚至数十亿个特征,所以我们通常需要在安全数据科学中使用特征哈希技巧。

特征哈希技巧的另一个限制是,在分析模型内部时,它使得恢复你哈希的原始特征变得困难或不可能。以决策树为例:因为我们将任意特征哈希到特征向量的每个条目中,所以我们不知道哪些特征导致决策树算法基于这个条目进行划分,因为任何特征都可能让决策树认为在这个条目上划分是个好主意。尽管这是一个显著的限制,但安全数据科学家依然接受这一点,因为特征哈希技巧在将数百万个特征压缩为一个可管理的数量时带来了巨大的好处。

现在我们已经了解了构建一个现实世界恶意软件检测器所需的基本构件,让我们来探索如何构建一个端到端的机器学习恶意软件检测器。

构建工业级检测器

从软件需求的角度来看,我们的实际检测器需要完成三件事:从软件二进制文件中提取特征,以便用于训练和检测;使用训练数据自我训练以检测恶意软件;并且实际对新的软件二进制文件进行检测。让我们逐步查看执行这些操作的代码,这将向你展示它们是如何结合在一起的。

你可以在本书附带的代码中,或在本书提供的虚拟机中的malware_data_science/ch8/code/complete_detector.py中访问我在本节中使用的代码。一个一行的 Shell 脚本malware_data_science/ch8/code/run_complete_detector.sh展示了如何从 Shell 中运行检测器。

提取特征

为了创建我们的检测器,首先实现的是从训练二进制文件中提取特征的代码(这里略过了模板代码,专注于程序的核心功能)。提取特征涉及从训练二进制文件中提取相关数据,将这些特征存储在 Python 字典中,然后,如果我们认为唯一特征的数量会变得过大,我们会使用sklearn实现的哈希技巧对它们进行转换。

为了简化起见,我们仅使用字符串特征并选择使用哈希技巧。列表 8-11 展示了如何同时进行这两者。

def get_string_features(➊path,➋hasher):
    # extract strings from binary file using regular expressions
    chars = r" -~"
    min_length = 5
    string_regexp = '[%s]{%d,}' % (chars, min_length)
    file_object = open(path)
    data = file_object.read()
    pattern = re.compile(string_regexp)
    strings = pattern.findall(data)

    # store string features in dictionary form
  ➌ string_features = {}
    for string in strings:
        string_features[string] = 1

    # hash the features using the hashing trick
  ➍ hashed_features = hasher.transform([string_features])

    # do some data munging to get the feature array
    hashed_features = hashed_features.todense()
    hashed_features = numpy.asarray(hashed_features)
    hashed_features = hashed_features[0]

    # return hashed string features
  ➎ print "Extracted {0} strings from {1}".format(len(string_features),path)
    return hashed_features

列表 8-11:定义 get_string_features 函数

在这里,我们声明了一个名为get_string_features的函数,它以目标二进制文件的路径➊和sklearn的特征哈希类实例➋作为参数。然后,我们使用正则表达式提取目标文件的字符串,该正则表达式解析出所有最小长度为 5 的可打印字符串。接着,我们将特征存储在一个 Python 字典中➌,以便进一步处理,通过将每个字符串的值设置为 1,简单地表示该特征在二进制文件中存在。

接下来,我们使用sklearn的哈希技巧实现通过调用hasher来哈希特征。注意,在将string_features字典传递给hasher实例时,我们将其包装在一个 Python 列表中➍,因为sklearn要求我们传入一个字典列表进行转换,而不是单个字典。

因为我们将特征字典作为字典列表传入,所以返回的特征是一个数组列表。此外,返回的特征采用稀疏格式,这是一种压缩表示,适用于处理大矩阵,但我们在本书中不会讨论它。我们需要将数据转换回常规的numpy向量格式。

为了将数据恢复为常规格式,我们调用.todense().``asarray``(),然后选择hasher结果列表中的第一个数组,以恢复我们的最终特征向量。函数的最后一步是简单地将特征向量hashed_features ➎返回给调用者。

训练检测器

因为sklearn完成了大部分训练机器学习系统的繁重工作,训练一个检测器只需要少量代码,一旦我们从目标二进制文件中提取了机器学习特征。

要训练一个检测器,我们首先需要从训练示例中提取特征,然后实例化特征哈希器和我们希望使用的sklearn机器学习检测器(在本例中,我们使用随机森林分类器)。接着,我们需要调用sklearnfit方法来训练检测器。最后,我们将检测器和特征哈希器保存到磁盘,以便将来扫描文件时使用。

示例 8-12 展示了训练检测器的代码。

def ➊get_training_data(benign_path,malicious_path,hasher):
    def ➋get_training_paths(directory):
        targets = []
        for path in os.listdir(directory):
            targets.append(os.path.join(directory,path))
        return targets
  ➌ malicious_paths = get_training_paths(malicious_path)
  ➍ benign_paths = get_training_paths(benign_path)
  ➎ X = [get_string_features(path,hasher) 
    for path in malicious_paths + benign_paths]
    y = [1 for i in range(len(malicious_paths))] 
    + [0 for i in range(len(benign_paths))]
    return X, y
def ➏train_detector(X,y,hasher):
    classifier = tree.RandomForestClassifier()
  ➐ classifier.fit(X,y)
  ➑ pickle.dump((classifier,hasher),open("saved_detector.pkl","w+"))

示例 8-12:编程 sklearn 以训练检测器

我们从声明get_training_data()函数 ➊ 开始,该函数从我们提供的训练示例中提取特征。该函数有三个参数:包含良性二进制程序示例的目录路径(benign_path),包含恶意二进制程序示例的目录路径(malicious_path),以及用于执行特征哈希的sklearnFeatureHasher类实例(hasher)。

接下来,我们声明get_training_paths() ➋,这是一个本地辅助函数,用于获取给定目录中出现的文件的绝对路径列表。在接下来的两行中,我们使用get_training_paths来获取出现在恶意 ➌ 和良性 ➍ 训练示例目录中的路径列表。

最后,我们提取了特征并创建了标签向量。我们通过在每个训练示例文件路径 ➎ 上调用示例 8-11 中描述的get_string_features函数来完成这一步。注意,标签向量中每个恶意路径对应的值是 1,每个良性路径对应的值是 0,因此标签向量中的索引值与X数组中相应索引的特征向量的标签相对应。这是sklearn期望的特征和标签数据格式,它使我们能够为每个特征向量指定标签。

现在我们已经完成了特征提取,并创建了我们的特征向量X和标签向量y,我们可以告诉sklearn使用特征向量和标签向量来训练我们的检测器。

我们使用train_detector()函数 ➏ 来完成这一步,函数接收三个参数:训练示例的特征向量(X),标签向量(y),以及sklearn特征哈希器(hasher)的实例。在函数体内,我们实例化tree.RandomForestClassifier,这是sklearn的检测器。然后我们将Xy传递给检测器的fit方法来训练它 ➐,接着使用 Python 的pickle模块 ➑ 来保存检测器和哈希器,以便将来使用。

在新二进制文件上运行检测器

现在,让我们来看看如何使用我们刚刚训练的保存的检测器来检测新的程序二进制文件中的恶意软件。列表 8-13 展示了如何编写 scan_file() 函数来实现这一点。

def scan_file(path):
    if not os.path.exists("saved_detector.pkl"):
        print "Train a detector before scanning files."
        sys.exit(1)
  ➊ with open("saved_detector.pkl") as saved_detector:
        classifier, hasher = pickle.load(saved_detector)
    features = ➋get_string_features(path,hasher)
    result_proba = ➌classifier.predict_proba(features)[1]
    # if the user specifies malware_paths and 
    # benignware_paths, train a detector
  ➍ if result_proba > 0.5:
        print "It appears this file is malicious!",`result_proba`
    else:
        print "It appears this file is benign.",`result_proba`

列表 8-13:在新的二进制文件上运行检测器

在这里,我们声明了 scan_file() 函数,用于扫描文件以确定它是恶意的还是良性的。它的唯一参数是我们将要扫描的二进制文件的路径。该函数的第一项任务是加载从 pickle 文件中保存的检测器和哈希器 ➊。

接下来,我们使用在 列表 8-11 中定义的 get_string_features 函数 ➋ 提取目标文件的特征。

最后,我们调用检测器的 predict 方法,根据提取的特征来判断目标文件是否为恶意文件。我们通过使用 classifier 实例的 predict_proba 方法 ➌,并选择返回数组的第二个元素,该元素对应文件为恶意的概率。如果该概率超过 0.5 或 50% ➍,我们认为文件是恶意的;否则,我们告诉用户文件是良性的。我们可以将此决策阈值调高,以减少误报。

我们到目前为止实现的内容

列表 8-14 展示了这个小规模但现实的恶意软件检测器的完整代码。我希望在你已经了解每个单独模块如何工作的基础上,代码能够流畅地读懂。

#!/usr/bin/python

import os
import sys
import pickle
import argparse
import re
import numpy
from sklearn.ensemble import RandomForestClassifier
from sklearn.feature_extraction import FeatureHasher

def get_string_features(path,hasher):
    # extract strings from binary file using regular expressions
    chars = r" -~"
    min_length = 5
    string_regexp = '[%s]{%d,}' % (chars, min_length)
    file_object = open(path)
    data = file_object.read()
    pattern = re.compile(string_regexp)
    strings = pattern.findall(data)

    # store string features in dictionary form
    string_features = {}
    for string in strings:
        string_features[string] = 1

    # hash the features using the hashing trick
    hashed_features = hasher.transform([string_features])

    # do some data munging to get the feature array
    hashed_features = hashed_features.todense()
    hashed_features = numpy.asarray(hashed_features)
    hashed_features = hashed_features[0]

    # return hashed string features
    print "Extracted {0} strings from {1}".format(len(string_features),path)
    return hashed_features

def scan_file(path):
    # scan a file to determine if it is malicious or benign
    if not os.path.exists("saved_detector.pkl"):
        print "Train a detector before scanning files."
        sys.exit(1)
    with open("saved_detector.pkl") as saved_detector:
        classifier, hasher = pickle.load(saved_detector)
    features = get_string_features(path,hasher)
    result_proba = classifier.predict_proba([features])[:,1]
    # if the user specifies malware_paths and 
    # benignware_paths, train a detector
    if result_proba > 0.5:
        print "It appears this file is malicious!",`result_proba`
    else:
        print "It appears this file is benign.",`result_proba`

def train_detector(benign_path,malicious_path,hasher):
    # train the detector on the specified training data
    def get_training_paths(directory):
        targets = []
        for path in os.listdir(directory):
            targets.append(os.path.join(directory,path))
        return targets
    malicious_paths = get_training_paths(malicious_path)
    benign_paths = get_training_paths(benign_path)
    X = [get_string_features(path,hasher) for path in malicious_paths + benign_paths]
    y = [1 for i in range(len(malicious_paths))] + [0 for i in range(len(benign_paths))]
    classifier = tree.RandomForestClassifier(64)
    classifier.fit(X,y)
    pickle.dump((classifier,hasher),open("saved_detector.pkl","w+"))

def get_training_data(benign_path,malicious_path,hasher):
    def get_training_paths(directory):
        targets = []
        for path in os.listdir(directory):
            targets.append(os.path.join(directory,path))
        return targets
    malicious_paths = get_training_paths(malicious_path)
    benign_paths = get_training_paths(benign_path)
    X = [get_string_features(path,hasher) for path in malicious_paths + benign_paths]
    y = [1 for i in range(len(malicious_paths))] + [0 for i in range(len(benign_paths))]
    return X, y

parser = argparse.ArgumentParser("get windows object vectors for files")
parser.add_argument("--malware_paths",default=None,help="Path to malware training files")
parser.add_argument("--benignware_paths",default=None,help="Path to benignware training files")
parser.add_argument("--scan_file_path",default=None,help="File to scan")
args = parser.parse_args()

hasher = FeatureHasher(20000)
if args.malware_paths and args.benignware_paths:
    train_detector(args.benignware_paths,args.malware_paths,hasher)
elif args.scan_file_path:
    scan_file(args.scan_file_path)
else:
    print "[*] You did not specify a path to scan," \
        " nor did you specify paths to malicious and benign training files" \
        " please specify one of these to use the detector.\n"
    parser.print_help()

列表 8-14:基础的机器学习恶意软件检测器代码

编写基于机器学习的恶意软件检测器是很棒的,但如果你打算自信地部署该检测器,你需要评估并改进它的性能。接下来,你将学习评估检测器性能的不同方法。

评估检测器的性能

方便的是,sklearn 提供了便于使用 ROC 曲线等度量标准来评估检测系统的代码,正如你在 第七章 中学到的那样。sklearn 库还提供了额外的评估功能,专门用于评估机器学习系统。例如,你可以使用 sklearn 的函数进行交叉验证,这是预测检测器在部署后效果的强大方法。

在本节中,你将学习如何使用 sklearn 绘制 ROC 曲线,以展示检测器的准确性。你还将了解交叉验证,并学习如何在 sklearn 中实现它。

使用 ROC 曲线评估检测器效果

还记得接收者操作特征(ROC)曲线衡量的是检测器的真正阳性率(它成功检测到的恶意软件的百分比)和假阳性率(它错误标记为恶意软件的良性软件的百分比),这些值会随着你调整敏感度而变化。

灵敏度越高,假阳性越多,但检测率越高;灵敏度越低,假阳性越少,但检测到的数量也会减少。要计算 ROC 曲线,你需要一个能够输出威胁评分的检测器,评分越高,二进制文件被判定为恶意的可能性就越大。幸运的是,sklearn中实现的决策树、逻辑回归、k-近邻、随机森林和本书中介绍的其他机器学习方法都提供了输出威胁评分的选项,该评分反映了一个文件是恶意软件还是良性软件。让我们探讨如何利用 ROC 曲线来确定检测器的准确性。

计算 ROC 曲线

要为我们在 Listing 8-14 中构建的机器学习检测器计算 ROC 曲线,我们需要做两件事:首先,定义一个实验设置,其次,使用sklearnmetrics模块实现该实验。对于我们的基本实验设置,我们将训练样本分成两半,一半用于训练,另一半用于计算 ROC 曲线。这种分割模拟了检测零日恶意软件的问题。基本上,通过分割数据,我们告诉程序:“给我展示一组恶意软件和良性软件的样本,我将用它们来学习如何识别恶意软件和良性软件,然后展示另一组给我测试,看看我学会了多好。”因为检测器从未见过测试集中的恶意软件(或良性软件),这个评估设置是预测检测器如何应对全新恶意软件的简单方法。

使用sklearn来实现这种分割是非常直接的。首先,我们在检测器程序的参数解析类中添加一个选项,以表明我们想要评估检测器的准确性,像这样:

parser.add_argument("--evaluate",default=False,
action="store_true",help="Perform cross-validation")

然后,在程序中处理命令行参数的部分,见 Listing 8-15,我们添加了另一个elif语句来处理用户添加了-evaluate到命令行参数的情况。

elif args.malware_paths and args.benignware_paths and args.evaluate:
  ➊ hasher = FeatureHasher()
    X, y = ➋get_training_data(
    args.benignware_paths,args.malware_paths,hasher)
    evaluate(X,y,hasher)
def ➌evaluate(X,y,hasher):
    import random
    from sklearn import metrics
    from matplotlib import pyplot

Listing 8-15: 在新二进制文件上运行检测器

让我们详细分析一下这段代码。首先,我们实例化一个sklearn特征哈希器 ➊,获取我们评估实验所需的训练数据 ➋,然后调用一个名为evaluate的函数 ➌,该函数接收训练数据(X, y)和特征哈希器实例(hasher)作为参数,然后导入进行评估所需的三个模块。我们使用random模块来随机选择用于训练检测器的训练示例以及用于测试的训练示例。我们使用sklearn中的metrics模块来计算 ROC 曲线,并使用matplotlib中的pyplot模块(这是 Python 中标准的数据可视化库)来可视化 ROC 曲线。

将数据分割为训练集和测试集

现在我们已经随机排序了与训练数据对应的Xy数组,可以将这些数组拆分成大小相等的训练集和测试集,如列表 8-16 所示,接着继续定义在列表 8-15 中开始的evaluate()函数。

    ➊ X, y = numpy.array(X), numpy.array(y)
    ➋ indices = range(len(y))
    ➌ random.shuffle(indices)
    ➍ X, y = X[indices], y[indices]
       splitpoint = len(X) * 0.5
    ➎ splitpoint = int(splitpoint)
    ➏ training_X, test_X = X[:splitpoint], X[splitpoint:]
       training_y, test_y = y[:splitpoint], y[splitpoint:]

列表 8-16:将数据分割成训练集和测试集

首先,我们将Xy转换为numpy数组 ➊,然后创建一个索引列表,表示Xy中元素的数量 ➋。接下来,我们随机打乱这些索引 ➌,并根据这个新顺序重新排列Xy ➍。这样,我们就为随机分配样本到训练集或测试集做好了准备,确保我们不是单纯按实验数据目录中的顺序来拆分样本。为了完成随机拆分,我们通过查找一个将数据集均分的数组索引,将该点四舍五入为最接近的整数,使用int()函数 ➎,然后将Xy数组实际拆分为训练集和测试集 ➏。

现在我们已经拥有了训练集和测试集,可以使用以下方法,利用训练数据实例化并训练我们的决策树检测器:

    classifier = RandomForestClassifier()
    classifier.fit(training_X,training_y)

然后我们使用训练好的分类器,获取测试样本的得分,这些得分对应于这些测试样本是恶意的可能性:

    scores = classifier.predict_proba(test_X)[:,-1]

在这里,我们调用predict_proba()方法对分类器进行预测,该方法预测我们的测试样本是良性软件还是恶意软件的概率。然后,使用numpy索引技巧,我们仅提取出样本是恶意的概率,而不是良性概率。请记住,这些概率是冗余的(例如,如果某个样本是恶意软件的概率是 0.99,那么它是良性软件的概率就是 0.01,因为概率总和为 1.00),因此这里我们只需要恶意软件的概率。

计算 ROC 曲线

现在我们已经使用检测器计算了恶意软件概率(我们也可以称之为“得分”),是时候计算我们的 ROC 曲线了。我们通过首先调用sklearnmetrics模块中的roc_curve函数来完成这一步,如下所示:

    fpr, tpr, thresholds = metrics.roc_curve(test_y, scores)

roc_curve函数测试各种决策阈值,即我们认为软件二进制文件是恶意软件的得分阈值,并衡量如果我们使用该检测器时,假阳性率和真阳性率的变化。

你可以看到,roc_curve函数接受两个参数:我们的测试样本的标签向量(test_y)和score数组,该数组包含了我们检测器对每个训练样本是否为恶意软件的判断。该函数返回三个相关数组:fpr(假阳性率),tpr(真阳性率),和thresholds(阈值)。这些数组长度相等,因此在每个索引处的假阳性率、真阳性率和决策阈值是相互对应的。

现在我们可以使用matplotlib来可视化我们刚刚计算出的 ROC 曲线。我们通过调用matplotlibpyplot模块中的plot方法来实现,代码如下所示:

    pyplot.plot(fpr,tpr,'r-')
    pyplot.xlabel("Detector false positive rate")
    pyplot.ylabel("Detector true positive rate")
    pyplot.title("Detector ROC Curve")
    pyplot.show()

我们调用xlabelylabeltitle方法来标记图表的坐标轴和标题,然后使用show方法让图表窗口弹出。

结果的 ROC 曲线如图 8-2 所示。

image

图 8-2:可视化检测器的 ROC 曲线

从图 8-2 中的图表可以看出,我们的检测器在这样的基本示例中表现良好。在大约 1%的假阳性率(10^(–2))下,它能够检测出测试集中的约 94%的恶意软件样本。我们这里只在几百个训练样本上进行训练;要提高准确性,我们需要在成千上万、几十万,甚至百万级的样本上进行训练(唉,扩展机器学习到如此规模超出了本书的范围)。

交叉验证

尽管可视化 ROC 曲线是有用的,但实际上我们可以通过对训练数据进行多次实验来更好地预测我们检测器在现实世界中的准确性,而不仅仅是进行一次实验。回想一下,我们为了进行测试,将训练样本分为两部分,在前一部分上训练检测器,在后一部分上测试检测器。这实际上是对检测器的不充分测试。在现实世界中,我们的准确性将不会仅仅在这一特定的测试集上进行衡量,而是针对新出现、以前未见过的恶意软件来衡量。为了更好地了解我们部署后的表现,我们需要在多个测试集上进行多次实验,并了解整体的准确性趋势。

我们可以使用交叉验证来做到这一点。交叉验证的基本思想是将我们的训练样本分成若干个折叠(这里我使用了三折,你可以使用更多)。例如,如果你有 300 个样本并决定将它们分成三折,第一个 100 个样本会进入第一折,第二个 100 个样本进入第二折,第三个 100 个样本进入第三折。

然后我们进行三次测试。在第一次测试中,我们在第 2 折和第 3 折上训练系统,在第 1 折上测试系统。在第二次测试中,我们重复这一过程,但在第 1 折和第 3 折上训练系统,在第 2 折上测试系统。在第三次测试中,如你所料,我们在第 1 折和第 2 折上训练系统,在第 3 折上测试系统。图 8-3 展示了这一交叉验证过程。

image

图 8-3:一个示例交叉验证过程的可视化

sklearn库使得实现交叉验证变得简单。为了做到这一点,让我们将清单 8-15 中的evaluate函数重写为cv_evaluate

def cv_evaluate(X,y,hasher):
    import random
    from sklearn import metrics
    from matplotlib import pyplot
    from sklearn.cross_validation import KFold

我们以与开始初步评估函数相同的方式启动cv_evaluate()函数,唯一不同的是这里我们还从sklearncross_validation模块中导入了KFold类。K 折交叉验证,简称KFold,与我刚才讨论的交叉验证类型是同义的,也是最常用的交叉验证方法。

接下来,我们将训练数据转换为numpy数组,以便我们可以对其使用numpy增强的数组索引:

    X, y = numpy.array(X), numpy.array(y)

以下代码实际上启动了交叉验证过程:

    fold_counter = 0
       for train, test in KFold(len(X),3,➊shuffle=True):
        ➋ training_X, training_y = X[train], y[train]
           test_X, test_y = X[test], y[test]

我们首先实例化KFold类,将训练样本的数量作为第一个参数传入,并将希望使用的折数作为第二个参数传入。第三个参数shuffle=True ➊,告诉sklearn在将训练数据分成三折之前随机排序。KFold实例实际上是一个迭代器,在每次迭代时提供不同的训练或测试样本拆分。在for循环内,我们将训练实例和测试实例分配给包含相应元素的training_Xtraining_y数组 ➋。

在准备好训练数据和测试数据后,我们就可以像本章前面学到的那样实例化并训练RandomForestClassifier

        classifier = RandomForestClassifier()
        classifier.fit(training_X,training_y)

最后,我们为这个特定的折计算 ROC 曲线,然后绘制一条代表该 ROC 曲线的线:

        scores = classifier.predict_proba(test_X)[:,-1]
        fpr, tpr, thresholds = metrics.roc_curve(test_y, scores)
        pyplot.semilogx(fpr,tpr,label="Fold number {0}".format(fold_counter))
        fold_counter += 1

请注意,我们暂时还没有调用matplotlibshow方法来显示图表。我们会在所有折评估完成并准备好一次性显示所有三条曲线时再做此操作。就像在上一节中一样,我们给坐标轴添加标签并为图表添加标题,像这样:

    pyplot.xlabel("Detector false positive rate")
    pyplot.ylabel("Detector true positive rate")
    pyplot.title("Detector Cross-Validation ROC Curves")
    pyplot.legend()
    pyplot.grid()
    pyplot.show()

结果的 ROC 曲线显示在图 8-4 中。

如你所见,我们的结果在每一折上都类似,但确实存在一定的波动。在三次运行中,我们的检测率(真正率)在 1%的假阳性率下平均约为 90%。这个估计考虑了所有三次交叉验证实验,比我们只在数据上进行一次实验时得到的结果更准确;在后者的情况下,训练和测试样本的选择会导致一个有些随机的结果。通过进行更多的实验,我们可以更可靠地了解我们解决方案的有效性。

image

图 8-4:使用交叉验证绘制检测器的 ROC 曲线

请注意,这些结果并不理想,因为我们训练的数据量非常小:只有几百个恶意软件和良性软件样本。在我的日常工作中,我们训练大规模的机器学习恶意软件检测系统,通常使用数亿个样本。你不需要数亿个样本来训练自己的恶意软件检测器,但你应该至少准备几万的样本集,以便开始获得真正优秀的性能(例如,在 0.1%的假阳性率下达到 90%的检测率)。

下一步

到目前为止,我讲解了如何使用 Python 和sklearn从软件二进制文件的训练数据集中提取特征,然后训练和评估基于决策树的机器学习方法。为了改善系统,你可以使用除可打印字符串特征之外的其他特征(例如之前讨论的 PE 头、指令 N-gram 或导入地址表特征),或者你可以使用不同的机器学习算法。

为了使检测器更加准确,我建议超越sklearnRandomForestClassifiersklearn.ensemble.RandomForestClassifier)尝试其他分类器。回想上一章,随机森林检测器也基于决策树,但它们不仅仅构建一个决策树,而是构建许多决策树,并随机化它们的构建方式。为了判断一个新文件是恶意软件还是良性软件,每棵决策树都会做出独立的判断,我们通过将它们的结果相加并除以树的总数来获得平均结果。

你还可以使用sklearn提供的其他算法,例如逻辑回归。使用这些算法中的任何一种,只需在本章讨论的示例代码中进行简单的搜索和替换。例如,在本章中,我们通过以下方式实例化并训练我们的决策树:

        classifier = RandomForestClassifier()
        classifier.fit(training_X,training_y)

但是你可以简单地将那段代码替换为以下内容:

        from sklearn.linear_model import LogisticRegression
        classifier = LogisticRegression()
        classifier.fit(training_X,training_y)

这个替代方法会生成一个逻辑回归检测器,而不是基于决策树的检测器。通过对这个逻辑回归检测器进行基于交叉验证的评估,并将其与图 8-4 中的结果进行比较,你可以确定哪种方法效果更好。

总结

在本章中,你学习了构建基于机器学习的恶意软件检测器的方方面面。具体来说,你学习了如何从软件二进制文件中提取特征用于机器学习,如何使用哈希技巧压缩这些特征,以及如何使用这些提取的特征训练基于机器学习的恶意软件检测器。你还学习了如何绘制 ROC 曲线,以检查检测器的检测阈值与其真正和假阳性率之间的关系。最后,你了解了交叉验证这一更高级的评估概念,以及其他可能的扩展,用于增强本章中使用的检测器。

本书的讨论已经结束,内容涵盖了基于sklearn的机器学习恶意软件检测方法。我们将在第十章和第十一章中介绍另一类机器学习方法,即深度学习方法或人工神经网络。现在你已经掌握了在恶意软件识别中有效使用机器学习所需的基础知识。我鼓励你进一步阅读有关机器学习的内容。由于计算机安全在许多方面是一个数据分析问题,机器学习将在安全行业中持续存在,并且不仅在检测恶意二进制文件时有用,还能在检测网络流量、系统日志及其他环境中的恶意行为时发挥作用。

在下一章中,我们将深入探讨恶意软件关系的可视化,这有助于我们快速理解大量恶意软件样本之间的相似性和差异性。

第九章:恶意软件趋势的可视化

image

有时候,分析恶意软件集合的最佳方式就是可视化。可视化安全数据可以帮助我们快速识别恶意软件及整个威胁环境中的趋势。这些可视化图通常比非可视化统计数据更直观,并且可以帮助向不同的受众传达见解。例如,在本章中,你将看到如何通过可视化帮助我们识别数据集中流行的恶意软件类型、恶意软件数据集中的趋势(例如 2016 年勒索软件的出现)以及商业杀毒系统在检测恶意软件方面的相对有效性。

通过这些例子的讲解,你将明白如何使用 Python 数据分析包pandas,以及 Python 数据可视化包seabornmatplotlib,创建你自己的可视化图表,这些图表能够通过数据分析提供有价值的见解。pandas包主要用于加载和处理数据,与数据可视化本身关系不大,但它对于为可视化准备数据非常有用。

为什么可视化恶意软件数据很重要

为了展示可视化恶意软件数据如何有助于分析,让我们通过两个例子进行探讨。第一个可视化图解答了以下问题:杀毒行业检测勒索软件的能力是否在提升?第二个可视化图展示了在一年内哪些类型的恶意软件有所增加。我们来看第一个例子,如图 9-1 所示。

image

图 9-1:勒索软件检测的时间可视化

我使用从成千上万的勒索软件样本收集的数据创建了这个勒索软件可视化图。这些数据包含了 57 个独立杀毒引擎对每个文件进行扫描的结果。每个圆圈代表一个恶意软件样本。纵轴表示每个恶意软件样本在扫描时从杀毒引擎接收到的检测次数,或者说是阳性结果。请记住,虽然纵轴的最大值为 60,但给定扫描的最大检测次数是 57,即扫描引擎的总数。横轴表示每个恶意软件样本首次出现在恶意软件分析网站VirusTotal.com并被扫描的时间。

在这个图表中,我们可以看到杀毒社区在 2016 年 6 月时对这些恶意文件的检测能力相对较强,但在 2016 年 7 月左右有所下降,随后在全年内逐步回升。到 2016 年底,勒索软件文件仍然有大约 25%的概率未被杀毒引擎检测到,因此我们可以得出结论,安全社区在这一时期内对这些文件的检测能力仍然较弱。

为了扩展这项调查,你可以创建一个可视化,显示 哪些 杀毒引擎正在检测勒索软件及其检测率,以及它们随着时间的推移如何改进。或者你可以查看其他类别的恶意软件(例如木马)。这样的图表在决定购买哪些杀毒引擎,或者决定哪些类型的恶意软件可能需要设计定制的检测解决方案时非常有用——或许是为了补充商业杀毒检测系统(有关构建定制检测系统的更多信息,请参见 第八章)。

现在让我们看看 图 9-2,这是另一个样本可视化图,使用与 图 9-1 相同的数据集生成。

image

图 9-2:按家族恶意软件检测的时间可视化

图 9-2 显示了在 150 天时间段内最常见的前 20 个恶意软件家族及其出现频率的相对关系。该图揭示了一些关键见解:虽然最流行的恶意软件家族 Allaple.A 在 150 天内持续出现,其他恶意软件家族,如 Nemucod.FG,则在较短时间内盛行,随后沉寂。这类图表,可以使用在自己工作网络中检测到的恶意软件生成,能够揭示有助于了解随着时间推移,哪些类型的恶意软件在攻击组织时较为频繁。没有类似的对比图,理解并比较这些恶意软件类型在时间上的相对峰值和数量将会变得困难且耗时。

这两个示例展示了恶意软件可视化的实用性。本章的其余部分将展示如何创建你自己的可视化图。我们从讨论本章使用的示例数据集开始,然后使用 pandas 包来分析数据。最后,我们使用 matplotlibseaborn 包来可视化数据。

理解我们的恶意软件数据集

我们使用的数据集包含了由病毒检测聚合服务 VirusTotal 收集的 37,000 个独特恶意软件二进制文件的数据。每个二进制文件都标注了四个字段:标记该二进制文件为恶意的杀毒引擎数量(从 57 个引擎中筛选)(我将其称为每个样本的 阳性 数量)、每个二进制文件的大小、二进制文件的 类型(比特币矿工、键盘记录器、勒索软件、木马或蠕虫)、以及该二进制文件首次出现的日期。我们将看到,即使每个二进制文件的元数据相对有限,我们仍然可以以一种揭示数据集重要见解的方式分析和可视化数据。

将数据加载到 pandas 中

流行的 Python 数据分析库pandas使得将数据加载到名为DataFrame的分析对象中变得容易,然后提供方法来切片、转换和分析这些重新包装的数据。我们使用pandas来加载和分析数据,并为轻松可视化做准备。我们可以使用示例 9-1 来定义并将一些示例数据加载到 Python 解释器中。

In [135]: import pandas

In [136]: example_data = [➊{'column1': 1, 'column2': 2},
    ...:  {'column1': 10, 'column2': 32},
    ...:  {'column1': 3, 'column2': 58}]

In [137]: ➋pandas.DataFrame(example_data)
Out[137]:
  column1  column2
0        1        2
1       10       32
2        3       58

示例 9-1:直接将数据加载到 pandas*

在这里,我们定义一些数据,称之为example_data,它是一个 Python 字典的列表 ➊。创建了这个dicts列表后,我们将它传递给DataFrame构造函数 ➋,从而得到相应的pandas DataFrame。这些dicts中的每一个都成为生成的DataFrame中的一行。字典中的键(column1column2)变成列。这是将数据直接加载到pandas的一种方式。

你还可以从外部 CSV 文件加载数据。我们使用示例 9-2 中的代码来加载本章的数据集(可在虚拟机上或本书随附的数据和代码归档中找到)。

import pandas
malware = pandas.read_csv("malware_data.csv")

示例 9-2:从外部 CSV 文件加载数据到 pandas*

当你导入malware_data.csv时,生成的malware对象应类似于下方所示:

      positives      size        type            fs_bucket
0             45    251592      trojan  2017-01-05 00:00:00
1             32    227048      trojan  2016-06-30 00:00:00
2             53    682593        worm  2016-07-30 00:00:00
3             39    774568      trojan  2016-06-29 00:00:00
4             29    571904      trojan  2016-12-24 00:00:00
5             31    582352      trojan  2016-09-23 00:00:00
6             50   2031661        worm  2017-01-04 00:00:00

我们现在有一个由我们的恶意软件数据集组成的pandas DataFrame。它有四列:positives(该样本在 57 个杀毒引擎中检测到的病毒数),size(恶意软件二进制文件在磁盘上占用的字节数),type(恶意软件的类型,如木马、蠕虫等),以及fs_bucket(首次出现该恶意软件的日期)。

与 pandas DataFrame 一起工作

现在我们已经有了pandas DataFrame中的数据,让我们看看如何通过调用describe()方法来访问和操作这些数据,如示例 9-3 所示。

In [51]: malware.describe()
Out[51]:
         positives          size
count  37511.000000  3.751100e+04
mean      39.446536  1.300639e+06
std       15.039759  3.006031e+06
min        3.000000  3.370000e+02
25%       32.000000  1.653960e+05
50%       45.000000  4.828160e+05
75%       51.000000  1.290056e+06
max       57.000000  1.294244e+08

示例 9-3:调用 describe() 方法

如示例 9-3 所示,调用describe()方法可以显示关于我们的DataFrame的一些有用统计信息。第一行,count,统计非空positives行的总数,以及非空行的总数。第二行给出了mean,即每个样本的平均正检测数,以及恶意软件样本的平均大小。接下来是positivessize的标准差,以及数据集中每一列的最小值。最后,我们可以看到每一列的百分位值和每一列的最大值。

假设我们想要检索恶意软件DataFrame中的某一列数据,例如positives列(例如,查看每个文件的平均检测数,或绘制展示数据集中positives分布的直方图)。为此,我们只需要写malware['positives'],它将返回positives列的数字列表,如示例 9-4 所示。

In [3]: malware['positives']
Out[3]:
0        45
1        32
2        53
3        39
4        29
5        31
6        50
7        40
8        20
9        40
--snip--

示例 9-4:返回 positives

在检索到一列数据后,我们可以直接对其进行统计计算。例如,malware['positives'].mean()计算该列的均值,malware['positives'].max()计算最大值,malware['positives'].min()计算最小值,malware['positives'].std()计算标准差。清单 9-5 展示了每个操作的示例。

In [7]: malware['positives'].mean()
Out[7]: 39.446535682866362

In [8]: malware['positives'].max()
Out[8]: 57

In [9]: malware['positives'].min()
Out[9]: 3

In [10]: malware['positives'].std()
Out[10]: 15.039759380778822

清单 9-5:计算均值、最大值、最小值和标准差

我们还可以对数据进行切片处理,进行更详细的分析。例如,清单 9-6 计算了特洛伊木马、比特币和蠕虫类型恶意软件的均值检测率。

In [67]: malware[malware['type'] == 'trojan']['positives'].mean()
Out[67]: 33.43822473365119

In [68]: malware[malware['type'] == 'bitcoin']['positives'].mean()
Out[68]: 35.857142857142854

In [69]: malware[malware['type'] == 'worm']['positives'].mean()
Out[69]: 49.90857904874796

清单 9-6:计算不同恶意软件的平均检测率

我们首先通过以下表示法选择DataFrametypetrojan的行:malware[malware['type'] == 'trojan']。为了选择结果数据的positives列并计算均值,我们将该表达式扩展为:malware[malware['type'] == 'trojan']['positives'].mean()。清单 9-6 得出了一个有趣的结果,即蠕虫(worm)比比特币挖矿和特洛伊木马恶意软件的检测频率更高。因为 49.9 > 35.8 和 33.4,平均而言,恶意的worm样本(49.9)比恶意的bitcointrojan样本(35.8,33.4)更频繁地被多个厂商检测到。

使用条件筛选数据

我们还可以使用其他条件选择数据的子集。例如,我们可以对数字数据(如恶意软件文件大小)使用“大于”和“小于”样式的条件来筛选数据,然后对结果子集进行统计计算。如果我们有兴趣了解杀毒引擎的有效性是否与文件大小相关,这将非常有用。我们可以使用清单 9-7 中的代码来检查这一点。

In [84]: malware[malware['size'] > 1000000]['positives'].mean()
Out[84]: 33.507073192162373

In [85]: malware[malware['size'] > 2000000]['positives'].mean()
Out[85]: 32.761442050415432

In [86]: malware[malware['size'] > 3000000]['positives'].mean()
Out[86]: 27.20672682526661

In [87]: malware[malware['size'] > 4000000]['positives'].mean()
Out[87]: 25.652548725637182

In [88]: malware[malware['size'] > 5000000]['positives'].mean()
Out[88]: 24.411069317571197

清单 9-7:按恶意软件文件大小筛选结果

以前面的代码中的第一行为例:首先,我们通过仅选择文件大小超过一百万的样本来对子集进行筛选(malware[malware['size'] > 1000000])。然后,我们抓取positives列并计算均值(['positives'].mean()),结果大约是 33.5。随着文件大小逐渐增大,我们看到每组的平均检测次数下降。这意味着我们发现了恶意软件文件大小和检测这些恶意软件样本的杀毒引擎平均数量之间确实存在关系,这一点很有趣,值得进一步研究。接下来,我们通过使用matplotlibseaborn进行可视化分析。

使用 matplotlib 可视化数据

Python 数据可视化的首选库是matplotlib;事实上,大多数其他 Python 可视化库本质上都是matplotlib的便捷封装。使用pandasmatplotlib非常方便:我们使用pandas来获取、切片和处理我们想要绘制的数据,然后使用matplotlib来绘制它。对我们而言,最有用的matplotlib函数是plot函数。图 9-3 展示了plot函数可以做什么。

image

图 9-3:恶意软件样本的大小与防病毒检测次数的图表

在这里,我绘制了我们的恶意软件数据集中的positivessize属性。一个有趣的结果出现了,正如我们在上一节中讨论pandas时所预示的那样。它显示小文件和非常大的文件很少被这 57 个扫描这些文件的防病毒引擎检测到。然而,中等大小的文件(大约在 10^(4.5)–10⁷之间)却被大多数引擎检测到了。这可能是因为小文件不包含足够的信息,使得引擎无法判断它们是恶意的,而大文件扫描速度太慢,导致许多防病毒系统干脆放弃扫描它们。

绘制恶意软件大小与供应商检测之间的关系

让我们通过使用清单 9-8 中的代码,演示如何生成图 9-3 中所示的图表。

➊ import pandas
   from matplotlib import pyplot
   malware = ➋pandas.read_csv("malware_data.csv")
   pyplot.plot(➌malware['size'], ➍malware['positives'],
               ➎'bo', ➏alpha=0.01)
   pyplot.xscale(➐"log")
➑ pyplot.ylim([0,57])
   pyplot.xlabel("File size in bytes (log base-10)")
   pyplot.ylabel("Number of detections")
   pyplot.title("Number of Antivirus Detections Versus File Size")
➒ pyplot.show()

清单 9-8:使用 plot() 函数可视化数据

如你所见,渲染这个图表并不需要太多代码。让我们逐行分析每一行的功能。首先,我们导入 ➊ 所需的库,包括pandasmatplotlib库中的pyplot模块。然后我们调用read_csv函数 ➋,正如你之前学到的,它将我们的恶意软件数据集加载到一个pandas DataFrame中。

接下来,我们调用plot()函数。该函数的第一个参数是恶意软件的size数据 ➌,第二个参数是恶意软件的positives数据 ➍,即每个恶意软件样本的正检测次数。这些参数定义了matplotlib将要绘制的数据,第一个参数代表将在 x 轴上显示的数据,第二个参数代表将在 y 轴上显示的数据。下一个参数'bo' ➎,告诉matplotlib使用何种颜色和形状来表示数据。最后,我们将alpha(即圆圈的透明度)设置为0.1 ➏,这样即使圆圈完全重叠,我们也能看到数据在图表不同区域的密度。

注意

bo中的 b 代表蓝色,* o 代表圆形,这意味着我们告诉* matplotlib 绘制蓝色圆圈来表示我们的数据。你还可以尝试其他颜色,例如绿色(g)、红色(r)、青色(c)、品红色(m)、黄色(y)、黑色(k)和白色(w)。你可以尝试的其他形状有点(.)、每个数据点一个像素( ,)、方形(s)和五边形(p)。有关完整细节,请参阅 matplotlib *文档:matplotlib.org

在调用plot()函数后,我们将 x 轴的尺度设置为对数尺度 ➐。这意味着我们将以 10 的幂次方来查看恶意软件文件的大小数据,从而使得我们能够更容易地看到非常小的文件与非常大文件之间的关系。

现在我们已经绘制了数据,接下来我们对坐标轴进行标注,并为图表命名。x 轴代表恶意软件文件的大小("文件大小(字节,log10 底数)"),y 轴代表检测次数("检测次数")。由于我们分析了 57 个杀毒引擎,因此我们将 y 轴的尺度设置为 0 到 57 的范围 ➑。最后,我们调用show()函数 ➒来显示图表。如果我们想将图表保存为图像文件,可以将此调用替换为pyplot.savefig("myplot.png")

现在我们已经完成了一个初步示例,让我们再做一个。

绘制勒索病毒检测率

这一次,让我们尝试再现图 9-1,我在本章开头展示的勒索病毒检测图。清单 9-9 展示了绘制勒索病毒检测随时间变化的完整代码。

import dateutil
import pandas
from matplotlib import pyplot

malware = pandas.read_csv("malware_data.csv")
malware['fs_date'] = [dateutil.parser.parse(d) for d in malware['fs_bucket']]
ransomware = malware[malware['type'] == 'ransomware']
pyplot.plot(ransomware['fs_date'], ransomware['positives'], 'ro', alpha=0.05)
pyplot.title("Ransomware Detections Over Time")
pyplot.xlabel("Date")
pyplot.ylabel("Number of antivirus engine detections")
pyplot.show()

清单 9-9:绘制勒索病毒检测率随时间变化的图表

清单 9-9 中的部分代码应该是我们到目前为止已经讲解过的内容,有些则不是。让我们逐行分析代码:

import dateutil

有用的 Python 包dateutil可以帮助你轻松地解析多种不同格式的日期。我们导入dateutil是因为我们需要解析日期,以便进行可视化展示。

import pandas
from matplotlib import pyplot

我们还导入了matplotlib库的pyplot模块以及pandas

malware = pandas.read_csv("malware_data.csv")
malware['fs_date'] = [dateutil.parser.parse(d) for d in malware['fs_bucket']]
ransomware = malware[malware['type'] == 'ransomware']

这些代码行读取我们的数据集,并创建一个名为ransomware的过滤数据集,里面仅包含勒索病毒样本,因为这正是我们这里要绘制的数据类型。

pyplot.plot(ransomware['fs_date'], ransomware['positives'], 'ro', alpha=0.05)
pyplot.title("Ransomware Detections Over Time")
pyplot.xlabel("Date")
pyplot.ylabel("Number of antivirus engine detections")
pyplot.show()

这五行代码与清单 9-8 中的代码相对应:它们绘制数据,给图表命名,标注 x 轴和 y 轴,然后将所有内容渲染到屏幕上(参见图 9-4)。同样,如果我们想将图表保存到磁盘上,可以将pyplot.show()调用替换为pyplot.savefig("myplot.png")

image

图 9-4:勒索病毒检测随时间变化的可视化

让我们再使用plot()函数尝试绘制一个图表。

绘制勒索病毒和蠕虫病毒检测率

这次,我们不仅绘制勒索病毒随时间变化的检测情况,还将在同一图表中绘制蠕虫检测情况。图 9-5 清楚地显示出,抗病毒行业在检测蠕虫(较旧的恶意软件趋势)方面优于勒索病毒(较新的恶意软件趋势)。

在这个图表中,我们看到的是每个时间点有多少个抗病毒引擎检测到恶意软件样本(纵轴),以及随时间变化的趋势(横轴)。每个红点代表一个type="ransomware"的恶意软件样本,而每个蓝点代表一个type="worm"的样本。我们可以看到,平均而言,更多的引擎检测到蠕虫样本,而非勒索病毒样本。然而,检测到这两种样本的引擎数量随时间缓慢上升。

image

图 9-5:勒索病毒和蠕虫恶意软件检测的可视化随时间变化

列表 9-10 显示了绘制此图表的代码。

import dateutil
import pandas
from matplotlib import pyplot

malware = pandas.read_csv("malware_data.csv")
malware['fs_date'] = [dateutil.parser.parse(d) for d in malware['fs_bucket']]

ransomware = malware[malware['type'] == 'ransomware']
worms = malware[malware['type'] == 'worm']

pyplot.plot(ransomware['fs_date'], ransomware['positives'],
            'ro', label="Ransomware", markersize=3, alpha=0.05)
pyplot.plot(worms['fs_date'], worms['positives'],
            'bo', label="Worm", markersize=3, alpha=0.05)
pyplot.legend(framealpha=1, markerscale=3.0)
pyplot.xlabel("Date")
pyplot.ylabel("Number of detections")
pyplot.ylim([0, 57])
pyplot.title("Ransomware and Worm Vendor Detections Over Time")
pyplot.show()

列表 9-10:随时间变化绘制勒索病毒和蠕虫检测率

让我们通过查看列表 9-10 的第一部分来逐步解析代码:

   import dateutil
   import pandas
   from matplotlib import pyplot

   malware = pandas.read_csv("malware_data.csv")
   malware['fs_date'] = [dateutil.parser.parse(d) for d in malware['fs_bucket']]

   ransomware = malware[malware['type'] == 'ransomware']
➊ worms = malware[malware['type'] == "worm"]
   --snip--

这段代码与之前的示例类似。到目前为止,唯一的区别是我们使用相同的方法创建了worm(蠕虫)过滤后的数据 ➊,与创建ransomware(勒索病毒)过滤后的数据的方式相同。现在,让我们来看看其余的代码:

   --snip--
➊ pyplot.plot(ransomware['fs_date'], ransomware['positives'],
               'ro', label="Ransomware", markersize=3, alpha=0.05)
➋ pyplot.plot(worms['fs_bucket'], worms['positives'],
               'bo', label="Worm", markersize=3, alpha=0.05)
➌ pyplot.legend(framealpha=1, markerscale=3.0)
   pyplot.xlabel("Date")
   pyplot.ylabel("Number of detections")
   pyplot.ylim([0,57])
   pyplot.title("Ransomware and Worm Vendor Detections Over Time")
   pyplot.show()
   pyplot.gcf().clf()

这段代码与列表 9-9 的主要区别在于,我们调用了plot()函数两次:一次使用ro选择器 ➊ 为勒索病毒数据创建红色圆点,另一次使用bo选择器 ➋ 为蠕虫数据创建蓝色圆点。请注意,如果我们想绘制第三个数据集,也可以这么做。另外,与列表 9-9 不同,在这里,➌我们为图例创建了一个标签,显示蓝色标记代表蠕虫恶意软件,红色标记代表勒索病毒。参数framealpha决定了图例背景的透明度(将其设置为 1 时,背景完全不透明),而参数markerscale则调整图例中标记的大小(在此例中,放大三倍)。

在这一节中,你学会了如何在matplotlib中制作一些简单的图表。然而,说实话——它们并不美观。在下一节中,我们将使用另一个绘图库,它应该能帮助我们让图表看起来更专业,并且帮助我们更快地实现更复杂的可视化。

使用 seaborn 可视化数据

现在我们已经讨论了pandasmatplotlib,接下来让我们介绍seaborn,这是一个建立在matplotlib之上的可视化库,但它提供了更为简洁的封装。它包括内置的主题来美化我们的图形,以及预设的更高级函数,这些都能节省在进行复杂分析时的时间。这些特点使得生成复杂且美观的图表变得简单易行。

为了探索seaborn,我们首先制作一个条形图,显示数据集中每种恶意软件类型的样本数量(见图 9-6)。

image

图 9-6:本章数据集中不同类型恶意软件的条形图

清单 9-11 展示了绘制该图的代码。

   import pandas
   from matplotlib import pyplot
   import seaborn

➊ malware = pandas.read_csv("malware_data.csv")
➋ seaborn.countplot(x='type', data=malware)
➌ pyplot.show()

清单 9-11:根据恶意软件类型绘制恶意软件数量的条形图

在这段代码中,我们首先通过pandas.read_csv ➊读取数据,然后使用seaborncountplot函数创建一个显示DataFrametype列的条形图 ➋。最后,通过调用pyplotshow()方法在 ➌ 使图表显示出来。请记住,seaborn封装了matplotlib,这意味着我们需要请求matplotlib来显示我们的seaborn图形。接下来我们来看一个更复杂的示例图表。

绘制杀毒软件检测分布图

以下图表的前提是:假设我们想了解数据集中恶意软件样本的杀毒软件检测分布(频率),以便了解大多数杀毒引擎漏掉了多少恶意软件,哪些恶意软件被大多数引擎检测到。这些信息能帮助我们了解商业杀毒软件行业的有效性。我们可以通过绘制一个条形图(直方图)来实现,显示每个检测次数下,具有该检测次数的恶意软件样本所占的比例,如图 9-7 所示。

image

图 9-7:杀毒软件检测(阳性)分布的可视化

该图的 x 轴代表恶意软件样本的分类,按 57 个总杀毒引擎检测到的数量排序。如果一个样本被 57 个引擎中的 50 个检测为恶意,它就被放置在 50 的位置;如果它仅被 57 个引擎中的 10 个检测到,则放在 10 的位置。每个条形的高度与该类别中样本的总数成正比。

该图清楚地表明,许多恶意软件样本被我们 57 个防病毒引擎中的大多数检测到(在图表的右上方区域频率的大幅峰值所示),但也表明,少数样本只被少数几个引擎检测到(图表最左侧区域所示)。我们没有显示被少于五个引擎检测到的样本,这是因为我在构建该数据集时使用的方法:我将恶意软件定义为被五个或更多防病毒引擎检测到的样本。这个绘制结果表明,仍然存在防病毒引擎之间的显著分歧,许多样本仅被 5-30 个引擎检测到。这些样本中,有的在 57 个引擎中只被 10 个引擎检测到,这意味着要么 47 个引擎没有检测到它,要么 10 个引擎犯了错误,对一个无害文件发出了误报。后一种可能性非常小,因为防病毒厂商的产品具有非常低的误报率:更可能的是,大多数引擎没有检测到这些样本。

创建这个图只需要几行绘图代码,如清单 9-12 所示。

   import pandas
   import seaborn
   from matplotlib import pyplot
   malware = pandas.read_csv("malware_data.csv")
➊ axis = seaborn.distplot(malware['positives'])
➋ axis.set(xlabel="Number of engines detecting each sample (out of 57)",
            ylabel="Amount of samples in the dataset",
            title="Commercial Antivirus Detections for Malware")
   pyplot.show()

清单 9-12:绘制阳性检测结果的分布图

seaborn包内置了一个函数,用于创建分布图(直方图),因此我们所做的只是将我们想要显示的数据malware['positives']传递给distplot函数➊。然后,我们使用seaborn返回的轴对象来配置图表标题、x 轴标签和 y 轴标签,以描述我们的图表➋。

现在让我们尝试一个包含两个变量的seaborn图:恶意软件的阳性检测次数(被五个或更多检测到的文件)及其文件大小。我们之前已经在图 9-3 中用matplotlib创建了这个图,但我们可以通过使用seabornjointplot函数,获得一个更具吸引力和信息量的结果。得到的图,如图 9-8 所示,信息丰富,但一开始可能有些难以理解,因此我们一步步来分析。

这个图与我们在图 9-7 中制作的直方图类似,但它不是通过条形高度显示单一变量的分布,而是通过颜色强度显示两个变量的分布(恶意软件文件的大小在 x 轴上,检测数量在 y 轴上)。区域越暗,数据在该区域中的数量越多。例如,我们可以看到,文件最常见的大小约为 10^(5.5),阳性值约为 53。主图上方和右侧的子图显示了大小和检测数据的平滑版本,揭示了检测(如我们在前一个图中看到的)和文件大小的分布。

image

图 9-8:恶意软件文件大小与阳性检测结果的分布可视化

中间的图最为有趣,因为它展示了文件大小与正面检测之间的关系。与图 9-3 中使用matplotlib展示单个数据点不同,它以更清晰的方式显示了整体趋势。这表明,文件非常大的恶意软件(大小为 10⁶及以上)较少被杀毒引擎检测到,这告诉我们可能需要定制一个专门检测这类恶意软件的解决方案。

创建这个图只需要一次seaborn绘图调用,如清单 9-13 所示。

   import pandas
   import seaborn
   import numpy
   from matplotlib import pyplot

   malware = pandas.read_csv("malware_data.csv")
➊ axis=seaborn.jointplot(x=numpy.log10(malware['size']),
                          y=malware['positives'],
                          kind="kde")
➋ axis.set_axis_labels("Bytes in malware file (log base-10)",
                        "Number of engines detecting malware (out of 57)")
   pyplot.show()

清单 9-13:绘制恶意软件文件大小与正面检测的分布图

在这里,我们使用seabornjointplot函数来创建positivessize列的联合分布图 ➊。另外,有些令人困惑的是,对于seabornjointplot函数,我们必须调用与清单 9-11 中不同的函数来标记坐标轴:set_axis_labels()函数 ➋,该函数的第一个参数是 x 轴标签,第二个参数是 y 轴标签。

创建小提琴图

本章我们探讨的最后一种图表类型是seaborn的小提琴图。该图可以帮助我们优雅地探索给定变量在多种恶意软件类型之间的分布。例如,假设我们对查看数据集中每种恶意软件类型的文件大小分布感兴趣。那么我们可以创建类似图 9-9 的图。

image

图 9-9:按恶意软件类型可视化文件大小

在这个图的 y 轴上是文件大小,以 10 的幂表示。x 轴上列出了每种恶意软件类型。如你所见,表示每种文件类型的条形宽度在不同大小级别上有所不同,显示了该恶意软件类型的数据中有多少是该大小。例如,你可以看到有大量非常大的勒索软件文件,而蠕虫的文件大小往往较小——这可能是因为蠕虫旨在快速传播到网络中,因此蠕虫的作者通常会将文件大小最小化。了解这些模式可能有助于我们更好地分类未知文件(较大的文件更可能是勒索软件,较小的文件则更可能是蠕虫),或者帮助我们了解在针对特定类型恶意软件的防御工具中,应该关注哪些文件大小。

创建小提琴图只需要一次绘图调用,如清单 9-14 所示。

   import pandas
   import seaborn
   from matplotlib import pyplot

   malware = pandas.read_csv("malware_data.csv")

➊ axis = seaborn.violinplot(x=malware['type'], y=malware['size'])
➋ axis.set(xlabel="Malware type", ylabel="File size in bytes (log base-10)",
            title="File Sizes by Malware Type", yscale="log")
➌ pyplot.show()

清单 9-14:创建小提琴图

在清单 9-14 中,首先我们创建小提琴图 ➊。接着我们告诉seaborn设置坐标轴标签和标题,并将 y 轴设置为对数尺度 ➋。最后,我们使图表显示出来 ➌。我们还可以绘制一个类似的图,显示每种恶意软件类型的正面检测数量,如图 9-10 所示。

image

图 9-10:按恶意软件类型可视化杀毒软件检测到的数量(阳性结果)

图 9-9 和图 9-10 的唯一区别在于,我们不再查看 y 轴上的文件大小,而是查看每个文件收到的阳性结果数量。结果显示了一些有趣的趋势。例如,勒索软件几乎总是被 30 个以上的扫描器检测到。相比之下,比特币、木马和键盘记录器类型的恶意软件,常常在不到 30 个扫描器的情况下被检测到,这意味着这些恶意软件类型中的更多部分正悄悄突破安全行业的防线(没有安装能够检测这些文件的扫描器的用户,很可能会被这些样本感染)。列表 9-15 展示了如何创建图 9-10 中所示的图表。

import pandas
import seaborn
from matplotlib import pyplot

malware = pandas.read_csv("malware_data.csv")

axis = seaborn.violinplot(x=malware['type'], y=malware['positives'])
axis.set(xlabel="Malware type", ylabel="Number of vendor detections",
         title="Number of Detections by Malware Type")
pyplot.show()

列表 9-15:按恶意软件类型可视化杀毒软件检测

这段代码与之前的唯一不同之处在于,我们传递给violinplot函数的数据不同(使用malware['positives']而不是malware['size']),我们对坐标轴的标签进行了不同的设置,标题也有所不同,并且我们省略了将 y 轴刻度设置为对数 10 的操作。

总结

在本章中,你学习了如何通过可视化恶意软件数据,帮助你获得关于趋势性威胁和安全工具效果的宏观洞察。你使用了pandasmatplotlibseaborn来创建自己的可视化图表,并从样本数据集中获得洞察。

你还学习了如何使用pandas中的describe()方法来显示有用的统计信息,以及如何提取数据集的子集。接着,你利用这些数据子集创建了自己的可视化图表,以评估杀毒软件检测的改进情况,分析趋势性恶意软件类型,并回答其他更广泛的问题。

这些都是强大的工具,可以将你手头的安全数据转化为可操作的情报,帮助指导新工具和技术的开发。我希望你能更多地了解数据可视化,并将其融入到你的恶意软件和安全分析工作流程中。

第十章:深度学习基础

image

深度学习是机器学习的一种类型,近年来由于处理能力和深度学习技术的进步而迅速发展。通常,深度学习指的是深度或多层神经网络,这些网络在执行非常复杂、通常以人为主的任务(如图像识别和语言翻译)方面表现出色。

例如,检测一个文件是否包含你以前见过的恶意代码的完全复制对计算机程序来说很简单,不需要高级的机器学习技术。但检测一个文件是否包含与以前见过的恶意代码相似的恶意代码则是一个复杂得多的任务。传统的基于签名的检测方案是死板的,对于以前未见过或经过混淆的恶意软件表现不佳,而深度学习模型能够看穿表面变化,识别出使样本具有恶意特征的核心要素。网络活动、行为分析和其他相关领域也同样适用。深度学习通过从一堆噪声中提取有用特征的能力,使其成为网络安全应用中极为强大的工具。

深度学习只是机器学习的一种类型(我们在第六章和第七章中讨论了机器学习的总体内容)。但它通常会产生比我们在前几章中讨论的方法更高的准确性,这也是为什么在过去五年左右,整个机器学习领域都强调深度学习的原因。如果你有兴趣在安全数据科学的前沿工作,那么学习如何使用深度学习是至关重要的。然而,需要注意的是:深度学习比我们在本书前面讨论的机器学习方法更难理解,完全掌握它需要一定的时间投入,以及高中水平的微积分知识。你会发现,投入的时间将会为你在安全数据科学工作中带来回报,尤其是在构建更精确的机器学习系统方面。因此,我们敦促你仔细阅读这一章,并努力理解,直到完全掌握!让我们开始吧。

什么是深度学习?

深度学习模型学会将它们的训练数据视为一个嵌套的概念层次结构,这使它们能够表示非常复杂的模式。换句话说,这些模型不仅考虑你给它们的原始特征,还会自动将这些特征组合成新的、优化的元特征,接着再将这些元特征组合成更多的特征,依此类推。

“深度”也指的是用于实现这一目标的架构,通常由多个处理单元层组成,每一层使用上一层的输出作为其输入。每个处理单元被称为神经元,整体架构被称为神经网络,如果有很多层,则称为深度神经网络

要了解这种架构如何有所帮助,我们可以考虑一个程序,该程序尝试将图像分类为自行车或独轮车。对于人类来说,这是一项简单的任务,但编程让计算机查看像素网格并判断图像代表的是什么物体却相当困难。如果独轮车稍微移动、放置在不同的角度,或者颜色发生变化,那么在一张图片中表示独轮车的某些像素,在下一张图片中可能完全代表其他意思。

深度学习模型通过将问题分解成更易管理的部分来解决这个问题。例如,深度神经网络的第一层神经元可能首先将图像分解成部分,只识别图像中的低级视觉特征,如边缘和形状的边界。这些创建的特征被送入网络的下一层,以便在这些特征中找到模式。然后,这些模式被送入后续层,直到网络识别出一般形状,最终识别出完整的物体。在我们的独轮车示例中,第一层可能会找到线条,第二层可能会看到线条形成圆形,第三层可能会识别出某些圆形实际上是车轮。通过这种方式,模型不再只是查看一堆像素,而是能够看到每张图像中有一定数量的“车轮”元特征。它可以学到,例如,两个车轮可能表示一辆自行车,而一个车轮则意味着一辆独轮车。

本章我们将重点讲解神经网络的工作原理,包括其数学原理和结构。首先,我将使用一个非常基础的神经网络作为示例,解释什么是神经元以及它如何连接到其他神经元,从而构建出一个神经网络。其次,我将描述用于训练这些网络的数学过程。最后,我将介绍一些流行的神经网络类型,它们的特殊之处以及它们擅长的领域。这将为你后续在第十一章中实际使用 Python 创建深度学习模型打下基础。

神经网络的工作原理

机器学习模型本质上只是大型的数学函数。例如,我们输入数据(比如表示为一系列数字的 HTML 文件),应用一个机器学习函数(比如神经网络),然后得到一个输出,告诉我们 HTML 文件看起来有多恶意。每个机器学习模型实际上就是一个包含可调参数的函数,这些参数在训练过程中会不断优化。

但深度学习函数究竟是如何工作的,长什么样呢?神经网络,顾名思义,就是由许多神经元组成的网络。所以,在我们理解神经网络如何工作之前,首先需要了解什么是神经元。

神经元的结构

神经元本身只是一个小而简单的函数。图 10-1 展示了一个单独神经元的样子。

image

图 10-1:单个神经元的可视化

你可以看到输入数据从左侧进入,单一的输出值从右侧输出(尽管某些类型的神经元会产生多个输出)。输出的值是神经元输入数据和一些参数的函数(这些参数在训练过程中得到优化)。每个神经元内部进行两步操作,将输入数据转换为输出。

首先,计算神经元输入的加权和。在图 10-1 中,每个输入值x[i],进入神经元后都会与相应的权重w[i]相乘。将得到的结果相加(得到加权和),然后加上一个偏置项。偏置和权重是神经元的参数,这些参数在训练过程中会被调整,以优化模型。

第二步,应用一个激活函数于加权和加偏置的值。激活函数的目的是对加权和进行非线性变换,而加权和本身是神经元输入数据的线性变换。常见的激活函数类型有很多,并且它们通常都很简单。激活函数唯一的要求是可微分,这使我们可以利用反向传播来优化参数(我们将在 “训练神经网络” 中进一步讨论此过程,参见第 189 页)。

表 10-1 展示了各种常见的激活函数,并解释了哪些激活函数适合用于哪些目的。

表 10-1: 常见激活函数

名称 图示 方程 描述
Identity image f(x) = x 基本上:没有激活函数!
ReLU image image 仅为 max(0, x)。与其他激活函数(如 sigmoid)相比,ReLU 能够实现快速学习,并且在应对梯度消失问题(将在本章后面解释)时更加稳定。
Leaky ReLU image image 类似于普通的 ReLU,但返回的是一个小常数αx的乘积,而不是 0。通常你会选择α非常小,比如 0.01。而且,α在训练过程中保持固定。
PReLU image image 这与 Leaky ReLU 类似,但在 PReLU 中,α是一个参数,其值会在训练过程中与标准的权重和偏置参数一起优化。
ELU image image 类似于 PReLU,其中α是一个参数,但当* x * < 0 时,曲线不是无限下降而是被* α 所限制,因为 e ^(x*) 在 x < 0 时始终介于 0 和 1 之间。
步骤 image image 仅仅是一个阶跃函数:该函数除非* x * ≤ 0,否则返回 0;当* x * ≤ 0 时,函数返回 1。
高斯 image f(x) = e^(-x²) 一条钟形曲线,当 x = 0 时,最大值为 1。
Sigmoid image image 由于消失梯度问题(本章后面会解释),Sigmoid 激活函数通常只用于神经网络的最后一层。由于输出是连续的并且被限制在 0 和 1 之间,Sigmoid 神经元非常适合用作输出概率的代理。
Softmax (多输出) image 输出多个加和为 1 的值。Softmax 激活函数通常用于网络的最后一层来表示分类概率,因为 Softmax 强制神经元的所有输出加和为 1。

修正线性单元(ReLU) 迄今为止是最常用的激活函数,它就是 max(0, s)。举个例子,假设你的加权和加偏置值叫做 s。如果 s 大于零,那么神经元的输出就是 s;如果 s 小于或等于零,则神经元的输出为 0。你可以简单地将 ReLU 神经元的整个函数表示为 max(0, 加权和输入 + 偏置),或者更具体地说,对于 n 个输入如下所示:

image

非线性激活函数实际上是使得这种神经元网络能够逼近任何连续函数的关键原因,这也是它们如此强大的一个重要原因。在接下来的部分中,你将学习神经元如何连接在一起形成一个网络,随后你将理解为什么非线性激活函数如此重要。

神经元网络

要创建一个神经网络,你将神经元安排在一个有向图(一个网络)中,形成多个层级,连接起来构成一个更大的函数。图 10-2 展示了一个小型神经网络的示例。

image

图 10-2:一个非常小的四神经元神经网络示例,数据通过连接从神经元传递到神经元。

在图 10-2 中,我们有原始输入:x[1]、x[2]和x[3]在左侧。这些x[i]值的副本沿着连接发送到每个神经元的隐藏层(一个神经元的层,其输出不是模型的最终输出),从而产生三个输出值,每个神经元一个。最后,这三个神经元的每个输出都被发送到最终神经元,该神经元输出神经网络的最终结果。

神经网络中的每个连接都与一个权重参数,w,相关联,每个神经元还包含一个偏置参数,b(加到加权和中),因此一个基本神经网络中可以优化的参数总数是连接输入到神经元的边的数量,再加上神经元的数量。例如,在图 10-2 中,总共有 4 个神经元,加上 9 + 3 条边,总共 16 个可优化参数。由于这是一个示例,我们使用了一个非常小的神经网络——实际的神经网络通常有成千上万个神经元和数百万条连接。

通用逼近定理

神经网络的一个显著特点是它们是通用逼近器:只要有足够的神经元,以及正确的权重和偏置值,神经网络几乎可以模拟任何类型的行为。在图 10-2 中显示的神经网络是前馈型的,这意味着数据始终是向前流动的(在图像中从左到右)。

通用逼近定理更正式地描述了普遍性概念。它声明,一个具有单个隐藏层的前馈网络(该层的神经元具有非线性激活函数)可以逼近(具有任意小的误差)R^(n)的任何连续函数。¹ 这有点复杂,但它的意思是,通过足够的神经元,神经网络可以非常精确地逼近任何具有有限输入和输出的连续有界函数。

换句话说,定理指出,不管我们想要逼近什么函数,理论上总有一个神经网络,具备正确的参数,可以完成这个任务。例如,如果你画一个波动的连续函数,f(x),就像在图 10-3 中那样,存在某个神经网络,对于每一个可能的x输入,f(x) ≈ 网络(x),无论f(x)多么复杂。这也是神经网络如此强大的原因之一。

image

图 10-3:小型神经网络如何逼近复杂函数的示例。随着神经元数量的增加,y与ŷ之间的差异将接近 0。

在接下来的章节中,我们手动构建一个简单的神经网络,以帮助你理解在给定正确参数的情况下,如何以及为什么我们可以建模如此不同的行为。虽然我们仅使用单一的输入和输出进行非常小规模的操作,但同样的原理适用于处理多个输入和输出,以及极其复杂的行为。

构建你自己的神经网络

为了看到这种普遍性,让我们尝试构建我们自己的神经网络。我们从两个 ReLU 神经元开始,使用单一的输入 x,如图 10-4 所示。然后,我们看看不同的权重和偏置值(参数)如何用于建模不同的函数和结果。

image

图 10-4:两个神经元输入数据 x 的可视化

在这里,两个神经元的权重都是 1,并且都使用 ReLU 激活函数。它们之间的唯一区别是神经元[1]应用了一个偏置值 –1,而神经元[2]应用了偏置值 –2。让我们看看当我们向神经元[1]输入不同的 x 值时会发生什么。表格 10-2 总结了结果。

表格 10-2: 神经元[1]

输入 加权和 加权和 + 偏置 输出
x x* w[x→1] x* w[x→1] + bias[1] max(0, x* w[x→1] + bias[1])
--- --- --- ---
0 0 * 1 = 0 0 + –1 = –1 max(0, –1) = 0
1 1 * 1 = 1 1 + –1 = 0 max(0, 0) = 0
2 2 * 1 = 2 2 + –1 = 1 max(0, 1) = 1
3 3 * 1 = 3 3 + –1 = 2 max(0, 2) = 2
4 4 * 1 = 4 4 + –1 = 3 max(0, 3) = 3
5 5 * 1 = 5 5 + –1 = 4 max(0, 4) = 4

第一列展示了一些 x 的输入示例,第二列展示了相应的加权和。第三列加上了偏置参数,第四列应用 ReLU 激活函数,得到给定 x 输入时神经元的输出。图 10-5 展示了神经元[1]的函数图。

image

图 10-5:神经元[1]作为函数的可视化。x 轴表示神经元的单一输入值,y 轴表示神经元的输出。

由于神经元[1]的偏置是 –1,神经元[1]的输出始终为 0,直到加权和超过 1,然后以一定的斜率上升,正如你在图 10-5 中看到的那样。斜率为 1,这与 w[x→1] 的权重值 1 相关。想象一下如果权重是 2 会发生什么:因为加权和值会翻倍,图 10-5 中的角度会出现在 x = 0.5,而不是 x = 1,直线的斜率将变为 2,而不是 1。

现在让我们看看神经元[2],它的偏置值为 –2(见表格 10-3)。

表格 10-3: 神经元[2]

输入 加权和 加权和 + 偏置 输出
x x* w[x→2] x* w[x→2] + bias[2] max(0, x* w[x→2]) + bias[2])
--- --- --- ---
0 0 * 1 = 0 0 + –2 = –2 max(0, –2) = 0
1 1 * 1 = 1 1 + –2 = –1 max(0, –1) = 0
2 2 * 1 = 2 2 + –2 = 0 max(0, 0) = 0
3 3 * 1 = 3 3 + –2 = 1 max(0, 1) = 1
4 4 * 1 = 4 4 + –2 = 2 max(0, 2) = 2
5 5 * 1 = 5 5 + –2 = 3 max(0, 3) = 3

因为 neuron[2]的偏置是–2,所以图 10-6 中的角度出现在x = 2 而不是x = 1。

image

图 10-6:neuron[2]作为函数的可视化

所以现在我们已经构建了两个非常简单的函数(神经元),它们在一段时间内什么都不做,然后以斜率 1 无限增长。因为我们使用的是 ReLU 神经元,每个神经元的函数斜率会受到其权重的影响,而其偏置和权重项则决定了斜率的起点。当使用其他激活函数时,也会遵循类似的规则。通过调整参数,我们可以随意改变每个神经元函数的角度和斜率。

然而,为了实现普适性,我们需要将神经元组合在一起,这将使我们能够逼近更复杂的函数。让我们将两个神经元连接到第三个神经元,如图 10-7 所示。这将创建一个由 neuron[1]和 neuron[2]组成的小型三神经元网络,并包含一个隐藏层。

在图 10-7 中,输入数据x被发送到 neuron[1]和 neuron[2]。然后,neuron[1]和 neuron[2]的输出作为输入传送到 neuron[3],最终得出网络的输出结果。

image

图 10-7:小型三神经元网络的可视化

如果你检查图 10-7 中的权重,你会发现权重 w[1→3] 是 2,意味着 neuron[1]对 neuron[3]的贡献被放大了两倍。同时,w[2→3] 是–1,表示 neuron[2]的贡献被反转。本质上,neuron[3]只是在将其激活函数应用于 neuron[1] * 2 – neuron[2]。 表 10-4 总结了该网络的输入和相应的输出。

表 10-4:三神经元网络

原始网络输入 输入到 neuron[3]的值 加权和 加权和 + 偏置 最终网络输出
x neuron[1] neuron[2] (neuron[1] * w[1→3]) + (neuron[2] * w[2→3]) (neuron[1] * w[1→3]) + (neuron[2] * w[2→3]) + bias[3]
--- --- --- --- ---
0 0 0 (0 * 2) + (0 * –1) = 0 0 + 0 + 0 = 0
1 0 0 (0 * 2) + (0 * –1) = 0 0 + 0 + 0 = 0
2 1 0 (1 * 2) + (0 * –1) = 2 2 + 0 + 0 = 2
3 2 1 (2 * 2) + (1 * –1) = 3 4 + –1 + 0 = 3
4 3 2 (3 * 2) + (2 * –1) = 4 6 + –2 + 0 = 4
5 4 3 (4 * 2) + (3 * –1) = 5 8 + –3 + 0 = 5

第一列显示原始网络输入 x,随后是神经元[1]和神经元[2]的输出。其余列显示神经元[3]如何处理输出:计算加权和,加入偏置,最后在最后一列应用 ReLU 激活函数,从而得出每个原始输入值 x 的神经元和网络输出。图 10-8 显示了网络的功能图。

image

图 10-8:我们的网络输入和相应输出的可视化

我们可以看到,通过这些简单函数的组合,我们可以创建一个图形,使其在不同点上具有任意的上升斜率或周期,正如我们在图 10-8 中所做的那样。换句话说,我们离能够为我们的输入 x 表示任何有限函数的目标更近了!

向网络中添加另一个神经元

我们已经看到如何通过添加神经元使网络的功能图上升(具有任意斜率),但我们如何让图形下降呢?让我们向网络中添加另一个神经元(神经元[4]),如图 10-9 所示。

image

图 10-9:带有单个隐藏层的小型四神经元网络的可视化

在图 10-9 中,输入数据 x 被传送到神经元[1]、神经元[2] 和神经元[4]。它们的输出随后作为输入传递给神经元[3],最终产生网络的最终输出。神经元[4]与神经元[1]和神经元[2]相同,但其偏置被设置为–4。表 10-5 总结了神经元[4]的输出。

表 10-5:神经元[4]

输入 加权和 加权和 + 偏置 输出
x x * w[x→4] (x * w[x→4]) + 偏置[4] max(0, (x * w[x→4]) + 偏置[4])
--- --- --- ---
0 0 * 1 = 0 0 + –4 = –4 max(0, –4) = 0
1 1 * 1 = 1 1 + –4 = –3 max(0, –3) = 0
2 2 * 1 = 2 2 + –4 = –2 max(0, –2) = 0
3 3 * 1 = 3 3 + –4 = –1 max(0, –1) = 0
4 4 * 1 = 4 4 + –4 = 0 max(0, 0) = 0
5 5 * 1 = 5 5 + –4 = 1 max(0, 1) = 1

为了让我们的网络图形下降,我们通过将连接神经元[4]到神经元[3]的权重设置为–2,从神经元[1]和神经元[2]的函数中减去神经元[4]的函数,来调整神经元[3]的加权和。表 10-6 显示了整个网络的新输出。

表 10-6:四神经元网络

原始网络输入 神经元[3]的输入 加权和 加权和 + 偏置 最终网络输出
x 神经元[1] 神经元[2] 神经元[4] (神经元[1] * w[1→3]) + (神经元[2] * w[2→3]) + (神经元[4] * w[4→3])
--- --- --- --- ---
0 0 0 0 (0 * 2) + (0 * –1) + (0 * –2) = 0
1 0 0 0 (0 * 2) + (0 * –1) + (0 * –2) = 0
2 1 0 0 (1 * 2) + (0 * –1) + (0 * –2) = 2
3 2 1 0 (2 * 2) + (1 * –1) + (0 * –2) = 3
4 3 2 0 (3 * 2) + (2 * –1) + (0 * –2) = 4
5 4 3 1 (4 * 2) + (3 * –1) + (1 * –2) = 5

图 10-10 显示了这一过程的具体样子。

image

图 10-10:我们的四神经元网络的可视化

希望现在你能看到神经网络架构是如何通过结合多个简单的神经元(普适性!)来使我们在图表上的任何点上以任意速率上下移动的。我们可以继续添加更多神经元,以创建更加复杂的功能。

自动特征生成

你已经学到,具有单个隐藏层的神经网络可以通过足够的神经元来逼近任何有限的函数。这是一个相当强大的想法。但如果我们有多个隐藏层的神经元会怎样呢?简而言之,自动特征生成发生了,这可能是神经网络的一个更强大的方面。

历史上,构建机器学习模型的一个重要部分是特征提取。对于 HTML 文件来说,通常会花费大量时间来决定 HTML 文件的哪些数字特征(例如章节标题的数量、唯一单词的数量等等)可能有助于模型。

拥有多个层和自动特征生成的神经网络使我们能够卸载大量的工作。一般来说,如果你将相对原始的特征(例如 HTML 文件中的字符或单词)提供给神经网络,每一层神经元都可以学习以适当的方式表示这些原始特征,这些表示将作为后续层的输入。换句话说,神经网络将学会统计字母a在 HTML 文档中出现的次数,如果这对检测恶意软件特别相关,即使没有人类明确指出它是否相关。

在我们的图像处理自行车示例中,没人特别告诉网络边缘或车轮的元特征是有用的。模型在训练过程中学会了这些特征作为输入传递到下一个神经元层时的有用性。特别有用的是,这些低级的学习到的特征可以被后续层以不同的方式使用,这意味着深度神经网络可以使用比单层网络更少的神经元和参数来估计许多极其复杂的模式。

神经网络不仅完成了以前需要大量时间和精力的特征提取工作,而且它们以优化和节省空间的方式完成这些工作,且由训练过程引导。

训练神经网络

到目前为止,我们已经探讨了如何在给定大量神经元以及合适的权重和偏置项的情况下,神经网络可以逼近复杂的函数。在我们迄今为止的所有示例中,我们手动设置了这些权重和偏置参数。然而,由于真实的神经网络通常包含成千上万的神经元和数百万个参数,我们需要一种高效的方式来优化这些值。

通常,在训练一个模型时,我们从一个训练数据集和一个拥有一堆未优化(随机初始化)参数的网络开始。训练需要优化参数,以最小化目标函数。在监督学习中,我们试图训练模型以预测标签,比如 0 代表“良性”而 1 代表“恶意软件”,这个目标函数将与网络在训练过程中的预测误差相关。对于某个给定的输入 x(例如,特定的 HTML 文件),这是我们知道的正确标签 y(例如,1.0 代表“是恶意软件”)和我们从当前网络得到的输出 ŷ(例如,0.7)之间的差异。你可以把误差看作是预测标签 ŷ 和已知的真实标签 y 之间的差异,其中网络 (x) = ŷ,而网络试图逼近某个未知的函数 f,使得 f(x) = y。换句话说,网络 = images

训练网络的基本思想是将一个来自训练数据集的观测值 x 输入网络,接收一个输出 ŷ,然后找出如何改变参数能使 ŷ 更接近你的目标 y。想象你在一个宇宙飞船中,飞船上有各种旋钮。你不知道每个旋钮的作用,但你知道你想去的方向是 (y)。为了解决这个问题,你踩下油门并记录你行进的方向 (ŷ)。然后,你稍微调节一个旋钮,再次踩下油门。你第一次和第二次的方向差异告诉你那个旋钮对方向的影响有多大。通过这种方式,你最终可以学会如何非常好地驾驶飞船。

训练神经网络类似。首先,你将一个来自训练数据集的观测值,x,输入网络,并接收到某些输出,ŷ。这一步骤被称为前向传播,因为你将输入 x 向前传递通过网络,得到最终的输出 ŷ。接下来,你需要确定每个参数如何影响你的输出 ŷ。例如,如果你的网络输出是 0.7,但你知道正确的输出应该更接近 1,你可以尝试稍微增加一个参数,w,看看 ŷ 是接近还是远离 y,并且相差多少。² 这就叫做 ŷ 关于 w 的偏导数,或者 ∂ŷ/∂w

网络中的所有参数随后都会在一个方向上微调一个 极小 的量,使得 ŷ 稍微接近 y(从而网络更接近 f)。如果 ∂ŷ/∂w 为正,则说明你应该增加 w 一个小量(具体来说,比例为 (yŷ)/∂w),以使新的 ŷ 从 0.7 稍微远离,接近 1(y)。换句话说,你通过纠正训练数据中带标签的错误,教会你的网络近似 未知 函数 f

反复计算这些偏导数、更新参数,然后重复这一过程的过程称为 梯度下降。然而,对于包含成千上万神经元、数百万参数和通常数百万训练样本的网络来说,所有这些微积分运算需要大量计算。为了绕开这个问题,我们使用一个巧妙的算法,称为 反向传播,它使得这些计算在计算上可行。反向传播的核心是,它允许我们高效地沿着计算图(如神经网络)计算偏导数!

使用反向传播优化神经网络

在这一节中,我们构建了一个简单的神经网络,展示反向传播是如何工作的。假设我们有一个训练样本,其值为 x = 2,并且其真实标签为 y = 10。通常,x 会是一个包含多个值的数组,但为了简单起见,我们就用一个单一的值。将这些值代入后,我们可以看到在 图 10-11 中,当输入 x 为 2 时,网络输出的 ŷ 值为 5。

image

图 10-11:我们三层神经网络的可视化,输入为 x = 2

为了调整我们的参数,使得网络的输出 ŷ(在 x = 2 下)更接近我们已知的 y 值 10,我们需要计算 w[1→3] 如何影响最终的输出 ŷ。让我们来看一下,当我们将 w[1→3] 增加一点(比如 0.01)时会发生什么。神经元[3]中的加权和变为 1.01 * 2 + (1 * 3),使得最终的输出 ŷ 从 5 改变为 5.02,结果增加了 0.02。换句话说,ŷ 关于 w[1→3] 的偏导数为 2,因为改变 w[1→3] 会使 ŷ 改变两倍。

因为 y 为 10,而当前的输出 ŷ(在当前参数值和 x = 2 下)为 5,所以我们现在知道应该稍微增加 w[1→3],以将 y 移动得更接近 10。

这相当简单。但是我们需要能够知道在网络中如何调整所有参数的方向,而不仅仅是最终层中神经元的参数。例如,w[x→1]呢?计算 ∂ŷ/∂w[x→1] 更复杂,因为它只是间接地影响ŷ。首先,我们询问神经元[3]的函数,看看神经元[1]的输出如何影响ŷ。如果我们将神经元[1]的输出从 2 改为 2.01,神经元[3]的最终输出将从 5 改为 5.01,所以 ∂ŷ/neuron[1] = 1。为了知道 w[x→1] 如何影响 ŷ,我们只需要将 ∂ŷ/neuron[1] 乘以 w[x→1] 如何影响神经元[1]的输出。如果我们将 w[x→1] 从 1 改为 1.01,神经元[1]的输出将从 2 改为 2.02,所以 neuron[1]/∂w[x→1] 是 2。因此:

image

或者:

image

你可能已经注意到,我们刚才使用了链式法则。³

换句话说,要弄清楚像 w[x→1] 这样深处于网络中的参数如何影响我们的最终输出 ŷ,我们需要将每一条路径上偏导数的值相乘,直到路径结束。也就是说,如果 w[x→1] 输入到一个神经元,这个神经元的输出输入到十个其他神经元,那么计算 w[x→1] 对 ŷ 的影响将涉及对所有从 w[x→1] 到 ŷ 的路径进行求和,而不仅仅是计算一条路径的影响。图 10-12 可视化了由示例权重参数 w[x→2] 影响的路径。

image

图 10-12:受 w[x→2] 影响的路径的可视化(显示为深灰色):输入数据 x 与第一层(最左边)中间神经元之间连接的权重

请注意,这个网络中的隐藏层并不是完全连接的层,这有助于解释为什么第二个隐藏层的底部神经元没有被高亮显示。

路径爆炸

但是当我们的网络变得更大时会发生什么呢?我们需要添加的路径数量以指数级增加,以计算低层参数的偏导数。考虑一个神经元,它的输出输入到一层 1000 个神经元,这些神经元的输出再输入到另外 1000 个神经元,最后这些输出再输入到一个最终的输出神经元。

这导致了上百万条路径!幸运的是,逐条遍历每一条路径并将其加总以获得 ∂ŷ/(parameter) 是不必要的。这时反向传播就显得非常有用了。我们不需要沿着每一条路径走到最终输出 ŷ,而是逐层计算偏导数,从上到下,或者反向进行。

使用上一节中的链式法则逻辑,我们可以计算任何偏导数 ∂ŷ/∂w,其中 w 是连接层[i–1]的输出到层[i]中神经元[i]的一个参数,通过对所有神经元[i][+1]进行求和,其中每个神经元[i][+1] 是层[i][+1] 中与神经元[i](即 w 所连接的神经元)相连的神经元:

image

通过从上到下逐层进行,我们通过在每一层合并导数来限制路径爆炸。换句话说,在顶层[i+1]中计算的导数(如∂ŷ/neuron[i+1])会被记录下来,帮助计算第[i]层的导数。然后,为了计算第[i]层的导数[–1],我们使用来自第[i]层的已保存导数(如∂ŷ/neuron[i])。接着,第[i]层[–2]使用来自第[i–1]层的导数,以此类推。这个技巧大大减少了我们需要重复的计算量,并帮助我们更快速地训练神经网络。

梯度消失

很深的神经网络面临的一个问题是梯度消失问题。考虑一个神经网络中第一层的权重参数,这个网络有十层。它从反向传播中得到的信号是该权重的神经元到最终输出的所有路径信号的总和。

问题在于,每条路径的信号可能会变得非常微小,因为我们通过在沿着十层神经元深的路径的每一点上乘以偏导数来计算该信号,这些偏导数的值往往小于 1。这意味着低层神经元的参数是基于大量非常小的数值的总和来更新的,其中许多数值会相互抵消。因此,网络很难协调向较低层的参数发送强信号。随着更多层的添加,这个问题会呈指数级恶化。正如你在下一部分中将学到的,某些网络设计试图绕过这个普遍存在的问题。

神经网络的类型

为了简便起见,到目前为止我展示的每个例子都使用了一种叫做前馈神经网络的网络类型。实际上,还有许多其他有用的网络结构可以用于不同类别的问题。让我们讨论一些最常见的神经网络类别,以及它们在网络安全背景下的应用。

前馈神经网络

最简单(也是最初)类型的神经网络——前馈神经网络——就像是没有配件的芭比娃娃:其他类型的神经网络通常只是这种“默认”结构的变种。前馈架构应该听起来很熟悉:它由一层层神经元堆叠组成。每一层神经元都与下一层中的某些或所有神经元相连,但连接从不反向或形成循环,因此得名“前馈”。

在前馈神经网络中,存在的每个连接都是将第i层的神经元(或原始输入)连接到第j层的神经元,其中j > i。第i层中的每个神经元不一定要与第i+1 层中的每个神经元连接,但所有连接必须是前馈的,将前面的层与后面的层连接起来。

前馈网络通常是你在面对问题时首先采用的网络,除非你已经知道另一种在当前问题上表现特别好的架构(比如用于图像识别的卷积神经网络)。

卷积神经网络

一个卷积神经网络(CNN)包含卷积层,其中每个神经元输入的内容是通过一个滑动窗口定义的,该窗口在输入空间上滑动。想象一个小方形窗口在一张较大的图片上滑动,只有通过窗口可见的像素才会连接到下一层的特定神经元。然后,窗口继续滑动,新的像素集合连接到新的神经元。图 10-13 展示了这一过程。

这些网络的结构鼓励局部特征学习。例如,网络的低层更专注于图像中相邻像素之间的关系(这些关系形成边缘、形状等),而不是专注于图像中随机散布的像素之间的关系(这些关系通常意义不大)。滑动窗口明确地强制了这种聚焦,这有助于在局部特征提取尤为重要的领域加快学习速度。

image

图 10-13:一个 2 × 2 的卷积窗口在 3 × 3 的输入空间上滑动,步幅(步长)为 1,得到 2 × 2 的输出

由于能够专注于输入数据的局部部分,卷积神经网络在图像识别和分类中非常有效。它们也已被证明在某些类型的自然语言处理任务中有效,这对网络安全有重要意义。

在每个卷积窗口的值被输入到卷积层的特定神经元后,窗口再次滑动,覆盖这些神经元的输出,但不同于将其输入到标准神经元(例如 ReLU)并为每个输入分配权重的方式,它们被输入到没有权重的神经元(即固定为 1)和最大值(或类似)激活函数中。换句话说,一个小窗口被滑过卷积层的输出,每个窗口的最大值被取出并传递到下一层。这称为池化层。池化层的目的是对数据(通常是图像)进行“缩小”,从而减少特征的大小以加速计算,同时保留最重要的信息。

卷积神经网络可以包含一个或多个卷积层和池化层的组合。标准架构可能包括一个卷积层,一个池化层,接着是另一组卷积层和池化层,最后是几层全连接层,就像在前馈神经网络中一样。这个架构的目标是使这些最终的全连接层接收相对较高层次的特征作为输入(例如单轮车的轮子),从而能够准确地分类复杂的数据(如图像)。

自编码神经网络

自编码器 是一种神经网络,它试图在原始训练输入和解压后的输出之间保持最小差异,来压缩并解压输入。自编码器的目标是学习一组数据的高效表示。换句话说,自编码器充当像优化过的有损压缩程序一样,它们将输入数据压缩成更小的表示,再解压回原始输入大小。

自编码器网络不是通过最小化已知标签(y)与预测标签(ŷ)之间的差异来优化参数,而是通过最小化原始输入 x 与重建输出之间的差异来优化!images。

在结构上,自编码器通常与标准的前馈神经网络非常相似,不同之处在于中间层的神经元数量比早期和后期的层要少,正如图 10-14 所示。

image

图 10-14:自编码器网络的可视化

如你所见,中间层比最左侧(输入)和最右侧(输出)层要小得多,而这两层的大小相同。最后一层应始终包含与原始输入相同数量的输出,这样每个训练输入 x[i] 就可以与其压缩和重建后的副本进行比较!images。

自编码器网络训练完成后,可以用于不同的目的。自编码器网络可以简单地作为高效的压缩/解压程序。例如,训练用于压缩图像文件的自编码器,可以创建比使用 JPEG 压缩相同图像到相同大小时更加清晰的图像。

生成对抗网络

生成对抗网络(GAN) 是一组 两个 神经网络互相竞争、提升各自任务的系统。通常,生成 网络尝试从随机噪声中生成假样本(例如某种图像)。然后,第二个 判别器 网络试图区分真实样本和假生成样本之间的差异(例如,区分真实卧室图像与生成的图像)。

GAN 中的两个神经网络都通过反向传播进行优化。生成器网络根据它在某一回合中如何欺骗判别器网络来优化其参数,而判别器网络则根据它能够多准确地区分生成样本和真实样本来优化其参数。换句话说,它们的损失函数是彼此的直接对立面。

GANs 可用于生成逼真的数据或增强低质量或损坏的数据。

递归神经网络

递归神经网络(RNN) 是一类相对广泛的神经网络,其中神经元之间的连接形成有向循环,其激活函数依赖于时间步。这样,网络就能发展出记忆,帮助它学习数据序列中的模式。在 RNN 中,输入、输出或输入和输出都可以是某种时间序列。

RNNs 非常适用于数据顺序重要的任务,如连写识别、语音识别、语言翻译和时间序列分析。在网络安全领域,它们与诸如网络流量分析、行为检测和静态文件分析等问题相关。因为程序代码类似于自然语言,顺序同样重要,因此可以将其视为时间序列。

RNNs 的一个问题是,由于梯度消失问题,RNN 中每个时间步引入的内容类似于前馈神经网络中的整个额外层。在反向传播过程中,梯度消失问题使得低层(或在这种情况下,较早的时间步)中的信号变得非常微弱。

长短期记忆(LSTM)网络是一种专门设计用来解决这一问题的 RNN。LSTM 包含 记忆单元 和特殊的神经元,旨在决定记住哪些信息以及忘记哪些信息。丢弃大部分信息大大限制了梯度消失问题,因为它减少了路径爆炸。

ResNet

ResNet残差网络的简称)是一种神经网络,通过在网络的早期/浅层与更深层之间创建 跳跃连接,即跳过一个或多个中间层来实现。这里的 残差 一词指的是这些网络学习直接在层之间传递数据信息,而无需通过我们在 表 10-1 中说明的激活函数。

这种结构大大减少了梯度消失问题,使得 ResNet 能够变得非常深——有时可以达到超过 100 层。

非常深的神经网络擅长建模输入数据中极其复杂和异常的关系。由于 ResNet 可以拥有如此多的层,它们特别适合复杂问题。像前馈神经网络一样,ResNet 更重要的是因其在解决复杂问题方面的通用有效性,而不是在非常具体的领域中的专长。

总结

在本章中,你学习了神经元的结构以及它们如何连接在一起形成神经网络。你还探讨了这些网络是如何通过反向传播进行训练的,并且发现了一些神经网络的优缺点,例如普适性、自动特征生成以及梯度消失问题。最后,你了解了几种常见神经网络的结构和优点。

在下一章,你将使用 Python 的Keras包实际构建神经网络来检测恶意软件。

第十一章:使用 Keras 构建一个神经网络恶意软件检测器

image

十年前,构建一个功能完善、可扩展且快速的神经网络既费时又需要大量代码。然而,在过去的几年里,这一过程变得不那么痛苦了,因为越来越多的高级神经网络设计接口已经开发出来。Python 包 Keras 就是其中之一。

在本章中,我将带你通过使用 Keras 包构建一个示例神经网络。首先,我会解释如何在 Keras 中定义模型架构。其次,我们将训练这个模型来区分良性和恶意的 HTML 文件,你将学习如何保存和加载这些模型。第三,使用 Python 包 sklearn,你将学习如何评估模型在验证数据上的准确性。最后,我们将用你所学的知识将验证准确性报告集成到模型训练过程中。

我鼓励你在阅读本章的同时,阅读并编辑附带本书的数据中的相关代码。你可以在那里找到本章讨论的所有代码(已经组织成参数化函数,便于运行和调整),以及一些额外的示例。到本章结束时,你将感到准备好开始构建自己的神经网络了!

要运行本章中的代码示例,你不仅需要安装本章中 ch11/requirements.txt 文件中列出的包(pip install –r requirements.txt),还需要按照指示在系统中安装 Keras 的后端引擎(TensorFlow、Theano 或 CNTK)。按照这里的说明安装 TensorFlow: www.tensorflow.org/install/

定义模型架构

要构建一个神经网络,你需要定义它的架构:哪些神经元去哪里,它们如何连接到后续的神经元,以及数据如何在整个网络中流动。幸运的是,Keras 提供了一个简单、灵活的接口来定义这一切。Keras 实际上支持两种相似的模型定义语法,但我们将使用功能 API 语法,因为它比其他(“顺序”)语法更加灵活和强大。

在设计模型时,你需要三样东西:输入、中间的处理部分以及输出。有时,你的模型会有多个输入、多个输出,并且中间的部分非常复杂,但基本的思路是,当定义一个模型的架构时,你只是定义了输入——你的数据,比如与 HTML 文件相关的特征——是如何通过各个神经元(即中间部分)流动的,直到最后的神经元输出某些结果。

为了定义这个架构,Keras使用层。是一组神经元,这些神经元使用相同的激活函数,所有神经元接收来自上一层的数据,并将它们的输出发送到后一层神经元。在神经网络中,输入数据通常会被送入初始层的神经元,这些神经元将其输出传递给后续的层,然后继续传递,直到最后一层神经元生成网络的最终输出。

清单 11-1 是一个使用Keras的函数式 API 语法定义的简单模型示例。我鼓励你打开一个新的 Python 文件,跟着我们逐行讲解代码,同时自己动手编写和运行代码。或者,你也可以尝试运行本书附带的数据中的相关代码,可以通过将ch11/model_architecture.py文件的部分内容复制并粘贴到 ipython 会话中,或者在终端窗口中运行python ch11/model_architecture.py来尝试。

➊ from keras import layers
➋ from keras.models import Model

   input = layers.Input(➌shape=(1024,), ➍dtype='float32')
➎ middle = layers.Dense(units=512, activation='relu')(input)
➏ output = layers.Dense(units=1, activation='sigmoid')(middle)
➐ model = Model(inputs=input, outputs=output)
   model.compile(➑optimizer='adam', 
                 ➒loss='binary_crossentropy', 
                 ➓metrics=['accuracy'])

清单 11-1:使用函数式 API 语法定义简单模型

首先,我们导入Keras包的layers子模块➊,以及Kerasmodels子模块中的Model类➋。

接下来,我们通过将一个shape值(一个整数元组)➌和数据类型(字符串)➍传递给layers.Input()函数来指定此模型将接受的单个观测数据的类型。在这里,我们声明输入数据将是一个包含 1,024 个浮点数的数组。如果我们的输入是整数矩阵,第一行代码将更像是input = Input(shape=(100, 100,) dtype='int32')

注意

如果模型在一个维度上接受可变大小的输入,可以使用 None 代替数字——例如,(100, None,)

接下来,我们指定该输入数据将被发送到的神经网络层。为此,我们再次使用导入的layers子模块,特别是Dense函数➎,来指定这一层将是一个密集连接层(也称为全连接层),这意味着前一层的每个输出都会发送到这一层的每个神经元。Dense是你在开发Keras模型时最常用的层类型。其他层则允许你执行一些操作,例如更改数据的形状(Reshape)或实现自定义层(Lambda)。

我们将两个参数传递给 Dense 函数:units=512,表示我们希望这一层有 512 个神经元,以及 activation='relu',表示我们希望这些神经元使用修正线性单元(ReLU)激活函数。(回想一下 第十章,ReLU 神经元使用一种简单的激活函数,输出两者中较大的值:要么是 0,要么是神经元输入的加权和。)我们使用 layers.Dense(units=512, activation='relu') 来定义这一层,然后行尾的 (input) 表示该层的输入(即我们的 input 对象)。理解这一点很重要,因为传递 input 给我们的层就是定义模型中数据流的方式,而不是代码行的顺序。

在下一行,我们定义了模型的输出层,同样使用了 Dense 函数。但这次,我们为该层指定了一个神经元,并使用 'sigmoid' 激活函数 ➏,该激活函数非常适合将大量数据合并成一个介于 0 和 1 之间的单一评分。输出层将 (middle) 对象作为输入,声明我们在 middle 层的 512 个神经元的输出都将发送到这个神经元。

现在我们已经定义了各层,使用 models 子模块中的 Model 类将所有这些层组合成一个模型 ➐。注意,你需要指定输入层和输出层。因为每一层的输入来自前一层,所以最终的输出层包含了模型所需的所有前一层的信息。我们可以在 inputoutput 层之间再声明 10 个 middle 层,但在 ➐ 处的代码行将保持不变。

编译模型

最后,我们需要编译模型。我们已经定义了模型的架构和数据流,但还没有指定如何执行训练。为此,我们使用模型的 compile 方法,并传入三个参数:

  • 第一个参数 optimizer ➑ 指定了要使用的反向传播算法类型。你可以通过字符字符串指定想要使用的算法名称,如我们这里所做的,或者直接从 keras.optimizers 导入一个算法,传入特定的参数,甚至设计你自己的算法。

  • loss 参数 ➒ 指定了在训练过程中(反向传播)要最小化的目标。具体来说,它指定了你希望用来表示真实训练标签和模型预测标签(输出)之间差异的公式。同样,你可以指定一个损失函数的名称,或者直接传入一个实际的函数,例如 keras.losses.mean_squared_error

  • 最后,对于metrics参数 ➓,你可以传递一个包含你希望Keras在训练期间和训练后分析模型性能时报告的度量列表。例如,你可以传递字符串或实际的度量函数,比如['categorical_accuracy', keras.metrics.top_k_categorical_accuracy]

在运行列表 11-1 中的代码后,运行model.summary()以查看模型结构输出到你的屏幕上。你的输出应当类似于图 11-1。

image

图 11-1: model.summary() 的输出

图 11-1 展示了model.summary()的输出。每一层的描述都会打印到屏幕上,连同与该层相关的参数数量。例如,dense_1层有 524,800 个参数,因为它的每个 512 个神经元都从输入层获取每一个 1,024 个输入值的副本,这意味着有 1,024 × 512 个权重。再加上 512 个偏置参数,得到 1,024 × 512 + 512 = 524,800。

虽然我们还没有训练我们的模型或在验证数据上测试它,但这是一个已编译的Keras模型,准备好进行训练!

注意

查看 ch11/model_architecture.py 中的示例代码,了解一个稍微复杂一点的模型示例!

训练模型

为了训练我们的模型,我们需要训练数据。本书随附的虚拟机包含约 50 万份良性和恶意 HTML 文件的数据。这些数据包括两个文件夹,分别是良性(ch11/data/html/benign_files/)和恶意(ch11/data/html/malicious_files/)HTML 文件。(记住不要在浏览器中打开这些文件!)在本节中,我们使用这些数据训练神经网络,预测 HTML 文件是良性(0)还是恶意(1)。

特征提取

为了实现这一点,我们首先需要决定如何表示我们的数据。换句话说,我们想从每个 HTML 文件中提取哪些特征作为模型的输入?例如,我们可以简单地将每个 HTML 文件的前 1,000 个字符传递给模型,或者传递字母表中所有字母的频率计数,或者我们可以使用 HTML 解析器来开发一些更复杂的特征。为了简化操作,我们将每个可变长度、可能非常大的 HTML 文件转换为一个统一大小的压缩表示,以便我们的模型能够快速处理并学习重要的模式。

在这个例子中,我们将每个 HTML 文件转换为一个长度为 1,024 的类别计数向量,其中每个类别计数表示 HTML 文件中哈希值解析为给定类别的标记数量。列表 11-2 展示了特征提取代码。

import numpy as np
import murmur
import re
import os

def read_file(sha, dir):
    with open(os.path.join(dir, sha), 'r') as fp:
       file = fp.read()
    return file

def extract_features(sha, path_to_files_dir,
                     hash_dim=1024, ➊split_regex=r"\s+"):
  ➋ file = read_file(sha=sha, dir=path_to_files_dir)
  ➌ tokens = re.split(pattern=split_regex, string=file)
    # now take the modulo(hash of each token) so that each token is replaced
    # by bucket (category) from 1:hash_dim.
    token_hash_buckets = [
      ➍ (murmur.string_hash(w) % (hash_dim - 1) + 1) for w in tokens
    ]
    # Finally, we'll count how many hits each bucket got, so that our features
    # always have length hash_dim, regardless of the size of the HTML file:
    token_bucket_counts = np.zeros(hash_dim)
    # this returns the frequency counts for each unique value in
    # token_hash_buckets:
    buckets, counts = np.unique(token_hash_buckets, return_counts=True)
    # and now we insert these counts into our token_bucket_counts object:
    for bucket, count in zip(buckets, counts):
      ➎ token_bucket_counts[bucket] = count
    return np.array(token_bucket_counts)

列表 11-2:特征提取代码

你不必理解这段代码的所有细节来理解Keras的工作原理,但我鼓励你阅读代码中的注释,更好地理解代码的执行过程。

extract_features 函数首先将一个 HTML 文件作为一个大字符串读取 ➋,然后根据正则表达式 ➌ 将这个字符串拆分成一组标记。接着,取每个标记的数字哈希值,并通过对每个哈希值取模 ➍ 将这些哈希值分为不同的类别。最终的特征集是每个类别中的哈希数量 ➎,类似于直方图的箱数。如果你愿意,可以尝试更改正则表达式 split_regex ➊,该表达式将 HTML 文件拆分成多个块,看看它如何影响结果的标记和特征。

如果你跳过了或者没有理解所有内容,也没关系:只需知道我们的 extract_features 函数接受一个 HTML 文件的路径作为输入,然后将其转换为一个长度为 1,024 的特征数组,或者任何 hash_dim 的值。

创建数据生成器

现在我们需要让我们的 Keras 模型真正基于这些特征进行训练。当数据量较少且已经加载到内存中时,你可以使用类似于 Listing 11-3 的一行代码来在 Keras 中训练你的模型。

# first you would load in my_data and my_labels via some means, and then:
model.fit(my_data, my_labels, epochs=10, batch_size=32)

Listing 11-3: 当数据已经加载到内存时训练模型

然而,当你开始处理大量数据时,这并不太实用,因为你无法一次性将所有训练数据加载到计算机内存中。为了解决这个问题,我们使用了稍微复杂但更具扩展性的 model.fit_generator 函数。你不会一次性将所有训练数据传递给这个函数,而是传递一个生成器,这个生成器会批量生成训练数据,以避免计算机的内存崩溃。

Python 生成器与 Python 函数非常相似,唯一不同的是它们有一个 yield 语句。生成器不会返回单一的结果,而是返回一个可以多次调用的对象,以产生多个或无限数量的结果。 Listing 11-4 展示了我们如何使用特征提取函数创建自己的数据生成器。

def my_generator(benign_files, malicious_files,
                 path_to_benign_files, path_to_malicious_files,
                 batch_size, features_length=1024):
    n_samples_per_class = batch_size / 2
  ➊ assert len(benign_files) >= n_samples_per_class
    assert len(malicious_files) >= n_samples_per_class
  ➋ while True:
        ben_features = [
            extract_features(sha, path_to_files_dir=path_to_benign_files,
                             hash_dim=features_length)
            for sha in np.random.choice(benign_files, n_samples_per_class,
                                        replace=False)
        ]
        mal_features = [
          ➌ extract_features(sha, path_to_files_dir=path_to_malicious_files,
                             hash_dim=features_length)
          ➍ for sha in np.random.choice(malicious_files, n_samples_per_class,
                                        replace=False)
        ]
      ➎ all_features = ben_features + mal_features
        labels = [0 for i in range(n_samples_per_class)] + [1 for i in range(
                  n_samples_per_class)]

        idx = np.random.choice(range(batch_size), batch_size)
      ➏ all_features = np.array([np.array(all_features[i]) for i in idx]) 
        labels = np.array([labels[i] for i in idx])
      ➐ yield all_features, labels

Listing 11-4: 编写数据生成器

首先,代码做了两个 assert 语句来检查数据是否足够 ➊。然后,在一个 while ➋ 循环中(这样它就会无限迭代),通过随机选择文件键的样本 ➍ 来抓取良性和恶性特征,然后使用我们的 extract_features 函数提取这些文件的特征 ➌。接下来,将良性和恶性特征及其关联标签(0 和 1)连接起来 ➎ 并打乱 ➏。最后,返回这些特征和标签 ➐。

一旦实例化,生成器应该每次调用生成器的 next() 方法时,生成 batch_size 个特征和标签供模型训练(50% 恶性,50% 良性)。

Listing 11-5 展示了如何使用本书随附的数据创建训练数据生成器,以及如何通过将生成器传递给模型的 fit_generator 方法来训练模型。

   import os

   batch_size = 128
   features_length = 1024
   path_to_training_benign_files = 'data/html/benign_files/training/'
   path_to_training_malicious_files = 'data/html/malicious_files/training/'
   steps_per_epoch = 1000 # artificially small for example-code speed!

➊ train_benign_files = os.listdir(path_to_training_benign_files)
➋ train_malicious_files = os.listdir(path_to_training_malicious_files)

   # make our training data generator!
➌ training_generator = my_generator(
       benign_files=train_benign_files,
       malicious_files=train_malicious_files,
       path_to_benign_files=path_to_training_benign_files,
       path_to_malicious_files=path_to_training_malicious_files,
       batch_size=batch_size,
       features_length=features_length
   )

➍ model.fit_generator(
    ➎ generator=training_generator,
    ➏ steps_per_epoch=steps_per_epoch,
    ➐ epochs=10
   )

Listing 11-5: 创建训练生成器并使用它来训练模型

尝试阅读这段代码以理解发生了什么。在导入必要的包并创建一些参数变量后,我们将无害数据 ➊ 和恶意数据 ➋ 的文件名读入内存(但不读取文件本身)。我们将这些值传递给新的my_generator函数 ➌ 来获取我们的训练数据生成器。最后,使用来自清单 11-1 的model,我们使用model内置的fit_generator方法 ➍ 开始训练。

fit_generator方法接受三个参数。generator参数 ➎ 指定了生成每个批次训练数据的数据生成器。在训练过程中,参数会通过对每个批次中的所有训练数据的信号进行平均来更新。steps_per_epoch参数 ➏ 设置我们希望模型每个周期处理的批次数量。因此,模型在每个周期看到的观察总数是batch_size*steps_per_epoch。按照惯例,模型每个周期看到的观察数量应等于数据集的大小,但在本章及虚拟机示例代码中,我减少了steps_per_epoch以加快代码的运行速度。epochs参数 ➐ 设置我们希望运行的周期数。

尝试在本书随附的ch11/目录中运行此代码。根据你计算机的性能,每个训练周期需要一定的时间来运行。如果你使用的是交互式会话,运行几轮后如果训练耗时较长,可以随时取消进程(CTRL-C)。这会停止训练而不丢失进度。取消进程后(或代码完成),你将拥有一个训练好的模型!你虚拟机屏幕上的输出应该类似于图 11-2。

image

图 11-2:训练一个 Keras 模型的控制台输出

顶部的几行表明,作为Keras默认后端的 TensorFlow 已加载。你还会看到一些警告,像在图 11-2 中那样;这些警告只是意味着训练将使用 CPU 而非 GPU(通常 GPU 在训练神经网络时比 CPU 快 2 到 20 倍,但对于本书的目的,基于 CPU 的训练是可以的)。最后,你会看到每个训练周期的进度条,显示当前周期剩余的时间,以及该周期的损失和准确率指标。

结合验证数据

在上一节中,你学会了如何使用可扩展的fit_generator方法在 HTML 文件上训练一个Keras模型。正如你所看到的,模型在训练过程中会打印出每个周期当前的损失和准确率统计数据。然而,你真正关心的是训练好的模型在验证数据上的表现,或者说它从未见过的数据。这更能代表模型在真实生产环境中将面临的数据。

在尝试设计更好的模型并确定训练时长时,应该尽量最大化 验证准确度,而不是 训练准确度,后者在 图 11-2 中展示过。更理想的是使用来自训练数据之后日期的验证文件,以更好地模拟生产环境。

列表 11-6 展示了如何使用 列表 11-4 中的 my_generator 函数将我们的验证特征加载到内存中。

   import os
   path_to_validation_benign_files = 'data/html/benign_files/validation/'
   path_to_validation_malicious_files = 'data/html/malicious_files/validation/'
   # get the validation keys:
   val_benign_file_keys = os.listdir(path_to_validation_benign_files)
   val_malicious_file_keys = os.listdir(path_to_validation_malicious_files)
   # grab the validation data and extract the features:
➊ validation_data = my_generator(
       benign_files=val_benign_files,
       malicious_files=val_malicious_files,
       path_to_benign_files=path_to_validation_benign_files,
       path_to_malicious_files=path_to_validation_malicious_files,
     ➋ batch_size=10000,
       features_length=features_length
➌ ).next()

列表 11-6:通过使用 my_generator 函数将验证特征和标签读取到内存中

这段代码与我们创建训练数据生成器的方式非常相似,唯一的区别是文件路径发生了变化,现在我们希望将所有验证数据加载到内存中。所以,我们不仅仅是创建生成器,而是创建了一个验证数据生成器 ➊,其 batch_size ➋ 设置为我们想要验证的文件数量,并且我们立即调用其 .next() ➌ 方法,仅调用一次。

现在我们已经将一些验证数据加载到内存中,Keras 允许我们在训练过程中简单地将验证数据传递给 fit_generator(),正如在 列表 11-7 中所示。

model.fit_generator(
  ➊ validation_data=validation_data,
    generator=training_generator,
    steps_per_epoch=steps_per_epoch,
    epochs=10
)

列表 11-7:在训练期间使用验证数据进行自动监控

列表 11-7 与 列表 11-5 的结尾几乎相同,唯一的不同是现在将 validation_data 传递给了 fit_generator ➊。这样有助于通过确保在计算训练损失和准确度的同时也计算验证损失和准确度,来增强模型监控。

现在,训练语句应该类似于 图 11-3 中的内容。

image

图 11-3:使用验证数据训练 Keras 模型的控制台输出

图 11-3 类似于 图 11-2,不同之处在于,现在不仅显示每个 epoch 的训练 lossacc 指标,Keras 还计算并显示每个 epoch 的 val_loss(验证损失)和 val_acc(验证准确度)。一般来说,如果验证准确度下降而不是上升,那说明模型可能在过拟合训练数据,这时最好停止训练。如果验证准确度在上升,像这里的情况一样,就意味着模型仍在不断改进,你应该继续训练。

保存和加载模型

现在你已经知道如何构建和训练神经网络,让我们来看看如何保存它,以便你能与他人共享。

列表 11-8 展示了如何将训练好的模型保存到 .h5 文件 ➊ 中并重新加载 ➋(可能在稍后的时间)。

   from keras.models import load_model
   # save the model
➊ model.save('my_model.h5')
   # load the model back into memory from the file:
➋ same_model = load_model('my_model.h5')

列表 11-8:保存和加载 Keras 模型

评估模型

在模型训练部分,我们观察到一些默认的模型评估指标,如训练损失和准确率,以及验证损失和准确率。接下来,让我们回顾一些更复杂的指标,以便更好地评估我们的模型。

一个用于评估二分类预测器准确度的有用指标叫做曲线下面积(AUC)。这条曲线指的是接收者操作特征(ROC)曲线(见第八章),该曲线将假阳性率(x 轴)与真阳性率(y 轴)对所有可能的分数阈值进行绘制。

例如,我们的模型尝试通过使用 0(良性)到 1(恶性)之间的分数来预测文件是否为恶意。如果我们选择一个相对较高的分数阈值来将文件分类为恶意,那么我们会得到较少的假阳性(好)但也会得到较少的真正阳性(坏)。另一方面,如果我们选择较低的分数阈值,我们可能会有较高的假阳性率(坏)但检测率会非常高(好)。

这两种样本情况将作为两个点表示在我们模型的 ROC 曲线上,第一个点位于曲线的左侧,而第二个点则靠近右侧。AUC 通过简单地计算 ROC 曲线下方的面积来表示所有这些可能性,如图 11-4 所示。

简单来说,AUC 为 0.5 代表了抛硬币的预测能力,而 AUC 为 1 则表示完美。

image

图 11-4:各种样本的 ROC 曲线。每条 ROC 曲线(线)对应一个不同的 AUC 值。

我们可以使用我们的验证数据,通过示例 11-9 中的代码来计算验证集的 AUC。

   from sklearn import metrics

➊ validation_labels = validation_data[1]
➋ validation_scores = [el[0] for el in model.predict(validation_data[0])]
➌ fpr, tpr, thres = metrics.roc_curve(y_true=validation_labels,
                                       y_score=validation_scores)
➍ auc = metrics.auc(fpr, tpr)
   print('Validation AUC = {}'.format(auc))

示例 11-9:使用 sklearn metric 子模块计算验证 AUC

在这里,我们将validation_data元组拆分为两个对象:表示验证标签的validation_labels ➊,以及表示展平的验证模型预测值的validation_scores ➋。然后,我们使用sklearn中的metrics.roc_curve函数来计算假阳性率、真阳性率和与模型预测相关的阈值 ➌。利用这些,我们再次使用sklearn函数计算 AUC 指标 ➍。

虽然我在这里不会详细介绍函数代码,但你也可以使用本书附带数据中的ch11/model_evaluation.py文件中的roc_plot()函数来绘制实际的 ROC 曲线,正如示例 11-10 中所示。

from ch11.model_evaluation import roc_plot
roc_plot(fpr=fpr, tpr=tpr, path_to_file='roc_curve.png')

示例 11-10:使用 roc_plot 函数从本书附带的数据中创建 ROC 曲线图,位于 ch11/model_evaluation.py 中。

运行示例 11-10 中的代码应该会生成一张图(保存在roc_curve.png中),其样式如图 11-5 所示。

image

图 11-5:一条 ROC 曲线!

图 11-5 中的 ROC 曲线上的每个点都代表一个特定的假阳性率(x 轴)和真阳性率(y 轴),这些是与各种模型预测阈值(从 0 到 1)相关的。当假阳性率增加时,真阳性率也会增加,反之亦然。在生产环境中,您通常需要选择一个特定的阈值(即曲线上的某个点,假设验证数据与生产数据相似)来做出决策,依据您愿意容忍的假阳性率和您愿意冒着漏掉恶意文件的风险之间的平衡。

通过回调增强模型训练过程

到目前为止,您已经学习了如何设计、训练、保存、加载和评估Keras模型。尽管这已经足够让您有一个不错的起步,但我还想介绍一下Keras回调,它们可以让我们的模型训练过程变得更好。

Keras回调代表一组在训练过程中的某些阶段由Keras应用的函数。例如,您可以使用Keras回调来确保在每个训练周期结束时保存一个.h5文件,或者在每个训练周期结束时将验证 AUC 打印到屏幕上。这有助于记录并更精确地告知您模型在训练过程中的表现。

我们首先使用一个内置回调,然后再尝试编写自定义回调。

使用内置回调

要使用内置回调,只需在训练期间将回调实例传递给模型的fit_generator()方法即可。我们将使用callbacks.ModelCheckpoint回调,它会在每个训练周期后评估验证损失,并在当前模型的验证损失小于之前任何一个周期的验证损失时保存模型。为了实现这一点,回调需要访问我们的验证数据,因此我们将在fit_generator()方法中传入这些数据,如清单 11-11 所示。

from keras import callbacks

model.fit_generator(
    generator=training_generator,
    # lowering steps_per_epoch so the example code runs fast:
    steps_per_epoch=50,
    epochs=5,
    validation_data=validation_data,
    callbacks=[
        callbacks.ModelCheckpoint(save_best_only=True,➊
                                  ➋ filepath='results/best_model.h5',
                                  ➌ monitor='val_loss')
   ],
)

清单 11-11:向训练过程中添加一个 ModelCheckpoint 回调

这段代码确保每当'val_loss'(验证损失)达到新低时,模型会被覆盖到一个单独的文件'results/best_model.h5' ➊。这样可以确保当前保存的模型('results/best_model.h5')始终代表所有已完成训练周期中验证损失最小的最佳模型。

或者,我们可以使用清单 11-12 中的代码,在每个周期后将模型保存到一个单独的文件中,而不考虑验证损失。

callbacks.ModelCheckpoint(save_best_only=False,➍
                        ➎ filepath='results/model_epoch_{epoch}.h5',
                          monitor='val_loss')

清单 11-12:向训练过程中添加一个 ModelCheckpoint 回调,在每个周期后将模型保存到不同的文件中

为此,我们使用清单 11-11 中的相同代码和相同的 ModelCheckpoint 函数,但设置 save_best_only=False ➍,并指定一个 filepath,让 Keras 填入周期编号 ➎。与只保存“最佳”版本的模型不同,清单 11-12 中的回调函数会保存每个周期的模型版本,分别存储为 results/model_epoch_0.h5results/model_epoch_1.h5results/model_epoch_2.h5 等。

使用自定义回调函数

尽管 Keras 不直接支持 AUC,但我们可以设计自己的自定义回调函数,例如,让我们在每个周期后将 AUC 打印到屏幕上。

要创建一个自定义的 Keras 回调函数,我们需要创建一个继承自 keras.callbacks.Callback 的类,这是用于构建新回调函数的抽象基类。我们可以添加一个或多个方法,这些方法会在训练期间自动运行,并且会在它们的名称指定的时间运行:on_epoch_beginon_epoch_endon_batch_beginon_batch_endon_train_beginon_train_end

清单 11-13 展示了如何创建一个回调函数,在每个训练周期结束时计算并打印验证 AUC 到屏幕。

   import numpy as np
   from keras import callbacks
   from sklearn import metrics

➊ class MyCallback(callbacks.Callback):

    ➋ def on_epoch_end(self, epoch, logs={}):
        ➌ validation_labels = self.validation_data[1]
           validation_scores = self.model.predict(self.validation_data[0])
           # flatten the scores:
           validation_scores = [el[0] for el in validation_scores]
           fpr, tpr, thres = metrics.roc_curve(y_true=validation_labels,
                                               y_score=validation_scores)
        ➍ auc = metrics.auc(fpr, tpr)
           print('\n\tEpoch {}, Validation AUC = {}'.format(epoch,
                                                            np.round(auc, 6)))
   model.fit_generator(
       generator=training_generator,
       # lowering steps_per_epoch so the example code runs fast:
       steps_per_epoch=50,
       epochs=5,
    ➎ validation_data=validation_data,
    ➏ callbacks=[
           callbacks.ModelCheckpoint('results/model_epoch_{epoch}.h5',
                                     monitor='val_loss',
                                     save_best_only=False,
                                     save_weights_only=False)
       ]
   )

清单 11-13:创建并使用自定义回调函数,在每个训练周期后将 AUC 打印到屏幕

在这个例子中,我们首先创建了我们的 MyCallback 类 ➊,它继承自 callbacks.Callbacks。为了简化,我们只重写了一个方法,on_epoch_end ➋,并给它提供了 Keras 期望的两个参数:epochlogs(日志信息字典),这两个参数会在训练期间由 Keras 调用该函数时传入。

然后,我们获取 validation_data ➌,它已经通过继承自 callbacks.Callback 存储在 self 对象中,并像在“评估模型”一节中提到的那样计算并打印出 AUC ➍,这在第 209 页有讲解。注意,为了使这段代码正常工作,验证数据需要传递给 fit_generator(),这样回调函数才能在训练过程中访问到 self.validation_data ➎。最后,我们告诉模型进行训练,并指定我们的新回调函数 ➏。结果应该类似于图 11-6 所示。

image

图 11-6:使用自定义 AUC 回调训练 Keras 模型时的控制台输出

如果你真正关心的是最小化验证 AUC,这个回调函数能帮助你轻松查看模型在训练过程中的表现,从而帮助你评估是否应该停止训练过程(例如,如果验证准确率持续下降)。

总结

在本章中,你学习了如何使用 Keras 构建自己的神经网络。你还学习了如何训练、评估、保存和加载模型。接着,你学习了如何通过添加内置和自定义回调来提升模型训练过程。我鼓励你尝试修改本书附带的代码,看看改变模型架构和特征提取对模型准确性的影响。

本章旨在让你入门,但并非作为参考指南。请访问 keras.io 获取最新的官方文档。我强烈建议你花时间研究你感兴趣的 Keras 方面。希望本章能为你所有的安全深度学习冒险提供一个良好的起点!

第十二章:成为数据科学家

image

为了总结本书,让我们退后一步,讨论一下你如何在恶意软件数据科学家或一般安全数据科学家的职业生涯中取得成功。尽管这是一个非技术性的章节,但它与本书中的技术性章节同样重要,甚至可能更为重要。这是因为,成为一名成功的安全数据科学家,涉及的远不只是理解主题内容。

在本章节中,我们作为作者,分享了自己成为专业安全数据科学家的职业路径。你将了解到作为一名安全数据科学家的日常生活是什么样子的,以及成为一名有效数据科学家需要具备什么条件。我们还分享了一些如何应对数据科学问题的技巧,以及如何在面对不可避免的挑战时保持韧性。

成为安全数据科学家的路径

由于安全数据科学是一个新兴领域,成为安全数据科学家的道路有很多。许多数据科学家通过研究生院接受正式训练,而许多人则是自学成才。例如,我在 1990 年代的计算机黑客圈子中成长,学会了用 C 语言和汇编语言编程,并编写黑帽黑客工具。后来,我获得了人文学科的学士学位和硕士学位,然后重新进入技术领域,成为一名安全软件开发人员。在此过程中,我自学了数据可视化和机器学习,最终进入了 Sophos,一家安全研发公司,担任正式的安全数据科学家角色。我的合著者 Hillary Sanders,在大学学习了统计学和经济学,曾在一段时间内担任数据科学家,后来在一家安全公司找到工作,成为数据科学家,并在工作中获得了安全知识。

我们 Sophos 团队同样具有多样性。我们的同事们在心理学、数据科学、数学、生物化学、统计学和计算机科学等众多领域拥有不同的学位。尽管安全数据科学偏向于那些在科学定量方法方面接受过正式训练的人,但它也吸引了来自这些领域的具有不同背景的人。尽管科学和定量训练对学习安全数据科学有帮助,但根据我的经验,只要你愿意自学,凭借非传统背景进入并在这一领域取得卓越成就也是完全可能的。

在安全数据科学领域取得成功,关键在于一个人是否愿意不断学习新知识。这是因为,在我们的领域,实践知识与理论知识同等重要,而实践知识是通过来获得的,而不是通过学术作业。

愿意学习新知识也很重要,因为机器学习、网络分析和数据可视化在不断变化,所以你在学校学到的知识很快就会过时。例如,深度学习仅在 2012 年左右成为一个趋势,并且自那时以来迅速发展,因此几乎所有在此之前毕业的数据科学家都不得不自学这些强大的理念。这对那些寻求专业进入安全数据科学领域的人来说是个好消息。由于那些已经在该领域的人必须不断自学新技能,因此你可以通过已经掌握这些技能来打开大门。

安全数据科学家的一天

安全数据科学家的工作是将本书中教授的技能应用于棘手的安全问题。但这些技能的应用往往是嵌入在一个更大的工作流程中的,这个流程还涉及其他技能。图 12-1 展示了我们以及其他公司和组织同事的经验基础上,安全数据科学家的典型工作流程。

image

图 12-1:安全数据科学工作流程模型

如图 12-1 所示,安全数据科学工作流程涉及五个工作领域之间的相互作用。第一个领域,问题识别,涉及识别数据科学可以帮助解决的安全问题。例如,我们可以假设通过数据科学方法来识别网络钓鱼邮件,或者识别用于掩盖已知恶意软件的特定方法是一个值得研究的问题。

在这个阶段,任何假设某个问题可以通过数据科学解决的观点都只是一个假设。当你手里有一把锤子(数据科学)时,每个问题看起来都像一根钉子(机器学习、数据可视化或网络分析问题)。我们必须反思这些问题是否真的是通过数据科学方法来解决的最佳方式,并牢记这将需要构建一个原型数据科学解决方案,然后测试这个解决方案,以便更好地理解数据科学是否真的提供了最佳的解决方案。

当你在一个组织中工作时,识别一个好的问题几乎总是涉及与那些不是数据科学家的利益相关者互动。例如,在我们的公司中,我们经常与产品经理、高层管理人员、软件开发人员和销售人员互动,他们认为数据科学就像一根魔杖,可以解决任何问题,或者认为数据科学类似于“人工智能”,因此具有某种神奇的能力,可以实现不切实际的结果。

处理这类利益相关者时要记住的关键是,要诚实地说明基于数据科学的方法的能力和局限性,并保持一种精明、谨慎的态度,以免追逐错误的问题。你应该放弃那些没有数据支持数据科学算法,或者没有方法评估你的数据科学方法是否有效的问题,此外,还有那些明显可以通过更手动的方法解决的问题。

例如,以下是我们在别人要求我们解决后拒绝的几个问题:

  • 自动识别可能向竞争对手泄露数据的内部员工。 目前没有足够的数据来驱动机器学习算法,但可以通过数据可视化或网络分析来尝试。

  • 解密网络流量。 机器学习的数学原理表明,机器学习根本无法解密武器级加密数据!

  • 自动识别根据对员工生活方式的详细背景知识手工制作的钓鱼邮件。 同样,没有足够的数据来驱动机器学习算法,但这可能通过时间序列或电子邮件数据的可视化来实现。

一旦你成功识别出一个潜在的安全数据科学问题,接下来的任务就是确定可以帮助你解决问题的数据流,并运用本书中解释的数据科学技术来实现。这在图 12-1 的第 2 步中有所展示。最终,如果你没有可以用来训练机器学习模型、生成可视化内容或推动网络分析以解决你选择的安全问题的数据流,那么数据科学可能不会对你有帮助。

在你选择了一个问题并确定了能够让你构建基于数据科学的解决方案的数据流后,是时候开始构建你的解决方案了。实际上,这个过程发生在图 12-1 的第 3 步和第 4 步之间的迭代循环中:你构建某些东西,评估它,改进它,重新评估,依此类推。

最后,一旦你的系统准备好,你就可以部署它,正如图 12-1 中的第 5 步所示。只要你的系统保持部署状态,你需要回去并整合新的数据流,尝试新的数据科学方法,并重新部署系统的新版本。

有效的安全数据科学家特征

成功的安全数据科学在很大程度上取决于你的态度。在本节中,我们列出了我们认为对安全数据科学工作成功至关重要的一些心理特质。

开放心态

数据充满了惊喜,这颠覆了我们对问题的先入之见。保持心态开放,接受数据可能证明我们原有的观点是错误的,这一点非常重要。如果你做不到这一点,你会错过从数据中获得的重要洞察,甚至可能会把随机噪声过度解读,试图说服自己某个错误的理论。幸运的是,做得越多安全数据科学,你就越能保持开放心态,从数据中“学习”,也会更能接受自己知道的少、每个新问题都需要学习的多。随着时间的推移,你会开始享受并期待数据带来的惊喜。

无限好奇

数据科学项目与软件工程和 IT 项目非常不同,因为它们需要探索数据以寻找模式、异常值和趋势,然后利用这些信息构建我们的系统。识别这些动态并不容易:这通常需要运行数百次实验或分析,才能对数据的整体形态和其中隐藏的故事有所了解。有些人天生具有设计巧妙实验并深入挖掘数据的动力,几乎是上瘾的,而另一些人则没有。前者是更容易在数据科学中取得成功的人。因此,好奇心是这个领域的必要条件,因为它决定了我们是否能够深入理解数据,而不是仅仅停留在表面。你在构建模型和数据可视化时越能培养好奇心,你的系统就会变得越有用。

对结果的执着

一旦你定义了一个好的安全数据科学问题,并开始反复尝试解决方案并评估它们,特别是在机器学习项目中,结果的执着可能会完全占据你。这是一个好兆头。例如,当我深度参与一个机器学习项目时,我会有多个实验 24 小时、每周 7 天持续运行。这意味着我可能会在夜里多次醒来检查实验的状态,并且常常需要在凌晨 3 点修复错误并重新启动实验。我倾向于在每晚睡前检查实验状态,并且在周末也会多次查看。

这种全天候的工作流程常常是构建一流安全数据科学系统所必需的。如果没有它,容易满足于平庸的结果,无法突破困境或克服因错误假设而形成的障碍。

对结果的怀疑

很容易让自己误以为在一个安全数据科学项目上取得了成功。例如,也许你错误地设置了评估标准,以至于系统的准确率看起来比实际情况要好。对与你的训练数据过于相似或与现实世界数据过于不同的数据进行评估是一个常见的陷阱。你也可能无意中从你的网络可视化中挑选出你认为有用但大多数用户认为没什么价值的例子。或者也许你在方法上花费了大量精力,以至于你让自己相信评估统计数据是好的,而实际上它们还不足以让你的系统在现实中有用。保持对结果的健康怀疑态度非常重要,否则你有可能在某一天发现自己陷入尴尬的境地。

下一步该做什么

本书中我们已经涵盖了很多内容,但我们也只是刚刚触及表面。如果本书已经让你决定认真从事安全数据科学,我们有两个建议:首先,立即将你在本书中学到的工具应用到你关心的问题中。其次,阅读更多关于数据科学和安全数据科学的书籍。以下是一些你可能考虑将你新学的技能应用到的实际问题:

  • 检测恶意域名

  • 检测恶意 URL

  • 检测恶意电子邮件附件

  • 可视化网络流量以发现异常

  • 可视化电子邮件发送者/接收者模式以检测钓鱼邮件

为了扩展你对数据科学方法的知识,我们建议从简单的开始,阅读你想了解更多的关于数据科学算法的 Wikipedia 文章。对于数据科学来说,Wikipedia 是一个出奇容易访问且权威的资源,而且它是免费的。对于那些想深入了解的人,特别是在机器学习方面,我们建议阅读关于线性代数、概率论、统计学、图分析和多变量微积分的书籍,或者参加免费的在线课程。学习这些基础知识将为你未来的数据科学职业生涯带来回报,因为它们是我们领域的基础。除了专注于这些基础知识外,我们还建议学习关于 Python、numpysklearnmatplotlibseabornKeras 以及本书中涉及的其他在数据科学社区中广泛使用的工具的课程或阅读更多“应用”的书籍。

第十三章:数据集和工具概述

image

本书的所有数据和代码都可以在 www.malwaredatascience.com/ 下载。请注意:数据中包含 Windows 恶意软件。如果你在安装了杀毒引擎的机器上解压数据,许多恶意软件示例可能会被删除或隔离。

注意

我们已修改了每个恶意软件可执行文件中的几个字节,以禁用其执行。话虽如此,你仍然需要小心存储位置。我们建议将其存储在与家庭或企业网络隔离的非 Windows 机器上。

理想情况下,你应该只在一个隔离的虚拟机中实验代码和数据。为了方便起见,我们提供了一个预加载数据和代码的 VirtualBox Ubuntu 实例,地址是 www.malwaredatascience.com/,并且安装了所有必需的开源库。

数据集概述

现在让我们来逐步了解本书每一章附带的数据集。

第一章:基本的静态恶意软件分析

回想一下,在第一章中,我们演示了一个名为 ircbot.exe 的恶意软件二进制文件的基本静态分析。这个恶意软件是一个植入程序,意味着它会隐藏在用户的系统上,等待攻击者的命令,从而允许攻击者收集受害者计算机的私人数据或达到恶意目的,比如擦除受害者的硬盘。这个二进制文件可以在本书附带的数据中找到,路径为 ch1/ircbot.exe

本章中,我们还使用了一个名为 fakepdfmalware.exe 的示例(位于 ch1/fakepdfmalware.exe)。这是一个恶意程序,具有 Adobe Acrobat/PDF 桌面图标,旨在欺骗用户以为他们正在打开 PDF 文档,而实际上是在运行恶意程序并感染他们的系统。

第二章:超越基本静态分析:x86 反汇编

在这一章中,我们探讨了恶意软件逆向工程的一个更深层次的主题:分析 x86 反汇编。在本章中,我们重新使用了第一章中的 ircbot.exe 示例。

第三章:动态分析简要介绍

在我们讨论第三章中的动态恶意软件分析时,我们使用了存储在路径 ch3/d676d9dfab6a4242258362b8ff579cfe6e5e6db3f0cdd3e0069ace50f80af1c5 下的勒索病毒示例,这些数据附带在本书中。文件名对应文件的 SHA256 加密哈希。这个勒索病毒并没有什么特别之处,我们通过在 VirusTotal.com 的恶意软件数据库中搜索勒索病毒示例获取了它。

第四章:使用恶意软件网络识别攻击活动

第四章 介绍了网络分析和可视化在恶意软件中的应用。为了展示这些技术,我们使用了一组用于高调攻击的高质量恶意软件样本,并将分析重点放在由中国军事团体生产的恶意软件样本上,该团体在安全界被称为 高级持续性威胁 1(简称 APT1)。

这些样本和生成它们的 APT1 组由网络安全公司 Mandiant 发现并公开。在其报告中(摘录如下)标题为“APT1:揭露中国的网络间谍单位” (www.fireeye.com/content/dam/fireeye-www/services/pdfs/mandiant-apt1-report.pdf),Mandiant 发现了以下内容:

  • 自 2006 年以来,Mandiant 观察到 APT1 入侵了 141 家跨越 20 个主要行业的公司。

  • APT1 拥有明确的攻击方法论,这一方法论经过多年的打磨,旨在窃取大量有价值的知识产权。

  • 一旦 APT1 建立了访问权限,他们会定期在几个月或几年的时间内重新访问受害者的网络,窃取广泛类别的知识产权,包括技术蓝图、专有制造工艺、测试结果、商业计划、定价文件、合作协议,以及受害组织领导层的电子邮件和联系人列表。

  • APT1 使用了一些我们尚未观察到其他组织使用的工具和技术,其中包括两个用于窃取电子邮件的工具:GETMAIL 和 MAPIGET。

  • APT1 平均在受害者网络中保持访问权限的时间为 356 天。

  • APT1 保持对某一受害者网络访问的最长时间为 1,764 天,即四年十个月。

  • 在其他大规模知识产权盗窃事件中,我们观察到 APT1 在十个月的时间里,从一个单一的组织窃取了 6.5TB 的压缩数据。

  • 在 2011 年的第一个月,APT1 成功地入侵了至少 17 个新的受害者,这些受害者来自 10 个不同的行业。

正如本报告的摘录所示,APT1 样本被用于高风险的国家级间谍活动。这些样本可以在本书随附的数据中找到,位置为 ch4/data/APT1_MALWARE_FAMILIES

第五章:共享代码分析

第五章 重用了在第四章中使用的 APT1 样本。为了方便起见,这些样本也位于 第五章 目录下,位于 ch5/data/APT1_MALWARE_FAMILIES

第六章:理解基于机器学习的恶意软件检测器,第七章:评估恶意软件检测系统

这些概念章节不需要任何样本数据。

第八章:构建机器学习检测器

第八章探讨了如何构建基于机器学习的恶意软件检测器,并使用了 1,419 个示例二进制文件作为训练你自己的机器学习检测系统的样本数据集。这些二进制文件位于ch8/data/benignware文件夹中,良性样本存储在那里,恶意软件样本存储在ch8/data/malware文件夹中。

数据集包含 991 个良性文件样本和 428 个恶意软件样本,我们从VirusTotal.com获取了这些数据。这些样本具有代表性,在恶意软件的情况下,代表了 2017 年互联网上观察到的恶意软件类型,在良性文件的情况下,代表了用户在 2017 年上传到VirusTotal.com的二进制文件类型。

第九章:恶意软件趋势的可视化

第九章探讨了数据可视化,并使用了文件ch9/code/malware_data.csv中的示例数据。该文件中共有 37,511 行数据,每一行都记录了一个恶意软件文件的相关信息,包括首次出现时间、多少款杀毒软件检测到了它,以及它属于什么类型的恶意软件(例如,特洛伊木马、勒索软件等)。这些数据来自VirusTotal.com

第十章:深度学习基础

本章介绍了深度神经网络,并未使用任何样本数据。

第十一章:使用 Keras 构建神经网络恶意软件检测器

本章讲解了如何构建一个神经网络恶意软件检测器,用于检测恶意和良性 HTML 文件。良性 HTML 文件来自合法的网页,恶意网页来自那些通过浏览器尝试感染受害者的网站。我们通过付费订阅从VirusTotal.com获取了这两个数据集,订阅使我们能够访问数百万个恶意和良性 HTML 页面的样本。

所有数据都存储在根目录ch11/data/html中。良性文件存储在ch11/data/html/benign_files中,恶意软件文件存储在ch11/data/html/malicious_files中。此外,在这些目录中还有子目录trainingvalidationtraining目录包含我们在本章中用来训练神经网络的文件,validation目录包含我们用来测试神经网络准确性的文件。

第十二章:成为数据科学家

第十二章讨论了如何成为数据科学家,并未使用任何样本数据。

工具实现指南

尽管本书中的所有代码都是示例代码,旨在展示书中的思想,而不是完全复制并应用于现实世界,但我们提供的一些代码可以作为你自己恶意软件分析工作中的工具,尤其是如果你愿意为自己的目的扩展它的话。

注意

这些工具旨在作为完整恶意软件数据科学工具的示例和起点,并未经过强健的实现。它们已在 Ubuntu 17 上进行测试,预计在该平台上能正常工作,但通过一些安装正确依赖项的工作,您应该能相对轻松地让这些工具在其他平台(如 macOS 和其他 Linux 发行版)上运行。

本节将按照它们出现的顺序,逐步介绍本书中提供的初步工具。

共享主机名网络可视化

在第四章中提供了一个共享主机名网络可视化工具,位于ch4/code/listing-4-8.py。该工具从目标恶意软件文件中提取主机名,然后根据它们共享的主机名显示文件之间的连接。

该工具接受恶意软件目录作为输入,然后输出三个 GraphViz 文件,您可以用它们进行可视化。要安装此工具的依赖项,请在ch4/code目录中运行命令run bash install_requirements.sh。清单 A-1 展示了该工具的“帮助”输出,之后我们将讨论这些参数的含义。

usage: Visualize shared hostnames between a directory of malware samples
       [-h] target_path output_file malware_projection hostname_projection

positional arguments:
➊ target_path          directory with malware samples
➋ output_file          file to write DOT file to
➌ malware_projection   file to write DOT file to
➍ hostname_projection  file to write DOT file to

optional arguments:
  -h, --help           show this help message and exit

清单 A-1:来自第四章的共享主机名网络可视化工具的帮助输出

如清单 A-1 所示,共享主机名可视化工具需要四个命令行参数:target_path ➊、output_file ➋、malware_projection ➌和hostname_projection ➍。参数target_path是您希望分析的恶意软件样本的目录路径。output_file参数是程序将写入表示将恶意软件样本与其包含的主机名连接的网络的 GraphViz .dot 文件的文件路径。

malware_projectionhostname_projection参数也是文件路径,并指定程序将写入表示这些派生网络的.dot文件的位置(有关网络投影的更多信息,请参见第四章)。运行程序后,您可以使用在第四章和第五章中讨论的 GraphViz 工具集来可视化这些网络。例如,您可以使用命令fdp malware_projection.dot -Tpng -o malware_``projection``.png,生成类似图 A-1 中的.png文件,用于您自己的恶意软件数据集。

image

图 A-1:来自第四章的共享主机名可视化工具的示例输出

共享图像网络可视化

我们在第四章中介绍了一个共享图像网络可视化工具,位于ch4/code/listing-4-12.py。该程序显示基于恶意软件样本共享的嵌入图像之间的网络关系。

该工具将恶意软件目录作为输入,然后输出三个 GraphViz 文件,您可以使用这些文件进行可视化。要安装此工具的依赖项,请在ch4/code目录下运行命令run bash install_requirements.sh。让我们来讨论一下工具“帮助”输出中的参数(参见清单 A-2)。

usage: Visualize shared image relationships between a directory of malware samples
       [-h] target_path output_file malware_projection resource_projection

positional arguments:
➊ target_path          directory with malware samples
➋ output_file          file to write DOT file to
➌ malware_projection   file to write DOT file to
➍ resource_projection  file to write DOT file to

optional arguments:
  -h, --help           show this help message and exit

清单 A-2:来自共享资源网络可视化工具的帮助输出,见第四章

如清单 A-2 所示,共享图像关系可视化工具需要四个命令行参数:target_path ➊,output_file ➋,malware_projection ➌,和resource_projection ➍。与共享主机名程序类似,这里target_path是您希望分析的恶意软件样本目录的路径,output_file是程序将写入 GraphViz .dot文件的文件路径,该文件表示连接恶意软件样本与其包含的图像之间的二分图(二分图在第四章中讨论)。malware_projectionresource_projection参数也是文件路径,指定程序将写入表示这些网络的.dot文件的位置。

与共享主机名程序一样,运行程序后,您可以使用 GraphViz 工具套件来可视化这些网络。例如,您可以在自己的恶意软件数据集上使用命令fdp resource_projection.dot -Tpng -o resource_``projection``.png来生成一个文件,就像在第 4-12 图中渲染的.png文件一样,见第 55 页。

恶意软件相似性可视化

在第五章中,我们讨论了恶意软件相似性、共享代码分析和可视化。我们提供的第一个示例工具位于ch5/code/listing_5_1.py。该工具将包含恶意软件的目录作为输入,并可视化该目录中恶意软件样本之间的共享代码关系。要安装此工具的依赖项,请在ch5/code目录下运行命令run bash install_requirements.sh。清单 A-3 显示了该工具的帮助输出。

usage: listing_5_1.py [-h] [--jaccard_index_threshold THRESHOLD]
                      target_directory output_dot_file

Identify similarities between malware samples and build similarity graph

positional arguments:
➊ target_directory      Directory containing malware
➋ output_dot_file       Where to save the output graph DOT file

optional arguments:
  -h, --help            show this help message and exit
➌ --jaccard_index_threshold THRESHOLD, -j THRESHOLD
                        Threshold above which to create an 'edge' between
                        samples

清单 A-3:来自恶意软件相似性可视化工具的帮助输出,见第五章

当您从命令行运行此共享代码分析工具时,您需要传入两个命令行参数:target_directory ➊和output_dot_file ➋。您可以使用可选参数jaccard_index_threshold ➌来设置程序使用的 Jaccard 指数相似性阈值,用于判断两个样本之间是否创建边。Jaccard 指数在第五章中有详细讨论。

图 A-2 显示了在使用命令fdp output_dot_file.dot -Tpng -o similarity_network.png生成output_dot_file后,该工具的示例输出。这是该工具为我们刚刚描述的 APT1 恶意软件样本推断的共享代码网络。

image

图 A-2:在第五章中给出的恶意软件相似性分析工具的示例输出

恶意软件相似性搜索系统

我们在第五章中提供的第二个代码共享估算工具位于ch5/code/listing_5_2.py。该工具允许您将成千上万的样本索引到数据库中,然后使用查询的恶意软件样本对它们进行相似性搜索,从而找到可能与该样本共享代码的恶意软件样本。要安装该工具的依赖项,请在ch5/code目录中运行命令run bash install_requirements.sh。列表 A-4 展示了该工具的帮助输出。

usage: listing_5_2.py [-h] [-l LOAD] [-s SEARCH] [-c COMMENT] [-w]

Simple code-sharing search system which allows you to build up a database of
malware samples (indexed by file paths) and then search for similar samples
given some new sample

optional arguments:
  -h, --help            show this help message and exit
➊ -l LOAD, --load LOAD Path to directory containing malware, or individual
                        malware file, to store in database
➋ -s SEARCH, --search SEARCH
                        Individual malware file to perform similarity search
                        on
➌ -c COMMENT, --comment COMMENT
                        Comment on a malware sample path
➍ -w, --wipe           Wipe sample database

列表 A-4:在第五章中给出的恶意软件相似性搜索系统的帮助输出

该工具有四种运行模式。第一种模式,LOAD ➊,将恶意软件加载到相似性搜索数据库中,并将路径作为其参数,该路径应指向一个包含恶意软件的目录。您可以多次运行LOAD,每次都向数据库添加新的恶意软件。

第二种模式,SEARCH ➋,将单个恶意软件文件的路径作为参数,然后在数据库中搜索相似的样本。第三种模式,COMMENT ➌,将恶意软件样本路径作为参数,并提示您输入关于该样本的简短文本评论。使用COMMENT功能的好处是,当您搜索与查询的恶意软件样本相似的样本时,您可以看到与相似样本对应的评论,从而丰富您对查询样本的了解。

第四种模式,wipe ➍,删除相似性搜索数据库中的所有数据,以防您想重新开始并索引不同的恶意软件数据集。列表 A-5 展示了一个SEARCH查询的示例输出,帮助您了解该工具的输出格式。在这里,我们使用LOAD命令索引了之前描述的 APT1 样本,随后在数据库中搜索与其中一个 APT1 样本相似的样本。

Showing samples similar to WEBC2-GREENCAT_sample_E54CE5F0112C9FDFE86DB17E85A5E2C5
Sample name                                                      Shared code
[*] WEBC2-GREENCAT_sample_55FB1409170C91740359D1D96364F17B       0.9921875
[*] GREENCAT_sample_55FB1409170C91740359D1D96364F17B             0.9921875
[*] WEBC2-GREENCAT_sample_E83F60FB0E0396EA309FAF0AED64E53F       0.984375
    [comment] This sample was determined to definitely have come from the advanced persistent
              threat group observed last July on our West Coast network
[*] GREENCAT_sample_E83F60FB0E0396EA309FAF0AED64E53F             0.984375

列表 A-5:在第五章中给出的恶意软件相似性搜索系统的示例输出

机器学习恶意软件检测系统

你在自己的恶意软件分析工作中可以使用的最终工具是第八章 中使用的机器学习恶意软件检测器,位于 ch8/code/complete_detector.py。该工具允许你在恶意软件和良性软件上训练恶意软件检测系统,然后使用该系统来检测新样本是恶意还是良性。你可以通过在 ch8/code 目录下运行 bash install.sh 命令来安装该工具的依赖项。列表 A-6 显示了此工具的帮助输出。

usage: Machine learning malware detection system [-h]
                                         [--malware_paths MALWARE_PATHS]
                                         [--benignware_paths BENIGNWARE_PATHS]
                                         [--scan_file_path SCAN_FILE_PATH]
                                         [--evaluate]

optional arguments:
  -h, --help            show this help message and exit
➊ --malware_paths MALWARE_PATHS
                        Path to malware training files
➋ --benignware_paths BENIGNWARE_PATHS
                        Path to benignware training files
➌ --scan_file_path SCAN_FILE_PATH
                        File to scan
➍ --evaluate           Perform cross-validation

列表 A-6:第八章 中提供的机器学习恶意软件检测工具的帮助输出

此工具有三种模式可供运行。evaluate 模式 ➍ 测试你选择的用于训练和评估系统的数据上的系统准确性。你可以通过运行 python complete_detector.py –malware_paths <包含恶意软件的目录路径> --benignware_paths <包含良性软件的目录路径> --evaluate 来启动此模式。此命令将调用一个 matplotlib 窗口,显示检测器的 ROC 曲线(ROC 曲线在 第七章 中有讨论)。图 A-3 显示了 evaluate 模式的示例输出。

image

*图 A-3:第八章 中提供的恶意软件检测工具在 evaluate 模式下的示例输出

训练模式用于训练恶意软件检测模型并将其保存到磁盘。你可以通过运行 python complete_detector.py –malware_paths<包含恶意软件的目录路径> --benignware_paths<包含良性软件的目录路径> 来启动此模式。请注意,这个命令调用与 evaluate 模式的调用唯一不同之处在于我们没有添加 --evaluate 标志。此命令的结果是它生成一个模型,并将其保存到名为 saved_detector.pkl 的文件中,该文件保存在当前工作目录。

第三种模式,scan ➌,加载 saved_detector.pkl,然后扫描目标文件,预测它是否为恶意文件。请确保在运行扫描之前已运行过训练模式。你可以在训练系统的目录中运行 python complete_detector.py –scan_file_path <PE EXE 文件> 来进行扫描。输出将是目标文件是否恶意的概率。

posted @ 2025-11-28 09:39  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报