第一部分 简介
第1章 反汇编简介
这是一本专门介绍 Ghidra 的书,虽然它以 Ghidra 为中心,但我并不希望读者将它作为 Ghidra的用户手册。相反,本书旨在将Ghidra作为探讨逆向工程技术的工具,在分析各种软件(包括存在漏洞的软件和恶意软件等)时,这些技术非常有用。在适当的时候,我会在Ghidra中演示详细步骤,以期望对你手头的特定任务有所帮助。因此,我将简略地介绍Ghidra的功能,包括最初分析文件时需要执行的基本任务,最后讨论Ghidra的高级用法和自定义功能(用来解决更具挑战性的逆向工程问题)。本书所介绍的Ghidra功能并非全部,但都极其有用,这将使Ghidra成为你工具箱中最强大的工具。
在详细介绍Ghidra之前,我将介绍反汇编的一些基础知识,以及其他一些可用于逆向工程的工具。虽然这些工具都不如Ghidra全面,但每个工具都具备Ghidra的一部分功能,对我们理解Ghidra有所帮助。本章的剩余部分将从较高的视角介绍反汇编工程。
1.1 反汇编理论
学过编程的人都知道,编程语言可以分为好几代,下面为那些上课不认真的读者简要总结一下。
·第一代语言:最低级的语言,通常由0和1或者某些简写编码(如十六进制)组成,只有精通二进制的人才能读懂它们。在这个层级上,数据和代码看起来都差不多,很难将它们区分开来。第一代语言也称为机器语言,有时称为字节码,由机器语言构成的程序通常称为二进制程序。
·第二代语言:也称为汇编语言,它只是一种脱离了机器语言的表查找方式。通常,汇编语言会将具体的位模式或操作码,与短小且易于记忆的字符序列(即助记符)对应起来。这些助记符可以帮助程序员记住与它们有关的指令。汇编器是程序员用来将汇编语言程序转换成能够执行的机器语言的工具。除指令助记符外,一个完整的汇编语言通常还包括针对汇编器的指令,用于帮助决定最终二进制文件中代码和数据的内存布局。
·第三代语言:通过引入关键字和结构(用作程序的构建块),使其更接近自然语言。虽然使用第三代语言编写的程序可能因使用了特定操作系统的独特功能而对该系统产生依赖,但通常它们应该是平台无关的。常见的第三代语言包括 FORTRAN、C 和 Java。程序员通常使用编译器将程序转换成汇编语言,或者直接转换成机器语言(或某种等价形式,如字节码)。
·第四代语言:这些语言与本书无关,在此不做讨论。
1.2 何为反汇编
在传统的软件开发模型中,编译器、汇编器和链接器被单独或组合使用,以构建可执行程序。为了回溯编译过程(或对程序进行逆向工程),我们使用各种工具来撤销汇编和编译过程,这些工具就称为反汇编器和反编译器。其中,反汇编器用于撤销汇编过程,以机器语言为输入,以汇编语言为输出;反编译器则以汇编语言甚至机器语言为输入,以高级语言为输出。
在竞争激烈的软件市场中,“源代码恢复”具有相当大的吸引力。因此,在计算机科学中,开发适用的反编译器是一个活跃的研究领域。下面列举一些导致反编译如此困难的原因。
·编译过程会造成损失。机器语言中没有变量或函数名,变量类型信息只有通过数据的用途(而不是显式的类型声明)来确定。例如一个32位的数据,你需要做一些分析工作,才能确定它所表示的到底是一个整数、一个32位浮点数还是一个32位指针。
·编译是多对多操作。这意味着源程序可以通过多种不同的方式转换成汇编语言,而机器语言也可以通过多种不同的方式转换回源程序。因此,编译一个文件后立即反编译,所得到的源文件可能与输入时截然不同。
·反编译器依赖于语言和库。用专门生成C代码的反编译器处理由Delphi编译器生成的二进制文件,可能会得到非常奇怪的结果。同样,用对 Windows API 一无所知的反编译器处理Windows二进制文件,也不会得到任何有用的结果。
·要想准确地反编译一个二进制文件,需要近乎完美的反汇编能力。反汇编阶段的任何错误或遗漏都会影响反编译代码。通过查询处理器参考手册可以验证反汇编代码的正确性,但尚无规范的参考手册可用于验证反编译输出的正确性。
Ghidra内置的反编译器将在第19章进行介绍。
1.3 为何反汇编
通常,使用反汇编工具是为了在没有源代码的情况下加深对目标程序的理解,下面列举几种常见的情形。
·分析恶意软件。
·分析闭源软件的漏洞。
·分析闭源软件的互操作性。
·分析编译器生成的代码,以验证编译器的性能和准确性。
·在调试时显示程序指令。
1.3.1 恶意软件分析
通常,除非是基于脚本的恶意软件,恶意软件的作者很少会提供他们“作品”的源代码。在缺少源代码的情况下,想要准确地理解恶意软件的行为,你的选择非常有限,通常可使用动态分析和静态分析两种主要技术。动态分析(dynamic analysis)是在严格控制的环境(沙盒)中执行恶意软件,同时使用系统检测工具来记录其行为。静态分析(static analysis)则试图通过查看程序的代码来理解其行为,对于恶意软件,就是查看反汇编或者反编译后得到的代码清单。
1.3.2 漏洞分析
为了简单起见,我们将整个安全审计过程分为三个步骤:漏洞发现、漏洞分析和漏洞利用开发。这些步骤无论是否拥有源代码都适用,但如果只有二进制文件,则工作量和难度都会大大增加。该过程的第一步,是发现程序中潜在的可利用点,通常可以使用模糊测试[1]等动态分析技术来完成,也可以通过静态分析来实现(通常需要付出更大的努力)。一旦发现漏洞,通常需要进一步分析,以确定该漏洞是否可被利用,以及达成利用的条件是什么。
识别那些对攻击者有利的可操纵的变量是漏洞发现中一个重要的早期步骤。反汇编清单提供了编译器如何分配程序变量的详细信息。例如,源代码中声明的一个70字节的字符数组,在由编译器分配时,会扩大到80字节——知道这一点会很有用。另外,要想理解编译器如何对全局或函数内声明的所有变量进行排序,查看反汇编清单是唯一的办法。在开发漏洞利用代码时,确定这些变量之间的空间关系非常重要。最终,通过结合使用反汇编器和调试器,就可以完成漏洞利用的开发。
1.3.3 软件互操作性
如果一个程序仅以二进制的形式发布,那么竞争对手就很难创建与之交互的软件,或者为其提供插件。一个常见的例子是针对某个仅有一种平台支持的硬件发布的驱动程序代码。如果制造商暂时不支持,或者更糟糕地,拒绝支持在其他平台上使用他们的硬件,那么,为了开发支持该硬件的软件驱动程序,可能需要做大量的逆向工程工作。在这种情况下,静态代码分析几乎是唯一的手段,并且为了理解嵌入式固件,还需要分析那些超出软件驱动程序以外的代码。
1.3.4 编译器验证
由于编译器(或汇编器)的作用是生成机器语言,因此通常需要一个优秀的反汇编工具来验证编译器是否按照设计规范来工作。分析人员还可以从中寻找优化编译器输出的机会,从安全的角度来看,还可以查明编译器本身是否已被攻破以致在生成的代码中插入后门。
1.3.5 显示调试信息
在调试器中生成代码清单,可能是反汇编器最常见的用途之一。遗憾的是,调试器中内置的反汇编器往往过于简单,通常不能批量反汇编,在无法确定函数边界时,可能还会出现错误。因此,在调试过程中,最好是将调试器与优秀的反汇编器结合使用,以提供更好的环境和上下文信息。
1.4 如何反汇编
清楚了反汇编的目的,接下来介绍如何反汇编。以反汇编器所面临的一个典型的艰巨任务为例:将一个100KB文件中的代码和数据进行区分,并将代码转换成汇编语言显示给用户。在整个过程中,不能遗漏任何信息。对于该任务,我们还可以附加许多特殊要求,进一步增加反汇编器工作的难度,例如要求反汇编器做函数定位、识别跳转并确定局部变量等。
为了满足这些需求,反汇编器在处理目标文件时,需要从多种算法中进行选择。所选择的算法及其实现的质量,将直接影响生成的反汇编代码的质量。
在本节中,我们将讨论当今用于反汇编机器代码的两种基本算法。在介绍这些算法的同时,我们还将指出它们的缺点,以便于你对反汇编器失效的情形有所防备。理解了反汇编器的局限性,就可以通过手动干预来提高反汇编输出的整体质量了。
1.4.1 基础反汇编算法
首先,我们通过一种以机器语言为输入、以汇编语言为输出的简单算法来了解自动反汇编过程中的挑战、假设和折衷方案。
(1)识别要反汇编的代码区域。这并不像看起来那么简单。指令通常与数据混杂在一起,因此将两者进行区分非常重要。以最常见的情形——反汇编可执行文件为例,该文件必须符合可执行文件的某种通用格式,例如Windows所使用的可移植可执行(Portable Executable,PE)格式或许多类UNIX系统中常见的可执行和链接格式(Executable and Linkable Format,ELF)。这些格式通常包含一种机制(通常为层级文件头的形式),用于定位文件中包含代码和该代码入口点[2]的部分。
(2)得到指令的起使地址后,下一步就是读取该地址(或文件偏移)所包含的值,并执行表查找,将二进制操作码与它的汇编语言助记符进行匹配。根据被反汇编的指令集的复杂程度,这个过程可能很简单,也可能涉及其他一些操作,例如查明任何可能修改指令行为的前缀,以及确定指令所需的操作数。对于指令长度可变的指令集,例如 Intel x86,可能需要检索额外的指令字节才能完全反汇编一条指令。
(3)获取指令并解码所有必需的操作数后,需要对其等效的汇编语言进行格式化,作为反汇编代码列表的一部分。在输出时,有多种汇编语言格式可供选择,例如 x86 汇编语言的两种主要格式为Intel格式和AT&T格式。
(4)输出一条指令后,继续反汇编下一条指令,并重复上述过程,直到反汇编完文件中的所有指令。
x86汇编语法:AT&T和Intel
汇编语言的源代码主要采用两种语法:AT&T语法和Intel语法。尽管都属于第二代语言,但它们在变量、常量、寄存器访问、段和指令大小重写、间接寻址和偏移量等方面都存在较大差异。AT&T语法以%作为所有寄存器名称的前缀,以$作为立即操作数的前缀,并使用源操作数在左、目的操作数在右的排列顺序。例如,EAX寄存器加4的指令表示为add $0x4,%eax。GNU汇编器(as)和许多其他GNU工具(包括gcc和gdb)默认都使用AT&T语法。
与AT&T语法的不同点在于,Intel语法的寄存器和立即数都不需要前缀,并且使用源操作数在右、目的操作数在左的排列顺序——与AT&T语法正好相反。上述加法指令表示为add eax,0x4。使用Intel语法的反汇编器包括Microsoft汇编器(MASM)和Netwide汇编器(NASM)。
有大量算法可用于确定从何处开始反汇编,如何选择下一条反汇编的指令,如何区分代码与数据,以及如何确定何时完成对最后一条指令的反汇编。线性扫描(linear sweep)和递归下降(recursive descent)是两种最主要的反汇编算法。
1.4.2 线性扫描反汇编
线性扫描反汇编算法采用一种非常简单的方法来定位要反汇编的指令:一条指令的结束,就是另一条指令的开始。因此,最困难的问题就是指令从哪里开始,何时结束。通常的解决办法是,假设程序中标记为代码(通常由程序文件的头部指定)的节包含的所有内容都是机器语言指令,反汇编从代码段的第一个字节开始,以线性方式移动,逐条反汇编每条指令,直到完成整个代码段。这种算法不会通过识别分支等非线性指令来了解程序的控制流。
在反汇编过程中,可以维护一个指针来标记当前正在反汇编的指令的起始地址,通过计算每条指令的长度,从而确定下一条将要反汇编的指令的地址。该方法对那些由固定长度指令构成的指令集(如MIPS)会更加容易,因为定位后续指令非常简单。
线性扫描算法的主要优点是,它可以完全覆盖程序的代码段;一个主要的缺点是,它无法解决代码中可能混有数据的问题。清单 1-1 中展示了这个问题,清单内容是使用线性反汇编器反汇编某函数的输出。
清单1-1:线性扫描反汇编
该函数包含一个switch语句,编译器选择使用跳转表来解析case标签,并且将跳转表嵌入函数本身。❶处的 jmp 语句引用了❷处的地址表,然而反汇编器把地址表误认为是一系列指令,并且错误地生成了对应的汇编语言形式。
如果将跳转表❷按照连续4字节分组,并作为小端值[3]分析,则每个组都代表一个指向临近地址的指针,这些地址就是跳转的目的地址(004012e0、0040128b、00401290……)。因此,❷处的loopne指令并不是真实的指令;相反,这表明线性扫描算法无法正确地区分嵌入的数据和代码。
GNU调试器(gdb)、微软的WinDbg调试器和objdump工具的反汇编引擎均采用的是线性扫描算法。
1.4.3 递归下降反汇编
递归下降反汇编算法采用了另一种方法来定位指令:它强调控制流的概念,根据一条指令是否被另一条指令引用来决定是否对其进行反汇编。为便于理解递归下降,我们根据指令对指令指针的影响来对它们进行分类。
1.顺序流指令
顺序流指令将执行权传递给紧随其后的下一条指令。顺序流指令的例子包括:简单的算术指令,如add;寄存器到内存的传输指令,如mov;栈操作指令,如push和pop。这些指令的反汇编过程以线性扫描的方式进行。
2.条件分支指令
条件分支指令(如x86的jnz)提供了两种可能的执行路径。如果条件为真,则执行分支,并且修改指令指针,使其指向分支的目标。但是,如果条件为假,则继续以线性的方式执行,此时可以使用线性扫描算法来反汇编下一条指令。由于通常无法在静态上下文中确定条件测试的结果,因此递归下降算法会将两条路径都进行反汇编。同时,它将分支目标的地址添加到稍后才进行反汇编的地址列表中,以推迟分支目标指令的反汇编过程。
3.无条件分支指令
无条件分支不遵循线性流模型,因此递归下降算法对它的处理方式有所不同。与顺序流指令一样,执行权只能传递给一条指令,但那条指令不必紧跟在分支指令后面。事实上,如清单1-1所示,根本没有要求规定在无条件分支后必须紧跟一条指令。因此,也就没有理由反汇编紧跟在无条件分支后面的字节。
递归下降反汇编器将尝试确定无条件跳转的目标,并在目标地址处继续反汇编过程。遗憾的是,某些无条件分支可能会导致递归下降反汇编器出错。如果跳转指令的目标取决于一个运行时的值,可能就无法通过静态分析来确定跳转目标,例如x86指令jmp rax。只有当程序实际运行时,rax寄存器才会包含一个值。由于寄存器在静态分析期间不包含任何值,因此无法确定跳转指令的目标,也就无法确定从什么地方继续反汇编过程。
4.函数调用指令
函数调用指令的运行方式与无条件跳转指令非常相似(包括反汇编器也无法确定如call rax等指令的目标),唯一的不同在于,所调用的函数一旦执行完成,执行权将会返还给紧跟在调用指令后面的指令。在这方面,它们与条件分支指令类似,都生成了两条执行路径。调用指令的目标地址被添加到推迟进行反汇编的地址列表中,紧跟在调用指令之后的指令则以线性扫描的方式进行反汇编。
如果程序从被调用函数返回时出现异常,则递归下降可能会失败。例如,函数中的代码可能会有意篡改函数的返回地址,从而在函数完成时将控制权返回到一个与反汇编器预期不同的位置。一个简单例子如下所示,函数badfunc在返回调用者之前,给返回地址加了1。
结果,在调用badfunc之后,控制权实际上并没有返回给❶处的add指令。此时的反汇编结果应当如下所示:
以上代码更清楚地展示了程序的真实执行流程,函数 badfunc实际上返回到❶处的 mov指令。值得注意的是,线性扫描反汇编器可能也无法正确反汇编这段代码,虽然原因有所不同。
5.返回指令
函数返回指令(如x86的ret)不会提供下一条要执行指令的信息,此时递归下降算法已经访问了当前的所有路径。如果程序实际上正在运行,则可以从运行时栈的顶部获取一个地址,然后从该地址继续执行。但是,反汇编器并不能访问运行时栈,于是反汇编过程就此打住,转而处理前面搁置在一旁的延迟反汇编地址列表。反汇编器从列表中取出一个地址,并从这个地址开始继续反汇编过程。递归下降反汇编算法也因此得名。
递归下降算法的一个主要优点在于,它具有区分代码和数据的强大能力。作为一种基于控制流的算法,它很少会将数据值错误地反汇编为代码。递归下降算法的主要缺点是无法处理间接代码路径,如利用指针表来查找目标地址的跳转或调用。然而,通过添加一些启发式方法来识别代码指针,递归下降反汇编器能够获得非常完整的代码覆盖,并清楚地区分代码和数据。使用Ghidra递归下降反汇编器处理前面清单1-1中的switch语句,得到了清单1-2。
清单1-2:递归下降反汇编
可以看到,二进制文件的这一部分已被识别为switch语句,并进行了相应的格式化。了解递归下降过程有助于我们识别Ghidra无法进行最佳反汇编的情形,并制定策略来改进Ghidra的输出结果。
1.5 小结
在使用反汇编器时,虽然没有必要去深入研究反汇编算法,但适当了解会很有帮助。在进行逆向工程时,选一个得心应手的好工具至关重要。在 Ghidra 的众多优点中,其中一个重要的优点是:作为一个交互式反汇编器,它为你提供了大量机会来指导和推翻它的决定,最终的结果将是准确而彻底的反汇编。
在下一章中,我们将介绍一系列可在各种逆向工程情形下使用的现有工具。尽管它们与 Ghidra没有直接关系,但其中很多工具都影响了Ghidra,而且也有助于我们理解在Ghidra用户界面上显示的大量信息。
[1] 模糊测试是一种漏洞发现技术,它为程序生成大量不常见的输入,希望其中一个输入会在程序中造成可被检测、分析,最终可被利用的错误。
[2] 程序入口点是一个指令地址,程序加载到内存后,操作系统会将控制权交给该地址上的指令。
[3] x86是一个小端序的体系结构,这意味着多字节数据值的最低有效字节最先被存储,位于较低的内存地址处。大端序则相反,数据值的最高有效字节被存储在较低的内存地址处。处理器通常可分为大端或者小端,有时也可以两种皆有。