2.2 x86汇编语言基础
汇编语言是给定体系结构中最低级别的人类可读编程语言,它与特定CPU体系结构的二进制指令格式紧密对应。汇编语言的一行几乎总是等价于单个CPU指令。因为汇编语言的级别很低,所以通常可以使用正确的工具从恶意软件二进制文件中轻松地提取出来。
要达到阅读反汇编后恶意软件x86代码的基本熟练程度比你想象的要容易,这是因为大部分恶意软件汇编代码在大多数时间都是通过Windows操作系统的动态链接库(DLL)调用操作系统,这些库文件在运行时被加载到程序内存中。恶意软件程序使用DL L来完成大部分实际工作,例如修改系统注册表、移动和复制文件、建立网络连接以及通过网络协议进行通信等。因此,跟踪恶意软件汇编代码通常需要了解汇编语言进行函数调用的方式以及了解各种DLL调用的作用。当然,事情可能会变得更加复杂,但是了解这些可以揭示很多关于恶意软件的信息。
在下面的部分中,我将介绍一些重要的汇编语言概念。我还将解释一些如控制流和控制流图等的抽象概念。最后,我们对ircbot.exe程序进行反汇编,并探讨其汇编代码和控制流程是如何指引我们了解它的目的的。
x86汇编语言有两种主要的语法:Intel和AT&T。在本书中,我使用的是Intel语法,目前所有主要的反汇编工具中都支持该语法,并且也是Intel官方的x86 CPU文档中使用的语法。
下面我们先来看看CPU寄存器。
2.2.1 CPU寄存器
寄存器是x86 CPU执行计算的小型数据存储单元。由于寄存器位于CPU内部,寄存器的访问速度比内存的访问速度快几个数量级。这就是为什么核心计算操作,如算术和条件测试指令,都是针对寄存器而言的。这也是CPU使用寄存器存储正在运行的程序状态的相关信息的原因。尽管有经验的x86汇编程序员可以使用许多寄存器,但我们在这里只关注几个重要的寄存器。
1.通用寄存器
通用寄存器就像汇编程序员的暂存空间。在32位系统中,每个寄存器包含32位、16位或8位空间,我们可以在这些空间执行算术运算、按位运算、字节顺序交换操作等。
在常见的计算工作流程中,程序将数据从内存或外部硬件设备移入寄存器,对这些数据执行一些运算,然后将数据移回内存进行存储。例如,要对一个长列表进行排序,程序通常从内存的数组中提取列表项,在寄存器中对它们进行比较,然后将比较结果写回到内存中。
要了解Intel 32位架构中通用寄存器模型的一些细微差别,请看图2-1。
图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的直接引用。
2.堆栈和控制流寄存器
堆栈管理寄存器存储有关程序堆栈的关键信息,程序堆栈负责存储函数的局部变量、传递给函数的参数以及与程序控制流相关的控制信息。下面我们来浏览这些寄存器。
简单来说,ESP寄存器指向当前执行的函数堆栈的顶部,而EBP寄存器指向当前执行的函数堆栈的底部。这对于现代程序来说是非常关键的信息,因为这意味着通过引用相对于堆栈的数据而不是使用它的绝对地址,面向过程和面向对象的代码可以更优雅和更有效地访问局部变量。
虽然你不会在x86汇编代码中看到对EIP寄存器的直接引用,但它在安全性分析中非常重要,特别是在漏洞研究和缓冲区溢出漏洞利用等情景中。这是因为EIP包含当前正在执行的指令的存储器地址。攻击者可以使用缓冲区溢出漏洞利用代码间接破坏EIP寄存器的值,进而可以控制程序的执行。
除了在漏洞利用过程中起到作用外,EIP在分析恶意软件的恶意代码方面也很重要。我们可以使用调试器随时查看EIP的值,这有助于我们了解恶意软件在任何特定时刻所执行的代码。
EFLAGS是一个包含CPU标志位的状态寄存器,CPU标志位是存储程序当前执行状态的状态信息位。EFLAGS寄存器对于在x86程序中创建条件分支过程或者由于if/then类型程序逻辑的结果而导致执行流程变化的过程非常重要。具体来说,每当一个x86汇编程序检查一个值是否大于或小于零,然后基于这个测试的结果跳转到某个函数时,EFLAGS寄存器会起到启用作用,更详细的内容在2.2.3节中进行描述。
2.2.2 算术指令
指令在通用寄存器上运行。你可以利用通用寄存器使用算术指令进行简单的计算。例如,add、sub、inc、dec和mul是你在恶意软件逆向工程中经常会遇到的算术指令示例。表2-1列出了一些基本指令及其语法的示例。
表2-1 算术指令
add指令是对两个整数进行相加,并将结果存储在指定的第一个操作数中,根据语法这里可以是内存位置也可以是寄存器。请记住,只有一个参数可以是内存位置。sub指令类似于add指令,区别是它是减去一个整数。inc指令是递增寄存器或内存位置中的整数值,而dec指令是递减寄存器或内存位置中的整数值。
2.2.3 数据传送指令
x86处理器为在寄存器和存储器之间传送数据提供了一组强大的指令。这些指令提供了使我们能够操作数据的基本机制,主存储器数据传送指令是mov指令,表2-2显示了如何使用mov指令来传送数据。
表2-2 数据传送指令
与mov指令相关,lea指令将指定的绝对内存地址加载到寄存器中,用于获取内存位置的指针地址。例如,lea edx, [esp -4]从ESP的值中减去4并将结果值加载到EDX中。
1.堆栈指令
x86汇编语言中的堆栈是一种允许你将值压入或弹出堆栈以进行数据存取的数据结构。这与你在一叠盘子的顶部添加和取出盘子的过程类似。
因为控制流通常是通过x86程序集中的C语言风格的函数调用来表示的,并且因为这些函数调用使用堆栈来传递参数、分配局部变量,以及记住在函数执行完毕后要返回到程序的哪个部分,所以堆栈和控制流需要在一起进行了解。
当程序员希望将寄存器的值保存到堆栈中时,push指令将值压入到程序堆栈中,pop指令将堆栈中的值删除并将它们存入指定的寄存器。
push指令使用以下语法执行其操作:
在本例中,程序将堆栈指针(寄存器ESP)指向一个新的内存地址,从而为值(1)腾出空间,这个值现在存储在堆栈的顶部位置。然后,它将参数中的值复制到CPU刚刚在堆栈顶部腾出的空间的内存位置。
让我们将其与pop指令进行对比:
程序使用pop指令从堆栈顶部弹出值并将其存入指定的寄存器。在这个例子中,pop eax这条指令将堆栈中最顶部的值弹出并将其存入到eax寄存器中。
关于x86程序堆栈,需要理解的一个不直观但很重要的细节是,它在内存中的地址增长是向下的,因此堆栈上最高位置的值实际上存储在堆栈内存中的最低地址。当你对存储在堆栈上数据进行引用的汇编代码进行分析时,记住这一点非常重要,因为除非你知道堆栈的内存布局,否则很快就会变得混乱。
因为x86堆栈在内存中向下增长,当push指令在程序堆栈上为一个新的值分配空间时,它会减小ESP的值,使其指向内存中较低的位置,然后将目标寄存器中的值复制到内存位置,从堆栈的顶部地址开始并逐渐增长。相反,pop指令实际上是从堆栈中拷贝并弹出顶部值,然后增加ESP的值,这样它就指向更高的内存位置。
2.控制流指令
x86程序的控制流定义了程序可能执行的指令序列网络,具体取决于程序可能接收到的数据、设备交互以及程序可能接收的其他输入。控制流指令定义了一个程序的控制流。它们比堆栈指令更复杂,但仍然非常直观。由于控制流通常通过x86汇编语言中C语言风格的函数调用来表示,所以堆栈和控制流密切相关。由于这些函数调用使用堆栈传递参数、分配局部变量,并且记住在函数完成执行后返回到程序的哪个部分,这也体现了它们之间的相关性。
在x86汇编语言中,就程序如何调用函数以及这些函数执行完毕后程序如何从函数返回而言,call和ret指令在控制流指令中是最重要的部分。
call指令用于调用一个函数。你可以将其视为可以用像C语言这样的高级语言编写的函数,使得程序可以在调用call指令并完成函数执行之后返回到指令中。你可以使用以下语法调用call指令,address表示函数代码在内存中的起始位置:
call指令有两个作用。首先,它将函数调用后返回的将要执行的指令地址压入堆栈的顶部,这样程序就知道了在所调用函数完成执行后返回的地址。其次,call指令将EIP寄存器的当前值替换为address操作数指定的值。然后,CPU从EIP所指向的新的内存位置开始执行。
就像call指令启动函数调用一样,ret指令用来结束一个函数。你可以单独使用ret指令而不使用任何参数,如下所示:
当ret指令被调用时,堆栈顶部的值将被弹出,这是我们所期望的保存在程序计数器的值(EIP),这个值在call指令被调用时,由call指令压入堆栈。然后它将弹出的程序计数器值放回EIP寄存器并继续执行。
jmp指令是另一个重要的控制流结构,它的操作比call指令简单。jmp指令只是告诉CPU移动到由其参数指定的内存地址并从那里开始执行,而不需要担心保存EIP的值。例如,jmp 0x12345678告诉CPU在下一条指令时开始执行存储在内存地址0x12345678处的程序代码。
你可能想知道如何使jmp和call指令以条件方式执行,例如“如果程序收到一个网络数据包,则执行以下函数。”答案是x86汇编语言没有像if、then、else、else if等的高级结构。相反,在程序代码中分支选择到一个地址进行执行通常需要两条指令:一条是cmp指令,它根据一些测试值检查某些寄存器中的值,并将该测试的结果存储在EFLAGS寄存器中;另一条是条件分支指令。
大多数条件分支指令都以字母j开头,它允许程序跳转到一个地址,并使用代表被测试条件的字母作为后缀。例如,jge指令告诉程序在大于或等于的条件下进行跳转。这意味着被测试寄存器中的值必须大于或等于测试值。
cmp指令使用以下语法:
如上所述,cmp将指定的通用寄存器中的值与value进行比较,然后将该比较的结果存储在EFLAGS寄存器中。
调用各种条件jmp指令的语法如下所示:
正如你所看到的,我们可以在任意数量的条件测试指令前加上前缀j。例如,只有在测试值大于或等于寄存器中的值时才进行跳转,请使用以下指令:
注意,与call和ret指令的情况不同,jmp指令系列从不涉及程序堆栈。实际上,对于jmp指令系列,x86程序负责跟踪自身的执行流程,并可能保存或删除有关其访问过的地址以及在执行特定指令序列后应该返回的地址等信息。
3.基本块和控制流程图
虽然当我们在文本编辑器中滚动程序代码时,x86程序看起来是按顺序的,但实际上程序里有循环、条件分支和无条件分支(控制流)等结构。所有这些都为每个x86程序提供了一个网络结构。让我们使用代码清单2-1中的简易汇编程序来看看它是如何工作的。
代码清单2-1 理解控制流程图的汇编程序
如你所见,该程序将计数器的值初始化为10,并存储在寄存器EAX❶中。接下来,它执行一个循环,其中EAX中的值在每次迭代中递减1 ❷。最后,一旦EAX中的值减到0 ❸,程序就跳出循环。
在控制流图分析的语言中,我们可以把这些指令看作是由三个基本块组成的。基本块是我们知道将始终连续执行的一系列指令。也就是说,基本块总是以分支指令或分支的目标指令作为结束,并且它始终以程序的第一条指令(称为程序的入口点)或分支目标作为开始。
在代码清单2-1中,你可以看到我们简易程序的基本块从哪里开始和结束。第一个基本块由setup:下的指令mov eax, 10组成。第二个基本块由loopstart:下从sub eax, 1开始到jne $loopstart结束的几行代码组成,第三个基本块从loopend:下的mov eax, 1开始。我们可以使用图2-2中的图形来表示基本块之间的关系。(我们视术语图(graph)与术语网络(network)为同义词;在计算机科学中,这些术语是可以互换的。)
图2-2 简易汇编程序的控制流程图示意
如图2-2所示,如果一个基本块能够流入另一个基本块,我们就把它们连接起来。如图所示,setup基本块指向loopstart基本块,该基本块在转换到loopend基本块之前要重复10次。实际工作中的程序会有这样的控制流图,但它们要复杂得多,有数千个基本块和数千个相互连接。