x86反汇编
抽象层面
通常,计算机系统被描述为六个抽象层次。
硬件 硬件层是唯一的一个物理层,由电子电路组成。这些电路实现了XOR门、AND门、OR门和NOT门等逻辑运算器的复杂组合,成为数字逻辑。硬件很难被软件所操作。
微指令 微指令层又成为固件(firmware)。微指令只能在为它设计的特定电路上执行。这层由一些微指令构成,它们从更高的机器码层翻译而来,提供了访问硬件的接口。当分析恶意代码时,我们通常不关心微指令,因为它们通常是为特定的计算机硬件设计的。
机器码 机器码层由操作码(opcode)组成,操作码是一些十六进制形式的数字,用于告诉处理器你想它做什么。机器码一般由多条微指令实现,这样底层硬件就能实际执行代码了。而机器码本身又由高级语言编写的计算机程序编译而来。
低级语言 低级语言杀死计算机体系结构指令集的人类易读版本,主要是汇编语言,恶意代码分析师利用这一层,因为对人来说,机器码太难理解了。我们使用反汇编器来生成低级语言的文本,这些文本由一些简单的助记符组成,如mov和jmp。
高级语言 大部分程序员使用高级语言。高级语言对机器层做了很强的抽象,从而可以很轻松地使用程序逻辑和流控制机制。高级语言包括C,C++等。它们被一个编译器经过成为编译地过程转为机器码。
解释性语言 解释性语言位于最高层。很多程序员使用诸如c#、Perl、NET、Java等解释语言。这一层的代码不会被编译成机器码,而是被翻译为字节码。字节码是特定于该语言的一种中间表示,它在解释器中执行。解释器(interpreter)是一个在运行时将字节码实时翻译成可执行机器码的程序。相比于传统被编译的代码,解释器提供了一种自动的抽象层次,因为它可以独立于操作系统,自己处理错误和管理内存。
逆向工程
恶意代码存储在磁盘上时,通常是机器码层的二进制形式。机器码是一种计算机可以快速高效执行的代码形式。而我们反汇编恶意代码,就是使用反汇编器,将恶意代码二进制文件作为输入,输出汇编语言代码。
x86体系结构
大部分现代计算机体系结构(包括x86)在内部实现上遵循冯诺依曼结构。这种结构包括3种硬件组件:
中央处理单元(CPU),负责执行代码
内存(RAM),负责存储所有的数据和代码。
输入/输出系统(I/O),为硬盘、磁盘、显示器等设备提供接口。
CPU又包含一些组件。其中:控制单元(control unit)使用某一个又成为指令指针(instruction pointer)的寄存器(register)从内存取得要执行的指令,这个寄存器中存有指令的地址。寄存器是CPU中数据的基本存储单元,通过它,很多时候CPU不在需要访问内存,从而节省了事件。算术逻辑单元(arithmetic logic unit,ALU)执行从内存取来的指令,并将结果放到存储器或内存中。一条条取指令、执行指令的过程不断重复,就形成了程序的运行。
内存
数据 这个词指的是内存中一个特定的节,名为数据节(data section),其中包含了一些值。这些值在程序初始加载时被放到这里,称为静态值(static value),因为程序运行时他们可能并不发生变化,还可以成为全局值(global value),因为程序的任何部分都可以使用它们。
代码 代码节包含了在执行程序任务时CPU所取得的指令。这些代码决定了程序是做什么的,以及程序中的任务如何协调工作。
堆 堆是为程序执行期间需要的动态内存准备的,用于创建(分配)新的值,以及消除(释放)不在需要的值。将其称为动态内存(dynamic memory),是因为其内容在程序运行期间经常被改变。
栈 栈用于函数的局部变量和参数,以及控制程序执行流。
指令
指令是汇编程序的构成块。在x86汇编语言中,一条指令由一个助记符,以及零个或多个操作数组成。
操作码(opcode)和字节序
每条指令使用操作码告诉CPU程序要执行什么样的操作。
反汇编器将操作码翻译为人类易读的指令。
用0x42000000表示值0x42,是因为x86架构使用小端字节序。
数据的字节序(endianness)是指在一个大数据项中,最高位(大端,big-endian)还是最小位(小端,little-endian)被排在第一位(即排在最低的地址上)。一些恶意代码在网络通信时必须改变字节序,因为网络数据使用大端字节序,而x86程序使用小端字节序。因此在大端字节序下,IP地址127.0.0.1会被表示为0x7F000001,而在小端字节序下,表示为0x0100007F.
操作数
操作数说明指令要使用的数据。
立即数(immediate)操作数是一个固定的值,比如4-1中的0x42
寄存器(register)操作数指向寄存器,如ecx
内存地址(memory address)操作数指向感兴趣的值所在的内存地址,一般由方括号内包含值、寄存器或方程式组成,如【eax】。
寄存器
寄存器是可以被CPU使用的少数数据存储器,访问其中内容的速度会比访问其他存储器要快。x86处理器中有一组寄存器,可以用于临时存储或者作为工作区。
通用寄存器,CPU在执行时使用。
段寄存器,用于定位内存节。
状态标志,用于做出决定。
指令指针,用于定位要执行的下一条语句。
所有通用寄存器的大小都是32位,可以在汇编代码中以32位或16位引用。比如,EDX指向这个完整的32位寄存器,而DX指向EDX寄存器的低16位。
通用寄存器
通用寄存器一般用于存储数据或内存地址,而且经常交换着使用以完成程序。
标志寄存器
EFLAGS寄存器是一个标志寄存器。在x86架构中,它是32位的,每一位是一个标志。在执行期间,每一位表示要么是置位要么是清除,并由这些值来控制CPU的运算,或者给出某些CPU运算的值。具体的标志见笔记标志寄存器。
EIP、指令指针
在X86架构中,EIP寄存器,又称为指令指针或程序计数器,保存了程序将要执行的下一条指令在内存中的地址。EIP的唯一作用就是告诉处理器接下来做什么。
简单指令
由方括号括起来的操作数是对内存中数据的引用。不允许在不加方括号的情况下,在指令里面做一个运算。例如:mov eax,ebx+esi*4是一条非法指令
指令 | 描述 |
---|---|
MOV EAX,EBX | 将EBX中的内容复制到EAX寄存器 |
MOV EAX,0X42 | 将立即数0x42复制到EAX寄存器 |
MOV EAX,[0X4037C4] | 将内存地址0x4037C4的四个字节复制到EAX寄存器 |
MOV EAX,[EBX] | 将EBX寄存器指向的内存地址四个字节复制到EAX寄存器 |
MOV EAX,[EBX+ESI*4] | 将ebx+esi*4等式结果指向的内存地址处四个字节复制到EAX |
另一条类似于MOV的指令是lea(load effective address 加载有效地址)。
lea destination,source。lea指令用来将一个内存地址赋给目的操作数。例如,lea eax,[ebx+8]就将EBX+8的值给了EAX。相反的,mov则加载内存中地址为EBX+8处的数据。因此,lea eax,[ebx+8]就和mov eax,ebx+8指令实际上是等价的,然而,采用这种寻址方式的mov指令是无效的。
mov eax,[ebx+8]将值0x20赋给EAX。
而指令lea eax,[ebx+8]则将0xB30048这个值给EAX。
lea指令并非专门用于计算内存地址。它还被用来计算普通的值,因为它所需的指令更少。
算术运算
加减法
指令 | 描述 |
---|---|
sub eax,0x10 | eax寄存器值减去0x10 |
add eax,ebx | 将ebx值加入eax并将结构保存至eax |
inc edx | edx值递增1 |
dec ecx | ecx值递减1 |
乘除法
乘法和除法都是用了一个预先预定的寄存器,因此其指令很简单,就是指令码加上寄存器要去乘或除的值。
mul指令的格式是mul value;div指令的格式是div value。
mul value指令总是将eax乘上value。因此,eax寄存器必须在乘法指令出现前就赋值好。乘法的结果以64位的形式分开存储在edx和eax中。其中edx存储了高32位,eax存储了低32位。
div value指令与mul运算方向相反:它将EDX和EAX合起来存储的64位值除以value。因此,在做除法之前,EDX和EAX这两个寄存器必须赋值好。商存储到EAX,余数存储到EDX中。
mul 0x50:将EAX的值乘以0x50,并将结果存入EDX:EAX寄存器中
div 0x75:将EDX:EAX值除以0x75,并将结果存入EAX,将余数存入EDX
shr和shl指令用于寄存器做移位操作。
shr指令的格式是“shr destination(目的数),count” shl指令的格式也是一样。
shr和shl指令对目的操作数右移或左移,由count决定移多少位。移出目的操作数(看宽度)边界的位则会先移动到CF标志位(进位标志)中。在移位时,使用0填充新的位。
eg:shr 0B1000,1 那么其结果位0100 CF标志位中就包含了最后移出目的操作数的哪一位。
*注:标志寄存器CF:进位标志 如果运算结果的最高位产生了一个进位或错位,那么其值位1
循环移位指令ror(右循环)和rol(左循环)与移位指令类似,但移出的哪一位会被填到另一端(注意寄存器的宽度)空出来的位上,即右循环移位会将最低位循环移到最高位;左循环则相反;
移位经常被用于对乘法运算的优化,因为乘法运算前需要准备寄存器、移动数据,这时移位就会显得更简单、更快。shl eax,1的计算结果和将eax乘以2一样。
在分析恶意代码时,如果出现一个函数中只有xor、or、and、shl、ror、shr、rol这样的指令,并且反复出现,看起来随机排列,就可能遇到了一个加密或者压缩函数。
指令 | 描述 |
---|---|
xor eax,eax | 将EAX寄存器清零 |
or eax,0x7575 | 将EAX的值与0x7575进行或操作 |
mov eax,0xA | 将eax寄存器左移两位,eax=0x28 |
shl eax,2 | 1010 001010->101000 |
mov bl,0xA | 将BL(8位)寄存器循环移位移两位 |
ror bl,2 | 1010 00001010—>10000010 |
注:三四指令一起 五六指令一起
NOP指令 当它出现时,直接执行下一条指令。
这条指令的opcode是0x90。在缓冲区溢出攻击中,当攻击者无法完美地控制利用代码,就经常使用NOP滑板。它起到了填充代码的作用,以降低shellcode可能在中间部分开始执行所造成的风险。
栈
用于函数的内存、局部变量、流控制结构等被存储在栈中。栈是一种用压和弹操作来刻画的数据结构,向栈中压入一些东西,然后再弹出来。是一种先进后出(LIFO)的结构。
x86架构有对栈的内建支持。用于这种支持的寄存器包括ESP(栈顶)和EBP(栈底)。其中,ESP时栈指针,包含了指向栈顶的内存地址。EBP是栈基址寄存器,在一个函数中会保持不变。
栈只能用于短期存储。它经常用来保存局部变量、参数和返回地址。规格如下:
局部变量是从ebp-4开始 参数是ebp+8 返回地址(eip)是ebp+4
局部变量(ebp-4) |
---|
ebp |
返回地址(ebp+4) |
参数(ebp+8) |
函数调用
函数是程序中的一段代码,执行一个特定的任务,并于其他代码相对独立。主代码调用函数,并在其返回到主代码前,临时将执行权交给函数。程序如何使用栈,这对一个二进制文件是贯穿始终的问题。现在,我们将关注最常见的约定,称之为cdecl。
注:函数调用的三种方式cdecl、stdcall、__fastcall
cdecl是C/C++默认的调用方式
stdcall是Windows API函数的调用方式,只不过我们在头文件里查看这些API的声明的时候是用了winap的宏进行代替了,而这个宏其实就是stdcall了。
函数的调用,设计参数传递,返回值传递,调用后返回,这都是通过栈的变化来实现的
cdecl
*C/C++默认方式,参数从右向左入栈,主调函数(call指令所属的函数)负责栈平衡。
add esp,8 即是栈平衡操作
stdcall
Windows API默认方式,参数从右向左入栈,被调函数(函数内部)负责栈平衡。
ret 8为栈平衡操作
fastcall
快速调用方式。所谓快速,这种方式选择将参数优先从寄存器传入(ecx和edx),剩下的参数再从右向左从栈传入。因为栈是位于内存的区域,而寄存器位于CPU内,故存取方式快于内存,故其名曰fast
许多函数包含一段“序言”(prologue),它是函数开始处的少数几行代码,用于保存函数中要用到的栈和寄存器。相应的,在函数末尾的“结语”(epilougue)则将栈和这些寄存器恢复至函数被调用前的状态(说白了就是平衡堆栈操作)
条件指令
所有的汇编语言都能做比较,并根据比较结果做出决定。条件指令就是用来做比较的指令。
最常见的两个条件指令是test和cmp。test指令与and(与操作:相同操作数返回值为1)指令的功能一样,但它并不会修改其使用的操作数。test指令只设置标志位。test指令执行后,我门感兴趣的是ZF标志位。
注:ZF:零标志 用于判断结果是否为0,运算结果为0,ZF置1,否则置0
对某个东西与它自身的test经常被用于检查它是否是一个NULL值.
eg:test eax,eax 其实也可以直接将eax与0比较,但是其字节更少,花费的CPU周期也少
cmp指令与sub指令的功能一样,但是它不影响其操作数。cmp指令也是只用于设置标志位,其执行结果是,ZF和CF标志可能会发生变化。
注:这里可以去看看某站的滴水逆向汇编部分 用一个圆来解释如何判断是否溢出
分支指令
分支指令是一串指令根据程序流有条件地执行。
最常见的分支指令是跳转指令。程序中使用了大量的跳转指令,最简单的是jmp指令,
注:jmp指令控制程序直接跳转到目标地址执行程序,程序总是顺序执行,指令本身无堆栈操作过程。
它使得下一条要被执行得指令是其格式jmp location中指定位置的指令,又称为无条件跳转,因为总会跳到目的位置去执行。这个简单的跳转无法满足所有的跳转需求。
条件跳转使用标志位来决定是跳转,还是继续执行下一条指令。
下面表格是一些跳转指令
指令 | 描述 |
---|---|
jz loc | 如果ZF=1,跳转至指定位置 |
jnz loc | 如果ZF=0,跳转到指定位置 |
je loc | 与jz类似,但通常在一条cmp指令后使用。如果操作数与目的操作数相等,则跳转 |
jne loc | 与jnz类似,但通常在一条cmp指令后使用。如果操作数与目的操作数不相等,则跳转 |
jg loc | 在一条cmp指令做有符号比较之后,如果目的操作数大于源操作数,跳转 |
jge loc | 在一条cmp指令做有符号比较之后,如果目的操作数大于等于源操作数,跳转 |
ja loc | 与jg类似,但使用无符号比较 |
jae loc | 与jge类似,但是用无符号比较 |
jl loc | 在一条cmp指令做有符号比较之后,如果目的操作数小于源操作数,则跳转 |
jle loc | 在一条cmp指令做有符号比较之后,如果目的操作数小于等于源操作数,则跳转 |
jb loc | 与jl类似,但使用无符号比较 |
jbe loc | 与jle类似,但使用无符号比较 |
jo loc | 如果前一条指令置位了溢出标志位(OF=1),则跳转 |
js loc | 如果符号标志位被置位(SF=1),则跳转 |
jecxz loc | 如果ECX=0,则跳转 |
重复指令
重复指令是一组操作数据缓冲区的指令。数据缓冲区通常是一个字节数组的形式,也可以是单字或者双字。
最常见的数据缓冲区操作指令是movsx、cmpsx、stosx和scasx,其中x可以是b、
w或者d。分别表示字节、字和双字。
比如函数请求堆栈,填充数据缓冲区时,就利用了这个指令
mov eax,0xCCCCCCCC
mov ecx,0x10
lea edi,dword ptr ds:[ebp-0x40](内存寻址方式,将ebp-40的值赋给edi)
rep stosd
在上述重复指令操作中,使用了ESI和EDI,ESI是原索引寄存器,EDI是目的索引寄存器。还有ECX用作计数的变量
这些指令还需要一个前缀,用于对长度超过1的数据做操作。movsb指令本身只会移动一个字节,而不使用ECX寄存器。
指令 | 描述 |
---|---|
rep | 循环终止条件ECX=0 |
repe,repz | 循环终止条件ECX=0 or ZF=0 |
repne,repnz | 循环终止条件ECX=0 or ZF=1 |
在x86下,使用重复前缀来做字节操作。rep指令会增加ESI和EDI这两个偏移,减少ECX寄存器。rep前缀会不断重复,直至ECX=0.repe/repz和repne/repnz前缀则不断重复,直至ECX=0或直至ZF=0或1.因此,在大部分数据缓冲区操作指令中,ESI、EDI和ECX必须为rep指令的生效,而进行适当的初始化。
movsb指令用于将一串字节从一个位置移动到另一个位置。rep前缀经常与movsb一起使用,从而复制一串长度由ECX决定的字节。从逻辑上说,rep movsb指令等价于C语言的memcpy函数(用于内存拷贝)。movsb指令从ESI指向地址取出一个字节,将其存入EDI指向地址,然后根据方向标志(DF)的设置,将ESI和EDI的值加1或者减1。如果DF=0,则加,否则减。
在由C代码编译后的结果中,很少能看到DF标志。但是在shellcode里,人们有时候会调换方向标志,这样就可以反方向存储数据。
cmpsb指令用于比较两串字节,以确定其是否是相同的数据。cmpsb指令用ESI指向地址的字节减去EDI指向地址的字节,并更细相关的标志位。它经常与repe前缀一起使用。此时,cmpsb指令逐一比较两串字节,直至发现一处不同,或比较到头。cmpsb指令从地址ESI获得一个字节,将其与EDI指向位置的字节进行比较,并设置标志位,然后对ESI和EDI分别加1。如果repe前缀,就检查ECX的值和标志位,如果ECX=0或者ZF=0,就停止重复。这相当于C语言的memcmp函数。
scasb指令用于从一串字节中搜索一个值。这个值由AL寄存器给出。它的工作方式与cmpsb一样,但是它是将ESI指向地址的字节与AL进行比较,而不是与EDI指向地址的字节比较。repe操作会使得这个比较不断继续,直到找到该字节,或者ECX=0.如果在这串字节中找到了这个值,则其位置会被存储在ESi中。
stosb指令用于将值存储到EDI指向的地址。它与scasb一样,但不是去搜索,而是将指定的字节存入EDI指向的地址。rep前缀与scasb一起使用后,就初始化了一段内存缓冲区,其中的每个字节都是相同的值。这等价于C语言的memset函数。
rep指令实例:
指令 | 描述 |
---|---|
repe cmpsb | 用于比较两块数据缓冲区。EDI和ESI必须被设为两段缓冲区的地址,ECX必须被设为缓冲区长度。当ECX=0或者发现缓冲区不一致的时候,停止比较 |
rep stosb | 用于用一个给定的值初始化一块缓冲区中所有字节。EDI包含了缓冲区地址,AL则包含初始值。这个指令通常与xor eax,eax一起使用。 |
rep movsb | 一般用于复制缓冲区中的字节。ESI需要被设为原缓冲区地址,EDI被设为目的缓冲区地址,ECX则必须为要复制的长度。会逐字节复制,直至ECX=0. |
repne scasb | 用于在一段数据缓冲区中搜索一个字节。EDI徐指向缓冲区地址,AL则包含要找的字节,ECX设为缓冲区长度。当ECX=0或找到该字节时,比较停止 |
参考书文:《恶意代码分析实战》