x86汇编语言包括数百种指令。其中最常用的包括以下几种:
●算术指令:
■ add。
■ sub。
■ mul。
■ inc。
■ dec。
●位操作指令:
■ and。
■ or。
■ xor。
■ not。
●栈指令:
■ call。
■ return。
■ push。
■ pop。
●数据移动指令:
■ mov。
●执行流程指令:
■ jmp。
■ 条件跳跃指令。
●比较指令:
■ test。
■ cmp。
●其他指令:
■ lea。
■ nop。
虽然这看起来很多,但请考虑一下编程语言中常用的操作符(+、-、*、/、%、&&、||、&、|、^、!、~、<、>、<=、>=、==、.、->等)和主要关键词(if、else、switch、while、do、case、break、continue、for等)。用汇编语言实现这些行为需要很强的能力。
说实话,没有人能记住所有的x86指令,也没有必要这样做。x86指令的完整列表可以在http://ref.x86asm.net/coder32.html上找到,若有需要,可以在这里查阅任何指令的详细信息。
然而,要想成为一名成功的逆向工程师,理解最常用的x86指令的工作原理是非常必要的。如果你熟悉这部分关键的x86指令,你就能阅读并理解大多数x86程序。
正如它的名字所暗示的,mov指令将数据从一个位置移动到另一个位置,例如,在寄存器和内存位置之间复制数据,或者在特定位置放置一个立即数。需要注意的是,尽管它的名字叫作“移动”,但它其实是在复制数据,并不是在移动数据(数据并未从源头被移除,而是被从源头复制到目标位置)。
mov指令的语法是move destination, source。例如,mov eax, 5这条指令会将值5放进寄存器eax。同样,mov eax,[1]这条指令会将地址为0x1的值移动到eax。
在处理mov指令和类似指令时,需要记住变量名的使用会影响被移动值的长度。例如,指令mov eax,[0x100]会将一个32位数值移动到eax,而指令mov dx,[0x100]会将一个16位数值移动到dx。
注意: 在x86指令中,可以使用寄存器值来标识内存地址。例如,指令mov[eax], ebx将ebx中存储的值移动到以eax为地址的内存位置。如果eax的值为0x7777,内存地址0x7777就是ebx的值被存储的地方。
mov是一种多功能的操作符,是助记符与机器码能力的绝佳示例。如图3.1所示,mov可以以各种方式被使用,而每一种方式都会根据使用的两个操作数转化为不同的机器码。所有这些不同的变体在助记符级别都被表示为mov。将助记符转化为正确机器码的任务就是由汇编器来完成的。
假设变量i位于地址100,而j位于地址200,那么下面的伪代码应该如何用汇编语言编写呢?
这一行伪代码可以被转化为三条x86指令:
图3.1 mov指令
注意,寄存器eax用于存储从内存地址100复制到内存地址200的值。这样做的原因是单条指令无法执行两次内存访问。必须使用像eax这样的寄存器进行临时存储。
当我们查看代码时,可能会觉得直接将立即数42装入内存地址200,而不是用两次操作从内存地址100将之加载到内存地址200,似乎更有意义。然而,编译器不会,也不应该这么做。
这样做的原因是潜在的多线程应用程序。如果系统上运行着另一个线程,那么在将42放入内存地址100和将内存地址100处的值复制到内存地址200的步骤之间,内存地址100处的值可能已经被更新了。从内存地址100处复制值而不是使用立即数,有助于确保存储在内存地址200处的变量j获取存储在i中的值的最新版本。
x86指令inc和dec分别用来将指定的值增加或减少1。这与传统代码中的i++或i--指令是等价的。
这些指令只需要一个操作数,操作数可以是寄存器或内存地址。例如,指令inc eax将eax中储存的数值增加1,而dec[0x12345678]将内存地址0x12345678中储存的数值减少1。
add和sub指令分别用于对特定值进行加法或减法计算。这些指令接受两个操作数。例如,add指令会采用add destination, value形式。
在add指令中,目标位置(destination)可以是寄存器或内存位置,而值(value)可以是寄存器、内存位置或立即数。这个操作会进行destination+value计算,并将结果存储在目标位置。这意味着目标位置的原始值与该数学表达式是相关的,但在保存结果之后会被覆盖。注意,这两个操作数的大小必须相同。例如,add eax, ebx是一条有效的指令(32位值与32位值相加),而sub eax, bx是无效的(32位值减16位值)。
在使用add和sub指令时,我们需要考虑所操作数值的大小。例如,sub ecx,[100]这条指令的目标位置是ecx,这就意味着我们在操控一个32位的数值。然而,add dword[edx], 100这条指令需要大小说明符dword,因为32位数值edx表明了这个内存地址的长度是32位的,但并没有指出那个位置上的数据大小。
mul操作执行无符号整数乘法运算。然而,它有些不寻常,因为它只接受一个操作数,但隐式地使用了两个额外的寄存器。mul操作的语法是mul operand,其中operand可以是寄存器或内存地址。该操作将eax中存储的值与operand指定的值相乘。
mul操作的结果会保存在edx:eax中,其中edx存储了结果的高32位。即使结果小于32位且不需要edx,edx和eax中的值也会被mul改变。有意思的是,mul在进行32位算术运算时,可以得到一个64位的输出(edx:eax)。
mul操作的一个例子是mul eax,它对存储在eax中的32位值进行平方运算。当操作数包含一个内存地址时,该值的长度可以变化。例如,mul dword[0x555]将eax与存储在0x555地址上的32位值相乘,而mul byte[0x123]则使用存储在0x123地址上的8位值与eax相乘。
div操作执行无符号除法运算。就像mul操作一样,它只接受一个操作数,但隐式地修改eax和edx寄存器。在这种情况下,商储存在eax中,余数储存在edx中。例如,5除以2的商是2,余数是1。
div操作同时使用eax和edx作为其输入,并以与mul输出相同的方式对其进行格式化,将高32位放在edx中。就像mul一样,即使不需要edx(也就是余数是零),输出依然会修改eax和edx。
div eax是div操作的一个例子。这相当于计算eax, edx=edx:eax / eax。在这种情况下,操作数是一个32位寄存器,但使用内存地址可以指示并使用不同长度的除数。
假设你想计算123除以4的余数。这可以通过四条汇编指令完成:
最后,商被存储在eax中,余数则被存储在edx中。
x86标准支持几种不同的布尔操作。与(and)、或(or)和异或(xor)操作都需要两个操作数。下面展示了这三种操作的真值表。输入选项显示在表格的顶部和左边。
这三种操作都使用相同的语法mnemonic destination, source。例如,and操作的语法是and destination, source。与add操作类似,destination必须是一个寄存器或内存地址,而source可以是寄存器、内存地址或者立即数。此外,就像add操作一样,destination会在计算中被使用,但是也会被重写以保存结果。
布尔操作可以用于各种目的。例如,or eax, 0xffffffff就是一个快速将eax值设为全1的方法。操作and dword[0xdeadbeef], 0x1可以掩盖0xdeadbeef位置32位值除低位之外的所有位。操作xor eax,eax是清零eax值的常用方法。
not操作计算值的补码。如果你不熟悉“补码”这个术语,那么可以把它理解为把所有的0变成1,把所有的1变成0。它反转了数字。它采用not operand语法格式,只接受一个操作数。
not操作可以作用于各种长度的值。例如,操作not ch计算8位寄存器ch的补码,not dword[2020]计算位于地址2020的32位值的补码。
shr和shl是x86中可用的两种移位操作,其中shr代表向右移位,shl代表向左移位。这两项操作都需要两个操作数:需要移位的值的位置以及移位的位数,例如shr register, immediate。
shr和shl是逻辑移位操作符。这意味着,当按照给定的立即数将数值移位时,这两项操作会在左边或右边将这个数值进行零扩展。因此,任何因移位产生的新的位都会自动填充为0。
例如,操作shr al, 3将把存储在al中的值向右移动三位。如果al存储的值是00010000,那么操作后的结果将会是00000010。
提示: 零扩展向右移位的值会用零填满空位,这被称为逻辑移位。符号扩展向右移位的值则会用最高有效位的值填满空位,这被称为算术移位。
sar和sal是算术移位操作符。它们的语法与逻辑移位的语法相同,但实现方式不同。sar执行的是向右的算术移位,而sal执行的是向左的算术移位。
当执行左移操作时,sal指令与shl指令操作相同,都是对值进行零扩展。例如,当al中存储的值为00000100时,指令shl al, 3和sal al, 3都会产生值00100000。所有新的位都会用0来填充。
然而,sar操作会对值进行符号扩展,而shl操作会进行零扩展。符号扩展意味着它会复制最高有效位。例如,如果al寄存器包含的值是10000000,那么指令shr al, 3会产生值00010000,如下所示:
然而,指令sar al, 3会生成11110000。由于最高有效位是1,所以所有新的位都会复制为1。
nop代表“无操作”(no operation)。它是一个一字节的操作符(0x90),并不执行任何操作。
虽然nop在技术上并无实际作用,但它在多种合法场景下都会被应用,包括以下几种:
●时间调整。
●内存对齐(Memory alignment)。
●风险防控。
●分支延迟槽(RISC架构)。
●稍后由未来补丁替换的占位符。
而在安全领域,它被用于以下情况:
●黑客攻击。
●破解。
lea代表“加载有效地址”(load effective address)。它接受两个操作数,包括destination(寄存器或内存地址)和必须是内存地址的source。lea指令将计算出指定source操作数的地址,并将其放在destination处。对于熟悉指针的人来说,它类似于&操作符。
虽然lea用来处理地址,但它也常常被用于简单的数学运算。例如,操作lea eax,[ebx+ecx+5]是在询问ebx+ecx+5指向的地址是什么,并将那个地址存入eax。这实际上就是在计算ebx+ecx+5并将结果存入eax。而lea的一个更常规的用法是lea eax,[100],它会将值100存入eax。
虽然从表面上看,这可能会显得有点儿愚蠢或无意义,但是lea这个操作符实际上是很有用的,因为它能让我们在汇编中更加高效地处理数组。在数组中,各个值是存储在基址的特定偏移量处。(还记得基址加偏移量寻址模式吗?)有了lea,我们就能够有效地计算出数组中某个元素的地址。例如,假设eax中存储的是字符数组的基址,那么指令lea ebx,[eax+2]就会把数组第二个元素的地址放到ebx中。这条单一的指令比执行同样的操作需要的一系列指令mov ebx, eax和add ebx, 2更高效。
如果将下面的伪代码翻译成汇编语言,应该怎么写?我们假设变量i存储在地址100处,j存储在地址200处,k存储在地址300处。
这段伪代码将会被汇编成如下的x86指令:
在这个例子中,需要注意ebx和bl的使用。本来要存入这个寄存器的值可以放在bl中。但是,在进行加法运算时,整个ebx寄存器都被用到了。这是因为类升级,如果向一个4字节的值加一个1字节的值,那么1字节的值会被升级为4字节,且额外的字节必须为0。在这种情况下,bl中原本为0x05的值被升级为ebx中的0x00000005。进行xor操作清除ebx是必要的,这样可以确保完全清除在ebx寄存器中存储的以前的值,不会影响加法运算的结果。