如果你正在翻阅本书,那么你可能已经听说过Arm汇编语言,并且知道理解它是分析在Arm上运行的二进制文件的关键。但这种语言是什么,为什么会有这种语言?毕竟,程序员通常使用C/C++等高级语言来编写代码,几乎没有人会直接用汇编语言来编程。因为对于程序员来说,使用高级语言编程更加方便。
不幸的是,这些高级语言对于处理器来说过于复杂,无法直接解析。程序员需要将这些高级程序编译成处理器能够运行的二进制机器码。
这种机器码并不完全等同于汇编语言。如果你直接在文本编辑器中查看它,会发现它看起来非常难理解。处理器也不会直接运行汇编语言,处理器只运行机器码,那么,为什么汇编语言在逆向工程中如此重要呢?
为了理解汇编语言的用途,让我们快速回顾一下计算机发展历史,了解一下计算机是如何达到现在的状态的,以及所有事物是如何互相联系的。
在计算机发展的早期,人们决定创建计算机并让它们执行简单的任务。计算机不会说我们人类的语言——毕竟,它们只是电子设备——因此我们需要一种电子通信方式。在底层,计算机是通过电信号运作的,这些信号是通过在两个电压水平之间进行切换(开和关)来形成的。
第一个问题是,我们需要一种方法来描述这些“开”和“关”,才能将它们用于通信、存储和简单的系统状态。既然有两种状态,那么使用二进制系统对这些值进行编码是非常自然的。每个二进制位可以是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 一书,列出了采用8位字节的三个主要原因:
1)其256个字符的总容量被认为足以满足绝大多数应用程序的需求。
2)在这种容量的限制下,一个字符由一个字节来表示,因此任何特定记录的长度不取决于该记录中字符的重合度。
3)8位字节在存储空间上相当经济。
一个8位字节只可以存储从00000000到11111111的256个不同的值中的一个。当然,这些值的解释取决于使用它的软件。例如,我们可以在这些字节中存储正数,以表示从0到255(含)的正数。我们还可以使用二进制补码方案来表示从-128到127(含)的有符号数字。
当然,计算机并不仅仅使用字节来编码和处理整数。它们还经常存储和处理人类可读的字母和数字——称为字符。
早期的字符编码(如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:
与之前的机械计算器相比,计算机的一个独特的强大之处在于,它们也可以将逻辑编码为数据。这种代码也可以存储在内存或磁盘上,并根据需要进行处理或更改。例如,软件更新可以完全改变计算机的操作系统,而不需要购买一台新机器。
我们已经看到了数字和字符是如何编码的,但是逻辑如何编码呢?这就是处理器架构及其指令集发挥作用的地方。
如果要从头开始创建自己的计算机处理器,那么我们可以设计自己的指令编码,将二进制模式映射为处理器可以解释和响应的机器码,这实际上是创建我们自己的“机器语言”。由于机器码是为了“指示”电路执行“操作”,因此也被称为指令码,或者更常见的操作码(opcode)。
在实践中,大多数人使用现有的计算机处理器,因此使用处理器制造商定义的指令编码。在Arm处理器上,指令编码具有固定的大小,可以是32位或16位,具体取决于程序使用的指令集。处理器获取并解释每条指令,然后依次运行每条指令以执行程序的逻辑。每条指令都是一个二进制模式或指令编码,它遵循Arm架构定义的特定规则。
举例来说,假设我们正在建立一个小型的16位指令集,并定义每条指令的模样。我们的第一项任务是指定部分编码,即指定要运行的指令类型——称为操作码。例如,我们可以将指令的前7位设置为操作码,并指定加法和减法的操作码,如表1.1所示。
因此手动编写机器码是可能的,但过于烦琐。实际上,我们更希望用一些人类可读的“汇编语言”来编写汇编代码,并将这些代码转换为机器码的等效形式。为了做到这一点,我们还应该定义指令的简写形式,它们称为指令助记符,如表1.2所示。
表1.1 加法和减法的操作码
表1.2 加法和减法的助记符
当然,仅仅告诉处理器执行“加法”是不够的。我们还需要告诉它要将哪两个值相加以及如何处理结果。例如,如果我们编写一个执行 a = b + c 操作的程序, b 和 c 的值需要在指令开始执行前存储在某个地方,而且指令需要知道将结果 a 写到哪里。
在大多数处理器中,特别是在Arm处理器中,这些临时值通常存储在寄存器中,寄存器存储一小部分“工作”值。程序可以将数据从内存(或磁盘)中读入寄存器中,以便进行处理,并且可以在处理后将结果数据存放到长期存储器中。
寄存器的数量和命名规则取决于架构。随着软件变得越来越复杂,程序往往需要同时处理更多的数值。在寄存器中存储和操作这些值比直接在内存中进行操作要快,这意味着寄存器减少了程序需要访问内存的次数,并且提升了执行速度。
回到我们之前的例子,假设我们设计了一条16位的指令来执行一个操作,该操作将一个值加到一个寄存器中,并将结果写入另一个寄存器。由于我们用7位来完成操作( ADD/SUB ),因此剩下的9位可以用于编码源寄存器(操作数寄存器)、目标寄存器和我们想要加或减的常量值。在这个例子中,我们将剩余的位数平均分配,并分配了表1.3所示的快捷方式和相应的机器码。
表1.3 手动分配机器码
我们可以编写一个小程序将语法 ADD R1 , R0 , #2 ( R1=R0+2 )转换为相应的机器码模式,而不是手动生成这些机器码(见表1.4)。然后,将这个机器码模式交给我们的示例处理器。
表1.4 机器码编程
我们构建的位模式表示T32指令集中16位 ADD 和 SUB 指令的一个指令编码。在图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加载一个值
这本质上是机器码和汇编代码之间的区别。汇编语言具有可读性较强的语法,可以显示如何解释每条编码指令。相比之下,机器码是实际由处理器处理的二进制数据,其编码由处理器设计者精确指定。
由于处理器只能理解机器码而不能理解汇编语言,因此我们需要一个程序将手写的汇编指令转换为它们的机器码等效形式。执行这个任务的程序被称为汇编器。
实际上,汇编器不仅能够理解指令,还能将单条指令转换为机器码,而且能够解释汇编器指令 ,汇编器指令可以指导汇编器执行其他任务,例如在数据和代码之间切换或汇编不同的指令集。因此,汇编语言和汇编器语言只是看待同一件事情的两种方式。汇编器指令和表达式的语法及含义取决于特定的汇编器。
这些指令和表达式是汇编程序中可用的快捷方式。然而,严格来说,它们并不属于汇编语言,而是汇编器应该如何操作的指示。
在不同的平台上有不同的汇编器,例如用于汇编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可执行文件。
那么,为什么汇编语言没有成为编写软件的主流编程语言?一个主要原因是汇编语言不具有可移植性。想象一下,为了支持每种处理器架构,每次都必须重新编写整个应用程序代码库!这是非常大的工作量。取而代之的是,新的语言已经发展了起来,能将这些特定于处理器的细节抽象出来,使同一个程序可以很容易地在不同的架构下被编译。这些语言通常被称为高级语言,与更贴近特定计算机硬件和架构的低级汇编语言形成对比。
这里的“高级语言”一词本质上是相对的。C和C++刚开始被认为是高级语言,而汇编语言被认为是低级语言。由于出现了更新的、更抽象的语言,如Visual Basic和Python,C/C++如今经常被视为低级语言。归根结底,这取决于你所站的角度和所问的对象。
与汇编语言一样,处理器不能直接理解高级源代码。程序员需要使用编译器将编写的高级程序转换成机器码。和以前一样,我们需要指定二进制文件将在哪种架构上运行,并且使用交叉编译器在非Arm系统上创建Arm架构的二进制文件。
编译器的输出是一个可在特定的操作系统上运行的可执行文件,而且通常输出给用户的是二进制可执行文件,而不是程序的源代码。因此往往当我们想分析一个程序时,我们所拥有的只是编译后的可执行文件。
不幸的是,对于逆向工程师来说,一般情况下,不可能逆转编译过程返回到原始源代码。编译器不仅是非常复杂的程序,在原始源代码和生成的二进制文件之间有许多层的迭代和抽象,而且其中许多步骤丢弃了方便程序员推理程序的人类可读信息。
在没有要分析的软件源代码的情况下,根据要求的详细程度,我们有两种分析方式:反编译或反汇编可执行文件。
反汇编二进制文件的过程包括将二进制文件运行的汇编指令从其机器码格式重构为人类可读的汇编语言。反汇编最常见的用例包括恶意软件分析、编译器的性能和输出准确性验证、漏洞分析,以及针对闭源软件缺陷进行漏洞利用或概念验证开发。
在这些应用中,漏洞利用开发可能是最需要对实际汇编代码进行分析的。虽然漏洞发现通常可以通过模糊处理等技术来完成,但从检测到的崩溃代码构建漏洞利用或发现为什么某些代码区域无法被模糊测试覆盖,通常需要扎实的汇编知识。
在这种情况下,通过阅读汇编代码对漏洞的确切条件实现精细的掌握是至关重要的。编译器分配变量和数据结构的确切方式对于开发漏洞利用至关重要,因此深入了解汇编知识是必需的。通常一个看似“无法利用”的漏洞,实际上,只要再投入一点创造力和辛勤工作来真正理解易受攻击的功能的内部机制,便可变得可利用。
反汇编可执行文件可以通过多种方式进行,我们将在本书的第二部分更详细地研究这个问题。但是,目前快速查看可执行文件的反汇编输出的最简单的工具之一是Linux工具 objdump 。
让我们编译并反汇编以下 write() 程序:
我们可以用GCC编译这段代码并指定 -c 选项。这个选项告诉GCC在不调用链接进程的情况下创建目标文件,因此我们可以只对编译的代码运行 objdump ,而不看周围所有目标文件(如C运行时)的反汇编。 main 函数的反汇编输出如下:
虽然像 objdump 这样的Linux实用工具对快速反汇编小程序很有用,但较大的程序需要更方便的解决方案。如今存在各种反汇编器可以使逆向工程更高效,包括免费的开源工具(如Ghidra )和昂贵的解决方案(如IDA Pro )等。这些将在本书的第二部分中进行详细讨论。
逆向工程的一个较新的创新是使用反编译器。反编译器比反汇编器更进一步。反汇编器只是显示程序的人类可读的汇编代码,而反编译器则试图从编译的二进制文件中重新生成等价的C/C++代码。
反编译器的一个优点是通过生成伪代码显著减少和简化反汇编的输出。当快速浏览一个函数以从宏观层面上了解程序正在执行什么操作时,这可以使阅读更加容易。
当然,反编译的缺点是在这个过程中可能会丢失重要的细节。此外,由于编译器在从源代码到可执行文件的转换过程中本身是有损失的,因此反编译器不能完全重建原始源代码。符号名称、局部变量、注释以及大部分程序结构在编译过程中会被破坏。同样,如果存储位置被积极优化的编译器重复使用,那么试图自动命名或重新标记局部变量和参数的做法也会产生误导。
让我们看一个C函数的例子,使用GCC编译它,然后用IDA Pro和Ghidra的反编译器进行反编译,以显示实际的情况。
图1.7显示了Linux源代码库中 ihex2fw.c 文件中一个名为 file_record 的函数。
在Armv8-A架构上编译C文件(没有任何特定的编译器选项)并将可执行文件加载到IDA Pro 7.6中后,图1.8显示了由反编译器生成的 file_record 函数的伪代码。
图1.9显示了Ghidra 10.0.4对同一函数的反编译输出。
在这两种情况下,如果我们仔细观察,便可以看到原始代码的影子,但是这些代码远不如原始代码易读和直观。换句话说,虽然在某些情况下反编译器可以为我们提供程序的高层次概述,但它绝不是万无一失的,也无法替代深入研究给定程序的汇编代码。
图1.7 ihex2fw.c源文件中file_record函数的源代码
图1.8 IDA Pro 7.6对编译后的file_record函数的反编译输出
图1.9 Ghidra 10.0.4对编译后的file_record函数的反编译输出
话虽如此,反编译器在不断发展,并且越来越擅长重构源代码,特别是对于简单的函数。虽然,使用你想在更高层次上进行逆向工程的函数的反编译器输出是一个有用的辅助,但是当你想要更深入地了解正在发生的事情时,请不要忘记查看反汇编输出。