来源:Malware Data Science Attack Detection and Attribution
要彻底理解恶意程序,我们通常需要超越对其节、字符串、导入和图像的基本静态分析。这涉及到对程序的汇编代码进行逆向工程。事实上,反汇编和逆向工程是恶意软件样本深度静态分析的核心。
由于逆向工程是一门艺术、技术工艺和科学,所以彻底的探索超出了本章的范围。我在这里的目标是向您介绍逆向工程,以便您可以将其应用于恶意软件数据科学。理解这种方法对于成功地将机器学习和数据分析应用于恶意软件是至关重要的。
在本章中,我将从理解x86反汇编所需的概念开始。在本章的后面,我将展示恶意软件作者如何试图绕过反汇编,并讨论如何减轻这些反分析和反检测操作。但首先,让我们回顾一些常见的反汇编方法以及x86汇编语言的基础知识。
一、反汇编方法
反汇编是将恶意软件的二进制代码转换成有效的x86汇编语言的过程。恶意软件作者通常用高级语言(如C或c++)编写恶意软件程序,然后使用编译器将源代码编译成x86二进制代码。汇编语言是这种二进制代码的可读表示形式。因此,将恶意软件程序分解成汇编语言是了解其核心行为的必要条件。
不幸的是,反汇编不是一件容易的事情,因为恶意软件作者经常使用一些技巧来阻止潜在的逆向工程师。事实上,在故意混淆的情况下,完全反汇编是计算机科学中一个尚未解决的问题。目前,仅存在近似的、容易出错的方法来反汇编这些程序。
例如,考虑自修改代码的情况,或者在执行时修改自身的二进制代码。正确分解这段代码的唯一方法是理解代码修改自身的程序逻辑,但这可能非常复杂。
由于完全反汇编目前是不可能的,我们必须用不完善的方法来完成这项任务。我们将使用的方法是线性反汇编,这涉及到在可移植可执行(PE)文件中识别与x86程序代码相对应的连续字节序列,然后解码这些字节。这种方法的主要限制是,它忽略了CPU在程序执行过程中如何解码指令的细微差别。此外,它也没有解释恶意软件作者有时使用的各种混淆,使他们的程序更难分析。
逆向工程的其他方法,我们在这里不讨论,是工业级反汇编器(如IDA Pro)使用的更复杂的反汇编方法。这些更高级的方法实际上模拟或推理程序执行,以发现程序可能通过一系列条件分支达到哪些汇编指令。
尽管这种类型的反汇编比线性反汇编更精确,但它比线性反汇编方法占用的CPU资源要多得多,这使得它不太适合数据科学的目的,因为数据科学的重点是反汇编数千甚至数百万个程序。
然而,在开始使用线性反汇编进行分析之前,您需要回顾汇编语言的基本组件。
二、x86汇编语言基础
汇编语言是针对给定体系结构的人类可读的最低级编程语言,它紧密地映射到特定CPU体系结构的二进制指令格式。汇编语言的一行几乎总是等同于一条CPU指令。因为程序集的级别很低,所以您常常可以通过使用正确的工具轻松地从恶意软件二进制文件中检索它。
获得基本的熟练阅读反汇编的恶意软件x86代码比你想象的要容易。这是因为大多数恶意软件汇编代码花费了大部分时间通过Windows操作系统的动态链接库(dll)调用操作系统,dll在运行时加载到程序内存中。恶意软件程序使用dll来完成大部分实际工作,比如修改系统注册表、移动和复制文件、建立网络连接以及通过网络协议进行通信等等。因此,跟踪恶意程序汇编代码通常需要了解函数调用从汇编中生成的方式,以及了解各种DLL调用的功能。当然,事情可能会变得复杂得多,但了解这么多可以揭示很多关于恶意软件的信息。
在接下来的几节中,我将介绍一些重要的汇编语言概念。我还解释了一些抽象的概念,如控制流和控制流图。最后,我们对ircbot.exe程序进行了分解,并探讨了它的组装和控制流程如何使我们了解它的用途。
x86汇编有两种主要的语法:Intel和AT&T。在本书中,我使用的是Intel语法,它可以从所有主要的反汇编器中获得,并且是x86 CPU的官方Intel文档中使用的语法。
让我们从查看CPU寄存器开始。
2.1CPU寄存器
寄存器是x86 cpu执行计算的小型数据存储单元。由于寄存器位于CPU本身,寄存器访问比内存访问快几个数量级。这就是为什么核心计算操作,如算术和条件测试指令,都是目标寄存器。这也是为什么CPU使用寄存器来存储有关运行程序状态的信息。虽然许多寄存器对于经验丰富的x86汇编程序员是可用的,但是我们在这里只关注几个重要的寄存器。
1、通用寄存器
通用寄存器对于程序集程序员来说就像一个空白空间。在32位系统上,每个寄存器包含32位、16位或8位空间,我们可以对这些空间执行算术操作、位操作、字节顺序交换操作等等。
在常见的计算工作流中,程序将数据从内存或外部硬件设备转移到寄存器中,对这些数据执行一些操作,然后将数据移回内存中进行存储。例如,要对长列表进行排序,程序通常从内存中的数组中提取列表项,在寄存器中比较它们,然后将比较结果写回内存。
要了解Intel 32位体系结构中通用寄存器模型的一些细微差别,请看图2-1。
纵轴显示通用寄存器的布局,横轴显示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的直接引用。
2、堆栈和控制流寄存器
堆栈管理寄存器存储有关程序堆栈的关键信息,程序堆栈负责存储函数的局部变量、传递给函数的参数以及与程序控制流相关的控制信息。我们来复习一下这些寄存器。
简单地说,ESP寄存器指向当前执行函数堆栈的顶部,而EBP寄存器指向当前执行函数堆栈的底部。这对于现代程序来说是至关重要的信息,因为这意味着通过引用相对于堆栈的数据而不是使用它的绝对地址,过程性和面向对象的代码可以更优雅、更有效地访问本地变量。
尽管您不会在x86汇编代码中看到对EIP寄存器的直接引用,但它在安全分析中非常重要,特别是在漏洞研究和缓冲区溢出利用开发的上下文中。这是因为EIP包含当前执行指令的内存地址。攻击者可以使用缓冲区溢出漏洞间接破坏EIP寄存器的值,并控制程序的执行。
除了它在开发中的作用,EIP在分析恶意软件部署的恶意代码方面也很重要。使用调试器,我们可以随时检查EIP的值,这有助于我们了解在任何特定时间执行的代码恶意软件。
EFLAGS是一个包含CPU标志的状态寄存器,CPU标志是存储当前执行程序状态信息的位。EFLAGS寄存器是在x86程序中创建条件分支的过程的核心,或者是由于if/then风格的程序逻辑的结果而导致的执行流的更改。具体来说,当一个x86汇编程序检查一个值是否大于或小于零,然后跳到一个函数基于这个测试的结果,EFLAGS寄存器起到了支持作用,更详细地描述“基本块和控制流图”在19页。
3、算术指令
指令在通用寄存器上操作。您可以使用算术指令使用通用寄存器执行简单的计算。例如,add、sub、inc、dec和mul是在恶意软件逆向工程中经常遇到的算术指令。表2-1列出了一些基本指令及其语法示例。
add指令添加两个整数,并将结果存储在指定的第一个操作数中,无论这个操作数是内存位置还是寄存器(根据以下语法)。记住,只有一个参数可以是内存位置。子指令类似于加法,只是它减去整数。inc指令递增寄存器或内存位置的整数值,而dec递减寄存器或内存位置的整数值。
4、数据转移指令
x86处理器提供了一组健壮的指令,用于在寄存器和内存之间移动数据。这些指令提供了允许我们操作数据的底层机制。主体内存运动指令是mov指令。表2-2显示了如何使用mov指令移动数据。
与mov指令相关,lea指令将指定的绝对内存地址加载到寄存器中,用于获得指向内存位置的指针。例如,lea edx [ESP -4]从ESP中的值中减去4并将结果值加载到edx中。
5、栈指令
x86汇编中的堆栈是一种数据结构,允许您将值推入和弹出到堆栈中。这类似于在一堆板的顶部或顶部添加和删除板。
因为控制流往往是通过c风格的表达在x86汇编函数调用,因为这些函数调用使用堆栈来传递参数,分配本地变量,并且记住什么该计划的一部分返回一个函数执行完毕后,栈需要理解和控制流在一起。
当程序员希望将寄存器值保存到堆栈中时,push指令将值推送到程序堆栈中,pop指令将从堆栈中删除值并将它们放入指定的寄存器中。
push指令使用以下语法执行操作:
push 1
在本例中,程序将堆栈指针(寄存器ESP)指向一个新的内存地址,从而为值(1)腾出空间,该值现在存储在堆栈的顶部位置。然后它将值从参数复制到CPU刚刚为堆栈顶部腾出空间的内存位置。
让我们将其与pop进行对比:
pop eax
该程序使用pop将堆栈顶部的值弹出并将其移动到指定的寄存器中。在本例中,pop eax从堆栈中弹出顶部值并将其移动到eax中。
关于x86程序堆栈,需要理解的一个不直观但重要的细节是,它在内存中向下增长,因此堆栈上的最大值实际上存储在堆栈内存中的最低地址。当您分析引用存储在堆栈上的数据的汇编代码时,记住这一点非常重要,因为除非您知道堆栈的内存布局,否则很快就会混淆。
因为x86堆栈增长下行在内存中,当推指令分配空间程序堆栈的一个新值,它将ESP的价值,让它指向一个较低的位置在内存中,然后从目标寄存器值拷贝到内存位置,堆栈的顶部开始地址和成长。相反,pop指令实际上复制堆栈的顶部值,然后增加ESP的值,使其指向更高的内存位置。
6、控制流指令
x86程序的控制流定义了程序可能执行的指令执行序列的网络,具体取决于程序可能接收到的数据、设备交互和其他输入。控制流指令定义程序的控制流。它们比堆栈指令更复杂,但仍然非常直观。由于控制流在x86汇编中通常是通过c风格的函数调用来表示的,所以堆栈和控制流是紧密相关的。它们也是相关的,因为这些函数调用使用堆栈传递参数、分配局部变量,并记住函数执行完后返回程序的哪个部分。
调用和ret控制流指令对于程序在x86汇编中如何调用函数以及程序在执行这些函数之后如何从函数返回是最重要的。
调用指令调用一个函数。可以把它看作是一个函数,您可以用C之类的高级语言编写它,以便在调用调用指令并完成函数的执行之后,程序返回到该指令。您可以使用以下语法调用调用指令,其中address表示调用指令调用函数的内存。可以把它看作是一个函数,您可以用C之类的高级语言编写它,以便在调用调用指令并完成函数的执行之后,程序返回到该指令。您可以使用以下语法调用调用指令,其中address表示内存:
call address
调用指令做两件事。首先,它将函数调用返回后将执行的指令的地址推到堆栈的顶部,以便程序知道被调用的函数执行完后返回什么地址。其次,call用地址操作数指定的值替换EIP的当前值。然后,CPU在EIP指向的新内存位置开始执行。
就像调用开始一个函数调用一样,ret指令完成它。你可以单独使用ret指令,不需要任何参数,如下图所示:
ret
当被调用时,ret将从堆栈中弹出顶部值,我们希望该值是调用指令被调用时被推入堆栈的保存程序计数器值(EIP)。然后它将弹出的程序计数器值放回EIP并继续执行。
jmp指令是另一个重要的控制流结构,它的操作比调用简单。jmp不需要担心保存EIP,只需告诉CPU移动到作为其参数指定的内存地址,并在那里开始执行。例如,jmp 0x12345678告诉CPU在下一条指令上开始执行存储在内存位置0x12345678的程序代码。
您可能想知道如何使jmp和调用指令以一种有条件的方式执行,比如“如果程序收到了一个网络包,执行以下函数”。答案是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
注意,与调用和ret指令的情况不同,jmp指令家族从不涉及程序堆栈。实际上,在jmp指令家族中,x86程序负责跟踪自己的执行流,并可能保存或删除关于它访问过哪些地址的信息,以及在执行了特定的指令序列之后应该返回到哪里的信息。
7、基本块和控制流程图
虽然当我们在文本编辑器中滚动x86程序的代码时,它们看起来是顺序的,但实际上它们有循环、条件分支和无条件分支(控制流)。所有这些都为每个x86程序提供了一个网络结构。让我们使用清单2-1中的简单玩具组装程序来看看这是如何工作的。
正如您可以看到的,这个程序初始化计数器的值10,存储在寄存器EAX➊。接下来,它的循环价值EAX由每个迭代1➋递减。最后,一旦EAX已经到了一个值0➌,程序循环的爆发。
在控制流程图分析语言中,我们可以把这些指令看作是由三个基本块组成的。基本块是一系列指令,我们知道这些指令总是连续执行的。换句话说,基本块总是以分支指令或分支目标指令结束,并且总是以程序的第一条指令(称为程序的入口点)或分支目标开始。
在清单2-1中,您可以看到我们的简单程序的基本块从哪里开始和结束。第一个基本块由指令mov eax, 10在setup:下组成。第二个基本块由以下几行组成:从子eax开始,1到在loopstart:下面的jne $loopstart,第三个基本块从mov eax开始,1在loopend:下面。我们可以使用图2-2中的图可视化基本块之间的关系。(我们使用术语图与术语网络同义;在计算机科学中,这些术语是可以互换的。
如果一个基本块可以流到另一个基本块中,我们将它连接起来,如图2-2所示。如图所示,setup基本块指向loopstart基本块,该基本块在转换到loopend基本块之前重复10次。现实世界中的程序有这样的控制流程图,但它们要复杂得多,有数千个基本块和数千个互连。
8、使用pefile和capstone反汇编ircbot.exe
现在您已经很好地理解了汇编语言的基础知识,让我们来分解ircbot的前100个字节。使用线性反汇编的exe汇编代码。为此,我们将使用开放源码Python库pefile(在第1章中介绍)和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)
这将产生以下输出:
➊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寄存器➋,和它添加值0 x5c➌ESP寄存器中的值。最后,程序使用的调用指令调用一个函数存储在内存地址0 x496308➍。
因为这不是一本关于逆向工程的书,所以我在这里不再深入讨论代码的含义。我所介绍的是理解汇编语言如何工作的一个开端。有关汇编语言的更多信息,我推荐使用Intel程序员手册,网址是http://www.intel.com/content/www/us/en/processors/architectures-software-developer-manuals.html。
9、限制静态分析的因素
在本章和第1章中,您了解了各种使用静态分析技术来阐明新发现的恶意二进制文件的目的和方法的方法。不幸的是,静态分析有一些限制,使得它在某些情况下用处不大。例如,恶意软件作者可以使用某些攻击策略,这些策略的实现要比防御容易得多。让我们来看看这些进攻性的战术,看看如何防御它们。
(1)包装
恶意软件打包是恶意软件作者压缩、加密或以其他方式破坏大部分恶意程序的过程,从而使恶意软件分析人员无法理解这些程序。当恶意软件运行时,它会解包,然后开始执行。绕过恶意软件打包最明显的方法是在安全的环境中实际运行恶意软件,这是我将在第3章介绍的动态分析技术。
注意:出于合法的原因,软件安装程序也会使用软件打包。良性软件作者使用打包来交付他们的代码,因为它允许他们压缩程序资源来减少软件安装程序下载的大小。它还帮助它们阻止业务竞争对手的逆向工程尝试,并提供了一种方便的方法来将许多程序资源捆绑到一个安装程序文件中。
(2)资源混淆
恶意软件作者使用的另一种反检测、反分析技术是资源混淆。它们混淆了程序资源(如字符串和图形图像)存储在磁盘上的方式,然后在运行时对它们进行反混淆,以便恶意程序可以使用它们。例如,一个简单的混淆操作是在PE resources部分中存储的图像和字符串中的所有字节中添加一个值1,然后在运行时从所有这些数据中减去1。当然,这里可能存在许多混淆,所有这些都使恶意软件分析人员难以使用静态分析来理解恶意软件二进制文件。
与打包一样,绕过资源混淆的一种方法是在安全的环境中运行恶意软件。当这不是一个选项时,唯一能减轻资源混淆的方法就是找出恶意软件混淆其资源的方式,并手动消除它们的混淆,这是专业恶意软件分析师经常做的事情。
(3)Anti-disassembly技术
恶意软件作者使用的第三组反检测、反分析技术是反汇编技术。这些技术旨在利用最先进的反汇编技术的固有限制,向恶意软件分析人员隐藏代码,或使恶意软件分析人员认为存储在磁盘上的代码块包含与实际不同的指令。
反汇编技术的一个例子是将分支转移到一个内存位置,恶意软件作者的反汇编程序将把这个内存位置解释为另一条指令,本质上是向逆向工程师隐藏恶意软件的真实指令。反汇编技术有巨大的潜力,没有完美的方法来抵御它们。在实践中,针对这些技术的两种主要防御方法是在动态环境中运行恶意软件样本,并手动找出恶意软件样本中反汇编策略的表现,以及如何绕过它们。
(4)动态下载数据
恶意软件作者使用的最后一类反分析技术包括从外部获取数据和代码。例如,恶意软件示例可能在恶意软件启动时从外部服务器动态加载代码。如果是这种情况,静态分析对于这样的代码将是无用的。类似地,恶意软件可能在启动时从外部服务器获取解密密钥,然后使用这些密钥解密将在恶意软件执行过程中使用的数据或代码。
显然,如果恶意软件使用工业级加密算法,静态分析将不足以恢复加密的数据和代码。这种反分析和反检测技术非常强大,解决它们的唯一方法是通过某种方式获取外部服务器上的代码、数据或私钥,然后使用它们来分析所涉及的恶意软件。
总结
本章介绍了x86汇编代码分析,并演示了如何使用开放源码Python工具在ircbot.exe上执行基于解体的静态分析。虽然这并不是x86程序集的完整入门,但是您现在应该已经有了一个了解给定恶意程序集转储中发生的事情的起点。最后,您了解了恶意软件作者如何防范反汇编和其他静态分析技术,以及如何减轻这些反分析和反检测策略。在第3章中,您将学习如何进行动态恶意软件分析,这弥补了静态恶意软件分析的许多弱点。