第1章 逆向工程简介

1.1 汇编简介

如果你正在翻阅本书,那么你可能已经听说过Arm汇编语言,并且知道理解它是分析在Arm上运行的二进制文件的关键。但这种语言是什么,为什么会有这种语言?毕竟,程序员通常使用C/C++等高级语言来编写代码,几乎没有人会直接用汇编语言来编程。因为对于程序员来说,使用高级语言编程更加方便。

不幸的是,这些高级语言对于处理器来说过于复杂,无法直接解析。程序员需要将这些高级程序编译成处理器能够运行的二进制机器码。

这种机器码并不完全等同于汇编语言。如果你直接在文本编辑器中查看它,会发现它看起来非常难理解。处理器也不会直接运行汇编语言,处理器只运行机器码,那么,为什么汇编语言在逆向工程中如此重要呢?

为了理解汇编语言的用途,让我们快速回顾一下计算机发展历史,了解一下计算机是如何达到现在的状态的,以及所有事物是如何互相联系的。

1.1.1 位和字节

在计算机发展的早期,人们决定创建计算机并让它们执行简单的任务。计算机不会说我们人类的语言——毕竟,它们只是电子设备——因此我们需要一种电子通信方式。在底层,计算机是通过电信号运作的,这些信号是通过在两个电压水平之间进行切换(开和关)来形成的。

第一个问题是,我们需要一种方法来描述这些“开”和“关”,才能将它们用于通信、存储和简单的系统状态。既然有两种状态,那么使用二进制系统对这些值进行编码是非常自然的。每个二进制位可以是0或1。尽管每个位(bit)只能存储尽可能小的信息量,但将多个位串联在一起可以表示非常大的数字。例如,数字30 284 334 537只需要35位就可以表示出来,如下所示:

这个系统已经允许对比较大的数字进行编码,但现在我们面临一个新的问题:在内存(或磁带)中,一个数字在哪里结束,下一个数字从哪里开始?对于现代读者来说,这可能是一个奇怪的问题,但在计算机刚刚被设计出来的时候,这是一个严重的问题。最简单的解决方案是创建固定大小的位分组。计算机科学家从不想错过一个好的命名双关语,他们将这组二进制位称为字节。

那么,一个字节应该有多少位?对于现代人来说,这个问题的答案似乎是显而易见的,因为我们都知道一个字节是8位。但并非一开始就是这样的。

最初,不同的系统对其字节中的位数做出了不同的选择。我们今天知道的8位字节的前身是6位二十进制交换码(Binary Coded Decimal Interchange Code,BCDIC)格式,用于表示早期IBM计算机(如1959年的IBM 1620)的字母数字信息。在此之前,字节的长度通常为4位,更早的时候,一个字节代表大于1的任意位数。直到IBM于20世纪60年代在其大型计算机产品线System/360中引入8位扩充的二十进制交换码(Extended Binary Coded Decimal Interchange Code,EBCDIC),并具有8位字节的可寻址内存,字节才开始围绕8位进行标准化。这随后促使其他广泛使用的计算机系统(包括Intel 8080和Motorola 6800)采用了8位存储大小。

以下这段内容摘自1962年出版的Planning a Computer System[1]一书,列出了采用8位字节的三个主要原因:

1)其256个字符的总容量被认为足以满足绝大多数应用程序的需求。

2)在这种容量的限制下,一个字符由一个字节来表示,因此任何特定记录的长度不取决于该记录中字符的重合度。

3)8位字节在存储空间上相当经济。

一个8位字节只可以存储从00000000到11111111的256个不同的值中的一个。当然,这些值的解释取决于使用它的软件。例如,我们可以在这些字节中存储正数,以表示从0到255(含)的正数。我们还可以使用二进制补码方案来表示从-128到127(含)的有符号数字。

1.1.2 字符编码

当然,计算机并不仅仅使用字节来编码和处理整数。它们还经常存储和处理人类可读的字母和数字——称为字符。

早期的字符编码(如ASCII)已经确定使用每个字节的7位,但这只能提供有限的128个可能的字符。这允许对英语字母和数字以及一些符号字符和控制字符进行编码,但无法表示许多其他语言中使用的字母。EBCDIC标准使用8位字节,选择了一个完全不同的字符集,其代码页可以“交换”到不同的语言。但最终这种字符集过于烦琐和不灵活。

随着时间的推移,人们逐渐认识到需要一个真正通用的字符集来支持世界上所有现存的语言和特殊符号。这最终促成了1987年Unicode项目的建立。存在不同的Unicode编码,但在Web上使用的主要编码方案是UTF-8。ASCII字符集中的字符都被包含在了UTF-8中,而“扩展字符”可以分布在多个连续的字节中。

由于字符现在被编码为字节,因此我们可以用两个十六进制数字来表示字符。例如,字符A、R和M通常用图1.1所示的八位数(octet)进行编码。

图1.1 字符A、R和M以及它们的十六进制值

每个十六进制数字都可以用从0000到1111的4位模式进行编码,如图1.2所示。

图1.2 十六进制的ASCII值及其等效的8位二进制值

由于编码一个ASCII字符需要两个十六进制的数字,8位似乎是存储世界上大多数书面语言的文本的理想位数,对于无法仅用8位表示的字符,可以使用多个8位来存储。

使用这种模式,我们可以更容易地解释一长串位的含义。以下位模式编码了单词Arm:

1.1.3 机器码和汇编

与之前的机械计算器相比,计算机的一个独特的强大之处在于,它们也可以将逻辑编码为数据。这种代码也可以存储在内存或磁盘上,并根据需要进行处理或更改。例如,软件更新可以完全改变计算机的操作系统,而不需要购买一台新机器。

我们已经看到了数字和字符是如何编码的,但是逻辑如何编码呢?这就是处理器架构及其指令集发挥作用的地方。

如果要从头开始创建自己的计算机处理器,那么我们可以设计自己的指令编码,将二进制模式映射为处理器可以解释和响应的机器码,这实际上是创建我们自己的“机器语言”。由于机器码是为了“指示”电路执行“操作”,因此也被称为指令码,或者更常见的操作码(opcode)。

在实践中,大多数人使用现有的计算机处理器,因此使用处理器制造商定义的指令编码。在Arm处理器上,指令编码具有固定的大小,可以是32位或16位,具体取决于程序使用的指令集。处理器获取并解释每条指令,然后依次运行每条指令以执行程序的逻辑。每条指令都是一个二进制模式或指令编码,它遵循Arm架构定义的特定规则。

举例来说,假设我们正在建立一个小型的16位指令集,并定义每条指令的模样。我们的第一项任务是指定部分编码,即指定要运行的指令类型——称为操作码。例如,我们可以将指令的前7位设置为操作码,并指定加法和减法的操作码,如表1.1所示。

因此手动编写机器码是可能的,但过于烦琐。实际上,我们更希望用一些人类可读的“汇编语言”来编写汇编代码,并将这些代码转换为机器码的等效形式。为了做到这一点,我们还应该定义指令的简写形式,它们称为指令助记符,如表1.2所示。

表1.1 加法和减法的操作码

表1.2 加法和减法的助记符

当然,仅仅告诉处理器执行“加法”是不够的。我们还需要告诉它要将哪两个值相加以及如何处理结果。例如,如果我们编写一个执行a=b+c操作的程序,bc的值需要在指令开始执行前存储在某个地方,而且指令需要知道将结果a写到哪里。

在大多数处理器中,特别是在Arm处理器中,这些临时值通常存储在寄存器中,寄存器存储一小部分“工作”值。程序可以将数据从内存(或磁盘)中读入寄存器中,以便进行处理,并且可以在处理后将结果数据存放到长期存储器中。

寄存器的数量和命名规则取决于架构。随着软件变得越来越复杂,程序往往需要同时处理更多的数值。在寄存器中存储和操作这些值比直接在内存中进行操作要快,这意味着寄存器减少了程序需要访问内存的次数,并且提升了执行速度。

回到我们之前的例子,假设我们设计了一条16位的指令来执行一个操作,该操作将一个值加到一个寄存器中,并将结果写入另一个寄存器。由于我们用7位来完成操作(ADD/SUB),因此剩下的9位可以用于编码源寄存器(操作数寄存器)、目标寄存器和我们想要加或减的常量值。在这个例子中,我们将剩余的位数平均分配,并分配了表1.3所示的快捷方式和相应的机器码。

表1.3 手动分配机器码

我们可以编写一个小程序将语法ADD R1R0#2R1=R0+2)转换为相应的机器码模式,而不是手动生成这些机器码(见表1.4)。然后,将这个机器码模式交给我们的示例处理器。

表1.4 机器码编程

我们构建的位模式表示T32指令集中16位ADDSUB指令的一个指令编码。在图1.3中,你可以看到它的组成部分以及它们在指令编码中的顺序。

当然,这只是一个简化的例子。现代处理器提供了数百条可能的指令,这些指令通常具有更复杂的子编码。例如,Arm定义了加载寄存器指令(使用LDR助记符),该指令可以将一个32位的值从内存加载到一个寄存器中,如图1.4所示。

图1.3 16位Thumb编码的ADD和SUB立即数指令

在这条指令中,要加载的“地址”在寄存器2(R2)中指定,读取的值被写入寄存器3(R3)。

R2的两边使用括号的语法表示R2寄存器中的值将被解释为内存中的一个地址,而不是普通值。换句话说,我们不想将R2寄存器中的值复制到R3寄存器中,而是要获取R2寄存器给定地址处内存的内容,并将该值加载到R3寄存器中。程序引用内存位置的原因有很多,其中包括调用函数或将内存中的值加载到寄存器中。

图1.4 LDR指令从R2中的地址向寄存器R3加载一个值

这本质上是机器码和汇编代码之间的区别。汇编语言具有可读性较强的语法,可以显示如何解释每条编码指令。相比之下,机器码是实际由处理器处理的二进制数据,其编码由处理器设计者精确指定。

1.1.4 汇编

由于处理器只能理解机器码而不能理解汇编语言,因此我们需要一个程序将手写的汇编指令转换为它们的机器码等效形式。执行这个任务的程序被称为汇编器。

实际上,汇编器不仅能够理解指令,还能将单条指令转换为机器码,而且能够解释汇编器指令[2],汇编器指令可以指导汇编器执行其他任务,例如在数据和代码之间切换或汇编不同的指令集。因此,汇编语言和汇编器语言只是看待同一件事情的两种方式。汇编器指令和表达式的语法及含义取决于特定的汇编器。

这些指令和表达式是汇编程序中可用的快捷方式。然而,严格来说,它们并不属于汇编语言,而是汇编器应该如何操作的指示。

在不同的平台上有不同的汇编器,例如用于汇编Linux内核的GNU汇编器as,以及ARM工具链汇编器armasm和包含在Visual Studio中具有相同名称(armasm)的Microsoft汇编器。

举个例子,假设我们想要在名为myasm.s的文件中汇编以下两条16位指令:

在这个程序中,前三行是汇编器指令。这些指令告诉汇编器数据应该在哪里被汇编(在本例中,放在.text节),将代码的入口点的标签(在本例中,称为_start)定义为全局符号,最后指定它应该使用Thumb指令集(T32)进行编码。Thumb指令集(T32)是Arm架构的一部分,它允许指令的宽度为16位。

我们可以使用GNU汇编器as,在运行于Arm处理器上的Linux操作系统机器上编译这个程序:

汇编器读取汇编语言程序myasm.s并创建一个名为myasm.o的目标文件。这个文件包含4个字节的机器码,对应于我们的两条2字节的十六进制指令:

汇编器另一个特别有用的功能是标签,它引用内存中的特定地址,如分支目标、函数或全局变量的地址。

让我们以汇编程序为例:

这个程序首先给两个寄存器填充数值,然后跳转到标签mylabel执行ADD指令。在执行完ADD指令后,程序跳转到result标签,执行移动指令,然后跳转到_exit标签结束。汇编器将使用这些标签为链接器提供提示,链接器为它们分配相对的内存位置。图1.5说明了程序的流程。

图1.5 汇编程序示例的程序流程

标签不仅可以用来引用跳转指令,还可以用来获取内存位置的内容。例如,下面的汇编代码片段使用标签从内存位置获取内容或跳转到代码中的不同指令:

首先用ADR指令将变量myvalue的地址加载到寄存器R2中,并使用LDR指令将该地址的内容加载到寄存器R3中。然后程序跳转到标签mylabel所引用的指令,执行ADD指令,再跳转到标签result所引用的指令,如图1.6所示。

图1.6 ADR和LDR指令逻辑的说明

作为一个稍微有趣的例子,下面的汇编代码将Hello World!输出到控制台,然后退出。它使用一个标签来引用字符串hello,方法是通过ADR指令将标签mystring的相对地址放入寄存器R1中。

在支持Arm架构和指令集的处理器上汇编并链接此程序后,执行时会输出Hello

现代汇编器通常被整合到编译器工具链中,并且输出可以合并成更大的可执行程序的文件。因此,汇编程序通常不仅仅是将汇编指令直接转换为机器码,而是创建一个目标文件,其中包括汇编指令、符号信息和编译器链接程序的提示,最终负责创建在现代操作系统上运行的完整可执行文件。

交叉汇编器

如果在不同的处理器架构上运行我们的Arm程序,会怎样?在Intel x86-64处理器上执行myasm2程序将产生一个错误,它会告诉我们由于可执行格式的错误,二进制文件不能被执行。

我们不能在x64机器上运行Arm二进制文件,因为这两个平台上的指令编码方式不同。即使我们想在不同的架构上执行相同的操作,汇编语言和分配的机器码也会有很大的不同。假设你想在三种不同的处理器架构上执行一条将十进制数字1移到第一个寄存器的指令。尽管操作本身是一样的,但指令编码和汇编语言却取决于架构。以下列三种一般的架构类型为例:

●Armv8-A:64位指令集(AArch64)

●Armv8-A:32位指令集(AArch32)

●Intel x86-64指令集

不仅是语法不同,而且不同指令集之间相应的机器码字节也有很大的差异。这意味着,为Arm 32位指令集汇编的机器码字节在不同指令集的架构(如x64或A64)上具有完全不同的含义。

反过来也是如此。相同的字节序列在不同的处理器上可能会有显著不同的解释,例如:

●Armv8-A:64位指令集(AArch64)

●Armv8-A:32位指令集(AArch32)

换句话说,汇编程序需要使用我们想要运行这些汇编程序的架构的汇编语言编写,并且必须用支持这种指令集的汇编器进行汇编。

然而,可能令人感到意外的是,可以在不使用Arm机器的情况下创建Arm二进制文件。当然,汇编器本身需要了解Arm语法,但如果该汇编器是为x64编译的,则在x64机器上运行它将使你能够创建Arm二进制文件。这种汇编器称为交叉汇编器,允许你针对不同于当前正在使用的目标架构的架构进行代码汇编。

例如,你可以在x86-64的Ubuntu机器上下载一个AArch32的汇编器,然后从那里汇编代码。

使用Linux命令file,我们可以看到,我们创建了一个32位Arm可执行文件。