Ghidra-之书-全-

Ghidra 之书(全)

原文:zh.annas-archive.org/md5/52d75f99cfbb63cc4bb1c8af2d7311f6

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Image

我们写这本书的目标是提供一个资源,介绍 Ghidra 给当前和未来的逆向工程师。在熟练的逆向工程师手中,Ghidra 可以简化分析过程,并允许用户定制和扩展其功能,以满足个人需求并改善工作流程。Ghidra 对新手逆向工程师也非常友好,尤其是它内置的反编译器,可以帮助他们更清楚地理解高级语言与反汇编清单之间的关系,尤其是在他们开始探索二进制分析的世界时。

编写关于 Ghidra 的书籍是一个具有挑战性的任务。Ghidra 是一个复杂的开源逆向工程工具套件,持续不断地在发展中。我们的文字描述的是一个不断变化的目标,因为 Ghidra 社区在不断改进和扩展其功能。和许多新的开源项目一样,Ghidra 以一系列快速演化的版本开始了它的公开历程。在撰写本书时,主要目标之一是确保随着 Ghidra 的发展,本书的内容能继续为读者提供广泛而深入的知识基础,以便他们能够理解并有效利用当前和未来的 Ghidra 版本,来应对他们的逆向工程挑战。我们尽可能使本书保持与版本无关。幸运的是,Ghidra 的新版本有详细的文档说明,列出了变更内容,提供了版本特定的指导,以防你在使用过程中遇到与书中内容的差异。

关于本书

本书是关于 Ghidra 的第一本全面书籍。它旨在成为一本全方位的逆向工程资源,涵盖了 Ghidra 的各个方面。它提供了介绍性内容,帮助新手进入逆向工程领域,提供了高级内容,扩展了经验丰富的逆向工程师的世界观,并提供了例子,供新手和资深 Ghidra 开发者一起继续扩展 Ghidra 的强大功能,并成为 Ghidra 社区的贡献者。

谁应该阅读本书?

本书面向有志和经验丰富的软件逆向工程师。如果你没有逆向工程经验,也没关系,因为前几章提供了必要的背景材料,介绍了逆向工程,并使你能够使用 Ghidra 探索和分析二进制文件。经验丰富的逆向工程师如果想将 Ghidra 添加到他们的工具包中,可能会选择快速浏览前两部分,以便对 Ghidra 有一个基本的了解,然后跳到他们感兴趣的具体章节。经验丰富的 Ghidra 用户和开发者可能会选择集中精力阅读后面的章节,以便他们能够创建新的 Ghidra 扩展,并运用他们的经验和知识,为 Ghidra 项目贡献新内容。

本书内容是什么?

本书分为五部分。第一部分介绍反汇编、逆向工程以及 Ghidra 项目。第二部分涵盖基本的 Ghidra 使用。第三部分展示了如何定制和自动化 Ghidra,使其更好地为你服务。第四部分深入解释了 Ghidra 模块的具体类型和相关概念。第五部分展示了 Ghidra 在逆向工程师可能遇到的实际应用场景中的应用。

第一部分:简介

第一章:反汇编简介

本章是介绍性章节,将带你了解反汇编的理论与实践,并讨论两种常见反汇编算法的优缺点。

第二章:反汇编与逆向工具

本章讨论了用于逆向工程和反汇编的主要工具类别。

第三章:认识 Ghidra

在这里,你将认识 Ghidra,了解它的起源,以及如何获得并开始使用这个免费的开源工具套件。

第二部分:基本的 Ghidra 使用

第四章:开始使用 Ghidra

本章将开始你的 Ghidra 之旅。你将首次看到 Ghidra 的实际操作,创建一个项目,分析文件,并开始了解 Ghidra 的图形用户界面(GUI)。

第五章:Ghidra 数据展示

在这里,你将首次接触到 CodeBrowser,这是 Ghidra 用于文件分析的主要工具。你还将探索 CodeBrowser 的主要显示窗口。

第六章:理解 Ghidra 反汇编

本章探讨了理解和导航 Ghidra 反汇编中基本概念的内容。

第七章:反汇编操作

本章中,你将学习如何补充 Ghidra 的分析并操作 Ghidra 的反汇编作为自己分析过程的一部分。

第八章:数据类型和数据结构

在本章中,你将学习如何操作和定义在编译程序中发现的简单和复杂数据结构。

第九章:交叉引用

本章详细介绍了交叉引用,如何支持图形化显示,以及它们在理解程序行为中的关键作用。

第十章:图形

本章介绍了 Ghidra 的图形功能以及如何将图形作为二进制分析工具使用。

第三部分:让 Ghidra 为你服务

第十一章:协作式 SRE

本章介绍了 Ghidra 中的一个独特功能——将 Ghidra 作为协作工具使用。你将学习如何配置 Ghidra 服务器,并与其他分析员共享项目。

第十二章:定制 Ghidra

在这里,你将开始看到如何通过配置项目和工具来定制 Ghidra,以支持你个人的分析工作流程。

第十三章:扩展 Ghidra 的世界观

本章教你如何生成和应用库签名及其他专用内容,以便 Ghidra 识别新的二进制结构。

第十四章:基础 Ghidra 脚本

在本章中,你将接触到 Ghidra 的基本脚本能力,包括使用 Ghidra 内联编辑器编写 Python 和 Java 脚本。

第十五章:Eclipse 与 GhidraDev

本章将通过将 Eclipse 集成到 Ghidra 中,提升你的 Ghidra 脚本水平,并探索这一组合提供的强大脚本功能,包括构建新分析器的实际示例。

第十六章:Ghidra 无头模式

你将学习如何在无头模式下使用 Ghidra,在这种模式下不需要图形界面。你会迅速理解这种模式在常见的大规模重复任务中的优势。

第 IV 部分:深入探讨

第十七章:Ghidra 加载器

在本章中,你将深入了解 Ghidra 如何导入和加载文件。你将有机会构建新的加载器,以处理之前无法识别的文件类型。

第十八章:Ghidra 处理器

本章介绍了 Ghidra 的 SLEIGH 语言,用于定义处理器架构。你将探讨如何向 Ghidra 添加新的处理器和指令。

第十九章:Ghidra 反编译器

本章将深入探讨 Ghidra 最受欢迎的功能之一:Ghidra 反编译器。你将了解它在幕后是如何工作的,以及它如何为你的分析过程提供帮助。

第二十章:编译器变种

本章帮助你理解使用不同编译器和面向不同平台编译的代码可能出现的变化。

第 V 部分:实际应用

第二十一章:混淆代码分析

你将学习如何使用 Ghidra 分析静态上下文中的混淆代码,而不需要执行代码。

第二十二章:二进制修补

本章教你一些方法,使用 Ghidra 在分析过程中修补二进制文件,包括在 Ghidra 内部修补以及创建新的修补版本的原始二进制文件。

第二十三章: 二进制差异与版本跟踪

本章提供了 Ghidra 功能的概述,介绍了如何识别两个二进制文件之间的差异,以及 Ghidra 的高级版本跟踪功能的简要介绍。

附录: IDA 用户的 Ghidra

如果你是经验丰富的 IDA 用户,本附录将为你提供将 IDA 术语和用法映射到 Ghidra 中类似功能的技巧和窍门。

注意

访问配套网站nostarch.com/GhidraBook/ ghidrabook.com/以访问本书中的代码列表。

第一章:**第一部分

引言**

第二章:反汇编简介**

Image

你可能会好奇一本专门介绍 Ghidra 的书会是什么样子。虽然这本书显然是以 Ghidra 为中心,但并不打算让它显得像是Ghidra 用户手册。相反,我们打算将 Ghidra 作为工具,来讨论逆向工程技巧,这些技巧将帮助你分析各种软件,从易受攻击的应用程序到恶意软件。当适当时,我们将提供在 Ghidra 中执行特定任务的详细步骤。因此,我们将通过 Ghidra 的功能进行一个相当曲折的探索,从你在初步检查文件时想要执行的基本任务开始,直到更具挑战性的逆向工程问题时,如何使用和定制 Ghidra。我们不会尝试覆盖 Ghidra 的所有功能。然而,我们会涵盖那些在应对你的逆向工程挑战时最有用的功能。这本书将帮助你将 Ghidra 打造成你工具库中最强大的武器。

在深入探讨任何 Ghidra 的细节之前,我们将介绍一些反汇编过程的基本概念,并回顾其他可用于逆向工程已编译代码的工具。虽然这些工具可能无法匹配 Ghidra 的全部功能,但每个工具都涵盖了 Ghidra 功能的特定子集,并且提供了对某些 Ghidra 特性的有价值的见解。本章的其余部分将专注于从高层次理解反汇编过程。

反汇编理论

任何曾花时间研究编程语言的人,可能已经学过各种语言的世代,但这里为那些可能还不熟悉的读者做了简要总结:

第一代语言 这些是最低级的语言,通常由一和零组成,或者是像十六进制这样的简写形式,仅能被二进制高手读取。在这一层级中,区分数据和指令非常困难,因为所有内容看起来都相同。第一代语言也可以称为机器语言,在某些情况下也称为字节码,而机器语言程序通常被称为二进制文件

第二代语言 也叫汇编语言,第二代语言与机器语言仅一步之遥,通常将特定的比特模式或操作码(opcodes)映射到简短而易记的字符序列,称为助记符。这些助记符帮助程序员记住它们所对应的指令。汇编器是程序员用来将他们的汇编语言程序翻译成适合执行的机器语言的工具。除了指令助记符外,完整的汇编语言通常还包括一些指令,用于告诉汇编器代码和数据在最终二进制文件中的内存布局。

第三代语言 这些语言通过引入关键词和结构,使程序员可以使用它们作为程序的构建模块,从而进一步接近自然语言的表达能力。第三代语言通常是平台无关的,尽管使用它们编写的程序可能因使用特定操作系统的特性而与平台相关。常见的第三代语言包括 FORTRAN、C 和 Java。程序员通常使用编译器将其程序转换为汇编语言,或者直接转换为机器语言(或某种粗略等效的字节码)。

第四代语言 尽管存在,但不相关于本书,因此不作讨论。

反汇编的基本概念

在传统软件开发模型中,编译器、汇编器和链接器单独或组合使用来创建可执行程序。为了向后操作(或逆向工程程序),我们使用工具来撤销汇编和编译过程。毫不奇怪,这些工具被称为反汇编器反编译器,它们基本上做了它们名称所示的事情。反汇编器撤销汇编过程,因此我们应该期望输出汇编语言(从而输入机器语言)。反编译器旨在在给定汇编甚至机器语言作为输入时生成高级语言的输出。

在竞争激烈的软件市场中,“源代码恢复”的承诺始终具有吸引力,因此可用的反编译器的开发仍然是计算机科学中一个活跃的研究领域。以下是一些解释反编译困难的原因:

编译过程存在信息损失。 在机器语言级别上,不存在变量或函数名称,变量类型信息仅能通过数据使用方式来确定,而非显式类型声明。当观察到传输 32 位数据时,您需要进行一些调查工作来确定这 32 位数据是表示整数、32 位浮点值还是 32 位指针。

编译是一对多的操作。 这意味着源程序可以以许多不同的方式翻译成汇编语言,而机器语言可以以许多不同的方式翻译回源代码。因此,编译文件然后立即反编译它通常会产生与原始文件大不相同的源文件。

反编译器依赖于语言和库。 使用设计为生成 C 代码的反编译器处理由 Delphi 编译器生成的二进制文件可能会产生非常奇怪的结果。同样地,将经过编译的 Windows 二进制文件输入到没有 Windows 编程 API 知识的反编译器中可能得不到任何有用的结果。

为了准确地反编译一个二进制文件,几乎完美的反汇编能力是必需的。 反汇编阶段的任何错误或遗漏几乎肯定会传播到反编译后的代码中。可以通过适当的处理器参考手册验证反汇编代码的正确性;然而,没有权威的参考手册可用于验证反编译器输出的正确性。

Ghidra 内置了反编译器,这是第十九章的主题。

反汇编的意义

反汇编工具的目的通常是为了在源代码不可用时帮助理解程序。使用反汇编的常见情况包括以下几种:

  • 恶意软件分析

  • 封闭源代码软件的漏洞分析

  • 封闭源代码软件的互操作性分析

  • 分析编译器生成的代码以验证编译器的性能或正确性

  • 调试时程序指令的显示

后续部分将更详细地解释每种情况。

恶意软件分析

除非你正在处理基于脚本的恶意软件,否则恶意软件作者通常不会提供他们创作的源代码。没有源代码,你面临的选项非常有限,用于发现恶意软件具体行为的方式也很有限。恶意软件分析的两种主要技术是动态分析和静态分析。动态分析涉及在精心控制的环境(沙箱)中运行恶意软件,并通过使用各种系统工具记录其行为的每个可观察方面。与此相对,静态分析尝试仅通过阅读程序代码来理解程序的行为,在恶意软件的情况下,程序代码通常仅由反汇编清单和可能的反编译器清单组成。

漏洞分析

为了简化,我们将整个安全审计过程分为三个步骤:漏洞发现、漏洞分析和漏洞利用开发。无论是否有源代码,步骤是相同的;然而,当你只有二进制文件时,所需的工作量会大大增加。该过程的第一步是发现程序中可能被利用的条件。这通常是通过动态技术(如模糊测试)完成的,^(1),但也可以通过静态分析(通常需要更多的努力)来完成。一旦发现问题,通常需要进一步分析以确定问题是否可以被利用,如果可以,被利用的条件是什么。

识别可以被攻击者利用的变量是发现漏洞的一个重要早期步骤。反汇编清单提供了理解编译器如何分配程序变量所需的详细信息。例如,知道程序员声明的 70 字节字符数组在编译器分配时被四舍五入为 80 字节可能是有用的。反汇编清单还提供了唯一的方式来确定编译器如何选择对所有全局或函数内声明的变量进行排序。了解变量之间的空间关系通常是开发利用程序时的关键。最终,通过结合使用反汇编器和调试器,可能会开发出一个漏洞利用程序。

软件互操作性

当软件仅以二进制形式发布时,竞争对手很难开发能够与之互操作的软件,或为该软件提供插件替代品。一个常见的例子是仅在一个平台上支持的硬件发布的驱动程序代码。当厂商在支持或更糟糕的是,拒绝支持其硬件与其他平台的兼容性时,可能需要大量的逆向工程工作才能开发出支持该硬件的软件驱动程序。在这些情况下,静态代码分析几乎是唯一的解决办法,并且通常需要超越软件驱动程序,以了解嵌入式固件。

编译器验证

由于编译器(或汇编器)的目的是生成机器语言,通常需要良好的反汇编工具来验证编译器是否按照设计规范执行其任务。分析人员可能还会关注寻找优化编译器输出的其他机会,并且从安全角度出发,确定编译器本身是否已被破坏,是否可能将后门代码插入到生成的代码中。

调试显示

也许反汇编器最常见的用途之一是生成调试器中的清单。不幸的是,嵌入调试器中的反汇编器通常缺乏复杂性。它们通常不能批量反汇编,有时在无法确定函数边界时会停止反汇编。这也是为什么最好将调试器与高质量的反汇编器结合使用,以提供更好的情境意识和调试过程中的上下文。

反汇编的原理

现在,既然您已经熟悉了反汇编的目的,是时候了解这个过程的实际操作了。考虑反汇编器面临的一个典型而艰巨的任务:处理这 100KB,区分代码和数据,将代码转换为汇编语言并显示给用户,并且请在整个过程中不要遗漏任何部分。 我们可以附加任何数量的特殊请求,比如要求反汇编器定位函数、识别跳转表、识别局部变量,使得反汇编器的工作更加困难。

为了满足我们所有的需求,任何反汇编器在处理我们输入的文件时,都需要从各种算法中挑选合适的算法。生成的反汇编列表的质量将直接与所使用算法的质量以及它们的实现效果相关。

在本节中,我们讨论当前用于反汇编机器代码的两种基本算法。在介绍这些算法时,我们还将指出它们的不足之处,以便让您为反汇编器可能出现的失败情况做好准备。通过理解反汇编器的局限性,您将能够手动干预,以提高反汇编输出的整体质量。

基本反汇编算法

首先,让我们开发一个简单的算法,用于接受机器语言作为输入,并生成汇编语言作为输出。在此过程中,您将了解自动化反汇编过程背后的挑战、假设和折衷:

  1. 反汇编过程的第一步是确定要反汇编的代码区域。这并不总是看起来那么简单。指令通常与数据混合在一起,因此区分它们非常重要。在最常见的情况下,反汇编可执行文件时,文件将遵循常见的可执行文件格式,如 Windows 上使用的可移植可执行(PE)格式和许多 Unix 系统上常用的可执行与可链接格式(ELF)。这些格式通常包含机制(通常以分层文件头的形式)来定位包含代码的文件部分及其入口点。^(2)

  2. 给定一条指令的地址,下一步是读取该地址(或文件偏移量)处的值,并执行表查找,将二进制操作码值与其汇编语言助记符匹配。根据所反汇编指令集的复杂性,这可能是一个简单的过程,也可能涉及一些额外的操作,例如理解可能修改指令行为的前缀,并确定指令所需的操作数。对于具有可变长度指令的指令集,例如 Intel x86 指令集,可能需要检索额外的指令字节,以完全反汇编一条指令。

  3. 一旦指令被取出并解码了所需的操作数,它的汇编语言等效指令将被格式化并输出,作为反汇编列表的一部分。可能可以选择多种汇编语言输出语法。例如,x86 汇编语言的两种主要格式是 Intel 格式和 AT&T 格式。

  4. 在输出一条指令后,我们需要推进到下一条指令,并重复之前的过程,直到我们反汇编完文件中的每一条指令。

X86 汇编语法:AT&T 与 Intel

汇编源代码主要使用两种语法:AT&T 和 Intel。尽管它们都是二代语言,但两者在语法上差异很大——从变量、常量和寄存器访问,到段和指令大小重写,再到间接寻址和偏移。AT&T 汇编语法的特点是使用 % 符号作为所有寄存器名称的前缀,使用 $ 作为字面常量(也称为 立即数操作数)的前缀,以及操作数顺序,其中源操作数位于左侧,目标操作数位于右侧。使用 AT&T 语法,将 4 加到 EAX 寄存器的指令是 add $0x4,%eax。GNU 汇编器(as)和许多其他 GNU 工具(包括 gccgdb)默认使用 AT&T 语法。

Intel 语法与 AT&T 的区别在于,它不需要寄存器或字面量前缀,操作数的顺序也被颠倒,源操作数出现在右侧,目标操作数出现在左侧。使用 Intel 语法的相同 add 指令将是 add eax,0x4。采用 Intel 语法的汇编器包括 Microsoft 汇编器(MASM)和 Netwide 汇编器(NASM)。

存在多种算法用于确定从哪里开始反汇编,如何选择下一条要反汇编的指令,如何区分代码与数据,以及如何判断最后一条指令是否已经反汇编完成。两种主要的反汇编算法是线性扫描和递归下降。

线性扫描反汇编

线性扫描反汇编算法采用一种非常直接的方法来定位需要反汇编的指令:一条指令结束后,另一条指令开始。因此,最困难的决策是从哪里开始以及何时停止。通常的解决方案是假设程序中标记为代码的部分(通常由程序文件的头部指定)包含机器语言指令。反汇编从代码段中的第一个字节开始,以线性方式遍历该段,逐条反汇编指令,直到到达该段的末尾。不会尝试通过识别非线性指令(如跳转)来理解程序的控制流。

在反汇编过程中,可以保持一个指针来标记当前正在反汇编的指令的起始位置。作为反汇编过程的一部分,会计算每条指令的长度,并用它来确定下一条指令的位置。对于固定长度指令集(例如 MIPS),反汇编相对容易一些,因为定位后续指令很直接。

线性扫描算法的主要优点在于它可以完全覆盖程序的代码段。线性扫描方法的一个主要缺点是,它未能考虑可能与代码混合的数据。这在清单 1-1 中得到了体现,该清单展示了使用线性扫描反汇编器反汇编的一个函数的输出。

    40123f:   55                       push ebp

    401240:   8b ec                    mov ebp,esp

    401242:   33 c0                    xor eax,eax

    401244:   8b 55 08                 mov edx,DWORD PTR [ebp+8]

    401247:   83 fa 0c                 cmp edx,0xc

    40124a:   0f 87 90 00 00 00        ja 0x4012e0

    401250:   ff 24 95 57 12 40 00     jmp DWORD PTR [edx*4+0x401257]➊

➋  401257:   e0 12                    loopne 0x40126b

    401259:   40                       inc eax

    40125a:   00 8b 12 40 00 90        add BYTE PTR [ebx-0x6fffbfee],cl

    401260:   12 40 00                 adc al,BYTE PTR [eax]

    401263:   95                       xchg ebp,eax

    401264:   12 40 00                 adc al,BYTE PTR [eax]

    401267:   9a 12 40 00 a2 12 40     call 0x4012:0xa2004012

    40126e:   00 aa 12 40 00 b2        add BYTE PTR [edx-0x4dffbfee],ch

    401274:   12 40 00                 adc al,BYTE PTR [eax]

    401277:   ba 12 40 00 c2           mov edx,0xc2004012

    40127c:   12 40 00                 adc al,BYTE PTR [eax]

    40127f:   ca 12 40                 lret 0x4012

    401282:   00 d2                    add dl,dl

    401284:   12 40 00                 adc al,BYTE PTR [eax]

    401287:   da 12                    ficom DWORD PTR [edx]

    401289:   40                       inc eax

    40128a:   00 8b 45 0c eb 50        add BYTE PTR [ebx+0x50eb0c45],cl

    401290:   8b 45 10                 mov eax,DWORD PTR [ebp+16]

    401293:   eb 4b                    jmp 0x4012e0

清单 1-1:线性扫描反汇编

这个函数包含了一个 switch 语句,并且在此案例中使用的编译器选择通过跳转表来实现 switch,以解析 case 标签目标。此外,编译器选择将跳转表嵌入到函数内部。jmp 语句 ➊ 引用了一个地址表 ➋。遗憾的是,反汇编器将地址表当作一系列指令来处理,并错误地生成了以下的汇编语言表示。

如果我们将跳转表 ➋ 中的连续 4 字节组视为小端值^(3),我们会发现每个值代表一个指向附近地址的指针,而这些地址实际上是多个跳转的目的地(004012e00040128b00401290,等等)。因此,loopne 指令 ➋ 根本不是一条指令。相反,它表明线性扫描算法未能正确区分嵌入的数据和代码。

线性扫描被 GNU 调试器(gdb)、微软的 WinDbg 调试器和 objdump 工具所使用。

递归下降反汇编

递归下降 反汇编算法采取不同的方法来定位指令:它侧重于控制流的概念,决定是否应当反汇编某条指令,依据的是它是否被其他指令引用。要理解递归下降,帮助理解的一个方法是根据指令如何影响指令指针来分类指令。

顺序流指令

顺序流指令 将执行传递给紧跟其后的指令。顺序流指令的例子包括简单的算术指令,如 add;寄存器到内存的转移指令,如 mov;以及栈操作,如 pushpop。对于这些指令,反汇编过程与线性扫描相同。

条件分支指令

条件跳转指令,如 x86 的jnz,提供两条可能的执行路径。如果条件为真,则跳转被执行,程序计数器必须更改为反映跳转目标。然而,如果条件为假,执行将继续按线性方式进行,并且可以使用线性扫描方法来反汇编下一条指令。由于在静态环境中通常无法确定条件测试的结果,递归下降算法会反汇编两条路径,通过将目标指令的地址添加到待反汇编的地址列表中,推迟对跳转目标指令的反汇编。

无条件跳转指令

无条件跳转不遵循线性流模型,因此递归下降算法会以不同的方式处理它们。与顺序流指令一样,执行可以流向只有一条指令;然而,该指令不一定紧接着跳转指令。实际上,如示例 1-1 所示,没有要求指令必须紧跟在无条件跳转后。因此,没有理由立即反汇编紧随无条件跳转后的字节。

递归下降反汇编器尝试确定无条件跳转的目标,并在目标地址继续反汇编。不幸的是,一些无条件跳转会给递归下降反汇编器带来问题。当跳转指令的目标依赖于运行时值时,可能无法通过静态分析确定跳转的目的地。x86 指令jmp rax就演示了这个问题。只有在程序实际运行时,rax寄存器才包含一个值。由于在静态分析期间寄存器没有值,我们无法确定跳转指令的目标,因此也无法确定继续反汇编的地方。

函数调用指令

函数调用指令的操作方式类似于无条件跳转指令(包括反汇编器无法确定像call rax这样的指令目标),但额外的期望是,在函数完成后,执行通常会返回到紧接在调用指令之后的指令。在这一点上,它们与条件跳转指令相似,因为它们生成两个执行路径。调用指令的目标地址会被添加到待定反汇编的地址列表中,而紧随调用指令的指令则按线性扫描的方式反汇编。

如果程序在从调用的函数返回时没有按照预期行为执行,递归下降可能会失败。例如,函数中的代码可以故意修改该函数的返回地址,以便在完成后,控制返回到一个不同于反汇编器预期的地址。以下是不正确的列表示例,其中函数badfunc在返回调用者之前,仅仅将返回地址加 1:

badfunc proc near

48 FF 04 24  inc qword ptr [rsp] ; increments saved return addr

C3           retn

badfunc endp

 ; -------------------------------------

label:

E8 F6 FF FF FF   call badfunc

05 48 89 45 F8   add eax, F8458948h➊

结果是,控制并没有实际传递到add指令➊,它位于调用badfunc之后。接下来是正确的反汇编:

badfunc proc near

48 FF 04 24  inc qword ptr [rsp]

C3           retn

badfunc endp

; -------------------------------------

label:

E8 F6 FF FF FF  call badfunc

05              db 5        ;formerly the first byte of the add instruction

48 89 45 F8     mov [rbp-8], rax➊

这个列表更清晰地展示了程序的流程,其中badfunc函数实际上返回到mov指令➊。理解这一点很重要:线性扫描反汇编器也无法正确地反汇编这段代码,尽管原因略有不同。

返回指令

在某些情况下,递归下降算法会走到无路可走的地步。一个函数的返回指令(例如 x86 的ret)并没有提供有关下一条指令的信息。如果程序真的在运行,地址会从运行时堆栈的顶部获取,并在该地址恢复执行。反汇编器无法访问堆栈,因此反汇编过程会突然中断。在这一点上,递归下降反汇编器会转向它之前保留的待反汇编地址列表。一个地址从该列表中移除,反汇编过程会从该地址继续。这就是递归过程,赋予了反汇编算法其名称。

递归下降算法的主要优势之一是它在区分代码和数据方面的卓越能力。作为一种基于控制流的算法,它不太可能错误地将数据值反汇编为代码。递归下降的主要缺点是无法跟踪间接代码路径,例如使用指针表查找目标地址的跳转或调用。然而,通过加入一些启发式方法来识别指向代码的指针,递归下降反汇编器可以提供非常完整的代码覆盖,并且能够很好地区分代码和数据。列表 1-2 展示了使用 Ghidra 递归下降反汇编器对前面列表 1-1 中展示的相同 switch 语句的输出。

0040123f  PUSH   EBP

00401240  MOV    EBP,ESP

00401242  XOR    EAX,EAX

00401244  MOV    EDX,dword ptr [EBP + param_1]

00401247  CMP    EDX,0xc

0040124a  JA     switchD_00401250::caseD_0

        switchD_00401250::switchD

00401250  JMP    dword ptr [EDX*0x4 + ->switchD_00401250::caseD_0] = 004012e0

        switchD_00401250::switchdataD_00401257

00401257  addr   switchD_00401250::caseD_0

0040125b  addr   switchD_00401250::caseD_1

0040125f  addr   switchD_00401250::caseD_2

00401263  addr   switchD_00401250::caseD_3

00401267  addr   switchD_00401250::caseD_4

0040126b  addr   switchD_00401250::caseD_5

0040126f  addr   switchD_00401250::caseD_6

 00401273  addr   switchD_00401250::caseD_7

00401277  addr   switchD_00401250::caseD_8

0040127b  addr   switchD_00401250::caseD_9

0040127f  addr   switchD_00401250::caseD_a

00401283  addr   switchD_00401250::caseD_b

00401287  addr   switchD_00401250::caseD_c

        switchD_00401250::caseD_1

0040128b  MOV    EAX,dword ptr [EBP + param_2]

0040128e  JMP    switchD_00401250::caseD_00040128E

列表 1-2:递归下降反汇编

请注意,这段二进制代码已被识别为一个 switch 语句,并进行了相应的格式化。理解递归下降过程将帮助我们识别 Ghidra 可能产生不尽人意的反汇编情况,并允许我们开发策略来改善 Ghidra 的输出。

总结

在使用反汇编器时,深入理解反汇编算法是否必要?不。是否有用?是的!在进行逆向工程时,和工具作斗争是你最不希望花时间做的事。Ghidra 的众多优点之一是,作为一个交互式反汇编器,它提供了大量的机会让你引导和覆盖它的决策。最终的结果往往是一个既全面又准确的反汇编。

在下一章中,我们将回顾一些在许多逆向工程场景中非常有用的现有工具。虽然这些工具与 Ghidra 没有直接关系,但它们中的许多对 Ghidra 产生了影响,并且帮助解释了 Ghidra 用户界面中各种信息展示的多样性。

第三章:逆向工程与反汇编工具**

Image

在我们掌握了一些反汇编的基本背景后,在深入了解 Ghidra 的具体细节之前,了解一些其他用于逆向工程二进制文件的工具将非常有用。这些工具中的许多在 Ghidra 之前就已经存在,并且仍然对快速查看文件以及核对 Ghidra 工作结果非常有用。正如我们将看到的,Ghidra 将这些工具的许多功能集成到其用户界面中,提供了一个单一的、集成的逆向工程环境。

分类工具

当第一次遇到未知文件时,回答一些简单问题通常是很有帮助的,比如“这是什么东西?”解决这个问题的首要原则是 绝不 依赖文件扩展名来确定文件的真实类型。这也是第二、第三和第四个原则。一旦你开始认同 文件扩展名毫无意义 的观点,你可能会希望熟悉以下一种或多种工具。

file

file 命令是一个标准工具,包含在大多数 *nix 风格的操作系统中,以及 Windows Subsystem for Linux (WSL) 中。^(1) 通过安装 Cygwin 或 MinGW,Windows 用户也可以使用该命令。^(2) file 命令通过检查文件中的特定字段来识别文件类型。

在某些情况下,file 能够识别常见的字符串,如 #!/bin/sh(一个 shell 脚本)和 <html>(一个 HTML 文档)。

含有非 ASCII 内容的文件会带来一些挑战。在这种情况下,file 会尝试确定文件内容是否按照已知的文件格式进行结构化。在许多情况下,它会寻找特定的标签值(通常称为 magic numbers)^(3),这些标签值被认为是特定文件类型的独特标识。以下的十六进制列表示例展示了几种用于识别常见文件类型的 magic numbers。

Windows PE executable file

  00000000 4D 5A 90 00 03 00 00 00 04 00 00 00 FF FF 00 00 MZ..............

  00000010 B8 00 00 00 00 00 00 00 40 00 00 00 00 00 00 00 ........@.......

Jpeg image file

  00000000 FF D8 FF E0 00 10 4A 46 49 46 00 01 01 01 00 60 ......JFIF.....`

  00000010 00 60 00 00 FF DB 00 43 00 0A 07 07 08 07 06 0A .`.....C........

Java .class file

  00000000 CA FE BA BE 00 00 00 32 00 98 0A 00 2E 00 3E 08 .......2......>.

  00000010 00 3F 09 00 40 00 41 08 00 42 0A 00 43 00 44 0A .?..@.A..B..C.D.

file 命令有能力识别多种文件格式,包括几种类型的 ASCII 文本文件和各种可执行文件及数据文件格式。file 执行的 magic number 检查是根据 magic 文件 中的规则进行的。默认的 magic 文件因操作系统而异,但常见的位置包括 /usr/share/file/magic/usr/share/misc/magic/etc/magic。有关 magic 文件的更多信息,请参考 file 的文档。

在某些情况下,file 可以区分给定文件类型中的不同变种。以下列表演示了 file 不仅能够识别 ELF 二进制文件的多个变种,还能识别与二进制文件如何链接(静态或动态)以及是否经过剥离相关的信息。

ghidrabook# file ch2_ex_*

  ch2_ex_x64:        ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), 

                     dynamically linked, interpreter /lib64/l, for GNU/Linux 

                     3.2.0, not stripped

  ch2_ex_x64_dbg:    ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), 

                     dynamically linked, interpreter /lib64/l, for GNU/Linux 

                     3.2.0, with debug_info, not stripped

  ch2_ex_x64_static: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), 

                     statically linked, for GNU/Linux 3.2.0, not stripped

  ch2_ex_x64_strip:  ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), 

                     dynamically linked, interpreter /lib64/l, for GNU/Linux 

                     3.2.0, stripped

  ch2_ex_x86:        ELF 32-bit LSB shared object, Intel 80386, version 1 

                     (SYSV), dynamically linked, interpreter /lib/ld-, for 

                     GNU/Linux 3.2.0, not stripped

  ch2_ex_x86_dbg:    ELF 32-bit LSB shared object, Intel 80386, version 1 

                     (SYSV), dynamically linked, interpreter /lib/ld-, for 

                     GNU/Linux 3.2.0, with debug_info, not stripped

  ch2_ex_x86_static: ELF 32-bit LSB executable, Intel 80386, version 1 

                     (GNU/Linux), statically linked, for GNU/Linux 3.2.0, 

                     not stripped

  ch2_ex_x86_strip:  ELF 32-bit LSB shared object, Intel 80386, version 1 

                     (SYSV), dynamically linked, interpreter /lib/ld-, for 

                     GNU/Linux 3.2.0, stripped

  ch2_ex_Win32:      PE32 executable (console) Intel 80386, for MS Windows

  ch2_ex_x64:        PE32+ executable (console) x86-64, for MS Windows

WSL 环境

Windows Subsystem for Linux 提供了一个直接在 Windows 中运行的 GNU/Linux 命令行环境,无需创建虚拟机。在 WSL 安装过程中,用户可以选择一个 Linux 发行版,然后在 WSL 上运行它。这提供了对常见命令行自由软件的访问(grepawk)、编译器(gccg++)、解释器(Perl、Python、Ruby)、网络工具(ncssh)以及其他许多工具。一旦安装了 WSL,许多为 Linux 编写的程序就可以在 Windows 系统上进行编译和执行。

file 工具和类似的工具并非万无一失。很有可能由于文件恰巧带有特定文件格式的标识符,导致其被错误地识别。你可以通过使用十六进制编辑器,将任何文件的前 4 个字节修改为 Java 魔术数字序列:CA FE BA BE,来亲自验证这一点。file 工具会错误地将这个新修改的文件识别为 编译过的 Java 类数据。类似地,一个仅包含字符 MZ 的文本文件会被识别为 MS-DOS 可执行文件。在进行任何逆向工程时,一个好的做法是,在将任何工具的输出作为最终结果之前,首先通过多种工具和手动分析来验证这些输出。

去除二进制可执行文件中的符号

去除二进制文件符号是从二进制文件中去除符号的过程。二进制目标文件由于编译过程而包含符号。这些符号中的一些在链接过程中被使用,以在创建最终可执行文件或库时解析文件之间的引用。在其他情况下,符号可能存在,以便为调试器提供附加信息。链接过程完成后,许多符号已不再需要。传递给链接器的选项可以使链接器在构建时去除不必要的符号。或者,可以使用名为 strip 的工具从现有二进制文件中去除符号。虽然去除符号的二进制文件比未去除符号的二进制文件小,但去除符号后的二进制文件行为保持不变。

PE 工具

PE 工具是一组有助于分析 Windows 系统上正在运行的进程和可执行文件的工具集合。^4 图 2-1 展示了 PE 工具提供的主要界面,界面显示了活动进程的列表,并提供访问所有 PE 工具实用程序的功能。

从进程列表中,用户可以将进程的内存镜像转储到文件中,或使用 PE Sniffer 工具确定构建可执行文件时使用的编译器,或确定该可执行文件是否经过任何已知的混淆工具处理。工具菜单提供类似的选项,用于分析磁盘文件。用户可以使用嵌入式 PE 编辑器工具查看文件的 PE 头字段,该工具还允许轻松修改任何头部值。在尝试从混淆版本的文件重构有效的 PE 时,通常需要修改 PE 头部。

image

图 2-1:PE Tools 工具

二进制文件混淆

混淆是任何试图掩盖某物真实含义的行为。当应用于可执行文件时,混淆就是试图隐藏程序的真实行为。程序员可能出于多种原因使用混淆技术。常见的例子包括保护专有算法和掩盖恶意意图。几乎所有形式的恶意软件都会利用混淆技术来阻碍分析。有很多工具可以帮助程序作者生成混淆的程序。混淆工具和技术及其对逆向工程过程的影响将在第二十一章中进一步讨论。

PEiD

PEiD 是另一个 Windows 工具,其主要功能是识别用于构建特定 Windows PE 二进制文件的编译器,并识别任何用于混淆 Windows PE 二进制文件的工具。^(5) 图 2-2 显示了使用 PEiD 来识别用于混淆 Gaobot 蠕虫变种的工具(此案例中为 ASPack)。^(6)

image

图 2-2:PEiD 工具

PEiD 的许多附加功能与 PE Tools 重叠,包括概述 PE 文件头信息、收集正在运行的进程信息以及执行基本的反汇编操作。

汇总工具

由于我们的目标是逆向工程二进制程序文件,我们需要更复杂的工具来提取文件初步分类后的详细信息。本节讨论的工具,必然会更加了解它们所处理的文件格式。在大多数情况下,这些工具理解非常特定的文件格式,且它们被用来解析输入文件,以提取非常具体的信息。

nm

当源文件被编译为目标文件时,编译器必须嵌入有关任何全局(外部)符号位置的信息,以便链接器能够在合并目标文件以创建可执行文件时解析对这些符号的引用。除非指示从最终可执行文件中剥离符号,否则链接器通常会将目标文件中的符号携带到生成的可执行文件中。根据 man 页面,nm 工具“列出目标文件中的符号”。

nm用于检查中间目标文件(.o文件而不是可执行文件)时,默认输出将显示文件中声明的任何函数和全局变量的名称。接下来是nm工具的示例输出:

ghidrabook# gcc -c ch2_nm_example.c

ghidrabook# nm ch2_nm_example.o

                   U exit

                   U fwrite

  000000000000002e t get_max

                   U _GLOBAL_OFFSET_TABLE_

                   U __isoc99_scanf

 00000000000000a6 T main

  0000000000000000 D my_initialized_global

  0000000000000004 C my_uninitialized_global

                   U printf

                   U puts

                   U rand

                   U srand

                   U __stack_chk_fail

                   U stderr

                   U time

  0000000000000000 T usage

ghidrabook#

在这里,我们看到nm列出了每个符号,并提供了关于符号的信息。字母代码用于表示列出的符号的类型。在这个例子中,我们看到了以下字母代码:

U 一个未定义的符号(通常是外部符号引用)。

T 在文本段中定义的符号(通常是一个函数名)。

t 在文本段中定义的局部符号。在 C 程序中,这通常等同于静态函数。

D 一个初始化的数据值。

C 一个未初始化的数据值。

注意

大写字母代码用于全局符号,而小写字母代码用于局部符号。更多信息,包括字母代码的完整解释,可以在 nm 的手册页中找到。*

使用nm显示可执行文件中的符号时,会显示更多信息。在链接过程中,符号会被解析为虚拟地址(如果可能的话),这会导致在运行nm时显示更多的信息。以下是nm在可执行文件上的截断示例输出:

ghidrabook# gcc -o ch2_nm_example ch2_nm_example.c

ghidrabook# nm ch2_nm_example

  ...

                   U fwrite@@GLIBC_2.2.5

  0000000000000938 t get_max

  0000000000201f78 d _GLOBAL_OFFSET_TABLE_

                   w __gmon_start__

  0000000000000c5c r __GNU_EH_FRAME_HDR

  0000000000000730 T _init

  0000000000201d80 t __init_array_end

  0000000000201d78 t __init_array_start

  0000000000000b60 R _IO_stdin_used

                   U __isoc99_scanf@@GLIBC_2.7

                   w _ITM_deregisterTMCloneTable

                   w _ITM_registerTMCloneTable

  0000000000000b50 T __libc_csu_fini

  0000000000000ae0 T __libc_csu_init

 U __libc_start_main@@GLIBC_2.2.5

  00000000000009b0 T main

  0000000000202010 D my_initialized_global

  000000000020202c B my_uninitialized_global

                   U printf@@GLIBC_2.2.5

                   U puts@@GLIBC_2.2.5

                   U rand@@GLIBC_2.2.5

  0000000000000870 t register_tm_clones

                   U srand@@GLIBC_2.2.5

                   U __stack_chk_fail@@GLIBC_2.4

  0000000000000800 T _start

  0000000000202020 B stderr@@GLIBC_2.2.5

                   U time@@GLIBC_2.2.5

  0000000000202018 D __TMC_END__

  000000000000090a T usage

ghidrabook#

此时,一些符号(例如main)已经被分配了虚拟地址,新的符号(如__libc_csu_init)由于链接过程而引入,一些符号(如my_unitialized_global)已更改了符号类型,另一些符号仍未定义,因为它们继续引用外部符号。在这种情况下,我们正在检查的二进制文件是动态链接的,未定义的符号在共享的 C 库中定义。

ldd

当创建一个可执行文件时,必须解决该可执行文件引用的任何库函数的位置。链接器有两种方法来解决对库函数的调用:静态链接动态链接。提供给链接器的命令行参数决定使用哪种方法。一个可执行文件可以是静态链接的、动态链接的,或者是两者都有的。^(7)

当请求静态链接时,链接器将应用程序的目标文件与所需库的副本结合起来,以创建可执行文件。在运行时,无需查找库代码,因为它已经包含在可执行文件中。静态链接的优点包括:(1) 它能使函数调用稍微更快,(2) 由于不需要假设用户系统上是否有库代码,二进制文件的分发变得更容易。静态链接的缺点包括:(1) 生成的可执行文件较大,(2) 当库组件发生变化时,程序的升级更加困难。程序更新变得更加困难,因为每次库发生更改时,程序必须重新链接。从逆向工程的角度来看,静态链接在某种程度上使问题变得更加复杂。如果我们面临分析一个静态链接的二进制文件的任务,就没有简单的方法来回答“这个二进制文件链接了哪些库?”以及“这些函数中哪些是库函数?”第十三章讨论了在逆向工程静态链接代码时遇到的挑战。

动态链接与静态链接的不同之处在于,链接器不需要复制任何所需的库。相反,链接器仅在最终可执行文件中插入对所需库(通常是.so.dll文件)的引用,这通常会导致生成的可执行文件更小。当使用动态链接时,库代码的升级变得更加容易。由于只维护一个库的副本,并且该副本被多个二进制文件引用,替换掉过时的库副本并用新版本替代,将导致任何基于动态链接到该库的二进制文件的进程使用更新的版本。使用动态链接的一个缺点是,它需要更复杂的加载过程。所有必要的库必须被定位并加载到内存中,而不像静态链接文件那样,加载的文件已经包含了所有的库代码。动态链接的另一个缺点是,供应商不仅必须分发他们自己的可执行文件,还必须分发所有该可执行文件所依赖的库文件。如果在没有所有必需库文件的系统上尝试执行程序,将会导致错误。

以下输出演示了程序的动态和静态链接版本的创建、生成的二进制文件的大小,以及file如何识别这些二进制文件:

ghidrabook# gcc -o ch2_example_dynamic ch2_example.c

ghidrabook# gcc -o ch2_example_static ch2_example.c -static

ghidrabook# ls -l ch2_example_*

  -rwxrwxr-x 1 ghidrabook ghidrabook  12944 Nov  7 10:07 ch2_example_dynamic

  -rwxrwxr-x 1 ghidrabook ghidrabook 963504 Nov  7 10:07 ch2_example_static

ghidrabook# file ch2_example_*

  ch2_example_dynamic: ELF 64-bit LSB executable, x86-64, version 1 (SYSV),

  dynamically linked, interpreter /lib64/l, for GNU/Linux 3.2.0,

  BuildID[sha1]=e56ed40012accb3734bde7f8bca3cc2c368455c3, not stripped

  ch2_example_static:  ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux),

  statically linked, for GNU/Linux 3.2.0,

  BuildID[sha1]=430996c6db103e4fe76aea7d578e636712b2b4b0, not stripped

ghidrabook#

为了使动态链接正常工作,动态链接的二进制文件必须指明它们所依赖的库,以及每个库所需的特定资源。因此,与静态链接的二进制文件不同,确定动态链接的二进制文件所依赖的库非常简单。ldd列出动态依赖项)工具用于列出任何可执行文件所需的动态库。在以下示例中,ldd用于确定 Apache web 服务器所依赖的库:

ghidrabook# ldd /usr/sbin/apache2

  linux-vdso.so.1 =>  (0x00007fffc1c8d000)

  libpcre.so.3 => /lib/x86_64-linux-gnu/libpcre.so.3 (0x00007fbeb7410000)

  libaprutil-1.so.0 => /usr/lib/x86_64-linux-gnu/libaprutil-1.so.0 (0x00007fbeb71e0000)

  libapr-1.so.0 => /usr/lib/x86_64-linux-gnu/libapr-1.so.0 (0x00007fbeb6fa0000)

  libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007fbeb6d70000)

  libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fbeb69a0000)

  libcrypt.so.1 => /lib/x86_64-linux-gnu/libcrypt.so.1 (0x00007fbeb6760000)

 libexpat.so.1 => /lib/x86_64-linux-gnu/libexpat.so.1 (0x00007fbeb6520000)

  libuuid.so.1 => /lib/x86_64-linux-gnu/libuuid.so.1 (0x00007fbeb6310000)

  libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007fbeb6100000)

  /lib64/ld-linux-x86-64.so.2 (0x00007fbeb7a00000)

ghidrabook#

ldd工具可在 Linux 和 BSD 系统上使用。在 macOS 系统上,可以使用otool工具配合-L选项来实现类似功能:otool -L 文件名。在 Windows 系统上,可以使用dumpbin工具,这是 Visual Studio 工具套件的一部分,用于列出依赖库:dumpbin /dependents 文件名。

小心你的工具!

尽管ldd看起来像是一个简单的工具,但ldd的手册页面指出,“你永远不应该在不可信的可执行文件上使用ldd,因为这可能会导致执行任意代码。”虽然在大多数情况下这种情况不太可能发生,但它提醒我们,当检查不可信的输入文件时,甚至像ldd这样的简单软件逆向工程(SRE)工具也可能会产生意想不到的后果。虽然执行不可信的二进制文件显然不安全,但即使是在静态分析不可信二进制文件时,也应该采取预防措施,并假设进行 SRE 任务的计算机以及与其连接的任何数据或其他主机,可能会因 SRE 活动而被入侵。

objdump

虽然ldd是一个相对专业的工具,但objdump则非常多功能。objdump的目的是“显示目标文件中的信息”。^(8) 这是一个相当广泛的目标,为了实现这一目标,objdump支持超过 30 个命令行选项,用于从目标文件中提取各种信息。objdump工具可以用来显示与目标文件相关的以下数据(还有更多):

段头信息 程序文件中各个部分的摘要信息。

私有头文件 程序内存布局信息和运行时加载器所需的其他信息,包括所需库的列表,类似于ldd产生的输出。

调试信息 程序文件中嵌入的任何调试信息。

符号信息 符号表信息,类似于nm工具的输出方式。

反汇编清单 objdump工具对标记为代码的文件部分执行线性遍历反汇编。当反汇编 x86 代码时,objdump可以生成 AT&T 或 Intel 语法,并且可以将反汇编结果保存为文本文件。这类文本文件称为反汇编死清单,虽然这些文件可以用于逆向工程,但它们难以有效导航,并且更难以一致且无错误地修改。

objdump工具作为 GNU binutils 工具套件的一部分可用,可以在 Linux、FreeBSD 和 Windows(通过 WSL 或 Cygwin)上找到。^(9) 注意,objdump依赖于二进制文件描述符库(libbfd),这是 binutils 的一个组件,用于访问目标文件,因此能够解析 libbfd 支持的文件格式(例如 ELF 和 PE)。对于 ELF 特定的解析,还提供了一个名为readelf的工具。readelf工具提供了与objdump大致相同的功能,主要的区别是readelf不依赖于 libbfd。

otool

otool工具最简单的描述就是类似于objdump的 macOS 选项,它对于解析 macOS Mach-O 二进制文件的信息非常有用。以下清单展示了otool如何显示 Mach-O 二进制文件的动态库依赖性,从而执行类似ldd的功能:

ghidrabook# file osx_example

  osx_example: Mach-O 64-bit executable x86_64

ghidrabook# otool -L osx_example

  osx_example:

    /usr/lib/libstdc++.6.dylib (compatibility version 7.0.0, current version 7.4.0)

    /usr/lib/libgcc_s.1.dylib (compatibility version 1.0.0, current version 1.0.0)

    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1281.0.0)

otool工具可以用于显示与文件头和符号表相关的信息,并对文件的代码段执行反汇编。有关otool功能的更多信息,请参考相关的 man 页面。

dumpbin

dumpbin命令行工具包含在微软的 Visual Studio 工具套件中。与otoolobjdump类似,dumpbin能够显示与 Windows PE 文件相关的广泛信息。以下清单展示了dumpbin如何以类似ldd的方式显示 Windows 记事本程序的动态依赖:

$ dumpbin /dependents C:\Windows\System32\notepad.exe

Microsoft (R) COFF/PE Dumper

Copyright (C) Microsoft Corporation.  All rights reserved.

Dump of file notepad.exe

File Type: EXECUTABLE IMAGE

  Image has the following delay load dependencies:

    ADVAPI32.dll

    COMDLG32.dll

    PROPSYS.dll

    SHELL32.dll

    WINSPOOL.DRV

    urlmon.dll

  Image has the following dependencies:

    GDI32.dll

    USER32.dll

    msvcrt.dll

    ...

额外的dumpbin选项提供了从 PE 二进制文件的各种部分提取信息的能力,包括符号、导入的函数名、导出的函数名和反汇编代码。有关使用dumpbin的更多信息,可以通过微软网站获取。^(10)

c++filt

支持函数重载的语言必须有一种机制来区分多个重载版本的函数,因为每个版本的函数名称相同。以下 C++示例展示了名为demo的函数几个重载版本的原型:

void demo(void);

void demo(int x);

void demo(double x);

void demo(int x, double y);

void demo(double x, int y);

void demo(char* str);

一般来说,在目标文件中不可能有两个同名的函数。为了支持函数重载,编译器通过结合描述函数参数类型顺序的信息来为重载函数生成唯一的名称。为具有相同名称的函数生成唯一名称的过程称为名称混淆。^(11) 如果我们使用nm从编译后的 C++代码中转储符号,我们可能会看到类似以下内容(已过滤,专注于demo的版本):

ghidrabook# g++ -o ch2_cpp_example ch2_cpp_example.cc

ghidrabook# nm ch2_cpp_example | grep demo

  000000000000060b T _Z4demod

  0000000000000626 T _Z4demodi

  0000000000000601 T _Z4demoi

  0000000000000617 T _Z4demoid

  0000000000000635 T _Z4demoPc

  00000000000005fa T _Z4demov

C++标准并没有定义一个标准的名称混淆方案,因此编译器设计者需要自行开发。这是用来解密显示的demo混淆变体的工具所需的,我们需要一个理解我们编译器(在此案例中为g++)名称混淆方案的工具。c++filt正是为了这个目的设计的。这个工具将每个输入单词当作一个混淆后的名称,然后尝试确定用于生成该名称的编译器。如果该名称看起来是有效的混淆名称,它会输出去混淆后的名称。当c++filt无法将一个单词识别为混淆名称时,它会原样输出该单词。

如果我们将前面示例中nm的结果传递给c++filt,我们就可以恢复去混淆后的函数名称,如下所示:

ghidrabook# nm ch2_cpp_example | grep demo | c++filt

  000000000000060b T demo(double)

  0000000000000626 T demo(double, int)

  0000000000000601 T demo(int)

  0000000000000617 T demo(int, double)

  0000000000000635 T demo(char*)

  00000000000005fa T demo()

需要注意的是,混淆后的名称包含了关于函数的附加信息,而nm通常不会提供这些信息。这些信息在逆向工程中非常有用,在更复杂的情况下,这些额外的信息可能包括关于类名或函数调用约定的数据。

深度检查工具

到目前为止,我们已经讨论了基于对文件内部结构的最少了解进行简要分析的工具。我们还看到了能够根据文件结构的详细知识提取特定数据的工具。在这一节中,我们将讨论旨在独立于被分析文件类型提取特定类型信息的工具。

字符串

有时,提出一些关于文件内容的更一般性的问题是有用的——

不一定需要了解文件结构的特定知识的问题。其中一个问题是:“这个文件包含任何嵌入的字符串吗?”当然,我们首先需要回答的问题是:“什么构成了字符串?”我们可以宽泛地定义字符串为一串连续的可打印字符。这个定义通常会被扩展,指定一个最小长度和特定的字符集。因此,我们可以指定搜索所有至少包含四个连续 ASCII 可打印字符的序列,并将结果打印到控制台。此类字符串的搜索通常不受文件结构的任何限制。你可以在 ELF 二进制文件中搜索字符串,就像你在 Microsoft Word 文档中搜索字符串一样轻松。

strings工具专门用于从文件中提取字符串内容,通常不考虑文件的格式。使用strings的默认设置(7 位 ASCII 序列,至少四个字符)可能会得到如下内容:

ghidrabook# strings ch2_example

  /lib64/ld-linux-x86-64.so.2

  libc.so.6

  exit

  srand

  __isoc99_scanf

  puts

  time

  __stack_chk_fail

  printf

  stderr

  fwrite

  __libc_start_main

  GLIBC_2.7

  GLIBC_2.4

  GLIBC_2.2.5

  _ITM_deregisterTMCloneTable

  __gmon_start__

  _ITM_registerTMCloneTable

  usage: ch4_example [max]

  A simple guessing game!

  Please guess a number between 1 and %d.

  Invalid input, quitting!

  Congratulations, you got it in %d attempt(s)!

  Sorry too low, please try again

  Sorry too high, please try again

  GCC: (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0

  ...

为什么 STRINGS 发生了变化?

从历史上看,当strings用于可执行文件时,默认只会搜索二进制文件中可加载的初始化数据区段的字符序列。这需要strings解析二进制文件,以查找这些区段,并使用像 libbfd 这样的库。当它用于解析不受信任的二进制文件时,库中的漏洞可能会导致任意代码执行。^(12) 因此,strings的默认行为已更改为检查整个二进制文件,而不解析可加载的初始化数据区段(与使用-a标志的效果相同)。可以使用-d标志来调用历史行为。

12。参见 CVE-2014-8485 和lcamtuf.blogspot.com/2014/10/psa-dont-run-strings-on-untrusted-files.html

不幸的是,尽管我们看到一些看起来像是程序输出的字符串,但其他一些字符串似乎是函数名和库名。我们应当小心,不要根据strings的输出草率推断程序的行为。分析人员常常会陷入试图根据strings的输出推断程序行为的陷阱。记住,二进制文件中的字符串的存在并不意味着该字符串以任何方式被该二进制文件使用。

以下是关于strings使用的一些最后说明:

  • 默认情况下,strings不会指示字符串在文件中的位置。使用-t命令行参数,可以让strings为每个找到的字符串打印文件偏移信息。

  • 许多文件使用替代字符集。使用-e命令行参数,可以使strings搜索宽字符,如 16 位 Unicode 字符。

反汇编器

如前所述,已经有工具可以生成二进制对象文件的死列表式反汇编。PE、ELF 和 Mach-O 二进制文件可以分别使用dumpbinobjdumpotool进行反汇编。然而,这些工具都无法处理任意的二进制数据块。你有时会遇到不符合广泛使用文件格式的二进制文件,在这种情况下,你需要能够从用户指定的偏移量开始反汇编过程的工具。

两个适用于 x86 指令集的流式反汇编工具的示例是ndisasmdiStorm。^(13) 工具ndisasm包含在 NASM 中。^(14) 以下示例展示了如何使用ndisasm反汇编通过 Metasploit 框架生成的一段 shellcode:^(15)

ghidrabook#  msfvenom -p linux/x64/shell_find_port -f raw > findport

ghidrabook#  ndisasm –b 64 findport

  00000000  4831FF            xor rdi,rdi

  00000003  4831DB            xor rbx,rbx

  00000006  B314              mov bl,0x14

  00000008  4829DC            sub rsp,rbx

  0000000B  488D1424          lea rdx,[rsp]

  0000000F  488D742404        lea rsi,[rsp+0x4]

  00000014  6A34              push byte +0x34

  00000016  58                pop rax

  00000017  0F05              syscall

  00000019  48FFC7            inc rdi

  0000001C  66817E024A67      cmp word [rsi+0x2],0x674a

  00000022  75F0              jnz 0x14

  00000024  48FFCF            dec rdi

  00000027  6A02              push byte +0x2

  00000029  5E                pop rsi

  0000002A  6A21              push byte +0x21

  0000002C  58                pop rax

  0000002D  0F05              syscall

  0000002F  48FFCE            dec rsi

  00000032  79F6              jns 0x2a

  00000034  4889F3            mov rbx,rsi

  00000037  BB412F7368        mov ebx,0x68732f41

  0000003C  B82F62696E        mov eax,0x6e69622f

  00000041  48C1EB08          shr rbx,byte 0x8

  00000045  48C1E320          shl rbx,byte 0x20

  00000049  4809D8            or rax,rbx

  0000004C  50                push rax

  0000004D  4889E7            mov rdi,rsp

  00000050  4831F6            xor rsi,rsi

  00000053  4889F2            mov rdx,rsi

  00000056  6A3B              push byte +0x3b

  00000058  58                pop rax

  00000059  0F05              syscall

ghidrabook#

流式反汇编的灵活性在许多情况下都非常有用。一种场景涉及计算机网络攻击的分析,其中网络数据包可能包含 shellcode。流式反汇编工具可以用来反汇编数据包中包含 shellcode 的部分,以便分析恶意负载的行为。另一种情况是分析没有布局参考的 ROM 镜像。ROM 的某些部分包含数据,而其他部分则包含代码。流式反汇编工具可以用来仅反汇编那些被认为是代码的镜像部分。

总结

本章讨论的工具不一定是同类中最优秀的。然而,它们确实代表了任何希望反向工程二进制文件的人常用的工具。更重要的是,它们代表了促使 Ghidra 开发的工具类型。在未来的章节中,我们偶尔会介绍一些独立工具,这些工具提供与 Ghidra 集成的功能类似的功能。了解这些工具将极大提升你对 Ghidra 用户界面和 Ghidra 所提供的众多信息展示的理解。

第四章:认识 Ghidra

Image

Ghidra 是由美国国家安全局(NSA)开发的一个免费开源的 SRE 工具套件。这个平台无关的 Ghidra 环境包括一个交互式反汇编器和反编译器,以及大量相关工具,它们协同工作,帮助你分析代码。它支持多种指令集架构和二进制格式,可以在独立模式和协作 SRE 配置中运行。也许 Ghidra 最棒的特点是,它允许你自定义工作环境,开发自己的插件和脚本,增强你的 SRE 过程,并与 Ghidra 社区分享你的创新。

Ghidra 许可证

Ghidra 是免费分发的,并且根据 Apache 许可证 2.0 版授权。这个许可证为个人使用 Ghidra 提供了很大的自由,但也有一些相关的限制。所有下载、使用或编辑 Ghidra 的个人都鼓励阅读 Ghidra 用户协议(docs/UserAgreement.html)以及GPLlicenses目录中的许可证文件,以确保他们遵守所有许可协议,因为 Ghidra 中的第三方组件有自己的许可证。如果你在阅读过程中忘记了本段中的内容,Ghidra 会在每次启动 Ghidra 或从帮助菜单选择“关于 Ghidra”时,友好地显示许可信息。

Ghidra 版本

Ghidra 支持 Windows、Linux 和 macOS。虽然 Ghidra 高度可配置,但大多数新用户可能会下载 Ghidra 并选择开始使用最新版的 Ghidra Core,其中包括传统的逆向工程功能。本书的重点是非共享项目中的 Ghidra Core 功能。此外,我们还花时间讨论共享项目、无头 Ghidra 以及开发者、功能 ID 和实验配置。

Ghidra 支持资源

使用一套新的软件工具可能会让人感到困难,尤其是当目标是通过逆向工程来解决一个具有挑战性的实际问题时。作为 Ghidra 用户(或潜在开发者),你可能会想知道当你遇到与 Ghidra 相关的问题时该向哪里求助。如果我们做好了本书的工作,它可以在许多情况下提供帮助。然而,当你需要额外帮助时,以下是你可以求助的一些额外资源:

官方帮助文档 Ghidra 包含一个详细的帮助系统,可以通过菜单激活或按 F1 键启动。帮助系统提供了一个层次化菜单和搜索功能。虽然帮助菜单提供了多种视图,但它目前不支持提问式问题,比如“如何做x?”

自述文件 在某些情况下,Ghidra 帮助菜单将引导您查阅特定主题的附加内容,例如自述文件。文档中包含许多自述文件,用于补充特定插件、扩展帮助菜单中的主题(如support/analyzeHeadlessREADME.html)、协助各种安装(docs/InstallationGuide.html)并帮助您作为开发者的成长(例如Extensions/Eclipse/GhidraDev/GhidraDev_README.html),如果您选择追求这条路线(也许是开发支持询问“我如何做x”的功能)。

Ghidra 网站 Ghidra 项目主页(www.ghidra-sre.org/)为潜在用户、当前用户、开发者和贡献者提供了进一步了解 Ghidra 的选项。除了与每个 Ghidra 发布版本相关的详细下载信息外,还有一个有用的安装指南视频,指导您完成安装过程。

Ghidra docs目录 您的 Ghidra 安装包括一个目录,其中包含有关 Ghidra 的有用文档,包括可打印的菜单和热键指南(docs/CheatSheet.html),这些可以极大地简化您对 Ghidra 的介绍,还有更多。涵盖 Ghidra 初学者、中级和高级功能的教程可以在docs/GhidraClass下找到。

下载 Ghidra

获得您的免费 Ghidra 副本是一个简单的三步过程:

  1. 导航至ghidra-sre.org/

  2. 单击大红色的下载 Ghidra按钮。

  3. 将文件保存到计算机上的所需位置。

与许多简单的三步过程一样,有几个地方可能会有一些叛逆者选择略微偏离推荐路径。以下选项适用于那些希望与传统起始包有所不同的人:

  • 如果您想安装不同的版本,只需单击发布按钮,您将有下载其他已发布版本的选项。虽然某些功能可能有所不同,但 Ghidra 的基本功能应该保持不变。

  • 如果您希望安装到支持协作工作的服务器,请等到第十一章了解如何对安装进行重要更改(或者随意跳过并尝试使用server目录中的信息。)最坏的情况是,可以轻松撤销并使用简单的三步过程重新开始,并以本地 Ghidra 实例开始。

  • 铁心勇士可能希望从源代码构建 Ghidra。 Ghidra 的源代码在 GitHub 上可用,网址为github.com/NationalSecurityAgency/ghidra/

让我们继续传统的安装过程。

安装 Ghidra

那么,当您点击神奇的红色下载按钮并选择了计算机上的目标位置时,它究竟做了什么呢?如果一切顺利,您现在应该在选定的目录中有一个zip文件。对于原始的 Ghidra 版本,zip 文件的名称为 ghidra_9.0_PUBLIC_20190228.zip。我们可以分解一下命名规则。首先,9.0 是版本号。接着,PUBLIC 是发布类型(还有其他类型的发布,例如 BETA_DEV 发布)。最后是发布日期,后面跟着 .zip 文件扩展名。

这个 zip 文件实际上是包含了超过 3400 个文件的集合,这些文件构成了 Ghidra 框架。如果您对保存文件的位置满意,那么解压它(例如,右键点击并选择“全部解压”)将使您能够访问 Ghidra 的层级目录。请注意,Ghidra 需要编译一些内部数据文件,因此 Ghidra 用户通常需要对所有 Ghidra 程序子目录具有写访问权限。

Ghidra 目录布局

在开始使用 Ghidra 之前,熟悉 Ghidra 安装内容并不是必须的要求。然而,由于我们目前关注的是您下载的提取文件,让我们先简单看看基本的布局。随着您逐步使用 Ghidra 更高级的功能,对 Ghidra 目录结构的理解将变得越来越重要。以下是对 Ghidra 安装中每个子目录的简要描述。图 3-1 展示了 Ghidra 目录布局。

image

图 3-1:Ghidra 目录布局

docs 包含了有关 Ghidra 及其使用方法的一般支持文档。此目录中有两个子目录值得一提。首先,GhidraClass 子目录提供了帮助您了解 Ghidra 的教育内容。其次,languages 子目录描述了 Ghidra 的处理器规范语言 SLEIGH。SLEIGH 在第十八章中有详细讨论。

扩展 包含了有用的预构建扩展以及编写 Ghidra 扩展所需的重要内容和信息。此目录将在第十五章、第十七章和第十八章中详细讨论。

Ghidra 包含了 Ghidra 的代码。随着我们在第十二章中开始定制 Ghidra,并在第十三章至第十八章中构建新功能时,您将进一步了解此目录中的资源和内容。

GPL Ghidra 框架中的一些组件并非由 Ghidra 团队开发,而是包含了其他根据 GNU 通用公共许可证(GPL)发布的代码。GPL 目录包含与这些内容相关的文件,包括许可信息。

licenses 包含概述 Ghidra 各种第三方组件的适当和合法使用的文件。

server 支持安装 Ghidra 服务器,促进协作式 SRE。本目录将在第十一章中深入讨论。

support 作为一个汇总,包含了各种 Ghidra 专用功能和能力。作为额外内容,如果你想进一步自定义工作环境(例如,创建一个 Ghidra 启动脚本的快捷方式),这里也可以找到 Ghidra 图标(ghidra.ico)。在全书中,根据需要会讨论这个目录,以介绍各种 Ghidra 功能。

启动 Ghidra

在子目录旁边,根目录中的文件让你能够开始你的 Ghidra SRE 之旅。这个目录中还有另一个许可文件(LICENSE.txt),但更重要的是,你会找到实际上启动 Ghidra 的脚本。第一次双击 ghidraRun.bat(或者在 Linux 或 macOS 的命令行中运行等效的 ghidraRun 脚本)时,你需要同意在图 3-2 中显示的最终用户许可协议(EULA),以确认你计划根据 Ghidra 用户协议使用 Ghidra。一旦同意后,在后续启动中你将不再看到此窗口,但可以随时通过帮助菜单查看内容。

此外,系统可能会要求你提供 Java 安装路径。(如果你没有安装 Java,请参阅 docs 子目录中的安装指南,其中在 Java 注释部分提供了相关文档。)Ghidra 需要版本 11 或更高版本的 Java 开发工具包(JDK)。^(1)

image

图 3-2:Ghidra 用户协议

总结

一旦成功打开 Ghidra,你就可以继续使用它来完成一些有用的工作。在接下来的几个章节中,你将学习如何使用 Ghidra 进行基本的文件分析,了解 CodeBrowser 及其许多常见的 Ghidra 显示窗口,并学习如何配置和操作这些显示窗口,以进一步理解程序的行为。

第五章:**第二部分

基本 GHIDRA 使用方法**

第六章:启动 Ghidra

Image

现在是时候开始实际使用 Ghidra 了。本书的其余部分将介绍 Ghidra 的各种功能,以及如何利用它们来最好地满足您的逆向工程需求。在本章中,我们首先介绍启动 Ghidra 时您会看到的选项,然后描述当您打开单个二进制文件进行分析时会发生什么。最后,我们简要概述用户界面,为接下来的章节打下基础。

启动 Ghidra

每次启动 Ghidra 时,您都会短暂看到一个显示 Ghidra 徽标、构建信息、Ghidra 和 Java 版本号以及许可证信息的启动画面。如果您希望详细阅读启动画面以了解更多关于版本的信息,可以随时通过选择帮助 ▸ 关于 Ghidra 从 Ghidra 项目窗口显示它。一旦启动画面消失,Ghidra 将在“每日提示”对话框后显示 Ghidra 项目窗口,如图 4-1 所示。您可以通过点击“下一条提示”按钮浏览提示。当您准备开始工作时,关闭“每日提示”对话框。

如果您不希望看到每日提示,可以随时取消勾选对话框底部的“启动时显示提示”复选框。如果您取消勾选该框并发现自己错过了每日提示对话框,您可以通过 Ghidra 帮助菜单轻松恢复它。

image

图 4-1:启动 Ghidra

如果您关闭了“每日提示”对话框或取消勾选该框并重新启动 Ghidra,您将看到 Ghidra 项目窗口。Ghidra 使用项目环境来帮助您管理和控制与文件或文件组相关的工具和数据。当您使用它们时,这种初步介绍重点讨论了作为非共享项目组成部分的单个文件。更复杂的项目功能将在第十一章中讨论。

创建新项目

如果这是您第一次启动 Ghidra,您需要创建一个项目。如果您之前启动过 Ghidra,活动项目将是您最近使用的项目。选择文件 ▸ 新建项目可以让您指定与项目相关的环境特性。创建新项目的第一步是选择非共享项目或共享项目。在本章中,我们从非共享项目开始。做出选择后,您将看到图 4-2 中的对话框。非共享项目需要您指定项目目录和名称。

image

图 4-2:创建 Ghidra 项目

输入项目位置信息后,点击完成以完成项目创建过程。这将使您返回到项目窗口,并选中刚创建的项目,如图 4-3 所示。

image

图 4-3:Ghidra 项目窗口

Ghidra 文件加载

要进行任何有用的工作,您需要至少向新项目中添加一个文件。您可以通过选择“文件 ▸ 导入文件”并浏览到您希望导入的文件,或者直接将文件拖放到项目窗口中的文件夹来打开文件。在选择文件后,您将看到如图 4-4 所示的导入对话框。

image

图 4-4:Ghidra 导入对话框

Ghidra 会生成潜在文件类型的列表,并将这些文件类型提供给您,在对话框顶部的格式选择框中。点击格式字段右侧的“信息”按钮,将为您提供支持的格式列表,这些格式在第十七章中进行了描述。格式选择框提供了一组 Ghidra 加载器,这些加载器最适合处理所选文件。在此示例中,格式选择框中提供了两种选项:可移植执行文件(PE)和原始二进制文件。原始二进制文件选项将始终存在,因为它是 Ghidra 在加载无法识别的文件时的默认选项;这是加载任何文件的最低级别选项。当提供多个加载器选择时,除非您有与 Ghidra 判断相矛盾的特定信息,否则接受默认选择通常是一个不错的策略。

语言字段允许您指定在反汇编过程中应使用哪个处理器模块。Ghidra 的语言/编译器规范可以包括处理器类型、字节序(LE/BE)、位数值(16/32/64)、处理器变体以及编译器 ID(例如,ARM:LE:32:v7:default)。有关更多信息,请参阅第十三章中的语言/编译器规范说明以及第 396 页上的“语言定义文件”。在大多数情况下,Ghidra 会根据从可执行文件头部读取的信息选择合适的处理器。

目标文件夹字段允许您选择显示新导入文件的项目文件夹。默认情况下,显示的是顶级项目文件夹,但可以添加子文件夹以组织项目中的导入程序。您可以选择语言和目标文件夹字段右侧的扩展按钮,以查看每个字段的其他选项。您还可以编辑程序名称字段中的文本。不要被术语的变化弄混淆:程序名称是 Ghidra 用于在项目中引用导入二进制文件的名称,包括在项目窗口中的显示。默认情况下,它是导入文件的名称,但可以更改为更具描述性的名称,例如“来自星际企业号的恶意软件”。

除了图 4-4 中显示的四个字段外,你还可以通过“选项”按钮访问其他选项,以控制加载过程。这些选项依赖于所选格式和处理器。图 4-5 中显示了 ch4_example.exe,一个用于 x86 的 PE 文件,选择了默认选项。虽然通常采用默认选项是一个好的方法,但随着经验的积累,你也可以选择其他选项。例如,如果你希望将任何依赖的库导入到项目中,可以选择“加载外部库”选项。

image

图 4-5:Ghidra PE 文件加载选项

导入选项用于更精细地控制文件加载过程。并非所有输入文件类型都适用这些选项,在大多数情况下,你可以依赖默认选择。关于选项的更多信息可以在 Ghidra 帮助中找到。有关 Ghidra 导入过程和加载器的更多细节,请参见第十七章。

当你对加载选项满意并点击 OK 关闭对话框后,将会看到导入结果摘要窗口,如图 4-6 所示。这里为你提供了一个机会,可以查看所选导入选项,并查看加载器从所选文件中提取的基本信息。在《导入文件》中,我们讨论了如何在分析之前修改一些导入结果,如果你拥有在导入结果摘要窗口中未反映的附加信息。

image

图 4-6:Ghidra 导入结果摘要窗口

使用 Raw Binary 加载器

有时,Raw Binary 将是格式选择列表中唯一的选项。这是 Ghidra 告诉你它的加载器无法识别所选文件的方式。需要使用 Raw Binary 加载器的情况包括分析自定义固件镜像和可能从网络数据包捕获或日志文件中提取的漏洞载荷。在这些情况下,Ghidra 无法识别任何文件头信息来指导加载过程,因此需要你介入并执行加载器通常会自动完成的任务,例如指定处理器、位数,并在某些情况下指定特定的编译器。

例如,如果你知道二进制文件包含 x86 代码,可以在语言对话框中看到许多可供选择的选项,如图 4-7 所示。通常需要进行一些研究,有时还需要一些试验和错误,才能将语言选择缩小到适合你的二进制文件的选项。你能获得的关于该文件设计运行设备的信息将非常有用。如果你确信该文件不打算用于 Windows 系统,你应该在编译器设置中选择 gcc 或默认选项(如果可用)。

image

图 4-7:语言和编译器选择选项

如果二进制文件没有包含 Ghidra 能处理的头部信息,Ghidra 将无法识别文件的内存布局。如果你知道文件的基地址、文件偏移量或文件长度,可以将这些值输入到图 4-8 中所示的相应加载器选项字段中,或者在不输入这些附加信息的情况下继续加载文件。(这些信息可以在分析前后通过“内存映射窗口”在第 85 页中提供或调整。)

image

图 4-8:Ghidra 原始二进制文件加载选项

第十七章提供了关于手动加载和组织无法识别的二进制文件的更详细讨论。

使用 Ghidra 分析文件

从本质上讲,Ghidra 本质上是一个由插件库控制的数据库应用程序,每个插件具有独特的功能。所有项目数据都使用一个自定义数据库存储,该数据库会随着用户向项目中添加信息而增长和演变。Ghidra 提供的各种显示仅仅是数据库的视图,展示了对软件逆向工程师有用的格式中的信息。用户对数据库所做的任何修改都会反映在视图中,并保存在数据库中,但这些更改对原始可执行文件没有任何影响。Ghidra 的强大之处在于它包含的用于分析和操作数据库内数据的工具。

CodeBrowser 作为 Ghidra 中众多工具的基石,具有独特的功能,帮助你保持窗口的有序,添加和删除工具,重新排列内容,并记录你的操作过程。默认情况下,CodeBrowser 会打开包含程序树、符号树、数据类型管理器、列表、反编译器和控制台的窗口。这些窗口和其他显示内容将在第五章中介绍。

上述过程可以用来创建项目并向其中添加文件,但分析的真正工作尚未开始。当你在 Ghidra 项目窗口中双击一个文件时,会打开 CodeBrowser 窗口,如图 4-9 所示。如果这是你第一次选择已导入的文件,你将看到一个选项,允许 Ghidra 自动分析该文件。图 4-10 中展示了使用分析选项对话框进行自动分析的示例。在涉及来自常见平台并使用常见编译器构建的二进制文件的绝大多数情况下,自动分析可能是正确的首选。你可以随时通过点击 CodeBrowser 窗口右下角的红色停止按钮来停止自动分析过程。(该按钮仅在自动分析期间可见。)

image

图 4-9:Ghidra CodeBrowser 窗口

image

图 4-10:分析选项对话框

请记住,如果你不满意 Ghidra 的自动分析结果,你总是可以通过关闭 CodeBrowser 并选择不保存更改来放弃工作,这时你可以重新打开文件并尝试不同的自动分析选项组合。修改自动分析选项的最常见原因是遇到结构特别复杂的文件,例如混淆的二进制文件,或是由可能不为 Ghidra 所知的编译器或操作系统构建的二进制文件。

请注意,如果你正在打开一个非常大的二进制文件(例如 10MB 或更大),Ghidra 可能需要几分钟到几小时才能完成自动分析。在这种情况下,你可以选择禁用或为一些更高要求的分析器设置分析超时(例如,反编译器切换分析、反编译器参数 ID 和堆栈)。如 图 4-10 所示,突出显示一个分析器将显示该分析器的描述,其中可能包含有关该分析器运行时间的有用警告。此外,你还会看到选项框,提供你控制个别分析器一些行为方面的机会。任何你选择禁用或超时的分析,都可以稍后通过 Ghidra 的分析菜单下的选项重新运行。

自动分析警告

一旦加载器开始分析文件,它可能会在分析过程中遇到一些问题,认为这些问题足够重要,需要向你发出警告。例如,构建时未关联程序数据库(PDB)文件的 PE 文件就是一种情况。在这种情况下,分析完成后,你将看到一个自动分析汇总对话框,里面包含了总结任何遇到的问题的消息(见 图 4-11)。

image

图 4-11:自动分析汇总对话框

在大多数情况下,消息只是信息性的。在某些情况下,消息是指导性的,提供了解决问题的建议,可能是通过安装一个可选的第三方工具,以便 Ghidra 在未来使用。

在 Ghidra 完成自动分析文件后,你可以看到导入汇总信息已经补充了有关文件的新信息,如 图 4-12 所示。

image

图 4-12:Ghidra 帮助 ▸ 关于 ch4_example.exe 的导入汇总信息视图

自动分析结果

Ghidra 的自动分析通过运行每个选定的分析器来执行,分析器会在你新加载的二进制文件上进行分析。分析选项对话框以及 Ghidra 帮助提供了每个分析器的描述。默认选择的分析器是因为 Ghidra 用户历史上发现它们在各种文件类型中最为实用。在接下来的章节中,我们将讨论在文件初次加载及随后的自动分析过程中,从二进制文件中提取的一些最有用的信息。

编译器识别

确定构建某个软件所使用的编译器有助于我们理解二进制文件中使用的函数调用约定,并确定该二进制文件可能链接的库。如果在加载文件时能够识别出编译器,Ghidra 的自动分析将会结合特定于该编译器的行为知识。你在使用不同的编译器和不同的编译选项时可能观察到的差异,详细讨论见第二十章。

函数参数和局部变量识别

在每个已识别的函数内(通过符号表条目和作为调用指令目标的地址识别),Ghidra 对堆栈指针寄存器的行为进行详细分析,以识别对堆栈中变量的访问并理解函数的堆栈帧布局。根据这些变量是作为函数内的局部变量,还是作为函数调用过程中传入函数的堆栈分配参数,自动为这些变量生成名称。堆栈帧的相关内容在第六章中有进一步讨论。

数据类型信息

Ghidra 利用对常见库函数及其相关参数的知识,来识别每个函数中使用的函数、数据类型和数据结构。这些信息会被添加到符号树、数据类型管理器窗口以及列表窗口中。这个过程通过提供本来需要从各种应用程序编程接口(API)参考中手动获取和应用的信息,节省了你大量的时间。关于 Ghidra 如何处理库函数及相关数据类型的详细信息,可以参见第八章。

初始分析期间的桌面行为

在初步分析新打开的文件时,CodeBrowser 桌面上会发生大量活动。你可以通过观察 CodeBrowser 窗口右下角的分析更新,来理解这一分析过程。这也可以让你跟踪分析进度。如果你不是速读专家,可以打开相关的 Ghidra 日志文件,慢慢浏览活动。你可以从 Ghidra 项目窗口通过选择帮助 ▸ 显示日志来打开日志文件。(注意,显示日志菜单选项仅在 Ghidra 项目 ▸ 帮助菜单中可用,而不在 CodeBrowser ▸ 帮助菜单中。)

以下输出来自 Ghidra 在自动分析ch4_example.exe时生成的日志文件,代表了在自动分析过程中生成的消息。这些消息构成了分析过程的叙述,并提供了对 Ghidra 执行的操作序列的深入了解,以及每个任务所需的时间:

2019-09-23 15:38:26 INFO  (AutoAnalysisManager) -----------------------------

    ASCII Strings                              0.016 secs

    Apply Data Archives                        1.105 secs

 Call Convention Identification             0.018 secs

    Call-Fixup Installer                       0.000 secs

    Create Address Tables                      0.012 secs

    Create Function                            0.000 secs

    Data Reference                             0.014 secs

    Decompiler Parameter ID                    2.866 secs

    Decompiler Switch Analysis                 2.693 secs

    Demangler                                  0.004 secs

    Disassemble Entry Points                   0.016 secs

    Embedded Media                             0.031 secs

    External Entry References                  0.000 secs

    Function ID                                0.312 secs

    Function Start Search                      0.051 secs

    Function Start Search After Code           0.006 secs

    Function Start Search After Data           0.005 secs

    Non-Returning Functions - Discovered       0.062 secs

    Non-Returning Functions - Known            0.000 secs

    PDB                                        0.000 secs

    Reference                                  0.025 secs

    Scalar Operand References                  0.074 secs

    Shared Return Calls                        0.000 secs

    Stack                                      0.063 secs

    Subroutine References                      0.016 secs

    Windows x86 PE Exception Handling          0.000 secs

    Windows x86 PE RTTI Analyzer               0.000 secs

    WindowsResourceReference                   0.100 secs

    X86 Function Callee Purge                  0.001 secs

    x86 Constant Reference Analyzer            0.509 secs

-----------------------------------------------------

     Total Time   7 secs

-----------------------------------------------------

2019-09-23 15:38:26 DEBUG (ToolTaskManager)   task finish (8.128 secs)

2019-09-23 15:38:26 DEBUG (ToolTaskManager)   Queue - Auto Analysis

2019-09-23 15:38:26 DEBUG (ToolTaskManager)   (0.0 secs)

2019-09-23 15:38:26 DEBUG (ToolTaskManager)   task Complete (8.253 secs)

即使自动分析尚未完成,你也可以开始浏览各种数据展示。当自动分析完成后,你可以安全地对项目文件进行任何更改。

保存你的工作并退出

当你需要从分析中休息时,保存你的工作是一个好主意。你可以通过以下任何一种方式在 CodeBrowser 窗口中轻松完成:

  • CodeBrowser 文件菜单中使用其中一个保存选项。

  • 点击CodeBrowser工具栏中的保存图标。

  • 关闭CodeBrowser窗口。

  • Ghidra窗口中保存项目。

  • 通过Ghidra 文件菜单退出 Ghidra。

在每种情况下,系统都会提示你保存任何修改过的文件。关于如何更改 CodeBrowser 和其他 Ghidra 工具的外观和功能的更详细信息,请参阅第十二章。

Ghidra 桌面技巧与窍门

Ghidra 显示了大量信息,其桌面可能会变得杂乱。以下是一些快速技巧,帮助你更好地利用桌面:

  • 你为 Ghidra 分配的屏幕空间越多,你就会越高兴。利用这一点为购买一台超大屏幕显示器(或者四个)提供理由!

  • 别忘了使用 CodeBrowser 中的窗口菜单来打开新的视图或恢复你不小心关闭的数据展示。许多窗口也可以通过 CodeBrowser 工具栏上的工具按钮打开。

  • 当你打开一个新窗口时,它可能会出现在现有窗口的前面。当这种情况发生时,查找窗口顶部或底部的选项卡,它们允许你在窗口之间切换。

  • 你可以关闭任何窗口并根据需要重新打开它,也可以将其拖动到 CodeBrowser 桌面上的新位置。

  • 可以使用“编辑 ▸ 工具选项”来控制显示的外观,并找到相关的显示选项。

虽然这些提示只是冰山一角,但它们在你开始使用 Ghidra CodeBrowser 桌面时应该会有所帮助。更多关于 CodeBrowser 的技巧和窍门,包括快捷键和工具栏选项,请参见第五章。

总结

熟悉 CodeBrowser 桌面界面将极大提升你使用 Ghidra 的体验。逆向工程二进制代码本身就足够困难,何况还要与工具作斗争。你在初始加载阶段选择的选项以及 Ghidra 执行的相关分析为之后的所有分析奠定了基础。此时,你可能对 Ghidra 已经为你完成的工作感到满意,对于简单的二进制文件,这可能就是你所需要的一切。另一方面,如果你想了解如何在逆向工程过程中获得更多控制权,那么你已经准备好深入探索 Ghidra 的各类数据展示功能。在接下来的章节中,你将接触到每个主要展示界面、你会在什么情况下使用它们,以及如何掌握这些工具和展示界面,从而优化你的工作流程。

第七章:GHIDRA 数据展示**

Image

此时,您应该对创建项目、将二进制文件加载到项目中以及启动自动分析有一定信心。一旦 Ghidra 的初始分析阶段完成,您就可以开始掌控分析过程。如第四章中所讨论的,启动 Ghidra 时,您的冒险旅程始于 Ghidra 项目窗口。当您在项目中打开一个文件时,会打开第二个窗口。这就是 Ghidra 的 CodeBrowser,它是您进行 SRE 工作的主要基地。您已经使用 CodeBrowser 自动分析了文件;现在我们将深入了解 CodeBrowser 菜单、窗口和基本选项,以增强您对 Ghidra 功能的认知,并帮助您创建符合个人工作流程的 SRE 分析环境。让我们从 Ghidra 的主要数据展示开始。

CodeBrowser

您可以通过从 Ghidra 项目窗口选择工具 ▸ 运行工具 ▸ CodeBrowser 来打开 CodeBrowser 窗口。虽然 CodeBrowser 通常通过选择一个用于分析的文件来打开,但我们现在打开一个空实例,以便在没有特定文件相关内容影响显示的情况下演示功能和配置选项,如图 5-1 所示。在默认配置下,CodeBrowser 有六个子窗口。在详细了解这些显示相关的内容之前,让我们先花点时间看看 CodeBrowser 菜单及其相关功能。

image

图 5-1:未填充的 CodeBrowser 窗口

在 CodeBrowser 窗口的顶部是主菜单,下方是工具栏。工具栏提供一些最常用菜单选项的一键快捷方式。由于我们目前没有加载文件,因此在本节中我们将重点介绍那些与已加载文件无关的菜单选项。其他菜单操作将在与 SRE 过程相关的实际应用中演示和解释。

文件 提供大多数文件操作菜单中预期的基本功能,包括打开/关闭、导入/导出、保存和打印的选项。此外,还有一些专门针对 Ghidra 的选项,例如工具选项,允许您保存和操作 CodeBrowser 工具,和解析 C 源代码,这可以通过从 C 头文件提取数据类型信息来辅助反编译过程。(参见 “解析 C 头文件” 章节,第 269 页)

编辑 包含一个适用于所有子窗口外部的命令:编辑 ▸ 工具选项命令,该命令打开一个新窗口,允许您控制与 CodeBrowser 提供的众多工具相关的参数和选项。与控制台相关的选项显示在图 5-2 中。恢复默认按钮(恢复到默认设置)始终可以在右下角找到。

image

图 5-2:CodeBrowser 控制台编辑选项

分析 允许您重新分析二进制文件或选择性地执行单独的分析任务。基本的分析选项在“使用 Ghidra 分析文件”一节中进行了介绍,见第 48 页。

导航 方便在文件内进行导航。此菜单提供许多应用程序支持的基本键盘功能,并为二进制文件添加了特殊的导航选项。虽然该菜单提供了一种通过文件移动的方法,但在掌握了可用的多种导航选项后,您可能会更常使用工具栏选项或快捷键(列在每个菜单选项的右侧)。

搜索 提供对内存、程序文本、字符串、地址表、直接引用、指令模式等的搜索功能。基本的搜索功能在“搜索”一节中进行了介绍,见第 114 页。更专业的搜索概念会在后续章节的许多示例中进行介绍。

选择 提供了识别文件中某个部分以供特定任务处理的功能。选择可以基于子程序、函数、控制流,或仅仅是通过突出显示文件中所需的部分。

工具 包含一些有趣的功能,允许您将额外的 SRE 资源放置在桌面上。最有用的功能之一是处理器手册选项,它会显示与当前文件关联的处理器手册。如果您尝试打开缺失的处理器手册,系统会提供一种方法来包含该手册,如图 5-3 所示。

image

图 5-3:缺失处理器手册信息

窗口 允许您根据工作流程配置 Ghidra 的工作环境。本章大部分内容将介绍和研究默认的 Ghidra 窗口以及一些其他您会觉得有用的窗口。

帮助 提供了丰富、组织良好且非常详细的选项。帮助窗口支持搜索、不同的视图、收藏夹、缩放以及打印和页面设置选项。

CodeBrowser 窗口

扩展的窗口菜单可以在图 5-4 的中央看到。默认情况下,启动 CodeBrowser 时会打开六个可用窗口:程序树、符号树、数据类型管理器、列表、控制台和反编译器。每个窗口的名称显示在关联窗口的左上角。每个窗口都作为窗口菜单中的一个选项出现,其中一些在菜单下方的工具栏上也有相应的图标。(例如,我们在图 5-4 中使用箭头突出显示了工具栏选项和菜单选项,用于打开和访问反编译器窗口。)

热键、按钮和工具栏,哦,我的天!

在 Ghidra 中,几乎所有常用的操作都有对应的菜单项、快捷键和工具栏按钮。如果没有,你可以创建它们!Ghidra 的工具栏是高度可配置的,快捷键与菜单操作的映射也是如此。(参见 CodeBrowser 编辑 ▸ 工具选项 ▸ 快捷键绑定,或者只需将鼠标悬停在一个命令上并按 F4。)如果这还不够,Ghidra 还提供了良好的上下文敏感菜单,以响应右键点击。这些上下文敏感菜单虽然没有提供给定位置的所有可用操作列表,但它们确实能很好地提醒你最常用的操作。这个灵活性允许你使用最适合自己的方式来执行操作根据你发现 Ghidra 如何为你工作来定制环境。

image

图 5-4:突出显示显示反编译器窗口的 CodeBrowser 窗口选项

让我们深入了解这六个默认窗口,理解它们在 SRE 过程中的基本重要性。

窗口内部与外部

当你开始探索 Ghidra 的各个窗口时,你会注意到,默认情况下,有些窗口会在 CodeBrowser 桌面内打开,而其他窗口则作为新的浮动窗口在 CodeBrowser 桌面外打开。让我们花一点时间来讨论这些“内部”与“外部”窗口在 Ghidra 环境中的含义。

“外部”窗口漂浮在 CodeBrowser 环境外,并且可以是连接的或独立的。这些窗口允许你与 CodeBrowser 一起并排查看它们的内容。这些窗口的示例包括功能图、注释和内存映射。

接下来,有三种不同类别的“内部”窗口:

  • 默认在 CodeBrowser 中打开的窗口(例如,符号树和列表)

  • 与默认的 CodeBrowser 窗口叠加的窗口(例如,字节)

  • 与其他 CodeBrowser 窗口创建或共享空间的窗口(例如,等价项和外部程序)

当你打开一个与另一个打开的窗口共享空间的窗口时,它会显示在现有窗口的前面。所有共享同一空间的窗口都会被标签化,以便在窗口之间快速导航。如果你希望同时查看两个共享空间的窗口,可以单击窗口的标题栏并将其拖动到 CodeBrowser 窗口外。

但要小心!将窗口移回 CodeBrowser 窗口并不像将它们移出去那么容易。(有关详细信息,请参见 “重新排列窗口” 第 242 页)

我的窗口在哪里?

Ghidra 有很多窗口,跟踪它们在任何特定时间的位置可能是一个挑战。随着你打开更多的窗口,其他窗口可能会消失在 CodeBrowser 中或桌面上,这使得定位更加复杂。Ghidra 提供了一个独特的功能来帮助你找到那些丢失的窗口。点击相关的工具栏图标或菜单项将把选定的窗口移到最前面,但这可能还不够。如果你继续点击窗口的工具栏图标,丢失的窗口将通过振动、改变字体大小或颜色、缩放、旋转以及其他令人兴奋的动作来吸引你的注意,帮助你找到它。如果你感到无聊,你也可以向它挥挥手。

Listing 窗口

被称为反汇编窗口的 Listing 窗口将是你查看、操作和分析 Ghidra 生成的反汇编代码的主要工具。文本显示呈现了程序的完整反汇编列表,并提供了查看二进制文件数据区域的主要方式。

ch5_example1.exe 的 CodeBrowser 显示如 图 5-5 所示,采用其默认配置。Listing 窗口左侧的边距提供有关文件的重要信息,以及你在文件中的位置。Listing 窗口右侧(即垂直滚动条右边)有一个额外的标记区域,提供重要信息和导航功能。滚动条指示你在文件中的位置,可以用于导航。在滚动条的右侧是一些信息显示,包括书签,提供有关文件的额外见解。

image

图 5-5:加载了 ch5_example1.exe 的默认 CodeBrowser 窗口

你最喜欢的边距条

在文件自动分析后,你可以使用信息边距条帮助你导航并进一步分析文件。默认情况下,只有导航栏会显示。你可以选择通过使用 Listing 窗口右上角的切换概述边距工具按钮(参见图 5-6)来添加(或隐藏)概述栏和熵条。不管显示了哪些边距条,所有边距条左侧的导航标记都会提醒你当前所在文件的位置。左键点击任何边距条中的位置,将把你定位到文件中的该位置,并更新 Listing 窗口的内容。

现在你已经知道如何控制边距条的显示(与隐藏),让我们来看看每个边距条显示了什么,以及你如何在 SRE 过程中使用它:

导航标记区域 允许您在文件中移动,但它还有另一个非常重要的功能:如果右键单击导航标记区域,您将看到可以与文件关联的标记和书签类别。通过选择和取消选择标记类型,您可以控制在导航条中显示的内容。这使您可以轻松地浏览特定类型的标记(例如高亮显示)。

概览条 为您提供文件内容的重要视觉信息。概览条中的水平带表示程序的颜色编码区域。虽然 Ghidra 为常见类别(如函数、外部引用、数据和指令)提供默认颜色,但您可以通过“编辑 ▸ 工具选项”菜单控制颜色方案。默认情况下,将鼠标悬停在某个区域上时,您可以查看该区域的详细信息,包括区域类型和相关地址(如果适用)。

熵条 提供了一个独特的 Ghidra 功能:它根据周围文件内容对文件内容进行“刻板化”。如果某个区域内变化很小,则分配较低的熵值。如果该区域具有较高的随机性,则相应的熵值较高。将鼠标悬停在熵条上的水平带上,可以查看该区域的熵值(介于 0.0 到 8.0 之间)、类型(例如,.text),以及文件中相关的地址。高度可配置的熵条可以帮助确定该带中最可能的内容。有关此功能及其背后数学原理的更多信息,可以在 Ghidra 帮助菜单中找到。

图 5-6 详细说明了特定于列表窗口的工具按钮。在 图 5-7 中,我们扩展并放大了列表窗口,以便调查显示的内容。反汇编以线性方式呈现,最左列默认显示虚拟地址。

image

图 5-6:列表窗口工具按钮

image

图 5-7:带标签的示例工件的列表窗口

在列表窗口中,有几个项目值得您注意。窗口最左侧的灰色带是边距标记器。它用于指示您当前在文件中的位置,并包含点标记和区域标记,这些在 Ghidra 帮助中有详细描述。在此示例中,当前文件位置(004011b6)通过小黑箭头在边距标记器中显示。

在边缘标记的右侧区域用于图形化地描述函数内的非线性流动。^(1) 当控制流指令的源地址或目标地址在列出窗口中可见时,相关的流箭头将会出现。实心箭头表示无条件跳转,而虚线箭头表示条件跳转。将鼠标悬停在流线上的时候,会弹出一个工具提示,显示流的起始地址和结束地址以及流的类型。当跳转(无条件或条件)将控制转移到程序中较早的地址时,这通常意味着有一个循环。此行为在图 5-7 中通过从地址004011cf004011c5的流箭头进行展示。你可以通过双击相关的流箭头轻松导航到任何跳转的源地址或目标地址。

图 5-7 顶部的声明展示了 Ghidra 关于函数栈帧布局的最佳估计。^(2) Ghidra 通过对栈指针和函数内部任何栈帧指针的行为进行详细分析,计算出函数栈帧(局部变量)的结构。栈显示将在第六章中进一步讨论。

列表通常会有许多由XREF标示的数据和代码交叉引用,这些可以在图 5-7 的右侧看到。每当反汇编中的一个位置引用另一个位置时,就会创建一个交叉引用。例如,地址 A 的指令跳转到地址 B 的指令时,会在 A 到 B 之间创建一个交叉引用。将鼠标悬停在引用地址上时,会弹出一个参考提示,显示引用位置。参考提示与列出窗口的布局相同,但具有黄色背景(类似于工具提示弹出)。弹出窗口允许你查看内容,但不允许你跟踪引用。交叉引用在第九章中进行详细讨论。

创建额外的反汇编窗口

如果你希望同时查看两个函数的列出,可以通过在列出工具栏中使用快照图标来打开另一个反汇编窗口(参见图 5-6)。第一个打开的反汇编窗口在文件名之前会有前缀Listing:。所有随后的反汇编窗口将标题为[Listing: ],表示它们与主显示窗口是断开的。快照是断开的,因此你可以自由地在它们之间导航,而不会影响其他窗口。

配置列出窗口

反汇编列表可能被分解为多个组成字段,包括助记符字段、地址字段和注释字段等信息。我们迄今看到的列表是由一组默认字段组成的,这些字段提供了关于文件的重要信息。然而,有时默认视图并没有提供你希望看到的信息。这时,浏览器字段格式化器便派上用场了。

浏览器字段格式化器为你提供了自定义超过 30 个字段的能力,确保你对列表窗口的外观拥有完全控制。你可以通过点击列表工具栏中的按钮来激活浏览器字段格式化器(参见图 5-6)。这将打开一个强大的子菜单和布局编辑器,如图 5-8 所示,位于列表顶部。浏览器字段格式化器允许你控制地址断点、板注释、函数、变量、指令、数据、结构和数组的外观。在这些类别中,有可以调整、调优和控制的字段,以便为你创建完美的列表格式。我们主要使用列表的默认格式,但你应该探索浏览器字段格式化器,看看是否有任何选项可以帮助你更好地理解列表窗口的内容。

image

图 5-8:激活浏览器字段格式化器后的列表窗口

Ghidra 函数图形视图

虽然汇编列表既有趣又富有信息,但通过查看基于图形的显示,程序的流程可能更容易理解。你可以通过选择窗口 ▸ 函数图形或点击 CodeBrowser 工具栏中的相关图标来打开与 CodeBrowser 相关联的函数图形窗口。与图 5-7 中函数对应的函数图形窗口如图 5-9 所示。图形视图有点像程序流程图,因为函数被拆解为基本块,让你可以从一个块到另一个块可视化函数的控制流。^(3)

image

图 5-9:来自图 5-7 的列表的图形视图

在屏幕上,Ghidra 使用不同颜色的箭头来区分函数块之间的各种流动类型。此外,当你将鼠标悬停在箭头上时,箭头会变成动画,指示流动方向。以条件跳转结束的基本块会生成两种可能的流:Yes edge 箭头(即,测试条件成立)默认为绿色,而 No edge 箭头(即,测试条件未成立)默认为红色。以一个潜在的后继块结束的基本块使用 Normal edge(默认为蓝色)箭头指向下一个要执行的块。你可以点击任何箭头查看一个块到另一个块之间的关联转换。由于图形视图和列表视图默认是同步的,当你在列表视图和图形视图之间切换和导航时,文件位置通常会保持一致。例外情况请参见第十章以及 Ghidra 帮助。

在图形模式下,Ghidra 一次显示一个函数。Ghidra 通过使用传统的图像交互技术,如平移和缩放,来帮助你在图形中进行导航。较大或较复杂的函数可能会导致图形变得极其杂乱,难以导航,这时卫星视图可以为你提供帮助。默认情况下,卫星视图位于图形窗口的右下角,可以作为一个有价值的工具,帮助你提供一定的情境意识(参见图 5-9)。

卫星导航

卫星视图始终显示图形的完整块结构,并带有一个高亮框,表示当前在反汇编窗口中查看的图形区域。点击卫星视图中的任何块会将图形聚焦于该块。高亮框起到放大镜的作用,可以拖动它到概览窗口的任何位置,从而快速重新定位图形视图到图形上的任何位置。除了提供在函数图窗口中导航的方式外,这个神奇的窗口还有其他功能,可能在你查看文件时既能对你有所帮助,也可能会对你造成干扰。

这个窗口会占据你函数图窗口中的宝贵空间,可能会隐藏你想要查看的重要块和内容。解决此问题有两种方法。你可以右键点击卫星视图,取消选中“停靠卫星视图”复选框。这将把卫星视图及其完整功能移出函数图窗口。任何时候重新勾选此选项,卫星视图会回到函数图窗口的原始位置。

第二个选项是隐藏卫星视图,前提是你不需要它来进行导航。这是右键上下文菜单中的另一个复选框。当你隐藏卫星视图时,函数图窗口的右下角会出现一个小图标。点击此图标将恢复卫星视图。

当卫星视图可见时,它可能会导致主视图的响应变得比预期的慢。隐藏卫星视图可以帮助提高响应速度。

工具连接

工具可以协同工作或独立工作。我们已经看到列表窗口和功能图窗口如何共享数据,以及在一个窗口中发生的事件如何影响另一个窗口。如果在功能图窗口中选择某个块,相应的代码将在列表窗口中高亮显示。相反,在列表窗口中导航功能时,将导致功能图窗口更新。这是许多自动发生的双向工具连接之一。Ghidra 还支持单向连接,并能够通过工具事件的生产者/消费者模型手动连接和断开工具。在本书中,我们重点介绍 Ghidra 提供的双向自动工具连接。

除了使用卫星视图进行导航外,您还可以通过多种方式在功能图窗口中操作视图,以适应您的需求:

平移 首先,除了使用卫星视图快速重新定位图形外,您还可以通过点击并拖动背景来重新定位图形,以改变图形视图。

缩放 您可以使用传统的键盘方法,如 CTRL/COMMAND、鼠标滚轮或关联的快捷键进行缩放。如果您缩放得太远,可能会超过绘制阈值,此时块内容将不再显示。每个块只会变成一个彩色矩形。在某些情况下,特别是在与列表窗口并排工作时,这可能是有利的,因为它提高了渲染功能图的速度。

重新排列块 您可以通过点击目标块的标题栏并将其拖动到新位置来重新排列图形中的单个块。在移动块时,块之间的所有链接都会被保留。如果您在某个时刻希望恢复图形的默认布局,可以通过选择功能图工具栏中的刷新图标来实现。

分组和折叠块 块可以单独或与其他块一起分组,并且可以折叠以减少显示中的杂乱。分组会导致块折叠。折叠块是跟踪已分析块的一个简便方法。您可以通过选择块工具栏最右侧的分组图标来折叠任何块。如果选择多个块并使用此选项,它们将被折叠,并且相关块的列表将显示在堆叠窗口中。有关形成/解散分组以及在新形成的分组上执行操作的一些细节,请参见 Ghidra 帮助。

自定义图形显示

为了帮助您的分析,Ghidra 在每个函数图节点的顶部提供了一个菜单栏,允许您控制该特定节点的显示。您可以控制节点的背景/文本颜色,跳转到 XREF,查看该图节点的完整窗口列表,并使用分组功能将节点合并和折叠。(请注意,更改函数图中块的背景也会更改列表窗口中的背景。)如果您同时使用列表窗口和函数图窗口,这些功能可能没有必要,但这些自定义选项可能会很有用,值得进一步探索。这些选项将在第十章中进一步讨论。

由于基于图形的显示会在 CodeBrowser 外部的窗口中打开,您可以并排查看这两种显示。由于这些窗口之间有连接,在一个窗口中移动位置时,另一个窗口中的位置标记也会随之移动。虽然许多用户倾向于选择一种视图来可视化程序流程,但您并不需要只选择其中一种。同时请记住,您对图形视图和文本视图的控制远远超出了这些示例。有关 Ghidra 图形功能的更多内容,请参见第十章,有关操作 Ghidra 视图选项的更多信息,请参见 Ghidra 帮助文档。

在接下来的五章中,我们主要关注示例的列表显示,必要时辅以图形显示,以增强清晰度。在第六章中,我们将重点介绍如何理解 Ghidra 反汇编,在第七章中,我们将讨论如何操作列表显示,以清理和注释反汇编内容。

移动操作

除了传统的文件导航方式(向上箭头、向下箭头、向上翻页、向下翻页等),Ghidra 还提供了针对 SRE 过程的特定导航工具。导航工具栏中的图标(如图 5-10 所示)使您可以轻松地在程序中移动。让我们来看看这些为逆向工程师提供服务的图标。

image

图 5-10:CodeBrowser 导航工具栏

最左侧是方向图标。这个箭头在向上和向下之间切换,控制其他所有导航图标的方向。接下来的八个图标将帮助您浏览图 5-11 中显示的各个目标。

image

图 5-11:导航工具栏定义

与仅仅将您推进到列表中的下一个数据项不同,选择数据选项会跳过相邻数据,直接带您到下一个不相邻的数据的开始位置。指令和未定义项表现出相同的行为。

导航工具栏最右侧的下拉箭头显示一个列表,允许你选择特定的书签类型以便快速导航。虽然这些快捷导航主要用于列出窗口,但它们在所有与列出窗口连接的窗口中都有效。在这些窗口中进行导航时,所有连接的窗口会同步导航。

程序树窗口

让我们回到默认 CodeBrowser 窗口的讨论,简要查看程序树窗口,如图 5-12 所示。

image

图 5-12:程序树窗口

该窗口显示了你程序的文件夹和片段结构,并提供了在自动分析过程中精细调整组织结构的功能。片段是 Ghidra 中指代一段连续地址范围的术语。片段之间不能重叠。片段的更传统名称是程序段(例如,.text.data.bss)。与程序树相关的操作包括以下内容:

  • 创建文件夹/片段

  • 展开/折叠/合并文件夹

  • 添加/删除文件夹/片段

  • 在列出窗口中识别内容并移动到片段

  • 按名称/地址排序

  • 选择地址

  • 复制/剪切/粘贴片段/文件夹

  • 重新排序文件夹

程序树窗口是一个连接窗口,因此在窗口中点击某个片段会将你导航到列出窗口中的相应位置。有关程序树窗口的更多信息,请参阅 Ghidra 帮助文档。

符号树窗口

当你将文件导入 Ghidra 项目时,系统会选择一个 Ghidra 加载模块来加载文件内容。如果二进制文件中存在符号表信息,加载器能够提取该符号表信息(在第二章中讨论)并在符号树窗口中显示,如图 5-13 所示。符号树窗口包括与程序相关的导入、导出、函数、标签、类和命名空间。接下来的部分将讨论这些类别及相关的符号类型。

image

图 5-13:CodeBrowser 符号树窗口

符号树窗口底部的过滤器可以控制所有六个符号树文件夹。随着你对所分析的文件越来越熟悉,这个功能会变得更加有价值。此外,你会发现符号树窗口提供了与命令行工具类似的功能,如 objdump (-T)、readelf (-s) 和 dumpbin (/EXPORTS)。

导入

在符号树窗口中的Imports文件夹列出了正在分析的二进制文件导入的所有函数。仅当二进制文件使用共享库时,此文件夹才相关——静态链接的二进制文件没有外部依赖,因此没有导入项。Imports文件夹列出了导入的库,每个条目表示从该库导入的项(函数或数据)。单击符号树视图中的任何符号会使所有相关显示跳转到选定的符号。在我们示例中的 Windows 二进制文件中,点击Imports文件夹中的GetModuleHandleA将使反汇编窗口跳转到GetModuleHandleA的导入地址表条目,在这个例子中它位于地址0040e108,如图 5-14 所示。

image

图 5-14:导入地址表条目及其在列表窗口中的相关位置

需要记住的一个重要点是,Imports 类别只显示二进制文件的导入表中命名的符号。二进制文件选择通过诸如dlopen/dlsymLoadLibrary/GetProcAddress等机制自行加载的符号将不会在符号树窗口中列出。

导出

Exports文件夹列出了文件的入口点。这些入口点包括程序的执行入口点,通常在其头部部分中指定,以及文件导出供其他文件使用的任何函数和变量。导出的函数通常出现在共享库中,例如 Windows DLL 文件。导出的条目按名称列出,当选择导出项时,相应的虚拟地址将在列表窗口中突出显示。对于可执行文件,Exports文件夹始终至少包含一个条目:程序的执行入口点。根据二进制文件的类型,Ghidra 可能将此符号命名为entry_start

函数

Functions 文件夹包含了 Ghidra 在二进制文件中识别的每个函数的列表。在符号树窗口中,将鼠标悬停在函数名上会弹出一个详细信息框,显示有关该函数的详细信息,如 图 5-15 所示。在加载过程中,加载器利用各种算法,包括文件结构分析和字节序列匹配,以推断用于创建文件的编译器。在分析阶段,Function ID 分析器利用编译器识别信息,进行基于哈希的函数体匹配,以识别可能已经链接到二进制文件中的库函数体。当哈希匹配成功时,Ghidra 从哈希数据库(包含在 Ghidra .fidbf 文件中)中获取匹配函数的名称,并将其添加为函数符号。哈希匹配对于去符号化的二进制文件特别有用,因为它提供了一种独立于符号表存在的符号恢复方式。有关此功能的更深入讨论,请参见 “Function IDs” 章节中的 第 272 页。

image

图 5-15:符号树函数文件夹弹出框

标签

Labels 文件夹是 Functions 文件夹的数据等价物。任何包含在二进制符号表中的数据符号都将列在 Labels 文件夹中。此外,每当你将新的标签名添加到数据地址时,该标签将被添加到 Labels 文件夹中。

类别

Classes 文件夹包含 Ghidra 在分析阶段识别的每个类的条目。在每个类下,Ghidra 列出了已识别的数据和方法,这些数据和方法有助于你理解类的行为。C++ 类及 Ghidra 用于填充类文件夹的结构在 第八章 中有更详细的讨论。

命名空间

Namespaces 文件夹中,Ghidra 可能会创建新的命名空间以提供组织结构,并确保分配的名称在二进制文件中不会冲突。例如,可能会为每个识别到的外部库或每个使用跳转表的 switch 语句创建命名空间(允许跳转表标签在其他 switch 语句中重复使用而不发生冲突)。

数据类型管理器窗口

数据类型管理器窗口允许你通过使用数据类型档案系统来定位、组织并应用数据类型到文件中。档案代表了 Ghidra 从大多数流行编译器附带的头文件中收集的预定义数据类型的积累知识。通过处理头文件,Ghidra 理解常见库函数所期望的数据类型,并能够相应地注释你的反汇编和反编译列表。同样,Ghidra 从这些头文件中理解复杂数据结构的大小和布局。所有这些信息都被收集到档案文件中,并在每次分析二进制文件时应用。

回顾图 5-4,你可以看到内建类型树的根节点,其中包含诸如int等无法更改、重命名或在数据类型档案中移动的原始类型,即使没有加载程序,它也会在数据类型管理器窗口(CodeBrowser 窗口的左下角)中显示。除了内建类型,Ghidra 还支持创建用户定义的数据类型,包括结构体、联合体、枚举和类型定义。它还支持数组和指针作为派生数据类型。

每个你打开的文件在数据类型管理器窗口中都有一个相关条目,如前文图 5-5 所示。该文件夹的名称与当前文件相同,文件夹内的条目是特定于当前文件的。

数据类型管理器窗口显示每个已打开的数据类型档案的节点。档案可以自动打开,例如当程序引用一个档案时,或者由用户手动打开。数据类型和数据类型管理器将在第八章和第十三章中更详细地介绍。

控制台窗口

位于 CodeBrowser 窗口底部的控制台窗口作为 Ghidra 的插件和脚本输出区,包括你自己开发的插件和脚本,是查看 Ghidra 在处理文件时执行任务信息的地方。开发脚本和插件将在第十四章和第十五章中介绍。

反编译器窗口

反编译器窗口允许你通过连接的窗口同时查看和操作二进制文件的汇编和 C 语言表示。由 Ghidra 反编译器生成的 C 语言表示并不总是完美的,但它在帮助你理解二进制文件时非常有用。反编译器提供的基本功能包括恢复表达式、变量、函数参数和结构体字段。反编译器通常还能够恢复函数的块结构,而这个结构在汇编语言中通常会被掩盖,因为汇编语言不是块结构的,并且大量使用goto(或等效)语句使其看起来像是块结构。

反编译器窗口显示所选函数的 C 语言表示,如图 5-16 所示。根据你对汇编语言的了解,反编译后的代码可能比在列表窗口中的代码更容易理解。即使是初学者程序员,也应能够识别出反编译函数中的无限循环。(while循环条件依赖于param_3的值,而该值在循环中没有被修改。)

image

图 5-16:列表窗口和反编译器窗口

反汇编窗口中的图标显示在图 5-17 中。如果你想比较多个函数的反汇编版本,或者在 Listing 窗口中移动时继续查看特定函数,可以使用“快照”图标打开额外的(不连接的)反汇编窗口。“导出”图标允许你将反汇编的函数保存为 C 文件。

在反汇编窗口中,可以通过右键单击打开上下文菜单,执行与高亮项目相关的操作。与函数参数param_1相关的选项显示在图 5-18 中。

image

图 5-17:反汇编窗口工具栏

image

图 5-18:反汇编窗口中函数参数的选项

反汇编是一个极其复杂的过程,反汇编理论仍然是一个活跃的研究领域。与能够根据制造商参考手册验证其准确性的反汇编不同,当前没有提供从汇编语言回译为 C 语言的规范化手册(或者 C 到汇编的规范化手册)。事实上,虽然 Ghidra 的反汇编器始终生成 C 源代码,但反汇编器正在分析的二进制文件可能最初并非用 C 语言编写,因此许多反汇编器假设的 C 语言相关推断可能根本不成立。

与大多数复杂插件一样,反汇编器有其独特性,其输出的质量在很大程度上取决于输入的质量。反汇编窗口中许多问题和不规则现象都可以追溯到底层反汇编中的问题,因此,如果反汇编的代码没有意义,你可能需要花时间改进反汇编的质量。在大多数情况下,这涉及使用更准确的数据类型信息对反汇编进行注释,相关内容将在第八章和第十三章中讨论。我们将在后续章节继续探索反汇编器的功能,并在第十九章中深入讨论它。

其他 Ghidra 窗口

除了六个默认窗口外,你还可以打开其他窗口,以通过不同的视图来支持你的 SRE 过程,查看文件的其他或专业化的内容。可用窗口的列表显示在“窗口”菜单中,如前文图 5-4 所示。这些显示的实用性取决于你分析的二进制文件的特征以及你使用 Ghidra 的技能。其中一些窗口足够专业,需要在后续章节中更详细地介绍,但我们在这里介绍一些常见的窗口。

字节窗口

Bytes 窗口提供了文件字节级内容的原始视图。默认情况下,Bytes 窗口会打开在 CodeBrowser 的右上方,并以每行 16 个字节的标准十六进制转储显示程序内容。该窗口同时作为十六进制编辑器,并且可以通过使用 Bytes 窗口工具栏中的设置工具来配置显示各种格式。在许多情况下,向 Bytes 窗口添加 ASCII 显示可能会很有帮助,如图 5-19 所示。该图还显示了字节查看器选项对话框和用于编辑或快照字节视图的工具栏图标。

image

图 5-19:同步的十六进制和反汇编视图,突出显示了切换和快照图标

与列表窗口一样,可以使用 Bytes 窗口工具栏中的快照图标(见图 5-19)同时打开多个 Bytes 窗口。默认情况下,第一个 Bytes 窗口与列表窗口相连,因此在一个窗口中滚动和点击一个元素会导致另一个窗口滚动到相同的位置(相同的虚拟地址)。后续的 Bytes 窗口是断开的,这使得您可以独立滚动它们。当窗口断开连接时,窗口名称会出现在窗口标题栏的方括号内。

要将 Bytes 窗口转换为十六进制(或 ASCII)编辑器,只需切换图 5-19 中高亮的铅笔图标。光标将变为红色,表示可以进行编辑,尽管您无法在包含现有代码项(如指令)的地址上进行编辑。当编辑完成后,再次切换该图标即可返回只读模式。(请注意,任何更改不会反映在断开连接的 Bytes 窗口中。)

如果十六进制列显示问号而不是十六进制值,Ghidra 正在告诉您,它不确定给定虚拟地址范围内可能占用的值。这种情况通常出现在程序包含 bss 节时,^(4) 该节通常在文件中不占用空间,但会被加载器扩展以适应程序的静态存储需求。

定义数据窗口

定义数据窗口显示当前程序、视图或选择中定义的数据的字符串表示形式,以及相关的地址、类型和大小,如图 5-20 所示。与大多数列式窗口一样,您可以通过点击列标题按升序或降序对任何列进行排序。在定义数据窗口中双击任何一行会导致列表窗口跳转到所选项的地址。

当与交叉引用一起使用时(在第九章中讨论),已定义数据窗口可以快速识别有趣的项目,并通过几次点击追踪到程序中引用该项目的任何位置。例如,您可能会看到字符串 "SOFTWARE\Microsoft\Windows\Current Version\Run" 列出,并想知道为什么某个应用程序会引用 Windows 注册表中的这个特定键,然后发现该程序正在设置该注册表键,以便在 Windows 启动时自动启动。

image

图 5-20:已定义数据窗口,强调显示了过滤图标

已定义数据窗口具有强大的过滤功能。除了窗口底部的过滤栏外,右上角的过滤图标(在图 5-20 中突出显示)允许您控制额外的数据类型过滤选项,如图 5-21 所示。

image

图 5-21:已定义数据类型过滤选项

每次通过点击确定关闭设置数据类型过滤器对话框时,Ghidra 会根据新的设置重新生成已定义数据窗口的内容。

已定义字符串窗口

已定义字符串窗口显示了在二进制文件中定义的字符串。此窗口的示例如图 5-22 所示。除了图中显示的默认列外,您还可以通过右键单击列标题的行来添加列。可能最有趣的列之一是“是否存在编码错误”标志,它可能表明字符集存在问题或字符串被误识别。除了此窗口,Ghidra 还提供了强大的字符串搜索功能。有关此功能的讨论,请参见第六章。

image

图 5-22:已定义字符串窗口

符号表和符号引用窗口

符号表窗口提供了二进制文件中所有全局名称的汇总列表。默认显示八列,如图 5-23 所示。该窗口具有高度的可配置性,可以在显示中添加和删除列,也可以对任意列进行升序或降序排序。前两列默认是名称和位置。名称只是赋予在位置上定义的符号的一个符号性描述。

符号表与列表窗口连接,但提供了控制其与列表窗口交互的功能。图 5-23 中右侧的强调图标是一个切换按钮,用于决定单击符号表窗口中的某个位置是否会导致列表窗口中的相关位置移动。无论切换按钮的选择如何,双击任何符号表位置条目都会立即跳转到列表视图并显示所选条目。这为快速在程序列表中导航到已知位置提供了一个有用的工具。

image

图 5-23:符号表窗口,突出显示显示符号引用和导航切换图标

符号表窗口提供了强大的过滤功能,并且有多种方式可以访问过滤选项。工具栏中的齿轮图标打开符号表过滤对话框。该对话框(选中“使用高级过滤器”框)如图 5-24 所示。除了这个对话框,你还可以使用窗口底部的过滤选项。有关符号表过滤选项的详细讨论,请参见 Ghidra 帮助文档。

图 5-23 中左侧的强调图标是显示符号引用图标。点击此图标将把符号引用窗口添加到符号表窗口。默认情况下,这两个表格将并排显示。为了提高可读性,你可以将符号引用窗口拖到符号表窗口下方,如图 5-25 所示。这两个表格之间的连接是单向的,当在符号表中进行选择时,符号引用表会更新。

image

图 5-24:符号表过滤对话框

image

图 5-25:带有符号引用的符号表

与符号表窗口一样,符号引用窗口也具有相同的列组织控制。此外,符号引用窗口的内容由符号引用工具栏右上角的三个图标(S、I 和 D)控制。这些选项是互斥的,意味着一次只能选择一个:

S 图标 当选择此图标时,符号引用窗口将显示你在符号表中选择的符号的所有引用。图 5-25 显示了选择此选项时的符号引用窗口。

I 图标 当选择此图标时,符号引用窗口将显示你在符号表中选择的函数的所有指令引用。(如果你没有选择函数入口点,则此列表为空。)

D 图标 选择此图标时,符号引用窗口将显示符号表中你所选函数的所有数据引用。如果未选择函数入口点或该函数没有引用任何数据符号,则此列表将为空。

内存映射窗口

内存映射窗口显示了程序中内存块的汇总列表,如图 5-26 所示。请注意,Ghidra 所称的内存块在讨论二进制文件结构时通常被称为。窗口中展示的信息包括内存块(节)名称、起始和结束地址、长度、权限标志、块类型、初始化标志以及源文件名和用户评论的空间。起始和结束地址表示程序节在运行时将被映射到的虚拟地址范围。

image

图 5-26:内存映射窗口

双击窗口中的任何起始地址或结束地址,都会将列表窗口(以及所有其他连接的窗口)跳转到指定地址。内存映射窗口工具栏提供了添加/删除块、移动块、拆分/合并块、编辑地址和设置新的图像基地址等选项。这些功能在反向工程处理非标准格式的文件时尤为有用,因为 Ghidra 加载器可能未能检测到二进制文件的段结构。

命令行工具与内存映射窗口的对应命令包括objdump-h)、readelf-S)和dumpbin/HEADERS)。

函数调用图窗口

在任何程序中,函数既可以调用其他函数,也可以被其他函数调用。函数调用图窗口展示了给定函数的直接邻居。为了简便起见,我们将 Y 称为 X 的邻居,如果 Y 直接调用 X,或者 X 直接调用 Y。当你打开函数调用图窗口时,Ghidra 会确定光标所在函数的邻居,并生成相应的显示。该显示展示了函数在程序文件中的使用上下文,但它只是大局中的一部分。

图 5-27 显示了一个名为FUN_0040198c的函数,它是从FUN_00401edc调用的,并且依次调用了另外六个函数。在窗口中双击任何函数都会立即将列表窗口和其他连接的窗口跳转到选中的函数。Ghidra 的交叉引用(XREFs)机制是生成函数调用图窗口的基础。关于 XREFs 的详细内容,请参见第九章。

image

图 5-27:函数调用图窗口

谁在调用?

虽然函数调用图窗口很有帮助,但有时你需要更广泛的视角,或者至少是更大的视角。函数调用树窗口(窗口 ▸ 函数调用树)可以让你看到所有对选定函数的调用以及从选定函数的调用。函数调用树窗口(如图 5-28 所示)分为两个部分:一个用于显示进入调用,另一个用于显示输出调用。进入调用和输出调用都可以根据需要展开或折叠。

image

图 5-28:函数调用树视图

如果你在选中入口函数的情况下打开函数调用树窗口,你可以查看程序函数调用的层级表示。

总结

初看之下,Ghidra 提供的显示窗口数量可能让人感到不知所措。在你足够熟悉之后,可能会发现坚持使用默认显示窗口最为简单,直到你开始探索额外的显示选项。无论如何,你绝不必觉得有义务使用 Ghidra 提供的所有功能,并不是每个窗口在每个逆向工程场景下都能发挥作用。

熟悉 Ghidra 显示界面的最佳方法之一,就是浏览 Ghidra 为你的二进制文件填充的数据的各种标签子窗口,并且打开其他可用的窗口。当你对 Ghidra 越来越熟悉时,你的逆向工程效率和效果也会提高。

Ghidra 是一个非常复杂的工具。除了本章介绍的窗口外,你可能会遇到一些额外的对话框,当你努力掌握 Ghidra 时,我们会在书的其余部分介绍关键的对话框。

到此为止,你应该开始对 Ghidra 界面和 CodeBrowser 桌面感到更为熟悉。在下一章中,我们将开始关注你可以通过许多方式操作反汇编代码,以增强你对其行为的理解,并且一般来说帮助你更轻松地使用 Ghidra。

第八章:理解 Ghidra 反汇编

Image

在本章中,我们介绍了一些重要的基础技能,帮助你更好地理解 Ghidra 的反汇编内容。我们从基本的导航技巧开始,这些技巧可以让你在汇编代码中移动,并检查你遇到的各种文物。随着你从一个函数跳转到另一个函数,你会发现你需要通过仅使用反汇编中提供的线索来解码每个函数的原型。因此,我们将讨论如何理解一个函数接受多少个参数,并且如何解码我们遇到的每个参数的数据类型。由于一个函数执行的大部分工作都与函数维护的局部变量相关,我们还将讨论函数如何使用栈来存储局部变量,以及如何在 Ghidra 的帮助下准确理解一个函数如何使用它可能为自己保留的任何栈空间。无论你是在调试代码、分析恶意软件,还是开发漏洞,理解如何解码一个函数的栈分配变量是理解任何程序行为的基本技能。最后,我们将介绍 Ghidra 提供的搜索选项,以及这些选项如何帮助理解反汇编内容。

反汇编导航

在第四章和第五章中,我们展示了在基本层面上,Ghidra 将许多常见的逆向工程工具的功能集成到它的 CodeBrowser 显示中。浏览显示内容是掌握 Ghidra 所需的基本技能之一。静态反汇编清单,例如像objdump这样的工具提供的清单,除了上下滚动之外,并没有内建的导航功能。即使是提供集成的grep风格搜索的最佳文本编辑器,这种死列表也非常难以浏览。另一方面,Ghidra 提供了出色的导航功能。除了提供你在使用文本编辑器或文字处理软件时习惯的标准搜索功能外,Ghidra 还开发并显示了一个全面的交叉引用列表,这些交叉引用像网页的超链接一样起作用。最终的结果是,在大多数情况下,导航到感兴趣的位置只需要双击即可。

名称和标签

当程序被反汇编时,程序中的每个位置都会分配一个虚拟地址。因此,我们可以通过提供我们感兴趣访问的位置的虚拟地址来在程序内导航。不幸的是,记住这些地址并将其整理成目录并非易事。正因为如此,早期的程序员便开始为他们想要引用的程序位置分配符号名称,这使得工作变得轻松得多。将符号名称分配给程序地址就像为程序操作码分配助记符指令名称一样;通过使标识符更容易记住,程序变得更加易于阅读和编写。Ghidra 继承了这一传统,通过为虚拟地址创建标签并允许用户修改和扩展标签集来延续这一做法。我们已经在 Symbol Tree 窗口中看到过名称的使用。回想一下,双击名称会导致 Listing 视图(以及 Symbol References 窗口)跳转到引用的位置。虽然在使用上,“名称”和“标签”这两个术语有所不同(例如,函数有名称,并且在 Ghidra Symbol Tree 中与标签出现在不同的分支中),但在导航上下文中,这两个术语通常可以互换使用,因为它们都表示导航目标。

Ghidra 在自动分析阶段通过使用二进制文件中现有的名称(如果有)或根据二进制文件中某个位置的引用方式自动生成名称,从而生成符号名称。除了符号用途外,任何在反汇编窗口中显示的标签都是潜在的导航目标,类似于网页上的超链接。这些标签与标准超链接的两大主要区别是:标签没有任何突出显示,无法指示它们可以被点击跟踪,而且 Ghidra 通常需要双击才能跟踪,而传统的超链接只需单击。

您已被邀请加入命名约定!

Ghidra 在分配标签时为用户提供了很多灵活性,但某些模式有特殊含义,并且是 Ghidra 保留的。这些包括以下前缀,当它们后面跟着下划线和地址时:EXTFUNSUBLABDATOFFUNK。创建标签时应避免使用这些模式。此外,标签中不允许使用空格和不可打印字符。幸运的是,标签最多可以包含 2000 个字符。如果你认为自己可能会超出这个限制,请仔细计算!

Ghidra 中的导航

在图 6-1 中显示的列表中,每个由实心箭头指示的符号都代表一个命名的导航目标。在 Listing 窗口中双击它们中的任何一个,Ghidra 将会将 Listing 显示(以及所有相关窗口)移动到选定的位置。

image

图 6-1:列出显示导航目标

在导航方面,Ghidra 将另外两种显示实体视为导航目标。首先,交叉引用(在图 6-1 中由虚线箭头表示)被视为导航目标。双击底部的交叉引用地址将使显示跳转到引用位置(此例中为00401331)。交叉引用将在第九章中详细介绍。将鼠标悬停在这些可导航对象上会显示一个弹出框,显示目标代码。

其次,另一种在导航方面受到特殊处理的显示实体是使用十六进制值的实体。如果显示的十六进制值序列表示二进制文件中的有效虚拟地址,那么关联的虚拟地址将在右侧显示,如图 6-2 所示。双击显示的值将会将反汇编窗口重新定位到该虚拟地址。在图 6-2 中,双击任何由实心箭头指示的值将会跳转显示,因为每个值都是该二进制文件中的有效虚拟地址。双击其他值则不会产生任何效果。

image

图 6-2:显示十六进制导航目标的列表

转到

当你知道想要导航到的地址或名称时(例如,在 ELF 二进制文件中导航到 main 以开始分析),你可以滚动列表寻找该地址,或者在符号树窗口的函数文件夹中查找所需的名称,或者使用 Ghidra 的搜索功能(在本章后面会讨论)。最终,最简单的方式是使用“转到”对话框(如图 6-3 所示),可以通过导航 ▸ 转到,或者在反汇编窗口处于活动状态时使用 G 热键来访问。

image

图 6-3:转到对话框

导航到二进制文件中的任何位置非常简单,只需指定一个有效的地址(区分大小写的符号名称或十六进制值),然后点击确认,显示就会立即跳转到所需位置。输入到对话框中的值会通过下拉历史列表在后续使用中提供,简化了返回先前请求位置的操作。

导航历史

作为最终的导航功能,Ghidra 支持基于你浏览反汇编的顺序进行前后导航。每次你导航到反汇编中的新位置时,当前的位置都会被添加到历史列表中。这个列表可以通过“转到”窗口或者代码浏览器工具栏中的左右箭头图标进行浏览。

在 Go To 窗口中,如图 6-3 所示,文本框右侧的箭头打开一个选择列表,允许你从之前在 Go To 对话框中输入的位置信息中选择。CodeBrowser 工具栏按钮,如图 6-4 中左上方所见,提供类似浏览器的前进和后退功能。每个按钮都关联着一个详细的下拉历史列表,允许你直接访问导航历史中的任何位置,而不需要重新回溯整个列表。图 6-4 中显示了与返回箭头相关的下拉列表示例。

image

图 6-4:带地址列表的前进和后退导航箭头

ALT-左箭头(Mac 上是 OPTION-左箭头)用于向后导航,是你可以牢记的最有用的快捷键之一。当你已经深入跟踪了一系列函数调用,并且决定返回到反汇编的原始位置时,向后导航非常方便。ALT-右箭头(Mac 上是 OPTION-右箭头)将反汇编窗口在历史列表中向前移动。

虽然我们现在对如何在 Ghidra 中导航反汇编有了更清晰的认识,但我们仍然没有对我们访问过的各个目标赋予意义。下一节将探讨为什么函数(尤其是堆栈帧)对逆向工程师来说是如此重要的导航目标。

堆栈帧

因为 Ghidra 是一个低级分析工具,它的许多功能和显示期望用户对编译语言的低级细节有一定了解,这些语言专注于生成机器语言和管理高级程序使用的内存。Ghidra 特别关注编译器如何处理局部变量声明和访问。你可能已经注意到,在大多数函数列表的开头,有大量的行专门用于局部变量。这些行来自 Ghidra 对每个函数进行的详细堆栈分析,通过其堆栈分析器完成。进行此分析是必要的,因为编译器将函数的局部变量(在某些情况下,还包括函数的传入参数)放置在分配到堆栈上的内存块中。在本节中,我们将回顾编译器如何处理局部变量和函数参数,以帮助你更好地理解 Ghidra 列表视图的细节。

函数调用机制

函数调用可能需要为传递给函数的参数(实参)以及执行函数时的临时存储空间分配内存。参数值或其对应的内存地址需要存储在函数能够找到的地方。临时空间通常通过程序员声明局部变量来分配,这些变量可以在函数内使用,但在函数完成后无法访问。栈帧(也称为激活记录)是分配在程序运行时栈中的内存块,专门用于特定函数调用的内存空间。

编译器使用栈帧使函数参数和局部变量的分配与释放对程序员透明。对于在栈上传递参数的调用约定,编译器会插入代码,在将控制权传递给函数之前,将函数的参数放入栈帧中,随后插入代码分配足够的内存来保存函数的局部变量。在某些情况下,函数应返回的地址也会存储在新的栈帧中。栈帧还支持递归,^(1)因为每个递归调用都会得到自己的栈帧,确保每次调用都与前一次调用相互独立。

以下是函数调用时发生的操作:

  1. 调用者将被调用函数所需的任何参数放入被调用函数采用的调用约定所要求的位置。如果参数通过运行时栈传递,则程序栈指针可能会发生变化。

  2. 调用者通过类似 x86 的CALL、ARM 的BL或 MIPS 的JAL指令将控制权传递给被调用的函数。一个返回地址会被保存在程序栈中或处理器寄存器里。

  3. 如果需要,被调用的函数配置一个帧指针并保存调用者期望保持不变的寄存器值。^(2)

  4. 被调用的函数为其可能需要的任何局部变量分配空间。这通常通过调整程序栈指针来预留运行时栈上的空间。

  5. 被调用的函数执行其操作,可能会访问传递给它的参数并生成结果。如果函数返回结果,通常会将其放入一个特定的寄存器中,调用者可以在函数返回后检查该寄存器。

  6. 当函数完成其操作后,为局部变量保留的任何栈空间会被释放。通常通过逆转步骤 4 中执行的操作来完成这一过程。

  7. 为调用者保存的寄存器值(在步骤 3 中)会被恢复为原始值。

  8. 被调用的函数将控制权返回给调用者。典型的指令包括 x86 的RET、ARM 的POP和 MIPS 的JR。根据使用的调用约定,这个操作也可能会从程序栈中清除一个或多个参数。

  9. 一旦调用者重新获得控制权,它可能需要通过恢复程序栈指针到步骤 1 之前的值,从程序栈中移除参数。

步骤 3 和步骤 4 是进入函数时常见的操作,合在一起被称为函数的序言。类似地,步骤 6 到步骤 8 构成了函数的尾声。除了步骤 5,这些操作都是与调用函数相关的开销,可能在程序的高级源代码中并不明显,但在汇编语言中却十分可见。

它们真的被移除了么?

当我们谈论“移除”栈中的项目以及整个栈帧的移除时,我们指的是调整栈指针,使其指向栈中更低的位置,并且已移除的内容不再通过POP操作访问。直到这些内容被PUSH操作覆盖,它们仍然存在。从编程的角度来看,这算作移除。从数字取证的角度来看,你需要稍微费点劲才能找到这些内容。从变量初始化的角度来看,这意味着栈帧中的任何未初始化的局部变量可能包含来自上次使用特定栈字节范围的过期值。

调用约定

当从调用者传递参数到被调用者时,调用函数必须按照被调用函数预期的方式存储参数;否则,可能会出现严重问题。调用约定严格规定了调用者应该将任何函数所需的参数放置的位置:在特定的寄存器中、在程序栈上,或同时在寄存器和栈中。当参数通过程序栈传递时,调用约定还决定了在被调用函数完成后,谁负责从栈中移除这些参数:调用者还是被调用者。

无论你是在逆向哪种架构的程序,如果不理解所使用的调用约定,理解函数调用周围的代码将会非常困难。在接下来的章节中,我们将回顾一些在编译后的 C 和 C++代码中常见的调用约定。

栈和寄存器参数

函数参数可以通过处理器寄存器、程序栈或两者的组合传递。当参数被放置到栈上时,调用者执行内存写操作(通常是PUSH)将参数放到栈上,而被调用函数必须执行内存读取操作才能访问该参数。为了加快函数调用过程,一些调用约定通过处理器寄存器传递参数。当参数通过寄存器传递时,无需执行内存的写入和读取操作,因为参数可以直接通过指定的寄存器提供给被调用函数。寄存器传递调用约定的一个缺点是处理器的寄存器数量有限,而函数参数列表可以非常长,因此这些约定必须正确处理需要更多参数而寄存器不足的情况。多余的参数通常会“溢出”到栈上。

C 调用约定

C 调用约定是大多数 C 编译器在生成函数调用时使用的默认调用约定。在 C/C++程序中,可以使用关键字_cdecl强制使用此调用约定。cdecl调用约定规定调用者将任何栈分配的函数参数按从右到左的顺序放置到栈上,并且调用者(而不是被调用者)在被调用函数完成后从栈中移除参数。对于 32 位 x86 二进制文件,cdecl将所有参数放在程序栈上。对于 64 位 x86 二进制文件,cdecl根据操作系统有所不同;在 Linux 上,最多六个参数被放置在寄存器RDIRSIRDXRCXR8R9中,顺序如下,任何额外的参数将溢出到栈上。对于 ARM 二进制文件,cdecl将前四个参数放在寄存器R0R3中,第五个及以后的参数溢出到栈上。

当栈分配的参数按从右到左的顺序放置到栈上时,最左边的参数在函数调用时将始终位于栈顶。这使得第一个参数在不考虑函数期望的参数数量的情况下也能轻松找到,同时这也使得cdecl调用约定非常适合用于可以接受可变数量参数的函数(如printf)。

要求调用函数从栈中移除参数意味着你通常会看到在从被调用函数返回后,紧接着有指令调整程序栈指针。在可以接受可变数量参数的函数中,调用者确切知道它传递了多少参数,因此可以轻松地做出正确的调整,而被调用函数则无法事先知道它将接收到多少个参数。

在以下示例中,我们考虑对 32 位 x86 二进制文件中的函数的调用,每个函数使用不同的调用约定。第一个函数有如下原型:

void demo_cdecl(int w, int x, int y, int z);

默认情况下,此函数将使用cdecl调用约定,期望四个参数按从右到左的顺序推送,并要求调用者在每次调用后清理堆栈上的参数。给定以下 C 语言中的函数调用:

demo_cdecl(1, 2, 3, 4);    // call to demo_cdecl (in C)

编译器可能会生成如下代码:

➊ PUSH   4              ; push parameter z

  PUSH   3              ; push parameter y

  PUSH   2              ; push parameter x

  PUSH   1              ; push parameter w

  CALL   demo_cdecl     ; call the function

➋ ADD   ESP, 16        ; adjust ESP to its former value

四个PUSH操作➊会将程序堆栈指针(ESP)改变 16 个字节(在 32 位架构上为4 * sizeof(int)),在从demo_cdecl返回后会立即撤销这一操作➋。以下技术在某些版本的 GNU 编译器(gccg++)中使用,同时遵循cdecl调用约定,并消除了调用者在每次调用demo_cdecl后显式清理堆栈参数的需求:

MOV    [ESP+12], 4    ; move parameter z to fourth position on stack

MOV    [ESP+8], 3     ; move parameter y to third position on stack

MOV    [ESP+4], 2     ; move parameter x to second position on stack

MOV    [ESP], 1       ; move parameter w to top of stack

CALL   demo_cdecl     ; call the function

在这个例子中,当demo_cdecl的参数被放置到堆栈上时,程序堆栈指针没有发生变化。请注意,无论使用哪种方法,当函数被调用时,堆栈指针都会指向最左侧的堆栈参数。

标准调用约定

在 32 位 Windows DLL 中,微软大量使用了一种它命名为标准调用约定的调用约定。在源代码中,这可以通过在函数声明中使用_stdcall修饰符来强制,如下所示:

void _stdcall demo_stdcall(int w, int x, int y);

为了避免“标准”一词的混淆,我们在本书的其余部分将这种调用约定称为stdcall调用约定。

stdcall调用约定还要求任何堆栈分配的函数参数按照从右到左的顺序放置在程序堆栈上,但被调用的函数负责在函数执行完毕后清除堆栈上的参数。这只有在函数接受固定数量的参数时才可行;像printf这样的可变参数函数不能使用stdcall调用约定。

demo_stdcall函数期望三个整数参数,总共占用 12 个字节的堆栈空间(在 32 位架构上为3 * sizeof(int))。x86 编译器可以使用一种特殊形式的RET指令,同时从堆栈顶部弹出返回地址,并通过堆栈指针的调整来清除堆栈分配的函数参数。以demo_stdcall为例,我们可能会看到以下指令用于返回给调用者:

RET 12    ; return and clear 12 bytes from the stack

使用stdcall可以消除每次函数调用后清理堆栈参数的需求,这会导致程序稍微更小、运行稍微更快。根据约定,微软对于所有从 32 位共享库(DLL)文件导出的固定参数函数使用stdcall约定。如果你打算为任何共享库组件生成函数原型或二进制兼容替代品,这一点非常重要。

x86 的 fastcall 约定

微软的 C/C++和 GNU gcc/g++(版本 3.4 及以上)编译器识别fastcall约定,这是stdcall约定的一种变体,其中前两个参数分别放入ECXEDX寄存器。其余的参数按从右到左的顺序放入栈中,调用的函数在返回时负责从栈中移除参数。以下声明演示了fastcall修饰符的使用:

void fastcall demo_fastcall(int w, int x, int y, int z);

给定以下 C 语言函数调用:

demo_fastcall(1, 2, 3, 4);      // call to demo_fastcall (in C)

编译器可能生成以下代码:

PUSH   4              ; move parameter z to second position on stack

PUSH   3              ; move parameter y to top position on stack

MOV    EDX, 2         ; move parameter x to EDX

MOV    ECX, 1         ; move parameter w to ECX

Call   demo_fastcall  ; call the function

demo_fastcall返回时不需要进行栈调整,因为demo_fastcall负责在返回调用者时从栈中清除参数yz。需要理解的是,虽然函数有四个参数,但由于两个参数是通过寄存器传递的,调用的函数只需要从栈中清除 8 个字节。

C++调用约定

C++类中的非静态成员函数必须提供指向用于调用该函数的对象的指针(this指针)。^(3) 调用该函数的对象地址必须由调用者作为参数提供,但 C++语言标准并未指定this应如何传递,因此不同编译器使用不同的技术并不奇怪。

在 x86 架构上,微软的 C++编译器使用thiscall调用约定,将this放入ECX/RCX寄存器,并要求非静态成员函数像stdcall一样清除栈上的参数。GNU 的g++编译器将this视为任何非静态成员函数的隐含第一个参数,并在其他方面表现得像使用cdecl约定一样。因此,对于g++编译的 32 位代码,this在调用非静态成员函数之前会被放置在栈顶,调用者负责在函数返回后从栈中移除参数(至少会有一个)。C++程序的其他特点将在第八章和第二十章中讨论。

其他调用约定

完全覆盖每种调用约定将需要一本书。调用约定通常是操作系统、语言、编译器和/或处理器特定的,如果你遇到由不常见的编译器生成的代码,可能需要进行一些研究。然而,有几种额外的情况值得特别提及:优化代码、自定义汇编语言代码和系统调用。

当函数被导出供其他程序员使用(例如库函数)时,重要的是它们需要遵循公认的调用约定,以便程序员能够轻松地与这些函数进行接口。另一方面,如果一个函数仅供程序内部使用,那么该函数使用的调用约定只需要在程序内部了解。在这种情况下,优化编译器可能会选择使用替代的调用约定来生成更快的代码。例如,使用 Microsoft C/C++ 的 /GL 选项会指示它执行“整体程序优化”,这可能会导致在函数边界间优化寄存器的使用,而使用 GNU gcc/g++regparm 关键字允许程序员指定最多三个参数通过寄存器传递。

当程序员费心编写汇编语言时,他们将完全控制如何将参数传递给他们创建的任何函数。除非他们希望将其函数提供给其他程序员,否则汇编语言程序员可以自由地以任何他们认为合适的方式传递参数。因此,在分析自定义汇编代码(如混淆例程和 shellcode)时需要特别小心。

系统调用是一种特殊类型的函数调用,用于请求操作系统服务。系统调用通常会引发从用户模式到内核模式的状态转换,以便操作系统内核处理用户的请求。系统调用的启动方式在不同的操作系统和处理器之间有所不同。例如,32 位的 Linux x86 系统调用可能使用 INT 0x80 指令或 sysenter 指令来启动,而其他 x86 操作系统可能只使用 sysenter 指令或替代的中断号,64 位 x86 代码则使用 syscall 指令。在许多 x86 系统上(Linux 为例外),系统调用的参数被放置在运行时栈上,且在启动系统调用之前,系统调用号被放入 EAX 寄存器。Linux 系统调用在特定的寄存器中接受参数,当可用寄存器不足时,参数有时会放置在内存中。

附加栈帧考虑

在任何处理器上,寄存器是有限的资源,需要在程序中的所有函数之间进行共享与合作。当一个函数(func1)正在执行时,它的视角是它完全控制所有处理器寄存器。当func1调用另一个函数(func2)时,func2可能希望采用相同的视角,并根据自己的需求使用所有可用的处理器寄存器,但如果func2随意更改寄存器,它可能会破坏func1所依赖的值。

为了解决这个问题,所有编译器都遵循明确定义的寄存器分配和使用规则。这些规则通常被称为平台的应用程序二进制接口(ABI)。ABI 将寄存器分为两类:调用者保存寄存器和被调用者保存寄存器。当一个函数调用另一个函数时,调用者只需要保存调用者保存寄存器中的寄存器,以防止值丢失。任何被调用者保存寄存器中的寄存器必须由被调用函数(即被调用者)在使用这些寄存器之前保存。这通常发生在函数的序言序列中,而调用者保存的值会在函数的尾声部分,在返回之前恢复。调用者保存寄存器被称为可覆盖寄存器,因为被调用函数可以自由修改它们的内容,而无需先保存它们。相反,被调用者保存寄存器被称为不可覆盖寄存器。

针对 Intel 32 位处理器的 System V ABI 规定,调用者保存寄存器包括EAXECXEDX,而被调用者保存寄存器包括EBXEDIESIEBPESP。^(4) 在编译代码中,你可能会注意到编译器通常倾向于在函数内部使用调用者保存寄存器,因为这样它们不需要在函数入口和退出时保存和恢复其内容。

局部变量布局

与决定如何将参数传递给函数的调用约定不同,没有约定决定函数局部变量的内存布局。在编译一个函数时,编译器必须计算该函数局部变量所需的空间大小,以及保存任何不应覆盖寄存器所需的空间,并确定这些变量是否可以分配到处理器寄存器中,或者是否必须分配到程序栈上。具体如何进行这些分配,对函数的调用者和任何可能被调用的函数都没有影响,并且通常无法仅通过检查函数的源代码来确定函数的局部变量布局。关于栈帧,有一点是肯定的:编译器必须至少分配一个寄存器来记住函数新分配的栈帧的位置。最明显的选择是栈指针寄存器,它的定义就是指向栈,因此也指向当前函数的栈帧。

栈帧示例

当你进行任何复杂的任务时,比如逆向工程二进制文件,你应始终尽量高效地利用时间。在理解反汇编函数的行为时,花费在常见代码序列上的时间越少,你就能花更多时间去处理困难的序列。函数的序言和尾声是常见代码序列的绝佳例子,了解它们、识别它们,并迅速跳到需要更多思考的有趣代码,是非常重要的。

Ghidra 在每个函数列表的开头通过局部变量列表总结了它对函数序言的理解,虽然它使代码更具可读性,但并不会减少你需要阅读的反汇编代码量。在以下示例中,我们将讨论两种常见的栈帧类型,并回顾创建它们所需的代码,这样当你遇到类似的代码时,就可以迅速跳过它,进入函数的核心部分。

考虑以下在 32 位 x86 计算机上编译的函数:

void helper(int j, int k);    // a function prototype

void demo_stackframe(int a, int b, int c) {

    int x;

    char buffer[64];

    int y;

    int z;

    // body of function not terribly relevant

    // other than the following function call

    helper(z, y);

}

demo_stackframe的局部变量需要 76 个字节(三个 4 字节整数和一个 64 字节缓冲区)。此函数可以使用stdcallcdecl,栈帧将保持相同。

示例 1:通过栈指针访问局部变量

图 6-5 显示了调用demo_stackframe时可能的栈帧。在这个示例中,编译器选择在引用栈帧中的变量时使用栈指针,保留所有其他寄存器以供其他用途。如果任何指令导致栈指针的值发生变化,编译器必须确保在所有后续的局部变量访问中考虑到这一变化。

image

图 6-5:在 32 位 x86 计算机上编译的函数示例栈帧

该栈帧的空间在进入demo_stackframe时通过一行代码的序言进行设置:

SUB    ESP, 76        ; allocate sufficient space for all local variables

图 6-5 中的偏移列表示引用栈帧中每个局部变量和参数所需的 x86 寻址模式(在此情况下为基址+位移)。在此情况下,ESP被用作基址寄存器,每个位移是从ESP到变量在栈帧中的起始位置的相对偏移量。然而,图 6-5 中显示的位移仅在ESP中的值没有改变时是正确的。不幸的是,栈指针经常变化,编译器必须不断适应,以确保在引用栈帧中的任何变量时使用正确的偏移量。考虑在函数demo_stackframe中对helper的调用,相关代码如下所示:

➊ PUSH   dword [ESP+4]  ; push y

➋ PUSH   dword [ESP+4]  ; push z

   CALL   helper

   ADD    ESP, 8         ; cdecl requires caller to clear parameters

第一个PUSH ➊正确地根据图 6-5 中的偏移量压入了局部变量y。乍一看,第二个PUSH ➋似乎错误地第二次引用了局部变量y。然而,由于堆栈帧中的所有变量都是相对于ESP进行引用的,并且第一个PUSH ➊修改了ESP,因此图 6-5 中的所有偏移量必须暂时调整。因此,在第一次PUSH ➊之后,局部变量z的新偏移量变为[ESP+4]。在检查使用堆栈指针引用堆栈帧变量的函数时,必须小心注意堆栈指针的任何变化,并相应地调整所有未来的变量偏移量。

一旦demo_stackframe完成,它需要返回给调用者。最终,RET指令将把所需的返回地址从堆栈顶部弹出到指令指针寄存器(在这种情况下是EIP)。在返回地址被弹出之前,局部变量需要从堆栈顶部移除,以便堆栈指针在执行RET指令时正确指向保存的返回地址。对于这个特定函数(假设使用的是cdecl调用约定),尾声代码如下:

ADD    ESP, 76        ; adjust ESP to point to the saved return address

RET                   ; return to the caller
示例 2:让堆栈指针休息一下

通过将第二个寄存器用于在堆栈帧中定位变量,可以允许堆栈指针在不需要重新计算每个变量偏移量的情况下自由变化。当然,编译器需要承诺不更改这个第二个寄存器;否则,它将需要处理前面示例中提到的相同问题。在这种情况下,编译器首先需要为此目的选择一个寄存器,然后它必须生成代码,在进入函数时初始化该寄存器。

任何为此目的选定的寄存器都称为帧指针。在前面的示例中,ESP被用作帧指针,我们可以说它是基于ESP的堆栈帧。大多数架构的 ABI 建议使用哪个寄存器作为帧指针。帧指针始终被视为不可破坏寄存器,因为调用函数可能已经将其用于相同的目的。在 x86 程序中,EBP/RBP(扩展基指针)寄存器通常专用于帧指针。默认情况下,大多数编译器生成的代码使用除堆栈指针外的寄存器作为帧指针,尽管通常有选项指定应使用堆栈指针。(例如,GNU 的gcc/g++提供了-fomit-frame-pointer编译器选项,生成不使用第二个寄存器作为帧指针的函数。)

为了查看使用专用帧指针时demo_stackframe的堆栈帧会是什么样子,我们需要考虑这段新的序言代码:

➊ PUSH   EBP            ; save the caller's EBP value, because it's no-clobber

➋ MOV    EBP, ESP       ; make EBP point to the saved register value

➌ SUB    ESP, 76        ; allocate space for local variables

PUSH指令➊保存当前调用者使用的EBP值,因为EBP是一个不会被覆盖的寄存器。在返回之前,必须恢复调用者的EBP值。如果需要保存其他寄存器(例如ESIEDI)以代表调用者,编译器可以在保存EBP的同时保存它们,或者将保存它们的操作延迟到局部变量分配之后。因此,堆栈帧中没有标准的位置用于存储保存的寄存器。

一旦EBP被保存,它可以通过MOV指令➋改变为指向当前堆栈位置,这将当前堆栈指针的值(此时唯一保证指向堆栈的寄存器)复制到EBP。最后,与基于ESP的堆栈帧一样,为局部变量分配空间➌。最终的堆栈帧布局如图 6-6 所示。

image

图 6-6:基于 EBP 的堆栈帧

使用专用的帧指针后,所有变量的偏移量现在都可以相对于帧指针寄存器进行计算,如图 6-6 所见。通常(但不一定)使用正偏移来访问任何栈分配的函数参数,而使用负偏移来访问局部变量。在使用专用帧指针的情况下,堆栈指针可以自由更改,而不会影响帧内任何变量的偏移量。对函数helper的调用现在可以按如下方式实现:

➍ PUSH   dword [ebp-72] ; PUSH y

   PUSH   dword [ebp-76] ; PUSH z

   CALL   helper

   ADD    ESP, 8         ; cdecl requires caller to clear parameters

堆栈指针在第一次PUSH ➍后发生变化,但这对后续PUSH中对局部变量z的访问没有影响。

在使用帧指针的函数的尾声部分,必须在返回之前恢复调用者的帧指针。如果帧指针需要通过POP指令恢复,则必须在弹出旧帧指针值之前先清除栈中的局部变量,但由于当前帧指针指向堆栈中保存的帧指针值位置,因此这一过程变得简单。在使用EBP作为帧指针的 32 位 x86 程序中,以下代码表示一个典型的尾声:

MOV    ESP, EBP       ; clears local variables by resetting ESP

POP    EBP            ; restore the caller's value of EBP

RET                   ; pop return address to return to the caller

这个操作如此常见,以至于 x86 架构提供了LEAVE指令来完成相同的任务:

LEAVE                 ; copies EBP to ESP AND then pops into EBP

RET                   ; pop return address to return to the caller

虽然不同处理器架构中使用的寄存器和指令的名称肯定会有所不同,但构建堆栈帧的基本过程将保持不变。无论架构如何,你都应当熟悉典型的序言和尾声序列,以便快速分析函数内更有趣的代码。

Ghidra 堆栈视图

堆栈框架是一个运行时概念;没有堆栈和正在运行的程序,就没有堆栈框架。虽然这是事实,但这并不意味着在使用 Ghidra 等工具进行静态分析时应该忽视堆栈框架的概念。每个函数设置堆栈框架所需的所有代码都存在于二进制文件中。通过仔细分析这些代码,我们可以详细了解任何函数的堆栈框架结构,即使该函数没有运行。事实上,Ghidra 的一些最复杂的分析正是为了确定它反汇编的每个函数的堆栈框架布局。

Ghidra 堆栈框架分析

在初步分析过程中,Ghidra 会非常详细地跟踪堆栈指针在函数执行过程中的行为,记录每一个 PUSHPOP 操作,以及可能更改堆栈指针的任何算术操作,例如加或减常量值。此分析的目标是确定分配给函数堆栈框架的局部变量区域的确切大小,确定是否在给定函数中使用了专用的帧指针(例如通过识别 PUSH EBP/MOV EBP, ESP 序列),并识别函数堆栈框架中所有变量的内存引用。

例如,如果 Ghidra 记录了指令

MOV    EAX, [EBP+8]

demo_stackframe 的代码中,它会理解函数的第一个参数(此例中为 a)被加载到 EAX 寄存器中(参见图 6-6)。Ghidra 能区分访问函数参数的内存引用(那些位于保存的返回地址以下的)和访问局部变量的引用(那些位于保存的返回地址以上的)。

Ghidra 采取额外步骤来确定堆栈框架内哪些内存位置被直接引用。例如,虽然图 6-6 中的堆栈框架大小为 96 字节,但只有七个变量可能会被引用(四个局部变量和三个参数)。因此,你可以将注意力集中在 Ghidra 确定为重要的七个元素上,而不用过多考虑 Ghidra 未命名的所有字节。在识别和命名堆栈框架中的各个元素的过程中,Ghidra 还识别了变量之间的空间关系。这在某些使用场景中非常有帮助,例如漏洞开发,当 Ghidra 可以轻松地确定哪些变量可能会在缓冲区溢出时被覆盖。Ghidra 的反编译器(在第十九章中讨论)也大量依赖堆栈框架分析,并利用这些结果推断函数接收的参数数量以及反编译代码中所需的局部变量声明。

列表视图中的堆栈框架

理解一个函数的行为通常归结于理解该函数操作的数据类型。当你阅读反汇编列表时,理解函数操作的数据的第一步通常是查看函数堆栈帧的分解。Ghidra 提供了两种查看任何函数堆栈帧的方式:摘要视图和详细视图。为了理解这两种视图,我们将参考以下版本的 demo_stackframe,这是我们使用 gcc 编译的:

void demo_stackframe(int i, int j, int k) {

    int x = k;

    char buffer[64];

 int y = j;

    int z = 10;

    buffer[0] = 'A';

    helper(z, y);

}

由于本地变量仅在函数运行时存在,因此任何在函数中没有被有意义地使用的本地变量实际上是没有用的。从高层次来看,以下代码是 demo_stackframe 的功能等效版本(你可以说是优化后的版本):

void demo_stackframe_2(int b) {

    helper(10, b);

}

(因此,尽管这个函数看起来像是在做很多工作,实际上它只是试图装作忙碌,以给老板留下好印象。)

在原始版本的 demo_stackframe 中,本地变量 xy 分别从参数 kj 初始化。本地变量 z 被初始化为字面值 10,并且 64 字节本地数组 buffer 中的第一个字符被初始化为字符 'A'。使用默认自动分析的 Ghidra 反汇编显示了该函数的内容,如 图 6-7 所示。

image

图 6-7: demo_stackframe 函数的反汇编

在我们开始熟悉 Ghidra 的反汇编符号时,有许多要点需要讨论。在本讨论中,我们专注于反汇编中的两个部分,它们为我们提供了特别有用的信息。让我们首先关注堆栈摘要,如下所示的列表中所示。(你可以随时参考 图 6-7 来查看这个摘要堆栈帧的上下文。)为了简化讨论,术语 本地变量参数 用来区分两种类型的变量。术语 变量 用于讨论这两者时。

        undefined   AL:1            <RETURN>

        undefined   Stack[0x4]:1    param_1

        undefined4  Stack[0x8]:4    param_2

        undefined4  Stack[0xc]:4    param_3

        undefined4  Stack[-0x10]:4  local_10

        undefined4  Stack[-0x14]:4  local_14

        undefined4  Stack[-0x18]:4  local_18

        undefined1  Stack[-0x58]:1  local_58

Ghidra 提供了一个摘要堆栈视图,列出了堆栈帧中直接引用的每个变量,并附带有关每个变量的重要信息。Ghidra 为每个变量分配的有意义的名称(在第三列)在查看反汇编列表时提供有关变量的信息:传递给函数的参数名称以 param_ 为前缀,本地变量名称以 local_ 为前缀。因此,很容易区分这两种类型的变量。

变量名的前缀与关于变量位置或位置的信息结合在一起。对于参数,例如param_3,名称中的数字对应函数参数列表中该参数的位置。对于局部变量,例如local_10,数字是一个十六进制偏移量,表示变量在堆栈帧中的位置。位置也可以在列表的中间列中找到,位于名称的左侧。该列有两个组件,通过冒号分隔:Ghidra 对变量大小的字节估计和变量在堆栈帧中的位置,表示为该变量相对于函数入口时初始堆栈指针值的偏移量。

该堆栈帧的表格表示如图 6-8 所示。如前所述,参数位于保存的返回地址下方,因此它们相对于返回地址有一个正偏移。局部变量位于保存的返回地址上方,因此它们有一个负偏移。堆栈中局部变量的顺序与它们在本章前面展示的源代码中的声明顺序不匹配,因为编译器可以根据多种内部因素自由地安排局部变量在堆栈中的位置,例如字节对齐和数组相对于其他局部变量的放置。

image

图 6-8:示例堆栈帧图像

反编译器辅助堆栈帧分析

记得我们识别的代码功能等价物吗?

void demo_stackframe_2(int j) {

    helper(10, j);

}

反编译器为该函数生成的代码如图 6-9 所示。Ghidra 的反编译器生成的代码与我们的优化代码非常相似,因为反编译器只包含了原始函数的可执行等价物。(例外是param_1的包含。)

image

图 6-9:反编译器窗口(使用反编译器参数 ID 分析器)

你可能已经注意到,函数demo_stackframe接受了三个整数参数,但在反编译器的列表中只有其中两个(param_1param_2)被列出。缺少的那个是哪一个,为什么?原来,Ghidra 反汇编器和 Ghidra 反编译器对参数命名的方式略有不同。虽然两者都命名了直到最后一个引用的所有参数,但反编译器只命名了直到最后一个有实际用途的参数。Ghidra 可以为你运行的一种分析器叫做反编译器参数 ID 分析器。在大多数情况下,这个分析器默认是禁用的(它只对小于 2MB 的 Windows PE 文件启用)。当启用反编译器参数 ID 分析器时,Ghidra 使用反编译器推导的参数信息来命名反汇编列表中的函数参数。以下列表展示了启用反编译器参数 ID 分析器时,demo_stackframe反汇编列表中的变量:

        undefined   AL:1            <RETURN>

        undefined   Stack[0x4]:4    param_1

        undefined4  Stack[0x8]:4    param_2

        undefined4  Stack[-0x10]:4  local_10

        undefined4  Stack[-0x14]:4  local_14

        undefined4  Stack[-0x18]:4  local_18

        undefined1  Stack[-0x58]:1  local_58

注意,param_3 不再出现在函数参数列表中,因为反编译器已确定它在函数内部没有以任何有意义的方式使用。这个特定的堆栈帧将在第八章中进一步讨论。如果你希望在打开二进制文件后,Ghidra 在禁用该分析器的情况下执行反编译器参数 ID 分析,你可以选择“分析 ▸ 一次性 ▸ 反编译器参数 ID”来在事后运行该分析器。

作为操作数的局部变量

让我们将注意力转向以下列表中的实际反汇编部分:

08048473 55           PUSH   EBP➊

08048474 89 e5        MOV    EBP,ESP

08048476 83 ec 58     SUB    ESP,0x58➋

08048479 8b 45 10     MOV    EAX,dword ptr [EBP + param_3]

0804847c 89 45 f4     MOV    dword ptr [EBP + local_10],EAX➌

0804847f 8b 45 0c     MOV    EAX,dword ptr [EBP + param_2]

08048482 89 45 f0     MOV    dword ptr [EBP + local_14],EAX➍

08048485 c7 45 ec     MOV    dword ptr [EBP + local_18],0xa➎

         0a 00 00 00

0804848c c6 45 ac 41  MOV    byte ptr [EBP + local_58],0x41➏

08048490 83 ec 08     SUB    ESP,0x8

08048493 ff 75 f0     PUSH   dword ptr [EBP + local_14]➐

08048496 ff 75 ec     PUSH   dword ptr [EBP + local_18]

该函数使用一个常见的函数序言➊,用于基于EBP的堆栈帧。编译器在堆栈帧中分配了 88 字节(0x58等于 88)的局部变量空间➋。这略高于预估的 76 字节,表明编译器有时会通过填充额外的字节来保持堆栈帧内的特定内存对齐。

Ghidra 的反汇编列表和我们之前执行的堆栈帧分析之间的一个重要区别是,在反汇编列表中,你看不到类似[EBP-12]的内存引用(例如你可能会在objdump中看到)。相反,Ghidra 已将所有常量偏移量替换为符号名称,这些符号名称对应堆栈视图中的符号及其相对于函数初始堆栈指针位置的偏移量。这与 Ghidra 生成更高级反汇编的目标一致。处理符号名称比处理数字常量更为简便。它还给我们提供了一个可以修改的名称,一旦我们了解变量的用途后,可以使其与我们的理解相匹配。Ghidra 会在 CodeBrowser 窗口的极低右角显示当前指令的原始形式,没有任何标签,供参考。

在这个例子中,由于我们有源代码可以进行对比,我们可以通过使用反汇编中可用的各种线索,将 Ghidra 生成的变量名映射回原始源代码中使用的名称:

  1. 首先,demo_stackframe接受三个参数,ijk,它们分别对应变量param_1param_2param_3

  2. 局部变量xlocal_10)由参数kparam_3)初始化➌。

  3. 类似地,局部变量ylocal_14)由参数jparam_2)初始化➍。

  4. 局部变量zlocal_18)被初始化为值 10➎。

  5. 第一个字符buffer[0]local_58)在 64 字节字符数组中被初始化为 A(ASCII 0x41)➏。

  6. helper的调用有两个参数被压入堆栈➐。在这两个压栈之前的 8 字节堆栈调整与这两个压栈结合在一起,产生了 16 字节的净堆栈变化。因此,堆栈保持了程序中先前所实现的任何 16 字节对齐。

Ghidra 堆栈帧编辑器

除了概览栈视图,Ghidra 还提供了一个详细的栈帧编辑器,其中对栈帧中分配的每一个字节都进行了详细记录。栈帧编辑器窗口可以通过右键点击并在 Ghidra 的概览栈视图中选择函数 ▸ 编辑栈帧来访问。当你在 Ghidra 的概览栈视图中选择一个函数或栈变量时,弹出的窗口将显示 demo_stackframe 函数的情况,具体请参见图 6-10。

image

图 6-10:示例概览栈视图

因为详细视图会考虑栈帧中的每一个字节,所以它占用的空间比概览视图要大得多。图 6-10 中显示的栈帧部分总共占用了 29 字节,这只是整个栈帧的一小部分。在之前的列表中,local_10 ➌,local_14 ➍ 和 local_18 ➎ 在反汇编列表中直接引用,其中它们的内容是通过 dword(4 字节)写入进行初始化的。基于移动了 32 位数据的事实,Ghidra 能推断出这些变量都是 4 字节的,并将它们标记为 undefined4(未知类型的 4 字节变量)。

由于这是一个栈帧编辑器,我们可以使用此窗口编辑字段、改变显示格式,并在对我们的分析有益时添加补充信息。例如,我们可以为 0x0 处的保存返回地址添加一个名称。

基于寄存器的参数

ARM 调用约定使用最多四个寄存器将参数传递给函数,而不使用栈。一些 x86-64 调用约定使用多达六个寄存器,一些 MIPS 调用约定则使用最多八个寄存器。基于寄存器的参数比基于栈的参数稍微难以识别。

请考虑以下两个汇编语言片段:

stackargs:               ; An example x86 32-bit function

    PUSH EBP             ; save no-clobber ebp

    MOV  EBP, ESP        ; set up frame pointer

 ➊ MOV  EAX, [EBP + 8]  ; retrieve stack-allocated argument

    MOV  CL, byte [EAX]  ; dereference retrieved pointer argument

    ...

    RET

regargs:                 ; An example x86-64 function

    PUSH RBP             ; save no-clobber rbp

    MOV  RBP, RSP        ; set up frame pointer

 ➋ MOV  CL, byte [RDI]  ; dereference pointer argument

    ...

    RET

在第一个函数中,保存的返回地址下方的栈区域被访问 ➊,我们可以推断出该函数至少需要一个参数。像大多数高级反汇编工具一样,Ghidra 通过执行栈指针和帧指针分析来识别访问函数栈帧成员的指令。

在第二个函数中,RDI 在初始化之前被使用 ➋。唯一合理的推断是,RDI 必须在调用者中初始化,在这种情况下,RDI 被用来将信息从调用者传递给 regargs 函数(也就是说,它是一个参数)。在程序分析术语中,RDI 在进入 regargs 时是活跃的。为了确定函数期望的基于寄存器的参数数量,可以通过观察函数内哪些寄存器的内容在写入(初始化)之前已经被读取和使用,从而识别出所有活跃的寄存器。

不幸的是,这种数据流分析通常超出了大多数反汇编器的能力,包括 Ghidra。另一方面,反编译器必须执行这种类型的分析,并且通常能够很好地识别基于寄存器的参数的使用。Ghidra 的反编译器参数 ID 分析器(编辑 ▸ 的选项 ▸ 属性 ▸ 分析器)可以根据反编译器执行的参数分析更新反汇编列表。

堆栈编辑器视图提供了编译器内部工作原理的详细视图。在图 6-10 中,可以清楚地看到编译器在保存的帧指针-0x4和局部变量xlocal_10)之间插入了 8 个额外的字节。这些字节占据了堆栈帧中的偏移量-0x5-0xc。除非你是编译器开发人员,或者愿意深入挖掘 GNU gcc的源代码,否则你只能猜测为什么这些额外的字节是以这种方式分配的。在大多数情况下,我们可以将这些额外字节归因于对齐的填充,并且通常这些额外字节的存在对程序的行为没有影响。在第八章中,我们将回到堆栈编辑器视图,并探讨它在处理数组和结构等复杂数据类型时的应用。

搜索

正如本章开头所示,Ghidra 使得通过反汇编导航变得容易,能够定位已知的工件,并发现新的工件。它还设计了许多数据显示,以总结特定类型的信息(如名称、字符串、导入等),使得这些信息也容易找到。然而,对反汇编列表的有效分析通常需要能够搜索新的线索来指导反汇编分析。幸运的是,Ghidra 有一个搜索菜单,允许我们进行搜索以定位感兴趣的项。图 6-11 中显示了默认的搜索菜单选项。在本节中,我们将探讨如何利用 CodeBrowser 提供的文本和字节搜索功能来搜索反汇编。

image

图 6-11:Ghidra 搜索菜单选项

搜索程序文本

Ghidra 的文本搜索实际上是通过反汇编列表视图进行的子字符串搜索。文本搜索通过“搜索 ▸ 程序文本”启动,这将打开图 6-12 所示的对话框。提供两种搜索类型:整个程序数据库,超出您在 CodeBrowser 窗口中看到的内容,以及 CodeBrowser 中的列表显示。除了搜索类型外,还有几个自解释选项,允许您选择搜索方式和搜索内容。

要在匹配项之间导航,可以使用“搜索程序文本”对话框底部的“下一项”和“上一项”按钮,或者选择“搜索全部”以在新窗口中打开搜索结果,方便地导航到任何匹配项。

image

图 6-12:搜索程序文本对话框

我命令你...

搜索窗口是 Ghidra 中的一种窗口类型,你可以随意重命名它们,这将帮助你在实验过程中跟踪搜索窗口。要重命名窗口,只需右键点击标题栏并提供一个对你有意义的名称。一个实用的小技巧是将搜索字符串和助记符一起包含在名称中,以帮助你记住所选择的设置。

搜索内存

如果你需要搜索特定的二进制内容,比如已知的字节序列,那么文本搜索就不是答案。相反,你需要使用 Ghidra 的内存搜索功能。可以通过“搜索 ▸ 内存”或快捷键 S 来启动内存搜索。图 6-13 显示了搜索内存对话框。要搜索十六进制字节序列,搜索字符串应该指定为以空格分隔的两位数、不区分大小写的十六进制值列表,如c9 c3,如图 6-13 所示。如果你不确定十六进制序列,可以使用通配符(*或?)。

image

图 6-13:搜索内存对话框

搜索内存结果中的字节c9 c3,使用“搜索全部”选项运行,结果如图 6-14 所示。你可以对任何列进行排序,重命名窗口,或应用过滤器。此窗口还提供了一些右键选项,包括删除行和操作选择的功能。

image

图 6-14:搜索内存结果

搜索值可以通过字符串、十进制、二进制和正则表达式格式输入。字符串、十进制和二进制各自提供了适合的格式选项。正则表达式让你能够搜索特定的模式,但由于处理方式的限制,它只能向前搜索。Ghidra 使用 Java 内建的正则表达式语法,相关内容在 Ghidra 帮助文档中有详细说明。

总结

本章的目的是为你提供有效解读 Ghidra 反汇编列表并进行导航的基本技能。你与 Ghidra 的绝大多数交互都将涉及到我们到目前为止讨论的操作。然而,能够执行基本的导航,理解堆栈等重要的反汇编结构,以及搜索反汇编,仅仅是逆向工程师技能的冰山一角。

在掌握这些技能后,下一步的逻辑是学习如何根据自己的需求使用 Ghidra。在下一章中,我们将开始研究如何根据对二进制内容和行为的理解,进行最基本的反汇编列表修改,从而添加新的知识。

第九章:反汇编操作**

Image

在导航之后,反汇编修改是 Ghidra 的下一个最重要功能。Ghidra 提供了轻松操作反汇编的能力,能够添加新信息或重新格式化列表以满足你的特定需求,并且由于 Ghidra 的底层结构,你对反汇编所做的更改会很容易地传播到所有相关的 Ghidra 视图,从而保持程序的一致视图。Ghidra 会自动处理诸如上下文感知的搜索和替换等操作(在合理的情况下),并且它能轻松地将指令重新格式化为数据,数据重新格式化为指令。而且,也许最好的功能是你几乎做的任何事情都可以撤销!

我希望我没有做那件事

成为软件逆向工程的高手的一部分是具备探索、实验的能力,并且在必要时,能够回溯并重新追溯自己的步骤。Ghidra 强大的撤销功能使你在 SRE 过程中可以灵活地撤销(和重做)操作。有多种方法可以访问这种神奇的功能:CodeBrowser 工具栏中的适当箭头图标 ➊➋,如图 7-1 所示;从 CodeBrowser 菜单中选择编辑 ▸ 撤销;以及使用热键 CTRL-Z 撤销和 CTRL-SHIFT-Z 重做。

image

图 7-1:CodeBrowser 工具栏中的撤销和重做图标

操作名称和标签

到目前为止,我们已经在 Ghidra 反汇编中遇到了两类标识符:标签(与位置相关的标识符)和名称(与栈帧变量相关的标识符)。在大多数情况下,我们会将这两者统称为名称,因为 Ghidra 在这方面也有些模糊。(如果要非常精确,标签实际上有相关的名称、地址、历史记录等。标签的名称是我们通常引用标签的方式。)当这种区分至关重要时,我们会使用更具体的术语。

总结一下,栈变量名称有两种前缀,取决于变量是参数(param_)还是局部变量(local_),而位置在自动分析期间会分配有帮助的前缀的名称/标签(例如,LAB_DAT_FUN_EXT_OFF_UNK_)。在大多数情况下,Ghidra 会根据它对相关变量或地址用途的最佳猜测自动生成名称和标签,但你仍然需要自己分析程序,理解位置或变量的目的。

当你开始分析任何程序时,最常见的处理反汇编列表的方法之一是将默认名称更改为更有意义的名称。幸运的是,Ghidra 允许你轻松更改任何名称,并且它会智能地将名称更改传播到整个程序中。要打开名称更改对话框,请点击名称,然后使用 L 快捷键或右键上下文菜单中的编辑标签选项。从这里开始,栈变量(名称)和命名位置(标签)的过程会有所不同,详情见后面的章节。

重命名参数和局部变量

与栈变量相关的名称并不与特定的虚拟地址相关联。与大多数编程语言一样,这些名称仅限于所属栈帧对应的函数作用域。因此,程序中的每个函数可以有一个名为param_1的栈变量,但没有任何函数可以有多个名为param_1的变量,如图 7-2 所示。

image

图 7-2: 符号树显示参数名称的重用(param_1)

当你在列出窗口中重命名变量时,图 7-3 中所示的提示对话框将弹出。你正在更改的实体类型(变量、函数等)将出现在窗口的标题栏中,而当前(即将更改的)名称将出现在可编辑文本框和标题栏中。

image

图 7-3: 重命名栈变量(local_14 改为 y)

一旦提供了新名称,Ghidra 将更改当前函数中所有旧名称的出现。以下列表显示了在demo_stackframe中将local_14重命名为y后的结果:

     *******************************************************************

     *                         FUNCTION                                *

     *******************************************************************

     undefined demo_stackframe(undefined param_1, undefined4

        undefined     AL:1              <RETURN>

        undefined     Stack[0x4]:1      param_1

        undefined4    Stack[0x8]:4      param_2      

        undefined4    Stack[0xc]:4      param_3      

        undefined4    Stack[-0x10]:4    local_10     

        undefined4    Stack[-0x14]:4    y➊           

        undefined4    Stack[-0x18]:4    local_18     

        undefined1    Stack[-0x58]:1    local_58     

     demo_stackframe    

08048473 55           PUSH   EBP

08048474 89 e5        MOV    EBP,ESP

08048476 83 ec 58     SUB    ESP,0x58

08048479 8b 45 10     MOV    EAX,dword ptr [EBP + param_3]

0804847c 89 45 f4     MOV    dword ptr [EBP + local_10],EAX

0804847f 8b 45 0c     MOV    EAX,dword ptr [EBP + param_2]

08048482 89 45 f0     MOV    dword ptr [EBP + y],EAX➋

08048485 c7 45 ec     MOV    dword ptr [EBP + local_18],0xa

         0a 00 00 00

0804848c c6 45 ac 41  MOV    byte ptr [EBP + local_58],0x41

08048490 83 ec 08     SUB    ESP,0x8

08048493 ff 75 f0     PUSH   dword ptr [EBP + y]➌

08048496 ff 75 ec     PUSH   dword ptr [EBP + local_18]

08048499 e8 88 ff     CALL   helper                        

         ff ff

0804849e 83 c4 10     ADD    ESP,0x10

080484a1 90           NOP

080484a2 c9           LEAVE

080484a3 c3           RET

这些更改➊➋➌也会反映在符号树中,如图 7-4 所示。

image

图 7-4: 重命名栈变量后的符号树视图,y

禁止的名称

有一些有趣的规则限制了你在函数内命名变量。以下是一些更相关的参数命名规则:

  • 不能在名称中使用前缀param_后跟一个整数,即使结果名称与现有参数名称不冲突。

  • 可以使用前缀param_,后跟其他字符。

  • 可以使用前缀Param_后跟一个整数,因为名称区分大小写(但这可能不建议这样做)。

  • 可以通过输入param_后跟一个整数值来将参数名称恢复为原始的 Ghidra 分配名称。如果使用原始的整数值,Ghidra 将无异常地恢复该名称。如果使用任何不同于原始值的整数,Ghidra 将警告“重命名失败——默认名称不可使用.”此时,在重命名参数对话框中点击取消将恢复原始名称。

  • 可以拥有两个参数,分别命名为param_1(由 Ghidra 命名)和Param_1(由你命名)。名称区分大小写,但不建议重复使用它们。

局部变量也区分大小写,你可以使用前缀local_和非数字后缀。

对于所有类型的变量,你不能使用已经在该范围内(例如,在同一个函数中)使用的变量名。你的尝试将会被对话框拒绝,并附有拒绝的理由。

最后,如果你对标签感到完全困惑,可以通过按快捷键 H,选择“显示所有历史记录”,并将变量的当前名称(或过去的名称)输入文本框来查看变量的标签历史。(此选项也可以通过主菜单中的“搜索 ▸ 标签历史”来访问。)

你应该在哪里更改你的名字?

变量名称可以从列表、符号树和反编译窗口中更改;结果是一样的,但从列表窗口访问的对话框会显示更多信息。使用任何这些方法时,都会强制执行与命名变量相关的所有规则。

本书中的许多示例参数名称都是在列表窗口中使用左侧所示对话框进行更改的,参见图 7-5。要在符号树中更改名称,请右键点击名称并从上下文菜单中选择重命名。在反编译窗口中,使用快捷键 L,或使用重命名变量上下文菜单选项;相应的对话框显示在图 7-5 的右侧。虽然这两个对话框提供相同的功能,但右侧对话框不包含与参数相关的命名空间或属性信息。

image

图 7-5:从列表窗口或符号树(左)或反编译窗口(右)重命名变量

在 Ghidra 中,命名空间只是一个命名的范围。在命名空间内,所有符号都是唯一的。全局命名空间包含二进制文件中的所有符号。函数命名空间嵌套在全局命名空间内。在函数命名空间内,所有变量名和标签都是唯一的。函数本身也可以包含嵌套的命名空间,例如与开关语句相关联的命名空间(这允许在不同的命名空间中重用 case 标签;例如,当一个函数包含两个开关语句,每个语句都有一个 case 10)。

重命名标签

标签是与位置相关联的默认或用户分配的名称。与堆栈变量一样,名称更改对话框可以通过快捷键 L 或上下文选项“编辑标签”打开。当你更改一个位置的名称时,还可以更改其命名空间和属性,如图 7-6 所示。

image

图 7-6:重命名函数

这个增强版对话框在标题栏中显示实体类型和虚拟地址。在属性下,你可以将地址标识为入口点或固定地址(参见“编辑标签”第 126 页)。如第六章所述,Ghidra 限制名称的最大长度为 2000 个字符,所以可以随意使用有意义的名称,甚至将关于该地址的叙述嵌入其中(不允许有空格)。如果名称过长,清单窗口将只显示部分名称,但反编译器窗口会显示完整名称。

添加新标签

虽然 Ghidra 会生成许多默认标签,但你也可以添加新标签并将它们与清单中的任何地址关联。这些标签可以用于注释你的反汇编,尽管在许多情况下,注释(本章后面会讨论)是更合适的方式。要添加新标签,请打开添加标签对话框(快捷键 L),如图 7-7 所示,针对与光标位置关联的地址。名称的下拉列表包含你最近使用的名称列表,而命名空间的下拉列表让你选择适当的标签范围。

image

图 7-7:添加标签对话框

FUN_ 与前缀

当 Ghidra 在自动分析过程中创建标签时,它使用有意义的前缀后跟地址,告诉你在该位置会发生什么。这些前缀如下列出,并附有非常概括的描述。有关每个前缀的更多信息,可以在 Ghidra 帮助中找到。

LAB_address 代码——自动生成的标签(通常是函数内的跳转目标)

DAT_address 数据——自动生成的全局变量名称

FUN_address 函数——自动生成的函数名称

SUB_address 调用目标(或等效项)——可能不是函数

EXT_address 外部入口点——可能是其他人的函数

OFF_address 剪切片(位于现有数据或代码内部)——可能是反汇编错误

UNK_address 未知——无法确定此处数据的目的

函数标签具有以下特定行为:

  • 如果你在清单窗口删除了默认的函数标签(例如 FUN_08048473),则 FUN_ 前缀将被 SUB_ 前缀替换(在这种情况下,结果为 SUB_08048473)。

  • 向一个已有默认 FUN_ 标签的地址添加新标签,会改变函数名称,而不是创建新标签。

  • 标签区分大小写,因此你可以使用 Fun_fun_ 作为有效的前缀,如果你的目的是创建混淆的反汇编。

如果你尝试使用 Ghidra 的保留前缀来命名,可能会遇到冲突。如果你坚持使用一个保留前缀,Ghidra 会拒绝你的新标签,如果它认为可能会发生名称冲突。仅当 Ghidra 确定你的后缀看起来像一个地址时才会发生这种情况(根据我们的经验,这意味着四个或更多的十六进制数字)。例如,Ghidra 会允许 FUN_zoneFUN_123,但会拒绝 FUN_12345。此外,如果你尝试在与具有默认标签的函数相同的地址上添加标签(例如,FUN_08048473),Ghidra 会重命名该函数,而不是在该位置添加第二个标签。

编辑标签

要编辑标签,可以使用快捷键 L 或右键菜单中的“编辑标签”选项。编辑标签会呈现与添加标签相同的对话框,只不过对话框中的字段会初始化为现有标签的当前值。请注意,编辑标签可能会对共享相同地址的其他标签产生影响,无论它们是否共享相同的命名空间。例如,如果你将一个标签标识为入口点,Ghidra 会将与该位置关联的所有标签标识为入口点。

这是一个 BUG 还是一个特性?

在尝试修改函数名称的过程中,你可能会注意到,Ghidra 很乐意让你为两个函数设置相同的名称。这可能会让你联想到重载函数,可以通过传递给它们的参数来区分它们。Ghidra 的能力远不止于此:即使这导致在同一命名空间内出现重复的函数原型,你也可以给两个函数完全相同的名称。这是可能的,因为标签不是唯一的标识符(在数据库意义上是主键),因此即使与其相关的参数一起考虑,它也不能唯一标识一个函数。重复的名称可以用来标记函数;例如,用于对它们进行进一步分析或将它们排除在考虑之外。请记住,所有的名称都会保存在函数历史记录中(快捷键 H),并且可以轻松恢复。

图 7-7 中的主复选框表示这是当地址显示时将显示的标签。默认情况下,此复选框对主标签是禁用的,因此你不能取消选择主名称。这是必要的,以确保始终有一个名称可供显示。如果选择了另一个标签作为主标签,那么该标签的复选框将被禁用,其他标签在相同地址上的复选框将被启用。

尽管到目前为止,我们将标签与地址关联,但实际上标签最常见的用途是与拥有地址的内容相关联。例如,main 标签通常表示程序中主函数代码块的开始。Ghidra 会根据文件头信息为该位置分配一个地址。如果我们将整个二进制内容重新定位到新的地址范围,我们预计 main 标签会继续正确地关联到 main 的新地址及其对应的、未改变的字节内容。当一个标签被 固定 时,标签与其地址处内容的关联会被切断。如果你随后将二进制内容移动到新地址范围,任何固定的标签将不会相应移动,而是会固定在你固定它们时的地址。固定标签最常见的用途是为复位向量和内存映射的 I/O 地址命名,这些地址是由处理器/系统设计者指定的特定地址。

移除标签

要移除光标处的标签,你可以使用右键菜单选项(或快捷键 DELETE)。需要注意的是,并非所有标签都可以删除。首先,不可能删除默认的 Ghidra 生成标签。其次,如果你重命名了一个默认标签,并且后来决定删除这个新标签,Ghidra 会将你删除的标签替换为最初分配的默认标签(这是上一陈述的直接结果)。有关删除标签的更多细节,请参考 Ghidra 帮助文档。

导航标签

标签与可导航的位置相关联,因此双击标签的引用将带你跳转到该标签。虽然这一点在第九章中有更详细的讨论,但请记住,你可以在反汇编中为任何你希望导航到的位置添加标签。虽然在第 132 页的“注释”部分也描述了相同的功能,但有时候,标签(特别是它的 2000 字符限制)是实现同样目标的最快方法。

注释

将注释嵌入到反汇编和反编译器列表中,是在分析程序时为自己留下进展和发现备注的特别有用方式。Ghidra 提供了五种类型的注释,每种类型都适用于不同的目的。我们首先来看一下可以直接添加到反汇编中的注释,这些注释出现在列表窗口中。

尽管你可以通过右键菜单导航到“设置注释”对话框(见图 7-8),最快的方法是使用注释的快捷键,即分号(;)键。(这是一个合乎逻辑的选择,因为在许多汇编语言的变种中,分号是注释的标志。)

image

图 7-8:设置注释对话框

“设置评论”对话框会与特定地址一起打开:如图 7-8 中所示的08048479,并在标题栏中显示。输入到五个评论类别标签(EOL、Pre、Post、Plate 和 Repeatable Comments)中的任何内容都会与该地址关联。

默认情况下,你在文本框中输入内容,包括回车符,创建一个或多行的评论,然后点击应用确定。(应用可以让你在上下文中查看评论,并保持“设置评论”对话框打开以便继续编辑。)为了节省输入简短评论的时间,可以在对话框左下角选择回车键接受评论复选框。(如果你写的是特别详细的板块评论,你随时可以暂时取消勾选此框。)

这三个按钮

在“设置评论”对话框底部的三个按钮(见图 7-8)中,OK 和 Apply 按钮的行为符合预期。点击 OK 会关闭对话框并提交更改。点击 Apply 会更新列表,方便你检查更改并决定是否批准或继续编辑评论。

然而,Dismiss 并不等同于 Cancel,Cancel 会退出对话框且对列表没有任何影响!这个独特的术语与其独特的行为一致。点击 Dismiss 按钮,如果没有修改任何评论,则立即退出窗口;如果修改了评论,则可以选择是否保存更改。使用右上角的 X 关闭窗口也会表现出相同的行为。这种 Dismiss 功能将在 Ghidra 的其他地方遇到。

要删除评论,可以在“设置评论”对话框中清除评论文本,或者当光标停在列表窗口的评论上时使用快捷键 DELETE。右键点击 Comments ▸ Show History for Comment 可以用来回顾与特定地址关联的评论,并根据需要恢复它们。

行尾评论

可能最常用的评论类型是行尾(EOL)评论,它们被放置在列表窗口现有行的末尾。要添加一个行尾评论,可以使用分号快捷键打开“设置评论”对话框并选择 EOL 评论标签。默认情况下,EOL 评论以蓝色文本显示,如果你在评论文本框中输入多行,它们会跨越多行。每一行会缩进对齐到反汇编的右侧,现有内容会被向下移动以腾出空间给新评论。你可以随时通过重新打开“设置评论”对话框来编辑评论。删除评论的最快方法是点击列表窗口中的评论并按 DELETE 键。

Ghidra 本身在自动分析过程中添加了许多 EOL 注释。例如,当你加载 PE 文件时,Ghidra 会插入描述 EOL 注释,用以描述IMAGE_DOS_HEADER部分的字段,包括Magic number注释。只有当 Ghidra 拥有与特定数据类型相关的信息时,才能做到这一点。这些信息通常包含在类型库中,在数据类型管理器窗口中显示,并在第八章和第十三章中深入讨论。在所有注释类型中,EOL 注释通过编辑 ▸ 工具选项 ▸ 列表字段选项对每个注释类型进行配置的能力最强。

前置和后置注释

前置注释和后置注释是完整行注释,分别出现在给定反汇编行的前后。以下示例显示了一个多行的前置注释和一个截断的单行后置注释,关联地址为08048476。将鼠标悬停在截断的注释上将显示完整的注释。默认情况下,前置注释以紫色显示,后置注释以蓝色显示,因此你可以轻松地将它们与列表中的正确地址关联。

08048473  PUSH   EBP

08048474  MOV    EBP,ESP

        ******** Pre Comment - This is a multi-line comment.

        ******** The following statement allocates 88 bytes of local

        ******** variable space in the stack frame.

08048476  SUB    ESP,0x58

        ******** Post Comment - Now that we have allocated the space...

08048479  MOV    EAX,dword ptr [EBP + param_3]

板块注释

板块注释允许你在列表窗口的任何位置分组显示注释。板块注释居中,并置于星号框住的矩形内。我们已经检查的许多列表都包含了一个简单的板块注释,框内写着FUNCTION,如图 7-9 所示。这个示例包括了右侧的反编译窗口,你可以看到,在这个默认展示中,一个板块注释已被插入到列表窗口中,但反编译窗口中没有对应的注释。

image

图 7-9:板块注释示例

当你在选择函数中的第一个地址时打开注释对话框,你可以选择用更具信息性的自定义注释替换默认的板块注释,如图 7-10 所示。除了替换默认的板块注释外,Ghidra 还会在反编译窗口顶部添加你的注释作为 C 风格注释。如果光标在创建板块注释时位于反编译窗口顶部,结果也是一样的。

image

图 7-10:自定义板块注释示例

注意

默认情况下,反编译窗口只显示板块注释和前置注释,但你可以通过编辑 ▸ 工具选项 ▸ 反编译器 ▸ 显示来更改此设置。

可重复的注释

可重复注释是一次输入但可能在反汇编中许多位置自动出现的注释。可重复注释的行为与交叉引用的概念相关,交叉引用将在第九章中深入讨论。基本上,在交叉引用目标位置输入的可重复注释会在交叉引用源位置回显。因此,一个可重复注释可能会在反汇编的多个位置回显(因为交叉引用可以是多对一的)。在反汇编清单中,可重复注释的默认颜色为橙色,回显注释为灰色,这使它们与其他类型的注释区分开来。以下清单演示了可重复注释的使用。

08048432  JGE    LAB_08048446                  Repeatable comment at 08048446➊

08048434  SUB    ESP,0xc

08048437  PUSH   s_The_second_parameter_is_larger

0804843c  CALL   puts                          

08048441  ADD    ESP,0x10

08048444  JMP    LAB_08048470

        LAB_08048446                                              

08048446  MOV    EAX,dword ptr [EBP + param_2] Repeatable comment at 08048446➋

在清单中,08048446 ➋ 位置设置了可重复注释,并在 08048432 ➊ 位置重复出现,因为 08048432 位置的指令将地址 08048446 作为跳转目标(因此,从 0804843208048446 存在交叉引用)。

当 EOL 注释和可重复注释共享相同的地址时,仅 EOL 注释会在清单中显示。两个注释都可以在“设置注释”对话框中查看和编辑。如果你删除 EOL 注释,则可重复注释将在清单中变为可见。

参数和局部变量注释

要将注释与堆栈变量关联,请选择堆栈变量并使用分号快捷键。图 7-11 显示了生成的最小注释窗口。该注释将以类似于 EOL 注释的格式显示在堆栈变量旁边。将鼠标悬停在注释上时,可以显示完整的注释。注释的颜色与变量类型的默认颜色相匹配,而不是 EOL 注释的蓝色默认颜色。

image

图 7-11:堆栈变量注释

注释

Ghidra 提供了一种强大的功能,可以在其“设置注释”对话框中为注释添加链接,链接可以指向程序、URL、地址和符号。当符号名称发生变化时,注释中的符号信息会自动更新。当你使用注释启动指定的可执行文件时,你还可以提供可选参数以获得更大的控制权限(是的,这对我们来说听起来也很危险)。

例如,图 7-12 中的板注释提供了一个指向清单中地址的超链接。关于注释强大功能的更多信息可以在 Ghidra 帮助中找到。

image

图 7-12:地址注释示例

基本代码转换

在许多情况下,您会对 Ghidra 生成的反汇编列表感到非常满意。然而,在某些情况下,您可能不会满意。当您分析的文件类型越来越远离普通可执行文件时,您可能需要更多地控制反汇编分析和显示过程。如果您分析的是混淆代码或使用了自定义(Ghidra 未知)文件格式的文件,这一点尤为重要。

Ghidra 支持以下代码转换(包括但不限于):

  • 更改代码显示选项

  • 格式化指令操作数

  • 操作函数

  • 将数据转换为代码

  • 将代码转换为数据

一般来说,如果二进制文件非常复杂,或者 Ghidra 不熟悉用于构建二进制文件的编译器生成的代码序列,那么在分析阶段,Ghidra 将遇到更多问题,您需要手动调整反汇编代码。

更改代码显示选项

Ghidra 允许对列表窗口中的行进行非常细粒度的格式控制。布局通过浏览器字段格式化器进行控制(在第五章中介绍)。选择浏览器字段格式化器图标会打开一个选项卡式显示,展示与您的列表相关的所有字段,如图 5-8 所示。您可以通过简单的拖放界面添加、删除和重新排列字段,并立即查看列表中的更改。列表字段项与关联的浏览器字段格式化器之间的紧密关联非常有用。每当您将光标移动到列表窗口中的新位置时,浏览器字段格式化器会自动移动相应的选项卡和字段,以便您可以立即识别与特定项相关的选项。有关浏览器字段格式化器的更多讨论,请参见第十二章中的“特殊工具编辑功能”。

要控制列表窗口中各个元素的显示方式,可以选择“编辑 ▸ 工具选项”,如第四章所述。每个字段在列表窗口中都有独特的子菜单,允许您根据个人喜好微调每个字段。虽然每个字段的功能不同,但通常您可以控制显示颜色、关联的默认值、配置和格式。例如,喜欢汇编代码并在闲暇时阅读的用户,可能会选择调整 EOL 注释字段区域的默认参数,如图 7-13 所示,启用“在每行开始处显示分号”选项,以便以熟悉的格式查看汇编注释。

image

图 7-13:EOL 注释字段的工具选项菜单

若要为列表窗口中的单独行或较大范围的选择设置背景颜色,请通过右键点击上下文菜单选择“颜色”选项,并选择一种颜色。可用的颜色范围广泛,还提供了最近使用的颜色快速选择选项。通过相同的菜单,您还可以清除单行、选定区域或整个文件的背景颜色。

注意

如果当前未为列表设置任何颜色,则清除选项不会显示。

格式化指令操作数

在自动分析过程中,Ghidra 会做出许多关于如何格式化与每条指令相关的操作数的决定,尤其是各种整数常量,这些常量被各种指令类型广泛使用。其中,这些常量可能表示跳转或调用指令中的相对偏移、全局变量的绝对地址、算术运算中使用的值,或程序员定义的常量。为了提高反汇编的可读性,Ghidra 尽量使用符号名称而不是数字。

在某些情况下,格式化决定是根据被反汇编指令的上下文做出的(例如调用指令);在其他情况下,决定是基于所使用的数据(例如访问全局变量或栈帧或结构中的偏移量)。通常,Ghidra 可能无法辨别常量使用的确切上下文。当这种情况发生时,常量通常以十六进制值的形式进行格式化。

如果你不是世界上少数几个以十六进制为生的人,你一定会欢迎 Ghidra 的操作数格式化功能。假设你的反汇编列表中有以下内容:

08048485  MOV    dword ptr [EBP + local_18],0xa

0804848c  MOV    byte ptr [EBP + local_58],0x41

右键点击十六进制常量0x41,会弹出如图 7-14 所示的上下文敏感菜单。(请参见图 6-7 查看此示例的上下文。)该常量可以按图中右侧显示的各种数字表示重新格式化,或者作为字符常量(因为该值也在 ASCII 可打印范围内)。这个功能非常有帮助,因为你可能没有意识到给定常量可以关联的多种表示方式。在所有情况下,菜单会显示如果选择某个选项,操作数文本将被替换的确切文本。

image

图 7-14:常量的格式化选项

在许多情况下,程序员在源代码中使用命名常量。这些常量可能是#define语句(或其等效语句)的结果,或者它们可能属于枚举常量集合。不幸的是,当编译器处理完源代码后,已经无法确定源代码使用的是符号常量还是文字常量。幸运的是,Ghidra 维护了一个包含许多常见库(如 C 标准库或 Windows API)关联的命名常量的大型目录。通过在与任何常量值相关的上下文菜单中选择“设置等式”选项(快捷键 E),可以访问此目录。选择常量0xa的“设置等式”选项后,会打开设置等式对话框(图 7-15)。

image

图 7-15:设置等式对话框

该对话框是从 Ghidra 的内部常量列表中填充的,经过筛选以符合我们正在尝试格式化的常量值。在这种情况下,我们可以滚动查看 Ghidra 已知的所有与值0xA相等的常量。如果我们确定该值与创建 X.25 风格的网络连接有关,我们可能会选择AF_CCITT,最终得到以下反汇编行:

08048485  MOV    dword ptr [EBP + local_18],AF_CCITT

标准常量列表非常有用,可以帮助确定特定常量是否与已知名称相关联,并节省大量时间浏览 API 文档寻找潜在匹配项。

操作函数

Ghidra 提供了在反汇编中操作函数的功能(例如,修正 Ghidra 识别为属于函数的代码,或更改函数属性),这在你不同意自动分析结果时尤其有用。在某些情况下,例如 Ghidra 无法找到函数调用时,可能会无法识别函数,因为没有显而易见的方式可以到达它们。在其他情况下,Ghidra 可能无法正确定位函数的结束位置,要求你修正反汇编。当编译器将函数分割到多个地址范围中,或在优化代码的过程中,编译器将两个或多个函数的常见结束序列合并以节省空间时,Ghidra 可能会很难找到函数的结束位置。

创建新函数

可以通过现有的、不属于函数的指令创建新函数。创建函数的方法是右键点击将要包含在新函数中的第一条指令,并选择“创建函数”(或快捷键 F)。如果选择了一个范围,它将成为函数体。如果没有选择,Ghidra 将跟随控制流,试图确定函数体的边界。

删除函数

您可以通过将光标放置在函数签名内并使用快捷键 DELETE 删除现有的函数。如果您认为 Ghidra 在自动分析时出错,或者您在创建函数时出错,您可能希望删除某个函数。请注意,尽管函数及其相关属性将不再存在,但底层字节内容不会发生任何变化,因此如果需要,可以重新创建该函数。

编辑函数属性

Ghidra 为它识别的每个函数关联多个属性,您可以通过从 CodeBrowser 菜单中选择“窗口 ▸ 函数”选项来查看这些属性。(虽然默认只显示五个属性,但您可以通过右键单击列标题添加额外的 16 个属性。)要编辑这些属性,请在光标位于函数的板注释与函数反汇编代码开始前的最后一个局部变量之间的区域时,从右键上下文菜单中打开编辑函数对话框。编辑函数对话框的示例见图 7-16。

image

图 7-16:编辑函数对话框

通过此对话框可以修改的每个属性在这里进行了说明:

函数名称

您可以在对话框顶部的文本框中修改名称,也可以在“函数名称”字段中修改。

函数属性

在此区域可以启用五个可选的函数属性。前四个属性,Varargs、In Line、No Return 和 Use Custom Storage,默认为未选中复选框。第五个可选属性 Call Fixup 出现在对话框的左下角,默认为 none,并提供一个下拉菜单,您可以从中选择一个值。如果您修改了任何函数的属性,Ghidra 会自动将更新后的函数原型传播到所有可能显示该函数的反汇编位置。

可变参数(Varargs)选项表示函数接受可变数量的参数(例如,printf)。如果你在编辑函数参数列表(位于图 7-16 顶部的文本字段中),使得最后一个参数有省略号(...),也会启用可变参数。内联选项对反汇编分析没有影响,除了在函数原型中包含inline关键字。请注意,如果编译器实际内联了函数,你在反汇编中将看不到该函数作为一个独立实体,因为其主体将嵌入在调用它的函数体内部。无返回选项用于已知函数永远不会返回的情况(例如,使用exit或不透明谓词跳转到另一个函数)。当标记函数为无返回时,Ghidra 将不假定调用该函数后的字节是可达的,除非有其他证据支持其可达性,例如跳转指令指向这些字节。使用自定义存储选项允许你覆盖 Ghidra 对参数和返回值存储位置及大小的分析。

调用约定

调用约定下拉菜单允许你修改函数使用的调用约定。修改调用约定可能会改变 Ghidra 的堆栈指针分析,因此正确设置非常重要。

函数变量

函数变量区域允许你在指导下编辑函数变量。当你修改与变量相关的四列数据时,Ghidra 会提供信息,帮助你适当地进行更改。例如,尝试更改param_1的存储将显示消息:“启用‘使用自定义存储’以允许编辑参数和返回存储”。右侧的四个图标允许你添加、删除和导航变量。

将数据转换为代码(反之亦然)

在自动分析阶段,数据字节可能会被错误地分类为代码字节并解析为指令,或者代码字节可能会被错误地分类为数据字节并格式化为数据值。这种情况有多种原因,包括某些编译器将数据嵌入程序的代码部分,以及一些代码字节从未直接引用为代码,因此 Ghidra 选择不对其进行反汇编。特别是混淆程序通常故意模糊代码和数据之间的区别。(参见第二十一章。)

重新格式化任何内容的第一个选项是移除其当前的格式(代码或数据)。通过右键点击您希望取消定义的项目并选择“清除代码字节”(快捷键 C),可以取消定义函数、代码或数据。取消定义一个项目会使底层字节重新格式化为一系列原始字节值。通过使用点击并拖动操作选择一段地址范围,可以取消定义较大的区域。在下面的例子中,考虑一个简单的函数列表:

004013e0  PUSH   EBP

004013e1  MOV    EBP,ESP

004013e3  POP    EBP

004013e4  RET

取消定义该函数将产生一系列未分类的字节,如下所示,我们可以几乎以任何方式重新格式化它们:

004013e0      ??       55h    U

004013e1      ??       89h

004013e2      ??       E5h

004013e3      ??       5Dh    ]

004013e4      ??       C3h

要反汇编一系列未定义的字节,请右键点击第一个要反汇编的字节并选择 反汇编。这会导致 Ghidra 从该点开始递归下降算法。通过使用点击并拖动选择一段地址范围,可以将大区域转换为代码,在执行代码转换操作之前。

将代码转换为数据稍微复杂一些。首先,除非您先取消定义要转换为数据的指令,并且适当地格式化字节,否则不能通过右键菜单直接将代码转换为数据。基本数据格式化将在下一节中讨论。

基本数据转换

要理解程序的行为,正确格式化的数据与正确格式化的代码一样重要。Ghidra 从多个来源获取信息,并使用算法方法来确定在反汇编中最合适的数据格式。例如:

  • 数据类型和/或大小可以通过寄存器的使用方式推断出来。一条从内存加载 32 位寄存器的指令意味着相关的内存位置包含一个 4 字节的数据类型(尽管我们可能无法区分 4 字节的整数和 4 字节的指针)。

  • 可以使用函数原型为函数参数分配数据类型。Ghidra 维护了一个大型的函数原型库,正是为了这个目的。通过分析传递给函数的参数,试图将一个参数与一个内存位置关联。如果能揭示出这种关系,可以将数据类型应用于相关的内存位置。考虑一个函数,其唯一的参数是指向 CRITICAL_SECTION(Windows API 数据类型)的指针。如果 Ghidra 能确定传递给该函数的地址,则该地址可以被标记为 CRITICAL_SECTION 对象。

  • 分析字节序列可以揭示可能的数据类型。这正是扫描二进制文件时发现字符串内容的情况。当遇到长序列的 ASCII 字符时,假设它们表示字符数组并不为过。

在接下来的几个部分中,我们将讨论一些您可以在反汇编中对数据进行的基本转换。

指定数据类型

Ghidra 提供了数据大小和类型说明符。最常见的说明符有 byteworddwordqword,分别表示 1 字节、2 字节、4 字节和 8 字节的数据类型。可以通过右键点击任何包含数据(不是指令)的反汇编行,并选择图 7-17 中显示的“设置数据类型”子菜单,来设置或更改数据类型。

image

图 7-17:数据子菜单

此列表允许你通过选择数据类型立即更改当前选中项的格式和数据大小。循环选项让你能够快速循环浏览一组相关的数据类型,例如数字、字符和浮点类型,正如图 7-18 所示(带有关联热键)。例如,反复按 F 键将让你在 float 和 double 类型之间切换,因为它们是该循环组中的唯一项。

image

图 7-18:循环组

切换数据类型会导致数据项的大小发生变化,可能变大、变小或保持不变。如果数据项的大小保持不变,唯一可观察到的变化就是数据的格式。如果你将数据项的大小从 ddw(4 字节)减少到 db(1 字节),例如,任何额外的字节(本例中为 3 字节)将变为未定义。如果你增大数据项的大小,Ghidra 会警告你存在冲突,并引导你解决该冲突。一个涉及数组维度的示例见图 7-19。

image

图 7-19:数组声明和警告示例

与字符串一起工作

选择“搜索 ▸ 字符串”会弹出图 7-20 所示的对话框,在该对话框中可以设置和控制特定字符串搜索的搜索条件。虽然该窗口中的大多数字段不言自明,Ghidra 的一个独特功能是能够将 字模型 与搜索关联。字模型可用于确定在特定上下文中某个字符串是否被视为一个单词。字模型的详细讨论请参见第十三章。

image

图 7-20:搜索字符串对话框

一旦搜索完成,结果将在字符串搜索窗口中显示(图 7-21)。后续的搜索结果将以标签页的形式展示在同一窗口中,窗口标题栏将包括每次搜索的时间戳,方便你按时间顺序排列。

image

图 7-21:显示搜索结果的字符串搜索窗口

字符串搜索窗口的最左侧列包含表示字符串定义状态的图标(从未定义到冲突)。这些图标的含义如图 7-22 所示。要显示或隐藏某一类别中的字符串,可以在标题栏中切换相应的图标。

image

图 7-22:字符串切换图标定义

使用图标可以让你轻松识别列表中尚未定义为字符串的项目,并通过选择它们并点击相应的“Make String”或“Make Char Array”按钮,将它们转化为字符串或字符数组。这些新定义的实体将显示在“已定义字符串”窗口中,详情请参见 “已定义字符串窗口”中的第 81 页。

定义数组

来自高级语言的反汇编列表的一个缺点是,它们提供的数组大小线索非常少。在反汇编列表中,如果每个数组项都单独列在一行,数组可能会占用大量空间。以下列表显示了数据段中的一系列项目。唯一被指令引用的项目是列表中的第一个项,这表明它可能是数组中的第一个元素。数组中的其他元素通常不会直接被引用,而是通过相对于数组起始位置的索引计算进行引用。

        DAT_004195a4                          XREF[1]:  main:00411727(W)

004195a4    undefined4    ??

004195a8      ??          ??

004195a9      ??          ??

004195aa      ??          ??

004195ab      ??          ??

004195ac      ??          ??

004195ad      ??          ??

004195ae      ??          ??

004195af      ??          ??

004195b0      ??          ??

004195b1      ??          ??

004195b2      ??          ??

004195b3      ??          ??

004195b4      ??          ??

004195b5      ??          ??

004195b6      ??          ??

Ghidra 可以将连续的数据定义组合成一个数组定义。要创建数组,选择数组的第一个元素,并在上下文菜单中使用“数据 ▸ 创建数组”选项(快捷键)。系统会提示你输入数组的元素数量,或者你可以接受 Ghidra 建议的默认值。(如果你选择的是一个数据范围而不是单个值,Ghidra 会将你的选择作为数组的边界。)默认情况下,数组元素的数据类型和大小是基于选择中的第一个元素的数据类型。数组以折叠格式呈现,但可以展开查看各个元素。每行显示的元素数量可在 CodeBrowser 窗口的“编辑 ▸ 工具选项”中进行控制。关于数组的详细讨论,请参见[第八章。

总结

与前一章一起,本章涵盖了 Ghidra 用户最常需要执行的操作。反汇编操作让你能够将自己的知识与 Ghidra 在分析阶段提供的知识结合起来,从而生成有价值的信息。就像源代码一样,正确使用名称、分配数据类型以及详细的注释,不仅有助于你记住自己分析过的内容,还能极大地帮助其他使用你工作的人。在下一章中,我们将探讨如何处理更复杂的数据结构,比如 C 语言的struct,并研究一些编译后的 C++语言的低级细节。

第十章:数据类型与数据结构

图片

理解你在分析二进制时遇到的数据类型和数据结构是逆向工程的基础。传递给函数的数据是逆向工程函数签名的关键(即函数所需的参数数量、类型和顺序)。除此之外,在函数内部声明和使用的数据类型和数据结构为每个函数的作用提供了更多线索。这进一步强调了深入理解数据类型和数据结构在汇编语言级别上的表示和操作的重要性。

在本章中,我们将大量时间专注于这些对逆向工程工作至关重要的主题。我们将演示如何识别反汇编中使用的数据结构,并在 Ghidra 中建模这些结构。接下来,我们将展示 Ghidra 丰富的结构布局如何帮助你节省分析时间。由于 C++ 类是 C 结构的复杂扩展,本章最后将讨论如何进行已编译 C++ 程序的逆向工程。所以,让我们开始讨论在已编译程序中如何操作和定义简单与复杂的数据类型及结构。

数据解析

作为逆向工程师,你需要理解在反汇编中看到的数据。将特定数据类型与变量关联的最简单方法是观察该变量作为已知函数参数的使用情况。在分析阶段,Ghidra 会尽可能地注释数据类型,当它可以基于变量与 Ghidra 拥有原型的函数的使用关系来推导时。

通过导入的库函数,Ghidra 通常已经知道函数的原型。在这种情况下,你可以通过将鼠标悬停在 Listing 窗口或 Symbol Tree 窗口中的函数名称上,轻松查看该原型。当 Ghidra 无法识别函数的参数顺序时,至少应该知道函数是从哪个库中导入的(请参见 Symbol Tree 窗口中的 Imports 文件夹)。遇到这种情况时,学习函数签名和行为的最佳资源是任何相关的 man 页面或其他可用的 API 文档。如果一切都失败了,记住这句格言:“谷歌是你的朋友。”

理解二进制程序行为的低悬果实在于 cataloging(编目)程序调用的库函数。调用 connect 函数的 C 程序正在创建一个网络连接。调用 RegOpenKey 函数的 Windows 程序则是在访问 Windows 注册表。然而,为了理解这些函数的调用方式及原因,还需要进行额外的分析。

发现一个函数是如何被调用的需要了解与该函数相关的参数。我们来看看一个 C 程序,它调用 connect 函数来检索一个 HTML 页面。在调用 connect 时,程序需要知道托管该页面的服务器的 IP 地址和目标端口,这些信息通过一个名为 getaddrinfo 的库函数提供。Ghidra 识别出这是一个库函数,并在调用中添加了注释,在列表窗口中为我们提供了额外的信息,如下所示:

00010a30  CALL  getaddrinfo    int getaddrinfo(char * __name, c...

你可以通过几种方式获取更多关于此调用的信息。将鼠标悬停在指令右侧的简略注释上,可以看到 Ghidra 提供了完整的函数原型,帮助你理解函数调用中传递的参数。将鼠标悬停在符号树中的函数名称上,会在弹出窗口中显示函数原型和变量。或者,右键菜单中选择编辑功能会以可编辑格式提供相同的信息,如图 8-1 所示。如果你需要更多信息,可以使用数据类型管理器窗口查找特定参数的信息,如 addrinfo 数据类型。如果你点击了前述列表中的 getaddrinfo,你会看到图 8-1 中显示的内容会在列表中重复显示。(这属于一个 thunk 函数,具体讨论内容请参见 “Thunk” 在第 212 页的内容。)

image

图 8-1:getaddrinfo 函数的编辑功能窗口

最后,你不需要通过符号树和数据类型管理器窗口来查看这些信息,因为反编译器已经在反编译器窗口中应用了这些信息。如果你查看反编译器窗口,你会看到 Ghidra 已经通过使用加载的类型库中的信息,为结构体 (addrinfo) 中包含的字段加入了成员名称。在这个示例中,在反编译器的以下代码摘录中,你可以看到成员名称 ai_familyai_socktype 帮助我们理解 local_48 是在获取 connect 所需的信息时使用的结构体。在这种情况下,ai_family 的赋值表示正在使用 IPv4 地址(2 等同于符号常量 AF_INET),而 ai_socktype 表示使用的是流套接字(1 等同于符号常量 SOCK_STREAM):

  local_48.ai_family = 2;

  local_48.ai_socktype = 1;

  local_10 = getaddrinfo(param_1,"www",&local_48,&local_18);

识别数据结构的使用

虽然原始数据类型通常适合存放在处理器的寄存器或指令操作数中,但复合数据类型如数组和结构体通常需要更复杂的指令序列来访问它们包含的各个数据项。在我们讨论 Ghidra 提供的用于提高复杂数据类型代码可读性的功能之前,我们需要回顾一下该代码的样子。

数组成员访问

数组是最简单的复合数据结构,就内存布局而言。传统上,数组是连续的内存块,包含相同数据类型的连续元素(同质集合)。数组的大小是数组中元素数量与每个元素大小的乘积。使用 C 语言的表示法,声明整数数组时所消耗的最小字节数是

int array_demo[100];

计算结果为

int bytes = 100 * sizeof(int); // or 100 * sizeof(array_demo[0])

通过提供一个索引值(该值可以是变量或常量),可以访问单个数组元素,如下所示的有效数组引用:

 ➊ array_demo[20] = 15;              // fixed index into the array

    for (int i = 0; i < 100; i++) {

     ➋ array_demo[i] = i;            // varying index into the array

假设为了举例,sizeof(int)是 4 个字节,那么第一次数组访问 ➊ 访问的是位于数组内 80 字节处的整数值,而第二次数组访问 ➋ 访问的是位于数组内偏移量为 0、4、8、... 96 字节处的整数值。第一次数组访问的偏移量可以在编译时计算为20 * 4。在大多数情况下,第二次数组访问的偏移量必须在运行时计算,因为循环计数器i的值在编译时并不固定。因此,i * 4的乘积会在每次循环时计算,以确定数组的确切偏移量。

最终,如何访问数组元素不仅取决于所使用的索引类型,还取决于数组在程序内存空间中的分配位置。

全局分配数组

当数组在程序的全局数据区分配时(例如,在.data.bss段内),编译器在编译时就知道数组的基地址,这使得编译器能够计算出任何通过固定索引访问的数组元素的固定地址。考虑以下简单程序,它通过固定和变量索引访问全局数组。

int global_array[3];

int main(int argc, char **argv) {

    int idx = atoi(argv[1]); //not bounds checked for simplicity

    global_array[0] = 10;

    global_array[1] = 20;

    global_array[2] = 30;

    global_array[idx] = 40;

}

C 语言究竟在期待什么?

为了简化,我们说 C 语言期望使用整数索引,无论是变量还是常量。实际上,任何可以计算为整数或被解释为整数的表达式都可以使用。一般准则是:“任何可以使用整数的地方,都可以使用一个能计算出整数的表达式。”当然,这并不仅限于整数。C 语言可以很好地评估你提供的任何表达式,并尝试使其与预期的变量类型兼容。如果值超出了数组的范围会怎样?当然,你就有了许多可以被利用的漏洞!值将会被读取或写入到超出范围的内存区域,或者如果计算出的目标地址在程序中无效,程序就会崩溃。

如果我们对相应的二进制文件进行反汇编,主函数包含以下代码:

          ...

00100657  CALL   atoi

0010065c  MOV    dword ptr [RBP + local_c],EAX

0010065f  MOV    dword ptr [DAT_00301018],10➊

00100669  MOV    dword ptr [DAT_0030101c],20➋

00100673  MOV    dword ptr [DAT_00301020],30➌

0010067d  MOV    EAX,dword ptr [RBP + local_c]

00100680  CDQE

00100682  LEA    RDX,[RAX*4]➍

0010068a  LEA    RAX,[DAT_00301018]➎

00100691  MOV    dword ptr [RDX + RAX*1]=>DAT_00301018,40➏

          ...

尽管这个程序只有一个全局变量(全局数组),但是反汇编结果的 ➊ ➋ ➌ 行似乎表明有三个全局变量:DAT_00301018DAT_0030101cDAT_00301020。然而,LEA 指令 ➎ 加载了之前看到的一个全局变量的地址 ➊。在这种情况下,当与偏移量计算(RAX*4) ➍ 和规模化内存访问 ➏ 结合时,DAT_00301018 很可能是一个全局数组的基地址。注释操作数 =>DAT_00301018 ➏ 为我们提供了一个数组的基地址,值 40 将会写入该数组。

什么是去除符号的二进制文件?

当编译器生成目标文件时,它们必须包含足够的信息,以便链接器能够完成工作。链接器的一个任务是解析目标文件之间的引用,比如调用一个函数,而该函数的实现位于不同的文件中,并利用编译器生成的符号信息。在许多情况下,链接器会将目标文件中的所有符号表信息合并,并将合并后的信息包含在最终的可执行文件中。这些信息对于可执行文件的正确运行不是必需的,但从逆向工程的角度来看,它非常有用,因为 Ghidra(以及像调试器这样的其他工具)可以利用符号表信息恢复函数和全局变量的名称及大小。

去除符号一个二进制文件意味着移除可执行文件中对二进制运行操作不必要的部分。这可以通过使用命令行工具 strip 对可执行文件进行后处理,或者通过向编译器和/或链接器提供构建选项(对于 gcc/ld 来说是 -s)让它们自己生成一个去除符号的二进制文件来实现。除了符号表信息,strip 还可以移除任何调试符号信息,例如局部变量 names 和类型信息,这些信息是在构建二进制文件时嵌入其中的。缺少符号信息时,逆向工程工具必须拥有算法来识别和命名函数及数据。

基于 Ghidra 所分配的名称,我们知道全局数组从地址 00301018 开始,包含 12 个字节。在编译期间,编译器使用固定的索引(0、1、2)来计算数组中相应元素的实际地址(003010180030101c00301020),这些地址分别由 ➊、➋ 和 ➌ 中的全局变量引用。根据写入这些位置的值,我们可以推测我们正在将 32 位整数(dword)值写入该数组。如果我们查看列表中相关的数据,看到以下内容:

        DAT_00301018

00301018      ??       ??

00301019      ??       ??

0030101a      ??       ??

0030101b      ??       ??

        DAT_0030101c

0030101c      ??       ??

0030101d      ??       ??

0030101e      ??       ??

0030101f      ??       ??

 DAT_00301020

00301020      ??       ??

00301021      ??       ??

00301022      ??       ??

00301023      ??       ??

问号表示该数组可能在程序的 .bss 区段中分配,并且文件镜像中没有初始化值。

当使用变量索引访问数组时,在反汇编中更容易识别数组。当使用常量索引访问全局数组时,相应的数组元素在反汇编中显示为全局变量。然而,使用变量索引值会在 ➎ 显示数组的基址,在 ➍ 显示单个元素的大小,因为访问数组时必须使用索引来计算偏移量。(这种缩放操作是必需的,用于将 C 中的整数数组索引转换为汇编语言中正确数组元素的字节偏移量。)

使用 Ghidra 中在前一章节讨论的类型和数组格式化操作(数据 ▸ 创建数组),我们可以将 DAT_000301018 格式化为一个三元素整数数组,从而在反汇编中显示带有名称的数组,使用索引而非偏移量访问:

00100660  MOV    dword ptr [INT_ARRAY_00301018],10

0010066a  MOV    dword ptr [INT_ARRAY_00301018[1]],20

00100674  MOV    dword ptr [INT_ARRAY_00301018[2]],30

Ghidra 分配的默认数组名称 INT_ARRAY_00301018 包括了数组类型以及数组的起始地址。

更新注释中的符号信息

当你开始识别数据类型、修改符号名称等时,你可以确保你在列表中添加的重要注释不会变得过时或难以理解,通过使用注释标注,这些标注会随着你更新符号而自动更新。Symbol 注释选项允许你包含符号引用,当你更改符号时,它们会自动更新,以准确反映你的发现。(请参见“注释”,见第 132 页)

让我们看看数组创建前后的反汇编窗口(图 8-2 和图 8-3)。在图 8-2 中,第 2 行的一个重要警告是另一个线索,提示你可能正在查看一个数组,并且整数值的赋值支持数组类型是整数的假设。

image

图 8-2:反汇编窗口指示潜在的数组

在创建整数数组后,反汇编窗口中的代码会更新为使用新的数组变量,如图 8-3 所示。

image

图 8-3:声明数组类型后的反汇编窗口视图

堆栈分配的数组

编译器在编译时无法知道堆栈上分配的数组的绝对地址,因为它是函数中的局部变量,因此即使是使用常量索引的访问也需要在运行时进行计算。尽管有这些差异,编译器通常将堆栈分配的数组与全局分配的数组几乎一样对待。

以下程序是前一个示例的变体,它使用堆栈分配的数组,而不是全局数组:

int main(int argc, char **argv) {

   int stack_array[3];

   int idx = atoi(argv[1]); //bounds check omitted for simplicity

   stack_array[0] = 10;

   stack_array[1] = 20;

   stack_array[2] = 30;

   stack_array[idx] = 40;

}

stack_array 分配的地址在编译时是未知的,因此编译器无法像处理 global_array[2] 那样预计算 stack_array[2] 的地址。然而,编译器可以计算数组中任何元素的相对位置。例如,stack_array[2] 的元素从数组开头的偏移量 2*sizeof(int) 开始,编译器在编译时是清楚这一点的。如果编译器决定在堆栈帧中的偏移量 EBP-0x18 分配 stack_array,它可以计算 EBP-0x18+2*sizeof(int),这在编译时会简化为 EBP-0x10,避免了在运行时访问 stack_array[2] 时需要额外的算术运算。这个过程在以下的列表中变得明显:

     undefined main()

        undefined     AL:1           <RETURN>

        undefined4    Stack[-0xc]:4  local_c➊

        undefined4    Stack[-0x10]:4 local_10

        undefined4    Stack[-0x14]:4 local_14

        undefined4    Stack[-0x18]:4 local_18

        undefined4    Stack[-0x1c]:4 local_1c

        undefined8    Stack[-0x28]:8 local_28

0010063a  PUSH   RBP

0010063b  MOV    RBP,RSP

0010063e  SUB    RSP,0x20

00100642  MOV➋  dword ptr [RBP + local_1c],EDI

00100645  MOV    qword ptr [RBP + local_28],RSI

00100649  MOV    RAX,qword ptr [RBP + local_28]

0010064d  ADD    RAX,0x8

00100651  MOV    RAX,qword ptr [RAX]

00100654  MOV    RDI,RAX

00100657  MOV    EAX,0x0

0010065c  CALL   atoi

00100661  MOV➌  dword ptr [RBP + local_c],EAX

00100664  MOV➍  dword ptr [RBP + local_18],10

0010066b  MOV    dword ptr [RBP + local_14],20

00100672  MOV    dword ptr [RBP + local_10],30

00100679  MOV    EAX,dword ptr [RBP + local_c]

0010067c  CDQE

0010067e  MOV    dword ptr [RBP + RAX*0x4 + -0x10],40➎

00100686  MOV    EAX,0x0

0010068b  LEAVE

0010068c  RET

检测这个数组比检测全局数组更困难。这个函数似乎有六个不相关的变量 ➊(local_clocal_10local_14local_18local_1clocal_28),而不是一个包含三个整数和一个整数索引变量的数组。这些局部变量中的两个(local_1clocal_28)是函数的两个参数,argcargv,它们被保存以便稍后使用 ➋。

使用常量索引值往往掩盖了堆栈分配数组的存在,因为你只会看到对单独局部变量的赋值 ➍。只有乘法 ➎ 暗示了存在一个数组,数组的每个元素都是 4 字节。我们来进一步分解这个表达式:RBP 保存堆栈帧基指针的地址;RAX*4 是数组索引(由 atoi 转换并存储在 local_c ➌)乘以数组元素的大小;-0x10 是从 RBP 开始到数组起始位置的偏移量。

将局部变量转换为数组的过程与在数据段中创建数组有所不同。由于堆栈结构信息与函数中的第一个地址相关联,你不能选择堆栈变量的子集。相反,将光标放置在数组起始位置的变量 local_18 上,右键点击并选择“设置数据类型”后选择“数组”选项,然后指定数组元素的数量。Ghidra 会显示关于与我们将局部变量拉入数组定义时冲突的警告信息,如图 8-4 所示。

image

图 8-4:定义堆栈数组时的潜在冲突警告

如果继续操作,尽管存在潜在冲突,你仍然会在列表窗口中看到数组,如下所示:

          ...

00100664  MOV    dword ptr [RBP + local_18[0]],10

0010066b  MOV    dword ptr [RBP + local_18[1]],20

00100672  MOV    dword ptr [RBP + local_18[2]],30

          ...

即使在定义了数组之后,图 8-5 中的反编译器输出也与原始源代码不相似。反编译器省略了静态数组赋值操作,因为它认为这些操作对函数的结果没有贡献。调用atoi及其结果赋值依然保留,因为 Ghidra 无法计算调用atoi的副作用,但 Ghidra 错误地将atoi保存的结果误认为是数组的第四个元素(反汇编中的local_c,反编译器输出中的iVar1)。

image

图 8-5:数组定义后的所有栈变量反编译器视图

堆分配数组

堆分配数组是使用动态内存分配函数如malloc(C)或new(C++)分配的。从编译器的角度来看,处理堆分配数组的主要区别是,编译器必须基于内存分配函数返回的地址生成对数组的所有引用。以下是一个在程序堆中分配一个小数组的 C 程序:

int main(int argc, char **argv) {

   int *heap_array = (int*)malloc(3 * sizeof(int));

   int idx = atoi(argv[1]); //bounds check omitted for simplicity

   heap_array[0] = 10;

   heap_array[1] = 20;

   heap_array[2] = 30;

   heap_array[idx] = 40;

}

相应的反汇编比前两个例子稍微复杂一些:

     undefined main()

        undefined     AL:1              <RETURN>

        undefined8    Stack[-0x10]:8    heap_array

        undefined4    Stack[-0x14]:4    local_14

        undefined4    Stack[-0x1c]:4    local_1c

        undefined8    Stack[-0x28]:8    local_28

0010068a  PUSH   RBP

0010068b  MOV    RBP,RSP

0010068e  SUB    RSP,0x20

 00100692  MOV    dword ptr [RBP + local_1c],EDI

00100695  MOV    qword ptr [RBP + local_28],RSI

00100699  MOV    EDI,0xc➊

0010069e  CALL    malloc

001006a3  MOV    qword ptr [RBP + heap_array],RAX➋

001006a7  MOV    RAX,qword ptr [RBP + local_28]

001006ab  ADD    RAX,0x8

001006af  MOV    RAX,qword ptr [RAX]

001006b2  MOV    RDI,RAX

001006b5  CALL    atoi

001006ba  MOV    dword ptr [RBP + local_14],EAX

001006bd  MOV    RAX,qword ptr [RBP + heap_array]

001006c1  MOV    dword ptr [RAX],10➌

001006c7  MOV    RAX,qword ptr [RBP + heap_array]

001006cb  ADD    RAX,0x4➍

001006cf  MOV    dword ptr [RAX],20

001006d5  MOV    RAX,qword ptr [RBP + heap_array]

001006d9  ADD    RAX,0x8➎

001006dd  MOV    dword ptr [RAX],30

001006e3  MOV    EAX,dword ptr [RBP + local_14]

001006e6  CDQE

001006e8  LEA    RDX,[RAX*0x4]➏

001006f0  MOV    RAX,qword ptr [RBP + heap_array]

001006f4  ADD➐  RAX,RDX

001006f7  MOV    dword ptr [RAX],40

001006fd  MOV    EAX,0x0

00100702  LEAVE

00100703  RET

数组的起始地址(由malloc返回并存储在RAX寄存器中)被保存在局部变量heap_array ➋中。在这个例子中,与前面的例子不同,每次访问数组时都会先读取heap_array的内容以获取数组的基地址。对heap_array[0]heap_array[1]heap_array[2]的引用分别需要 0 ➌、4 ➍和 8 字节的偏移 ➎。对数组索引的访问heap_array[idx]通过多条指令实现,首先将数组索引与数组元素的大小相乘 ➏,然后将结果加到数组的基地址 ➐。

堆分配的数组有一个特别好的特性:可以通过数组的总大小和每个元素的大小计算出分配给数组的元素个数。传递给内存分配函数的参数(这里是传给malloc12 ➊)告诉你分配给数组的字节数。将这个值除以元素的大小(在这个例子中是 4 字节,从偏移量➌ ➍ ➎可以观察到,步长是 4,而比例因子➏也表明这一点)就能得出数组中的元素数量。在这个例子中,分配了一个三元素的数组。

如图 8-6 所示,反编译器也能识别该数组。(数组指针的名称puVar2表明它是一个指向无符号整数的指针,前缀pu表示这一点。)

image

图 8-6:堆数组函数的反编译器视图

在这个函数中,与栈分配的数组函数不同,反编译器列出了常量索引数组的赋值,即使它通常会排除这些赋值,因为数组没有在其他操作中使用或从函数中返回。这个情况不同,因为这些赋值不是仅仅操作栈变量:栈变量实际上是一个指向堆上由malloc请求的内存的指针。通过这个变量写入数据并不是写入到本地栈变量,而是通过栈变量来定位已分配的内存。程序可能会在函数退出时丢失该指针(堆数组起始地址),但值会继续保存在内存中。(这个具体的例子实际上是一个内存泄漏的演示。虽然这不是一个好的编程实践,但它确实让我们展示了堆数组的概念。)

总之,当一个变量作为数组的索引使用时,数组最容易被识别。数组访问操作需要将索引乘以数组元素的大小,再将结果偏移量加到数组的基地址,这一点在反汇编列表中非常明显。

结构体成员访问

C 风格的结构体,通常在这里称为结构体,将(通常是异质的)数据项集合成复合数据类型。在源代码中,结构体中的数据字段是通过名称访问的,而不是通过索引。不幸的是,这些有用的字段名称在编译时被转换为数字偏移量,因此当你查看反汇编时,结构体字段的访问与使用常量索引访问数组元素非常相似。

以下是包含五个异质字段的结构定义,将在接下来的示例中使用:

struct ch8_struct {     //Size  Minimum offset  Default offset

    int    field1;      //  4       0            0

    short  field2;      //  2       4            4

    char   field3;      //  1       6            6

    int    field4;      //  4       7            8

    double field5;      //  8       11           16

};                      // Minimum total size: 19 Default size: 24

当编译器遇到结构体定义时,它会持续跟踪结构体各个字段占用的字节数,以确定每个字段在结构体中的偏移量。分配每个字段所需的空间总和决定了结构体所需的最小空间。然而,你不应当假设编译器会使用最小所需空间来分配结构体。默认情况下,编译器会将结构体字段对齐到最有效的内存地址,以便最有效地读写这些字段。例如,4 字节的整型字段会对齐到能被四整除的偏移量,而 8 字节的双精度浮点数则会对齐到能被八整除的偏移量。根据结构体的组成,编译器可能会插入填充字节来满足对齐要求,这意味着结构体的实际大小会比其组件字段的大小总和大。样本结构的默认偏移量和结果结构大小可以在前述结构定义的注释中的默认偏移量列中看到,它们的总和为 24,而不是最小的 19。

通过使用编译器选项请求特定的成员对齐方式,可以将结构打包到所需的最小空间中。Microsoft C/C++ 和 GNU gcc/g++ 都识别 pack 编译指令来控制结构字段对齐。GNU 编译器还识别 packed 属性,用于按结构单独控制结构对齐。请求对结构字段进行 1 字节对齐会导致编译器将结构压缩到所需的最小空间。样本结构的偏移量和结构大小可以在最小偏移量列中找到。(请注意,某些处理器在数据按其类型对齐时表现更好,而其他处理器则可能在数据按特定边界对齐时产生异常。)

记住这些事实后,我们来看编译后的代码中结构的处理方式。与数组一样,访问结构成员的方式是将结构的基地址与所需成员的偏移量相加。然而,虽然数组的偏移量可以根据提供的索引值在运行时计算(因为数组中的每个元素大小相同),但结构的偏移量必须在编译时计算,并且在编译后的代码中将显示为固定的结构偏移量,这些偏移量与使用常量索引的数组引用几乎一模一样。

在 Ghidra 中创建结构比创建数组更为复杂,因此我们将在下一节中讲解该内容,在展示几个反汇编和反编译的结构示例后进行说明。

全局分配结构

与全局分配的数组一样,全局分配的结构的地址在编译时已知。这使得编译器能够在编译时计算结构中每个成员的地址,从而避免在运行时进行任何计算。考虑以下程序,它访问一个全局分配的结构:

struct ch8_struct global_struct;

int main() {

    global_struct.field1 = 10;

    global_struct.field2 = 20;

    global_struct.field3 = 30;

    global_struct.field4 = 40;

    global_struct.field5 = 50.0;

}

如果该程序使用默认的结构对齐选项进行编译,那么我们可以期待在反汇编时看到如下所示的结果:

     undefined main()

        undefined     AL:1              <RETURN>

001005fa  PUSH   RBP

001005fb  MOV    RBP,RSP

001005fe  MOV    dword ptr [DAT_00301020],10

00100608  MOV    word ptr [DAT_00301024],20

00100611  MOV    byte ptr [DAT_00301026],30

00100618  MOV    dword ptr [DAT_00301028],40

00100622  MOVSD  XMM0,qword ptr [DAT_001006c8]

0010062a  MOVSD  qword ptr [DAT_00301030],XMM0

00100632  MOV    EAX,0x0

00100637  POP    RBP

00100638  RET

该反汇编中没有进行任何数学运算来访问结构的成员,并且如果没有源代码,我们无法确定是否确实使用了结构。因为编译器在编译时已经完成了所有的偏移量计算,这个程序看起来像是在引用五个全局变量,而不是一个结构中的五个字段。你应该能注意到,这与之前使用常量索引值的全局分配数组的示例有很多相似之处。

在 图 8-2 中,均匀的偏移量和相应的值使我们能够准确推测我们正在处理的是一个数组。在这个示例中,我们可以正确地得出结论,我们没有在处理一个数组,因为变量的大小是不均匀的(分别是 dwordwordbytedwordqword),但我们缺乏足够的证据来断言我们正在处理的是一个结构。

栈分配结构体

和栈分配的数组一样,单凭栈布局很难识别栈分配的结构体,反编译器也无法提供额外的洞察。修改前面的程序以使用在main中声明的栈分配结构体,得到如下反汇编结果:

     undefined main()

        undefined     AL:1              <RETURN>

        undefined8    Stack[-0x18]:8    local_18

        undefined4    Stack[-0x20]:4    local_20

        undefined1    Stack[-0x22]:1    local_22

        undefined2    Stack[-0x24]:2    local_24

        undefined4    Stack[-0x28]:4    local_28

001005fa  PUSH   RBP

001005fb  MOV    RBP,RSP

001005fe  MOV    dword ptr [RBP + local_28],10

00100605  MOV    word ptr [RBP + local_24],20

0010060b  MOV    byte ptr [RBP + local_22],30

0010060f  MOV    dword ptr [RBP + local_20],40

00100616  MOVSD  XMM0,qword ptr [DAT_001006b8]

0010061e  MOVSD  qword ptr [RBP + local_18],XMM0

00100623  MOV    EAX,0x0

00100628  POP    RBP

00100629  RET

同样,访问结构体字段时不进行任何数学运算,因为编译器可以在编译时确定每个字段在堆栈帧中的相对偏移量,我们得到的依然是一个可能具有误导性的图像——看起来像是使用了五个独立的变量,而不是一个包含五个不同字段的单一变量。实际上,local_28应该是一个 24 字节结构体的起始位置,其他变量应该以某种方式格式化,反映它们是结构体中的字段。

堆分配的结构体

堆分配的结构体揭示了结构体的大小和字段布局的更多信息。当一个结构体在程序堆中分配时,编译器别无选择,只能生成代码,在每次访问字段时计算正确的字段地址,因为结构体的地址在编译时是未知的。对于全局分配的结构体,编译器能够计算出固定的起始地址。对于栈分配的结构体,编译器可以计算出结构体起始地址与包含该栈帧的栈指针之间的固定关系。当结构体在堆中分配时,编译器唯一能访问的结构体引用就是指向结构体起始地址的指针。

为了演示堆分配的结构体,我们修改示例程序,在main中声明一个指针,并将其指向足够大的内存块,以容纳该结构体:

int main() {

    struct ch8_struct *heap_struct;

    heap_struct = (struct ch8_struct*)malloc(sizeof(struct ch8_struct));

    heap_struct->field1 = 10;

    heap_struct->field2 = 20;

    heap_struct->field3 = 30;

    heap_struct->field4 = 40;

    heap_struct->field5 = 50.0;

}

这是对应的反汇编:

     undefined main()

        undefined     AL:1              <RETURN>

        undefined8    Stack[-0x10]:8    heap_struct

0010064a  PUSH   RBP

0010064b  MOV    RBP,RSP

0010064e  SUB    RSP,16

00100652  MOV    EDI,24➊

00100657  CALL   malloc

0010065c  MOV    qword ptr [RBP + heap_struct],RAX

00100660  MOV    RAX,qword ptr [RBP + heap_struct]

00100664  MOV    dword ptr [RAX],10➋

0010066a  MOV    RAX,qword ptr [RBP + heap_struct]

0010066e  MOV    word ptr [RAX + 4],20➌

00100674  MOV    RAX,qword ptr [RBP + heap_struct]

00100678  MOV    byte ptr [RAX + 6],30➍

0010067c  MOV    RAX,qword ptr [RBP + heap_struct]

00100680  MOV    dword ptr [RAX + 8],40➎

00100687  MOV    RAX,qword ptr [RBP + heap_struct]

0010068b  MOVSD  XMM0,qword ptr [DAT_00100728]

00100693  MOVSD  qword ptr [RAX + 16],XMM0➏

00100698  MOV    EAX,0x0

0010069d  LEAVE

0010069e  RET

在这个例子中,我们可以分辨出结构体的确切大小和布局。结构体的大小可以通过malloc请求的内存量推测为 24 字节 ➊。结构体包含以下字段,位于指定的偏移量:

  • 偏移量为 0 的一个 4 字节(dword)字段 ➋

  • 偏移量为 4 的一个 2 字节(word)字段 ➌

  • 偏移量为 6 的一个 1 字节字段 ➍

  • 偏移量为 8 的一个 4 字节(dword)字段 ➎

  • 偏移量为 16 的一个 8 字节(qword)字段 ➏

基于浮点数指令(MOVSD)的使用,我们进一步推测,qword字段实际上是一个double类型。

使用 1 字节对齐打包结构体的同一程序编译结果如下:

0010064a  PUSH    RBP

0010064e  SUB    RSP,16

00100652  MOV    EDI,19

00100657  CALL   malloc

0010065c  MOV    qword ptr [RBP + local_10],RAX

00100660  MOV    RAX,qword ptr [RBP + local_10]

00100664  MOV    dword ptr [RAX],10

0010066a  MOV    RAX,qword ptr [RBP + local_10]

0010066e  MOV    word ptr [RAX + 4],20

00100674  MOV    RAX,qword ptr [RBP + local_10]

00100678  MOV    byte ptr [RAX + 6],30

0010067c  MOV    RAX,qword ptr [RBP + local_10]

00100680  MOV    dword ptr [RAX + 7],40

00100687  MOV    RAX,qword ptr [RBP + local_10]

0010068b  MOVSD  XMM0,qword ptr [DAT_00100728] =

00100693  MOVSD  qword ptr [RAX + 11],XMM0

00100698  MOV    EAX,0x0

0010069d  LEAVE

0010069e  RET

唯一的变化是结构体的大小变小(现在为 19 字节),并且为了适应每个结构体字段的重新对齐,偏移量进行了调整。

无论编译程序时使用了什么对齐方式,找到程序堆中分配和操作的结构体是确定给定数据结构大小和布局的最快方式。然而,请记住,许多函数不会直接访问结构体的每个成员来帮助你理解结构体的布局。相反,你可能需要跟踪指向结构体的指针,并注意每当该指针被解引用时所使用的偏移量,最终拼凑出结构体的完整布局。在第 437 页的“示例 3:自动化结构体创建”中,你将看到反编译器如何自动化这一过程。

结构体数组

一些程序员认为,复合数据结构的美妙之处在于,它们允许通过将较小的结构体嵌套在较大的结构体中,构建任意复杂的结构体:例如,结构体数组、结构体内嵌结构体、以及包含数组作为成员的结构体。之前关于数组和结构体的讨论同样适用于这些嵌套类型。例如,考虑以下简单程序,其中heap_struct指向一个包含五个ch8_struct项的数组:

int main() {

    int idx = 1;

    struct ch8_struct *heap_struct;

 heap_struct = (struct ch8_struct*)malloc(sizeof(struct ch8_struct) * 5);

    heap_struct[idx].field1 = 10;

}

在底层,访问field1涉及将索引值乘以数组元素的大小(在这种情况下,结构体的大小),然后将偏移量加到所需的字段上。对应的反汇编结果如下所示:

     undefined main()

        undefined     AL:1              <RETURN>

        undefined4    Stack[-0xc]:4     idx

        undefined4    Stack[-0x18]:8    heap_struct

0010064a  PUSH   RBP

0010064b  MOV    RBP,RSP

0010064e  SUB    RSP,16

00100652  MOV    dword ptr [RBP + idx],1

00100659  MOV➊  EDI,120

0010065e  CALL   malloc

00100663  MOV    qword ptr [RBP + heap_struct],RAX

00100667  MOV    EAX,dword ptr [RBP + idx]

0010066a  MOVSXD RDX,EAX

0010066d  MOV➋  RAX,RDX

00100670  ADD    RAX,RAX

00100673  ADD    RAX,RDX

00100676  SHL➌  RAX,3

0010067a  MOV    RDX,RAX

0010067d  MOV    RAX,qword ptr [RBP + heap_struct]

00100681  ADD➍  RAX,RDX

00100684  MOV➎  dword ptr [RAX],10

0010068a  MOV    EAX,0

0010068f  LEAVE

00100690  RET

该函数在堆中分配了 120 字节➊。RAX中的数组索引通过一系列操作乘以 24➋,最后以SHL RAX, 3 ➌结束,然后将结果加到数组的起始地址➍。(如果你不太清楚从➋开始的操作序列等同于乘以 24,不用担心,类似的代码序列在第二十章中有详细讨论。)由于field1是结构体的第一个成员,因此无需额外的偏移量即可生成分配到field1的最终地址➎。

从这些事实中,我们可以推导出数组项的大小(24),数组中的项数(120 / 24 = 5),以及每个数组元素在偏移量 0 处有一个 4 字节(dword)字段。这个简短的列表没有提供足够的信息来得出关于每个结构体中剩余 20 字节如何分配给其他字段的结论。使用反编译器列出的图 8-7 中的相同公式,我们可以更容易地推导出数组的大小(0x18 十六进制是 24 十进制)。

image

图 8-7:带有堆分配结构体数组的函数反编译视图

使用 Ghidra 创建结构体

在上一章中,你看到如何使用 Ghidra 的数组聚合功能,将长列表的数据声明折叠成表示数组的单一反汇编行。接下来的几节将探讨 Ghidra 提供的功能,以提高操作结构的代码的可读性。我们的目标是避免使用像 [EDX + 10h] 这样的晦涩结构引用,转而使用更易读的方式,比如 [EDX + ch8_struct.field_e]

每当你发现程序在操作数据结构时,你需要决定是否要将结构字段名称包含到你的反汇编中,还是你可以理解反汇编列表中散布的所有数字偏移量。在某些情况下,Ghidra 可能会识别出一个作为 C 标准库或 Windows API 一部分定义的结构,并利用它对该结构的精确布局的了解,将数字偏移量转换为符号字段名。这是理想的情况,因为它会减少你需要做的工作。我们将在你更深入了解 Ghidra 如何处理结构定义后,再回到这个场景。

创建新结构

当 Ghidra 没有某个结构的布局知识时,你可以通过选择数据并使用右键上下文菜单来创建该结构。当你选择 数据 ▸ 创建结构(或使用快捷键 SHIFT-)时,你将看到如[图 8-8 所示的“创建结构”窗口。由于你已高亮选中一块数据(无论它是已定义还是未定义),Ghidra 会尝试识别是否有现有结构具有匹配的格式或相同的大小。你可以从窗口中选择现有结构之一,或者创建一个新结构。在这个例子中,我们使用了之前讨论的全局分配结构示例代码,并创建了一个名为ch8_struct的新结构。点击“确定”后,该结构将成为数据类型管理器窗口中的官方类型,且信息会传播到其他 CodeBrowser 窗口。

image

图 8-8:创建结构窗口

让我们来看一下创建这个结构对相关 CodeBrowser 窗口的影响,从列表窗口开始。如章节早些时候所示,反汇编列表几乎没有提示你正在处理一个结构,因为代码修改了一系列看似无关的全局变量:

001005fa  PUSH   RBP

001005fb  MOV    RBP,RSP

001005fe  MOV    dword ptr [DAT_00301020],10

00100608  MOV    word ptr [DAT_00301024],20

00100611  MOV    byte ptr [DAT_00301026],30

00100618  MOV    dword ptr [DAT_00301028],40

00100622  MOVSD  XMM0,qword ptr [DAT_001006c8]

0010062a  MOVSD  qword ptr [DAT_00301030],XMM0

00100632  MOV    EAX,0

00100637  POP    RBP

00100638  RET

当你导航到相关数据项,选择范围(0030102000301037),并创建关联的结构时,你会看到结构中的各个数据项现在与名为 ch8_struct_00301020 的结构相关联,并且结构中的每个项目都有一个名称 field_,后面加上它与结构中第一个元素的偏移量。

00401035  POP    EBP

001005fb  MOV    RBP,RSP

001005fe  MOV    dword ptr [ch8_struct_00301020],10

00100608  MOV    word ptr [ch8_struct_00301020.field_0x4],20

00100611  MOV    byte ptr [ch8_struct_00301020.field_0x6],30

00100618  MOV    dword ptr [ch8_struct_00301020.field_0x8],40

00100622  MOVSD  XMM0,qword ptr [DAT_001006c8]

0010062a  MOVSD  qword ptr [ch8_struct_00301020.field_0x10],XMM0

00100632  MOV    EAX,0

00100637  POP    RBP

00100638  RET

这是随着结构的创建而变化的多个窗口之一。回想一下,Decompiler 窗口曾给出过一个有用的警告,提醒我们可能在处理结构体或数组。创建结构体后,警告消失,反编译的代码也更接近原始的 C 代码,如图 8-9 所示。

image

图 8-9:创建结构体后的反编译器视图

联合体的状态

联合体是一种与结构体相似的构造。结构体和联合体之间的主要区别在于,结构体字段有独立的偏移量和专用的内存空间,而联合体字段从偏移量 0 开始重叠在一起。其结果是所有联合体字段共享相同的内存空间。Ghidra 中的联合体编辑器窗口与结构体编辑器窗口相似,功能基本相同。

新创建的结构体现在也作为一个条目出现在 CodeBrowser 中的 Data Type Manager 窗口中。图 8-10 显示了 Data Type Manager 窗口中的新条目以及关联的窗口,展示了 ch8_struct 的所有引用。

image

图 8-10:Data Type Manager 和 References 窗口中新声明的结构体

编辑结构体成员

此时,Ghidra 将新创建的结构体呈现为一系列未定义字节的连续集合,每个偏移量由示例程序访问,而不是一组已定义的数据类型(你可以根据每个项的大小和使用方式来确定)。要定义每个字段的类型,你可以通过右键单击 Listing 窗口中的结构体并选择合适的 Data 选项来编辑结构体。或者,你可以通过双击 Data Type Manager 中的结构体来编辑它。

如果你在 Data Type Manager 窗口中双击新创建的结构体(如图 8-10 所示),将会打开结构体编辑器窗口(如图 8-11 所示),该窗口展示了 24 个未定义类型的元素,长度都为 1。要确定结构体中各个元素的数量、大小和类型,你可以研究反汇编代码,或者让前面图 8-9 中显示的反编译器列表提供答案。

image

图 8-11:结构体编辑器窗口

与我们新创建的结构相关的原始反编译器列表显示,在同一结构ch8_struct_00301020中,使用包含两个整数的字段名引用了五个项目。第一个整数表示从结构基地址的偏移量。第二个整数表示使用的字节数,这是衡量项目大小的一个好指标。使用这些信息(以及一些有意义的字段名),你可以更新结构编辑器窗口,如图 8-12 所示。结构编辑器中的字节偏移/组件位滚动条提供了结构的可视化表示。当结构被编辑时,反编译器窗口(在图 8-12 的左侧)、列表窗口以及其他相关窗口也会更新。

因为field_c是一个字符,反编译器将整数 30 转换为表示 30(0x1e)的 ASCII 字符,这是一个不可打印的控制字符(RS)。在结构编辑器中,已包含填充字节(由助记符??表示),以确保字段的正确对齐,并且每个字段的偏移量以及结构的整体大小(24 字节)与之前示例中的值相匹配。

image

图 8-12:编辑结构后的反编译器和结构编辑器窗口

应用结构布局

你已经看到如何使用现有的结构定义并创建新的结构,将现有内存与特定的内存布局关联。你还看到这种关联是如何通过 CodeBrowser 窗口传播的,以使内容更加清晰。模糊的内存引用,例如[EBX+8],通过将数字结构偏移量转换为符号引用,如[EBX+ch8_struct.field_d],变得更加易读,尤其是因为符号引用可以赋予有意义的名称。Ghidra 使用层次符号表示法,明确显示正在访问的结构类型以及该结构中的哪个字段。

Ghidra 的已知结构布局库已通过解析常见的 C 头文件收集的信息进行了填充。结构的布局定义了其总大小、每个字段的名称和大小以及每个字段在结构中的起始偏移量。即使数据段中没有相关内容,你也可以使用结构布局,这在处理结构指针时尤其有用。

每当你遇到形如[reg+N]的内存引用时(例如[RAX+0x12]),其中reg是寄存器名称,N是一个小常量,reg作为指针使用,N表示相对于reg所指向的内存的偏移量。这是结构体成员访问的常见模式,reg指向结构体的开始位置,N选择结构体中偏移量为N的字段。在某些情况下,在你的帮助下,Ghidra 可以清理这种类型的内存引用,以反映指向的结构类型以及引用的结构体中的具体字段。

让我们看一下本章开始时示例的 32 位版本,我们请求从服务器获取一个 HTTP 页面。请求是通过一个名为get_page的函数发出的。在这个版本的二进制文件中,Ghidra 确认该函数接收三个堆栈分配的参数。这些参数在 Listing 窗口中显示如下:

     undefined get_page(undefined4 param_1, undefined param_2...

        undefined     AL:1              <RETURN>

        undefined4    Stack[0x4]:4      param_1

        undefined     Stack[0x8]:1      param_2

        undefined4    Stack[0xc]:4      param_3

Decompiler 窗口显示,param_3在调用connect时与一些偏移量一起使用:

iVar1=connect(local_14,*(sockaddr **)(param_3+20),*(socklen_t*)(param_3+16));

通过追踪调用序列和被调用函数的返回值,我们可以得出结论,param_3是指向addrinfo结构体的指针,并将param_3重新定义为addrinfo*(使用 Listing 或 Decompiler 窗口中的 CTRL-L)。使用param_3的反编译语句将被替换为这里显示的更具信息量的语句:

iVar1 = connect(local_14, param_3->ai_addr, param_3->ai_addrlen);

你可以看到,指针运算已被结构体字段引用所取代。源代码中的指针运算很少能自我解释。你花费时间更新程序变量的数据类型是非常值得的。你将为同事节省他们自己推断param_3类型所需的时间,等你从海滩度假回来时,你会感谢自己,因为你不需要重新分析代码以重新学习那个你忘记更新的变量类型。

C++反向工程入门

C++类是 C 结构体的面向对象扩展,因此,通过回顾已编译 C++代码的特性来总结我们对数据结构的讨论是有一定逻辑的。C++的详细内容超出了本书的范围。在这里,我们尝试概述要点,并指出 Microsoft 的 C++编译器和 GNU 的g++之间的几个差异。

请记住,对 C++语言的扎实基础理解将极大地帮助你理解已编译的 C++。面向对象的概念,如继承和多态性,在源代码级别就很难掌握。如果在没有理解这些概念的源代码基础上试图在汇编级别深入探讨它们,可能会让你感到沮丧。

this 指针

this指针在所有非静态的 C++成员函数中都可以使用。每当调用这样的函数时,this会被初始化为指向用于调用该函数的对象。考虑以下 C++中的函数调用:

// object1, object2, and *p_obj are all the same type.

object1.member_func();

object2.member_func();

p_obj->member_func();

在三次对 member_func 的调用中,this 分别取值为 &object1&object2p_obj

最容易将 this 视为传递给所有非静态成员函数的隐藏第一个参数。如 第六章 所讨论,Microsoft C++ 编译器使用 thiscall 调用约定,并将 this 传递给 ECX 寄存器(x86)或 RCX 寄存器(x86-x64)。GNU g++ 编译器将 this 处理得就像它是非静态成员函数的第一个(最左边的)参数一样。在 32 位 Linux x86 上,用于调用函数的对象地址在调用函数之前被压入栈顶。在 Linux x86-64 上,this 通过第一个寄存器参数 RDI 传递。

从逆向工程的角度来看,在函数调用之前将地址移动到 ECX 寄存器中,很可能指示两件事。首先,文件是使用 Microsoft 的 C++ 编译器编译的。其次,函数可能是成员函数。当相同的地址被传递给两个或更多函数时,我们可以推断出这些函数都属于同一类层次结构。

在函数内部,如果在初始化 ECX 之前就使用了它,意味着调用者必须先初始化 ECX(回想一下在 “基于寄存器的参数” 中讨论的 活跃性 概念,第 113 页),这可能是函数是成员函数的一个迹象(尽管该函数也可能只是使用了 fastcall 调用约定)。进一步说,当成员函数将 this 传递给其他函数时,能够推断出这些函数也属于同一类。

对于使用 GNU g++ 编译的代码,调用成员函数时 this 参数看起来和其他任何第一个参数差不多,因此成员函数的调用不太容易区分。然而,任何不以指针作为第一个参数的函数,肯定可以排除为成员函数。

虚函数和虚函数表

虚函数 使 C++ 程序具有多态行为。对于每个包含虚函数的类(或通过继承得到的子类),编译器都会生成一个表,表中包含指向类中每个虚函数的指针。这些表被称为 虚函数表(也称为 vtable)。每个包含虚函数的类的实例都会增加一个数据成员,该成员指向类的虚函数表。虚函数表指针 被分配为类实例中的第一个数据成员,当对象在运行时被创建时,其构造函数会设置虚函数表指针,指向合适的虚函数表。当该对象调用虚函数时,正确的函数会通过在对象的虚函数表中查找来选择。因此,虚函数表是实现运行时解析虚函数调用的基本机制。

一些示例可能有助于澄清虚函数表的使用。考虑以下 C++ 类定义:

class BaseClass {

    public:

        BaseClass();

     ➊ virtual void vfunc1() = 0➋;

        virtual void vfunc2();

        virtual void vfunc3();

        virtual void vfunc4();

    private:

        int x;

        int y;

};

class SubClass : public BaseClass➌ {

    public:

        SubClass();

     ➍ virtual void vfunc1();

        virtual void vfunc3();

        virtual void vfunc5();

    private:

        int z;

};

在这种情况下,SubClass 继承自 BaseClass ➌。BaseClass 包含四个虚函数 ➊,而 SubClass 包含五个虚函数 ➍(其中四个来自 BaseClass,其中两个被它重写,另外加上新的 vfunc5)。在 BaseClass 中,vfunc1 是一个 纯虚函数,在其声明中由 = 0 ➋ 标明。纯虚函数在声明类中没有实现,并且 必须 在子类中重写,才能使类成为具体类。换句话说,BaseClass::vfunc1 没有实现,直到子类提供实现之前,不能实例化对象。SubClass 提供了这样的实现,因此可以创建 SubClass 对象。从面向对象的角度看,BaseClass::vfunc1 是一个 抽象函数,这使得 BaseClass 成为一个 抽象基类(即一个不完整的类,不能直接实例化,因为它缺少至少一个函数的实现)。

初看之下,BaseClass 似乎包含两个数据成员,SubClass 包含三个数据成员。然而请记住,任何包含虚函数的类,无论是显式定义的还是因为继承了虚函数,都包含一个 vftable 指针。因此,编译后的 BaseClass 实现有三个数据成员,而实例化后的 SubClass 对象有四个数据成员。在这两种情况下,第一个数据成员是 vftable 指针。在 SubClass 中,vftable 指针实际上是从 BaseClass 继承的,而不是专门为 SubClass 引入的。你可以在 图 8-13 中简化的内存布局中看到这一点,其中一个 SubClass 对象已经被动态分配。在对象创建过程中,新的对象的 vftable 指针会被初始化为指向正确的 vftable(在这种情况下是 SubClass 的 vftable)。

image

图 8-13:一个简单的 vftable 布局

SubClass 的 vftable 包含两个指向属于 BaseClass 的函数的指针(BaseClass::vfunc2BaseClass::vfunc4),因为 SubClass 并没有重写这两个函数,而是从 BaseClass 继承了它们。BaseClass 的 vftable 显示了纯虚函数是如何处理的。由于纯虚函数 BaseClass::vfunc1 没有实现,因此在 BaseClass 的 vftable 中没有地址可以存储在 vfunc1 的位置上。在这种情况下,编译器会插入一个错误处理函数的地址,这个函数在微软库中叫做 purecall,在 GNU 库中叫做 __cxa_pure_virtual。理论上,这些函数不应被调用,但如果它们被调用,程序将异常终止。

在 Ghidra 中操作类时,必须考虑 vftable 指针。由于 C++ 类是 C 结构体的扩展,您可以利用 Ghidra 的结构定义功能来定义 C++ 类的布局。对于多态类,您必须将 vftable 指针作为类中的第一个字段,并且在计算对象的总大小时考虑 vftable 指针的大小。这一点在观察使用 new 运算符动态分配对象时最为明显,其中传递给 new 的大小值包括所有显式声明字段(包括任何超类字段)所需的空间,以及 vftable 指针所需的空间。

在以下示例中,一个 SubClass 对象被动态创建,并将其地址保存在一个 BaseClass 指针中。然后,指针被传递给一个函数(call_vfunc),该函数使用该指针调用 vfunc3

void call_vfunc(BaseClass *bc) {

    bc->vfunc3();

}

int main() {

    BaseClass *bc = new Subclass();

    call_vfunc(bc);

}

由于 vfunc3 是一个虚拟函数,而 bc 指向一个 SubClass 对象,编译器必须确保调用 SubClass::vfunc3。以下是 call_vfunc 的 32 位 Microsoft C++ 版本反汇编,演示了如何解析虚拟函数调用:

     undefined __cdecl call_vfunc(int * bc)

        undefined     AL:1              <RETURN>

        int *         Stack[0x4]:4      bc

004010a0  PUSH   EBP

004010a1  MOV    EBP,ESP

004010a3  MOV    EAX,dword ptr [EBP + bc]

004010a6  MOV➊  EDX,dword ptr [EAX]

004010a8  MOV➋  ECX,dword ptr [EBP + bc]

004010ab  MOV➌  EAX,dword ptr [EDX + 8]

004010ae  CALL➍ EAX

004010b0  POP    EBP

004010b1  RET

vftable 指针(SubClass 的 vftable 地址)从结构体中读取并保存在 EDX 中 ➊。接下来,this 指针被移动到 ECX 中 ➋。然后,vftable 被索引,读取第三个指针(在此情况下为 SubClass::vfunc3 的地址)到 EAX 寄存器中 ➌。最后,虚拟函数被调用 ➍。

vftable 索引操作 ➌ 看起来非常像结构体引用操作。事实上,它与结构体引用操作没有区别,您可以为类及其 vftable 定义新的结构体(在数据类型管理器窗口右键单击),然后使用定义的结构体(见 图 8-14)使反汇编和反编译结果更加易读。

image

图 8-14:数据管理器窗口显示新的 SubClass SubClass_vftable

反编译器窗口显示了包含新结构的引用,如 图 8-15 所示。

image

图 8-15:反编译器窗口反映了 SubClass 的定义结构

类的 vftable 仅在两种情况下直接引用:在类的构造函数和析构函数中。当你定位到 vftable 时,可以利用 Ghidra 的数据交叉引用功能(见 第九章)快速定位相关类的所有构造函数和析构函数。

对象生命周期

理解对象的创建和销毁机制有助于揭示对象层次结构和嵌套对象关系,并快速识别类的构造函数和析构函数。

构造函数是什么?

类构造函数是一个初始化函数,在创建该类的新对象时会被调用。构造函数为类中的变量初始化提供了一个机会。构造函数的反向操作是析构函数,当对象超出作用域或动态分配的对象被显式删除时,会调用析构函数。析构函数执行清理工作,如释放资源(例如打开的文件描述符和动态分配的内存)。编写得当的析构函数可以减轻内存泄漏的潜在风险。

一个对象的存储类别决定了其构造函数何时被调用。^(1) 对于全局和静态分配的对象(静态存储类别),构造函数在程序启动时被调用,即在进入程序的main函数之前。对于栈分配的对象(自动存储类别),当对象在声明它的函数作用域内可见时,构造函数会被调用。在许多情况下,这会在进入声明它的函数时立即发生。然而,当一个对象在嵌套的块语句中声明时,只有在进入该块时(如果真的进入的话),它的构造函数才会被调用。当对象在程序堆中动态分配时,它的创建是一个两步过程:首先调用new运算符分配对象的内存,然后调用构造函数来初始化对象。Microsoft C++确保在调用构造函数之前,new的结果不为 null,但 GNU 的g++并不这样做。

新变化?

new运算符用于 C++中的动态内存分配,就像malloc在 C 中用于动态内存分配一样。它用于从堆中分配内存,并允许程序在执行过程中根据需要请求空间。new运算符是 C++语言内建的,而malloc仅仅是一个标准库函数。请记住,C 是 C++的子集,因此你可能在 C++程序中看到它们中的任何一个。mallocnew之间最显著的区别是,对于对象类型,new的调用会隐式地调用对象的构造函数,而malloc返回的内存在提供给调用者之前不会被初始化。

当构造函数执行时,以下操作顺序会发生:

  1. 如果类有超类,则会调用超类的构造函数。

  2. 如果类中有虚函数,vftable 指针会被初始化为指向该类的 vftable。这可能会覆盖在超类构造函数中初始化的 vftable 指针,这正是期望的行为。

  3. 如果类中有任何数据成员本身是对象,那么每个数据成员的构造函数都会被调用。

  4. 最后,类构造函数会执行。这是由类的程序员指定的 C++构造函数代码。

从程序员的角度来看,构造函数不指定返回类型,也不允许返回值。某些编译器实际上将this作为结果返回,并可能在调用者中进一步使用它,但这是编译器的实现细节,C++程序员无法访问返回的值。

析构函数顾名思义,在对象生命周期结束时被调用。对于全局和静态对象,析构函数由在main函数终止后执行的清理代码调用。对于栈上分配的对象,析构函数在对象超出作用域时被调用。对于堆上分配的对象,析构函数通过delete操作符在释放对象所分配的内存之前立即调用。

析构函数执行的操作与构造函数类似,唯一的区别是它们大致按照相反的顺序执行:

  1. 如果类有任何虚函数,对象的 vftable 指针会被恢复,指向与关联类对应的 vftable。这是必须的,以防子类在其创建过程中覆盖了 vftable 指针。

  2. 程序员指定的析构函数代码会被执行。

  3. 如果类有任何数据成员是对象,析构函数会依次执行这些成员的析构函数。

  4. 最后,如果对象有父类,则会调用父类的析构函数。

通过理解何时调用父类的构造函数和析构函数,可以通过调用其相关父类函数的链条追踪对象的继承层次结构。

我认为你超负荷了

重载函数是指具有相同名称但参数不同的函数。C++要求每个重载函数版本在接收的参数类型的顺序和/或数量上与其他版本有所不同。换句话说,虽然它们共享相同的函数名,但每个函数原型必须是唯一的,每个重载函数体可以在反汇编的二进制文件中被唯一标识。这与如printf这样的函数不同,后者接收可变数量的参数,但仅与一个函数体关联。

名称修饰

也叫做名称装饰名称修饰是 C++编译器用来区分重载函数版本的机制。为了为重载函数生成唯一的内部名称,编译器会通过附加字符修饰函数名,这些字符编码了函数的各种信息:函数(或其所属类)所在的命名空间(如果有的话)、函数所属的类(如果有的话),以及调用函数所需的参数顺序(类型和顺序)。

名称修饰是 C++ 程序的编译器实现细节,因此不是 C++ 语言规范的一部分。不出所料,编译器厂商开发了各自的、通常不兼容的名称修饰约定。幸运的是,Ghidra 能理解 Microsoft C++ 编译器和 GNU g++ v3(及之后版本)使用的名称修饰约定,以及其他一些编译器。Ghidra 用类似 FUN_地址 的形式代替被修饰的名称。被修饰的名称确实携带有关每个函数签名的有价值信息,Ghidra 会将这些信息包含在符号表窗口中,并将其传播到反汇编和其他相关窗口。(要确定没有被修饰名称的函数签名,可能需要进行耗时的数据流分析,分析该函数的输入和输出数据。)

运行时类型识别

C++ 提供了操作符 (typeid) 用于确定和检查(dynamic_cast)对象在运行时的数据类型。为了支持这些操作,C++ 编译器必须在程序二进制文件中嵌入每个多态类的类型特定信息。当在运行时执行 typeiddynamic_cast 操作时,库函数会引用类型特定信息以确定所引用的多态对象的确切运行时类型。不幸的是,和名称修饰一样,运行时类型识别(RTTI) 是编译器的实现细节,而不是语言问题,并且没有标准的方式来实现 RTTI 功能。

我们将简要介绍 Microsoft C++ 编译器和 GNU g++ 在 RTTI 实现上的相似性与差异性。具体而言,我们将描述如何定位 RTTI 信息,并从中了解与该信息相关的类名。希望获得更详细讨论的读者可以参考本章末尾列出的参考文献,特别是那些文献详细描述了如何遍历类的继承层次结构,包括在使用多重继承时如何追踪该层次结构。

请考虑以下简单程序,它使用了多态:

  class abstract_class {

      public:

          virtual int vfunc() = 0;

  };

    class concrete_class : public abstract_class {

      public:

          concrete_class(){};

          int vfunc();

  };

  int concrete_class::vfunc() {return 0;}

➊ void print_type(abstract_class *p) {

      cout << typeid(*p).name() << endl;

  }

  int main() {

      abstract_class *sc = new concrete_class();➋

      print_type(sc);

  }

print_type 函数 ➊ 打印指针 p 所指向对象的类型。在这种情况下,它必须打印 "concrete_class",因为在 main 函数 ➋ 中创建了一个 concrete_class 对象。那么 print_type,更具体地说,typeid 是如何知道 p 指向的是什么类型的对象呢?

答案出奇地简单。由于每个多态对象都包含指向 vftable 的指针,编译器利用这一事实将类类型信息与类的 vftable 放置在一起。具体而言,编译器会在类的 vftable 之前立即放置一个指针,该指针指向一个包含关于拥有该 vftable 的类的信息的结构体。在 GNU g++代码中,该指针指向一个type_info结构,该结构包含指向类名的指针。在 Microsoft C++代码中,指针指向 Microsoft 的RTTICompleteObjectLocator结构,该结构又包含指向TypeDescriptor结构的指针。TypeDescriptor结构包含一个字符数组,指定多态类的名称。

仅在使用typeiddynamic_cast运算符的 C++程序中,才需要 RTTI 信息。大多数编译器提供了选项,允许在不需要 RTTI 的二进制文件中禁用 RTTI 的生成;因此,如果你遇到的编译后的二进制文件中没有 RTTI 信息,而 vftable 却存在,也不必感到惊讶。

对于使用 Microsoft C++编译器构建的 C++程序,Ghidra 包含一个默认启用的 RTTI 分析器,能够识别 Microsoft 的 RTTI 结构,标注这些结构(如果存在)在反汇编清单中,并在符号树的Classes文件夹中利用从这些 RTTI 结构中恢复的类名。Ghidra 没有非 Windows 二进制文件的 RTTI 分析器。当 Ghidra 遇到一个未剥离的非 Windows 二进制文件时,如果它理解该二进制文件使用的名称重整方案,Ghidra 会利用可用的名称信息填充符号树的Classes文件夹。如果一个非 Windows 二进制文件已经被剥离,Ghidra 将无法自动恢复任何类名或识别 vftable 或 RTTI 信息。

继承关系

通过使用编译器特定实现的 RTTI,确实可以解开继承关系,但如果程序没有使用typeiddynamic_cast运算符,则 RTTI 可能不存在。没有 RTTI 信息的情况下,可以使用什么技术来确定 C++类之间的继承关系呢?

确定继承层次结构的最简单方法是观察在创建对象时调用的父类构造函数的调用链。使用内联构造函数是这一技术的最大障碍。在 C/C++中,被声明为inline的函数通常会被编译器当作宏处理,函数的代码会被扩展到显式函数调用的位置。内联函数隐藏了函数被调用的事实,因为不会生成任何汇编语言的调用语句。这使得理解父类构造函数是否被调用变得具有挑战性。

对 vftable 的分析和比较也能揭示继承关系。例如,在比较图 8-14 中显示的 vftable 时,我们注意到SubClass的 vftable 包含了在BaseClass的 vftable 中出现的两个相同的指针,我们由此得出结论,BaseClassSubClass之间一定存在某种关系。为了理解哪个是基类,哪个是子类,我们可以应用以下准则,单独使用或组合使用:

  • 当两个 vftable 包含相同数量的条目时,这两个相应的类可能涉及到继承关系。

  • 当类 X 的 vftable 包含的条目比类 Y 的 vftable 多时,类 X可能是类 Y 的子类。

  • 当类 X 的 vftable 包含在类 Y 的 vftable 中也存在的条目时,必须存在以下关系之一:X 是 Y 的子类,Y 是 X 的子类,或者 X 和 Y 都是共同基类 Z 的子类。

  • 当类 X 的 vftable 包含的条目也出现在类 Y 的 vftable 中,并且类 X 的 vftable 包含至少一个purecall条目,而该条目在类 Y 的相应 vftable 条目中不存在时,类 Y 很可能是类 X 的子类。

虽然前面的列表并非详尽无遗,但我们可以使用这些准则来推断图 8-14 中BaseClassSubClass之间的关系。在这种情况下,最后三个规则都适用,但最后一条规则特别引导我们得出结论,单凭 vftable 分析,我们可以推断SubClass继承自BaseClass

C++ 反向工程参考

关于反向工程编译后的 C++程序,存在若干优秀的参考资料。^(2)虽然这些文章中的许多细节专门适用于使用微软 C++编译器编译的程序,但许多概念同样适用于使用其他 C++编译器编译的程序。

总结

你可以预期在几乎所有非简单程序中都会遇到复杂数据类型。理解数据结构中数据的访问方式,以及如何识别数据结构布局的线索,是反向工程的重要技能。Ghidra 提供了多种专门用于处理数据结构的功能。熟悉这些功能将大大增强你理解正在操作的数据的能力,并让你有更多时间去理解数据是如何以及为什么被操作的。在下一章,我们将继续讨论 Ghidra 的基本功能,深入探讨交叉引用。

第十一章:交叉引用

Image

在逆向工程一个二进制文件时,两个常见的问题是“这个函数从哪里被调用?”和“哪些函数访问了这个数据?”这些以及其他类似的问题旨在识别并列出程序中对各种资源的引用。以下两个示例展示了这些问题的实用性。

示例 1

当你在查看某个二进制文件中的大量 ASCII 字符串时,发现了一个特别可疑的字符串:“72 小时内付款,否则恢复密钥将被销毁,您的数据将永远保持加密状态。”单凭这个字符串,不能作为确凿证据。它并不能确认该二进制文件具备执行加密勒索攻击的能力或意图。问题“这个字符串在二进制文件中的哪里被引用?”的答案,将帮助你迅速找到使用该字符串的程序位置。这些信息反过来应帮助你定位任何相关的加密勒索代码,或者证明这个字符串在这个上下文中是无害的。

示例 2

你已经定位到一个包含堆栈分配缓冲区的函数,该缓冲区可能会发生溢出,进而导致程序被利用,你想确定这种情况是否真的可能发生。如果你想开发并展示一个利用漏洞的攻击,除非你能够让这个函数执行,否则它对你来说是没用的。这就引出了一个问题:“哪些函数调用了这个易受攻击的函数?”以及关于这些函数可能传递给易受攻击函数的数据性质的其他问题。在你向上回溯潜在的调用链时,这一思路必须继续,以找出一个你可以影响的链路,证明溢出是可以被利用的。

引用基础

Ghidra 可以帮助你分析这两种情况(以及许多其他情况),通过其丰富的机制来展示和访问引用信息。在本章中,我们将讨论 Ghidra 提供的引用类型、用于访问引用信息的工具以及如何解读这些信息。在第十章中,我们将使用 Ghidra 的图形功能来检查引用关系的可视化表示。

所有引用都遵循相同的一般流量规则。每个引用都与一个方向的概念相关。所有引用都是从一个地址指向另一个地址。如果你熟悉图论,你可以将地址视为有向图中的节点(或顶点),而引用则是标识节点间有向连接的。图 9-1 提供了基本图形术语的快速回顾。在这个简单的图中,三个节点——A、B 和 C——通过两条有向边连接。

有向边通过箭头表示,以指示沿边的允许方向。在图 9-1 中,从 A 到 B 是可能的,但从 B 到 A 则不行,类似于单行道。如果箭头是双向的,那么两个方向的旅行都是可以接受的。

Ghidra 有两大类引用:正向引用和反向引用(每类还可细分)。反向引用是两类中较为简单的一种,并且在逆向工程中更为常见。反向引用,也叫做交叉引用,提供了一种在列表中的位置(如代码和数据)之间导航的方式。

image

图 9-1:具有三个节点和两条边的有向图

交叉引用(反向引用)

在 Ghidra 中,反向引用通常被简称为XREFs,这是cross-reference(交叉引用)一词的助记符。在本文中,我们仅在指代 Ghidra 列表、菜单项或对话框中的特定字符序列(XREF)时使用术语XREF。在其他情况下,我们使用更通用的术语cross-reference来指代反向引用。在进入更全面的示例之前,我们先看一下 Ghidra 中具体的 XREF 示例。

示例 1:基本 XREF

我们首先通过检查在demo_stackframe中遇到的一些 XREF(请参见第六章)来理解相关的格式和含义:

     *******************************************************************

     *                         FUNCTION                                *

     *******************************************************************

     undefined demo_stackframe(undefined param_1, undefined4\. . . 

        undefined   AL:1            <RETURN>

        undefined   Stack[0x4]:4    param_1 

        undefined4  Stack[0x8]:4    param_2   XREF[1]:➊0804847f➋(R)➌

        undefined4  Stack[0xc]:4    param_3   XREF[1]:  08048479(R) 

        undefined4  Stack[-0x10]:4  local_10  XREF[1]:  0804847c(W)  

        undefined4  Stack[-0x14]:4  local_14  XREF[2]:  08048482(W), 

                                                        08048493(R)  

        undefined4  Stack[-0x18]:4  local_18  XREF[2]:  08048485(W), 

                                                        08048496(R)  

        undefined1  Stack[-0x58]:1  local_58  XREF[1]:  0804848c(W)  

     demo_stackframe                          XREF[4]:  Entry Point(*),  

                                                        main:080484be(c)➍, 

                                                        080485e4, 08048690(*)  

Ghidra 不仅通过指示符XREF ➊表示存在交叉引用,还通过XREF后面的索引值显示交叉引用的数量。交叉引用的这一部分(例如XREF[2]:)称为XREF 头。通过检查列表中的头部,我们可以看到大多数交叉引用只有一个引用地址,但也有一些有多个。

紧跟在头部之后的是与交叉引用相关的地址 ➋,这是一个可导航的对象。在地址之后,括号内有一个类型指示符 ➌。对于数据交叉引用(本示例即为此情况),有效的类型有R(表示变量在对应的 XREF 地址被读取),W(表示变量正在写入),以及*(表示一个位置的地址被作为指针使用)。总而言之,数据交叉引用在声明数据的位置进行标识,相关的 XREF 条目提供了指向数据被引用位置的链接。

格式化 XREF

与你在 Listing 窗口中遇到的大多数项目一样,你可以控制与交叉引用显示相关的属性。选择 编辑 ▸ 工具选项 打开可编辑的 CodeBrowser 选项。由于 XREF 是 Listing 窗口的一部分,XREF 字段可以在 Listing 字段文件夹中找到。选中后,它会打开如图 9-2 所示的对话框(此处为默认选项)。如果你将“最大显示 XREF 数量”更改为 2,则所有超过此数字的交叉引用头将显示为 XREF[more]。显示非本地命名空间的选项可以帮助你快速识别所有不在当前函数体内的交叉引用。所有选项的详细说明请参见 Ghidra 帮助。

image

图 9-2:显示默认设置的 XREF 字段编辑窗口

列表中还包含一个 代码交叉引用 ➍。代码交叉引用是一个非常重要的概念,因为它们促进了 Ghidra 的函数图和函数调用图的生成,这也是第十章的重点内容。代码交叉引用用于表示一条指令将控制权转移或可能转移到另一条指令。指令转移控制的方式被称为 。流可以分为三种基本类型:顺序流、跳转流或调用流。跳转流和调用流可以进一步根据目标地址是近地址还是远地址来区分。

顺序流 是最简单的流类型,因为它表示从一条指令到下一条指令的线性流动。这是所有非分支指令(如 ADD)的默认执行流。顺序流没有特殊的显示指示符,唯一的标识是指令在反汇编中的排列顺序:如果指令 A 有一个顺序流指向指令 B,那么指令 B 将紧跟在反汇编列表中的指令 A 后面。

示例 2:跳转和调用 XREF

让我们快速看一个包含代码交叉引用的新示例,展示跳转和调用的情况。与数据交叉引用一样,代码交叉引用也在 Listing 窗口中有一个相关的 XREF 条目。以下列出了与 main 函数相关的信息:

     ********************************************************************

     *                         FUNCTION                                 *

     ********************************************************************

     undefined4 __stdcall main(void)

        undefined4  EAX:4           <RETURN>

        undefined4  Stack[-0x8]:4   ptr      ➊XREF[3]:  00401014(W),

                                                         0040101b(R),

                                                         00401026(R)

     main                                    ➋XREF[1]:  entry:0040121e(c)

你可以清楚地识别与堆栈变量 ➊ 相关的三个 XREF,以及与函数本身 ➋ 相关的 XREF。我们来解码 XREF 的含义,entry:0040121e(c). 冒号前的地址(或者在这个例子中是标识符)表示引用(或源)实体。在这种情况下,控制从 entry 转移。冒号右侧是 entry 中具体的地址,是交叉引用的来源。后缀 (c) 表示这是对 mainCALL。简单来说,交叉引用的意思是,“main 是从 entry 中的地址 0040121e 被调用的。”

如果我们双击交叉引用地址以跟随链接,我们将被带到 entry 中指定的地址,在那里可以查看调用。虽然 XREF 是单向链接,但我们可以通过双击函数名(main)或使用 CodeBrowser 工具栏中的向后导航箭头快速返回到 main

0040121e  CALL   main

在以下的列表中,XREF 上的 (j) 后缀表示该标记位置是 JUMP 的目标:

004011fe  JZ     LAB_00401207➊

00401200  PUSH   EAX

00401201  CALL   __amsg_exit

00401206  POP    ECX

        LAB_00401207                           XREF[1]: 004011fe(j)➋

00401207  MOV    EAX,[DAT_0040acf0]

类似于之前的示例,我们可以双击 XREF 地址 ➋ 来导航到转移控制的语句。我们可以通过双击相关的标签 ➊ 返回。

引用示例

让我们通过一个源代码到反汇编的示例,展示多种类型的交叉引用。以下程序 simple_flows.c 包含了多个操作,展示了 Ghidra 的交叉引用功能,如注释文本所示:

int read_it;            // integer variable read in main

int write_it;           // integer variable written 3 times in main

int ref_it;             // integer variable whose address is taken in main

void callflow() {}      // function called twice from main

int main() {

    int *ptr = &ref_it; // results in a "pointer" style data reference (*)

    *ptr = read_it;     // results in a "read" style data reference (R)

    write_it = *ptr;    // results in a "write" style data reference (W)

    callflow();         // results in a "call" style code reference (c)

    if (read_it == 3) { // results in "jump" style code reference (j)

        write_it = 2;   // results in a "write" style data reference (W)

    }

    else {              // results in an "jump" style code reference (j)

        write_it = 1;   // results in a "write" style data reference (W)

    }

    callflow();         // results in an "call" style code reference (c)

}
代码交叉引用

列表 9-1 显示了前面程序的反汇编。

     undefined4 __stdcall main(void)

        undefined4 EAX:4 <RETURN>

        undefined4 Stack[-0x8]:4 ptr          XREF[3]:  00401014(W),

                                                        0040101b(R),

                                                        00401026(R)

     main                                     XREF[1]:  entry:0040121e(c)

00401010  PUSH   EBP

00401011  MOV    EBP,ESP

00401013  PUSH   ECX

00401014  MOV➊  dword ptr [EBP + ptr],ref_it

0040101b  MOV    EAX,dword ptr [EBP + ptr]

0040101e  MOV➋  ECX,dword ptr [read_it]

00401024  MOV    dword ptr [EAX]=>ref_it,ECX

00401026  MOV    EDX,dword ptr [EBP + ptr]

00401029  MOV    EAX=>ref_it,dword ptr [EDX]

0040102b  MOV    [write_it],EAX

00401030  CALL➌ callflow

00401035  CMP    dword ptr [read_it],3

0040103c  JNZ    LAB_0040104a

0040103e  MOV    dword ptr [write_it],2

00401048  JMP➍  LAB_00401054

        LAB_0040104a                          XREF[1]:➎0040103c(j)

0040104a  MOV   dword ptr [write_it],1

 LAB_00401054                          XREF[1]:  00401048(j)

00401054  CALL   callflow

00401059  XOR    EAX,EAX

0040105b  MOV    ESP,EBP

0040105d  POP    EBP

0040105e  RET➏

列表 9-1: simple_flows.exe 中 main 函数的反汇编

除了 JMP ➍ 和 RET ➏ 指令之外,每条指令都与其紧随其后的指令存在关联的顺序流。用于调用函数的指令,如 x86 的 CALL 指令 ➌,会被分配一个 调用流,表示控制转移到目标函数。调用流通过 XREF 在目标函数处标记(即流的目标地址)。在 列表 9-1 中引用的 callflow 函数的反汇编展示在 列表 9-2 中。

     undefined __stdcall callflow(void)

        undefined AL:1 <RETURN>

     callflow                                 XREF[4]:  0040010c(*),

                                                        004001e4(*),

                                                        main:00401030(c),

                                                        main:00401054(c)

00401000  PUSH   EBP

00401001  MOV    EBP,ESP

00401003  POP    EBP

00401004  RET

列表 9-2: callflow 函数的反汇编

额外的 XREF?

时不时地,你会在列表中看到一些似乎异常的内容。列表 9-2 中有两个指针 XREF,0040010c(*)004001e4(*),它们不容易解释。我们立即理解了两个 XREF,可以追溯到 main 中对 callflow 的调用。那另外两个 XREF 是什么呢?事实证明,这些是该特定代码的有趣现象。这个程序是为 Windows 编译的,因此生成了一个 PE 文件,而这两个异常的 XREF 带我们进入了列表中 Headers 部分的 PE 头。这里显示了这两个引用地址(包括相关字节):

0040010c  00 10 00 00 ibo32     callflow               BaseOfCode

               .  .  .

004001e4  00 10 00 00 ibo32     callflow               VirtualAddress

为什么这个函数在 PE 头中被引用?一个快速的 Google 搜索可以帮助我们理解发生了什么:callflow恰好是文本段中的第一个内容,而两个 PE 字段间接地引用了文本段的起始位置,因此与 callflow 函数相关的 XREF 是出乎意料的。

在此示例中,我们看到callflowmain调用了两次:一次来自地址00401030,另一次来自地址00401054。由函数调用引起的交叉引用通过后缀(c)进行区分。交叉引用中显示的源位置既表示调用的地址,也表示包含该调用的函数。

每个无条件和条件分支指令都分配了一个跳转流。条件分支还会分配顺序流,以处理分支未被执行时的控制流;无条件分支没有关联的顺序流,因为该分支总是会被执行。跳转流与显示在清单 9-1 中的JNZ ➎目标处的跳转式交叉引用相关。与调用式交叉引用一样,跳转交叉引用显示引用位置的地址(跳转的来源)。跳转交叉引用通过(j)后缀加以区分。

基本块

在程序分析中,基本块是一个最大化的指令序列,从头到尾执行且不发生分支。因此,每个基本块都有一个入口点(块中的第一条指令)和一个出口点(块中的最后一条指令)。基本块中的第一条指令通常是分支指令的目标,而最后一条指令通常是分支指令。第一条指令可能是多个代码交叉引用的目标。除了第一条指令外,基本块中的其他任何指令都不能成为代码交叉引用的目标。基本块的最后一条指令可能是多个代码交叉引用的来源,例如条件跳转,或者它可能流向一个作为多个代码交叉引用目标的指令(根据定义,这必须开始一个新的基本块)。

数据交叉引用

数据交叉引用用于跟踪二进制文件中数据的访问方式。最常见的三种数据交叉引用类型分别表示何时读取某个位置、何时写入某个位置以及何时获取某个位置的地址。之前示例程序中的全局变量在清单 9-3 中展示,因为它们提供了多个数据交叉引用的示例。

        read_it                               XREF[2]:  main:0040101e(R),

                                                        main:00401035(R)

0040b720 undefined4    ??

        write_it                              XREF[3]:  main:0040102b(W),

                                                        main:0040103e(W),

                                                        main:0040104a(W)

 0040b724    ??         ??

0040b725    ??         ??

0040b726    ??         ??

0040b727    ??         ??

        ref_it                                XREF[3]:  main:00401014(*),

                                                        main:00401024(W),

                                                        main:00401029(R)

0040b728 undefined4    ??

清单 9-3:在 simple_flows.c 中引用的全局变量

读取交叉引用表示正在读取内存位置的内容。读取交叉引用只能来自指令地址,但可以引用任何程序位置。全局变量read_it在清单 9-1 中被读取了两次。该清单中显示的相关交叉引用注释准确地指示了main中哪些位置引用了read_it,并且可以通过后缀(R)识别为读取交叉引用。在清单 9-1 中对read_it的读取➋是一次 32 位的读取操作,结果存储在ECX寄存器中,这导致 Ghidra 将read_it格式化为undefined4(一个 4 字节的未指定类型的值)。Ghidra 通常会尝试根据代码在二进制文件中的操作推断数据项的大小。

全局变量write_it在清单 9-1 中被引用了三次。相关的写入交叉引用被生成并作为注释显示在write_it变量旁边,指示修改该变量内容的程序位置。写入交叉引用使用(W)后缀。在这种情况下,尽管似乎有足够的信息,Ghidra 并没有将write_it格式化为 4 字节变量。与读取交叉引用一样,写入交叉引用只能来源于程序指令,但可能引用任何程序位置。通常,针对程序指令字节的写入交叉引用表明是自修改代码,并且在恶意软件去混淆程序中经常遇到。

第三种数据交叉引用,指针交叉引用,表示正在使用位置的地址(而不是位置的内容)。全局变量ref_it的地址在清单 9-1 中被获取➊,这导致了清单 9-3 中ref_it的指针交叉引用,如后缀(*)所示。指针交叉引用通常是代码或数据中的地址推导的结果。如你在第八章中看到的,数组访问操作通常通过向数组起始地址添加偏移量来实现,并且大多数全局数组的第一个地址通常可以通过存在指针交叉引用来识别。因此,大多数字符串字面量(在 C/C++中,字符串是字符数组)是指针交叉引用的目标。

与只能从指令位置起源的读写交叉引用不同,指针交叉引用可以从指令位置或数据位置起源。来自程序数据区的指针交叉引用的一个例子是任何地址表(例如虚函数表 vftable,生成指向每个条目的指针交叉引用,指向相应的虚函数)。让我们通过 第八章 中的 SubClass 示例来看这个问题。下面显示的是 SubClass 的虚函数表的反汇编:

           SubClass::vftable           XREF[1]:  SubClass_Constructor:00401062(*)

   00408148 void * SubClass::vfunc1 vfunc1

➊ 0040814c void * BaseClass::vfunc2 vfunc2

   00408150 void * SubClass::vfunc3 vfunc3

   00408154 void * BaseClass::vfunc4 vfunc4

   00408158 void * SubClass::vfunc5 vfunc5

在这里,你可以看到位于 0040814c 位置的 ➊ 数据项是指向 BaseClass::vfunc2 的指针。导航到 BaseClass::vfunc2 会显示以下列表:

     **************************************************************

     *                          FUNCTION                          *

     **************************************************************

     undefined __stdcall vfunc2(void)

        undefined AL:1 <RETURN>

        undefined4 Stack[-0x8]:4 local_8      XREF[1]:  00401024(W)

     BaseClass::vfunc2                        XREF[2]:  00408138(*)➊,

                                                        0040814c(*)➋

00401020  PUSH   EBP

00401021  MOV    EBP,ESP

00401023  PUSH   ECX

00401024  MOV    dword ptr [EBP + local_8],ECX

00401027  MOV    ESP,EBP

00401029  POP    EBP

0040102a  RET

与大多数函数不同,这个函数没有代码交叉引用。相反,我们看到两个指针交叉引用,表示该函数的地址在两个位置派生。第二个 XREF ➋ 回溯到之前讨论的 SubClass 的虚函数表(vftable)条目。跟踪第一个 XREF ➊ 将引导我们到 BaseClass 的虚函数表,其中也包含指向这个虚函数的指针。

这个例子展示了 C++ 虚函数很少直接调用,通常也不是调用交叉引用的目标。由于虚函数表的创建方式,所有 C++ 虚函数将至少通过一个虚函数表条目进行引用,并且总会是至少一个指针交叉引用的目标。(记住,重写虚函数并不是强制性的。)

当二进制文件包含足够的信息时,Ghidra 能够为你定位虚函数表。Ghidra 找到的任何虚函数表都会作为条目列出在符号树(Symbol Tree)中 Classes 文件夹下,且该条目对应的类条目下。点击符号树窗口中的虚函数表将引导你到程序数据区的虚函数表位置。

引用管理窗口

到现在为止,你可能已经注意到 XREF 注释在列出窗口中非常常见。这绝非偶然,因为交叉引用形成的链接是将一个程序连接在一起的“粘合剂”。交叉引用讲述了内部和外部功能依赖关系的故事,而大多数成功的逆向工程工作需要对这些行为有全面的理解。接下来的章节将超越交叉引用的基本显示和导航功能,介绍几种在 Ghidra 中管理交叉引用的选项。

XRefs 窗口

你可以使用 XREF 头部来了解更多关于特定交叉引用的信息,如以下列表所示:

        undefined4 Stack[-0x10]:4 local_10    XREF[1]:  0804847c(W)  

        undefined4 Stack[-0x14]:4 local_14    XREF[2]:➊08048482(W), 

                                                        08048493(R)  

双击 XREF[2] 头部 ➊ 将弹出关联的 XRefs 窗口,如 图 9-3 所示,详细列出了交叉引用。默认情况下,窗口显示位置、标签(如果适用)、引用的反汇编代码和引用类型。

image

图 9-3:XRefs 窗口

引用到

另一个有助于理解程序流程的窗口是“引用到”窗口。在列表窗口中右键点击任何地址,选择引用显示引用地址,就会弹出如图 9-4 所示的窗口。

image

图 9-4:引用到窗口

在这个例子中,我们选择了helper函数的起始地址。在这个窗口中,你可以通过点击窗口中的任何条目来导航到相关位置。

符号引用

在第 82 页的“符号表和符号引用窗口”中介绍的另一个参考视图是符号表和符号引用窗口的组合。默认情况下,当你选择“窗口 ▸ 符号引用”时,会显示两个相关的窗口。一个显示整个符号表中的每个符号,另一个显示与符号相关的引用。在符号表窗口中选择任何条目(例如函数、vftable 等)会导致在符号引用窗口中显示相关的符号引用。

引用列表可用于快速识别从哪个位置调用了特定的函数。例如,许多人认为 C 语言的strcpy函数是危险的,因为它会将源字符数组(包括关联的空字符终止符)复制到目标数组中,而根本没有检查目标数组是否足够大以容纳源数组中的所有字符。你可以在列表中定位到任何一个strcpy的调用,并使用上述方法打开“引用到”窗口,但如果你不想花时间在二进制文件中找到strcpy的使用位置,可以打开符号引用窗口,快速定位到strcpy及其所有相关引用。

高级引用操作

在本章开始时,我们将回引用交叉引用等同,并简要提到 Ghidra 还具有前向引用,其中有两种类型。推断前向引用通常会自动添加到列表中,并与回引用一一对应,尽管推断前向引用是沿相反方向遍历的。换句话说,我们从目标地址回溯到源地址时遍历回引用,而我们从源地址向前遍历到目标地址时则遍历推断前向引用。

第二种类型是显式前向引用。显式前向引用有多种类型,它们的管理比其他交叉引用复杂得多。显式前向引用的类型包括内存引用、外部引用、栈引用和寄存器引用。除了查看引用外,Ghidra 还允许你添加和编辑各种类型的引用。

当 Ghidra 的静态分析无法确定在运行时计算出的跳转或调用目标,但你通过其他分析知道目标时,你可能需要手动添加交叉引用。在以下代码中,我们在第八章中最后看到过,它调用了一个虚函数。

0001072e  PUSH   EBP

0001072f  MOV    EBP,ESP

00010731  SUB    ESP,8

00010734  MOV    EAX,dword ptr [EBP + param_1]➊

00010737  MOV    EAX,dword ptr [EAX]

00010739  ADD    EAX,8

0001073c  MOV    EAX,dword ptr [EAX]

0001073e  SUB    ESP,12

00010741  PUSH   dword ptr [EBP + param_1]

00010744  CALL➋ EAX

00010746  ADD    ESP,16

00010749  NOP

0001074a  LEAVE

0001074b  RET

EAX ➋ 中存储的值取决于通过 param_1 ➊ 传递的指针的值。因此,Ghidra 没有足够的信息来创建交叉引用,将 00010744CALL 指令的地址)与调用的目标关联。手动添加交叉引用(例如,指向 SubClass::vfunc3)将使目标函数被链接到调用图中,从而改善 Ghidra 对程序的分析。右键点击调用 ➋ 并选择 引用从中添加引用 打开如图 9-5 所示的对话框。此对话框也可以通过“引用” ▸ “添加/编辑”选项访问。

image

图 9-5:添加引用对话框

将目标函数的地址指定为“目标地址”设置,并确保已选择正确的引用类型设置。当你点击“添加”按钮关闭对话框时,Ghidra 会创建引用,并在目标地址处出现新的(c)交叉引用。关于正向引用的更多信息,包括剩余的引用类型和引用操作,请参见 Ghidra 帮助文档。

总结

引用是强大的工具,可以帮助你理解二进制文件中各个构件之间的关系。我们详细讨论了交叉引用,并介绍了与引用相关的其他一些功能,这些功能将在后续章节中再次讨论。在下一章中,我们将探讨引用的可视化表示,以及生成的图表如何帮助我们更好地理解函数内的控制流以及二进制文件中函数之间的关系。

第十二章:图形**

Image

如我们在上一章中所做的那样,通过图形直观地表示数据(见图 9-1),提供了一种简洁明了的机制,展示图形中节点之间的多种连接关系,并帮助我们识别在操作图形作为抽象数据类型时可能难以发现的模式。Ghidra 的图形视图提供了一个新的视角(除了反汇编和反编译列表之外)来查看二进制文件的内容。它们通过将函数和其他类型的块表示为节点,且通过将流程和交叉引用表示为边(连接节点的线),让你快速查看一个函数的控制流以及文件中函数之间的关系。通过足够的练习,你可能会发现,像switch语句和嵌套的if/else结构这样的常见控制结构,在图形形式下比在长文本列表中更容易识别。在第五章中,我们简要介绍了函数图和函数调用图窗口。在本章中,我们将深入探讨 Ghidra 的图形功能。

由于交叉引用将一个地址与另一个地址关联,因此它们是绘制二进制图形的自然起点。通过将自己限制在顺序流和特定类型的交叉引用中,我们可以推导出许多有用的图形来分析我们的二进制文件。虽然流程和交叉引用充当图形中的边,但节点背后的含义可能会有所不同。根据我们希望生成的图形类型,节点可以包含一个或多个指令,或者整个函数。让我们通过查看 Ghidra 如何将代码组织成,然后再讨论 Ghidra 中可用的图形类型。

基本块

在计算机程序中,基本块是一个或多个指令的组合,该组合具有一个开始时的单一入口点和一个结束时的单一出口点。除了最后一条指令外,基本块中的每条指令都会将控制权转移到块内的恰好一个后继指令。类似地,除了第一条指令外,基本块中的每条指令都会从块内的恰好一个前驱指令接收控制权。在“交叉引用(反向引用)”一节中(见第 185 页),我们将其称为顺序流。你可能会不时注意到,在基本块的中间有一个函数调用,并心想,“这不正是应该终止一个块的指令,比如跳转吗?”为了确定基本块,通常忽略函数调用将控制权转移到当前块外的事实,除非已知被调用的函数不会正常返回。

一旦基本块中的第一条指令被执行,块中的其余部分将保证执行完毕。这对于程序的运行时插装有重要影响,因为不再需要在程序中的每条指令上设置断点,或者逐步执行程序来记录已执行的指令。相反,可以在每个基本块的第一条指令上设置断点,并且每次触发断点时,都可以假定该块中的每条指令都将被执行。让我们转向 Ghidra 的功能图能力,以提供另一种块的视角。

功能图

功能图窗口在第五章中介绍,以图形格式显示单个函数。以下程序包含一个由单个基本块组成的函数,因此它是展示 Ghidra 功能图的一个有用起点:

int global_array[3];

int main() {

    int idx = 2;

    global_array[0] = 10;

    global_array[1] = 20;

 global_array[2] = 30;

    global_array[idx] = 40;

}

当你打开main已选择的功能图窗口(窗口 ▸ 功能图),你将看到一个只有一个基本块的功能图,如图 10-1 所示。

image

图 10-1:一个包含卫星视图的单块功能图窗口,右下角

功能图窗口和列表窗口之间有一个有用的双向链接。如果你并排查看这两个窗口,功能的并行列出和图形表示可以帮助你更好地理解函数的控制流。你在功能图窗口所做的更改(例如,重命名函数、变量等)将立即反映在列表窗口中。你在列表窗口所做的更改也会反映在功能图窗口中,尽管你可能需要刷新窗口才能看到更改。

折线

随着函数变得更加复杂,每个函数中的基本块数量可能会增加。当你首次生成功能图时,连接各个基本块的边会被切割成折线。这意味着它们会整齐地弯曲成 90 度角,以确保它们不会被节点遮挡。这导致了一个整齐的网格布局,其中所有边的组成部分都是水平或垂直的。如果你决定通过拖动节点改变图的布局,边可能会失去折线的特性,变成直线并绕过其他节点。图 10-2 展示了左侧的折线表示与右侧的非折线表示之间的对比。你可以通过刷新功能图窗口随时恢复到原始布局。

image

图 10-2:带有折线和非折线边的功能图

如果你在 Function Graph 窗口中点击任何一行文本,Listing 窗口中的光标将移动到反汇编列表中的相应位置。如果你双击 Function Graph 中的数据,Listing 窗口将导航到该数据在 Listing 数据部分的位置,而 Function Graph 窗口将继续保持对该函数的聚焦。(尽管 Ghidra 目前没有提供基于图形的数据可视化或数据组件之间关系的可视化,但它允许你同时查看 Listing 视图中的数据和图形视图中的相关代码。)

让我们来看一个快速示例,演示 Listing 窗口和 Function Graph 窗口之间的关系。假设你在图 10-1 中看到了global_array变量,并想了解更多关于它的类型。当你通过在图形视图中双击该名称导航到它时,你会看到 Ghidra 已经将global_array分类为一个未定义字节数组(undefined1),并通过索引访问第四个和第八个元素。如果你将 Listing 窗口中数据部分的数组定义从undefined1[12]更改为int[3](分别显示在图 10-3 的上半部分和下半部分),你可以立即看到声明对 Function Graph 窗口(以及 Decompiler 窗口)中反汇编代码的影响:索引值变为12,以反映每个数组元素的新 4 字节大小。

image

图 10-3:修改数组声明对 Function Graph 和 Listing 窗口的影响

在 Listing 窗口中的导航是灵活的,只要你没有点击不同的函数。你可以滚动查看整个 Listing 窗口的内容,在数据部分点击并进行更改,在函数内进行修改,等等。如果你点击了另一个函数,图形视图将更新,显示新选定函数的图形。

什么是交互阈值?

在与 Function Graph 窗口交互时,特别是处理复杂函数时,你可能需要缩小视图,因为你无法看到所有想看的内容。当各个节点变得太小,无法以有意义的方式进行交互时,你已经超出了交互阈值。Function Graph 中每个节点的投影阴影用来指示这种情况。虚拟地址可能只显示最低有效值,而且图中的节点数量可能变得难以处理。试图选择节点中的内容最终会选择整个块。如果函数的复杂性使你超出了这个阈值,不要灰心。你可以点击任何节点将其聚焦,或者双击某个节点来放大它。

图 10-4 突出了 Function Graph 窗口中可用的菜单和工具栏。

image

图 10-4:Function Graph 工具栏

函数图实际上不过是代码浏览器窗口中单个函数的图形化展示,因此,除了窗口菜单外,代码浏览器中的所有菜单都可以在函数图窗口中使用➊。代码浏览器工具栏的可用子集➋包括保存当前文件状态、撤销和重做、以及在当前导航链中前进和后退的功能。需要注意的是,由于窗口是相互关联的,这可能会使你导航到当前函数之外(并返回),从而改变函数图窗口的内容。

函数图工具栏图标 ➌ 及其默认行为在图 10-5 中有描述。

image

图 10-5:函数图工具栏操作

每个基本块还有一个工具栏 ➍,让你能够修改该块,并通过将多个块(顶点)组合成一个单一的块来进行分组(有关工具栏图标及其默认行为的解释,请参见图 10-6)。这个功能对于简化因函数高度嵌套而导致的图形复杂性非常有用。例如,在你理解了循环的行为并且不再需要查看循环内的代码后,你可能选择将循环语句内嵌套的所有块折叠成一个图节点。根据你所分组的嵌套块的数量,图形的可读性可能会显著提升。要分组节点,你必须使用 CTRL-点击选择所有将要属于该组的节点,然后点击你认为位于组根节点的节点上的合并顶点工具。恢复组是一个特别有用的按钮,它让你能够快速查看组内内容,并随后重新折叠该组。

image

图 10-6:函数图基本块工具栏

要查看与函数图相关的其他功能,你需要查看包含多个基本块的示例。以下程序将在后续示例中使用:

int do_random() {

    int r;

    srand(time(0));

    r = rand();

    if (r % 2 == 0) {

       printf("The random value %d is even\n", r);

    }

    else {

       printf("The random value %d is odd\n", r);

    }

    return r;

}

int main() {

    do_random();

}

do_random 函数包含控制结构(if/else),导致图形中有四个基本块,这些基本块在图 10-8 中被标记出来。查看一个包含多个块的函数,可以更明显地看出,函数图是一个控制流图,边缘表示从一个块到另一个块的可能流动。请注意,Ghidra 为函数图提供的布局称为嵌套代码布局,它与 C 代码的流动非常相似。这使得在更大的程序上下文中查看你的列表和反汇编器窗口的图形表示变得更加容易。为了保持这种视图,我们强烈建议你修改图形选项,将边缘绕过顶点(编辑 ▸ 工具选项 ▸ 功能图 ▸ 嵌套代码布局 ▸ 将边缘绕过顶点)。默认情况下,Ghidra 会不幸地将边缘路由到节点后面,这往往会误导节点之间关系的呈现。

此图形已过时

虽然列表中的一些更改会立即反映在功能图窗口中,但在其他情况下,图形可能会变得过时(与列表视图不同步)。当这种情况发生时,Ghidra 会在图形窗口的底部显示如图 10-7 所示的消息。

image

图 10-7:陈旧图形警告消息

消息左侧的回收图标允许你刷新图形,而不需要恢复到原始布局。(当然,你也可以选择刷新并重新布局。)

在图 10-8 中显示的图形中,BLOCK-1 是进入该函数的唯一入口点。这个块像所有基本块一样,表现出从指令到指令的顺序流动。块内的三个函数调用(timesrandrand)不会“中断”基本块的执行,因为 Ghidra 假定它们都返回并继续顺序执行剩余的指令。如果 BLOCK-1 末尾的 JNZ 条件计算为假(即随机值为偶数),则进入 BLOCK-2。如果 JNZ 条件计算为真(即随机值为奇数),则进入 BLOCK-3。最后的块,BLOCK-4,是在完成 BLOCK-2 或 BLOCK-3 后进入的。请注意,点击一条边缘会使其聚焦,并使其显示得比其他边缘更粗。在图中,连接 BLOCK-1 和 BLOCK-3 的边缘是活动边缘,并且显示为粗体。

image

图 10-8:选择深色线条显示条件满足时的流程图

如果你有一个特别长的基本块,并希望将其拆分为更小的块,或者希望为进一步分析将代码的某个部分视觉上隔离开来,你可以通过在函数图中引入新的标签来拆分基本块。使用热键 L 在 0010072e 行插入一个新标签(位于 BLOCK-1 中,位于调用srand之前)会在函数图中添加一个第五个块,如图 10-9 所示。引入的新边表示数据流,且不与交叉引用相关联。

image

图 10-9:带有新标签的函数图,介绍了新的基本块

与函数图交互

虽然在书中展示这一点并不容易,但函数图窗口在你与图中各个组件交互时,会包含颜色、动画以及信息弹出框:

边的颜色基于该边表示的过渡类型。你可以通过“编辑 ▸ 工具选项”窗口控制这些颜色,如图 10-10 所示。默认情况下,绿色边表示条件跳转(条件为真时,跳转发生),红色边表示顺序执行(跳转未发生),蓝色边表示无条件跳转。点击单个边或一组边会增加边的厚度,并改变为该颜色的高亮阴影。

image

图 10-10:函数图颜色自定义选项

节点

每个节点的内容是相应基本块的反汇编列表。你与列出的代码的交互方式与在“列出窗口”中与代码交互的方式相同。例如,悬停在名称上会弹出一个显示该位置反汇编的框。当你将鼠标悬停在节点上时,Ghidra 会在相关的边上使用路径高亮动画,以指示当前选择的路径高亮选项一致的控制流方向。你可以在“编辑 ▸ 工具选项”中禁用此功能。

卫星图

卫星图(即图的简略概览)会围绕当前聚焦的块显示一个黄色光环,函数图窗口也是如此。为了便于识别,函数的入口块(包含函数入口地址)在卫星图中是绿色的,任何返回块(包含ret或等效指令的块)是红色的。即使你更改了图中相关块的背景颜色,卫星图中的入口和出口颜色也不会改变。所有其他块将镜像函数图窗口中分配给它们的颜色。

函数调用图

函数调用图有助于快速理解程序中函数调用的层次结构。函数调用图类似于函数图,但每个块代表一个完整的函数体,每条边代表从一个函数到另一个函数的调用交叉引用。

为了讨论函数调用图,我们使用以下简单的程序来创建一个简单的函数调用层次结构:

#include <stdio.h>

void depth_2_1() {

    printf("inside depth_2_1\n");

}

void depth_2_2() {

    fprintf(stderr, "inside depth_2_2\n");

}

void depth_1() {

    depth_2_1();

    depth_2_2();

    printf("inside depth_1\n");

}

int main() {

    depth_1();

}

使用 GNU gcc 编译一个动态链接版本的程序并通过 Ghidra 加载二进制文件后,我们可以通过窗口 ▸ 函数调用图生成一个函数调用图。默认情况下,这将创建一个以当前选中的函数为中心的函数调用图。当选择 main 时,函数调用图如 图 10-11 所示。(为了清晰起见,这些示例中隐藏了卫星视图。要取消隐藏卫星视图,可以使用 图 10-11 右下角的图标。)

image

图 10-11:聚焦于 main 的简单函数调用图

图表标题栏中的字符串 main(3 个函数;2 条边) 让我们知道当前所在的函数,并显示相关函数和边的数量。将鼠标悬停在图中的节点上,会在节点的顶部和/或底部显示加号和/或减号图标,如 图 10-12 所示。

顶部或底部的加号图标表示可以显示更多的传入或传出函数。相反,减号图标则提供了收缩节点的功能。例如,点击展开时位于 depth_1 函数底部的减号符号,将会使得函数调用图从 图 10-13 中显示的状态切换为 图 10-11 中显示的状态。

image

图 10-12:带有展开/收缩图标的函数调用图节点

image

图 10-13:从 main 扩展的函数调用图

与每个节点关联的右键上下文菜单提供了一个选项,可以同时展开或收缩所有同一水平节点的所有传出边。这相当于同时点击同一等级上所有节点的加号或减号图标。最后,双击图中的节点会将图中心定位到选中的节点,并完全展开所有传入和传出边。一个默认禁用但许多人认为有用的选项,允许你进行缩放操作。可以通过编辑 ▸ 工具选项中的勾选“滚轮平移”选项来启用此功能。Ghidra 会在你切换焦点时维护一个简短的图历史记录缓存,以便你返回时保持图的状态。这使得你可以展开和收缩节点,离开然后返回,找到你离开时的图形状态继续分析。

图 10-14 显示了相同的程序,重点是 _start 而不是 main,并且大多数节点已完全展开,以显示图表的全部范围。除了我们的 main 函数和相关子例程外,我们还可以看到编译器插入的包装代码。该代码负责库的初始化和终止,以及在将控制权转移到 main 函数之前正确配置环境。(细心的读者可能会注意到,编译器将对 putsfwrite 的调用替换为 printffprintf,因为它们在打印静态字符串时效率更高。)

image

图 10-14:从 _start 扩展的函数调用图

THUNK

你可能会注意到,图 10-14 中的图表显示了多次(显然是递归的)对 puts 的调用。欢迎进入神奇的 thunk 函数世界。thunk 函数 是一种编译器设备,它促进了对编译时地址未知的函数(如动态链接库函数)的调用。Ghidra 将地址未知的函数称为 thunked 函数。编译器将程序对 thunked 函数的所有调用替换为对编译器插入到可执行文件中的 thunk 函数存根的调用。thunk 函数存根 通常会执行表查找,以便在将控制权转移到 thunked 函数之前,获取该函数的运行时地址。thunk 存根查阅的表通常在运行时填充,待关联的 thunked 函数地址变得已知。在 Windows 可执行文件中,这个表通常称为 导入表。在 ELF 二进制文件中,这个表通常称为 全局偏移表(或 got)。

如果我们从 Listing 窗口中的 depth_1 函数导航到 puts,我们会进入以下列表:

    **************************************************************

    *                       THUNK FUNCTION                       *

    **************************************************************

             thunk int puts(char * __s)

                Thunked-Function: <EXTERNAL>::puts

     int               EAX:4          <RETURN>

     char *            RDI:8          __s

                puts@@GLIBC_2.2.5

                puts     XREF[2]: puts:00100590(T),

                                  puts:00100590(c), 00300fc8(*)

00302008                 ??         ??

00302009                 ??         ??

0030200a                 ??         ??

这个 thunk 函数列表出现在 Ghidra 所称的 EXTERNAL 程序部分。像这样的 Ghidra thunk 函数列表是外部库在运行时动态加载和链接到进程中的方式的结果,这意味着这些库在静态分析期间通常是不可用的。虽然该列表为你提供了被调用函数和库的指示,但函数代码并不直接可访问(除非库也被加载到 Ghidra 中,这可以通过导入过程中的选项页面轻松完成)。

在这里,我们还观察到一种新的 XREF 类型。第一个 XREF 上的 (T) 后缀表示这个 XREF 是指向 thunked 函数的链接。

现在,让我们回顾一下 call_tree 程序的静态链接版本。从 main 函数生成的初始图与图 10-11 所示的动态链接版本完全相同。不过,为了了解与静态链接二进制图形相关的潜在复杂性,让我们研究两个看似相对简单的扩展。图 10-15 显示了 puts 函数的外部调用。标题栏显示 puts(9 个函数;11 条边)。请注意,标题栏的总数可能不准确,直到程序被完全分析后才会更新。

image

图 10-15:静态链接二进制中的函数调用图

当我们将焦点转向 _lll_lock_wait_private 时,会看到一个包含 70 个节点和超过 200 条边的庞大图形,部分内容如图 10-16 所示。

image

图 10-16:静态链接二进制中的扩展函数调用图

虽然静态链接的二进制文件比较复杂,处理相关图形可能具有挑战性,但有两个特点使这一过程变得可行。首先,你通常可以通过使用快捷键 G 或从程序的 entry 符号进行导航来定位 main 函数。其次,一旦你在列表中找到了 main,你就可以轻松打开并控制相关函数调用图中显示的内容。

树形结构

Ghidra 以类似树的结构展示了与特定二进制文件相关的许多层次化概念。虽然这些结构不一定在纯图论意义上是树,但它们提供了扩展和折叠节点的功能,并展示了不同类型节点之间的层次关系。当我们在第五章讨论 CodeBrowser 窗口时,你已经接触到了程序树、符号树、函数调用树以及数据类型管理器(它也是以树的形式呈现)。这些树形视图可以与其他视图并行使用,为你分析的二进制文件提供额外的见解。

总结

图形是帮助你分析任何二进制文件的强大工具。如果你习惯了以纯文本格式查看反汇编代码,可能需要一些时间来适应基于图形的显示方式。在 Ghidra 中,关键在于意识到所有在文本显示中可用的信息仍然可以在图形显示中找到;不过,它可能会以略有不同的格式呈现。例如,交叉引用在图形显示中变成了连接各个模块的边。

你查看的图形取决于你想要了解二进制文件的哪些信息。如果你想知道某个特定函数是如何被调用的,你可能对函数调用图更感兴趣。如果你想了解某条特定指令是如何被访问的,你可能更关注函数图。两者都能为你提供有关程序运行的有价值的见解。

现在你已经了解了在仅有你作为反向工程师的情况下运行 Ghidra 作为独立实例时可用的基本功能,接下来是时候探讨将 Ghidra 用作协作工具的选项了。在下一章中,我们将了解 Ghidra Server 以及它提供的支持协作反向工程的环境。

第十三章:**第三部分

让 GHIDRA 为你服务**

第十四章:协作性 SRE

Image

到目前为止,你应该已经能够熟练地浏览 Ghidra 项目环境以及许多可用的工具和窗口。你知道如何创建项目、导入文件、浏览和操作反汇编。你理解 Ghidra 数据类型、数据结构和交叉引用。但你是否理解规模?一个 200MB 的二进制文件很可能会生成数百万行的反汇编,并包含数十万个函数。即使你拥有一台最大尺寸的、纵向显示的显示器,你一次也只能查看其中几百行反汇编。

承担这样一项巨大的任务的一种方法是分配一个团队来完成,但这会引入一个额外的问题:如何同步每个人的工作,以免他们在更改时互相干扰?现在是时候扩展我们对 Ghidra 使用的讨论,涵盖一个协作团队共同在一个共享项目上工作。Ghidra 对协作逆向工程的支持使其在软件分析工具中独树一帜。在本章中,我们将介绍 Ghidra 的协作服务器,该服务器随标准 Ghidra 分发包一起提供。我们将讨论它的安装、配置和使用,帮助你让更多的人集中精力解决最具挑战性的 RE 问题。

团队合作

SRE 是一个复杂的过程,很少有人能精通其中的所有细节。能够让具有不同技能的分析师同时分析一个二进制文件,可以大大减少获得所需结果所需的时间。一个擅长在复杂程序中导航控制流的人,可能会不愿意分析和记录相关的数据结构。一个恶意软件分析专家,可能不适合进行漏洞发现工作,而任何时间紧迫的人,也不太可能利用时间插入大量注释,这些注释在未来肯定会有用,但短期内可能会阻碍他们分析其他代码。五个同事可能希望独立分析同一个二进制文件,但他们意识到有些步骤是他们都需要完成的。一个人可能需要将任务交给同事,以便获得专家意见,或是在度假期间。 有时,多个视角同时审视同一件事有助于进行理智检查。无论动机如何,Ghidra 内的共享项目功能都支持多种形式的协作 SRE。

Ghidra 服务器设置

Ghidra 中的协作是通过共享的 Ghidra Server 实例来实现的。如果你是负责设置 Ghidra Server 的系统管理员,你有许多选择,比如是否在实体服务器上部署,还是在虚拟环境中部署以便于迁移和可重复安装。本章中我们使用的部署方式仅适用于开发和实验。如果你正在为生产环境配置 Ghidra Server,应该仔细阅读 Ghidra Server 文档,并根据你的环境和特定需求确定合适的配置。(整个书籍可以用来描述 Ghidra Server 的设置及其所有安装选项和相关方法,但这本书并不涉及这些内容。)

尽管 Ghidra Server 可以在所有支持 Ghidra 的平台上进行配置,但我们将描述如何在 Linux 环境中运行 Ghidra Server 实例,并假设你对 Linux 命令行和系统管理有一定的了解。在本章中,我们将对 Ghidra Server 配置文件(位于 server/server.conf)进行一些小的修改,以便展示我们希望演示的概念,避免在完成初始安装、配置、管理和访问控制后过度依赖 Linux 命令行界面。修改内容包括将默认的 Ghidra 仓库目录更改为我们自己选择的目录,正如 Ghidra Server 文档中推荐的那样,并调整用户管理和访问控制设置。

GHIDRA SERVER 特性

你的 Ghidra Server 很高兴为你提供以下安装选项:

平台 实体服务器、虚拟机、容器等!

操作系统 支持多种版本的 Windows、Linux 和 macOS,总有一种适合你的口味。

认证方法 选择你的朋友和同事如何访问你的服务——从“对公众开放”到“仅限 PKI”以及介于两者之间的所有方式。

准备工作 你可以通过容器、脚本、.bat 文件或详细的说明进行安装,或者你可以通过在控制台上敲击指令直到发生一些令人兴奋的事情,来自定义你的安装过程。

如果你没有找到喜欢的选项,不用担心,因为这里仅展示了部分可用选项。Ghidra Server 的配置可以满足即使是最挑剔用户的需求,为他们提供理想的 Ghidra 安装体验。感谢你访问 Ghidra Server。欲了解更多信息,请参阅位于 Ghidra 目录中的扩展服务器菜单 server/svrREADME.html

以下步骤将引导你通过一个脚本过程,在 Ubuntu 主机上创建一个环境并初始化 Ghidra 用户。

  1. 定义脚本中将使用的环境变量,包括你正在安装的 Ghidra 版本:

    #set some environment variables
    
    OWNER=ghidrasrv
    
    SVRROOT=/opt/${OWNER}
    
    REPODIR=/opt/ghidra-repos
    
    GHIDRA_URL=https://ghidra-sre.org/ghidra_version.zip
    
    GHIDRA_ZIP=/tmp/ghidra.zip
    
  2. 安装两个必要的包(unzipOpenJDK),这些是完成安装并运行服务器所需要的:

    sudo apt update && sudo apt install -y openjdk-version-jdk unzip
    
  3. 创建一个非特权用户来运行服务器,并创建一个目录来托管 Ghidra 仓库,该目录位于 Ghidra 服务器安装目录之外。根据服务器配置指南,建议将服务器可执行文件和仓库保存在不同的目录中,这有助于未来的服务器更新。Ghidra 服务器管理工具(svrAdmin)将使用服务器管理员用户的主目录。```
    sudo useradd -r -m -d /home/${OWNER} -s /usr/sbin/nologin -U $

    sudo mkdir ${REPODIR}

    sudo chown \({OWNER}.\){OWNER} ${REPODIR}

    
    
  4. 下载 Ghidra,解压并将其移动到服务器根目录。确保在下载 Ghidra 时获取最新的公开发布版本(发布日期在 .zip 文件名中):

    wget ${GHIDRA_URL} -O ${GHIDRA_ZIP}
    
    mkdir /tmp/ghidra && cd /tmp/ghidra && unzip ${GHIDRA_ZIP}
    
    sudo mv ghidra_* ${SVRROOT}
    
    cd /tmp && rm -f ${GHIDRA_ZIP} && rmdir ghidra
    
  5. 创建原始服务器配置文件的备份,并更改仓库保存的位置:

    cd ${SVRROOT}/server && cp server.conf server.conf.orig
    
    REPOVAR=ghidra.repositories.dir
    
    sed -i "s@^$REPOVAR=.*\$@$REPOVAR=$REPODIR@g" server.conf
    
    
  6. -u 参数添加到 Ghidra 服务器启动参数中,以便用户在连接时可以指定用户名,而不是被迫使用本地用户名。此选项使我们能够从一台机器登录为多个不同的用户进行演示,并允许我们从多台机器登录同一账户。(一些版本的 Ghidra 期望仓库路径是最后一个命令行参数,因此我们将 parameter.2 更改为 parameter.3,然后在更新的行之前添加了新的 parameter.2=-u。)

    PARM=wrapper.app.parameter.
    
    sed -i "s/^${PARM}2=/${PARM}3=/" server.conf
    
    sed -i "/^${PARM}3=/i ${PARM}2=-u" server.conf
    
    
  7. 将 Ghidra 服务器进程和 Ghidra 服务器目录的所有权更改为 ghidrasvr 用户。(由于这只是一个演示服务器,我们保持了所有其他参数不变。强烈建议您阅读 server/svrREADME.html 以确定适合生产部署的配置。)

    ACCT=wrapper.app.account
    
    sed -i "s/^.*$ACCT=.*/$ACCT=$OWNER/" server.conf
    
    sudo chown -R ${OWNER}.${OWNER} ${SVRROOT}
    
  8. 最后,作为服务安装 Ghidra 服务器并添加授权连接到服务器的用户:

    sudo ./svrInstall
    
    sudo ${SVRROOT}/server/svrAdmin -add user1
    
     sudo ${SVRROOT}/server/svrAdmin -add user2
    
    sudo ${SVRROOT}/server/svrAdmin -add user3
    

尽管有关访问控制的详细讨论将在本章稍后部分进行,但在此提到它仍然很重要,因为用户需要存在于 Ghidra 服务器实例所使用的认证系统中。这发生在 Ghidra 服务器本身。默认情况下,每个用户必须在 24 小时内通过使用默认密码changeme(在首次登录时必须更改)从 Ghidra 客户端登录。如果用户未在 24 小时内激活他们的账户,该账户将被锁定并且必须重置。Ghidra 为 Ghidra 服务器系统管理员提供了多种认证选项,从简单的密码到公钥基础设施(PKI)。我们选择使用本地 Ghidra 密码(这是默认的)。

如果您想安装自己的 Ghidra 服务器或只是需要更深入地了解各种安装选项,请参阅 Ghidra 目录中的 server/svrREADME.html

项目仓库

团队合作的一个优势是多个人可以同时在同一个二进制文件上工作。团队合作的一个劣势是多个人可以同时在同一个二进制文件上工作。当多个用户与相同内容交互时,可能会引入竞争条件。在竞争条件中,操作(例如保存更新的文件)执行的顺序可能会影响最终结果。Ghidra 拥有项目库和版本控制系统,以控制哪些更改何时由谁提交。

Ghidra 仓库负责文件的签入和签出,跟踪版本历史,并让你查看当前已签出的文件。当你签出一个文件时,会得到该文件的副本。当你完成文件操作并将文件签回时,会创建文件的新版本,并成为该文件的版本历史的一部分。如果其他人也签入了该文件的新版本,仓库会帮助解决任何冲突。我们将在本章稍后演示与仓库的交互。

共享项目

到目前为止,我们只创建并使用了独立的 Ghidra 项目,适用于单个分析师在单台计算机上工作。现在你已经配置并授权自己访问 Ghidra 服务器,接下来我们将介绍创建共享项目的过程。共享项目可以让任何被授权连接到你 Ghidra 服务器的用户访问,并支持项目的协作并发访问。

创建共享项目

当你创建一个新项目(文件 ▸ 新建项目)并选择共享项目时,必须指定与 Ghidra 服务器关联的服务器信息,如图 11-1 左侧所示。默认端口号(13100)已提供,但你必须提供服务器的主机名或 IP 地址,并且可能需要根据 Ghidra 服务器的配置进行身份验证。

image

图 11-1:登录到 Ghidra 服务器仓库

在图的右侧,我们以通过安装脚本创建的用户之一(user1)登录。如果这是第一次以此用户身份登录,您需要更改密码(从changeme开始),如前文所述。

接下来,选择一个现有的仓库,或通过输入新仓库名称来创建一个新仓库,如图 11-2 所示。在这个例子中,我们将创建一个名为CH11的新仓库。

image

图 11-2:新建项目对话框

点击下一步会创建一个新的仓库和新项目,并带你进入熟悉的项目窗口(图 11-3)。

image

图 11-3:共享项目的项目窗口,显示表格视图

我们已导入一些文件 ➊,并使用表格而不是默认的树状结构显示它们。表视图是选项卡布局选择之一 ➋,可以提供关于每个项目文件更详细的信息。项目窗口显示项目仓库的名称(CH11),您在项目中的角色(管理员),以及右侧的图标以提供有关连接服务器的信息 ➌。在这种情况下,悬停在图标上 ➍ 会显示消息:“作为用户 1 连接到 172.16.4.35。” 如果未连接,图标将显示为断开的链接,而不是显示的连接链接。

项目管理

一旦项目创建并有管理员,授权用户可以登录服务器并与项目一起工作。成功登录后,您将进入 Ghidra 项目窗口,其中您将可以访问您授权的项目。

这里谁才是老大?

服务器管理员负责创建 Ghidra 服务器帐户并配置连接到服务器的身份验证协议。服务器管理是一种完全基于命令行的活动,服务器管理员无需自己是 Ghidra 用户。在客户端,任何授权用户均可在 Ghidra 服务器上创建存储库,并自动成为他们创建的每个存储库的管理员。这使他们完全控制存储库,包括谁可以访问以及每个用户可以拥有的访问类型。创建后,管理员可以通过 Ghidra 的项目窗口向其他授权用户授予访问权限。

我不想分享

对于非共享项目,使用 Ghidra 服务器安装也有优势。您最初介绍了如何在单台计算机上安装 Ghidra,并使用该计算机访问您的项目和文件(所有这些都存储在该计算机上)。这意味着所有分析工作都依赖于该计算机。Ghidra 服务器支持从各种设备进行多点访问您的文件。您可以在访问文件之前要求进行身份验证,并且如果需要,可以将项目从非共享转换为共享。一个限制是您需要连接到 Ghidra 服务器才能签出或签入文件。

项目窗口菜单

现在我们已经创建并连接到 Ghidra 服务器,项目窗口中的选项变得更加有意义,因为一些先前不可用的选项现在具有新的上下文。在这里,以及在第十二章中,我们讨论了各个菜单组件及其如何用于改进您的分析过程。

文件

文件菜单显示在 图 11-4 中。文件菜单中的前五个选项是相当标准的文件类型操作,它们的行为与您从菜单驱动的应用程序中所期望的一样。我们将详细讨论用数字标记的显著选项。

image

图 11-4:文件菜单

删除项目

在 Ghidra 中删除项目 ➊ 是一种不可撤销的永久操作。幸运的是,这需要努力并需要确认。首先,您不能删除正在使用的活动项目。这最大程度地减少了意外删除的危险。要删除项目,您必须完成以下三个步骤:

  1. 从菜单中选择文件删除项目

  2. 浏览到(或输入名称)要删除的项目。

  3. 在结果确认窗口中确认您要删除该项目。

删除项目会删除其所有关联文件。因此,首先通过“归档当前项目”选项➋归档项目可能是明智的选择。

项目归档

归档项目允许您保存项目的快照、其关联文件和关联工具配置。归档项目的原因包括以下几点:

  • 你打算删除该项目,但希望保留一份副本“以防万一”。

  • 你想要为迁移到另一台服务器的项目打包。

  • 您希望能够轻松在 Ghidra 不同版本之间传输版本。

  • 您希望创建项目的备份。

按照以下步骤归档项目:

  1. 关闭 CodeBrowser 窗口和所有相关工具。

  2. 从菜单中选择文件归档当前项目

  3. 在您的本地机器上选择归档文件的位置和名称。

如果选择了现有文件的名称,您将有机会更改名称或覆盖现有文件。归档文件可以通过“恢复项目”选项轻松恢复。

批量导入

批量导入选项(在 图 11-4 的 ➌ 处)允许您在单个操作中将文件集合导入项目。当您选择文件 ▸ 批量导入时,Ghidra 显示一个类似于 图 11-5 中所示的文件浏览器窗口。此窗口允许您导航到包含您希望导入的文件的目录。

image

图 11-5:批量文件导入选择窗口,已选择文件

你可以从单个目录中选择文件(或多个文件),或选择整个目录,将其添加到批量导入列表中。在突出显示文件并点击“选择文件”按钮后,你将进入批量导入窗口,显示你已经选择用于导入的文件。在图 11-6 中,来自目录BinBatchImport1的文件作为单独文件加载,而目录BinBatchImport2则作为目录添加,右侧显示五个文件。你可以添加/删除文件以完善导入列表,并控制多个选项,包括在目录中查找文件时的递归深度。

要在批量导入窗口中确定适当的深度限制,或仅仅是浏览文件系统,可以使用“打开文件系统”菜单选项(见图 11-4 中的➍)。该选项会在单独的窗口中打开选定的文件系统容器(.zip文件、.tar文件、目录等)。(最好提前确定深度,因为如果你需要同时操作两个窗口,你将需要打开第二个 Ghidra 实例。在单一实例中,每个窗口会阻止对另一个窗口的访问。)

image

图 11-6:批量文件导入确认对话框

编辑

编辑菜单如图 11-7 所示。工具选项和插件路径选项将在第十二章中讨论,但 PKI 选项与 Ghidra 服务器的设置相关,因此值得在本章中进行讨论。

image

图 11-7:编辑菜单

PKI 证书

如本章开头所述,当你设置 Ghidra 服务器时,可以选择几种身份验证方法。我们设置了一个简单的服务器,使用用户名和密码进行身份验证。PKI 证书则更为复杂。虽然 PKI 的实现方式可能有所不同,但以下示例代表了一个合理的 Ghidra 服务器 PKI 客户端身份验证过程:

User1希望进行身份验证,以便她可以在 Ghidra 服务器项目上工作。她拥有一张包括用户名和公钥的客户端证书。她还持有与证书中公钥对应的私钥,并将其安全地存放,以便在此类重要场合使用。她的证书由一个受 Ghidra 服务器信任的证书颁发机构(CA)进行加密签名。

User1向服务器提供她的证书,服务器可以从中提取公钥和用户名。服务器进行检查以确认证书有效(例如,证书没有在证书撤销列表中、在有效的日期范围内、并且有受信任的证书颁发机构的有效签名,可能还有其他检查)。如果所有检查都通过,服务器确认证书有效并将User1的身份与公钥绑定。现在,User1需要证明她拥有相应的私钥,以便 Ghidra 服务器可以验证它与提取的公钥匹配。只要私钥确实仅由User1持有,Ghidra 服务器就会正确验证她的证书,服务器会验证User1确实拥有私钥,因此User1被认为已经通过身份验证。

管理 PKI 证书授权的过程可以在 Ghidra Server 的自述文件(server/svrREADME.html)中找到。设置 PKI 证书和清除 PKI 证书的菜单选项使用户能够将自己与密钥文件(.pfx.pks.p12)关联(或取消关联)。在设置 PKI 证书时,用户会获得一个文件导航窗口,用于识别合适的密钥库。证书可以随时通过“清除 PKI 证书”选项进行清除。如果选择启用 PKI 身份验证,Java 的keytool工具可以用于管理密钥、证书和 Java 密钥库。

项目

如图 11-8 所示,项目菜单提供了管理项目级活动的功能,包括查看和复制其他项目中的内容、更改密码,以及管理你所管理的项目的用户访问权限。

image

图 11-8:项目菜单

查看项目和代码库

前四个选项➊与查看项目和代码库有关。前两个选项“查看项目”和“查看代码库”会在与活动项目窗口相邻的新窗口中打开项目(本地)或代码库(远程服务器)的只读版本。在图 11-9 中,本地项目ExtraFiles已在活动项目旁边打开。你可以浏览该只读项目,或将“只读项目数据”窗口中的任何文件或目录拖动到活动项目窗口中。在图 11-9 中,三个被选中的文件(扩展名为NEW)已经从项目数据窗口复制到活动项目中:CH11

下一个选项“查看最近的”提供了一个最近项目的列表,可以加快定位项目或代码库的过程。关闭视图会关闭只读视图(尽管在某些版本的 Ghidra 中,这个选项似乎是无效的)。一个更简单可靠的替代方法是点击你想要关闭的项目标签底部的 X 按钮,如图 11-9 右下角所示。

image

图 11-9:使用项目窗口查看另一个项目

更改密码和项目访问权限

更改密码选项(位于图 11-8 中的 ➋)仅适用于共享项目中的用户,前提是 Ghidra 服务器已配置了允许密码更改的身份验证方法。这是一个两步过程,首先是初始确认对话框,如图 11-10 所示,接着是与初始强制密码更改相同的密码更改选项对话框。

image

图 11-10:密码更改初始确认对话框

虽然用户可以各自控制自己的密码,但共享项目还提供了控制谁可以访问项目以及授予每个用户哪些权限的能力。如本章前面所述,Ghidra 服务器系统管理员对访问权限有一定的控制权。具体而言,管理员可以为仓库分配管理员并创建和删除用户账户。

在客户端,如果您是管理员,还可以通过项目菜单中的“编辑项目访问列表”选项(位于图 11-8 中的 ➌)控制访问。当选择该选项时,您将看到图 11-11 中所示的对话框,该对话框允许您添加和删除项目中的用户,并控制他们的相关权限。每个用户只能被放置在一个特定的权限类中,从最少权限(左侧的只读)到最高权限(右侧的管理员)。

image

图 11-11:访问控制窗口

查看项目信息

最后的菜单选项是查看项目信息(位于图 11-8 中的 ➍)。结果对话框中提供的选项取决于项目是否托管在 Ghidra 服务器上。图 11-12 展示了非服务器型(左)和服务器型(右)项目信息对话框的示例。虽然显示的信息相当直观,但每个窗口底部的按钮允许您将非共享项目转换为共享项目(通过“转换为共享”按钮)或更改项目信息。

点击“转换为共享”按钮会打开一个对话框,要求您指定服务器信息并输入项目管理员的用户 ID 和密码。随后的步骤允许您指定一个仓库、添加用户、设置他们的权限,并确认是否要转换项目。请注意,此操作无法撤销,并且会删除所有现有的本地版本历史。

image

图 11-12:非共享和共享项目的项目信息窗口

项目仓库

到此为止,你可能会想,如何在保持项目完整性的同时共享项目?本节将介绍 Ghidra 用来确保每个人的工作在共享项目中得到保留的过程,团队可以并行工作。在深入讨论过程之前,让我们先了解与共享 Ghidra 项目相关的文件类型。我们将从讨论项目与仓库之间的关系开始。

仓库是版本控制过程的关键推动力。当你创建一个新的非共享项目时,会创建一个项目文件(.gpr 文件)和一个带有 .rep 扩展名的仓库目录,以便于版本控制。还会创建其他文件来控制锁定、版本控制等,但理解每个文件的目的对于成功使用 Ghidra 并非至关重要。对于非共享项目,所有文件都存储在你指定的目录中,这些目录位于项目创建时的计算机上(参见 第四章)。

当你创建一个共享项目时,你可以选择创建一个新的仓库,或者从现有仓库中选择,如本章前面所讨论的(参见 图 11-2)。如果你同时创建一个新项目和新仓库,那么项目和仓库之间会形成一对一的关系,而你成为项目管理员。如果你选择一个现有的仓库,你正在为一个你不是项目管理员的新项目创建(除非你拥有该仓库)。无论哪种情况,.gpr 文件和 .rep 目录共享相同的基本名称。如果仓库名为 RepoExample,则项目文件将命名为 RepoExample.gpr,仓库文件夹将命名为 RepoExample.rep。(尽管具有扩展名,仓库实际上是一个目录,而不是文件。)

总结一下:如果你创建了仓库,那么你就是项目管理员,可以选择其他谁可以访问你的仓库。如果你使用现有的仓库,那么你就是一个用户,拥有项目管理员分配给你的权限和特权。那么,当多个用户想要对同一个项目进行更改时,会发生什么呢?这就是版本控制发挥作用的地方。

版本控制与版本跟踪

Ghidra 包含两种非常不同的版本控制系统。在本章中,我们讨论的是版本控制,并希望这一概念很快能变得非常清晰。Ghidra 还具有 版本追踪 功能。版本追踪用于识别两个二进制文件之间的差异(和相似性)。在 SRE 社区中,这个过程通常称为 二进制差异比较。目标可能包括识别二进制文件不同版本中的更新、识别恶意软件家族使用的函数、识别签名等。由于相关的源代码可能不可用,因此源代码级别的差异比较无法进行,Ghidra 的版本追踪功能显得尤为重要。关于 Ghidra 版本追踪的更多细节将在第二十三章中讨论。

版本控制

版本控制 是任何可以由多个用户进行更改,或需要记录更改历史的系统中的一个重要概念。版本控制允许你管理系统的更新,有效控制竞争条件。项目窗口中有一个版本控制工具栏(图 11-13)。许多操作要求相关文件必须处于关闭状态才能完成该操作。

image

图 11-13:Ghidra 项目窗口版本控制工具栏

图标根据所选文件的有效性启用相应的版本控制操作。组成版本控制工作流程的基本操作显示在图 11-14 中。(我们还提供了一列,列出了所有 Git 爱好者大致的 Git 等效操作。)

image

图 11-14:Ghidra 版本控制工具栏操作

除了使用工具栏图标,你还可以通过右键点击上下文菜单执行版本控制操作。

合并文件

当协作团队成员决定将他们所做的更改提交到项目中时,以下两种情况之一将成立:

无冲突 在这种情况下,自用户检出文件以来,文件没有被检查出新的版本。由于不存在潜在冲突(没有用户未察觉的提交冲突),被检入的文件将成为文件的新版本。旧版本将以存档的形式保留,并且版本号将递增,以确保可以跟踪版本的连续链。

潜在冲突 在这种情况下,另一个用户在该用户检查文件时提交了新的更改。文件检查的顺序可能会影响最终的“当前版本”。在这种情况下,Ghidra 开始执行合并过程。如果提交中没有引入冲突,Ghidra 将继续进行自动合并。如果检测到冲突,必须由用户手动解决每个冲突。

作为一个冲突示例,假设user1user2都已经签出同一个文件,且user2FUN_00123456的名称更改为hash_something并提交了更改。与此同时,user1分析了同一个函数并将其重命名为compute_hash。当user1最终提交更改时(在user2之后),他们将被告知名称冲突,并被要求在hash_somethingcompute_hash之间选择正确的函数名,才能完成提交操作。关于此过程的更多信息,请参阅 Ghidra 帮助文档。

版本控制注释

当你在版本控制下添加或修改文件时,应添加一条注释解释你所做的更改。每次版本控制操作都会显示一个对话框,其中包含注释字段和一些特殊选项。图 11-15 显示了“添加文件到版本控制”注释对话框。

image

图 11-15:Ghidra 的“添加文件到版本控制”注释对话框

标题栏显示正在执行的操作,下面补充了与之关联的图标,并描述了你应在“注释”文本框中输入的内容。如果选择了多个文件,则任何注释将仅与第一个文件关联,除非点击了“应用到所有”按钮。在“注释”文本框下方是与正在执行的操作相关的特定选项,用户可以选择或取消选择这些选项。有关每个操作的特殊选项,请参见图 11-14 的第三列。

示例场景

共享项目涉及许多复杂的细节、选项和术语。为了澄清与 Ghidra 服务器和共享项目相关的一些概念,我们通过一个示例来展示我们所讨论的概念,从项目的概念开始。

一个项目是存在于客户端机器上的本地实体(就像本地的 Git 仓库)。共享项目还与 Ghidra 服务器上的仓库相关联(类似于 Git 远程仓库),该仓库存储所有协作分析工作的结果。在文件被导入并添加到版本控制后,它们会被共享,在此之前它们是私有的。因此,用户可以将文件导入项目,在此时它们是私有的,然后选择将它们添加到版本控制,这时它们变成共享的。

HELP! 我的文件被劫持了!

Ghidra 为共享项目环境中经常发生的情况提供了一个特殊的术语(及相关的项目数据树图标)。如果你在项目中有一个私有文件(已导入但尚未添加到版本控制中),并且另一个用户将同名的文件添加到仓库中,你的文件将被 劫持!这是一个非常常见的情况,因此 Ghidra 提供了一个右键菜单选项来处理这一情况。你需要关闭被劫持的文件,然后从右键菜单中选择撤销劫持选项。这样,你将有机会接受仓库中的文件,并在需要时保留自己的文件副本。解决劫持问题的其他选项包括重命名文件、将其移至其他项目或删除它。

实际上,项目权限实际上就是仓库权限。如果你使用现有仓库创建一个项目,实际上是在说,“这个项目在本地由服务器上的那个仓库远程支持”(就像 Git 克隆)。让我们通过一系列共享项目活动来观察它们如何影响共享项目环境:

  1. user1 创建了一个新的共享项目(及相关的新仓库)叫做 CH11-Example,并将 user2user3 添加到项目中,赋予他们权限(见图 11-16)。image

    图 11-16:示例场景,步骤 1

  2. user2 创建了一个与现有 CH11-Example 仓库相关联的新共享项目(即 user2 克隆了 CH11-Example)。注意,项目的名称与 user1 的项目不同,但仓库(远程)是相同的。此外,user2 在仓库中的权限显示在窗口底部(见图 11-17)。image

    图 11-17:示例场景,步骤 2

  3. user1 导入一个文件并将其添加到版本控制中,user2 随后也能看到该文件(大致等同于 git add/commit/push)。如图 11-18 所示。image

    图 11-18:示例场景,步骤 3

  4. user1user2 然后分别将相同的文件导入到各自的项目中,但没有将它们添加到版本控制中。这些是私有文件(见图 11-19)。image

    图 11-19:示例场景,步骤 4

  5. user2 将第二个文件添加到版本控制中(这会将其提交)。结果,文件不再是私有的。user1 现在将其视为被劫持的文件(见图 11-20)。image

    图 11-20:示例场景,步骤 5

  6. user1 选择了右键菜单中的“撤销劫持”选项,并可以选择用代码库中的版本替换她的文件,并保留自己的文件副本(如果需要)。她选择接受代码库版本并保留自己的文件副本(她已将其移至另一个项目,现在该文件的扩展名为.keep)。现在一切恢复正常。在这种情况下,user1 现在看到的是第二个文件的状态,即当 user2 将其添加到版本控制时的状态(见图 11-21)。image

    图 11-21:示例场景,第 6 步

  7. user1 检出了第二个文件,进行了分析,然后提交了该文件。现在,user1user2 都能看到该文件的分析版本(版本 2),如图 11-22 所示。image

    图 11-22:示例场景,第 7 步

  8. user3 创建了一个项目并将其与相同的代码库关联(见图 11-23)。user3 现在可以看到所有文件,并且可以在本地进行更改(包括添加私有文件),但没有提交到代码库的选项,因为她没有被授予写入权限。(该项目在窗口底部标注为“只读”。)image

    图 11-23:示例场景,第 8 步

  9. user2 在下班前提交了所有文件。这一点很重要,因为她知道自己希望通过在家用电脑上继续工作。由于该项目在她的家用电脑上不存在,她需要登录到 Ghidra 服务器并使用现有的代码库创建一个新项目。这将在她的家用电脑上创建该项目,并且她可以继续工作。(如果她在离开工作时没有提交所有文件,她将无法在家中访问到最新的工作。)

  10. 其余的用户回家时确信他们的协作 Ghidra 服务器正在按预期工作。

总结

并不是每个用户都需要 Ghidra 服务器或共享项目来促进协作式逆向工程,但许多相关的功能也可以应用于非共享项目。接下来的章节将集中讨论非共享项目,并在适当时提到共享项目和 Ghidra 服务器。无论你的 Ghidra 安装配置如何,都有可能默认配置、工具和视图并不完全适合你的工作流。下一章将重点讨论 Ghidra 的配置、工具和工作区,以及如何让它们更好地适应你的需求。

第十五章:自定义 Ghidra**

Image

在使用 Ghidra 一段时间后,你可能会偏好某些设置,希望在每次打开新项目时使用这些设置作为默认,或者希望将其应用于特定项目中的所有文件。此时,你可能会感到困惑,为什么有些你更改的选项会在会话之间持续存在,而其他选项则每次加载新项目或文件时都需要重新设置。在本章中,我们将探讨如何自定义 Ghidra 的默认外观和行为,以更好地满足你的逆向工程需求。

为了理解一些自定义设置的范围,理解插件工具之间的(模糊)区别是很有用的。从一般意义上讲,以下是成立的:

插件 插件是一个软件组件(例如,字节查看器、列表窗口等),它为 Ghidra 添加功能。插件通常表现为窗口,但许多插件在后台执行工作(例如,分析器)。

工具 工具可以是单个插件或一组协同工作的插件。它们通常表现为一个有用的图形用户界面(GUI),帮助用户完成任务。我们一直在使用的一个工具——CodeBrowser,是一个作为 GUI 框架的窗口。功能图也是一个工具。

如果这些定义没有严格遵循,也不要慌张。在许多情况下,区分两者并不重要。例如,后面将讨论的工具选项菜单中,包括了一些可以同时应用于工具和插件的选项,尽管使用的是工具一词。在这种情况下,以及其他许多情境中,区分它们并不重要,因为它们被视为相同。即使术语的使用有所不同,你也应该能够成功地完成自定义过程。

除了 Ghidra 的自定义设置外,我们还将讨论 Ghidra 的工作空间,以完整本章内容。工作空间将工具与配置结合,提供设计和使用个性化虚拟桌面的功能。

CodeBrowser

在第四章和第五章中,我们介绍了 CodeBrowser 及其许多相关窗口。我们已经涵盖了一些基本的自定义选项;现在我们将在继续介绍 Ghidra 项目窗口和工作空间之前,详细讲解 CodeBrowser 的自定义示例。

重新排列窗口

以下六个基本操作可以帮助你控制各个窗口在 CodeBrowser 窗口中的显示位置:

打开 窗口通常通过 CodeBrowser 的窗口菜单打开。每个窗口都有默认设置,决定它打开的位置。

关闭 窗口可以通过点击窗口右上角的 X 来关闭。(如果重新打开一个已关闭的窗口,它会出现在与关闭时相同的位置,而不是原来的默认位置。)

移动 通过拖放的方式移动窗口。

堆叠 使用拖放功能堆叠和取消堆叠窗口。

调整大小 将鼠标悬停在两个窗口之间的边框上,会出现一个箭头,允许你扩大或缩小与边框相邻的窗口。

取消停靠 你可以将工具从 CodeBrowser 窗口中取消停靠,但重新停靠并不像你想象的那么简单,如图 12-1 所示。

image

图 12-1:重新停靠反汇编窗口

要重新停靠窗口,你不能点击标题栏 ➊,因为那样会把窗口拖动到 CodeBrowser 前面。相反,点击内部标题栏 ➋ 来重新停靠或堆叠窗口。现在我们可以重新排列窗口,让我们通过使用“编辑 ▸ 工具选项”菜单来自定义窗口本身。

编辑工具选项

当你选择“编辑 ▸ 工具选项”时,会打开一个 CodeBrowser 选项窗口,如图 12-2 所示。这个窗口允许你控制与单个 CodeBrowser 组件相关的选项。

可用的选项由每个组件的开发者决定,且可用选项之间的显著差异反映了各个工具的具体性质。由于描述每个可用工具选项将占据整本书的篇幅,我们将重点看一些影响我们在前面章节中讨论过的工具的编辑内容,以及一些适用于许多工具的相似编辑。

image

图 12-2:默认的 CodeBrowser 编辑 ▸ 工具选项窗口

尽管在灰度模式下可能不明显,许多工具使用颜色来识别属性,并且相关的颜色调色板是可配置的。点击选项窗口中的默认颜色将打开一个标准的颜色编辑器对话框,如图 12-3 中的 Byte Viewer 选项面板所示。这为你提供了控制 CodeBrowser 中各种项目颜色的选项。

image

图 12-3:编辑 ▸ 工具选项颜色编辑器对话框

在图 12-3 中,你可以为 Byte Viewer 窗口中的六个项目选择颜色:块分隔符、当前视图光标、光标、编辑光标、高亮光标行和非焦点光标。除了在 Byte Viewer 窗口中自定义颜色外,你还可以选择字体,并选择高亮显示光标行。方便的是,任何 CodeBrowser 工具的选项面板中都包含一个右下角的“恢复默认值”选项。这使得你可以在某些分析步骤中使用特殊的颜色方案,完成后再恢复工具的默认颜色方案。

除了外观上的变化,许多工具还提供了在编辑选项中设置参数的能力。我们在之前的章节中介绍新功能时,也暗示了这一潜力,例如控制哪些分析器包含在自动分析中的能力。一般来说,任何有默认值的地方,都有方法将其更改为其他选项。

某些通用工具的设置也可以通过选项窗口访问和修改。例如,按键绑定用于指定 Ghidra 操作和热键序列之间的映射,在默认的 CodeBrowser 窗口中有超过 550 个操作,你可以通过选项窗口创建或重新分配热键绑定。热键重新分配在许多情况下都很有用,包括通过热键使额外的命令可用、将默认序列更改为更容易记住的序列、以及更改可能与操作系统或终端应用程序使用的其他序列冲突的序列。你甚至可以将所有热键重新映射为与其他反汇编器相同的热键。

每个热键绑定都与三个字段相关,如图 12-4 所示。第一个字段是操作名称。在某些情况下,操作名称对应于菜单命令(例如,分析 ▸ 自动分析)。在其他情况下,它是与菜单命令相关联的参数(例如,分析选项中的 Aggressive Instruction Finder)。

image

图 12-4:编辑 ▸ 工具选项 热键绑定选项

第二列是与操作相关联的实际按键绑定(热键)。最后一列包含实现该操作的插件名称。^(1) 并非所有操作都有关联的热键,但你可以通过选择一个操作并在文本框中输入所需的热键来轻松分配热键。如果该热键已经与其他操作关联,系统会显示该热键的所有其他用途。当你使用具有多个键绑定的热键时,系统会提供一个潜在操作列表,你需要选择合适的操作。

编辑工具

在 编辑 ▸ 工具选项 窗口的底部,有一个名为 Tool 的选项。Tool 的含义取决于用于打开选项对话框的工具菜单。通常,这将是 CodeBrowser 或项目窗口。图 12-5 显示了 CodeBrowser 工具的默认配置选项。选项对话框的标题栏提供了我们正在查看 CodeBrowser 选项页面的最明显线索。

image

图 12-5:使用 编辑 ▸ 工具选项 ▸ 工具 编辑 CodeBrowser 选项

特殊工具编辑功能

一些工具在各自的窗口中集成了编辑功能,以便你可以立即看到选项对关联内容的影响。最广泛的内置编辑功能可以在“列出窗口”中找到。列出窗口包含反汇编的文本内容,并且可以通过在第 133 页的“更改代码显示选项”中介绍的浏览器字段格式化器进行高度配置。图 12-6 显示了一个默认的浏览器字段格式化器打开的列出窗口。

image

图 12-6:带有默认浏览器字段格式化器的列出窗口

在格式化器的顶部出现了一排标签 ➊,表示反汇编中存在的各种字段类型。在这种情况下,我们正在查看指令,因此选择了指令/数据标签。格式化器的其余部分 ➋ 显示与指令/数据部分中地址相关的每个单独字段的条形图。在这种情况下,光标位于列出窗口中的某个地址,因此地址字段被高亮显示。

你可以使用浏览器字段格式化器更改列出窗口的外观。其功能非常广泛,每个字段都有其相关的选项。我们将只研究一些较简单的功能,许多功能类似于编辑代码浏览器中窗口外观的操作。你可以通过拖动字段到新的位置来重新排列字段;增大或减小字段的宽度;并添加、删除、启用或禁用个别字段。

图 12-7 显示了移除字节字段后的相同列出内容。在之前的章节中,我们已经移除了字节字段,以便简化列出内容并在可用空间中显示更多的内容。

image

图 12-7:带有自定义浏览器字段格式化器选择的列出窗口

保存代码浏览器布局

关闭代码浏览器时,你可以保存与文件相关的任何布局更改。或者,你也可以选择不保存退出,这将生成一条警告消息,确保你理解其中的含义。如果你在代码浏览器窗口中使用“文件 ▸ 保存工具”选项,则当前的代码浏览器外观将与当前文件关联,并存储在活动项目中。下次打开该文件时,Ghidra 将使用保存的代码浏览器布局。当你同时打开多个代码浏览器实例并且修改了其中一些(或全部)时,可能会导致工具配置冲突。此时,Ghidra 会显示一个新的保存工具对话框,如图 12-8 所示。

image

图 12-8:Ghidra 的保存工具—可能的冲突对话框

在本章的后面,我们将向你展示如何使用此功能以及类似的自定义功能,来创建一个强大的工具套件,这些工具是针对你的反向工程任务和个人喜好进行调优的。

Ghidra 项目窗口

让我们换个思路(或者换个窗口)回到 Ghidra 项目窗口,如图 12-9 所示。主菜单在前一章已经讨论过。在讨论项目窗口的自定义之前,让我们先看看窗口中两个尚未讨论的区域。

image

图 12-9:Ghidra 项目窗口

工具箱 ➋ 显示了所有能够操作你导入到项目中的二进制文件的工具图标。默认情况下,提供两个工具。龙形图标是 CodeBrowser 的默认图标,足迹图标与 Ghidra 的版本控制工具相关联。在本章稍后的部分,我们将演示如何通过修改和导入工具以及构建我们自己的工具来补充工具箱。

正在运行的工具 ➌ 包含每个正在运行的工具实例的图标。在这个例子中,我们已将每个项目文件在单独的 CodeBrowser 窗口中打开。因此,目前有四个 CodeBrowser 实例在运行。点击任何一个正在运行的工具图标将把相应的工具调到桌面前台。

让我们返回到 Ghidra 项目窗口菜单 ➊,查看一些自定义窗口的选项。我们将从调查图 12-10 中显示的 Ghidra 项目四个编辑 ▸ 工具选项开始。两个选项与 CodeBrowser 中的相同:键绑定和工具。

在图 12-10 中,选择了键绑定选项。与 CodeBrowser 工具相比,Ghidra 项目工具的操作显著较少,因此键绑定的选项也较少。如果你在家里跟着操作,可能会注意到大部分操作都与 FrontEndPlugin 相关。(Ghidra 项目工具也称为 Ghidra 前端,在整个 Ghidra 环境中,包括 Ghidra 帮助文档,都会交替使用这两个术语。)

image

图 12-10:Ghidra 项目窗口(即 Ghidra 前端),通过编辑 ▸ 工具选项

Eclipse 集成是第十五章的重点,因此我们暂时会推迟讨论这个选项。恢复选项允许你设置快照的频率,默认值是 5 分钟。将此值设置为 0 将禁用快照。

最后的选项,工具,可能会很有趣,可以尝试一下。如本章前面所述,术语工具在此上下文中指的是活动工具。在此情况下,它是 Ghidra 项目工具。相关选项如图 12-11 所示,我们将重点关注 Swing 外观与感觉和使用反转颜色选项,这些选项会改变 Ghidra 窗口的外观。

image

图 12-11:Ghidra 项目工具编辑选项

将“使用反转颜色”和选择“金属”作为 Swing 外观和感觉相结合,会产生一个深色主题,这在许多逆向工程师中非常流行。你所做的更改将在重启 Ghidra 后生效,新样式将用于所有 Ghidra 窗口,包括 CodeBrowser 和 Decompiler。下图展示了部分 CodeBrowser 窗口 图 12-12。

image

图 12-12:使用深色主题的 CodeBrowser 窗口部分

现在你已经知道如何改变 Ghidra 的外观和感觉以更好地符合你的个人风格,让我们回到文件菜单,探讨在该上下文中“配置”意味着什么。File ▸ Configure 选项展示了 Ghidra 插件集合的三类,如 图 12-13 所示。每个类别都有不同的用途。

Ghidra Core 包含我们在默认 Ghidra 配置中使用的一组插件。这些插件提供了逆向工程所必需的基本功能。Developer 类别提供的插件可以帮助你开发新的插件。如果你想了解更多关于 Ghidra 开发的信息,这是一个很好的起点。最后一组插件是 Experimental。这些插件尚未经过彻底测试,可能会导致 Ghidra 实例不稳定,因此使用时请小心。

image

图 12-13:Ghidra 项目配置选项

虽然默认 Ghidra 安装时只有 Ghidra Core 被启用,但你可以勾选其他选项旁边的框以启用它们。使用类别下方的 Configure 选项来选择(或取消选择)类别列表中显示的各个插件。图 12-14 显示了 Ghidra Core 插件列表,包括每个插件的描述和类别。如果你点击该菜单中的某个 Ghidra 插件,屏幕底部的窗口会提供关于该插件的更多信息。

还有两个额外的 Ghidra 项目菜单选项可用于 Ghidra 配置。第一个是 File ▸ Install Extensions,我们将在 第十五章 讨论。另一个选项是 Edit ▸ Plugin Path,它允许你添加、修改和删除新的用户插件路径,这些路径告诉 Ghidra 在其默认安装的 Java 类之外查找其他类。通过这个选项,你可以将额外的插件和类包含到 Ghidra 实例中。编辑插件路径后需要重启 Ghidra 才能看到效果。

image

图 12-14:选择了 ImporterPlugin 的 Ghidra Core 配置窗口

现在你已经了解了修改插件选项的潜力,我们可以继续扩展插件的使用。工具菜单选项允许你执行与工具相关的操作,包括创建新工具(如果现有工具无法完全满足你的需求)。在这种情况下,我们将构建并使用现有插件集合的工具,而不是从头编写插件。

工具

大多数工具选项都可以在 Ghidra 项目窗口的工具菜单中找到,如图 12-15 所示。到目前为止,你一直在使用和修改默认工具 CodeBrowser,作为你的主要分析工具。接下来,我们将演示如何在 Ghidra 中创建自定义工具。

image

图 12-15:Ghidra 工具菜单选项

如果你曾经尝试修改 CodeBrowser 工具,可能会因为默认工具在你打开后续文件时被修改而感到沮丧。让我们考虑一个特殊的情况:你想要检查一个包含许多函数调用的文件,这个文件导航起来比较复杂。在第十章中,我们演示了如何使用函数调用图和函数图来帮助你理解程序的控制流。这两种图表都会在各自的窗口中打开,如果你打开了很多文件,这可能会造成一些挑战。我们将通过一个名为 ExamineControlFlow 的专用工具来解决这些问题,它可以帮助你分析程序中的控制流。

当你选择“工具 ▸ 创建工具…”菜单选项时,你将看到两个窗口(如图 12-16 所示)。图中的上窗口展示了类似于图 12-13 中看到的插件选项,但增加了一个新的类别:功能 ID,详细内容请参见第十三章。图中的下窗口是一个空白的、未命名的工具开发窗口,你可以自定义该窗口以创建你的工具——ExamineControlFlow。

image

图 12-16:Ghidra 配置工具窗口

你可以通过使用 Ghidra Core 中的插件来组合你的新工具。当你选择 Ghidra Core 类别时,你的工具开发窗口将填充来自 Ghidra Core 的选项,如图 12-17 所示。结果窗口与 CodeBrowser 很相似,这是因为 CodeBrowser 也是基于 Ghidra Core 开发的。

image

图 12-17:配置前的新工具,未命名

你需要移除一些你不想在新工具中使用的插件,然后指定你想要的窗口。点击 Ghidra Core 下的 配置 选项,并删除以下你不需要的插件(你还可以删除其他插件,但为了简洁起见,我们只删除了这些):

  • 控制台

  • DataTypeManagerPlugin

  • EclipseIntegrationPlugin

  • ProgramTreePlugin

每个工具都与其他插件相关联,因此,当你从新工具中移除每个插件时,Ghidra 会显示一条警告信息,列出正在被移除的附加插件。你可以随时通过在新工具中选择“文件”▸“配置”来将插件重新添加。移除 DataTypeManagerPlugin 时的警告信息示例显示在图 12-18 中。

image

图 12-18:移除 DataTypeManagerPlugin 时的插件依赖警告

你还可以控制新工具的布局。在这种情况下,你希望能在同一工具中看到 Listing、Function Call Graph 和 Function Graph 窗口。按照前几章中描述的技巧,你可以通过新工具中的“窗口”菜单打开所需的窗口,然后将它们拖动到期望的位置。新创建的未命名工具显示在图 12-19 中。

image

图 12-19:新创建的未命名工具

由于你计划频繁使用这个工具并与合作伙伴共享,应该通过选择“文件”▸“另存为工具”来保存此工具,这样你可以为工具命名并为其关联一个图标(见图 12-20)。你可以从提供的图标中选择,或者选择你自己的图像文件(例如.jpg.png.gif等格式)。

image

图 12-20:新工具的图标选项

这个新工具(以及你创建的其他工具)将成为工具箱的一部分,并在你的项目中显示为一个选项,如图 12-21 所示。

若要与他人共享新工具,可以使用“工具”▸“导出工具”进行导出。Ghidra 会要求你选择一个文件夹来保存工具,并创建一个包含工具规格的.tool文件。若要导入工具,请使用“工具”▸“导入工具”选项。

image

图 12-21:新项目中显示在工具箱中的新工具选项

在 Ghidra 项目窗口中双击文件默认会将其打开在 CodeBrowser 中,但你也可以通过右键点击文件,选择工具箱中的任意工具,或者将文件名拖动并放到某个工具上来选择其他工具。

使用 Ghidra 的时间越长,你会越意识到没有一个统一的 Ghidra 界面可以为你提供完成每项逆向工程任务所需的所有工具。作为逆向工程师,分析特定文件的方法很大程度上取决于文件本身、分析目标以及实现目标的进度。

本章及前几章的大部分内容都致力于描述如何更改 Ghidra 的外观和功能,并根据你的需求调整可用工具。定制 Ghidra 的最后一步是能够保存你所创建的这些配置,以便你可以根据所进行的分析项目选择正确的配置。这是通过创建和保存 Ghidra 工作区来实现的。

工作区

Ghidra 的工作区可以看作是一个虚拟桌面,其中包含当前配置的工具和相关的文件。假设你正在分析一个二进制文件。在查看文件时,你注意到它具有与上周分析的另一个文件相似的特征。你想比较这两个文件,以识别它们之间函数的相似性,但你也希望继续分析当前的文件。这是两个具有共同文件的独立问题。

一种同时进行这两条路径的方法是为每个分析问题创建一个工作区。你可以通过在 Ghidra 项目窗口中选择“项目 ▸ 工作区 ▸ 添加”,并为新工作区命名来保存当前的分析。在这个例子中,我们将这个工作区命名为文件分析。然后,你可以从工具箱中打开另一个工具,并可能使用一个专门的工具,利用差异视图比较这两个文件(见第二十三章),然后使用相同的方法创建第二个工作区(文件比较)。现在,你可以通过在图 12-22 中显示的下拉菜单中选择工作区,或通过使用“项目 ▸ 工作区”菜单中的“切换”选项,轻松切换工作区,这将循环显示所有可用的工作区。

image

图 12-22:Ghidra 项目窗口中的工作区选项

总结

在开始使用 Ghidra 时,你可能对其默认行为和默认的代码浏览器布局感到非常满意。然而,随着你对 Ghidra 基本功能的熟悉,你一定会找到定制 Ghidra 以适应你的逆向工程工作流的方法。尽管没有办法通过单独一章提供 Ghidra 所有可能选项的完整覆盖,但我们已经介绍并提供了你在 SRE 过程中最有可能需要的定制功能的示例。发现额外有用的工具和选项将留给好奇的读者去探索。

第十六章:扩展 Ghidra 的世界观**

Image

我们希望从高质量的逆向工程工具中获得的一项功能是能够完全自动地识别并注释尽可能多的二进制内容。在理想情况下,100% 的指令会被识别,并归入构成二进制文件的 100% 原始函数中。这些函数每一个都会有名称和完整的原型,所有由函数操作的数据也会被识别,包括对程序员使用的原始数据类型的完全理解。这正是 Ghidra 的目标,从初始导入二进制文件开始,经过自动分析,Ghidra 无法完成的任何部分将变成用户的任务。

在本章中,我们将探讨 Ghidra 用于识别二进制文件中各种结构的技术,并讨论如何增强其识别能力。我们首先讨论初始加载和分析过程。你在这些步骤中的选择有助于确定 Ghidra 为你分析的文件提供哪些资源。这是一个向 Ghidra 提供它可能未能自动检测到的信息的机会,这样 Ghidra 的分析阶段就能做出更明智的决策。随后,我们将讨论 Ghidra 如何利用字模型、数据类型和函数识别算法,以及如何增强这些算法以根据你的特定逆向工程应用来定制其性能。

导入文件

在导入过程中,显示在图 13-1 中的对话框呈现了 Ghidra 对文件身份的初步分析,这将指导文件加载过程。你可以覆盖任何字段,或者按照 Ghidra 的建议继续操作。通过“选项...”按钮访问的附加选项是特定于所加载文件类型的。图 13-1 显示了 PE 文件的选项,图 13-2 显示了加载 ELF 二进制文件的选项。

image

图 13-1:PE 文件的导入对话框和选项

image

图 13-2:ELF 二进制文件的导入对话框和选项

语言/编译器规格

图 13-1 和 13-2 中的语言字段决定了 Ghidra 如何解释在你加载的文件中识别为机器代码的字节。语言/编译器规格由三个到五个冒号分隔的子字段组成,如下所述:

  • 处理器名称字段指定了二进制文件构建所使用的处理器类型。它将 Ghidra 定向到 Ghidra/Processors 目录下的特定子目录。

  • 字节序字段表示二进制文件处理器的字节序,它可以是小端字节序(LE)或大端字节序(BE)。

  • 架构大小(位数)字段通常与所选处理器的指针大小一致(16/32/64 位)。

  • 处理器变种/模式字段用于选择所选处理器的特定型号或标识特定的操作模式。例如,当选择 x86 处理器时,我们可以选择系统管理模式、实模式、保护模式或默认模式。对于 ARM 处理器,我们可以选择 v4、v4T、v5、v5T、v6、Cortex、v7、v8 或 v8T 等型号。

  • 如果已知,编译器字段会列出用于编译二进制文件的编译器,或者在某些情况下,列出用于编译的调用约定。有效的名称包括windowsgccborlandcppborlanddelphidefault

图 13-3 将语言标识符 ARM:LE:32:v7:default 分解为其组成的子字段。加载器最重要的任务之一就是推断正确的语言/编译器规范。

image

图 13-3:语言/编译器规范示例

格式选项指定 Ghidra 用于导入文件的加载器。Ghidra 依赖于加载器对特定文件格式的详细了解,以识别文件的特征并选择用于分析的正确插件。一个写得很好的加载器能够识别文件的类型、架构,并且希望能够识别用于创建该二进制文件的编译器。有关编译器的信息有助于增强函数识别。为了指纹识别编译器,加载器会检查二进制文件的结构,寻找特定于编译器的特征(例如,程序段的数量、名称、位置和顺序),或搜索二进制文件中的特定编译器字节序列(如代码块或字符串)。例如,在使用gcc编译的二进制文件中,找到版本字符串并不罕见——例如,GCC: (Ubuntu 7.3.0-27ubuntu1~18.04) 7.3.0

当 Ghidra 完成加载过程后,会显示一个导入结果摘要窗口,如图 13-4 所示。

image

图 13-4:ELF 二进制文件的导入结果摘要窗口

此摘要标识了一个 ELF 必需的库,lib.so.6 ➊。(请注意,如果该文件是静态链接的,这个库不会作为依赖项列出。)当可执行文件依赖多个共享库时,可能会列出多个库文件。了解程序依赖哪些库可以帮助你在分析程序时找到可能需要的资源。例如,如果libssl.solibcrypto.so 出现在所需库列表中,你可能需要查找 OpenSSL 文档并可能需要源代码。本章后面会讨论 Ghidra 如何使用源代码。一旦文件成功导入,你可以自动分析该文件。

分析器

自动分析是通过一组协同工作的分析工具(分析器)完成的,这些工具可以手动激活(例如,当打开一个新文件时),或者在检测到可能影响最终反汇编的更改时自动激活。分析器按优先级顺序依次运行,因为分析器所做的更改可能会影响后续的分析器。例如,堆栈分析器在函数分析器查看所有调用并创建函数之前无法查看函数。我们将在第十五章中更详细地探讨这个层次结构,当我们构建一个分析器时。

当你在 CodeBrowser 中打开一个新文件并选择自动分析时,Ghidra 会显示一个可以在该文件上运行的分析器列表。默认和可选分析器的列表取决于加载器提供的文件信息(这些信息也作为导入摘要的一部分显示给用户,如图 13-4 所示)。例如,Windows x86 PE RTTI 分析器在分析 ELF 或 ARM 二进制文件时就不太有用了。默认的分析器选择可以通过“编辑 ▸ 工具选项”菜单进行修改。

一些分析器也可以通过使用 CodeBrowser 中的“分析 ▸ 一次性”菜单作为一次性选项使用。如果分析器支持一次性使用并且适用于正在分析的文件类型,它就会出现在列表中。一次性分析对于运行在初始自动分析过程中未选择的分析器非常有用,或者在找到新信息可能从额外分析中受益时重新运行分析器。例如,如果在初始分析过程中收到缺少 PDB 的错误消息,你可以找到 PDB 文件,然后运行 PDB 分析器。

CodeBrowser ▸ 分析菜单中的“分析所有打开的文件”选项一次性分析项目中所有打开的文件,使用在“编辑 ▸ 工具选项”中选择的分析器列表。如果项目中所有打开的文件具有相同的架构(语言/编译器规范),则所有文件都将被分析。任何与当前文件架构不匹配的文件将不会包含在分析中。这确保了分析器与正在分析的文件类型一致。

许多 CodeBrowser 工具,包括分析器,依赖于各种工件来识别文件中的重要结构。幸运的是,我们可以扩展这些工件来增强 Ghidra 的功能。我们将从讨论单词模型文件以及它们如何用于在搜索结果中识别特殊字符串和字符串类型开始。

单词模型

词模型提供了一种识别你感兴趣的特殊字符串和字符串类型的方式,例如已知的标识符、电子邮件地址、目录路径名、文件扩展名等等。当你的字符串搜索与词模型相关联时,字符串搜索结果窗口将包含一个名为 IsWord 的列,用于指示找到的字符串是否根据词模型被视为一个词。将感兴趣的字符串定义为有效的词,然后筛选有效的词是一个很好的方法,可以优先考虑对这些字符串进行进一步检查。

从高层次来看,词模型使用有效字符串的训练集来确定“如果三元组 X(一个由三个字符组成的序列)出现在长度为 Z 的序列 Y 中,那么 Y 是一个词的概率为 P”。该概率被间接地用作阈值,以确定在分析过程中是否应将字符串视为有效词。

StringModel.sng,如图 13-5 所示,是 Ghidra 中字符串搜索的默认词模型文件。

image

图 13-5:字符串搜索对话框

以下是StringModel.sng文件的一个摘录,展示了有效词模型文件的格式:

➊ # Model Type: lowercase

➋ # Training file: contractions.txt

  # Training file: uniqueStrings_012615_minLen8.edited.txt

  # Training file: connectives

  # Training file: propernames

  # Training file: web2

  # Training file: web2a

  # Training file: words

➌ # [^] denotes beginning of string

   # [$] denotes end of string

   # [SP] denotes space

   # [HT] denotes horizontal tab

➍ [HT]    [HT]    [HT]    17

   [HT]    [HT]    [SP]    8

   [HT]    [HT]    (  1

 [HT]    [HT]    ;  1

   [HT]    [HT]    \  25

   [HT]    [HT]    a  2

   [HT]    [HT]    b  1

   [HT]    [HT]    c  1

文件中的前 12 行是关于模型的元数据注释。在这个例子中,模型类型➊是lowercase,这可能意味着模型不区分大小写字母。用于此模型的训练文件的名称列在➋处。文件名称通常表明内容:contractions.txt很可能是一个有效缩写的文件,例如can’t。四行注释➌描述了在三元组中使用的一些不可打印 ASCII 字符的符号。实际的三元组列表从➍开始,每一行条目包含三元组中的三个字符,后面跟着一个值,用于确定该三元组是否为一个词的一部分的概率。

你可以通过编辑StringModel.sng或创建新的模型文件并将它们存储在Ghidra/Features/Base/data/stringngrams中,来补充或替换默认的词模型,然后在字符串搜索对话框的词模型字段中选择新文件。有很多理由修改词模型,比如包括特定于已知恶意软件家族的字符串,或检测英语以外语言中的词汇。最终,词模型提供了一种强大的手段,通过在字符串窗口中标记它们来控制 Ghidra 识别为更高优先级的字符串类型。

以类似的方式,我们可以编辑并扩展 Ghidra 识别的数据类型。

数据类型

数据类型管理器允许我们管理与文件相关的所有数据类型。Ghidra 通过将数据类型定义存储在数据类型归档文件中,允许你重用数据类型定义。数据类型管理器窗口中的每个根节点都是一个数据类型归档。图 13-6 显示了一个数据类型管理器窗口,其中选择了三个数据类型归档。

image

图 13-6:数据类型管理器窗口

BuiltInTypes 档案始终会列出。该档案包括所有(且仅包括)由实现ghidra.program.model.data.BuiltInDataType接口的 Java 类在 Ghidra 中建模的类型。Ghidra 会在其类路径中查找每个这样的类,以便填充此档案。

第二个档案是特定于正在分析的文件的,且该档案与文件名相同。在这种情况下,档案与文件global_array_demo_x64相关联。档案旁边的勾选框表示它与当前活动文件相关联。最初,Ghidra 会使用特定于文件格式的数据类型(例如,PE 或 ELF 相关的数据类型)填充该档案。在自动分析过程中,Ghidra 会将其他档案中的数据类型复制到此档案中,前提是它们被识别为当前程序中正在使用的数据类型。换句话说,这个档案包含了所有已知数据类型管理器的子集,这些数据类型恰好在当前程序中使用。这个档案也是你在 Ghidra 中选择创建的任何自定义数据类型的存放地,具体内容可以参考《使用 Ghidra 创建结构体》,详见第 166 页。

第三个档案提供了 64 位 ANSI C 函数原型和 C 库数据类型。这个特定的档案包含从 64 位 Linux 系统的标准 C 库头文件中提取的信息,是默认 Ghidra 安装中的几个平台特定档案之一。之所以存在,是因为这个特定的二进制文件有一个库依赖,依赖于libc.so.6,如图 13-4 所示。默认的 Ghidra 安装有四个额外的特定平台数据档案,这些档案位于Ghidra/Features/Base/data/typeinfo目录下的一个平台特定的子目录中。文件名指示它们支持的平台:generic_clib.gdtgeneric_clib_64.gdtmac_osx.gdtwindows_vs12_32.gdtwindows_vs12_64.gdt。(.gdt扩展名用于所有 Ghidra 数据类型档案。)

除了 Ghidra 加载器自动选择的档案外,你还可以将自己的数据类型档案添加为数据类型管理器窗口中的节点。为了演示,图 13-7 展示了在所有默认的.gdt文件被添加到数据类型列表后,数据类型管理器窗口的样子。图的右侧显示了用于操作档案和数据类型的菜单。额外的档案通过“打开文件档案”菜单选项加载,该选项会打开文件浏览器供你选择感兴趣的档案。

要将新的内置类型添加到 BuiltInTypes 归档中,请将相应的.class文件添加到 Ghidra 的类路径中。如果在 Ghidra 运行时添加类型,您必须刷新 BuiltInTypes(见图 13-7),才能使它们显示出来。刷新操作会导致 Ghidra 重新扫描其类路径,以查找任何新添加的BuiltInDataType类。好奇的读者可能会在其 Ghidra 源代码分发包中找到大量内置类型的示例,路径为Ghidra/Framework/SoftwareModeling/src/main/java/ghidra/program/model/data

image

图 13-7:数据类型管理器,所有标准归档已加载,选项菜单已展开

创建新的数据类型归档

在分析二进制文件时,无法预见您可能遇到的所有数据类型。您在 Ghidra 分发包中包含的归档包含了来自 Windows(Windows SDK)和 Unix(C 库)系统中最常用库的数据类型。当 Ghidra 未包含您正在分析的程序中使用的数据类型信息时,它提供了创建新数据类型归档的功能,可以通过多种方式填充它们,并与其他人共享。在接下来的章节中,我们将讨论您可能创建新数据类型归档的三种方式。

解析 C 头文件

数据类型信息最常见的来源之一是 C 头文件。假设您拥有所需的头文件,或者花时间自己创建它们,您可以使用 C-Parser 插件从现有的 C 头文件中提取信息,创建自己的数据类型归档。例如,如果您经常分析与 OpenSSL 加密库链接的二进制文件,您可以下载 OpenSSL 源代码,并要求 Ghidra 解析其中的头文件,以创建一个包含 OpenSSL 数据类型和函数签名的归档。

这个过程远没有看起来那么简单。头文件通常充满了宏,用于根据所使用的编译器、操作系统和架构来影响编译器的行为。例如,C 结构体

struct parse_demo {

    uint32_t int_member;

    char    *ptr_member;

};

在 32 位系统上编译时占用 8 个字节,在 64 位系统上编译时占用 16 个字节。这种可变性给 Ghidra 带来了问题,因为 Ghidra 试图充当通用预处理器,您需要引导 Ghidra 完成解析过程,以创建一个有用的归档。当您需要将归档与 Ghidra 一起使用时,必须确保归档的创建方式与您正在分析的二进制文件兼容(也就是说,不要将 64 位归档加载到帮助分析 32 位文件时)。

要解析一个或多个 C 头文件,请在 CodeBrowser 中选择 文件 ▸ 解析 C 源代码,打开图 13-8 所示的对话框。待解析的源文件部分提供了一个按顺序排列的头文件列表,供插件解析。顺序很重要,因为一个文件中的数据类型和预处理指令会在下一个文件中生效。

解析选项框提供了一系列选项,类似于编译器命令行选项,影响 C 解析器插件的行为。解析器仅识别大多数编译器理解的 -I(包含目录)和 -D(定义宏)选项。Ghidra 提供了多种预处理器配置,形式为 .prf 文件,您可以从中选择,以为常见的操作系统和编译器组合提供合理的默认设置。您还可以自定义任何可用的配置,或从头开始创建自己的配置,并将其保存到自己的 .prf 文件中,以便将来使用。常见的解析器选项更改是正确设置您希望 C 解析器针对的架构,因为所有提供的配置都针对 x86。例如,如果您正在分析小端 ARM 二进制文件,您可能会将面向 Linux 的配置中的 -D_X86_ 更改为 -D__ARMEL__

插件的输出可以通过“解析到程序”按钮合并到当前活动文件中,或通过“解析到文件”存储在一个单独的 Ghidra 数据类型归档文件(.gdt)中。有关 C 解析器的更多信息,请参阅 Ghidra 帮助。

image

图 13-8:解析 C 源代码对话框

创建新的文件归档

作为解析 C 头文件的替代方法,您可能希望将分析文件时创建的自定义数据类型捕获到一个归档文件中,以便与其他 Ghidra 用户共享或在其他 Ghidra 项目中使用。数据类型管理器的“新建文件归档”选项(参见图 13-7)要求您选择文件名和保存位置,然后创建一个新的空归档,该归档会列在数据类型管理器窗口中。您可以使用在“使用 Ghidra 创建结构体”(第 166 页)中描述的技术将新的类型添加到归档中。归档创建后,您可以与其他 Ghidra 用户共享它,或在您的其他 Ghidra 项目中使用它。

创建新的项目归档

项目数据归档仅存在于创建它的项目中。如果您期望在项目中多个文件之间重用自定义数据类型,但不打算在项目外部使用这些数据类型,这可能会很有用。在数据类型管理器中,“新建项目归档”选项(参见图 13-7)要求您选择项目中的一个文件夹来存放新的归档,然后创建一个新的空归档,并将其列出在数据类型管理器窗口中。与其他数据类型归档一样,您可以根据需要向归档中添加新类型。

功能 ID

当你开始逆向工程任何二进制文件时,最不希望做的事情就是浪费时间逆向工程那些你可以通过简单地阅读手册页、查看一些源代码或做一点互联网研究就能更容易了解其行为的库函数。不幸的是,静态链接的二进制文件模糊了应用程序代码和库代码之间的区别:整个库与应用程序代码结合,形成一个单一的、庞大的可执行文件。幸运的是,Ghidra 提供了工具来识别和标记库代码,无论这些代码是来自库归档文件还是仅仅是通过跨多个二进制文件的代码重用生成的,这使我们能够将注意力集中在应用程序中的独特代码上。函数 ID 分析器使用 Ghidra 提供的函数签名识别许多常见的库函数,并且你可以通过使用函数 ID 插件来扩展函数签名数据库。

函数 ID 分析器与使用哈希值层次结构来描述函数的函数 ID 数据库(FidDb)配合使用。每个函数都会计算一个完整哈希(旨在抵御链接器可能引入的变化)和一个特定哈希(帮助区分函数的不同变种)。这两者之间的主要区别在于,特定哈希可能包括任何常量操作数的具体值(基于启发式方法),而完整哈希则不包括。两个哈希值的结合,再加上关于任何关联父函数和子函数的信息,形成了每个库函数的指纹,这些指纹被记录在 FidDb 中。函数 ID 分析器为你正在分析的二进制文件中的每个函数生成相同类型的指纹,并将其与相关 FidDb 中所有已知的指纹进行比较。当找到匹配项时,Ghidra 会从 FidDb 中恢复该函数的原始名称,将适当的标签应用到正在分析的函数上,添加该函数到符号树窗口,并更新该函数的注释。以下是 _malloc 函数的示例注释:

    **************************************************************

    * Library Function – SingleMatch                             *

    * Name: _malloc                                              *

    * Library: Visual Studio 2005 Release                        *

    **************************************************************

FidDb 中的函数信息是以层次结构存储的,包括名称、版本和变体。变体字段用于编码诸如编译器设置之类的信息,这些信息会影响哈希值,但不属于版本号的一部分。

功能 ID 分析器提供了多个选项,可以在自动分析对话框中选择分析器来控制其行为,如图 13-9 所示。指令计数阈值是一个可调节的阈值,旨在减少与小函数进行随机匹配时的假阳性。假阳性是指一个函数错误地匹配到一个库函数。假阴性是指一个函数没有与库函数匹配,但本应匹配。该阈值大致表示一个函数、其父函数和子函数(合计)必须包含的最小指令数,以便被考虑匹配。有关匹配分数的更多信息,请参阅 Ghidra 帮助中的评分与消歧

image

图 13-9:自动分析选项

由于二进制文件中的实际功能通常包含在函数中,因此扩展函数签名的能力对于减少重复工作至关重要,而这一工作由功能 ID 插件来促进。

功能 ID 插件

功能 ID 插件(与功能 ID 分析器不同)允许您创建、修改和控制 FidDb 的关联。默认的 Ghidra 安装中未启用此插件。要启用它,请从 CodeBrowser 窗口选择文件配置,然后勾选功能 ID 的复选框。在功能 ID 描述中选择配置,并选择FidPlugin以查看与插件相关的其他操作信息,如图 13-10 所示。

image

图 13-10:FidPlugin 详细信息

启用后,功能 ID 插件通过 CodeBrowser 的工具 ▸ 功能 ID 菜单进行控制,如图 13-11 所示。

image

图 13-11:CodeBrowser 功能 ID 子菜单

在我们通过示例演示如何使用功能 ID 插件扩展 Ghidra 签名之前,先简要讨论五个新的菜单选项:

选择活动 FidDb 显示一个活动功能 ID 数据库的列表。每个数据库都可以通过关联的复选框进行选择或取消选择。

创建新的空 FidDb 允许您创建并命名一个新的功能 ID 数据库。创建的新 FidDb 将在选择“选择活动 FidDb”时列出。

附加现有 FidDb 显示一个文件选择对话框,让您将现有的 FidDb 添加到活动 FidDb 列表中。添加 FidDb 后,您可以选择“选择活动 FidDb”以查看已添加的 FidDb。

分离现有 FidDb 仅适用于已手动附加的 FidDb。此操作将移除所选 FidDb 与当前 Ghidra 实例之间的关联。

从程序填充 FidDb 生成新的函数指纹并将其添加到现有的 FidDb 中。图 13-12 中的对话框用于控制此过程,稍后将讨论其使用方法。

image

图 13-12:填充 Fid 数据库对话框

函数 ID 插件示例:UPX

当我们自动分析的二进制文件中,除了 Ghidra 识别的库函数外,几乎没有其他函数时,我们的逆向工程任务相对简化。我们可以集中精力处理 Ghidra 未能识别的函数,假设新且有趣的功能就在这里。当 Ghidra 完全无法识别任何函数时,我们的任务变得更加具有挑战性。当我们(人工分析员)识别出这些函数并扩展 Ghidra 未来识别这些函数的能力时,我们减少了未来的工作量。接下来,我们将演示这种扩展是多么强大。

假设我们将一个 64 位的 Linux ELF 二进制文件加载到 Ghidra 中并自动分析该文件。生成的符号树条目如图 13-13 所示。我们使用符号树导航到入口点并检查代码。我们初步分析认为,该二进制文件使用Ultimate Packer for eXecutables (UPX) 进行打包,且我们看到的函数是 UPX 打包器添加的,用于在运行时解包二进制文件。我们通过将 entry 中看到的字节与已发布的 UPX 入口函数的字节进行对比,确认了这一假设。(另外,我们也可以创建自己的 UPX 打包二进制文件进行对比。)现在,我们将这些信息添加到我们的 FidDb 中,这样以后遇到其他 UPX 打包的 64 位 Linux 二进制文件时,我们就不必再进行相同的分析。

image

图 13-13:疑似 UPX 压缩程序函数,用于 upx_demo1_x64_static.upx

你添加到 FidDb 的函数应该有意义的名称。因此,我们将示例中的函数名称更改为表示它们是 UPX 压缩程序的一部分,如图 13-14 所示,然后将这些函数添加到一个新的函数 ID 数据库中,以便 Ghidra 在未来能正确标记这些函数。

image

图 13-14:已标记的 UPX 压缩程序函数,用于 upx_demo1_x64_static.upx

我们通过选择工具函数 ID创建新的空 FidDb 来创建一个新的 FidDb,并命名为UPX.fidb。接下来,我们通过选择工具函数 ID从程序填充 FidDb,将从更新后的二进制文件中提取的信息填充到新的数据库中。在弹出的对话框中输入 FidDb 信息,如图 13-15 所示。

image

图 13-15:填充 Fid 数据库对话框

这里描述了每个字段的目的以及我们输入的值:

Fid 数据库 UPX.fidb 是我们新创建的 FidDb 的名称。下拉列表允许你从已创建的所有 FidDb 中进行选择。

库的名称 选择一个能够描述你提取函数数据的库名称。在我们的例子中,我们输入了UPX

库版本 此字段可以是版本号、平台名称或两者的组合。由于 UPX 适用于许多平台,我们根据二进制文件的架构选择了库版本。

库变种 此字段可用于任何其他信息,用以区分同一版本的其他库。在本例中,我们使用了来自 GitHub 上 UPX 仓库的该版本 UPX 的提交 ID(github.com/upx/)。

基础库 在此,您可以引用另一个 FidDb,Ghidra 将用它来建立父/子关系。我们没有使用基础库,因为 UPX 是完全自包含的。

根文件夹 此字段命名一个 Ghidra 项目文件夹。选定文件夹中的所有文件将在功能摄取过程中进行处理。在本例中,我们从下拉菜单中选择了 /UPX

语言 此项包含与新 FidDb 相关联的 Ghidra 语言标识符。要从根文件夹进行处理,文件的语言标识符必须与此值匹配。此项内容来自二进制文件的导入结果摘要窗口,但可以使用文本框右侧的按钮进行修改。

通用符号文件 此字段指定包含应从摄取过程中排除的功能列表的文件。此字段在本例中未使用。

当我们点击“确定”时,摄取过程开始。当完成后,我们将看到 FidDb 填充的结果(图 13-16)。

image

图 13-16:来自 UPX FidDb 填充的结果窗口

一旦新 FidDb 被创建,Ghidra 就可以使用它来识别您正在分析的任何二进制文件中的功能。我们通过加载一个新的 UPX 打包的 64 位 Linux ELF 二进制文件 upx_demo2_x64_static.upx,并在没有功能 ID 分析器的情况下对文件进行自动分析来演示这一过程。结果符号树,如图 13-17 所示,显示了五个未识别的功能,正如我们预期的那样。

image

图 13-17: upx_demo2_x64_static.upx 在功能 ID 分析器前的符号树条目

运行功能 ID 作为一次性分析器(分析 ▸ 一次性 ▸ 功能 ID)会生成图 13-18 中所示的符号树,其中包括 UPX 功能名称。

image

图 13-18: upx_demo2_x64_static.upx 在功能 ID 分析器后的符号树条目

分析器还会更新列出窗口,显示新的功能名称和板块注释,类似于接下来展示的 UPX_1 的板块注释。这个板块注释包含了我们在创建 FidDb 时提供的信息:

    **************************************************************

    * Library Function - Single Match                            *

    * Name: UPX_1                                                *

    * Library: UPX AMD64 021c8db                                 *

    **************************************************************

                            undefined UPX_1()

    undefined         AL:1           <RETURN>

        UPX_1                             XREF[1]:     UPX_2:00457c08(c)

00457b1a 48 8d 04 2f  LEA    RAX,[RDI + RBP*0x1]

00457b1e 83 f9 05     CMP    ECX,0x5

创建新的 FidDb 只是扩展 Ghidra 函数识别功能的开始。你可以分析与函数相关的参数,并将其保存在数据类型档案中。然后,当 Function ID 正确识别函数时,你可以将相应的数据类型管理器条目拖动到列表窗口中的函数上,函数原型就会更新为适当的参数。

Function ID 插件示例:静态库分析

当你逆向工程一个静态链接的二进制文件时,你可能首先希望得到一个与该二进制文件中链接的函数相匹配的 FidDb,这样 Ghidra 就能识别出库代码,节省你分析的精力。以下示例解答了两个重要问题:(1)如何知道你是否拥有这样的 FidDb?(2)如果没有 FidDb,该怎么办?第一个问题的答案很简单:Ghidra 自带至少十几个 FidDb(以.fidbf文件的形式),这些都与 Visual Studio 的库代码相关。如果二进制文件不是 Windows 二进制文件,而且你还没有创建或导入任何 FidDb,那么你需要通过使用 Ghidra 的 Function ID 插件来自己制作一个 FidDb(这也解决了第二个问题)。

在填充新的 FidDb 时,最重要的一点是你需要一个输入源,它应该有较高的概率与任何你打算应用 FidDb 的二进制文件匹配。在 UPX 示例中,我们有一个包含我们直觉上可能在未来再次遇到的代码的二进制文件。在常见的静态链接情况下,我们有一个二进制文件,目标是尽可能匹配这个二进制文件中的所有代码。

有多种方法可以识别你正在处理的是一个静态链接的二进制文件。在 Ghidra 中,查看符号树中的Imports文件夹。对于一个完全静态链接的二进制文件,文件夹会为空,因为它不需要导入函数。部分静态链接的二进制文件可能有一些导入,因此你可以在“已定义字符串”窗口中查找来自著名库的版权或版本字符串。

在命令行中,你可以使用简单的工具,如filestrings

$ file upx_demo2_x64_static_stripped

  upx_demo2_x64_static_stripped: ELF 64-bit LSB executable, x86-64,

  version 1 (GNU/Linux), statically linked, for GNU/Linux 3.2.0,

  BuildID[sha1]=54e3569c298166521438938cc2b7a4dda7ab7f5c, stripped

$ strings upx_demo2_x64_static_stripped | grep GCC

  GCC: (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0

file命令的输出告诉我们,该二进制文件是静态链接的,已剥离符号,并且来自 Linux 系统。(一个剥离符号的二进制文件不包含任何熟悉的名称,因此无法通过函数名推测其行为。)使用grep GCC过滤strings命令的输出可以识别编译器 GCC 7.4.0 以及用于构建该二进制文件的 Linux 发行版 Ubuntu 18.04.1。(你也可以使用 CodeBrowser 的搜索功能,选择程序文本并使用GCC作为过滤条件,来获取相同的信息。)很可能这个二进制文件是与libc.a链接的,^(1)因此我们从 Ubuntu 18.04.1 中复制libc.a,并将其作为恢复剥离符号的起点。(二进制文件中的其他字符串可能会促使我们选择更多静态库进行函数 ID 分析;但在此示例中,我们仅限于使用libc.a。)

要使用libc.a来填充 FidDb,Ghidra 必须识别其中包含的指令和函数。归档(即.a)文件格式定义了一个容器,通常用于存储对象文件(.o),这些文件可能被编译器提取并链接到可执行文件中。Ghidra 导入容器文件的过程与导入单个二进制文件的过程不同,因此当我们使用“文件 ▸ 导入”来导入libc.a时,Ghidra 会提供其他导入模式,如图 13-19 所示。(这些选项也可以通过文件菜单选择。)

image

图 13-19:导入容器文件

单文件模式要求 Ghidra 将容器文件作为单个文件导入。由于容器文件并不是一个可执行文件,Ghidra 可能会建议使用原始二进制格式进行导入,并执行最小化的自动化分析。在文件系统模式下,Ghidra 会打开一个文件浏览窗口(见图 13-20),显示容器文件的内容。在这种模式下,你可以通过上下文菜单的选项选择容器中的任意文件进行导入。

image

图 13-20:文件系统导入模式

在批处理模式下,Ghidra 会自动导入容器中的文件,而不会暂停显示单个文件的信息。在初步处理容器内容后,Ghidra 会显示如图 13-21 所示的批量导入对话框。在点击确定之前,你可以查看每个正在导入文件的信息,添加更多文件到批量导入中,设置导入选项,并选择 Ghidra 项目中的目标文件夹。图 13-21 显示我们即将从libc.a库中导入 1690 个文件到 CH13 项目的根目录。

image

图 13-21:Ghidra 的批量导入对话框

点击OK开始导入过程(可能需要一些时间)。导入完成后,您将能够在 Ghidra 项目窗口中浏览新导入的文件。由于libc.a是一个容器文件,它将在项目窗口中显示为一个文件夹,您可以浏览其内容,打开并分析文件夹中的任何文件。

到这一步,我们终于可以将每个libc函数的指纹捕获到 FidDb 中,并使用这个 FidDb 对我们样本的静态链接二进制文件进行功能 ID 分析。这个过程类似于 UPX 示例,首先创建一个新的空 FidDb,然后从程序中填充数据。在这种情况下,程序将是我们新导入的libc.a文件夹的全部内容。这里我们遇到了一个重大挑战。

当我们选择文件来填充新的 FidDb 时,必须确保每个文件都已被 Ghidra 正确分析,以识别函数及其相关指令(即功能 ID 哈希过程的输入)。到目前为止,我们看到 Ghidra 只会在我们打开程序时在 CodeBrowser 中进行分析,但在处理libc.a时,我们面临着分析libc.a归档中 1690 个单独文件的艰巨任务。逐一打开并分析它们并不是一种高效的做法。即使选择在导入时打开所有文件,并使用 Ghidra 的“分析所有打开的文件”选项,也需要花费我们相当多的时间来处理所有 1690 个文件(并且可能需要手动调整工具选项和资源分配,以适应在 Ghidra 实例中处理如此庞大任务)。

如果这个问题看起来笨重,您是对的。这不是我们应该通过 Ghidra GUI 手动解决的任务。这是一个明确定义的重复性任务,不应该需要人工干预。幸运的是,接下来的三章介绍了我们可以用来自动化这一任务及其他任务的方法。当我们到达 “自动化 FidDb 创建” 第 359 页时,我们将重新审视这个具体任务,并演示如何使用 Ghidra 的无头模式轻松实现批处理操作。

无论我们使用什么方法来处理libc.a,一旦完成,返回到功能 ID 插件并填充我们的新 FidDb,生成以下结果就变得简单:

FidDb Populate Results

2905 total functions visited

2638 total functions added

267 total functions excluded

Breakdown of exclusions:    FAILS_MINIMUM_SHORTHASH_LENGTH: 234

    DUPLICATE_INFO: 9

    FAILED_FUNCTION_FILTER: 0

    IS_THUNK: 16

    NO_DEFINED_SYMBOL: 8

    MEMORY_ACCESS_EXCEPTION: 0

Most referenced functions by name:

749  __stack_chk_fail

431  free

304  malloc

...

我们的新 FidDb 现在可以使用了,并允许功能 ID 分析器匹配upx_demo2_x64_static_stripped中包含的许多函数,从而大大减少了我们对该二进制文件进行逆向工程的工作量。

总结

本章展示了通过解析 C 源文件、扩展词模型以及使用 Function ID 插件提取函数指纹等方式扩展 Ghidra 的一些方法。当二进制文件包含静态链接的代码或来自先前分析过的二进制文件的复用代码时,将这些函数与 Ghidra FidDb 进行匹配,可以帮助你避免手动查找大量代码的麻烦。可以预见的是,静态链接库种类繁多,Ghidra 不可能包含涵盖所有使用场景的 FidDb 文件。必要时创建自己的 FidDb 文件的能力,允许你构建一个针对特定需求调整的 FidDb 集合。在第十四章和第十五章中,我们将介绍 Ghidra 强大的脚本功能,进一步扩展 Ghidra 的功能。

第十七章:基础 Ghidra 脚本编写

Image

没有任何应用程序能够满足每个用户的所有需求。这是因为无法预见所有可能出现的使用情况。Ghidra 的开源模型鼓励开发者提出功能请求并进行创新贡献。然而,有时你需要立即解决眼前的问题,而不能等待其他人实现新功能。为了支持无法预见的使用案例和对 Ghidra 操作的程序化控制,Ghidra 包含了集成脚本功能。

脚本的用途是无穷无尽的,既可以是简单的单行代码,也可以是完整的程序,自动化常见任务或执行复杂分析。本章我们将重点介绍通过 CodeBrowser 界面提供的基本脚本编写。我们将介绍内部脚本环境,讨论如何使用 Java 和 Python 开发脚本,接着进入 第十五章中讨论的其他集成脚本选项。

脚本管理器

Ghidra 脚本管理器可以通过 CodeBrowser 菜单访问。选择“窗口 ▸ 脚本管理器”会打开如 图 14-1 所示的窗口。也可以通过 CodeBrowser 工具栏中的脚本管理器图标(一个绿色圆圈,内有箭头,也出现在脚本管理器窗口的左上角)来打开该窗口。

image

图 14-1:脚本管理器窗口

脚本管理器窗口

在全新安装的 Ghidra 中,脚本管理器加载时会包含超过 240 个脚本,这些脚本按类别树进行组织,如 图 14-1 左侧所示。部分文件夹内包含子文件夹,以便对脚本进行更详细的分类。你可以展开或折叠这些文件夹,查看脚本的组织结构。选择一个文件夹或子文件夹将只显示该文件夹中的脚本。为了填充这个窗口,Ghidra 会在 Ghidra 安装目录中的 ghidra_scripts 子目录内查找并索引所有脚本。Ghidra 还会查找用户主目录下的 ghidra_scripts 目录,并索引其中的脚本。

默认的脚本集覆盖了广泛的功能。一些脚本旨在演示基本的脚本概念。脚本列表表格中的列提供了关于每个脚本用途的更多细节。与大多数 Ghidra 表格一样,你可以控制显示哪些列以及各列的排序方式。默认情况下,所有可用的字段都会显示,除了“创建时间”和“路径”之外。六个信息列为脚本提供了以下详细信息:

状态 显示脚本的状态。该字段通常为空,但可以显示一个红色图标,表示脚本中有错误。如果你已将工具栏图标与脚本关联,该图标将显示在这一列。

名称 包含脚本的文件名及其扩展名。

描述 从脚本中的元数据注释提取的描述。该字段可能非常长,但您可以通过悬停在字段上阅读完整内容。该字段在“脚本开发”一节中有更详细的讨论,详见第 289 页。

指示是否为运行该脚本分配了键绑定。

类别 指定脚本将在脚本管理器的主题层次结构中列出的路径。这是一个逻辑层次结构,不是文件系统目录层次结构。

已修改 脚本最后保存的日期。对于默认脚本,日期为 Ghidra 实例的安装日期。

窗口左侧的过滤器字段用于在脚本类别中进行搜索。右侧的过滤器用于搜索脚本的名称和描述。最后,在底部,还有一个最初为空的额外窗口。该窗口以易于处理的格式显示所选脚本的元数据,包括从脚本中的元数据提取的字段。元数据字段的格式和含义在“编写 Java 脚本(不是 JavaScript!)”一节中讨论,详见第 289 页。

虽然脚本管理器提供了大量的信息,但此窗口的主要功能来自于其提供的工具栏。工具栏的概述请参见图 14-2。

脚本管理器工具栏

脚本管理器没有菜单来帮助您管理脚本。相反,所有的脚本管理操作都与脚本管理器工具栏上的工具相关联(见图 14-2)。

虽然大部分菜单选项从图 14-2 中的描述来看都比较清晰,但编辑选项值得额外讨论。Eclipse 中的编辑功能在第十五章中有所介绍,因为它支持更高级的脚本功能。编辑脚本选项会打开一个原始的文本编辑器窗口,并带有自己的工具栏,如图 14-3 所示。相关的操作提供了编辑文件的基本功能。有了编辑器,我们可以开始编写实际的脚本。

image

图 14-2: 脚本管理器工具栏

image

图 14-3: 编辑脚本工具栏

脚本开发

在 Ghidra 中开发脚本有几种方法。本章重点介绍使用 Java 和 Python 编写脚本,因为这些语言是脚本管理器窗口中现有脚本所使用的语言。超过 240 个系统脚本大多是用 Java 编写的,因此我们将从编辑和开发 Java 脚本开始。

编写 Java 脚本(不是 JavaScript!)

在 Ghidra 中,使用 Java 编写的脚本实际上是一个完整的类规范,旨在无缝编译、动态加载到运行中的 Ghidra 实例中、调用并最终卸载。该类必须扩展类Ghidra.app.script.GhidraScript,实现run()方法,并使用注释提供有关脚本的 Javadoc 格式元数据。我们将展示脚本文件的结构,描述元数据要求,查看一些系统脚本,然后继续编辑现有脚本并构建我们自己的脚本。

图 14-4 显示了选择“创建新脚本”选项(参见图 14-2)时打开的脚本编辑器,用于创建新的 Java 脚本。我们将新脚本命名为CH14_NewScript

image

图 14-4:一个新的空脚本

文件顶部是用于生成预期 Javadoc 信息的元数据注释和标签。这些信息也用于填充脚本管理器窗口中的字段(参见图 14-1)。任何在类、字段或方法声明之前以//开头的注释将成为脚本的 Javadoc 描述的一部分。额外的注释可以嵌入脚本中,并且不会包含在描述中。此外,以下元数据注释中的标签是支持的:

@author 提供有关脚本作者的信息。该信息由作者自行决定,可以包括任何相关细节(例如,姓名、联系信息、创建日期等)。

@category 确定脚本在类别树中的位置。这是唯一的强制标签,必须在所有 Ghidra 脚本中存在。句点(点)字符充当类别名称的路径分隔符(例如,@category Ghidrabook.CH14)。

@keybinding 记录用于从 CodeBrowser 窗口访问脚本的快捷键(例如,@keybinding K)。

@menupath 定义脚本的菜单路径,并提供一种从 CodeBrowser 菜单运行脚本的方式(例如,@menupath File.Run.ThisScript)。

@toolbar 为脚本关联一个图标。此图标显示为 CodeBrowser 窗口中的工具栏按钮,可用于运行脚本。如果 Ghidra 在脚本目录或 Ghidra 安装中找不到图像,将使用默认图像(例如,@toolbar myImage.png)。

当遇到一个新的 API(如 Ghidra API)时,可能需要一些时间才能在不不断查阅 API 文档的情况下编写脚本。尤其是 Java 对类路径问题和正确包含所需支持包非常敏感。一个节省时间和精力的选择是编辑现有程序,而不是创建一个新程序。我们在展示脚本的简单示例时采用了这种方法。

编辑脚本示例:正则表达式搜索

假设你的任务是开发一个脚本,接受用户输入的正则表达式并将匹配的字符串输出到控制台。此外,脚本需要在特定项目的脚本管理器中显示。虽然 Ghidra 提供了多种方法来完成这个任务,但你被要求编写一个脚本。为了找到一个具有相似功能的脚本作为基础,你查看了脚本管理器中的类别,检查了“字符串”和“搜索”类别的内容,然后筛选出包含 strings 的选项。使用筛选器可以提供一个更全面的与字符串相关的脚本列表供你参考。在这个示例中,你将编辑列表中第一个与要实现功能相似的脚本——CountAndSaveStrings.java

在编辑器中打开脚本,通过右键点击所需脚本并选择 编辑(使用基本编辑器)来确认它是否是我们新功能的良好起点;然后使用 另存为 选项保存该脚本并将其命名为 FindStringsByRegex.java。Ghidra 不允许你在脚本管理器窗口中编辑作为 Ghidra 安装一部分提供的系统脚本(尽管你可以在 Eclipse 和其他编辑器中编辑)。你也可以在使用“另存为”之前编辑该文件,因为 Ghidra 会防止你不小心将任何修改后的内容写入现有的 CountAndSaveStrings.java 脚本。

原始的 CountAndSaveStrings.java 包含以下元数据:

➊ /* ###

   * IP: GHIDRA

   *

   * Licensed under the Apache License, Version 2.0 (the "License");

   * you may not use this file except in compliance with the License.

   * You may obtain a copy of the License at

   * http://www.apache.org/licenses/LICENSE-2.0

   * Unless required by applicable law or agreed to in writing, software

   * distributed under the License is distributed on an "AS IS" BASIS,

   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.

   * See the License for the specific language governing permissions and

   * limitations under the License.

   */

➋  //Counts the number of defined strings in the current selection,

   //or current program if no selection is made,

   //and saves the results to a file.

➌ //@category CustomerSubmission.Strings

我们可以在不影响脚本执行或关联 Javadoc 的情况下,留下、修改或删除脚本的许可协议➊。我们将修改脚本的描述➋,以便 Javadoc 和脚本管理器中显示的信息能够准确描述该脚本。脚本作者只包含了五个可用标签中的一个➌,因此我们将为未填充的标签添加占位符,并修改描述,如下所示:

// Counts the number of defined strings that match a regex in the current

// selection, or current program if no selection is made, and displays the

// number of matching strings to the console.

//

//@author Ghidrabook

//@category Ghidrabook.CH14

//@keybinding

//@menupath

//@toolbar

类别标签 Ghidrabook.CH14 将添加到脚本管理器的树状显示中,如图 14-5 所示。

原始脚本的下一部分包含 Java import 语句。在创建新脚本时,Ghidra 会包含一个长列表的导入,如图 14-4 所示,只有以下导入对字符串搜索是必要的,因此我们将保留与原始 CountAndSaveStrings.java 相同的导入列表:

import ghidra.app.script.GhidraScript;

import ghidra.program.model.listing.*;

import ghidra.program.util.ProgramSelection;

import java.io.*;

保存新脚本后,然后在脚本管理器中选择它,以查看图 14-5 中显示的内容。我们的新类别已包含在脚本树中,脚本的元数据显示在信息窗口和脚本表格中。该表格只包含一个脚本,Ghidrabook.CH14,因为它是所选类别中唯一的脚本。

image

图 14-5:脚本管理器窗口中显示的新脚本信息

由于本书并非旨在作为 Java 教程,我们将总结我们对脚本所做的更改,而不是解释 Java 的语法和功能。以下列表描述了CountAndSaveStrings.java的行为:

  1. 获取程序列表内容以进行搜索。

  2. 获取文件以保存结果。

  3. 打开文件。

  4. 遍历程序列表:统计符合条件的字符串数量,并将每个符合条件的字符串写入文件。

  5. 关闭文件。

  6. 将符合条件的字符串的数量写入控制台。

我们所需的修改脚本的功能如下:

  1. 获取程序列表内容以进行搜索。

  2. 向用户询问要搜索的正则表达式(regex)。

  3. 遍历程序列表:统计符合条件的字符串数量,并将每个符合条件的字符串写入控制台。

  4. 将符合条件的字符串的数量写入控制台。

我们的新脚本将比原始脚本短得多,因为不再需要与文件系统交互以及执行相关的错误检查。我们的实现如下:

public class FindStringsByRegex extends GhidraScript➊ {

   @Override

   public void run() throws Exception {

      String regex =

         askString("Please enter the regex",

         Please enter the regex you're looking to match:);

      Listing listing = currentProgram.getListing();

      DataIterator dataIt;

      if (currentSelection != null) {

          dataIt = listing.getDefinedData(currentSelection, true);

      }

      else {

         dataIt = listing.getDefinedData(true);

      }

      Data data;

      String type;

      int counter = 0;

      while (dataIt.hasNext() && !monitor.isCancelled()) {

         data = dataIt.next();

         type = data.getDataType().getName().toLowerCase();

         if (type.contains("unicode") || type.contains("string")) {

            String s = data.getDefaultValueRepresentation();

            if (s.matches(regex)) {

               counter++;

               println(s);

            }

         }

      }

      println(counter + " matching strings were found");

   }

}

所有你为 Ghidra 编写的 Java 脚本必须继承(扩展)一个名为Ghidra.app.script.GhidraScript的现有类➊。保存脚本的最终版本后,从脚本管理器中选择并执行它。当脚本执行时,我们可以看到在图 14-6 中显示的提示。此图包含我们将要搜索的正则表达式,以测试我们的脚本。

image

图 14-6:输入正则表达式的新脚本提示

当我们的新脚本执行完成后,CodeBrowser 控制台将显示以下内容:

FindStringsByRegex.java> Running...

FindStringsByRegex.java> "Fatal error: glibc detected an invalid stdio handle\n"

FindStringsByRegex.java> "Unknown error "

FindStringsByRegex.java> "internal error"

FindStringsByRegex.java> "relocation error"

FindStringsByRegex.java> "symbol lookup error"

FindStringsByRegex.java> "Fatal error: length accounting in _dl_exception_create_format\n"

FindStringsByRegex.java> "Fatal error: invalid format in exception string\n"

FindStringsByRegex.java> "error while loading shared libraries"

FindStringsByRegex.java> "Unknown error"

FindStringsByRegex.java> "version lookup error"

FindStringsByRegex.java> "sdlerror.o"

FindStringsByRegex.java> "dl-error.o"

FindStringsByRegex.java> "fatal_error"

FindStringsByRegex.java> "strerror.o"

FindStringsByRegex.java> "strerror"

FindStringsByRegex.java> "__strerror_r"

FindStringsByRegex.java> "_dl_signal_error"

FindStringsByRegex.java> "__dlerror"

FindStringsByRegex.java> "_dlerror_run"

FindStringsByRegex.java> "_dl_catch_error"

FindStringsByRegex.java> 20 matching strings were found

FindStringsByRegex.java> Finished!

这个简单的示例展示了 Ghidra 广泛的 Java 脚本功能的低门槛。现有脚本可以很容易地修改,新的脚本可以通过脚本管理器从零开始构建。在第十五章和第十六章中,我们展示了一些更复杂的 Java 脚本功能,但 Java 只是 Ghidra 提供的脚本选项之一。Ghidra 还允许你使用 Python 编写脚本。

Python 脚本

在脚本管理器中的 240 多个脚本中,只有少数是用 Python 编写的。你可以通过在脚本管理器中过滤.py扩展名来轻松找到 Python 脚本。大多数 Python 脚本可以在树形结构中的 Examples.Python 类别下找到,并且包含类似于图 14-7 中所示的免责声明。

image

图 14-7:带免责声明的示例 Python 脚本

在此目录中的示例中,以下三个示例是如果你更喜欢使用 Python,提供了一个很好的起点:

ghidra_basic.py 这个脚本包含与 Ghidra 相关的基本 Python 脚本示例。

python_basics.py 这是对你可能想使用的许多 Python 命令的一个非常基础的介绍。

jython_basic.py 该脚本扩展了基础 Python 命令,展示了特定于 Jython 的内容。

这些示例中展示的 Ghidra 功能仅仅触及了 Ghidra API 的表面。你可能仍然需要花些时间阅读 Ghidra 的 Java 示例库,才能准备好通过你的 Python 脚本访问 Ghidra 的完整 Java API。

除了运行 Python 脚本外,Ghidra 还提供了 Python 解释器,使你能够使用 Python/Jython 直接访问与 Ghidra 相关的 Java 对象,如 图 14-8 所示。

GHIDRA 的 Python 未来

Python 因其简洁性和众多可用库而广受欢迎,成为创建脚本的首选语言。尽管 Ghidra 发布版中的大多数脚本是用 Java 编写的,但开源的逆向工程社区很可能会在 Ghidra 中使用 Python 作为主要脚本语言。Ghidra 依赖 Jython 来支持 Python(这使得可以直接访问 Ghidra 的 Java 对象)。Jython 与 Python 2(特别是 2.7.1)兼容,但不支持 Python 3。尽管 Python 2 在 2020 年 1 月停止了生命周期,但 Python 2 脚本在 Ghidra 中仍然能够正常运行,任何新的 Ghidra Python 2 脚本应尽可能地以便于迁移到 Python 3 的方式编写。

image

图 14-8:Python 解释器 打印 示例

通过 CodeBrowser 可以访问 Python 解释器,选择 Windows ▸ Python。有关如何使用解释器的更多信息,请参见 Ghidra 帮助。在使用 Python 和 Python 解释器时,要获取 API 信息,请选择解释器窗口左上角的帮助 ▸ Ghidra API 帮助,如 图 14-8 所示,这将打开 GhidraScript 类的 Javadoc 内容。或者,Python 有一个内置函数 help( ),在 Ghidra 中已经被修改,可以直接访问 Ghidra 的 Javadoc。要使用该功能,在解释器中键入 help(object),如 图 14-9 所示。例如,help(currentProgram) 显示 Ghidra Javadoc 内容,描述了 Ghidra API 类 ProgramDB

image

图 14-9:Python 解释器帮助示例

对其他语言的支持

最后,Ghidra 支持来自 Java 和 Python 以外的其他语言的脚本,这使你能够将现有的脚本从你的逆向工程工具包中带入 Ghidra 的工作流程中。这个功能在 Ghidra 帮助中有更详细的讨论。

Ghidra API 介绍

此时,你已经掌握了编辑和运行 Ghidra 脚本所需的所有信息。现在是时候使用 Ghidra API 来扩展你的脚本功能,并更直接地与 Ghidra 文物进行交互了。Ghidra 提供了两种截然不同的 API 风格。

Program API 定义了一个对象层次结构,深度多层,最上层由Program类根本。这一 API 可能会随着 Ghidra 的版本不同而有所变化。Flat API 通过暴露该 API 的所有层级,统一从一个类FlatProgramAPI中访问,从而将 Program API 进行扁平化。Flat API 通常是访问许多 Ghidra 构造的最便捷方式。此外,它比 Program API 更不容易随着 Ghidra 版本的更新而发生变化。

在本章的其余部分,我们将重点介绍一些更有用的 Flat API 功能。在必要时,我们还会提供有关 Program API 中特定类的详细信息。我们使用 Java 作为本讨论的语言,因为它是 Ghidra 的原生语言。

Ghidra API 包含许多包、类及其相关函数,用于与 Ghidra 项目和相关文件交互,所有这些内容都在随 Ghidra 提供的 Javadoc 风格文档中详细说明,可以通过点击脚本管理器窗口中的红色加号访问。该文档与随 Ghidra 提供的示例脚本一起,是你了解 API 及其使用方法的主要参考资料。最常见的做法是浏览 Ghidra 类,查找那些从名称上看似乎能完成你需要的任务的类。随着你对 Ghidra 的理解不断加深,你对命名约定和文件组织结构的理解将帮助你更快地识别出合适的类。

Ghidra 遵循 Java Swing 的模型-委托架构,其中数据值和特性存储在模型对象中,并由用户界面委托对象(如树视图、列表视图和表格视图)显示。委托对象处理事件,例如鼠标点击,来更新和刷新数据和视图。在绝大多数情况下,你的脚本将集中在表示各种程序和逆向工程构造的模型类所封装的数据上。

本节的其余部分集中介绍常用的模型类、它们之间的关系,以及与之交互的有用 API。我们并不打算涵盖整个 Ghidra API,实际上还有许多其他的函数和类可以使用。整个 Ghidra API 的权威文档是随 Ghidra 提供的 Javadoc,最终的参考资料是构建 Ghidra 的 Java 源代码。

地址接口

Address接口描述了地址空间内地址的模型。所有地址通过一个最多为 64 位的偏移量表示。分段地址可能会通过段值进一步限定。在许多情况下,一个地址的偏移量相当于程序列表中的虚拟地址。getOffset方法从Address实例中检索long类型的偏移量值。许多 Ghidra API 函数要求以Address对象作为参数,或者返回Address对象作为结果。

符号接口

Symbol接口定义了所有符号的共同属性。至少,一个符号由名称和地址组成。这些属性可以通过以下成员函数获取:

Address getAddress()

返回Symbol的地址。

String getName()

返回Symbol的名称。

引用接口

Reference表示源地址和目标地址之间的交叉引用关系(如第九章所述),并且具有一个引用类型。与Reference相关的有用函数包括:

public Address getFromAddress()

返回此引用的源地址。

public Address getToAddress()

返回此引用的目标地址。

public RefType getReferenceType()

返回一个RefType对象,描述源地址和目标地址之间的链接性质。

GhidraScript 类

虽然这个类并没有表示二进制文件中的某个特定属性,但你编写的每个脚本必须是GhidraScript类的子类,而GhidraScript又是FlatProgramAPI类的子类。因此,你的脚本可以即时访问整个 Flat API,而你唯一的义务是提供实现。

protected abstract void run() throws Exception;

这样, hopefully,你的脚本就能做一些有趣的事情。GhidraScript类的剩余部分为你提供了与 Ghidra 用户以及正在分析的程序交互的最常见资源的访问权限。该类的一些更有用的函数和数据成员(包括一些从FlatProgramAPI继承的)将在以下章节中总结。

有用的数据成员

GhidraScript类为你提供了方便访问在脚本中常用的多个对象,包括以下内容:

受保护的 Program currentProgram;

这是当前打开的程序。Program类将在后面讨论。这个数据成员可能是你获取更有趣信息(例如指令和符号列表)的通道。

受保护的 Address currentAddress;

这是当前光标位置的地址。Address类将在后面讨论。

受保护的 ProgramLocation currentLocation;

一个ProgramLocation对象,描述当前光标位置,包括其地址、光标所在的行、列以及其他信息。

受保护的 ProgramSelection currentSelection;

一个ProgramSelection对象,表示在 Ghidra 图形界面中选择的一系列地址。

受保护的 TaskMonitor monitor;

TaskMonitor类更新长时间运行任务的状态,并检查是否有长时间运行的任务被用户取消(monitor.isCancelled())。你编写的任何长时间运行的循环都应该包含调用monitor.isCancelled,作为附加的终止条件,以识别用户是否尝试取消你的脚本。

用户界面函数

GhidraScript 类提供了便捷的函数,用于执行基本的用户界面操作,从简单的消息输出到更具互动性的对话框元素。一些常见的用户界面函数在此描述:

public void println(String message)

message打印到 Ghidra 的控制台窗口,后跟换行符。此函数对于以非侵入的方式打印状态消息或脚本结果非常有用。

public void printf(String message, Object... args)

使用message作为 Java 格式化字符串,并将格式化后的args打印到 Ghidra 的控制台窗口。

public void popup(final String message)

在弹出对话框中显示message,要求用户点击确定才能继续脚本执行。这是一种更具侵入性的方式来向用户显示状态消息。

public String askString(String title, String message)

许多可用的ask函数之一。askString显示一个文本输入对话框,使用message作为提示,并返回用户输入的文本。

public boolean askYesNo(String title, String question)

使用对话框询问用户一个是或否的问题。如果选择“是”,返回true;选择“否”,返回false

public Address askAddress(String title, String message)

显示一个对话框,使用message作为提示,解析用户输入为Address对象。

public int askInt(String title, String message)

显示一个对话框,使用message作为提示,解析用户输入为int类型。

public File askFile(final String title, final String approveButtonText)

显示一个系统文件选择对话框,并返回一个 Java File对象,表示用户选择的文件。

public File askDirectory(final String title, final String approveButtonText)

显示一个系统文件选择对话框,并返回一个 Java File对象,表示用户选择的目录。

public boolean goTo(Address address)

将所有连接的 Ghidra 反汇编窗口重新定位到address。此函数的重载版本接受SymbolFunction参数,并根据这些参数调整显示。

地址相关函数

对于处理器而言,地址通常只是一个数字,恰好指向一个内存位置。Ghidra 通过Address类来表示地址。GhidraScript提供了一个包装函数,可以方便地将数字转换为 Ghidra 的Address对象:

public Address toAddr(long offset)

创建Address对象的便捷函数,位于默认地址空间中

读取程序内存

Memory 类表示字节值的连续范围,例如加载到 Ghidra 中的可执行文件的内容。在 Memory 对象中,每个字节值都与一个地址关联,尽管地址可能被标记为未初始化,并且没有可获取的值。如果尝试访问 Memory 对象中无效地址的内存位置,Ghidra 会抛出 MemoryAccessException。有关 Memory 类的完整 API 函数说明,请查阅文档。以下便捷函数通过 Flat API 暴露了 Memory 类的一部分功能:

public byte getByte(Address addr)

返回从 addr 获取的单个字节值。数据类型 byte 在 Java 中是有符号类型,因此该值的范围为 -128..127。

public byte[] getBytes(Address addr, int length)

返回从 addr 开始的 length 字节数据。

public int getInt(Address addr)

返回从 addr 开始的 4 字节值,作为 Java 的 int 类型。此函数会考虑字节序,并在重建 int 值时遵循二进制的底层架构。

public long getLong(Address addr)

返回从 addr 开始的 8 字节值,作为 Java 的 long 类型。此函数会考虑字节序,并在重建 long 值时遵循二进制的底层架构。

程序搜索功能

Ghidra 的搜索功能根据被搜索项的类型,分布在不同的 Program API 类中。Memory 类包含原始字节搜索功能。代码单元(如 DataInstruction)、注释文本及相关迭代器从 Listing 类中获取。符号/标签及相关迭代器通过 SymbolTable 类访问。以下便捷函数通过 Flat API 暴露了部分可用的搜索功能:

public Data getFirstData()

返回程序中的第一个数据项。

public Data getDataAfter(Data data)

返回 data 后的下一个数据项,如果没有此数据项,则返回 null

public Data getDataAt(Address address)

返回 address 处的数据项,如果没有此数据项,则返回 null

public Instruction getFirstInstruction()

返回程序中的第一条指令。

public Instruction getInstructionAfter(Instruction instruction)

返回 instruction 后的下一个指令项,如果没有此指令项,则返回 null

public Instruction getInstructionAt(Address address)

返回 address 处的指令,如果不存在此指令,则返回 null

public Address find(String text)

在 Listing 窗口中搜索 text 字符串。Listing 组件按以下顺序进行搜索:

  1. Plate 注释

  2. Pre 注释

  3. 标签

  4. 代码单元助记符和操作数

  5. EOL 注释

  6. 可重复注释

  7. Post 注释

成功的搜索返回包含匹配项的地址。请注意,由于搜索顺序的原因,返回的地址可能代表在严格递增的地址顺序下反汇编清单中第一次出现的文本。

public Address find(Address start, byte[] values);

addr处开始搜索内存,查找指定的字节values序列。当addrnull时,搜索从二进制文件中的最低有效地址开始。成功的搜索将返回匹配序列中第一个字节的地址。

public Address findBytes(Address start, String byteString)

addr处开始搜索内存,查找可能包含正则表达式的指定byteString。当addrnull时,搜索从二进制文件中的最低有效地址开始。成功的搜索将返回匹配序列中第一个字节的地址。

操作标签和符号

在脚本中,经常需要操作命名的位置。以下是可用于在 Ghidra 数据库中处理命名位置的函数:

public Symbol getSymbolAt(Address address)

返回与给定地址关联的Symbol,如果该位置没有Symbol,则返回null

public Symbol createLabel(Address address, String name, boolean makePrimary)

将给定的name分配给给定的address。Ghidra 允许多个名称分配给单一地址。如果makePrimarytrue,则新名称将成为与address关联的主名称。

public List getSymbols(String name, Namespace namespace)

返回namespace中所有名为name的符号列表。如果namespacenull,则搜索全局命名空间。如果结果为空,则表示该符号不存在。如果结果仅包含一个元素,则表示该名称是唯一的。

与函数的操作

许多脚本旨在分析程序中的函数。以下函数可用于访问有关程序函数的信息:

public final Function getFirstFunction()

返回程序中的第一个Function对象

public Function getGlobalFunctions(String name)

返回命名函数的第一个Function对象,如果没有这样的函数则返回null

public Function getFunctionAt(Address entryPoint)

返回entryPoint处的Function对象,如果没有这样的函数则返回null

public Function getFunctionAfter(Function function)

返回function的后继Function对象,如果没有这样的函数则返回null

public Function getFunctionAfter(Address address)

返回在地址后开始的Function对象,如果没有这样的函数则返回null

与交叉引用的操作

交叉引用在第九章中有详细介绍。在 Ghidra 程序 API 中,顶层的Program对象包含一个ReferenceManager,显而易见,它管理程序中的引用。与许多其他程序构造一样,Flat API 提供了方便的函数来访问交叉引用,其中一些在此处进行了详细说明:

public Reference[] getReferencesFrom(Address address)

返回所有来自addressReference对象的数组。

public Reference[] getReferencesTo(Address address)

返回所有指向addressReference对象的数组。

程序操作函数

在自动化分析任务时,您可能会希望将新信息添加到程序中。Flat API 提供了多种修改程序内容的功能,包括以下内容:

public final void clearListing(Address address)

移除address处定义的任何指令或数据。

public void removeFunctionAt(Address address)

移除位于address的函数。

public boolean disassemble(Address address)

address开始执行递归下降反汇编。如果操作成功,返回true

public Data createByte(Address address)

将指定地址处的项目转换为数据字节。此外,还可以使用createWordcreateDwordcreateQword和其他数据创建函数。

public boolean setEOLComment(Address address, String comment)

在给定的address处添加一个 EOL 注释。其他与注释相关的函数包括setPlateCommentsetPreCommentsetPostComment

public Function createFunction(Address entryPoint, String name)

entryPoint处创建一个具有给定name的函数。Ghidra 会尝试通过定位函数的返回指令来自动识别函数的结束。

public Data createAsciiString(Address address)

address处创建一个以空字符结尾的 ASCII 字符串。

public Data createAsciiString(Address address, int length)

address处创建指定length长度的 ASCII 字符串。如果length为零或更小,Ghidra 会尝试自动定位字符串的空字符终止符。

public Data createUnicodeString(Address address)

address处创建一个以空字符结尾的 Unicode 字符串。

程序类

Program类代表程序 API 层次结构的根节点,以及二进制文件数据模型的最外层。您通常会使用Program对象(通常是currentProgram)来访问二进制模型。常用的Program类成员函数包括以下内容:

public Listing getListing()

获取当前程序的Listing对象。

public FunctionManager getFunctionManager()

检索程序的 FunctionManager,该管理器提供对已在二进制文件中识别的所有函数的访问。此类提供了将 Address 映射回其包含的 FunctionFunction getFunctionContaining(Address addr))的功能。此外,它还提供了一个 FunctionIterator,当你想要处理程序中的每个函数时非常有用。

public SymbolTable getSymbolTable()

检索程序的 SymbolTable 对象。使用 SymbolTable,可以处理单个符号或遍历程序中的所有符号。

public Memory getMemory()

检索与此程序关联的 Memory 对象,该对象允许你操作原始程序字节内容。

public ReferenceManager getReferenceManager()

检索程序的 ReferenceManager 对象。ReferenceManager 可用于添加和删除引用以及检索多种类型引用的迭代器。

public Address getMinAddress()

返回程序中最低有效地址。这通常是二进制文件的基础内存地址。

public Address getMaxAddress()

返回程序中最高有效地址。

public LanguageID getLanguageID()

返回二进制文件语言规范的对象表示。可以使用 getIdAsString() 函数检索语言规范本身。

函数接口

Function 接口定义了函数对象所需的程序 API 行为。成员函数提供对与函数常相关的各种属性的访问,包括以下内容:

public String getPrototypeString(boolean formalSignature,

boolean includeCallingConvention)

返回 Function 对象的原型字符串。两个参数影响返回的原型字符串的格式。

public AddressSetView getBody()

返回包含函数代码体的地址集合。地址集合由一个或多个地址范围组成,允许函数的代码分布在多个非连续的内存范围中。获取 AddressIterator 以访问集合中的所有地址,或者获取 AddressRangeIterator 以遍历每个范围。请注意,必须使用 Listing 对象来检索函数体内的实际指令(参见 getInstructions)。

public StackFrame getStackFrame()

返回与函数相关联的堆栈帧。结果可用于获取关于函数局部变量和基于堆栈的参数布局的详细信息。

指令接口

Instruction 接口定义了指令对象所需的程序 API 行为。成员函数提供对与指令常相关的各种属性的访问,包括以下内容:

public String getMnemonicString()

返回指令的助记符。

public String getComment(int commentType)

返回与指令相关联的commentType注释,如果没有与该指令关联的指定类型的注释,则返回nullcommentType可能是EOL_COMMENTPRE_COMMENTPOST_COMMENTREPEATABLE_COMMENT之一。

public int getNumOperands()

返回与此指令关联的操作数数量。

public int getOperandType(int opIndex)

返回在OperandType类中定义的操作数类型标志的位掩码。

public String toString()

返回指令的字符串表示。

Ghidra 脚本示例

在本章的其余部分,我们将介绍一些脚本可以用来回答程序相关问题的常见情况。为简洁起见,本文仅展示每个脚本run函数的主体部分。

示例 1:枚举函数

很多脚本操作于单个函数。例如,生成以特定函数为根的调用树,生成函数的控制流图,以及分析程序中每个函数的堆栈帧。清单 14-1 遍历程序中的每个函数,并打印每个函数的基本信息,包括函数的起始和结束地址、函数参数的大小以及函数局部变量的大小。所有输出都发送到控制台窗口。

// ch14_1_flat.java

void run() throws Exception {

  int ptrSize = currentProgram.getDefaultPointerSize();

 ➊ Function func = getFirstFunction();

  while (func != null && !monitor.isCancelled()) {

     String name = func.getName();

     long addr = func.getBody().getMinAddress().getOffset();

     long end = func.getBody().getMaxAddress().getOffset();

  ➋ StackFrame frame = func.getStackFrame();

  ➌ int locals = frame.getLocalSize();

  ➍ int args = frame.getParameterSize();

     printf("Function: %s, starts at %x, ends at %x\n", name, addr, end);

     printf("  Local variable area is %d bytes\n", locals);

     printf("  Arguments use %d bytes (%d args)\n", args, args / ptrSize);

  ➎ func = getFunctionAfter(func);

  }

}

清单 14-1:函数枚举脚本

该脚本使用 Ghidra 的 Flat API 遍历从第一个函数 ➊开始的所有函数,并依次向前推进 ➎。获取每个函数堆栈帧的引用 ➋,并检索局部变量的大小 ➌以及基于堆栈的参数的大小 ➍。在继续迭代之前,会打印每个函数的摘要。

示例 2:枚举指令

在给定的函数内,您可能想要枚举每一条指令。清单 14-2 计算当前光标位置所标识的函数中包含的指令数量:

// ch14_2_flat.java

public void run() throws Exception {

   Listing plist = currentProgram.getListing();

➊ Function func = getFunctionContaining(currentAddress);

   if (func != null) {

   ➋ InstructionIterator iter = plist.getInstructions(func.getBody(), true);

      int count = 0;

      while (iter.hasNext() && !monitor.isCancelled()) {

         count++;

         Instruction ins = iter.next();

      }

   ➌ popup(String.format("%s contains %d instructions\n",

                          func.getName(), count));

   }

   else {

      popup(String.format("No function found at location %x",

                          currentAddress.getOffset()));

   }

}

清单 14-2:指令枚举脚本

该函数首先获取包含光标的函数的引用 ➊。如果找到该函数,下一步是使用程序的Listing对象获取该函数的InstructionIterator ➋。迭代循环计算检索到的指令数量,并通过弹出消息对话框 ➌将总数报告给用户。

示例 3:枚举交叉引用

迭代交叉引用可能会令人困惑,因为可以访问交叉引用数据的函数数量以及代码交叉引用是双向的。要获取所需的数据,您需要访问适合您情况的正确类型的交叉引用。

在我们第一个交叉引用示例中,如清单 14-3 所示,我们通过遍历函数中的每条指令,检查该指令是否调用了其他函数,从而获取函数中所有调用的列表。实现这一点的一种方法是解析getMnemonicString函数的结果,查找call指令。这并不是一个很便携或者高效的解决方案,因为调用函数的指令在不同的处理器类型之间有所不同,而且还需要额外的解析来确定到底调用了哪个函数。交叉引用避免了这些困难,因为它们与处理器无关,并且直接告诉我们交叉引用的目标。

// ch14_3_flat.java

void run() throws Exception {

   Listing plist = currentProgram.getListing();

➊ Function func = getFunctionContaining(currentAddress);

   if (func != null) {

      String fname = func.getName();

      InstructionIterator iter = plist.getInstructions(func.getBody(), true);

   ➋ while (iter.hasNext() && !monitor.isCancelled()) {

         Instruction ins = iter.next();

         Address addr = ins.getMinAddress();

         Reference refs[] = ins.getReferencesFrom();

     ➌ for (int i = 0; i < refs.length; i++) {

        ➍ if (refs[i].getReferenceType().isCall()) {

               Address tgt = refs[i].getToAddress();

               Symbol sym = getSymbolAt(tgt);

               String sname = sym.getName();

               long offset = addr.getOffset();

               printf("%s calls %s at 0x%x\n", fname, sname, offset);

            }

         }

      }

   }

}

清单 14-3:枚举函数调用

危险函数

C 语言中的strcpysprintf函数被认为是危险的,因为它们允许将数据无限制地复制到目标缓冲区。尽管程序员可以通过检查源缓冲区和目标缓冲区的大小来安全地使用这些函数,但这些检查常常被那些不了解这些函数危险性的程序员忽略。例如,strcpy函数的声明如下:

char *strcpy(char *dest, const char *source);

strcpy函数将源缓冲区中所有字符(包括遇到的第一个空字符终止符)复制到指定的目标缓冲区(dest)。根本问题在于,无法在运行时确定任何数组的大小,而strcpy无法判断目标缓冲区是否足够大,以容纳从源缓冲区复制的所有数据。这种未经检查的复制操作是缓冲区溢出漏洞的主要原因。

我们首先获取包含光标位置的函数的引用 ➊。接下来,我们遍历该函数中的每条指令 ➋,对于每条指令,我们会遍历每个来自该指令的交叉引用 ➌。我们只对调用其他函数的交叉引用感兴趣,因此我们必须测试getReferenceType的返回值 ➍,以确定isCall是否为true

示例 4:查找函数调用

交叉引用也对于识别每个引用特定位置的指令非常有用。在清单 14-4 中,我们遍历所有指向特定符号的交叉引用(与之前示例中的from不同,这次是to)。

   // ch14_4_flat.java

➊ public void list_calls(Function tgtfunc) {

     String fname = tgtfunc.getName();

     Address addr = tgtfunc.getEntryPoint();

     Reference refs[] = getReferencesTo(addr);

  ➋ for (int i = 0; i < refs.length; i++) {

     ➌ if (refs[i].getReferenceType().isCall()) {

           Address src = refs[i].getFromAddress();

        ➍ Function func = getFunctionContaining(src);

           if (func.isThunk()) {

              continue;

           }

           String caller = func.getName();

           long offset = src.getOffset();

        ➎ printf("%s is called from 0x%x in %s\n", fname, offset, caller);

        }

      }

   }

➏ public void getFunctions(String name, List<Function> list) {

      SymbolTable symtab = currentProgram.getSymbolTable();

      SymbolIterator si = symtab.getSymbolIterator();

      while (si.hasNext()) {

        Symbol s = si.next();

        if (s.getSymbolType() != SymbolType.FUNCTION || s.isExternal()) {

          continue;

       }

       if (s.getName().equals(name)) {

          list.add(getFunctionAt(s.getAddress()));

       }

    }

 }

 public void run() throws Exception {

    List<Function> funcs = new ArrayList<Function>();

    getFunctions("strcpy", funcs);

    getFunctions("sprintf", funcs);

    funcs.forEach((f) -> list_calls(f));

 }

清单 14-4:枚举函数的调用者

在这个示例中,我们编写了辅助函数getFunctions ➏,用于收集与我们关注的函数相关的Function对象。对于每个感兴趣的函数,我们调用第二个辅助函数list_calls ➊来处理所有对该函数的交叉引用 ➋。如果交叉引用类型被确定为调用类型的交叉引用 ➌,则会检索调用函数 ➍并将其名称显示给用户 ➎。除此之外,这种方法还可以用来创建一个低成本的安全分析工具,突出显示对strcpysprintf等函数的所有调用。

示例 5:模拟汇编语言行为

有多种原因可能需要编写脚本来模拟你正在分析的程序的行为。例如,您正在研究的程序可能是自我修改的,就像许多恶意软件程序一样,或者程序可能包含一些在运行时需要解码的数据。在不运行程序并从正在运行的进程内存中提取修改后的数据的情况下,你如何理解程序的行为呢?

如果解码过程不太复杂,你可能能够快速编写一个脚本,执行与程序运行时相同的操作。通过使用脚本以这种方式解码数据,可以在你不清楚程序执行内容或无法访问运行程序的平台时,避免运行程序。例如,没有 MIPS 执行环境时,你无法执行 MIPS 二进制文件并观察它可能执行的任何数据解码。然而,你可以编写一个 Ghidra 脚本来模拟二进制文件的行为,并在你的 Ghidra 项目中进行所需的更改,完全不需要 MIPS 执行环境。

以下 x86 代码是从一个 DEFCON Capture the Flag 二进制文件中提取的:^(1)

08049ede  MOV    dword ptr [EBP + local_8],0x0

        LAB_08049ee5

08049ee5  CMP    dword ptr [EBP + local_8],0x3c1

08049eec  JA     LAB_08049f0d

08049eee  MOV    EDX,dword ptr [EBP + local_8]

08049ef1  ADD    EDX,DAT_0804b880

08049ef7  MOV    EAX,dword ptr [EBP + local_8]

08049efa  ADD    EAX,DAT_0804b880

08049eff  MOV    AL,byte ptr [EAX]=>DAT_0804b880

08049f01  XOR    EAX,0x4b

08049f04  MOV    byte ptr [EDX],AL=>DAT_0804b880

08049f06  LEA    EAX=>local_8,[EBP + -0x4]

08049f09  INC    dword ptr [EAX]=>local_8

08049f0b  JMP    LAB_08049ee5

这段代码解码了一个嵌入在程序二进制文件中的私钥。使用列表 14-5 中的脚本,我们可以在不运行程序的情况下提取私钥。

// ch14_5_flat.java

public void run() throws Exception {

   int local_8 = 0;

   while (local_8 <= 0x3C1) {

      long edx = local_8;

      edx = edx + 0x804B880;

      long eax = local_8;

      eax = eax + 0x804B880;

      int al = getByte(toAddr(eax));

      al = al ^ 0x4B;

      setByte(toAddr(edx), (byte)al);

      local_8++;

   }

}

列表 14-5:使用 Ghidra 脚本模拟汇编语言

列表 14-5 是根据以下机械规则生成的前述汇编语言序列的相当字面翻译:

  • 对于汇编代码中使用的每个栈变量和寄存器,声明一个适当类型的脚本变量。

  • 对于每个汇编语言语句,编写一个模仿其行为的语句。

  • 通过读取和写入脚本中声明的相应变量来模拟栈变量的读写。

  • 使用getBytegetWordgetDwordgetQword函数模拟从非栈位置读取数据,具体取决于读取的数据量(1、2、4 或 8 字节)。

  • 使用setBytesetWordsetDwordsetQword函数来模拟写入非栈位置,具体取决于写入的数据量。

  • 如果代码包含一个终止条件不明显的循环,首先可以使用一个无限循环,比如 while(true){...},然后在遇到导致循环终止的语句时插入一个 break 语句。

  • 当汇编代码调用函数时,事情会变得更加复杂。为了正确模拟汇编代码的行为,你必须模仿已调用函数的行为,包括提供一个在模拟的代码上下文中有意义的返回值。

随着汇编代码复杂性的增加,编写一个能仿真汇编语言序列所有方面的脚本变得更加具有挑战性,但你不必完全理解你正在仿真的代码是如何工作的。一次翻译一到两条指令。如果每条指令都正确翻译,那么整个脚本就应该能够正确地模拟原始汇编代码的完整功能。完成脚本后,你可以使用该脚本更好地理解底层的汇编代码。你将会在 第二十一章 中看到这种方法,以及更多通用的仿真功能,届时我们将讨论如何分析混淆的二进制文件。

例如,一旦我们翻译了示例算法,并花些时间考虑它是如何工作的,我们可以将仿真脚本简化如下:

public void run() throws Exception {

   for (int local_8 = 0; local_8 <= 0x3C1; local_8++) {

      Address addr = toAddr(0x804B880 + local_8);

      setByte(addr, (byte)(getByte(addr) ^ 0x4B));

   }

}

一旦脚本执行,你可以看到解码后的私钥从地址0x804B880开始。如果你不想在仿真代码时修改 Ghidra 数据库,可以将 setByte 函数调用替换为 printf 函数调用,这样就会把结果输出到 CodeBrowser 控制台,或者将数据写入磁盘文件以存储二进制数据。别忘了,除了 Ghidra 的 Java API 外,你还可以访问所有标准的 Java API 类以及你选择安装到系统上的其他 Java 包。

总结

脚本编写提供了一种强大的手段,用于自动化重复任务并扩展 Ghidra 的功能。本章介绍了如何使用 Java 和 Python 编辑和构建新的脚本,Ghidra 的集成功能可以在 CodeBrowser 环境内构建、编译和运行基于 Java 的脚本,让你在无需深入理解 Ghidra 开发环境的复杂性情况下,扩展 Ghidra 的功能。第十五章 和 第十六章介绍了 Eclipse 集成以及在无头模式下运行 Ghidra 的能力。

第十八章:ECLIPSE 和 GHIDRADEV**

图片

Ghidra 附带的脚本以及我们在 第十四章 中创建的脚本相对简单。所需的编码非常少,这大大简化了开发和测试阶段。Ghidra 的脚本管理器提供的基本脚本编辑器适合快速且简单的工作,但它缺乏管理复杂项目的能力。对于更复杂的任务,Ghidra 提供了一个插件,方便在 Eclipse 开发环境中进行开发。在本章中,我们将讨论 Eclipse 以及它在开发更高级 Ghidra 脚本中的作用。我们还将展示如何使用 Eclipse 创建新的 Ghidra 模块,并在后续章节中重新讨论这一主题,随着我们扩展 Ghidra 的加载器库并讨论 Ghidra 处理器模块的内部工作原理。

Eclipse

Eclipse 是一个集成开发环境(IDE),被许多 Java 开发者使用,这使它成为 Ghidra 开发的自然选择。虽然可以在同一台机器上运行 Eclipse 和 Ghidra,而两者之间没有任何交互,但集成这两者可以大大简化 Ghidra 开发过程。如果没有集成,Eclipse 就只是 Ghidra 环境外的另一种脚本编辑选项。通过将 Eclipse 与 Ghidra 集成,你会突然拥有一个功能丰富的 IDE,其中包括 Ghidra 特定的功能、资源和模板,来促进你的 Ghidra 开发过程。集成 Eclipse 和 Ghidra 并不需要太多工作,你只需要为两者提供一些关于彼此的信息,便能实现它们的共同使用。

Eclipse 集成

为了让 Ghidra 与 Eclipse 配合使用,Eclipse 需要安装 GhidraDev 插件。你可以通过 Ghidra 或 Eclipse 内部将两者集成。有关两种集成方式的说明,请参阅位于 Ghidra 安装目录下的 Extensions/Eclipse/GhidraDev 目录中的 GhidraDev_README.html 文档。

尽管书面文档会指导你完成整个过程,但最简单的起点是选择一个需要 Eclipse 的 Ghidra 操作,例如使用 Eclipse 编辑脚本(参见 图 14-2)。如果你选择这个选项,并且之前没有集成 Eclipse 和 Ghidra,你将被提示输入所需的目录信息以完成连接。根据你的配置,你可能需要提供 Eclipse 安装目录、Eclipse 工作区目录、Ghidra 安装目录、Eclipse 插件目录,可能还需要提供用于与 Eclipse 进行脚本编辑通信的端口号。

Ghidra 的文档将帮助你克服集成过程中遇到的任何障碍。真正敢于冒险的人可以探索 Ghidra 源代码库中的 Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/eclipse 目录下的集成插件。

启动 Eclipse

一旦 Ghidra 和 Eclipse 成功集成,你就可以使用它们编写 Ghidra 脚本和插件。在 Ghidra 与 Eclipse 集成后第一次启动 Eclipse 时,你可能会看到图 15-1 中显示的对话框,要求在 Ghidra 实例和 Eclipse GhidraDev 实例之间建立通信路径。

继续前进,你将看到 Eclipse IDE 欢迎界面,如图 15-2 所示。这个 Eclipse 实例的菜单栏上新增了一个选项:GhidraDev。我们将使用这个菜单来创建更复杂的脚本和 Ghidra 工具。

Ghidra Eclipse 的登录页面,欢迎来到 Eclipse IDE for Java 开发者工作台,包括多个教程、文档以及有关 Eclipse IDE 和 Java 的信息,这些内容应该为新手用户提供必要的背景支持,同时也为有经验的用户提供可选的复习材料。为了继续使用 Ghidra,我们将重点讨论如何使用 GhidraDev 菜单来增强 Ghidra 的现有功能,构建新功能,并定制 Ghidra,以改善我们的逆向工程工作流程。

image

图 15-1:GhidraDevUser 同意对话框

image

图 15-2:Eclipse IDE 欢迎界面

使用 Eclipse 编辑脚本

一旦 GhidraDev 插件安装在 Eclipse 中,你就可以使用 Eclipse IDE 创建新脚本或编辑现有脚本。随着我们从使用 Ghidra 的脚本管理器来创建和编辑脚本,转向使用 Eclipse,有一点值得记住,虽然可以通过脚本管理器启动 Eclipse,但这仅限于编辑现有脚本(见图 14-2)。如果你想使用 Eclipse 编辑新脚本,你需要先启动 Eclipse,然后使用 GhidraDev 菜单来创建新脚本。无论是你自己启动 Eclipse,还是通过 Ghidra 的脚本管理器进入 Eclipse,在本章剩余部分,我们都将使用 Eclipse,而不是脚本管理器的基本编辑器,来创建和修改 Ghidra 的脚本和模块。

要编辑我们在“编辑脚本示例:正则搜索”中创建的第一个脚本,请从 Eclipse 菜单中选择 文件打开文件,并导航到脚本 FindStringByRegex.java。这将脚本在 Eclipse IDE 中打开,您可以开始使用 Eclipse 提供的丰富编辑选项。图 15-3 展示了脚本的前几行,其中注释和导入部分已被折叠。折叠行是 Eclipse IDE 的默认功能,如果您在 Ghidra 提供的基本编辑器和 Eclipse 之间切换,这可能会引起一些困惑。

image

图 15-3:Eclipse 编辑器展示的 FindStringsByRegex

默认情况下,只显示一行注释。您可以点击图标展开(点击第 2 行左侧的 + 图标)内容并显示所有注释,或者根据需要折叠(点击第 34 行左侧的 图标)内容。第 26 行的 import 语句也有类似的情况。将鼠标悬停在任何折叠部分的图标上,会在弹出窗口中显示隐藏的内容。

在我们开始构建扩展 Ghidra 功能的示例之前,您需要更多地了解 GhidraDev 菜单和 Eclipse IDE。让我们将焦点重新放回到 GhidraDev 菜单,探索各种选项以及它们在实际应用中的使用方式。

GhidraDev 菜单

展开的 GhidraDev 菜单如图 15-4 所示,包含五个选项,您可以利用这些选项来控制开发环境并处理文件。本章我们将重点讨论 Java 开发,虽然在一些窗口中 Python 也可作为选项。

image

图 15-4:GhidraDev 菜单选项

GhidraDev ▸ 新建

GhidraDev ▸ 新建菜单提供了三个子菜单选项,如图 15-5 所示。所有三个选项都会启动向导,指导您完成相关的创建过程。我们从最简单的选项开始,即创建一个新的 Ghidra 脚本。这是创建脚本的一种替代路径,和第十四章中讨论的路径有所不同。

image

图 15-5:GhidraDev ▸ 新建子菜单

创建脚本

使用 GhidraDev ▸ 新建 ▸ Ghidra 脚本创建新脚本时,会弹出一个对话框,允许您输入有关新脚本的信息。一个已填充内容的对话框示例如图 15-6 所示。除了目录和文件信息外,该对话框还会收集我们手动输入到脚本管理器基本编辑器中的相同元数据。

image

图 15-6:创建 Ghidra 脚本对话框

对话框底部的完成按钮生成了图 15-7 中所示的脚本模板。在图 15-6 中输入的元数据被包含在脚本顶部的注释部分。此内容与我们在第十四章中看到的元数据格式相同(见图 14-4 的顶部)。当您在 Eclipse 中编辑此脚本时,与脚本中每个TODO项相关的任务标签(如图 15-7 第 14 行左侧的剪贴板图标)标识出需要处理的位置。您可以随意删除和插入任务标签。

image

图 15-7:GhidraDev ▸ 新建脚本脚本外壳

Eclipse 并不会像 Ghidra 基本编辑器那样预加载您的脚本与 import 语句列表(参见图 14-4)。不用担心,Eclipse 通过在您使用需要相关 import 语句的内容时提醒您来帮助管理 import 语句。例如,如果我们将图 15-7 中的 TODO 注释替换为 Java ArrayList 的声明,Eclipse 会在该行添加一个错误标签并将 ArrayList 用红色下划线标出。将鼠标悬停在错误标签或 ArrayList 上时,会弹出一个窗口,建议快速修复此问题,如图 15-8 所示。

image

图 15-8:Eclipse 快速修复选项

选择建议列表中的第一个选项会指示 Eclipse 将选定的 import 语句添加到脚本中,如图 15-9 所示。虽然在 CodeBrowser 脚本管理器中创建新脚本时,加载潜在的 import 语句列表是很有帮助的,但在 Eclipse 中它并不是那么重要。

image

图 15-9:应用快速修复导入后的 Eclipse

创建脚本项目

GhidraDev ▸ 新建菜单中的第二个选项创建一个新的脚本项目,如图 15-10 所示。我们将第一个脚本项目命名为CH15_ProjectExample_linked,并将其放在我们为 Eclipse 设置的默认目录中。创建运行配置复选框允许您创建一个运行配置,该配置为 Eclipse 提供启动 Ghidra 所需的信息(命令行参数、目录路径等),并使我们能够使用 Eclipse 在 Ghidra 中运行和调试脚本。保持此复选框的默认状态,即选中状态。点击完成以使用默认格式完成脚本的创建,该格式将脚本项目与您的主目录链接。

image

图 15-10:Eclipse Ghidra 脚本项目对话框

我们将创建第二个脚本项目CH15_ProjectExample,这次会选择“下一步”按钮。点击“下一步”会出现一个对话框,里面有两个默认勾选的链接选项(因此我们第一个项目名称带有linked扩展名)。第一个选项创建一个指向你的主脚本目录的链接。第二个选项让你链接到 Ghidra 安装脚本目录。在此,“链接”意味着表示你的主脚本目录和/或 Ghidra 脚本目录的文件夹将被添加到新项目中,这样你在项目开发时可以方便地访问这些目录中的任何脚本。

选择或取消选择这些选项后点击“完成”按钮的结果将在本章后面讨论 Eclipse 包资源管理器时变得更加清晰。对于第二个脚本项目,如图 15-11 所示,取消勾选第一个链接复选框。

image

图 15-11:Eclipse 脚本项目配置选项

创建模块项目

GhidraDev ▸ 新建菜单中的最后一个选项会创建一个 Ghidra 模块项目。^(1) 这与 Ghidra 模块(例如分析器、加载器等)不同,Ghidra 模块项目将为新的 Ghidra 模块聚合代码、相关的帮助文件、文档和其他资源,例如图标。此外,它还允许你控制新模块与 Ghidra 中其他模块的交互方式。我们将在本章及未来的章节中演示 Ghidra 模块的具体应用。

选择“新建 ▸ Ghidra 模块项目”会显示如图 15-12 所示的对话框,这个对话框应该很熟悉,因为它与脚本项目对话框完全相同。我们将新项目命名为CH15_ModuleExample,以便在包资源管理器中轻松识别。

image

图 15-12:Eclipse 模块项目对话框

在此步骤点击“下一步”可以让你基于现有的 Ghidra 模板来创建模块,如图 15-13 所示。默认情况下,所有选项都已被选择。你可以根据开发目标选择包括所有、部分或没有任何模板。你选择的任何选项将会在包资源管理器中作为项目分组。如果是我们的话,我们已经取消了所有选项。

虽然大多数选择会生成带有任务标签的相关源代码模板,但有两个例外。首先,如果你没有选择任何模块模板,将不会有模板文件。其次,处理器模块不会生成模板文件,但会生成其他支持内容。(处理器模块将在第十八章中讨论。)

image

图 15-13:Ghidra 模块项目的模板选项

现在您已经了解了如何创建 Ghidra 脚本、脚本项目和模块项目,让我们将焦点转向 Eclipse 包资源管理器,以更好地理解如何使用我们新创建的内容。^(2)

导航包资源管理器

Eclipse 的包资源管理器是您完成 Ghidra 扩展所需的 Ghidra 文件的入口。在这里,我们展示了层级组织结构,并深入了解通过 GhidraDev 菜单创建的 Ghidra 项目和模块的示例。图 15-14 显示了一个示例的 Eclipse 包资源管理器窗口,包含了我们在本章早些时候创建的项目以及一些我们创建的其他项目,用于演示不同选项对最终包资源管理器内容的影响。

image

图 15-14:包资源管理器,包含示例模块和项目

我们首先来看这两个脚本项目。CH15_ProjectExample_linked是我们通过勾选两个链接选项创建的脚本项目(请参见图 15-11)。紧接着,我们看到一个类似的项目,CH15_ProjectExample,但在这种情况下,没有勾选任何链接选项。图 15-15 显示了CH15_ProjectExample的部分扩展包资源管理器条目。

以下四个组件包含在此脚本项目中:

JUnit4 这是一个开源的 Java 单元测试框架。更多信息,请访问junit.org/junit4/index.html

JRE 系统库 这是 Java 运行时环境系统库。

引用的库 这些是引用的库,它们不是 JRE 系统库的一部分,但属于我们的 Ghidra 安装的一部分。

Ghidra 这是您当前 Ghidra 安装的目录。我们已经扩展了此目录,以便您可以看到在第三章(见图 3-1)中介绍并在本书中使用的熟悉的文件结构。

image

图 15-15:包资源管理器脚本项目条目 没有链接

比较图 15-15 中的内容与图 15-16 中显示的CH15_ProjectExample_linked的扩展内容。在这个脚本项目中,我们选择了两个链接选项。将用户主脚本目录链接后,会在项目层次结构中显示Home scripts条目,并为我们提供便捷访问我们之前编写的脚本,以供示例使用或修改。

image

图 15-16:包资源管理器脚本项目条目链接

链接 Ghidra 安装脚本目录会导致图 15-16 中所有以Ghidra开头并以scripts结尾的文件夹。每一个文件夹都对应于 Ghidra 安装中的Ghidra/Features目录中的一个脚本目录。^(3) 展开这些文件夹中的任何一个,可以访问到你 Ghidra 安装中包含的每个脚本的源代码。像主页脚本一样,这些脚本可以作为修改或创建新脚本的基础示例。虽然你不能在 Ghidra 脚本管理器的基本编辑器中覆盖这些脚本,但你可以在 Eclipse 和 Ghidra 项目环境外的其他编辑器中编辑它们。当你完成创建或编辑新脚本时,你可以将其保存在脚本项目中的相应脚本目录中,并且下次打开 Ghidra 脚本管理器时可以使用它。

现在我们已经看过了 Eclipse 包资源管理器中的脚本,让我们看看我们构建的 Ghidra 模块项目是如何表示的。在包资源管理器中,我们项目的部分展开内容如图 15-17 所示。

image

图 15-17: CH15_ModuleExampleModule 的包资源管理器层次结构

我们是否再次构建那个脚本?

在第十四章中,我们展示了一个在 Ghidra 脚本管理器环境中的玩具示例,我们修改了现有的脚本CountAndSaveStrings并用它构建了一个名为FindStringsByRegex的新脚本。以下步骤将在 Eclipse IDE 中执行相同的任务:

  1. 在 Eclipse 中搜索CountAndSaveStrings.java(CTRL-SHIFT-R)。

  2. 双击以在 Eclipse 编辑器中打开文件。

  3. 用新类和注释替换现有的类和注释。

  4. 将文件(EclipseFindStringByRegex.java)保存到推荐的ghidra_scripts目录中。

  5. 从 Ghidra 的脚本管理器窗口中运行新脚本。

你可以手动启动 Ghidra 来访问脚本管理器窗口。或者,你可以在 Eclipse IDE 中选择“以此方式运行”选项,这将显示图 15-18 中的对话框。第一个选项会为你启动 Ghidra,第二个选项会启动一个无 GUI 版本的 Ghidra,这是第十六章的主题。

image

图 15-18:Eclipse“以此方式运行”选项

一旦 Ghidra 启动,你可以从脚本管理器运行脚本并使用 Eclipse 进行编辑。

模块项目包括以下新元素:

src/main/java 这是源代码的位置。如果你创建了一个有模板的模块类型,相关的.java文件会放在这个目录下。

src/main/help 当你创建或扩展内容时,你有机会通过使用此目录中的文件和信息将有用的信息添加到 Ghidra 帮助中。

src/main/resourcessrc/main 目录中的许多其他条目一样,展开此内容将带您进入一个 README.txt 文件,提供有关目录目的和使用方法的更多信息。例如,src/main/resources/images/README.txt 文件告知您,它是存储与模块相关的任何图像或图标文件的位置。

ghidra_scripts 这是存储特定于此模块的 Ghidra 脚本的位置。

data 此文件夹包含与此模块一起使用的任何独立数据文件。(虽然不禁止与其他模块类型一起使用,但此文件夹主要用于处理器模块,并在第十八章中进行了讨论。)

lib 模块所需的任何.jar文件应存储在此文件夹中。

os 此文件夹内有子目录,用于存储 linux64、oxs64 和 win64 的任何本地二进制文件,模块可能依赖于这些文件。

src 此目录用于存储单元测试用例。

build.gradle Gradle 是一个开源构建系统。此文件用于构建您的 Ghidra 扩展。

extension.properties 此文件存储有关扩展的元数据。

Module.manifest 您可以在此文件中输入有关模块的配置信息等。

您可能已经注意到,在图 15-14 中,我们创建了额外的 Test 模块(AnalyzerTestAllTypeTestLoaderTest)。每个模块都使用不同的模块模板选项组合创建(见图 15-13),这会为每个项目实例化不同的文件集。使用这些模板作为您项目的起点时,了解 Eclipse 和 Ghidra 已为您完成了多少工作——以及还有多少工作需要您完成,是非常有用的。

让我们从我们创建的 AnalyzerTest 目录开始,演示分析器模板。展开 src/main/java 目录,找到一个名为 AnalyzerTestAnalyzer.java 的文件。这个名字是模块名(AnalyzerTest)和模板类型(Analyzer)的拼接。双击此文件以在编辑器中打开它,并查看图 15-19 中显示的代码。像本章前面的脚本模板一样,Eclipse IDE 提供了带有相关注释的任务标签,帮助我们构建分析器,并提供扩展和折叠内容的选项。LoaderTest 模块包含构建加载器的模板,后者在第十七章中进一步讨论。剩下的模块 AllTypeTest 是跳过模块模板选项时的默认模块。它将所有模板填充到 src/main/java 目录中,如图 15-20 所示。

既然我们已经了解了 Ghidra 和 Eclipse 在创建新模块时的帮助作用,现在让我们利用这些信息构建一个新的分析器。

image

图 15-19:模块的默认分析器模板(已折叠的注释、导入和函数)

image

图 15-20:示例默认模块源代码内容

示例:Ghidra 分析器模块项目

在掌握了 Eclipse 集成基础知识后,让我们逐步构建一个简单的 Ghidra 分析器,以识别我们列表中的潜在 ROP gadgets。我们将采用简化的软件开发流程,因为这只是一个简单的演示项目。我们的流程包括以下步骤:

  1. 定义问题。

  2. 创建 Eclipse 模块。

  3. 构建分析器。

  4. 将分析器添加到我们的 Ghidra 安装中。

  5. 测试我们 Ghidra 安装中的分析器。

什么是 ROP gadget,为什么我们关心它?

对于那些不熟悉利用开发的人,ROP 代表面向返回的编程。一种旨在阻止原始 shellcode 注入的软件安全缓解措施是确保没有可写的内存区域同时也是可执行的。这类缓解措施通常被称为不可执行(NX)数据执行保护(DEP),因为这样就无法将 shellcode 注入到内存中(必须是可写的),然后将控制权转移到该 shellcode 上(必须是可执行的)。

ROP 技术旨在劫持程序的栈(通常通过基于栈的缓冲区溢出),将精心构造的一系列返回地址和数据放入栈中。在溢出发生后的某个时刻,程序开始使用攻击者提供的返回地址,而不是由正常程序执行放置在栈上的返回地址。攻击者放置在栈上的返回地址指向程序内存位置,这些位置已经包含了代码,这些代码通常是由于正常的程序和库加载操作而存在的。因为被利用程序的原始作者并未设计程序来为攻击者执行任务,所以攻击者通常需要挑选和组合这些现有代码的小片段。

一个ROP gadget是这些代码片段中的单个部分,而序列化机制通常依赖于该 gadget 以一个返回(因此是面向返回的)指令结束,这个指令从现在由攻击者控制的栈中检索一个地址,将控制权转移到下一个 gadget。一个 gadget 通常执行一个非常简单的任务,例如从栈中加载寄存器。以下简单的 gadget 可用于初始化 x86-64 系统上的 RAX:

POP RAX  ; pop the next item on the attacker-controlled stack into RAX

RET      ; transfer control to the address contained in the next stack item

由于每个可利用的程序都不同,攻击者不能依赖于任何给定二进制文件中存在特定的 gadget 集。自动化的 gadget 查找器是用于搜索二进制文件中可能用作 gadget 的指令序列的工具,并将这些 gadget 提供给攻击者,攻击者必须决定哪些 gadget 在构造攻击时有用。最先进的 gadget 查找器可以推断 gadget 的语义,并自动地将多个 gadget 排列成执行特定操作的序列,省去了攻击者自己处理的麻烦。

步骤 1:定义问题

我们的任务是设计并开发一个指令分析器,用于识别二进制文件中的简单 ROP gadget。该分析器需要添加到 Ghidra 中,并在 Ghidra 分析器菜单中作为可选分析器提供。

步骤 2:创建 Eclipse 模块

我们使用 GhidraDev ▸ New ▸ Ghidra Module Project 创建一个名为 SimpleROP 的模块,采用分析器模块模板。这将在 SimpleROP 模块的 src/main/java 文件夹中创建一个名为 SimpleROPAnalyzer.java 的文件。结果的包资源管理器视图如 图 15-21 所示。

image

图 15-21:包资源管理器 src/main SimpleROP 的条目

步骤 3:构建分析器

生成的部分 SimpleROPAnalyzer.java 代码如 图 15-22 所示。各个函数已经被折叠,我们可以看到提供的所有分析方法。Eclipse 在我们开发代码时,如果需要导入,会推荐相应的导入语句,这样我们可以直接跳入编写所需任务的代码,并在 Eclipse 检测到我们需要时,自动添加推荐的 import 语句。

image

图 15-22: SimpleROPAnalyzer 模板

图 15-22 中的六个任务标签(位于行号的左侧)标示了我们应该开始开发的位置。我们将在处理每个任务时扩展相关部分,并包含每个任务的前后内容。(请注意,为了可读性,一些内容将被换行或重新格式化,并且注释将尽量简化以节省空间。)

对于功能性,我们将依赖以下类级声明:

   private int gadgetCount = 0;         // Counts the number of gadgets

   private BufferedWriter outFile;      // Output file

// List of "interesting" instructions

   private List<String> usefulInstructions = Arrays.asList(

       "NOP", "POP", "PUSH", "MOV", "ADD", "SUB", "MUL", "DIV", "XOR");

// List of "interesting" instructions that don’t have operands

   private List<String> require0Operands = Arrays.asList("NOP");

// List of "interesting" instructions that have one operand

   private List<String> require1RegOperand = Arrays.asList("POP", "PUSH");

// List of "interesting" instructions for which we want the first

// parameter to be a register

   private List<String> requireFirstRegOperand = Arrays.asList(

        "MOV", "ADD", "SUB", "MUL", "DIV", "XOR");

// List of "start" instructions that have ZERO operands

   private List<String> startInstr0Params = Arrays.asList("RET");

// List of "start" instructions that have ONE register operand

   private List<String> startInstr1RegParam = Arrays.asList("JMP", "CALL");

每个声明旁边的注释描述了每个变量的用途。各种 List 变量包含了我们的 gadget 所需的指令,并根据它们所需的操作数数量和类型,以及该指令是否是我们 gadget 合法起始指令来分类这些指令。由于我们的 gadget 构建算法是从内存中倒着进行的,start 这里实际上指的是我们算法的起点。在运行时,这些起始指令实际上将是给定 gadget 中最后执行的指令。

步骤 3-1:记录类文档

当我们展开第一个任务标签时,我们会看到以下任务描述:

/**

 * TODO: Provide class-level documentation that describes what this

 * analyzer does.

 */

将现有的TODO注释替换为描述分析器功能的注释:

/**

 * This analyzer searches through a binary for ROP gadgets.

 * The address and contents of each gadget are written to a

 * file called inputfilename_gadgets.txt in the user’s home directory.

 */
步骤 3-2:命名并描述我们的分析器

展开下一个任务标签会显示一个TODO注释和我们需要编辑的代码行。在 Eclipse IDE 中,需要修改的代码以紫色字体显示,并且代码的名称表明了相关任务的内容。第二项任务包含以下内容:

// TODO: Name the analyzer and give it a description.

public SimpleROPAnalyzer() {

   super("My Analyzer",

         "Analyzer description goes here",

          AnalyzerType.BYTE_ANALYZER);

}

这两个字符串需要替换为有意义的内容。此外,还需要指定分析器类型。为了促进分析器之间的依赖解决,Ghidra 将分析器分为以下几类:字节、数据、函数、函数修饰符、函数签名和指令。在这种情况下,我们正在构建一个指令分析器。最终的代码如下:

public SimpleROPAnalyzer() {

   super("SimpleROP",

         "Search a binary for ROP gadgets",

          AnalyzerType.INSTRUCTION_ANALYZER);

}
步骤 3-3:确定我们的分析器是否应该是默认分析器

第三项任务要求我们返回true,如果分析器应该默认启用的话:

public boolean getDefaultEnablement(Program program) {

   // TODO: Return true if analyzer should be enabled by default

   return false;

}

我们不希望此分析器默认启用,因此无需修改代码。

步骤 3-4:确定输入是否适合此分析器

第四项任务要求我们确定我们的分析器是否与程序内容兼容:

public boolean canAnalyze(Program program) {

   // TODO: Examine 'program' to determine of this analyzer

   // should analyze it.

   // Return true if it can.

   return false;

}

由于此分析器仅用于演示目的,我们假设输入文件与我们的分析兼容,并直接返回true。实际上,我们会在使用分析器之前添加代码来验证分析文件的兼容性。例如,我们可能仅在确定文件是 x86 二进制文件后才返回true。有关此验证的工作示例,可以在您的 Ghidra 安装中找到大多数分析器(Ghidra/Features/Base/lib/Base-src/Ghidra/app/analyzers),可以通过 Eclipse 中的模块目录访问:

public boolean canAnalyze(Program program) {

   return true;

}
步骤 3-5:注册分析器选项

第五项任务为我们提供了指定任何特殊选项的机会,这些选项将呈现给我们的分析器用户:

public void registerOptions(Options options, Program program) {

   // TODO: If this analyzer has custom options, register them here

   options.registerOption("Option name goes here", false, null,

                          "Option description goes here");

}

由于此分析器仅用于演示目的,我们将不会添加任何选项。选项可能包括用户控制的选择(例如,选择输出文件、可选地注释列表等)。每个分析器的选项会在选择单个分析器时显示在分析器窗口中:

public void registerOptions(Options options, Program program) {

}
步骤 3-6:执行分析

第六项任务强调了在分析器被调用时触发的函数:

public boolean added(Program program, AddressSetView set, TaskMonitor

                     monitor, MessageLog log) throws CancelledException {

   // TODO: Perform analysis when things get added to the 'program'.

   // Return true if the analysis succeeded.

   return false;

}

这是模块中执行工作的部分。该模块使用了四个方法,每个方法接下来将详细说明:

  //*************************************************************************

  //  This method is called when the analyzer is invoked.

  //*************************************************************************

➊ public boolean added(Program program, AddressSetView set, TaskMonitor

                       monitor, MessageLog log) throws CancelledException {

      gadgetCount = 0;

      String outFileName = System.getProperty("user.home") + "/" +

                           program.getName() + "_gadgets.txt";

      monitor.setMessage("Searching for ROP Gadgets");

      try {

         outFile = new BufferedWriter(new FileWriter(outFileName));

      } catch (IOException e) {/* pass */}

      // iterate through each instruction in the binary

      Listing code = program.getListing();

      InstructionIterator instructions = code.getInstructions(set, true);

    ➋ while (instructions.hasNext() && !monitor.isCancelled()) {

         Instruction inst = instructions.next();

      ➌ if (isStartInstruction(inst)) {

            // We found a "start" instruction.  This will be the last

            // instruction in the potential ROP gadget so we will try to

            // build the gadget from here

            ArrayList<Instruction> gadgetInstructions =

               new ArrayList<Instruction>();

            gadgetInstructions.add(inst);

            Instruction prevInstr = inst.getPrevious();

         ➍ buildGadget(program, monitor, prevInstr, gadgetInstructions);

         }

      }

      try {

         outFile.close();

      } catch (IOException e) {/* pass */}

      return true;

   }

   //*************************************************************************

   //  This method is called recursively until it finds an instruction that

   //  we don't want in the ROP gadget.

   //*************************************************************************

   private void buildGadget(Program program, TaskMonitor monitor,

                            Instruction inst,

                            ArrayList<Instruction> gadgetInstructions) {

      if (inst == null || !isUsefulInstruction(inst)➎ ||

         monitor.isCancelled()) {

         return;

      }

      gadgetInstructions.add(inst);

   ➏ buildGadget(program, monitor, inst.getPrevious()➐, gadgetInstructions);

      gadgetCount += 1;

 ➑ for (int ii = gadgetInstructions.size() - 1; ii >= 0; ii--) {

         try {

            Instruction insn = gadgetInstructions.get(ii);

            if (ii == gadgetInstructions.size() - 1) {

               outFile.write(insn.getMinAddress() + ";");

            }

            outFile.write(insn.toString() + ";");

         } catch (IOException e) {/* pass */}

      }

      try {

         outFile.write("\n");

      } catch (IOException e) {/* pass */}

      // Report count to monitor every 100th gadget

      if (gadgetCount % 100 == 0) {

         monitor.setMessage("Found " + gadgetCount + " ROP Gadgets");

      }

      gadgetInstructions.remove(gadgetInstructions.size() - 1);

   }

   //*************************************************************************

   //  This method determines if an instruction is useful in the context of

   //  a ROP gadget

   //*************************************************************************

   private boolean isUsefulInstruction(Instruction inst) {

      if (!usefulInstructions.contains(inst.getMnemonicString())) {

         return false;

      }

      if (require0Operands.contains(inst.getMnemonicString())) {

         return true;

      }

      if (require1RegOperand.contains(inst.getMnemonicString()) &&

         inst.getNumOperands() == 1) {

         Object[] opObjects0 = inst.getOpObjects(0);

         for (int ii = 0; ii < opObjects0.length; ii++) {

            if (opObjects0[ii] instanceof Register) {

               return true;

            }

         }

      }

      if (requireFirstRegOperand.contains(inst.getMnemonicString()) &&

         inst.getNumOperands() >= 1) {

         Object[] opObjects0 = inst.getOpObjects(0);

         for (int ii = 0; ii < opObjects0.length; ii++) {

            if (opObjects0[ii] instanceof Register) {

               return true;

            }

         }

      }

      return false;

   }

   //*************************************************************************

   //  This method determines if an instruction is the "start" of a

   //  potential ROP gadget

   //*************************************************************************

 private boolean isStartInstruction(Instruction inst) {

      if (startInstr0Params.contains(inst.getMnemonicString())) {

         return true;

      }

      if (startInstr1RegParam.contains(inst.getMnemonicString()) &&

         inst.getNumOperands() >= 1) {

         Object[] opObjects0 = inst.getOpObjects(0);

         for (int ii = 0; ii < opObjects0.length; ii++) {

            if (opObjects0[ii] instanceof Register) {

               return true;

            }

         }

      }

      return false;

   }

Ghidra 调用分析器的 added 方法 ➊ 启动分析。我们的算法会测试二进制中的每一条指令 ➋,以确定该指令是否是我们构建工具的有效“起始”点 ➌。每当找到一个有效的起始指令时,我们的工具创建功能 buildGadget 会被调用 ➍。工具创建是一个递归过程 ➏,会沿指令列表向后 ➐ 遍历,只要指令对我们有用 ➎,就会继续。最后,每个工具会通过遍历其指令 ➑ 被打印出来,当它完成时。

步骤 4:在 Eclipse 中测试分析器

在开发过程中,频繁测试和修改代码是很常见的。当您构建分析器时,可以通过使用 Run As 选项并选择 Ghidra 在 Eclipse 中测试其功能。这会临时安装当前版本的模块并打开 Ghidra。如果在测试模块时结果与预期不符,您可以在 Eclipse 中编辑文件并重新测试。当您对结果满意时,可以进入步骤 5。使用这种方法在 Eclipse 中测试代码,能在开发过程中节省大量时间。

步骤 5:将分析器添加到我们的 Ghidra 安装中

要将此分析器添加到我们的 Ghidra 安装中,我们需要从 Eclipse 导出我们的模块,然后在 Ghidra 中安装该扩展。导出通过选择 GhidraDevExportGhidra Module Extension,选择您的模块并点击 Next 完成。在下一个窗口中,如果您没有本地的 Gradle 安装,请选择 Gradle Wrapper 选项,具体如图 15-23 所示(请注意,使用该包装器需要网络连接以便从 gradle.org 获取信息)。点击 Finish 完成导出过程。如果这是您第一次导出该模块,Eclipse 中的模块会新增一个 dist 目录,并将导出的内容以 .zip 文件的形式保存在该文件夹中。

image

图 15-23:配置 Gradle 对话框

在 Ghidra 项目窗口中,选择 FileInstall Extensions 添加新分析器。会显示一个类似图 15-24 的窗口,列出所有未安装的现有扩展。

image

图 15-24:安装扩展窗口

通过选择右上角的 + 图标并导航到我们新创建的 .zip 文件所在的 dist 目录,添加新的分析器 SimpleROP。当我们的分析器出现在列表中时,我们可以选择它并点击 OK(未显示)。重启 Ghidra 后,即可在分析菜单中使用新功能。

步骤 6:在 Ghidra 中测试分析器

与我们的有限开发计划一样,我们使用了有限范围的测试计划,仅用于演示功能。SimpleROP 通过了验收测试,因为该分析器满足以下标准:

  1. (通过)SimpleROP会出现在 CodeBrowser ▸ Analysis 菜单中的分析选项中。

  2. (通过)当选择时,SimpleROP的描述会出现在分析选项描述窗口中。

    测试案例 1 和 2 通过,如图 15-25 所示。(如果我们选择在步骤 3-5 中注册和编程相关选项,它们将显示在窗口右侧的选项面板中)。

    image

    图 15-25:分析选项窗口

  3. (通过)当选择时,SimpleROP会执行。

    在这种情况下,我们在分析过的文件上运行了SimpleROP,并作为自动分析的一部分进行处理。在未分析的文件上运行SimpleROP将不会产生任何结果,因为INSTRUCTION_ANALYZER扩展需要指令事先被识别(这是自动分析的默认部分)。当SimpleROP作为自动分析的一部分运行时,由于我们在步骤 3-2 中分配的分析器类型,它会被适当地优先处理。图 15-26 显示了SimpleROP分析器运行的 Ghidra 日志确认信息。

    image

    图 15-26:Ghidra 用户日志窗口显示分析确认信息

  4. (通过)SimpleROP在分析fileZZZ时会将每个小工具写入名为fileZZZ_gadgets.txt的文件中。

    以下摘录自文件call_tree_x64_static_gadgets.txt,显示了许多小工具来自call_tree_x64_static列表的这一部分,如图 15-27 所示:

    00400412;ADD RSP,0x8;RET;
    
    004004ce;NOP;RET;
    
    00400679;ADD RSP,0x8;POP RBX;POP RBP;POP R12;POP R13;POP R14;POP R15;RET;
    
    0040067d;POP RBX;POP RBP;POP R12;POP R13;POP R14;POP R15;RET;
    
    0040067e;POP RBP;POP R12;POP R13;POP R14;POP R15;RET;
    
    0040067f;POP R12;POP R13;POP R14;POP R15;RET;
    
    00400681;POP R13;POP R14;POP R15;RET;
    
    00400683;POP R14;POP R15;RET;
    
    00400685;POP R15;RET;
    
    00400a8b;POP RBP;MOV EDI,0x6babd0;JMP RAX;
    
    00400a8c;MOV EDI,0x6babd0;JMP RAX;
    
    00400a98;POP RBP;RET;
    

    image

    图 15-27:CodeBrowser 中call_tree_x64_static*的列表示例

总结

在第十四章中,我们介绍了脚本作为扩展 Ghidra 功能的手段。在本章中,我们介绍了 Ghidra 扩展模块以及 Ghidra 与 Eclipse 集成的功能。虽然 Eclipse 不是编辑 Ghidra 扩展的唯一选择,但 Ghidra 与 Eclipse IDE 的集成为开发者提供了一个极为强大的环境,用于扩展 Ghidra 的功能。开发向导和模板降低了编写扩展的门槛,它们为程序员提供了一种有指导性的方式来修改现有内容并构建新的扩展。在第十六章中,我们将探讨无头 Ghidra,这是图 15-18 中出现的一个选项。后续章节将基于 Ghidra 与 Eclipse IDE 的集成,进一步扩展 Ghidra 的功能,并为将 Ghidra 打造为最佳反向工程工作流工具奠定坚实的基础。

第十九章:Ghidra 的无头模式

Image

在之前的章节中,我们专注于在单个项目中探索单个文件,这是通过 Ghidra 的 GUI 完成的。除了 GUI 之外,Ghidra 还提供了一个名为 Ghidra 无头分析器 的命令行界面。无头分析器提供了与 Ghidra GUI 相同的一些功能,包括处理项目和文件的能力,但它更适合批处理和脚本化控制 Ghidra。在本章中,我们将讨论 Ghidra 的无头模式以及它如何帮助你在更多文件中执行重复任务。我们从一个熟悉的示例开始,然后扩展讨论更复杂的选项。

入门

让我们先回顾一下我们在第四章中首次使用 Ghidra 的经历。我们成功地完成了以下步骤:

  1. 启动 Ghidra。

  2. 创建一个新的 Ghidra 项目。

  3. 确定项目的位置。

  4. 将文件导入项目。

  5. 自动分析文件。

  6. 保存并退出。

现在,让我们使用 Ghidra 无头分析器的命令行界面来复现这些任务。无头分析器(analyzeHeadlessanalyzeHeadless.bat)以及一个名为 analyzeHeadlessREADME.html 的有用文件可以在你的 Ghidra 安装目录的 support 文件夹中找到。为了简化文件路径,我们临时将文件 global_array_demo_x64 放在了同一个目录下。首先,我们将识别每个单独任务所需的命令和参数,然后将它们组合在一起实现我们的目标。虽然在之前的章节中没有太大区别,但当我们从命令行操作时,三种 Ghidra 平台之间存在更多的差异。在我们的示例中,我们使用 Windows 安装,并且会标出其他平台上的显著差异。

使用斜杠还是反斜杠?

支持 Ghidra 的操作系统平台之间的一个主要区别是它们识别文件系统路径的方式。虽然语法一致,不同平台使用不同的目录分隔符。Windows 使用反斜杠,而 Linux 和 macOS 使用斜杠。在 Windows 中,路径如下所示:

D:\GhidraProjects\ch16\demo_stackframe_32

在 Linux 和 macOS 中,路径看起来是这样的:

/GhidraProjects/ch16/demo_stackframe_32

对于 Windows 用户来说,这种语法可能更加困惑,因为斜杠在 URL 和命令行开关(以及 Ghidra 文档)中都有使用。操作系统认识到这个问题,并尝试接受两者,但并不总是以可预测的方式进行。为了本章中的示例,我们使用 Windows 的约定,以便读者能够保持与 DOS 的向后兼容性。

第 1 步:启动 Ghidra

这一步通过 analyzeHeadless 命令完成。所有其他步骤将通过该命令相关的参数和选项完成。运行 analyzeHeadless 命令而不带任何参数时,会显示命令的用法信息及其选项,如 图 16-1 所示。为了启动 Ghidra,我们需要将这些参数添加到命令中。

image

图 16-1:无头分析器语法

步骤 2 和 3:在指定位置创建一个新的 Ghidra 项目

在无头模式下,如果项目尚不存在,Ghidra 会为你创建一个项目。如果项目已存在于指定位置,Ghidra 会打开现有的项目。因此,需要两个参数:项目位置和项目名称。以下命令会在我们的 D:\GhidraProjects 目录下创建一个名为 CH16 的项目:

analyzeHeadless D:\GhidraProjects CH16

这是一个最简化的无头 Ghidra 启动命令,只打开一个项目,并不会做更多操作。事实上,来自 Ghidra 的响应消息明确告诉你这一点:

Nothing to do...must specify -import, -process, or prescript and/or postscript.

步骤 4:将文件导入项目

要导入文件,Ghidra 需要 -import 选项和要导入的文件名。我们将导入之前使用过的 global_array_demo_x64 文件。如前所述,为了简化这个初始示例,我们将文件放置在 support 目录中。或者,我们可以在命令行中指定文件的完整路径。我们将 -import 选项添加到我们的命令中:

analyzeHeadless D:\GhidraProjects CH16 -import global_array_demo_x64

步骤 5 和 6:自动分析文件、保存并退出

在无头模式下,默认会自动分析并保存,因此步骤 4 中的命令可以完成我们需要的所有操作。如果不想分析文件,需要提供一个选项(-noanalysis),并且有选项可控制项目及其关联文件的保存方式。

这是我们完成六个目标的完整命令:

analyzeHeadless D:\GhidraProjects CH16 -import global_array_demo_x64

就像许多控制台命令一样,你可能会问自己:“我怎么知道是否有任何操作发生?”你成功(或失败)的第一个迹象是控制台上显示的消息。以 INFO 为前缀的信息性消息会在无头分析器开始工作时提供进度报告。错误消息以 ERROR 为前缀。清单 16-1 包含了一些消息的子集,包括一个错误消息。

➊ INFO  HEADLESS Script Paths:

      C:\Users\Ghidrabook\ghidra_scripts

   ➋ D:\ghidra_PUBLIC\Ghidra\Extensions\SimpleROP\ghidra_scripts

      D:\ghidra_PUBLIC\Ghidra\Features\Base\ghidra_scripts

      D:\ghidra_PUBLIC\Ghidra\Features\BytePatterns\ghidra_scripts

      D:\ghidra_PUBLIC\Ghidra\Features\Decompiler\ghidra_scripts

      D:\ghidra_PUBLIC\Ghidra\Features\FileFormats\ghidra_scripts

      D:\ghidra_PUBLIC\Ghidra\Features\FunctionID\ghidra_scripts

      D:\ghidra_PUBLIC\Ghidra\Features\GnuDemangler\ghidra_scripts

      D:\ghidra_PUBLIC\Ghidra\Features\Python\ghidra_scripts

      D:\ghidra_PUBLIC\Ghidra\Features\VersionTracking\ghidra_scripts

      D:\ghidra_PUBLIC\Ghidra\Processors\8051\ghidra_scripts

      D:\ghidra_PUBLIC\Ghidra\Processors\DATA\ghidra_scripts

      D:\ghidra_PUBLIC\Ghidra\Processors\PIC\ghidra_scripts(HeadlessAnalyzer)

   INFO  HEADLESS: execution starts (HeadlessAnalyzer)

   INFO  Opening existing project: D:\GhidraProjects\CH16 (HeadlessAnalyzer)

➌ ERROR Abort due to Headless analyzer error:

      ghidra.framework.store.LockException:

      Unable to lock project! D:\GhidraProjects\CH16 (HeadlessAnalyzer)

      java.io.IOException: ghidra.framework.store.LockException:

      Unable to lock project! D:\GhidraProjects\CH16

      ...

清单 16-1:带错误条件的无头分析器

无头模式中使用的脚本路径列在➊。在本章后续部分,我们将展示如何在无头命令中使用额外的脚本。我们在上一章中创建的扩展 SimpleROP 包含在脚本路径中➋,因为每个扩展都会将一个新的路径添加到脚本路径中。LockException ➌可能是无头分析器中最常见的错误。如果你试图在另一个 Ghidra 实例中已经打开的项目上运行它,分析器会失败。此时,无头分析器无法为其自身独占使用锁定该项目,因此命令执行失败。

为了修复该错误,关闭任何正在运行并打开CH16项目的 Ghidra 实例,再次运行命令。图 16-2 显示了成功执行命令后的输出末尾,这与我们在 Ghidra GUI 中分析文件时看到的弹出窗口类似。

image

图 16-2:无头分析器结果显示在控制台

为了在 Ghidra GUI 中验证结果,打开项目并确认文件已加载,如图 16-3 所示,然后在 CodeBrowser 中打开该文件以确认分析。

image

图 16-3:Ghidra GUI 确认项目已创建且文件已加载

现在我们已经使用 Ghidra 的无头模式重复了之前的分析,让我们探讨一些无头模式相对于图形用户界面(GUI)的优势。在 Ghidra 的 GUI 中创建项目并加载和分析图 16-4 中显示的所有文件,我们可以创建项目然后逐个加载文件,或者选择文件进行批量导入操作,正如在第 226 页的“批量导入”中所讨论的。无头 Ghidra 允许我们指定一个目录并分析该目录中的所有文件。

image

图 16-4:无头 Ghidra 示例的输入目录

以下命令告诉无头分析器在D:\GhidraProjects目录中打开或创建一个名为CH16的项目,并导入分析D:\ch16目录中的所有文件:

analyzeHeadless D:\GhidraProjects CH16 -import D:\ch16

命令执行后,我们可以将新项目加载到 Ghidra GUI 中,并查看其相关文件,如图 16-5 所示。子目录D:\ch16\CH16_subdirectory不会出现在项目中,该子目录中的任何文件也不会出现。我们将在接下来的章节中讨论更多无头 Ghidra 可以使用的选项和参数时再回到这个问题。

image

图 16-5:将无头 Ghidra 指向目录后生成的项目

选项和参数

使用无头模式的 Ghidra 创建项目、加载并分析单个文件,并使用批处理导入整个目录的简单示例,仅仅展示了其可能性的冰山一角。虽然我们无法讨论无头 Ghidra 的所有功能,但我们会简要介绍当前可用的每个选项。

常规选项

以下是我们可以使用的额外选项的简要描述,并附有相关示例,帮助我们进一步控制在简单示例中发生的情况。(换行的行会缩进。)当遇到时,将讨论常见的错误情况。专业错误情况留给读者在 Ghidra 帮助文件中自行探讨。

-log logfilepath

执行命令行时,许多事情可能会出错(或成功)。幸运的是,Ghidra 插件在 Ghidra 运行时提供持续的反馈,告知发生了什么。虽然在 Ghidra GUI 中这种反馈不那么重要(因为你可以通过视觉线索看到发生了什么),但在无头 Ghidra 中,这种反馈非常重要。

默认情况下,日志文件会写入用户主目录中的 .ghidra/.ghidra__PUBLIC/application.log。你可以通过在命令行中添加 -log 选项来选择新位置。要创建一个目录 CH16-logs 并将日志文件写入 CH16-logfile,请使用以下命令:

analyzeHeadless D:\GhidraProjects CH16 -import global_array_demo_x64

  -log D:\GhidraProjects\CH16-logs\CH16-Logfile

-noanalysis

该选项指示 Ghidra 不分析从命令行导入的任何文件。在执行以下语句后,在 Ghidra GUI 中打开文件 global_array_demo_x64 会展示一个已加载但未分析的文件版本,位于 CH16 项目中:

analyzeHeadless D:\GhidraProjects CH16 -import global_array_demo_x64

  -noanalysis

-overwrite

在 示例 16-1 中,我们看到了一个错误条件,当 Ghidra 尝试打开一个已经打开的项目时会发生。第二个常见错误发生在 Ghidra 尝试将文件导入项目时,而该文件已经被导入。要导入文件的新版本,或无论文件内容如何都覆盖现有文件,请使用 -overwrite 选项。如果没有此选项,运行以下无头命令两次会在第二次执行时导致错误。有了此选项,我们可以随意重新运行命令:

analyzeHeadless D:\GhidraProjects CH16 -import global_array_demo_x64

  -overwrite

-readOnly

要导入文件但不将文件保存到项目中,请使用 -readOnly 选项。如果使用此选项,-overwrite 选项将被忽略(如果存在)。当与 -process 选项一起使用时,而不是与 -import 命令一起使用时,此选项也具有意义。-process 选项将在本章后面介绍。

analyzeHeadless D:\GhidraProjects CH16 -import global_array_demo_x64

  -readOnly

-deleteProject

该选项指示 Ghidra 不保存使用 –import 选项创建的任何项目。此选项可以与其他选项一起使用,但在使用 -readOnly 时默认假定(即使省略)。新创建的项目会在分析完成后删除。此选项不会删除现有项目:

analyzeHeadless D:\GhidraProjects CH16 -import global_array_demo_x64

  -deleteProject

-recursive

默认情况下,Ghidra 在处理整个目录时不会递归进入子目录。当你希望 Ghidra 执行递归目录处理时(即处理它沿途找到的任何子目录),可以使用此选项。为了演示这个功能,我们将指向之前处理过的同一个ch16目录,但这次将使用-recursive选项:

analyzeHeadless D:\GhidraProjects CH16 -import D:\ch16 -recursive

在运行此命令后打开项目CH16,将会生成如图 16-6 所示的项目结构。与图 16-5 相比,项目中包含了CH16_subdirectory及其相关文件,并且目录层次结构在项目层次结构中得以保留。

image

图 16-6:通过 -recursive 选项生成的无头 Ghidra 项目

通配符!

通配符提供了一种简单的方法来为无头 Ghidra 选择多个文件,而无需单独列出每个文件。简而言之,星号(*)匹配任意字符序列,问号(?)匹配单个字符。为了仅加载和分析图 16-7 中的 32 位文件,可以使用以下通配符:

analyzeHeadless D:\GhidraProjects CH16 -import D:\ch16\demo_stackframe_32*

这会创建 CH16 项目并加载分析ch16目录下的所有 32 位文件。生成的项目如图 16-7 所示。有关使用通配符指定文件进行导入和处理的详细信息,请参见analyzeHeadlessREADME.html。你还将会在未来的无头 Ghidra 脚本示例中看到通配符的使用。

image

图 16-7:由通配符 demo_stackframe_32* 生成的项目文件*

-analysisTimeoutPerFile 秒

在你分析(或观察 Ghidra 分析)文件时,可能会注意到几个影响分析时间的因素,如文件大小、是否静态链接以及反编译选项。无论文件内容和选项如何,你无法提前知道分析文件究竟需要多长时间。

在无头 Ghidra 中,尤其是在处理大量文件时,可以使用-analysisTimeoutPerFile选项来确保任务在合理的时间内结束。使用此选项时,你需要指定超时时间(以秒为单位),如果超时,分析将被中断。例如,我们现有的无头 Ghidra 命令在我们的系统上分析文件大约需要一秒多(参见图 16-2)。如果我们真的有限时间来执行此脚本,以下无头命令将在一秒钟后停止分析:

analyzeHeadless D:\GhidraProjects CH16 -import global_array_demo_x64

  -analysisTimeoutPerFile 1

这将导致控制台显示图 16-8 中的信息。

image

图 16-8:控制台警告:分析超时

-processor languageID 和 -cspec compilerSpecID

如前面的示例所示,Ghidra 通常在识别文件信息并给出导入建议方面做得相当好。对于某个特定文件的建议,示例窗口如图 16-9 所示。每次使用 GUI 将文件导入项目时,都会显示此窗口。

image

图 16-9:Ghidra GUI 导入确认对话框

如果你认为自己对合适的语言或编译器有更多见解,可以展开语言规格右侧的框。这将呈现出图 16-10 所示的窗口,提供选择语言和编译器规格的机会。

image

图 16-10:Ghidra 语言/编译器规格选择窗口

要在无头 Ghidra 中执行相同操作,请使用 -cspec 和/或 processor 选项,如下所示。不能仅使用 -cspec 选项而不使用 -processor 选项。你可以在不使用 -cspec 选项的情况下使用 -processor 选项,这时 Ghidra 会选择与处理器相关的默认编译器。

analyzeHeadless D:\GhidraProjects CH16 -import global_array_demo_x64

  -processor "x86:LE:64:default" -cspec "gcc"

-loader loadername

-loader 选项可能是无头 Ghidra 选项中最复杂的一个。loadername 参数指定 Ghidra 的加载模块之一(在第十七章中讨论),该模块将用于将新文件导入指定的项目。示例加载模块名称包括 PeLoaderElfLoaderMachoLoader。每个加载模块可能还会识别一些额外的命令行参数。这些额外的参数在support/analyzeHeadlessREADME.html中有详细讨论。

-max-cpu number

此选项允许你限制用于处理无头 Ghidra 命令的处理器(CPU)核心数。该选项需要一个整数值作为参数。如果该值小于 1,则最大核心数设置为 1。

analyzeHeadless D:\GhidraProjects CH16 -import global_array_demo_x64

  -max-cpu 5
服务器选项

一些命令仅在与 Ghidra 服务器交互时使用。由于这不是本书的重点,我们将简要提及这些命令。更多信息请参考analyzeheadlessREADME.html

ghidra://server[:port]/repository_name[/folder_path]

前面的示例都指定了项目位置或项目名称。这个替代方法允许你指定一个 Ghidra 服务器仓库和可选的文件夹路径。

-p

在使用 Ghidra 服务器时,此选项通过控制台强制要求输入密码。

-connect [userID]

此选项提供一个 userID,用于在连接 Ghidra 服务器时覆盖默认的 userID。

-keystore path

此选项允许你在使用 PKI 或 SSH 认证时指定一个私有的密钥库文件。

-commit ["comment"]

虽然 commit 默认为启用,但此选项允许你为提交关联评论。

脚本选项

也许无头模式下 Ghidra 最强大的应用之一与 Ghidra 的脚本功能相关。第十四章和第十五章均演示了如何在 Ghidra GUI 中创建和使用脚本。介绍完脚本选项后,我们将展示无头模式下 Ghidra 在脚本环境中的强大功能。

-process [project_file]

该选项处理选定的文件(与导入文件不同)。如果没有指定文件,项目文件夹中的所有文件都将被处理。除非使用-noanalysis选项,否则所有指定的文件也将被分析。Ghidra 接受两个通配符字符(*?)用于–process选项,以简化多个文件的选择。对于此选项,与–import选项不同,你指定的是 Ghidra 导入的项目文件,而不是本地文件系统中的文件,因此需要引用任何包含通配符的文件名,以防止你的 shell 提前展开它们。

-scriptPath "path1[;path2...]"

默认情况下,无头模式下的 Ghidra 包含许多默认脚本路径以及导入扩展的脚本路径,如清单 16-1 所示。要扩展 Ghidra 搜索可用脚本的路径列表,可以使用–scriptPath选项,该选项需要一个带引号的路径列表参数。在引号内,多个路径必须使用分号分隔。路径组件中会识别两种特殊的前缀标识符:

$GHIDRA_HOME$USER_HOME$GHIDRA_HOME指的是 Ghidra 安装目录,$USER_HOME指的是用户的主目录。请注意,这些不是环境变量,且你的命令行 shell 可能需要你转义前导的$字符,以便将其传递给 Ghidra。以下示例将 D:\GhidraScripts 目录添加到脚本路径中:

analyzeHeadless D:\GhidraProjects CH16 -import global_array_demo_x64

  -scriptPath "D:\GhidraScripts"

在你运行命令后,新的脚本目录,D:\GhidraScripts,会被包括在脚本路径中:

INFO  HEADLESS Script Paths:

  D:\GhidraScripts

  C:\Users\Ghidrabook\ghidra_scripts

  D:\ghidra_PUBLIC\Ghidra\Extensions\SimpleROP\ghidra_scripts

  D:\ghidra_PUBLIC\Ghidra\Features\Base\ghidra_scripts

  D:\ghidra_PUBLIC\Ghidra\Features\BytePatterns\ghidra_scripts

  D:\ghidra_PUBLIC\Ghidra\Features\Decompiler\ghidra_scripts

  D:\ghidra_PUBLIC\Ghidra\Features\FileFormats\ghidra_scripts

  D:\ghidra_PUBLIC\Ghidra\Features\FunctionID\ghidra_scripts

  D:\ghidra_PUBLIC\Ghidra\Features\GnuDemangler\ghidra_scripts

  D:\ghidra_PUBLIC\Ghidra\Features\Python\ghidra_scripts

  D:\ghidra_PUBLIC\Ghidra\Features\VersionTracking\ghidra_scripts

  D:\ghidra_PUBLIC\Ghidra\Processors\8051\ghidra_scripts

  D:\ghidra_PUBLIC\Ghidra\Processors\DATA\ghidra_scripts

  D:\ghidra_PUBLIC\Ghidra\Processors\PIC\ghidra_scripts (HeadlessAnalyzer)

INFO  HEADLESS: execution starts (HeadlessAnalyzer)

-preScript

该选项指定在分析之前运行的脚本名称。脚本可能包含一个可选的参数列表。

-postScript

该选项指定在分析后运行的脚本名称。脚本可能包含一个可选的参数列表。

-propertiesPath

该选项指定与脚本相关的属性文件的路径。属性文件为以无头模式运行的脚本提供输入。脚本及其关联的属性文件示例包含在无头分析器文档中。

-okToDelete

由于脚本可以执行其创建者意图的任何操作,因此脚本有可能删除(或尝试删除)Ghidra 项目中的文件。为了防止这种不必要的副作用,无头模式下的 Ghidra 不允许脚本删除文件,除非在调用脚本时包含了-okToDelete选项。注意:在-import模式下运行时,此参数不是必需的。

编写脚本

现在你已经理解了无头 Ghidra 命令的基本组件,让我们编写一些脚本,在命令行中运行。

HeadlessSimpleROP

回想一下我们在第十五章中编写的 SimpleROP 分析器。我们使用 Eclipse IDE 编写了这个模块,然后将扩展导入 Ghidra,这样我们就可以在导入的任何文件上运行它。现在,我们希望将 SimpleROP 指向一个目录,并让它识别该目录中每个文件(或选定文件)中的 ROP 小工具。除了每个现有二进制文件中带有 ROP 小工具的 SimpleROP 输出文件外,我们还希望有一个摘要文件,列出每个文件及其识别到的 ROP 小工具数量。

对于这样的任务,通过 Ghidra GUI 运行 SimpleROP 会引入一些时间上的开销,例如打开和关闭 CodeBrowser 来显示列表窗口中的每个文件等。为了实现我们的新目标,我们并不需要在 CodeBrowser 窗口中看到任何文件。为什么我们不能编写一个脚本,完全独立于 GUI 来找到小工具呢?这正是适合无头 Ghidra 的用例。

虽然我们可以修改 SimpleROP 的功能以实现我们的目标,但我们不想丧失其他用户可能会觉得有用的现有 Ghidra 扩展的功能。(我们意识到我们刚刚在上一章中介绍了它……但它可能已经流行开了。)相反,我们将使用 SimpleROP 中的一些代码作为基础,创建我们新的脚本 HeadlessSimpleROP,它会查找 中的所有 ROP 小工具,并将其创建并写入到 _gadgets.txt 中,然后将 / 和 ROP 小工具的计数追加到一个名为 gadget_summary.txtHeadlessSimpleROP 摘要文件中。所有其他所需的功能(解析目录、文件等)将由无头 Ghidra 提供,使用我们在本章前面讨论的选项。

为了简化开发,我们使用第十五章中介绍的 Eclipse ▸ GhidraDev 方法创建一个新脚本,然后将SimpleROPAnalyzer.java 源代码复制到新脚本模板中,并根据需要编辑代码。最后,我们将使用-postScript选项运行脚本,以便在分析阶段后调用它,针对每个打开的文件。

创建 HeadlessSimpleROP 脚本模板

首先创建一个模板。在 GhidraDev 菜单中选择新建GhidraScript,并填写对话框中显示的信息,如图 16-11 所示。虽然我们可以将脚本放在任何文件夹中,但我们将把它放在 Eclipse 中现有的 SimpleROP 模块中的ghidra_scripts 文件夹内。

image

图 16-11:创建 Ghidra 脚本对话框

点击完成,查看新的脚本模板,连同元数据,如图 16-12 所示。第 14 行的任务标签显示了你可以开始的地方。

image

图 16-12:新的 HeadlessSimpleROP 脚本模板

要将 SimpleROP 分析器转换为HeadlessSimpleROP脚本,我们需要执行以下操作:

  1. 删除不需要的import语句。

  2. 删除分析器公共方法。

  3. 复制当使用run方法调用HeadlessSimpleROP脚本时,调用 SimpleROPAnalyzer 时added方法的功能。

  4. 添加将文件名和找到的小工具数量附加到汇总文件gadget_summary.txt中的功能。

我们将把脚本HeadlessSimpleROP放在D:\GhidraScripts目录中,并使用无头分析器演示其功能。在接下来的部分中,我们将运行一系列测试,调用HeadlessSimpleROP脚本,使用图 16-6 中显示的目录结构中的项目。这些测试还演示了与无头 Ghidra 相关的一些选项。

测试场景 1:加载、分析和处理单个文件

在以下清单中,我们使用无头 Ghidra 导入、分析并调用脚本来为单个文件生成小工具报告(^字符是 Windows 命令行中的行续符):

analyzeHeadless D:\GhidraProjects CH16_ROP ^

     -import D:\ch16\demo_stackframe_32 ^

     -scriptPath D:\GhidraScripts ^

     -postScript HeadlessSimpleROP.java

执行时,Ghidra 无头分析器会在GhidraProjects目录下创建一个名为CH16_ROP的项目,然后导入文件demo_stackframe_32,该文件也将被加载并分析。我们使用scriptPath指示脚本所在的目录。最后,在分析完成后,我们的脚本将在导入并分析后的文件上运行。

命令完成后,我们检查tool_summary.txtdemo_stackframe_32_gadgets.txt文件的内容,以确定我们的脚本是否正确工作。demo_stackframe_32_gadgets.txt包含 16 个潜在的 ROP 小工具:

080482c6;ADD ESP,0x8;POP EBX;RET;

080482c9;POP EBX;RET;

08048343;MOV EBX,dword ptr [ESP];RET;

08048360;MOV EBX,dword ptr [ESP];RET;

08048518;SUB ESP,0x4;PUSH EBP;PUSH dword ptr [ESP + 0x2c];PUSH dword ptr [ESP + 0x2c];

         CALL dword ptr [EBX + EDI*0x4 + 0xffffff0c];

0804851b;PUSH EBP;PUSH dword ptr [ESP + 0x2c];PUSH dword ptr [ESP + 0x2c];

         CALL dword ptr [EBX + EDI*0x4 + 0xffffff0c];

0804851c;PUSH dword ptr [ESP + 0x2c];PUSH dword ptr [ESP + 0x2c];

         CALL dword ptr [EBX + EDI*0x4 + 0xffffff0c];

08048520;PUSH dword ptr [ESP + 0x2c];CALL dword ptr [EBX + EDI*0x4 + 0xffffff0c];

08048535;ADD ESP,0xc;POP EBX;POP ESI;POP EDI;POP EBP;RET;

08048538;POP EBX;POP ESI;POP EDI;POP EBP;RET;

08048539;POP ESI;POP EDI;POP EBP;RET;

0804853a;POP EDI;POP EBP;RET;

0804853b;POP EBP;RET;

 0804854d;ADD EBX,0x1ab3;ADD ESP,0x8;POP EBX;RET;

08048553;ADD ESP,0x8;POP EBX;RET;

08048556;POP EBX;RET;

以下是tool_summary.txt中相关条目的内容:

demo_stackframe_32: Found 16 potential gadgets
测试场景 2:加载、分析和处理目录中的所有文件

在此测试中,我们导入一个完整的目录,而不是使用import语句导入单个文件:

analyzeHeadless D:\GhidraProjects CH16_ROP ^

      -import D:\ch16 ^

      -scriptPath D:\GhidraScripts ^

      -postScript HeadlessSimpleROP.java

当无头分析器完成时,gadget_summary.txt中会找到以下内容:

demo_stackframe_32: Found 16 potential gadgets

demo_stackframe_32_canary: Found 16 potential gadgets

demo_stackframe_32_stripped: Found 16 potential gadgets

 demo_stackframe_64: Found 24 potential gadgets

demo_stackframe_64_canary: Found 24 potential gadgets

demo_stackframe_64_stripped: Found 24 potential gadgets

这些是根目录中显示的六个文件,如图 16-6 所示。除了小工具汇总文件外,我们还生成了列出与每个文件相关的潜在 ROP 小工具的单独小工具文件。在剩余的示例中,我们只关注小工具汇总文件。

测试场景 3:递归加载、分析和处理目录中的所有文件

在此测试中,我们添加了-recursive选项。这会扩展导入操作,递归访问ch16目录中所有子目录中的所有文件:

analyzeHeadless D:\GhidraProjects CH16_ROP ^

      -import D:\ch16  ^

      -scriptPath D:\GhidraScripts ^

      -postScript HeadlessSimpleROP.java ^

      -recursive

当无头分析器完成时,gadget_summary.txt中会找到以下内容,子目录文件出现在列表的顶部:

demo_stackframe_32_sub: Found 16 potential gadgets

demo_stackframe_32: Found 16 potential gadgets

demo_stackframe_32_canary: Found 16 potential gadgets

demo_stackframe_32_stripped: Found 16 potential gadgets

demo_stackframe_64: Found 24 potential gadgets

demo_stackframe_64_canary: Found 24 potential gadgets

demo_stackframe_64_stripped: Found 24 potential gadgets
测试场景 4:加载、分析和处理目录中的所有 32 位文件

在这个测试中,我们使用 * 作为外壳通配符,将导入内容限制为带有 32 位设计符的文件:

analyzeHeadless D:\GhidraProjects CH16ROP ^

      -import D:\ch16\demo_stackframe_32* ^

      -recursive ^

      -postScript HeadlessSimpleROP.java ^

      -scriptPath D:\GhidraScripts

生成的 gadget_summary 文件包含以下内容:

demo_stackframe_32: Found 16 potential gadgets

demo_stackframe_32_canary: Found 16 potential gadgets

demo_stackframe_32_stripped: Found 16 potential gadgets

如果你事先知道只关心生成的 gadget 文件,可以使用 -readOnly 选项。此选项指示 Ghidra 不将导入的文件保存到命令中指定的项目中,适用于避免批量处理多个文件时造成项目杂乱。

自动化 FidDb 创建

在 第十三章 中,我们开始创建一个函数 ID 数据库(FidDb),并用从 libc 静态版本中提取的函数指纹进行填充。通过 GUI 和 Ghidra 的批量导入模式,我们从 libc.a 压缩包中导入了 1,690 个目标文件。然而,在分析这些文件时,我们遇到了瓶颈,因为 GUI 对批量分析的支持很有限。现在你已经熟悉了无头模式的 Ghidra,我们可以利用它来完成新的 FidDb。

批量导入与分析

导入并分析来自归档的 1,690 个文件曾一度看起来是一项艰巨的任务,但前面的例子已经向我们展示了完成这项任务所需的所有信息。我们在这里考虑两种情况,并为每种情况提供命令行示例。

如果 libc.a 尚未导入到 Ghidra 项目中,我们会将 libc.a 的内容提取到一个目录中,然后使用无头 Ghidra 处理整个目录:

$ mkdir libc.a && cd libc.a

$ ar x path\to\archive && cd ..

$ analyzeHeadless D:\GhidraProjects CH16 –import libc.a ^

        -processor x86:LE:64:default –cspec gcc –loader ElfLoader ^

        -recursive

该命令会输出数千行结果,Ghidra 会报告它在处理 1,690 个文件时的进度,但一旦命令完成,你的项目中将会有一个新的 libc.a 文件夹,里面包含 1,690 个已分析的文件。

如果我们已经使用 GUI 批量导入了 libc.a,但没有处理任何已导入的 1,690 个文件,以下命令行将负责分析:

$ analyzeHeadless D:\GhidraProjects CH16\libc.a –process

通过高效地导入并分析整个静态归档,我们现在可以使用函数 ID 插件的功能来创建并填充 FidDb,详细内容请参见 第十三章。

总结

虽然 GUI 版 Ghidra 仍然是最直接且功能最全的版本,但以无头模式运行 Ghidra 提供了巨大的灵活性,可以构建围绕 Ghidra 自动化分析的复杂工具。到目前为止,我们已经涵盖了 Ghidra 最常用的功能,并探讨了你可以如何让 Ghidra 为你工作。现在是时候深入了解更高级的功能了。

在接下来的几章中,我们将探讨一些在反向工程二进制文件时遇到的更具挑战性的问题,包括通过构建复杂的 Ghidra 扩展来处理未知的文件格式和未知的处理器架构。我们还将花一些时间研究 Ghidra 的反编译器,并讨论编译器在生成代码时的差异,以提高你阅读反汇编代码的流畅度。

第四部分

更深的探讨

第二十章:GHIDRA 加载器**

Image

除了在第四章中简要展示的原始二进制加载器示例外,Ghidra 已经识别了文件类型,并顺利加载并分析了我们投给它的所有文件。但这并非总是如此。在某些时候,你可能会遇到如图 17-1 所示的对话框。(这个特定的文件是 shellcode,Ghidra 无法识别它,因为没有定义的结构、意义明确的文件扩展名或魔术数字。)

image

图 17-1:原始二进制加载器示例

那么当我们尝试导入这个文件时发生了什么呢?让我们从一个高层次的角度来看一下 Ghidra 加载文件的过程:

  1. 在 Ghidra 项目窗口中,用户指定一个文件加载到项目中。

  2. Ghidra 导入器会轮询所有的 Ghidra 加载器,每个加载器尝试识别文件。如果可以加载文件,它们会响应并提供一份加载规范列表以填充导入对话框。(一个空列表意味着“我不能加载这个文件。”)

  3. 导入器收集所有加载器的响应,构建一个识别该文件的加载器列表,并向用户展示一个已填充的导入对话框。

  4. 用户选择加载器及其相关信息来加载文件。

  5. 导入器调用用户选择的加载器,然后加载该文件。

对于图 17-1 中的文件,没有任何格式特定的加载器给出“是”的回应。因此,任务被交给了唯一一个愿意随时接受任何文件的加载器——原始二进制加载器。这个加载器几乎不做任何工作,把分析的负担转移给了逆向工程师。如果你在分析类似的文件时,发现它们都表现出“原始”格式,可能是时候构建一个专门的加载器来帮助你完成部分或全部的加载过程。创建一个新的加载器以便 Ghidra 能够加载一种新格式的文件需要完成几个任务。

在本章中,我们首先将引导你分析一个 Ghidra 无法识别格式的文件。这将帮助你了解分析未知文件的过程,同时也为构建加载器提供有力的论据,我们将在本章的后半部分进行详细探讨。

未知文件分析

Ghidra 包含多个加载模块,用于识别许多常见的可执行文件和归档文件格式,但 Ghidra 无法适应日益增加的用于存储可执行代码的文件格式数量。二进制映像可能包含针对特定操作系统格式化的可执行文件、从嵌入式系统中提取的 ROM 镜像、从固件更新中提取的固件镜像,或者只是原始的机器语言块,可能是通过网络数据包捕获提取的。这些镜像的格式可能由操作系统(可执行文件)、目标处理器和系统架构(ROM 镜像)或者根本没有任何格式(嵌入在应用层数据中的利用 shellcode)来决定。

假设有一个处理器模块可以反汇编未知二进制文件中的代码,那么你的任务就是在 Ghidra 中正确安排文件镜像,并在告知 Ghidra 哪些二进制部分表示代码,哪些二进制部分表示数据之前完成此操作。对于大多数处理器类型,使用原始格式加载文件的结果通常只是一个包含文件内容的列表,这些内容堆积成一个单一段,从地址零开始,如 Listing 17-1 所示。

00000000 4d       ??         4Dh    M

00000001 5a       ??         5Ah    Z

00000002 90       ??         90h

00000003 00       ??         00h

00000004 03       ??         03h

00000005 00       ??         00h

00000006 00       ??         00h

00000007 00       ??         00h

Listing 17-1: 使用原始二进制加载器加载的未分析 PE 文件的初始行

在某些情况下,取决于所选处理器模块的复杂度,可能会进行一些反汇编。例如,为嵌入式微控制器选择的处理器可以对 ROM 镜像的内存布局做出特定假设,或者一个了解与特定处理器相关的常见代码序列的分析工具,可以乐观地将任何匹配的部分格式化为代码。

当你面对一个无法识别的文件时,尽可能多地收集有关该文件的信息。有用的资源可能包括文件的获取方式和位置的说明、处理器参考、操作系统参考、系统设计文档以及通过调试或硬件辅助分析(例如通过逻辑分析仪)获得的任何内存布局信息。

在接下来的部分,为了举例说明,我们假设 Ghidra 无法识别 Windows PE 文件格式。PE 是一个广为人知的文件格式,许多读者可能对此有所了解。更重要的是,关于 PE 文件结构的文档随处可得,这使得分析任意 PE 文件成为一项相对简单的任务。

手动加载 Windows PE 文件

当你能找到某个文件格式的文档时,使用 Ghidra 帮助你理清二进制文件时将变得更加轻松。列表 17-1 显示了使用 Raw Binary 加载器加载并使用 x86:LE:32:default:windows 作为语言/编译器规范的未分析 PE 文件的前几行。^(1) PE 规范规定,一个有效的 PE 文件应以 MS-DOS 头部结构开始,头部以 2 字节签名 4Dh 5AhMZ)开始,我们可以在 列表 17-1 的前两行看到这一点。^(2) 位于文件偏移量 0x3C 处的 4 字节值包含下一个头部的偏移量:PE 头部。

处理 MS-DOS 头部字段的两种策略是:(1) 为每个字段定义适当大小的数据值;(2) 使用 Ghidra 的数据类型管理器功能,按照 PE 文件规范定义并应用 IMAGE_DOS_HEADER 结构。我们将在本章后面的例子中探讨选项 1 相关的挑战。在这种情况下,选项 2 需要的工作量显著较少。

使用 Raw Binary 加载器时,Ghidra 并不会加载 Windows 数据类型的 Data Type Manager,因此我们可以自行加载包含 MS-DOS 类型的归档文件 windows_vs12_32.gdt。通过在归档中导航或按 CTRL-F 在数据类型管理器窗口中查找,定位 IMAGE_DOS_HEADER;然后将头部拖动到文件的起始位置。你也可以将光标放置在列表中的第一个地址上,然后从右键菜单中选择 Data ▸ Choose Data Type(或快捷键 T)并输入或导航到数据类型选择对话框中的数据类型。这些选项都会得到以下列表,并附有描述每个字段的行尾注释:

00000000 4d 5a      WORD      5A4Dh    e_magic

00000002 90 00      WORD      90h      e_cblp

00000004 03 00      WORD      3h       e_cp

00000006 00 00      WORD      0h       e_crlc

00000008 04 00      WORD      4h       e_cparhdr

0000000a 00 00      WORD      0h       e_minalloc

0000000c ff ff      WORD      FFFFh    e_maxalloc

0000000e 00 00      WORD      0h       e_ss

00000010 b8 00      WORD      B8h      e_sp

00000012 00 00      WORD      0h       e_csum

00000014 00 00      WORD      0h       e_ip

00000016 00 00      WORD      0h       e_cs

00000018 40 00      WORD      40h      e_lfarlc

0000001a 00 00      WORD      0h       e_ovno

0000001c 00 00 00   WORD[4]            e_res

         00 00 00

         00 00

 00000024 00 00      WORD      0h       e_oemid

00000026 00 00      WORD      0h       e_oeminfo

00000028 00 00 00   WORD[10]           e_res2

         00 00 00

         00 00 00

0000003c d8 00 00   LONG      D8h      e_lfanew

上一个列表中的最后一行 e_lfanew 字段的值为 D8h,这表示 PE 头部应该位于二进制文件的偏移量 D8h(216 字节)处。检查偏移量 D8h 处的字节应该能揭示出 PE 头部的魔术数字 50h 45hPE),这表示我们应该在二进制文件的偏移量 D8h 处应用 IMAGE_NT_HEADERS 结构。以下是 Ghidra 扩展列表的一部分:

000000d8    IMAGE_NT_HEADERS

   000000d8       DWORD           4550h     Signature

   000000dc    IMAGE_FILE_HEADER            FileHeader

      000000dc    WORD            14Ch      Machine➊

      000000de    WORD            5h        NumberOfSections➋

      000000e0    DWORD           40FDFD    TimeDateStamp

      000000e4    DWORD           0h        PointerToSymbolTable

      000000e8    DWORD           0h        NumberOfSymbols

      000000ec    WORD            E0h       SizeOfOptionalHeader

      000000ee    WORD            10Fh      Characteristics

   000000f0    IMAGE_OPTIONAL_HEADER32      OptionalHeader

      000000f0    WORD            10Bh      Magic

      000000f2    BYTE            '\u0006'  MajorLinkerVersion

      000000f3    BYTE            '\0'      MinorLinkerVersion

      000000f4    DWORD           21000h    SizeOfCode

      000000f8    DWORD           A000h     SizeOfInitializedData

      000000fc    DWORD           0h        SizeOfUninitializedData

      00000100    DWORD           14E0h     AddressOfEntryPoint➌

      00000104    DWORD           1000h     BaseOfCode

      00000108    DWORD           1000h     BaseOfData

      0000010c    DWORD           400000h   ImageBase➍

      00000110    DWORD           1000h     SectionAlignment➎

      00000114    DWORD           1000h     FileAlignment➏

到目前为止,我们已经揭示了许多有趣的信息,有助于进一步完善二进制文件的布局。首先,PE 头部中的 Machine 字段 ➊ 表示文件构建时的目标处理器类型。值 14Ch 表示该文件适用于 x86 处理器类型。如果机器类型是其他类型,例如 1C0h(ARM),我们需要关闭 CodeBrowser,右击项目窗口中的文件,选择 Set Language 选项,然后选择正确的语言设置。

ImageBase 字段 ➍ 指示加载文件镜像的基虚拟地址。利用这些信息,我们可以将一些虚拟地址信息整合到 CodeBrowser 中。通过“窗口 ▸ 内存映射”菜单选项,我们可以看到当前程序的内存块列表(见 图 17-2)。在这种情况下,单个内存块包含了程序的所有内容。原始二进制加载器无法为程序的任何内容确定合适的内存地址,因此它将所有内容放在从地址零开始的一个内存块中。

image

图 17-2:内存映射窗口

内存映射窗口的工具按钮,如 图 17-3 所示,用于操作内存块。为了正确地将我们的镜像映射到内存中,首先需要做的是设置 PE 头中指定的基地址。

image

图 17-3:内存映射窗口工具

ImageBase 字段 ➍ 告诉我们该二进制文件的正确基址是 00400000。我们可以使用“设置镜像基址”选项将镜像基址从默认值调整为该值。点击确认后,所有 Ghidra 窗口将更新,以反映程序的新内存布局,如 图 17-4 所示。(在已经定义了多个内存块的情况下使用此选项时需要小心,它会将每个内存块都移动与基内存块相同的距离。)

image

图 17-4:设置镜像基址后的内存映射

AddressOfEntryPoint 字段 ➌ 指定了程序入口点的相对虚拟地址(RVA)。在 PE 文件规范中,RVA 是从程序基虚拟地址的相对偏移量,而程序入口点是程序文件中将执行的第一条指令的地址。在本例中,14E0h 的入口点 RVA 表明程序将在虚拟地址 4014E0h400000h + 14E0h)处开始执行。这是我们开始在程序中查找代码的第一个指示。然而,在此之前,我们需要将程序的其余部分正确映射到适当的虚拟地址。

PE 格式使用节(sections)来描述文件内容与内存范围的映射。通过解析文件中每个节的节头,我们可以完成程序的基本虚拟内存布局。NumberOfSections 字段 ➋ 表示 PE 文件中包含的节的数量(在本例中为五个)。根据 PE 规范,一组节头结构紧随 IMAGE_NT_HEADERS 结构之后。该数组中的每个元素都是 IMAGE_SECTION_HEADER 结构,我们在 Ghidra 结构编辑器中定义,并将其应用(本例中为五次)到 IMAGE_NT_HEADERS 结构之后的字节上。或者,您可以选择第一个节头的第一个字节,并将其类型设置为 IMAGE_SECTION_HEADER[n],其中 n 在本例中为 5,从而将整个数组压缩为 Ghidra 显示行中的一行。

FileAlignment 字段 ➏ 和 SectionAlignment 字段 ➎ 表示每个节的数据在文件中的对齐方式,以及当数据映射到内存时如何对齐。在我们的例子中,两个字段都设置为 1000h 字节偏移量对齐。^(3) 在 PE 格式中,这两个数字不需要相同。然而,二者相同确实让我们的工作更轻松,因为这意味着磁盘文件内内容的偏移量与加载的内存映像中的相应字节的偏移量相同。理解节的对齐方式对于帮助我们在手动创建程序节时避免错误非常重要。

在构造了每个节头后,我们就有足够的信息来创建程序中的其他段。将 IMAGE_SECTION_HEADER 模板应用于紧跟在 IMAGE_NT_HEADERS 结构之后的字节,得到我们在 Ghidra 列表中的第一个节头:

004001d0    IMAGE_SECTION_HEADER

   004001d0       BYTE[8]         ".text"   Name➊

   004001d8    _union_226                   Misc

      004001d8    DWORD           20A80h    PhysicalAddress

      004001d8    DWORD           20A80h    VirtualSize

   004001dc       DWORD           1000h     VirtualAddress➋

   004001e0       DWORD           21000h    SizeOfRawData➌

   004001e4       DWORD           1000h     PointerToRawData➍

   004001e8       DWORD           0h        PointerToRelocations

   004001ec       DWORD           0h        PointerToLinenumbers

   004001f0       WORD            0h        NumberOfRelocations

   004001f2       WORD            0h        NumberOfLinenumbers

Name 字段 ➊ 告诉我们这个节头描述的是 .text 节。其余的字段在格式化列表时可能有用,但我们将重点关注三个描述节布局的字段。PointerToRawData 字段 ➍(1000h)表示可以找到节内容的文件偏移量。注意,这个值是文件对齐值 1000h 的倍数。PE 文件中的节按文件偏移量(和虚拟地址)升序排列。由于该节从文件偏移量 1000h 开始,因此文件的前 1000h 字节包含文件头数据和填充(如果文件头数据少于 1000h 字节,节必须填充到 1000h 字节边界)。因此,尽管文件头字节严格来说并不构成一个节,我们可以通过将它们分组为 Ghidra 列表中的内存块来突出它们在逻辑上是相关的。

Ghidra 提供了两种创建新内存块的方法,这两种方法都可以通过内存映射窗口访问,见图 17-2。添加块工具(参考图 17-3)打开图 17-5 中显示的对话框,用于添加与现有内存块不重叠的新内存块。该对话框要求提供新内存块的名称、起始地址和长度。该块可以通过常量值进行初始化(例如,填充零),也可以通过当前文件中的内容进行初始化(您需要指示内容来源的文件偏移量),或者保持未初始化状态。

创建新块的第二种方法是拆分一个现有的块。在 Ghidra 中拆分块时,必须首先在内存映射窗口中选择要拆分的块,然后使用拆分块工具(参考图 17-3)打开图 17-6 中显示的对话框。我们刚开始,所以只有一个块可以拆分。我们首先在 .text 部分的开始处拆分文件,将程序头从现有块的开头切割下来。当我们输入要拆分的块的长度(1000h)(即头部部分)时,Ghidra 会自动计算剩余的地址和长度字段。剩下的就是为新创建的块提供一个名称,名称来自于第一个部分头部:.text

image

图 17-5:添加内存块对话框

image

图 17-6:拆分块对话框

现在,我们的内存映射中有两个块。第一个块包含正确大小的程序头。第二个块包含正确命名但大小不正确的 .text 部分。这个情况在图 17-7 中得到了体现,我们可以看到 .text 部分的大小是 0x29000 字节。

image

图 17-7:拆分块后的内存映射窗口

回到 .text 部分的头部,我们看到 VirtualAddress 字段 ➋(1000h)是一个 RVA,指定了部分内容开始的内存偏移(从 ImageBase 开始),而 SizeOfRawData 字段 ➌(21000h)指示文件中存在多少字节的数据。换句话说,这个特定的部分头部告诉我们,.text 部分是通过将 21000h 字节的文件数据从 1000h-21FFFh 的偏移映射到虚拟地址 401000h-421FFFh 来创建的。

因为我们在.text段的开始处分割了原始内存块,新的.text段暂时包含了所有剩余的段,因为其当前大小0x29000大于正确的大小0x21000。通过查阅剩余的段头,并反复分割最后一个内存块,我们逐步接近程序的正确最终内存映射。然而,当我们遇到以下一对段头时,问题出现了:

00400220    IMAGE_SECTION_HEADER

   00400220       BYTE[8]         ".data"   Name

   00400228    _union_226                   Misc

      00400228    DWORD           5624h     PhysicalAddress

      00400228    DWORD           5624h     VirtualSize➊

   0040022c       DWORD           24000h    VirtualAddress➋

   00400230       DWORD           4000h     SizeOfRawData➌

   00400234       DWORD           24000h    PointerToRawData

   00400238       DWORD           0h        PointerToRelocations

   0040023c       DWORD           0h        PointerToLinenumbers

   00400240       WORD            0h        NumberOfRelocations

   00400242       WORD            0h        NumberOfLinenumbers

   00400244       DWORD           C0000040h Characteristics

00400248    IMAGE_SECTION_HEADER

   00400248       BYTE[8] ".idata" Name

   00400250    _union_226 Misc

      00400250    DWORD           75Ch      PhysicalAddress

      00400250    DWORD           75Ch      VirtualSize

 00400254       DWORD           2A000h    VirtualAddress➍

   00400258       DWORD           1000h     SizeOfRawData

   0040025c       DWORD           28000h    PointerToRawData➎

   00400260       DWORD           0h        PointerToRelocations

   00400264       DWORD           0h        PointerToLinenumbers

   00400268       WORD            0h        NumberOfRelocations

   0040026a       WORD            0h        NumberOfLinenumbers

   0040026c       DWORD           C0000040h Characteristics

.data段的虚拟大小 ➊ 大于其文件大小 ➌。这意味着什么,如何影响我们的内存映射?编译器已经得出结论,程序需要5624h字节的运行时静态数据,但只提供了4000h字节来初始化这些数据。剩余的1624h字节运行时数据将不会通过可执行文件的内容初始化,因为它们是为未初始化的全局变量分配的。(在程序中,常常会看到这样的变量分配到一个名为.bss的专用段中。)

为了完成我们的内存映射,我们必须为.data段选择一个合适的大小,并确保随后的段也正确映射。.data段将从文件偏移24000h映射4000h字节的数据到内存地址424000h ➋(ImageBase + VirtualAddress)。接下来的段(.idata)将从文件偏移28000h ➎映射1000h字节到内存地址42A000h ➍。如果你留心观察,可能已经注意到,.data段似乎在内存中占用了6000h字节(42A000h–424000h),实际上它确实占用了。这个大小的原因是,.data段需要5624h字节,但这不是1000h的整数倍,因此该段会填充到6000h字节,以确保.idata段符合 PE 头中指定的段对齐要求。为了完成我们的内存映射,我们必须执行以下操作:

  1. 使用4000h的长度分割.data段。生成的.idata段暂时将从428000h开始。

  2. 点击“移动块”图标(图 17-3),将.idata段移动到地址42A000h,并将起始地址设置为 42A000h。

  3. 分离并根据需要移动任何剩余的段,以实现最终的程序布局。

  4. 可选地,扩展任何虚拟大小对齐到比文件大小更高边界的段。在我们的示例中,.data段的虚拟大小5624h对齐到6000h,而其文件大小4000h对齐到4000h。一旦我们通过将.idata段移动到正确的位置来腾出空间,就可以将.data段从4000h扩展到6000h字节。

要扩展 .data 部分,在内存映射窗口中突出显示 .data 部分,然后选择 Expand Down 工具(参见图 17-3),修改该部分的结束地址(或长度)。展开块向下对话框显示在图 17-8 中。(此操作将为该部分名称添加 .exp 扩展名。)

image

图 17-8:展开块向下对话框

我们的最终内存映射,在经过一系列块移动、拆分和扩展后,如图 17-9 所示。除了部分名称、起始和结束地址以及长度列外,还显示了每个部分的读取(R)、写入(W)和执行(X)权限,权限以复选框的形式显示。对于 PE 文件,这些值是通过每个部分头中的 Characteristics 字段的位来指定的。请查阅 PE 规范,以了解如何解析 Characteristics 字段,正确设置每个部分的权限。

image

图 17-9:创建所有部分后的最终内存映射窗口

在所有程序部分正确映射后,我们需要定位一些很可能是代码的字节。AddressOfEntryPoint(RVA 14E0h,或虚拟地址 4014E0h)引导我们到程序的入口点,这是已知的代码位置。导航到该位置后,我们看到以下原始字节列表:

004014e0  ??     55h    U

004014e1  ??     8Bh

004014e2  ??     ECh

...

使用上下文菜单从 address 004014e0 进行反汇编(快捷键 D),启动递归下降过程(其进度可以在代码浏览器的右下角跟踪),并使上面的字节重新格式化为以下所示的代码:

     FUN_004014e0

004014e0  PUSH   EBP

 004014e1  MOV    EBP,ESP

004014e3  PUSH   -0x1

004014e5  PUSH   DAT_004221b8

004014ea  PUSH   LAB_004065f0

004014ef  MOV    EAX,FS:[0x0]

004014f5  PUSH   EAX

在此时,我们希望已收集到足够的代码来进行全面的二进制分析。如果我们对二进制文件的内存布局或文件中代码与数据的分离了解较少,我们将需要依赖其他信息来源来指导我们的分析。确定正确内存布局和定位代码的一些潜在方法包括以下几种:

  • 使用处理器参考手册来了解复位向量的位置。

  • 在二进制文件中查找可能暗示架构、操作系统或编译器的字符串。

  • 查找常见的代码序列,如与构建该二进制文件的处理器相关的函数前言。

  • 对二进制文件的部分进行统计分析,找出看起来在统计上类似于已知二进制文件的区域。

  • 寻找可能是地址表格的重复数据序列(例如,许多非平凡的 32 位整数,它们共享相同的上 12 位)。^(4) 这些可能是指针,并且可能提供有关二进制文件内存布局的线索。

在我们讨论加载原始二进制文件时,请考虑到每次打开一个格式相同但 Ghidra 无法识别的二进制文件时,您都需要重复本节中讲解的每个步骤。在这个过程中,您可能通过编写脚本自动化一些操作,执行一些头部解析和段创建。这正是 Ghidra 加载器模块的目的!在下一节中,我们将编写一个简单的加载器模块,以介绍 Ghidra 的加载器模块架构,然后再深入到执行一些常见任务的更复杂加载器模块,这些任务涉及加载符合结构化格式的文件。

示例 1:SimpleShellcode 加载器模块

在本章开始时,我们尝试将一个 shellcode 文件加载到 Ghidra 中,并且被引导到使用 Raw Binary 加载器。在第十五章中,我们使用了 Eclipse 和 GhidraDev 创建了一个分析模块,并将其作为扩展添加到 Ghidra 中。回想一下,Ghidra 提供的模块选项之一是创建一个加载器模块。在本章中,我们将构建一个简单的加载器模块,作为 Ghidra 的扩展来加载 shellcode。和我们在第十五章中的示例一样,我们将使用简化的软件开发流程,因为这只是一个简单的演示项目。我们的过程将包括以下步骤:

  1. 定义问题。

  2. 创建 Eclipse 模块。

  3. 构建加载器。

  4. 将加载器添加到我们的 Ghidra 安装中。

  5. 从我们的 Ghidra 安装中测试加载器。

什么是 SHELLCODE,为什么我们关心它?

严格来说,shellcode 是原始机器代码,其唯一目的是生成一个用户空间的 shell 进程(例如,/bin/sh),通常通过使用系统调用直接与操作系统内核进行通信。使用系统调用消除了对用户空间库(如libc)的依赖。在这种情况下,raw(原始)一词不应与 Ghidra 的 Raw Binary 加载器混淆。原始机器代码是没有文件头包装的代码,相比于执行相同行为的编译可执行文件,它非常紧凑。对于 Linux 上的 x86-64 架构,紧凑的 shellcode 可能小至 30 字节,但以下 C 程序的编译版本(它同样生成一个 shell)即使在去除调试信息后,仍然超过 6000 字节:

#include <stdlib.h>

int main(int argc, char **argv, char **envp) {

   execve("/bin/sh", NULL, NULL);

}

Shellcode 的缺点在于它不能直接从命令行运行。相反,它通常会被注入到一个现有的进程中,然后采取措施将控制权转交给 shellcode。攻击者可能会试图将 shellcode 放入进程的内存空间中,并与该进程消耗的其他输入一起,触发控制流劫持漏洞,从而允许攻击者将进程的执行重定向到他们注入的 shellcode。由于 shellcode 通常嵌入在其他供进程使用的输入中,因此 shellcode 可能会出现在针对易受攻击的服务器进程的网络流量中,或者出现在需要被易受攻击的查看应用程序打开的文件中。

随着时间的推移,术语 shellcode 被泛指为任何嵌入到漏洞利用中的原始机器代码,无论这些机器代码的执行是否会在目标系统上启动一个用户空间的 shell。

步骤 0:退后一步

在我们开始定义问题之前,我们需要了解 (a) Ghidra 当前如何处理 shellcode 文件,以及 (b) 我们希望 Ghidra 如何处理 shellcode 文件。基本上,我们必须将 shellcode 文件作为原始二进制文件加载并分析,然后利用我们发现的信息来指导我们 shellcode 加载器(并可能是分析器)的开发。幸运的是,大多数 shellcode 远没有 PE 文件那么复杂。让我们深呼吸一下,进入 shellcode 的世界。

让我们从分析我们在章节开始时尝试加载的 shellcode 文件开始。我们加载了文件,并且如前所示,被指向了原始二进制加载器作为唯一选项,图 17-1 中展示了这一点。由于其他加载器都不需要该文件,因此没有为其推荐语言。让我们选择一个相对常见的语言/编译器规格,x86:LE:32:default:gcc,如 图 17-10 所示。

image

图 17-10:带有语言/编译器规格的导入对话框

我们点击 确定,并得到一个包含 图 17-11 所示内容的导入结果摘要窗口。

image

图 17-11:shellcode 文件的导入结果摘要

根据总结中放大区块的内容,我们知道文件在一个内存/数据块中只有 78 字节,这就是我们从原始二进制加载器得到的所有帮助。如果我们在 CodeBrowser 中打开文件,Ghidra 会提供自动分析文件的选项。无论 Ghidra 是否自动分析该文件,CodeBrowser 中的 Listing 窗口都会显示图 17-12 中所示的内容。请注意,程序树中只有一个部分,符号树为空,数据类型管理器在文件特定的文件夹中没有条目。此外,反编译器窗口保持空白,因为文件中没有识别出的函数。

image

图 17-12:加载(或分析)shellcode 文件后的 CodeBrowser 窗口

右键点击文件中的第一个地址,并从上下文菜单中选择Disassemble(快捷键 D)。在 Listing 窗口中,我们现在看到一些可以操作的内容——一系列指令!列表 17-2 显示了反汇编后的指令,以及我们在文件分析后得到的结果。行末注释记录了一些关于这个简短文件的分析内容。

0000002b  INC    EBX

0000002c  MOV    AL,0x66       ; 0x66 is Linux sys_socketcall

0000002e  INT    0x80          ; transfers flow to kernel to

                               ; execute system call

 00000030  XCHG   EAX,EBX

00000031  POP    ECX

        LAB_00000032             XREF[1]:  00000038(j)

00000032  PUSH   0x3f          ; 0x3f is Linux sys_dup2

00000034  POP    EAX

00000035  INT    0x80          ; transfers flow to kernel to

                               ; execute system call

00000037  DEC    ECX

00000038  JNS   LAB_00000

0000003a  PUSH   0x68732f2f    ; 0x68732f2f converts to "//sh"

0000003f  PUSH   0x6e69622f    ; 0x6e69622f converts to "/bin"

00000044  MOV    EBX,ESP

00000046  PUSH   EAX

00000047  PUSH   EBX

00000048  MOV    ECX,ESP

0000004a  MOV    AL,0xb        ; 0xb is Linux sys_execve which

                               ; executes a specified program

0000004c  INT    0x80          ; transfers flow to kernel to

                                      ; execute system call

列表 17-2:反汇编后的 32 位 Linux shellcode

根据我们的分析,shellcode 调用了 Linux 的execve系统调用(在0000004c处),以启动/bin/sh(该路径在0000003a000003f处被压入堆栈)。这些指令对我们有意义,表明我们可能选择了合适的语言和反汇编起点。

我们现在对加载过程了解得足够多,可以定义我们的加载器了。(我们也有足够的信息来构建一个简单的 shellcode 分析器,但那是另一天的任务。)

步骤 1:定义问题

我们的任务是设计并开发一个简单的加载器,它将把 shellcode 加载到 Listing 窗口中并设置入口点,以便进行自动分析。该加载器需要添加到 Ghidra 中,并作为 Ghidra 加载器选项可用。它还需要能够以适当的方式响应 Ghidra Importer 的轮询:与原始二进制加载器的工作方式相同。这将使我们的新加载器成为第二个通用加载器选项。顺便提一下,所有示例都将使用 FlatProgramAPI。虽然 FlatProgramAPI 通常不用于构建扩展,但其使用将巩固在第十四章中介绍的脚本概念,这些概念在你使用 Java 开发 Ghidra 脚本时可能会用到。

步骤 2:创建 Eclipse 模块

如第十五章中所述,使用GhidraDevNewGhidra Module Project来创建一个名为 SimpleShellcode 的模块,该模块使用加载器模块模板。这将会在 SimpleShellcode 模块的src/main/java文件夹中创建一个名为SimpleShellcodeLoader.java的文件。该文件夹层级结构在图 17-13 中有展示。

image

图 17-13: SimpleShellcode 层次结构

第 3 步:构建加载器

加载器模板SimpleShellcodeLoader.java的部分图像如图 17-14 所示。功能已被折叠,以便你可以看到加载器模板中提供的所有加载器方法。回想一下,当你在开发代码时,如果需要导入,Eclipse 会推荐导入项,因此你可以直接开始编码,当 Eclipse 检测到你需要它们时,接受推荐的import语句。

image

图 17-14: SimpleShellcodeLoader 模板

在图 17-14 中的加载器模板内,左侧的行号旁有六个任务标签,指示你应该从哪里开始开发。我们将在处理具体任务时扩展每个部分,并包括与每个任务相关的前后内容,以便你理解如何修改模板。(为了可读性,某些内容将被折叠或重新格式化,注释将被简化以节省空间。)与第十五章中你编写的分析器模块不同,这个模块不需要任何明显的类成员变量,因此你可以直接开始当前任务。

第 3 步-1:记录类

当你展开第一个任务标签时,你会看到以下任务描述:

/**

 * TODO: Provide class-level documentation that describes what this

 * loader does.

 */

这个任务涉及将现有的TODO注释替换为描述加载器功能的注释:

/*

 * This loader loads shellcode binaries into Ghidra,

 * including setting an entry point.

 */
第 3 步-2:命名并描述加载器

展开下一个任务标签会显示一个TODO注释和你需要编辑的字符串。这使你可以轻松识别你应该从哪里开始工作。第二个任务包含以下内容:

public String getName() {

       // TODO: Name the loader.  This name must match the name

       // of the loader in the .opinion files

     return "My loader"➊;

}

将字符串 ➊ 更改为有意义的内容。你不需要担心与.opinion文件中的名称匹配,因为这些文件不适用于将接受任何文件的加载器。当你进入第三个示例时,你会看到.opinion文件。忽略模板中的.opinion文件注释会导致以下代码:

public String getName() {

   return "Simple Shellcode Loader";

}
第 3 步-3:确定加载器是否能加载该文件

我们在章节开头描述的加载过程的第二个步骤涉及到导入器加载器轮询。此任务要求你确定你的加载器是否可以加载文件,并通过你的方法的返回值向导入器提供响应:

public Collection<LoadSpec> findSupportedLoadSpecs(ByteProvider provider)

                            throws IOException {

   List<LoadSpec> loadSpecs = new ArrayList<>();

   // TODO: Examine the bytes in 'provider' to determine if this loader

   // can load it.  If it can load it, return the appropriate load

   // specifications.

   return loadSpecs;

}

大多数加载器通过检查文件的内容来查找魔数或头部结构来实现这一点。ByteProvider输入参数是 Ghidra 提供的一个只读包装器,封装了输入文件流。我们将简化任务,采用 Raw Binary 加载器使用的LoadSpec列表,该列表忽略文件内容,只列出所有可能的LoadSpec。然后,我们将给加载器设置一个比 Raw Binary 加载器更低的优先级,这样如果存在更具体的加载器,它将在 Ghidra 导入对话框中自动拥有更高的优先级。

public Collection<LoadSpec> findSupportedLoadSpecs(ByteProvider provider)

                                                   throws IOException {

   // The List of load specs supported by this loader

   List<LoadSpec> loadSpecs = new ArrayList<>();

   List<LanguageDescription> languageDescriptions =

        getLanguageService().getLanguageDescriptions(false);

   for (LanguageDescription languageDescription : languageDescriptions) {

      Collection<CompilerSpecDescription> compilerSpecDescriptions =

         languageDescription.getCompatibleCompilerSpecDescriptions();

      for (CompilerSpecDescription compilerSpecDescription :

           compilerSpecDescriptions) {

         LanguageCompilerSpecPair lcs =

            new LanguageCompilerSpecPair(languageDescription.getLanguageID(),

            compilerSpecDescription.getCompilerSpecID());

         loadSpecs.add(new LoadSpec(this, 0, lcs, false));

      }

   }

   return loadSpecs;

}

每个加载器都有一个关联的层级和层级优先级。Ghidra 定义了四个加载器层级,层级 0 为高度专业化的加载器,层级 3 为与格式无关的加载器。当多个加载器都愿意接受某个文件时,Ghidra 会按照层级的递增顺序对加载器列表进行排序。同一层级中的加载器则会根据层级优先级的递增顺序进一步排序(即,层级优先级为 10 的加载器会排在层级优先级为 20 的加载器之前)。

例如,PE 加载器和原始二进制加载器都愿意加载 PE 文件,但 PE 加载器是加载这种格式的更好选择(其层级为 1),因此它将出现在原始二进制加载器(层级 3,层级优先级 100)之前。我们将简单 Shellcode 加载器的层级设置为 3(LoaderTier.UNTARGETED_LOADER),优先级设置为 101,因此在 Importer 填充导入窗口中的候选加载器时,它会被赋予最低的优先级。为此,请将以下两个方法添加到您的加载器中:

@Override

public LoaderTier getTier() {

   return LoaderTier.UNTARGETED_LOADER;

}

@Override

public int getTierPriority() {

   return 101;

}
步骤 3-4:加载字节

以下方法展示了在我们编辑内容前后的操作,它完成了将文件内容加载到我们的 Ghidra 项目中的主要任务(在这个例子中,它加载的是 shellcode):

protected void load(ByteProvider provider, LoadSpec loadSpec,

               List<Option> options, Program program, TaskMonitor monitor,

               MessageLog log) throws CancelledException, IOException {

   // TODO: Load the bytes from 'provider' into the 'program'.

}
protected void load(ByteProvider provider, LoadSpec loadSpec,

               List<Option> options, Program program, TaskMonitor monitor,

               MessageLog log) throws CancelledException, IOException {

➊ FlatProgramAPI flatAPI = new FlatProgramAPI(program);

   try {

      monitor.setMessage("Simple Shellcode: Starting loading");

      // create the memory block we're going to load the shellcode into

      Address start_addr = flatAPI.toAddr(0x0);

   ➋ MemoryBlock block = flatAPI.createMemoryBlock("SHELLCODE",

      start_addr, provider.readBytes(0, provider.length()), false);

      // make this memory block read/execute but not writeable

   ➌ block.setRead(true);

      block.setWrite(false);

      block.setExecute(true);

      // set the entry point for the shellcode to the start address

   ➍ flatAPI.addEntryPoint(start_addr);

      monitor.setMessage( "Simple Shellcode: Completed loading" );

   } catch (Exception e) {

      e.printStackTrace();

      throw new IOException("Failed to load shellcode");

   }

}

请注意,与第十四章和第十五章中的脚本不同,这些脚本继承自GhidraScript(最终继承自FlatProgramAPI),我们的加载器类无法直接访问 Flat API。因此,为了简化我们对一些常用 API 类的访问,我们实例化了我们自己的FlatProgramAPI对象➊。接下来,我们在地址零处创建一个名为SHELLCODEMemoryBlock➋,并用输入文件的全部内容填充它。在添加一个入口点之前,我们花时间为新的内存区域设置一些合理的权限➌,该入口点通知 Ghidra 它应从哪里开始反汇编。

添加入口点是加载器的一个非常重要的步骤。入口点的存在是 Ghidra 定位已知包含代码(而非数据)的地址的主要手段。在解析输入文件时,加载器理想情况下能够发现任何入口点并将其标识给 Ghidra。

步骤 3-5:注册自定义加载器选项

一些加载器为用户提供修改与加载过程相关的各种参数的选项。您可以重写getDefaultOptions函数,以便向 Ghidra 提供可用于您加载器的自定义选项列表:

public List<Option> getDefaultOptions(ByteProvider provider, LoadSpec

       loadSpec,DomainObject domainObject, boolean isLoadIntoProgram) {

   List<Option> list = super.getDefaultOptions(provider, loadSpec,

                       domainObject, isLoadIntoProgram);

   // TODO: If this loader has custom options, add them to 'list'

   list.add(new Option("Option name goes here",

                        Default option value goes here));

   return list;

}

由于这个加载器只是用于演示,我们不会添加任何选项。加载器的选项可能包括设置开始读取文件的偏移量,以及设置加载二进制文件的基地址。要查看与任何加载器相关的选项,请点击导入对话框右下角的选项 . . .按钮(请参阅图 17-1)。

public List<Option> getDefaultOptions(ByteProvider provider, LoadSpec

       loadSpec,DomainObject domainObject, boolean isLoadIntoProgram) {

   // no options

   List<Option> list = new ArrayList<Option>();

   return list;

}
步骤 3-6:验证选项

接下来的任务是验证选项:

public String validateOptions(ByteProvider provider, LoadSpec loadSpec,

                              List<Option> options, Program program) {

   // TODO: If this loader has custom options, validate them here.

   // Not all options require validation.

   return super.validateOptions(provider, loadSpec, options, program);

}

由于我们没有任何选项,我们直接返回null

public String validateOptions(ByteProvider provider, LoadSpec loadSpec,

                              List<Option> options, Program program) {

   // No options, so no need to validate

   return null;

}

从 ECLIPSE 测试模块

如果你是那种在第一次尝试时不总是能精确写出代码的程序员,可以通过从 Eclipse 运行新代码来避免多次进行“导出,启动 Ghidra,导入扩展,添加扩展到导入列表,选择扩展,重启 Ghidra,测试扩展”这些循环。如果你从 Eclipse 菜单选择“运行 ▸ 作为运行”,你将获得作为 Ghidra(或 Ghidra Headless)运行的选项。这将启动 Ghidra,你可以将文件导入到当前项目中。你的加载器将作为导入选项包含在内,所有控制台反馈都会在 Eclipse 控制台中显示。你可以像处理其他文件一样在 Ghidra 中与该文件进行交互。然后,你可以在不保存的情况下退出 Ghidra 项目,并选择(1)调整代码,或(2)只进行一次“导出,启动 Ghidra,导入扩展,添加扩展到导入列表,选择扩展,重启 Ghidra,测试扩展”。

步骤 4:将加载器添加到我们的 Ghidra 安装中

在确认该模块正常工作后,从 Eclipse 导出 Ghidra 模块扩展,然后将扩展安装到 Ghidra,就像我们在第十五章中对 SimpleROPAnalyzer 模块所做的那样。选择 GhidraDev导出Ghidra 模块扩展,选择 SimpleShellcode 模块,然后按照你在第十五章中所做的相同步骤进行操作。

要将扩展导入到 Ghidra 中,请从 Ghidra 项目窗口选择 文件安装扩展。将新加载器添加到列表中并选择它。重新启动 Ghidra 后,新加载器应作为选项可用,但你应该进行测试以确保。

步骤 5:在 Ghidra 中测试加载器

我们简化的测试计划仅用于演示功能。SimpleShellcode 已通过以下标准的验收测试:

  1. (通过)SimpleShellcode 作为加载器选项出现,优先级低于原始二进制文件。

  2. (通过)SimpleShellcode 加载文件并设置入口点。

测试用例 1 已通过,如图 17-15 所示。第二个确认在图 17-16 中显示,其中前面章节分析的 PE 文件正在被加载。在这两种情况下,我们可以看到“简单 shellcode 加载器”选项在格式列表中的优先级最低。

image

图 17-15:导入窗口,显示我们的新加载器作为选项

image

图 17-16:导入窗口,显示我们的新加载器作为 PE 文件的选项

根据有关二进制文件的信息和获取方式,选择语言规范。假设 shellcode 是从指向 x86 机器的数据包中捕获的。在这种情况下,选择 x86:LE:32:default:gcc 作为我们的语言/编译器规范可能是一个好的起点。

在我们选择语言并点击 图 17-15 中所示的文件后,二进制文件将被导入到我们的 Ghidra 项目中。然后我们可以在 CodeBrowser 中打开程序,Ghidra 会提供一个选项来分析文件。如果我们接受分析,我们将看到以下清单:

     undefined FUN_00000000()

        undefined  AL:1 <RETURN>

        undefined4 Stack[-0x10]:4 local_10  XREF[1]: 00000022(W)

     FUN_00000000                           XREF[1]: Entry Point(*)➊

00000000 31 db          XOR    EBX,EBX

00000002 f7 e3          MUL    EBX

00000004 53             PUSH   EBX

00000005 43             INC    EBX

00000006 53             PUSH   EBX

00000007 6a 02          PUSH   0x2

00000009 89 e1          MOV    ECX,ESP

0000000b b0 66          MOV    AL,0x66

0000000d cd 80          INT    0x80

0000000f 5b             POP    EBX

00000010 5e             POP    ESI

00000011 52             PUSH   EDX

00000012 68 02 00 11 5c PUSH   0x5c110002

入口点 ➊ 被识别,因此 Ghidra 能够为我们提供反汇编结果,以便开始分析。

SimpleShellcodeLoader 是一个简单的示例,因为 shellcode 通常嵌入在其他数据中。为了演示目的,我们将以我们的加载器模块为基础,创建一个从 C 源文件中提取 shellcode 并加载 shellcode 进行分析的加载器模块。这可能会允许我们构建 Ghidra 能在其他二进制文件中识别的 shellcode 特征。我们不会深入探讨每个步骤,因为我们只是扩展了现有 shellcode 加载器的功能。

示例 2:简单的 Shellcode 源代码加载器

由于模块提供了一种组织代码的方法,并且您创建的 SimpleShellcode 模块具备创建加载器所需的一切,您不需要创建一个新模块。只需从 Eclipse 菜单中选择 文件新建文件,然后将一个新文件 (SimpleShellcodeSourceLoader.java) 添加到您的 SimpleShellcode src/main/java 文件夹中。通过这样做,您所有的新加载器将包含在您的新 Ghidra 扩展中。

为了简化操作,将现有的 SimpleShellcodeLoader.java 文件内容粘贴到这个新文件中,并更新关于加载器功能的注释。以下步骤突出显示了现有加载器中需要更改的部分,以使新加载器按预期工作。在大多数情况下,您将扩展现有代码。

更新 1:修改对导入器轮询的响应

简单的源代码加载器将严格根据文件扩展名做出决定。如果文件的扩展名不是.c,加载器将返回一个空的loadSpecs列表。如果文件扩展名是.c,它将返回与之前的加载器相同的loadSpecs列表。为了使这一点生效,您需要在findSupportLoadSpecs方法中添加以下测试:

// The List of load specs supported by this loader

List<LoadSpec> loadSpecs = new ArrayList<>();

// Activate loader if the filename ends in a .c extension

if (!provider.getName().endsWith(".c")) {

   return loadSpecs;

}

我们还决定,由于我们的加载器识别特定类型的文件并且更适合该类型的文件,所以它的优先级高于原始二进制加载器。这是通过在getTierPriority方法中返回一个较高的优先级(较低的值)来实现的:

public int getTierPriority() {

   // priority of this loader

   return 99;

}

更新 2:在源代码中找到 Shellcode

记住,shellcode 只是执行某些有用任务的原始机器码。shellcode 中的每个字节都在 0..255 范围内,其中许多值超出了 ASCII 可打印字符的范围。因此,当 shellcode 被嵌入到源文件中时,其中的大部分必须通过十六进制转义序列来表示,例如 \xFF。这种类型的字符串相当独特,我们可以构建一个正则表达式,帮助我们的加载器识别它们。以下实例变量声明描述了所有加载器函数可能使用的正则表达式,用于在选定的 C 文件中查找 shellcode 字节:

private String pattern = "\\\\x[0-9a-fA-F]{1,2}";

load 方法中,加载器解析文件,寻找与正则表达式匹配的模式,以帮助计算加载文件到 Ghidra 时所需的内存量。由于 shellcode 通常不是连续的,因此加载器应解析整个文件,寻找需要加载的 shellcode 区域。

// set up the regex matcher

CharSequence provider_char_seq =

      new String(provider.readBytes(0, provider.length())➊, "UTF-8");

Pattern p = Pattern.compile(pattern);

Matcher m = p.matcher(provider_char_seq)➋;

 // Determine how many matches (shellcode bytes) were found so that we can

// correctly size the memory region, then reset the matcher

int match_count = 0;

while (m.find()) {

 ➌ match_count++;

}

m.reset();

加载输入文件的全部内容 ➊ 后,我们统计所有与正则表达式匹配的项 ➌。

更新 3:将 Shellcode 转换为字节值

load() 方法接下来需要将十六进制转义序列转换为字节值,并将它们放入字节数组中:

byte[] shellcode = new byte[match_count];

// convert the hex representation of bytes in the source code to actual

// byte values in the binary we're creating in Ghidra

int ii = 0;

while (m.find()) {

   // strip out the \x

   String hex_digits = m.group().replaceAll("[⁰-9a-fA-F]+", "")➊;

   // parse what's left into an integer and cast it to a byte, then

   // set current byte in byte array to that value

   shellcode[ii++]➋ = (byte)Integer.parseInt(hex_digits, 16)➌;

}

十六进制数字从每个匹配的字符串 ➊ 中提取,并转换为字节值 ➌,然后附加到我们的 shellcode 数组 ➋。

更新 4:加载转换后的字节数组

最后,由于 shellcode 已经是字节数组,load() 方法需要将其从字节数组复制到程序的内存中。这是实际的加载步骤,也是加载器完成目标的最后一步:

// create the memory block and populate it with the shellcode

Address start_addr = flatAPI.toAddr(0x0);

MemoryBlock block =

      flatAPI.createMemoryBlock("SHELLCODE", start_addr, shellcode, false);

结果

为了测试我们的新加载器,我们创建了一个 C 源文件,其中包含以下表示 x86 shellcode 的转义形式:

unsigned char buf[] =

   "\x31\xdb\xf7\xe3\x53\x43\x53\x6a\x02\x89\xe1\xb0\x66\xcd\x80"

   "\x5b\x5e\x52\x68\x02\x00\x11\x5c\x6a\x10\x51\x50\x89\xe1\x6a"

   "\x66\x58\xcd\x80\x89\x41\x04\xb3\x04\xb0\x66\xcd\x80\x43\xb0"

 "\x66\xcd\x80\x93\x59\x6a\x3f\x58\xcd\x80\x49\x79\xf8\x68\x2f"

   "\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\xb0"

   "\x0b\xcd\x80";

由于我们的源文件名以 .c 结尾,因此我们的加载器出现在列表中的首位,优先级高于原始二进制和简单 shellcode 加载器,如 图 17-17 所示。

image

图 17-17:Shellcode 源文件导入对话框

选择此加载器,使用与之前示例相同的默认编译器/语言规范(x86:LE:32:default:gcc),并让 Ghidra 自动分析文件,结果如下所示的反汇编列表中的函数:

     **************************************************************

     *                         FUNCTION                           *

     **************************************************************

     undefined FUN_00000000()

        undefined AL:1 <RETURN>

        undefined4 Stack[-0x10]:4 local_10

     FUN_00000000                      XREF[1]: Entry Point(*)

00000000  XOR    EBX,EBX

00000002  MUL    EBX

00000004  PUSH   EBX

00000005  INC    EBX

00000006  PUSH   EBX

向下滚动查看列表,带我们到熟悉的内容(见 列表 17-2),此处显示的是(已添加注释以便理解):

        LAB_00000032

00000032  PUSH   0x3f

00000034  POP    EAX

 00000035  INT    0x80

00000037  DEC    ECX

00000038  JNS    LAB_00000

0000003a  PUSH   0x68732f2f          ; 0x68732f2f converts to "//sh"

0000003f  PUSH   0x6e69622f          ; 0x6e69622f converts to "/bin"

大多数逆向工程工作集中在二进制文件上。在这种情况下,我们跳出了这个范畴,使用 Ghidra 加载 shellcode 进行分析,并从 C 源文件中提取 shellcode。我们的目标是展示为 Ghidra 创建加载器的灵活性和简便性。现在,让我们重新回到这个范畴,创建一个结构化文件格式的加载器。

假设我们的目标 shellcode 包含在一个 ELF 二进制文件中,并且为了这个示例,Ghidra 无法识别 ELF 二进制文件。此外,我们中的任何人都从未听说过 ELF 二进制文件。冒险开始吧。

示例 3:Simple ELF Shellcode Loader

恭喜!你现在是 shellcode 的常驻逆向工程专家,同事们报告他们怀疑某些二进制文件中包含 shellcode,并且 Ghidra 将它们转介给原始二进制文件加载器。由于这似乎不是一次性的问题,而且你认为很有可能会看到更多具有类似特征的二进制文件,因此你决定构建一个能够处理这种新文件类型的加载器。如 第十三章 中所讨论的,你可以使用 Ghidra 内部或外部的工具来捕获文件信息。如果你再次转向命令行,file 命令提供了有用的信息,帮助你开始构建加载器:

$ file elf_shellcode_min

  elf_shellcode_min: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV),

  statically linked, corrupted section header size

$

file 命令提供了你之前从未听说过的 ELF 格式的信息。你的第一步是进行一些研究,看看是否能找到有关这种二进制文件的任何信息。你的朋友 Google 会很高兴地为你指引几篇关于 ELF 格式的参考资料,你可以利用这些资料来找到构建加载器所需的信息。任何提供足够准确信息以解决问题的资源都可以使用。^(5)

由于这是一个比之前两个加载器示例更大的挑战,我们将其分解为与 Eclipse SimpleShellcode 模块中各个文件相关的部分,你需要创建、修改或删除这些文件以完成你的新 SimpleELFShellcodeLoader。我们将从一些简单的基本准备工作开始。

基本准备工作

第一步是在 Eclipse 中的 SimpleShellcode 模块内创建一个 SimpleELFShellcodeLoader.java 文件。由于你不想从零开始,你应该使用另存为功能创建这个新文件,文件名为 SimpleShellcodeLoader.java。完成这个步骤后,你需要对新文件进行一些小的修改,才能开始集中精力解决新的挑战:

  • 将类名更改为 SimpleELFShellcodeLoader。

  • 修改 getTier 方法的返回值,从 UNTARGETED_LOADER 改为 GENERIC_TARGET_LOADER。

  • 删除 getTierPriority 方法。

  • 修改 getName 方法以返回 "Simple ELF Shellcode Loader"。

一旦你完成了基本的准备工作,让我们应用你从研究中学到的关于新头部格式的信息。

ELF 头部格式

在研究这种新格式时,你发现 ELF 格式包含三种类型的头文件:文件头(或 ELF 头文件)、程序头和节头。你可以从专注于 ELF 头文件开始。每个 ELF 头文件中的字段都与一个偏移量以及其他关于该字段的信息相关联。由于你只需要访问其中的少数字段,并且不会修改偏移量,因此可以将以下常量作为实例变量声明在加载器类中,以帮助加载器正确解析这种新的头文件格式:

private final byte[] ELF_MAGIC            = {0x7f, 0x45, 0x4c, 0x46};

private final long EH_MAGIC_OFFSET        = 0x00;

private final long EH_MAGIC_LEN           = 4;

private final long EH_CLASS_OFFSET        = 0x04;

private final byte EH_CLASS_32BIT         = 0x01;

private final long EH_DATA_OFFSET         = 0x05;

private final byte EH_DATA_LITTLE_ENDIAN  = 0x01;

private final long EH_ETYPE_OFFSET        = 0x10;

private final long EH_ETYPE_LEN           = 0x02;

private final short EH_ETYPE_EXEC         = 0x02;

private final long EH_EMACHINE_OFFSET     = 0x12;

private final long EH_EMACHINE_LEN        = 0x02;

private final short EH_EMACHINE_X86       = 0x03;

private final long EH_EFLAGS_OFFSET       = 0x24;

private final long EN_EFLAGS_LEN          = 4;

private final long EH_EEHSIZE_OFFSET      = 0x28;

private final long EH_PHENTSIZE_OFFSET    = 0x2A;

private final long EH_PHNUM_OFFSET        = 0x2C;

在获取了 ELF 头文件的描述后,下一步是确定如何响应导入器的轮询,以确保新的 ELF 加载器只加载符合 ELF 格式的文件。在前两个示例中,shellcode 加载器并没有查看文件内容来判断是否能够加载一个文件。这大大简化了这些示例的编写。现在情况有点复杂。幸运的是,ELF 文档提供了重要的线索,帮助确定适当的加载器规范。

查找支持的加载规范

加载器无法加载任何不符合正确格式的文件,并且可以通过返回一个空的loadSpecs列表来拒绝任何文件。在findSupportedLoadSpecs()方法中,立即通过以下代码消除所有没有预期魔数的二进制文件:

byte[] magic = provider.readBytes(EH_MAGIC_OFFSET, EH_MAGIC_LEN);

if (!Arrays.equals(magic, ELF_MAGIC)) {

   // the binary is not an ELF

   return loadSpecs;

}

一旦排除了不需要的内容,加载器可以检查位宽和字节顺序,以查看架构是否适合 ELF 二进制文件。为了演示,我们进一步限制加载器接受的二进制文件类型为 32 位小端模式:

byte ei_class = provider.readByte(EH_CLASS_OFFSET);

byte ei_data = provider.readByte(EH_DATA_OFFSET);

if ((ei_class != EH_CLASS_32BIT) || (ei_data != EH_DATA_LITTLE_ENDIAN)) {

   // not an ELF we want to accept

   return loadSpecs;

}

为了完善验证过程,以下代码检查是否为 x86 架构的 ELF 可执行文件(与共享库不同):

byte[] etyp = provider.readBytes(EH_ETYPE_OFFSET, EH_ETYPE_LEN);

short e_type =

      ByteBuffer.wrap(etyp).order(ByteOrder.LITTLE_ENDIAN).getShort();

byte[] emach = provider.readBytes(EH_EMACHINE_OFFSET, EH_EMACHINE_LEN);

short e_machine =

      ByteBuffer.wrap(emach).order(ByteOrder.LITTLE_ENDIAN).getShort();

if ((e_type != EH_ETYPE_EXEC) || (e_machine != EH_EMACHINE_X86)) {

   // not an ELF we want to accept

   return loadSpecs;

}

现在你已经限制了文件类型,你可以查询意见服务以匹配语言和编译器规范。从概念上讲,你是用从加载的文件中提取的值(例如,ELF 头文件中的e_machine字段)查询意见服务,作为响应,你将收到一个语言/编译器规范的列表,加载器愿意接受这些规范。(查询意见服务时后台进行的操作将在以下章节中详细描述。)

byte[] eflag = provider.readBytes(EH_EFLAGS_OFFSET, EN_EFLAGS_LEN);

int e_flags = ByteBuffer.wrap(eflag).order(ByteOrder.LITTLE_ENDIAN).getInt();

List<QueryResult> results =

     QueryOpinionService.query(getName(), Short.toString(e_machine),

                               Integer.toString(e_flags));

假设意见服务可能会返回比你希望此加载器处理的结果更多的内容。你可以通过基于相关语言/编译器规范中指定的属性进一步缩小列表。以下代码过滤掉了一个编译器和一个处理器变种:

for (QueryResult result : results) {

   CompilerSpecID cspec = result.pair.getCompilerSpec().getCompilerSpecID();

   if (cspec.toString().equals("borlanddelphi"➊)) {

      // ignore anything created by Delphi

      continue;

   }

   String variant = result.pair.getLanguageDescription().getVariant();

   if (variant.equals("System Management Mode"➋)) {

       // ignore anything where the variant is "System Management Mode"

       continue;

   }

   // valid load spec, so add it to the list

 ➌ loadSpecs.add(new LoadSpec(this, 0, result));

}

return loadSpecs;

上述示例(你可以自由地将其包括在加载器中)特别排除了Delphi 编译器 ➊和x86 系统管理模式 ➋。你也可以排除其他的。如果你没有排除的所有结果,都需要添加到loadSpecs列表中 ➌。

将文件内容加载到 Ghidra 中

你简化版加载器的load()方法假设文件包含一个最小的 ELF 头和一个简短的程序头,后面是 shellcode 的文本部分。你需要确定头部的总长度,以便为其分配正确的空间。以下代码通过使用 ELF 头中的 EH_EEHSIZE_OFFSETEH_PHENTSIZE_OFFSETEH_PHNUM_OFFSET 字段来确定所需的大小:

// Get some values from the header needed for the load process

//

// How big is the ELF header?

 byte[] ehsz = provider.readBytes(EH_EEHSIZE_OFFSET, 2);

e_ehsize = ByteBuffer.wrap(ehsz).order(ByteOrder.LITTLE_ENDIAN).getShort();

// How big is a single program header?

byte[] phsz = provider.readBytes(EH_PHENTSIZE_OFFSET, 2);

e_phentsize =

      ByteBuffer.wrap(phsz).order(ByteOrder.LITTLE_ENDIAN).getShort();

// How many program headers are there?

byte[] phnum = provider.readBytes(EH_PHNUM_OFFSET, 2);

e_phnum = ByteBuffer.wrap(phunm).order(ByteOrder.LITTLE_ENDIAN).getShort();

// What is the total header size for our simplified ELF format

// (This includes the ELF Header plus program headers.)

long hdr_size = e_ehsize + e_phentsize * e_phnum;

现在你知道了大小,按照以下方式创建并填充 ELF 头部区域和文本区域的内存块:

// Create the memory block for the ELF header

long LOAD_BASE = 0x10000000;  

Address hdr_start_adr = flatAPI.toAddr(LOAD_BASE);

MemoryBlock hdr_block =

      flatAPI.createMemoryBlock(".elf_header", hdr_start_adr,

                                 provider.readBytes(0, hdr_size), false);

// Make this memory block read-only

hdr_block.setRead(true);

hdr_block.setWrite(false);

hdr_block.setExecute(false);

// Create the memory block for the text from the simplified ELF binary

Address txt_start_adr = flatAPI.toAddr(LOAD_BASE + hdr_size);

MemoryBlock txt_block =

      flatAPI.createMemoryBlock(".text", txt_start_adr,

             provider.readBytes(hdr_size, provider.length() – hdr_size),

             false);

// Make this memory block read & execute

txt_block.setRead(true);

txt_block.setWrite(false);

txt_block.setExecute(true);

格式数据字节并添加入口点

再做几步,你就完成了。加载器通常会应用数据类型并为从文件头中派生的信息创建交叉引用。加载器的工作还包括识别二进制文件中的任何入口点。在加载时创建入口点列表,能为反汇编器提供它应该视为代码的位置列表。我们的加载器遵循以下做法:

  // Add structure to the ELF HEADER

➊ flatAPI.createData(hdr_start_adr, new ElfDataType());

 // Add label and entry point at start of shellcode

➋ flatAPI.createLabel(txt_start_adr, "shellcode", true);

➌ flatAPI.addEntryPoint(txt_start_adr);

  // Add a cross reference from the ELF header to the entrypoint

  Data d = flatAPI.getDataAt(hdr_start_adr).getComponent(0).getComponent(9);

➍ flatAPI.createMemoryReference(d, txt_start_adr, RefType.DATA);

首先,在 ELF 头部的开始应用 Ghidra ELF 头数据类型 ➊。^(6) 其次,为 shellcode 创建一个标签 ➋ 和入口点 ➌。最后,我们在 ELF 头的入口点字段和 shellcode 开始之间创建一个交叉引用 ➍。

恭喜!你已经完成了加载器的 Java 代码编写,但我们需要解决几个问题,以确保你理解新加载器与一些重要相关文件之间的所有依赖关系,以便加载器能够按预期正常运行。

这个示例利用了现有的处理器架构(x86),并且在幕后做了一些工作,帮助加载器正确运行。回想一下,导入器轮询了加载器并神奇地生成了可接受的语言/编译器规范。以下两个文件提供了对加载器至关重要的信息。第一个文件是 x86 语言定义文件 x86.ldefs,是 x86 处理器模块的一部分。

语言定义文件

每个处理器都有一个关联的语言定义文件。这个 XML 格式的文件包含生成处理器语言/编译器规范所需的所有信息。满足 32 位 ELF 二进制文件要求的来自 x86.ldefs 文件的语言定义如下所示:

<language processor="x86"

          endian="little"

          size="32"

          variant="default"

          version="2.8"

          slafile="x86.sla"

          processorspec="x86.pspec"

          manualindexfile="../manuals/x86.idx"

          id="x86:LE:32:default">

   <description>Intel/AMD 32-bit x86</description>

   <compiler name="Visual Studio" spec="x86win.cspec" id="windows"/>

   <compiler name="gcc" spec="x86gcc.cspec" id="gcc"/>

   <compiler name="Borland C++" spec="x86borland.cspec" id="borlandcpp"/>

 ➊ <compiler name="Delphi" spec="x86delphi.cspec" id="borlanddelphi"/>

</language>

<language processor="x86"

          endian="little"

          size="32"

        ➋ variant="System Management Mode"

          version="2.8"

          slafile="x86.sla"

          processorspec="x86-16.pspec"

          manualindexfile="../manuals/x86.idx"

          id="x86:LE:32:System Management Mode">

   <description>Intel/AMD 32-bit x86 System Management Mode</description>

   <compiler name="default" spec="x86-16.cspec" id="default"/>

</language>

这个文件用于填充作为导入选项呈现的推荐语言/编译器规范。在这种情况下,有五个推荐规范(每个以 compiler 标签开头),这些规范将根据与 ELF 二进制文件相关的信息返回,但我们的加载器会根据编译器 ➊ 和变种 ➋ 排除其中两个。

意见文件

另一种类型的支持文件是.opinion文件。这是一个 XML 格式的文件,包含与你的加载器相关的约束。为了让意见查询服务识别,每个加载器必须在意见文件中有一个条目。以下列出了你刚刚构建的加载器的合适意见文件条目:

<opinions>

   <constraint loader="Simple ELF Shellcode Loader" compilerSpecID="gcc">

      <constraint➊ primary➋="3" processor="x86"  endian="little" size="32" />

      <constraint primary="62" processor="x86"  endian="little" size="64" />

   </constraint>

</opinions>

入口中的所有内容应该是熟悉的,除了可能的primary字段 ➋。该字段是用于搜索的主键,用于标识在 ELF 头中定义的机器类型。在 ELF 头中,e_machine字段中的值0x03表示 x86,而0x3E表示 amd64。<constraint>标签 ➊定义了主键("3"/x86)和<constraint>标签其余属性之间的关联。这些信息由查询服务用于定位语言定义文件中的相关条目。

我们唯一剩下的任务是将我们的意见数据放置在一个合适的位置,确保 Ghidra 能够找到它。唯一随 Ghidra 一起发布的意见文件位于 Ghidra 处理器模块的data/languages子目录下。尽管你可以将意见数据插入到现有的意见文件中,但最好避免修改任何处理器的意见文件,因为每次升级 Ghidra 安装时,你的修改都需要重新应用。

相反,创建一个新的意见文件,包含我们的意见数据。你可以将文件命名为任何你喜欢的名字,但SimpleShellcode.opinion似乎是合理的。我们的 Eclipse 加载器模块模板包含它自己的data子目录。将你的意见文件保存在这个位置,这样它将与加载器模块关联。Ghidra 在查找意见文件时会定位到它,而且 Ghidra 的任何升级都不应该影响你的模块。

现在你了解了幕后发生的事情,是时候测试你的加载器,看看它是否按预期运行了。

结果

为了展示新的简化 ELF 加载器(一个程序头,没有节区)的成功,让我们逐步了解加载过程,并观察加载器在每个步骤中的表现。

从 Ghidra 项目窗口导入一个文件。导入器将扫描所有 Ghidra 的加载器,包括你自己的,看看哪些加载器愿意加载该文件。回顾一下,你的加载器期待的是符合以下配置文件的文件:

  • 文件开始时的 ELF 魔数

  • 32 位小端

  • x86 架构的 ELF 可执行文件

  • 不能由 Delphi 编译

  • 不能有“系统管理模式”变体

如果你加载了一个符合该配置文件的文件,你应该会看到类似于图 17-18 的导入对话框,显示出愿意处理该文件的加载器的优先列表。

image

图 17-18:elf_shellcode_min 的导入选项

优先级最高的加载器是 Ghidra 的 ELF 加载器。让我们将它接受的语言/编译器规范与新加载器在图底部接受的规范进行比较(见 图 17-19)。

image

图 17-19:两种不同加载器的可接受语言/编译器规范

Delphi 编译器和系统管理模式变种被标准 ELF 加载器接受,但不被你的加载器接受,因为它们已被过滤掉。当你选择加载器加载文件 elf_shellcode_min 时,你应该会看到类似于 图 17-20 的汇总信息。

image

图 17-20:新 ELF Shellcode 加载器的导入结果汇总窗口

如果你在 CodeBrowser 中打开文件,并允许 Ghidra 自动分析该文件,你应该会在文件顶部看到以下 ELF 头部定义:

10000000 7f             db      7Fh          e_ident_magic_num

10000001 45 4c 46       ds      "ELF"        e_ident_magic_str

10000004 01             db      1h           e_ident_class

10000005 01             db      1h           e_ident_data

10000006 01             db      1h           e_ident_version

10000007 00 00 00 00 00 db[9]                e_ident_pad

         00 00 00 00

10000010 02 00          dw      2h           e_type

10000012 03 00          dw      3h           e_machine

10000014 01 00 00 00    ddw     1h           e_version

10000018 54 00 00 10    ddw     shellcode➊  e_entry

1000001c 34 00 00 00    ddw     34h          e_phoff

10000020 00 00 00 00    ddw     0h           e_shoff

10000024 00 00 00 00    ddw     0h           e_flags

10000028 34 00          dw      34h          e_ehsize

在列表中,shellcode 标签 ➊ 显然与入口点相关。双击 shellcode 标签会带你进入一个名为 shellcode 的函数,里面包含了我们在之前两个示例中看到的相同的 shellcode 内容,包括以下内容:

1000008c  JNS    LAB_10000086

1000008e  PUSH   "//sh"

10000093  PUSH   "/bin"

10000098  MOV    EBX,ESP

1000009a  PUSH   EAX

现在你已经确认新加载器正常工作,可以将其作为扩展添加到 Ghidra 安装中,并与那些一直在期待此功能的同事分享。

总结

在本章中,我们集中讨论了处理未识别的二进制文件所面临的挑战。我们通过加载和分析过程的示例,展示了可以在 Ghidra 中使用的方法,以帮助我们解决这些具有挑战性的逆向工程场景。最后,我们将模块创建能力扩展到 Ghidra 加载器的领域。

虽然我们构建的示例非常简单,但它们为编写更复杂的加载器模块提供了基础,并介绍了在 Ghidra 中编写这些模块所需的所有组件。在下一章中,我们将通过介绍处理器模块来完成对 Ghidra 模块的讨论——这些模块在反汇编二进制文件的整体格式化中起着最重要的作用。

第二十一章:Ghidra 处理器

Image

处理器模块是 Ghidra 中最复杂的模块类型,负责 Ghidra 中所有的反汇编操作。除了将机器语言操作码转换为其汇编语言等价物外,处理器模块还支持创建函数、交叉引用和堆栈框架。

尽管 Ghidra 支持的处理器数量令人印象深刻,并且每次发布新版本时都会增加,但在某些情况下,仍然需要开发一个新的 Ghidra 处理器模块。开发处理器模块的显而易见的情况是逆向工程一个在 Ghidra 中没有处理器模块的二进制文件。除此之外,这样的二进制文件可能代表了嵌入式微控制器的固件镜像,或是从手持设备或物联网(IoT)设备中提取的可执行镜像。一个不太明显的使用场景是反汇编嵌入在模糊化的 x86 可执行文件中的自定义虚拟机指令。在这种情况下,现有的 Ghidra x86 处理器模块只能帮助你理解虚拟机本身,而无法理解虚拟机底层的字节码。

如果你决定承担这项艰巨的任务,我们希望确保你拥有一个坚实的基础来帮助你完成这一工作。我们之前的每个模块示例(分析器和加载器)都只需要修改一个 Java 文件。如果你在 Eclipse GhidraDev 环境中创建了这些模块,你会在每个模板中获得一个模块模板和任务标签,以帮助你完成任务。处理器模块更为复杂,不同文件之间的关系必须得到保持,才能使处理器模块正确工作。虽然在本章中我们不会从零开始构建一个处理器模块,但我们将为你提供一个坚实的基础,帮助你理解 Ghidra 处理器模块,并演示如何创建和修改这些模块中的组件。

谁可能会扩展 Ghidra?

根据一项完全不科学的研究,我们强烈怀疑以下几类人群的存在:

分类 1 使用 Ghidra 的少部分人会修改或编写脚本,以自定义或自动化与 Ghidra 相关的某些功能。

分类 2 在分类 1 中,有一小部分人会选择修改或开发插件,以自定义 Ghidra 相关的一些功能。

分类 3 在分类 2 中,更小的一部分人会选择修改或编写分析器,以扩展 Ghidra 的分析功能。

分类 4 在分类 3 中,有一小部分人会选择修改或编写一个加载器来支持新的文件格式。

类别 5 类别 4 中的极少数人会选择修改或编写 Ghidra 处理器模块,因为需要解码的指令集数量远少于使用这些指令集的文件格式数量。因此,新处理器模块的需求相对较低。

随着你深入类别列表,相关任务的性质往往变得越来越专业化。然而,仅仅因为你现在无法设想自己编写 Ghidra 处理器模块,并不意味着学习它们是如何构建的没有任何意义。处理器模块构成了 Ghidra 的反汇编、汇编和反编译功能的基础,了解它们的内部工作原理可能会让你在同事眼中成为 Ghidra 的高手。

理解 Ghidra 处理器模块

为一个真实架构创建处理器模块是一个高度专业化、耗时的工作,超出了本书的范围。然而,了解处理器及其相关指令集如何在 Ghidra 中表示,将有助于你识别需要查看的地方,这样当你需要有关 Ghidra 处理器模块的信息时,你可以轻松地获得所需资源。

Eclipse 处理器模块

我们将从稍微熟悉的领域开始。当你使用 Eclipse ▸ GhidraDev 创建一个处理器模块时,生成的文件夹结构基本上与其他模块类型相同,但处理器模块不会像其他模块那样在src/main/java文件夹中提供一个完整的 Java 源文件,包括注释、任务标签和TODO列表,如图 18-1 所示。

image

图 18-1:处理器模块内容

相反,data 文件夹(如图所示)包含的内容远超其他模块类型的README.txt文件。我们将简要介绍data文件夹中的九个文件,并重点关注它们的文件扩展名。(skel前缀让我们知道我们正在处理一个骨架文件。)

skel.cspec 这是一个 XML 格式的、初看可能令人不知所措的编译器规范文件。

skel.ldefs 这是一个 XML 格式的语言定义文件。骨架文件中包含了一个注释掉的模板,用于定义语言。

skel.opinion 这是一个 XML 格式的导入器意见文件。骨架文件中包含了一个注释掉的模板,用于定义语言/编译器规范。

skel.pspec 这是一个 XML 格式的处理器规范文件。

skel.sinc 这通常是一个 SLEIGH 语言指令文件。^(1)

skel.slaspec 这是一个 SLEIGH 规范文件。

buildLanguage.xml 这个 XML 文件描述了data/languages目录中文件的构建过程。

README.txt 该文件在所有模块中相同,但在本模块中它终于变得有意义,因为它专注于 data/ 目录的内容。

sleighArgs.txt 该文件包含 SLEIGH 编译器选项。

.ldefs.opinion 文件在构建 第十七章 中的 ELF shellcode 加载器时使用过。其他文件扩展名将在你进行示例时逐步呈现。你将学习如何使用这些文件来修改处理器模块,但首先让我们讨论一个特定于处理器模块的新术语——SLEIGH。

SLEIGH

SLEIGH 是一个特定于 Ghidra 的语言,用于描述微处理器指令集,以支持 Ghidra 的反汇编和反编译过程。^(2) languages 目录中的文件(见 图 18-1)要么是用 SLEIGH 编写的,要么是 XML 格式的,因此你一定需要了解一些 SLEIGH 语言,以便创建或修改处理器模块。

指令如何编码以及处理器如何解释它们的规范包含在一个 .slaspec 文件中(有点类似于 .c 文件的作用)。当一个处理器家族有多个不同变体时,每个变体可能有自己的 .slaspec 文件,而变体之间的公共行为可能被提取到单独的 .sinc 文件中(类似于 .h 文件的作用),这些 .sinc 文件可以在多个 .slaspec 文件中引用。Ghidra 的 ARM 处理器就是一个很好的例子,它有十多个 .slaspec 文件,每个文件都引用一个或多个 .sinc 文件。这些文件构成了处理器模块的 SLEIGH 源代码,SLEIGH 编译器的工作是将它们编译成适用于 Ghidra 的 .sla 文件。

我们不会从理论角度深入探讨 SLEIGH,而是会在遇到并需要它们的示例中介绍 SLEIGH 语言的各个组成部分,但首先让我们看一下 SLEIGH 文件中包含的有关指令的信息。

要查看与 CodeBrowser 列表中的指令相关的附加信息,请右键单击并从上下文菜单中选择 Instruction Info。显示的信息来源于 SLEIGH 文件规范中所选指令的定义。图 18-2 显示了 x86-64 PUSH 指令的 Instruction Info 窗口。

image

图 18-2: x86-64 PUSH 指令的 Instruction Info 窗口

Instruction Info 窗口结合了来自 SLEIGH 文件的 PUSH 指令信息,以及 PUSH 在地址 00100736 处的具体使用细节。在本章后面,我们将处理 SLEIGH 文件中的指令定义,并将在我们处理的指令上下文中重新审视此窗口。

处理器手册

处理器制造商提供的文档是获取指令集信息的重要资源。虽然这些版权材料不能包含在你的 Ghidra 分发版中,但你可以通过在列表窗口中右键单击来轻松地将它们集成。如果你右键单击任何指令并选择“处理器手册”,你可能会看到类似于图 18-3 所示的消息,告知你当前处理器的手册无法在预期位置找到。

image

图 18-3:缺失处理器手册对话框

在这里,Ghidra 为你提供了处理缺失手册情况所需的信息。在这个具体的例子中,你首先需要在线找到 x86 手册,然后按照指定的名称和位置将其保存。

注意

有许多与 x86 相关的处理器手册。通过搜索手册信息末尾提供的标识符,你可以在线找到正确的手册: 325383-060US。

一旦你正确安装了手册,选择“处理器手册”将会显示该手册。由于处理器手册通常很大(例如,这本 x86 处理器手册几乎有 2200 页),Ghidra 非常贴心地包含了处理索引文件的功能,这些索引文件将指令映射到手册中的特定页面。幸运的是,x86 手册的索引已经为你创建好了。

处理器手册应放置在适合你处理器的Ghidra/Processors//data/manuals目录中。索引文件应与其关联的手册放在同一目录中。索引文件的格式相对简单。Ghidra 的x86.idx文件的前几行如下所示:

@Intel64_IA32_SoftwareDevelopersManual.pdf [Intel 64 and IA-32 Architectures

     Software Developer's Manual Volume 2 (2A, 2B, 2C & 2D): Instruction Set

     Reference, A-Z, Sep 2016 (325383-060US)]

AAA, 120

AAD, 122

BLENDPS, 123

AAM, 124

文件中的第一行(在本例中已分成三行显示)将手册的本地文件名与当手册在系统中不可用时显示给用户的描述性文本配对。该行的格式如下:

@FilenameInGhidraManualDirectory [Description of manual file]

每一行额外的内容都应采用 INSTRUCTION, page 的格式。指令必须是大写字母,页码从.pdf文件的第一页开始计算。(这不一定是文档上任何给定页面显示的页码。)

可以在一个.idx文件中引用多个手册。只需使用额外的@指令来区分每个手册的指令映射。有关处理器手册索引文件的更多信息,请参见你的 Ghidra 安装目录中的docs/languages/manual_index.txt

一旦你保存并索引了手册,在列表窗口中选择任何指令的处理器手册应该会将你带到该指令在手册中的对应页面。如果手册没有出现,你可能需要选择 编辑 ▸ 工具选项 ▸ 处理器手册 来配置适当的查看器应用程序来查看手册。一个示例查看器设置,用于通过 Firefox 打开手册,如图 18-4 所示。

image

图 18-4:处理器手册工具选项

现在你已经掌握了一些基本的处理器模块术语,是时候深入了解处理器模块实现的内部结构了。

修改 Ghidra 处理器模块

从头开始构建一个处理器模块是一个重大的任务。我们将不像直接跳入其中,而是像以前的示例一样,从修改现有模块开始。由于我们希望展示与实际问题相关的概念,因此我们将从识别一个假设的问题开始,涉及 Ghidra 的 x86 处理器模块。我们将逐步解决这个问题,并使用我们学到的知识来创建一个完整的 Ghidra 处理器模块的宏观视图,展示各种组件如何协同工作。

GHIDRA 的 SLEIGH 编辑器

为了帮助你修改和构建处理器模块,Ghidra 包含一个 SLEIGH 编辑器,能够轻松集成到 Eclipse 环境中。编辑器的安装说明包含在前一节提到的 SLEIGH readme文件中,并且只需要几个步骤。编辑器支持的特殊功能包括以下内容:

语法高亮 为具有特殊含义的内容上色(例如,注释、标记、字符串、变量等)。

验证 标记许多语法错误,并为那些在编译之前无法检测到的错误生成警告。

快速修复 提供针对编辑器检测到的问题的解决建议。(这类似于我们在第十五章中看到的import语句的 QuickFix 选项。)

悬停 当你将鼠标悬停在某个构造上时,会提供该构造的附加信息。

导航 提供特定于 SLEIGH 的导航功能(例如,子构造器、标记、寄存器、pcode 操作等)。

查找引用 快速找到一个变量的所有使用。

重命名 不同于传统的基于字符串的查找和替换,这会在文件及其他相关的.sinc.slaspec文件中重命名实际的变量。

代码格式化 根据 SLEIGH 语言的结构重新格式化文件(例如,根据关键字对构造器进行对齐、对 attach 中的条目进行对齐等)。该功能可以应用于整个文件或选定的部分。

虽然我们推荐使用这个编辑器,特别是它在早期语法检查方面的帮助,但本章中的示例开发并不特定于此编辑器。

问题陈述

在本地安装的Ghidra/Processors目录中快速搜索后,我们发现 x86 处理器模块包含许多指令,但似乎缺少一个假设的虚拟机扩展(VMX)管理指令,适用于 IA32 和 IA64 架构。^(3) 这个指令(我们刚刚为这个示例发明的)叫做VMXPLODE。它的行为类似于 Ghidra 支持的VMXOFF指令。虽然现有的VMXOFF指令会让处理器退出 VMX 操作,VMXPLODE则会以一种炫酷的方式退出!我们将带你一步一步地将这个非常重要的指令添加到现有的 Ghidra x86 处理器模块中,以介绍一些与构建和修改处理器模块相关的概念。

示例 1:向处理器模块添加指令

我们的第一个目标是找到需要修改的文件,以支持VMXPLODE指令。Ghidra/Processors目录包含所有 Ghidra 支持的处理器的子目录,其中之一是 x86。你可以直接在 Eclipse 中打开 x86 处理器模块(或任何其他处理器模块),方法是使用文件 ▸ 从文件系统或归档中打开项目,并提供处理器文件夹的路径(Ghidra/Processors/x86)。这将把你的 Eclipse 实例链接到 Ghidra 的 x86 处理器模块,这意味着你在 Eclipse 中所做的更改将直接反映到你的 Ghidra 处理器模块中。

一个在 Eclipse 中部分展开的 x86 模块,准确反映了相关的 Ghidra 目录结构,见图 18-5。你下载的处理器手册与 x86 索引文件一起存在。

image

图 18-5:Eclipse 包浏览器中的 x86 处理器模块

x86文件夹包含一个data文件夹,就像你在使用 Eclipse ▸ GhidraDev 创建的处理器模块中看到的那样。在这个文件夹中有一个languages文件夹,里面包含 40 多个文件,其中包括 19 个.sinc文件,定义了语言指令。由于 x86 指令集相当庞大,因此该指令集被分割成几个文件,每个文件分组相似的指令。如果我们要向 Ghidra 添加一组新的指令(例如,x86 的SGX指令集),我们可能会创建一个新的.sinc文件来将它们全部集中在一起。(实际上,SGX指令被分组在一个名为sgx.sinc的公共文件中。这就解释了许多.sinc文件之一!)

通过搜索.sinc文件,我们发现ia.sinc包含现有VMX指令集的定义。我们将使用ia.sincVMXOFF的定义作为模板来定义VMXPLODEVMXOFFia.sinc中的两个不同部分被引用。第一个部分是 Intel IA 硬件辅助虚拟化指令的定义:

# MFL: definitions for Intel IA hardware assisted virtualization instructions

define pcodeop invept;   # Invalidate Translations Derived from extended page

                         # tables (EPT); opcode 66 0f 38 80

# -----CONTENT OMITTED HERE-----

define pcodeop vmread;   # Read field from virtual-machine control structure;

                         # opcode 0f 78

define pcodeop vmwrite;  # Write field to virtual-machine control structure;

                         # opcode 0f 79

define pcodeop vmxoff;   # Leave VMX operation; opcode 0f 01 c4

define pcodeop vmxon;    # Enter VMX operation; opcode f3 0f C7 /6

定义部分中的每一项都定义了一个 pcodeop,这是 x86 架构的新微代码操作。

该定义包括一个名称,在这种情况下,还有一个包含描述和操作码的注释。我们需要为我们的新指令填写注释。经过快速的另类现实网络搜索(并进行了测试)确认,操作码0f 01 c5早已为VMXPLODE保留。现在我们有了必要的信息,可以将新指令添加到文件中。以下是我们在上下文中的新定义:

define pcodeop vmxoff;   # Leave VMX operation; opcode 0f 01 c4

define pcodeop vmxplode; # Explode (Fake) VMX operation; opcode 0f 01 c5

define pcodeop vmxon;    # Enter VMX operation; opcode f3 0f C7 /6

我们在ia.sinc中遇到的第二个位置VMXOFF(我们将在此插入新指令)是操作码定义部分。(为了清晰起见,我们省略了部分内容,并将一些指令定义行进行了换行处理以提高可读性。)尽管我们不会完全剖析ia.sinc文件中 8000 多行的代码,但有几个有趣的要点值得注意:

# Intel hardware assisted virtualization opcodes

# -----CONTENT OMITTED HERE-----

# TODO: invokes a VM function specified in EAX➊

:VMFUNC EAX     is vexMode=0 & byte=0x0f; byte=0x01; byte=0xd4 & EAX     { vmfunc(EAX); }

# TODO: this launches the VM managed by the current VMCS. How is the

#       VMCS expressed for the emulator?  For Ghidra analysis?

:VMLAUNCH       is vexMode=0 & byte=0x0f; byte=0x01; byte=0xc2           { vmlaunch(); }

# TODO: this resumes the VM managed by the current VMCS. How is the

#       VMCS expressed for the emulator?  For Ghidra analysis?

:VMRESUME       is vexMode=0 & byte=0x0f; byte=0x01; byte=0xc3           { vmresume(); }

# -----CONTENT OMITTED HERE-----

:VMWRITE Reg32, rm32 is vexMode=0 & opsize=1 & byte=0x0f; byte=0x79;➋

         rm32 & Reg32 ... & check_Reg32_dest ... { vmwrite(rm32,Reg32); build check_Reg32_dest; }

@ifdef IA64➌

:VMWRITE Reg64, rm64 is vexMode=0 & opsize=2 & byte=0x0f;  byte=0x79;

         rm64 & Reg64 ...    { vmwrite(rm64,Reg64); }

@endif

:VMXOFF         is vexMode=0 & byte=0x0f; byte=0x01; byte=0xc4         { vmxoff(); }➍

 :VMXPLODE       is vexMode=0 & byte=0x0f; byte=0x01; byte=0xc5         { vmxplode(); }➎

# -----CONTENT OMITTED HERE-----

#END of changes for VMX opcodes

TODO注释➊,在许多 Ghidra 文件中找到,标识了尚未完成的任务。在 Ghidra 文件中搜索TODO任务是发现为这个开源项目贡献机会的好方法。

接下来,我们看到 32 位➋和 64 位架构的VMWRITE指令。64 位指令被一个测试➌包围,确保它只包含在 64 位.sla文件中。尽管 32 位指令在 64 位环境下是有效的(例如,EAXRAX的 32 位最低有效位),但反之则不成立。条件语句确保操作 64 位寄存器的指令仅包含在 64 位构建中。

VMXOFF指令➍并不直接涉及寄存器,因此不需要区分 32 位和 64 位版本的指令。我们新指令VMXPLODE ➎的构造函数(包含其新操作码)与VMXOFF的构造函数非常相似。让我们将这一行的组件分解:

:VMXPLODE

这是正在定义的指令,并显示在反汇编清单中。

是 vexMode=0 & byte=0x0f; byte=0x01; byte=0xc5

这些是与指令相关的比特模式,并为该指令提供了约束条件。&代表逻辑与操作。分号有着双重作用,既用于连接,也用于逻辑与。这部分的意思是:“如果我们不处于 VEX 模式,并且操作码按此顺序为这 3 个字节,那么该约束条件满足。”^(4)

{ vmxplode(); }

花括号用于括起指令的语义动作部分。SLEIGH 编译器将这些动作转换为一种称为 p-code 的 Ghidra 内部形式(在本章后面讨论)。定义指令需要理解 SLEIGH 操作符和语法。构造函数的这一部分,执行大多数指令相关的实际工作,可以迅速变成一个由分号分隔的多个语句的复杂序列。在这种情况下,由于我们已将VMXPLODE定义为新的 p-code 操作(define pcodeop vmxplode;),我们可以在此调用该指令。在以后的示例中,我们将向这一部分添加额外的 SLEIGH 语义动作。

最大的 x86.sinc文件是ia.sinc,因为在该文件中定义了许多指令(包括我们的新VMXPLODE指令),以及大量定义 x86 处理器属性的内容(例如字节序、寄存器、上下文、标记、变量等)。ia.sinc中的许多 x86 特定内容没有在该目录中的其他.sinc文件中复制,因为所有.sinc文件都被包含在 SLEIGH 规范(.slaspec)文件中。

两个 x86 的.slaspec文件,x86.slaspecx86-64.slaspec,每个文件都包含了对所需.sinc文件的include语句。(请注意,你可以省略使用.sinc文件,直接在.slaspec文件中包含内容,这对于具有较小指令集的处理器来说可能更有意义。)x86-64.slaspec的内容如下所示:

  @define IA64 "IA64"         # Only in x86-64.slaspec

➊ @include "ia.sinc"

  @include "avx.sinc"

  @include "avx_manual.sinc"

  @include "avx2.sinc"

  @include "avx2_manual.sinc"

  @include "rdrand.sinc"      # Only in x86-64.slaspec

  @include "rdseed.sinc"      # Only in x86-64.slaspec

  @include "sgx.sinc"         # Only in x86-64.slaspec

  @include "adx.sinc"

  @include "clwb.sinc"

  @include "pclmulqdq.sinc"

  @include "mpx.sinc"

  @include "lzcnt.sinc"

  @include "bmi1.sinc"

  @include "bmi2.sinc"

  @include "sha.sinc"

  @include "smx.sinc"

  @include "cet.sinc"

  @include "fma.sinc"         # Only in x86-64.slaspec

我们已添加 EOL 注释,以标示出* x86-64.slaspec文件中特有的内容。(x86.slaspec文件是x86-64.slaspec文件的一个子集。)在包含的文件中有ia.sinc* ➊,其中我们定义了VMXPLODE,因此无需添加任何内容。如果你创建一个新的.sinc文件,你需要在* x86.slaspecx86-64.slaspec*中都添加include语句,才能使指令在 32 位和 64 位二进制文件中都能被识别。

为了测试 Ghidra 是否能在二进制文件中识别新指令,我们构建了一个测试文件。该文件首先验证VMXOFF指令是否仍被识别,然后验证VMXPLODE是否已成功添加。用于测试VMXOFF的 C 源文件包含如下内容:

#include <stdio.h>

// The following function declares an assembly block and tells the

// compiler that it should execute the code without moving or changing it.

void do_vmx(int v) {

   asm volatile (

      "vmxon %0;"       // Enable hypervisor operation

      "vmxoff;"         // Disable hypervisor operation

      "nop;"            // Tiny nop slide to accommodate examples

 "nop;"

      "nop;"

      "nop;"

      "nop;"

      "nop;"

      "nop;"

      "vmxoff;"         // Disable hypervisor operation

      :

      :"m"(v)           // Holds the input variable

      :

   );

}

int main() {

   int x;

   printf("Enter an int: ");

   scanf("%d", &x);

   printf("After input, x=%d\n", x);

   do_vmx(x);

   printf("After do_vmx, x=%d\n", x);

   return 0;

}

当我们将编译后的二进制文件加载到 Ghidra 中时,我们会在 Listing 窗口看到函数do_vmx的以下主体:

  0010071a 55             PUSH     RBP

  0010071b 48 89 e5       MOV      RBP,RSP

  0010071e 89 7d fc       MOV      dword ptr [RBP + local_c],EDI

  00100721 f3 0f c7       VMXON    qword ptr [RBP + local_c]

           75 fc

➊ 00100726 0f 01 c4       VMXOFF

  00100729 90             NOP

  0010072a 90             NOP

  0010072b 90             NOP

  0010072c 90             NOP

  0010072d 90             NOP

  0010072e 90             NOP

  0010072f 90             NOP

➋ 00100730 0f 01 c4       VMXOFF

  00100733 90             NOP

  00100734 5d             POP RBP

  00100735 c3             RET

在两次调用VMXOFF ➊➋时显示的字节(0f 01 c4)与我们在ia.sinc中为该指令观察到的操作码匹配。以下来自反编译器窗口的列表与我们对源代码及相关反汇编的了解一致:

void do_vmx(undefined4 param_1)

{

   undefined4 unaff_EBP;

   vmxon(CONCAT44(unaff_EBP,param_1));

   vmxoff();

   vmxoff();

   return;

}

为了测试 Ghidra 是否能检测到 VMXPLODE 指令,我们将 do_vmx 测试函数中第一次出现的 VMXOFF 替换为 VMXPLODE。然而,VMXPLODE 指令不仅在 Ghidra 的处理器定义中缺失,而且在我们的编译器知识库中也没有。为了让汇编器接受我们的代码,我们通过数据声明手动组装了该指令,而不是直接使用指令助记符,以便汇编器能够处理这条新指令:

   //"vmxoff;"                 // replace this line

   ".byte 0x0f, 0x01, 0xc5;"   // with this hand assembled one

当您将更新后的二进制文件加载到 Ghidra 中时,您将在列表窗口中看到以下内容:

  0010071a 55 PUSH RBP

  0010071b 48 89 e5 MOV RBP,RSP

  0010071e 89 7d fc MOV dword ptr [RBP + local_c],EDI

  00100721 f3 0f c7 VMXON qword ptr [RBP + local_c]

           75 fc

➊ 00100726 0f 01 c5 VMXPLODE

  00100729 90 NOP

  0010072a 90 NOP

  0010072b 90 NOP

  0010072c 90 NOP

  0010072d 90 NOP

  0010072e 90 NOP

  0010072f 90 NOP

  00100730 0f 01 c4 VMXOFF

  00100733 90 NOP

  00100734 5d POP RBP

  00100735 c3 RET

您的新指令 ➊ 与我们分配给它的操作码(0f 01 c5)一起出现。反编译器窗口也显示了新指令:

void do_vmx(undefined4 param_1)

{

   undefined4 unaff_EBP;

   vmxon(CONCAT44(unaff_EBP,param_1));

   vmxplode();

   vmxoff();

   return;

}

那么,Ghidra 在后台为将我们新的指令添加到 x86 处理器指令集中做了什么工作呢?当 Ghidra 重启时(如有需要时才能使这些更改生效),它会检测到基础 .sinc 文件发生了变化,并在需要时生成新的 .sla 文件。

在本示例中,当我们加载原始编译后的 64 位二进制文件时,Ghidra 检测到 ia.sinc 文件的更改,并在重新编译 ia.sinc 文件时显示了图 18-6 中的窗口。(请注意,只有在需要时才会重新编译,而不是在重启时自动重新编译。)由于我们加载了一个 64 位文件,因此只有 x86-64.sla 被更新,而 x86.sla 没有更新。稍后,当我们加载包含 VMXPLODE 命令的更新文件时,Ghidra 不会重新编译,因为自上次加载以来,任何基础的 SLEIGH 源文件都没有发生更改。

image

图 18-6:重新编译语言文件时显示的 Ghidra 窗口

以下是将新指令添加到处理器模块的步骤总结:

  1. 定位目标处理器的 languages 目录(例如,Ghidra/Processor/<>/data/languages)。

  2. 将指令添加到选定处理器的 .sinc 文件中,或创建一个新的 .sinc 文件(例如,Ghidra/Processor//data/languages/.sinc)。

  3. 如果您创建了新的 .sinc 文件,请确保它已包含在 .slaspec 文件中(例如,Ghidra/Processor//data/languages/.slaspec)。

示例 2:修改处理器模块中的指令

我们已经成功将指令添加到 Ghidra x86 处理器模块,但尚未完成目标,即让 VMXPLODE华丽的方式 退出。目前,它只是无动于衷地退出。虽然使汇编语言指令做一些足以称为华丽的事情是具有挑战性的,但我们可以在它退出时让我们的指令 摆个姿势。^(5) 在本示例中,我们将通过三种选项来演示如何让 VMXPLODE 为我们摆个姿势。第一种选择是退出时将 EAX 设置为硬编码值:0xDAB

选项 1:将 EAX 设置为常量值

VMXPLODE指令在退出之前将EAX的值设置为0xDAB,只需对我们在示例 1 中使用的同一文件(ia.sinc)中的一条指令进行小幅修改。以下列出了在示例 1 之后我们留下的VMXOFFVMXPLODE指令:

:VMXOFF         is vexMode=0 & byte=0x0f; byte=0x01; byte=0xc4      { vmxoff(); }

:VMXPLODE       is vexMode=0 & byte=0x0f; byte=0x01; byte=0xc5      { vmxplode(); }

在指令内容中,立即在vmxplode操作之前添加对EAX的赋值,如下所示:

:VMXOFF         is vexMode=0 & byte=0x0f; byte=0x01; byte=0xc4      { vmxoff(); }

:VMXPLODE       is vexMode=0 & byte=0x0f; byte=0x01; byte=0xc5      { EAX=0xDAB; vmxplode(); }

当我们重新打开 Ghidra 并加载我们的测试文件时,Ghidra 再次显示图 18-6 中所示的窗口,告诉我们它已经检测到与关联语言文件的变化,并正在重新生成x86-64.sla。Ghidra 自动分析文件后,列表示例窗口没有显示任何变化,但在反编译器窗口中的差异是显而易见的:

undefined4 do_vmx(undefined4 param_1)

{

   undefined4 unaff_EBP;

   vmxon(CONCAT44(unaff_EBP,param_1));

   vmxplode();

   vmxoff();

   return 0xdab;

}

在反编译器窗口中,return语句现在返回EAX的内容(0xDAB)。这很有趣,因为我们知道这是一个 void 函数,并没有返回值。新指令的列在列表示例中没有显示VMXPLODE命令有任何变化:

00100726 0f 01 c5       VMXPLODE

反编译器和反汇编器之间的一个重要区别在于,反编译器理解并将每条指令的完整语义行为纳入其分析中,而反汇编器则主要关注每条指令的正确语法表示。在这个例子中,VMXPLODE不接受操作数,反汇编器正确地显示了该指令,但没有提供任何视觉提示说明EAX已发生变化。在阅读反汇编时,完全是你的责任去理解每条指令的语义行为。这个例子还展示了反编译器的价值,它理解VMXPLODE的完整语义,能够识别出EAX是作为指令的副作用而发生了变化。反编译器还识别到EAX在函数的其余部分不再使用,并假设该值将返回给调用函数。

Ghidra 提供了一个机会,让你深入了解指令是如何工作的,并能够检测和测试指令之间的微妙差异。首先,让我们查看与VMXPLODE相关的一些指令信息,如图 18-7 所示。

image

图 18-7: VMXPLODE 指令信息

左侧是我们原始的VMXPLODE指令,右侧是修改后的版本,0xdab列在输入对象➊部分,EAX列在结果对象➋部分。我们可以通过查看底层信息,称为 p-code,来获取有关任何指令的额外见解,之前我们没有查看过这些信息。^(6) 该指令的 p-code 可以非常详细地说明指令到底做了什么。

P-CODE:你能深入到什么程度?

Ghidra 文档将 p-code 描述为“一种为逆向工程应用设计的寄存器传输语言(RTL)。” 寄存器传输语言(RTL) 是一种与架构无关、类似汇编语言的语言,通常作为高层语言(如 C)和目标汇编语言(如 x86 或 ARM)之间的中间表示(IR,或称中间语言 IL)。编译器通常由特定语言的前端组成,负责将源代码翻译为 IR,再由特定架构的后端将 IR 翻译为特定的汇编语言。这种模块化允许将 C 前端与 x86 后端结合,创建一个生成 x86 代码的 C 编译器,同时也提供灵活性,可以用 ARM 模块替换后端,立即得到一个生成 ARM 代码的 C 编译器。将 C 前端换成 FORTRAN 前端,你就得到了一个适用于 ARM 的 FORTRAN 编译器。

在 IR 层面工作使我们能够构建在 IR 上操作的工具,而不是维持一套针对 C 或 ARM 特定的工具,这些工具在其他语言或架构下毫无用处。例如,一旦我们有了一个针对 IR 的优化器,我们可以在任何前端/后端组合中重用该优化器,而无需在每种情况下重写优化器。

逆向工程工具链,毫不奇怪,运行方向与传统的软件构建链相反。逆向工程前端需要将机器码转换为 IR(这个过程通常称为 lifting),而逆向工程后端将 IR 转换为 C 等高层语言。根据这个定义,纯粹的反汇编器不算是前端,因为它只将机器码转换为汇编语言。Ghidra 的反编译器是一个 IR 到 C 的后端。Ghidra 处理器模块是机器码到 IR 的前端。

当你在 SLEIGH 中构建或修改 Ghidra 处理器模块时,首先需要做的一件事就是让 SLEIGH 编译器知道你需要引入的任何新的 p-code 操作,以描述任何新指令或修改指令的语义动作。例如,操作定义

define pcodeop vmxplode

我们添加到 ia.sinc 文件中的定义指示 SLEIGH 编译器 vmxplode 是一个有效的语义动作,可用于描述我们架构中任何指令的行为。你将面临的一个最具挑战性的问题是,使用一系列语法正确的 SLEIGH 语句准确描述每条新增或更改的指令,并正确描述与指令相关的动作。所有这些信息都被捕获在 .slaspec 和包含的 .sinc 文件中,这些文件组成了你的处理器。如果你做得足够好,Ghidra 会免费给你提供反编译器后端。

要查看列表窗口中的 p-code,请打开 浏览器字段格式化器 并选择 指令/数据 标签,右键单击 P-code 栏,并启用该字段。一旦列表窗口显示每条指令的 p-code,我们就可以比较前两个列表,观察其中的差异。启用 p-code 后,我们的 VMXPLODE 第一次实现如下,p-code 显示在每条指令后面:

0010071b 48 89 e5       MOV      RBP,RSP

                                            RBP = COPY RSP

                                            $U620:8 = INT_ADD RBP, -4:8

                                            $U1fd0:4 = COPY EDI

                                            STORE ram($U620), $U1fd0

00100721 f3 0f c7 75 fc VMXON    qword ptr [RBP + local_c]

                                            $U620:8 = INT_ADD RBP, -4:8

                                            $Ua50:8 = LOAD ram($U620)

                                            CALLOTHER "vmxon", $Ua50

00100726 0f 01 c5       VMXPLODE

                                            CALLOTHER "vmxplode"

00100729 90             NOP

这是修改后的 VMXPLODE

00100726 0f 01 c5       VMXPLODE

                                            ➊ EAX = COPY 0xdab:4

                                               CALLOTHER "vmxplode"

相关的 p-code 现在显示常数值 (0xdab) 被移动到 EAX ➊。

选项 2:将寄存器(由操作数确定)设置为常数值

指令集通常由操作零个或多个操作数的指令组成。随着与指令相关的操作数的数量和类型增加,描述指令语义的难度也随之增加。在这个例子中,我们将扩展 VMXPLODE 的行为,使其需要一个单一的寄存器操作数,该寄存器将被赋值为 dab。这将要求我们访问 ia.sinc 文件中我们之前没有遇到的部分。这次,让我们从修改后的指令开始,然后向后推演。以下列表显示了我们需要对指令定义进行的修改,以接受一个操作数,该操作数将标识最终保存 0xDAB 的寄存器:

:VMXPLODE   Reg32➊ is vexMode=0 & byte=0x0f; byte=0x01; byte=0xc5; Reg32➋

         {  Reg32=0xDAB➋; vmxplode(); }

在这里,Reg32 ➊ 被声明为局部标识符,然后与操作码 ➋ 连接,成为指令相关约束的一部分。与之前直接将 0xDAB 赋值给 EAX 不同,指令现在将值赋给 Reg32 ➌。为了实现我们的目标,我们需要找到一种方法,将 Reg32 中的值与我们选择的 x86 寄存器关联起来。让我们探究 ia.sinc 中的其他组件,帮助我们理解如何正确地将操作数映射到特定的 x86 通用寄存器。

ia.sinc 文件的开头,我们看到所有整个规范所需的定义,如 Listing 18-1 所示。

# SLA specification file for Intel x86

@ifdef IA64➊

@define SIZE     "8"

@define STACKPTR "RSP"

@else

@define SIZE     "4"

@define STACKPTR "ESP"

@endif

define endian=little;➋

define space ram type=ram_space size=$(SIZE) default;

define space register type=register_space size=4;

# General purpose registers➌

@ifdef IA64

define register offset=0 size=8 [ RAX    RCX    RDX    RBX    RSP    RBP    RSI    RDI ]➍;

define register offset=0 size=4    [ EAX _  ECX _  EDX _  EBX _  ESP _  EBP _  ESI _  EDI ];

define register offset=0 size=2    [ AX _ _ _ CX _ _ _ DX _ _ _ BX];       # truncated

define register offset=0 size=1    [ AL AH _ _ _ _ _ _ CL CH _ _ _ _ _ _]; # truncated y

define register offset=0x80 size=8 [ R8    R9    R10    R11    R12    R13    R14    R15 ]➎;

define register offset=0x80 size=4 [ R8D _ R9D _ R10D _ R11D _ R12D _ R13D _ R14D _ R15D ];

define register offset=0x80 size=2 [ R8W _ _ _ R9W _ _ _ R10W _ _ _ R11W ];  # truncated

define register offset=0x80 size=1 [ R8B _ _ _ _ _ _ _ R9B _ _ _ _ _ _ _ ];  # truncated

@else

define register offset=0 size=4    [  EAX  ECX  EDX  EBX  ESP  EBP  ESI  EDI ];

define register offset=0 size=2    [  AX _ CX _ DX _ BX _ SP _ BP _ SI _ DI ];

define register offset=0 size=1    [  AL AH _ _ CL CH _ _ DL DH _ _ BL BH ];

@endif

Listing 18-1:x86 寄存器的部分 SLEIGH 规范(改编自 ia.sinc)

在文件的顶部,我们可以看到 32 位和 64 位构建的堆栈指针名称和大小 ➊,以及 x86 的字节序 ➋。一个注释 ➌ 引入了通用寄存器定义的开始。和所有其他组件一样,SLEIGH 在命名和定义寄存器方面有一个特殊的约定:寄存器位于一个名为register的特殊地址空间中,每个寄存器(可能跨越 1 个或多个字节)都被分配一个在地址空间中的偏移量。SLEIGH 寄存器定义指示寄存器列表在寄存器地址空间中开始的偏移量。寄存器列表中的所有寄存器是连续的,除非使用下划线在它们之间创建空格。64 位RAXRCX寄存器的地址空间布局 ➍ 在图 18-8 中有更详细的展示。

image

图 18-8:x86-64 的 RAXRCX寄存器的寄存器布局*

名为AL的寄存器恰好占据了RAXEAXAX的最低有效字节的位置(因为 x86 是小端序)。类似地,EAX占据了RAX的低 4 个字节。下划线表示没有名称与给定字节范围相关联。在这种情况下,位于偏移四到七的 4 字节块没有名称,尽管这些字节与RAX寄存器的上半部分同义。清单 18-1 描述了从偏移0x80开始的一个寄存器块,该寄存器块以R8为起始 ➎。位于偏移0x80的 1 字节寄存器被称为R8B,而位于偏移0x88的 1 字节寄存器被称为R9B。希望清单 18-1 中的寄存器定义与图 18-8 中的表格表示之间的相似性显而易见,因为 SLEIGH 文件中的寄存器定义不过是架构寄存器地址空间的文本表示。

如果你正在为一个完全不被 Ghidra 支持的架构编写 SLEIGH 描述,那么你的任务就是为该架构布局寄存器地址空间,确保寄存器之间没有重叠,除非该架构要求(例如 x86-64 架构中的RAXEAXAXAHAL)。

现在你理解了寄存器在 SLEIGH 中的表示方式,让我们回到我们选择寄存器进行dab的目标!为了让我们的指令正常工作,它需要将标识符Reg32映射到一个通用寄存器。为了完成这个任务,我们可以使用在ia.sinc中找到的现有定义,该定义位于以下代码行中:

➊ define token modrm (8)

       mod           = (6,7)

       reg_opcode    = (3,5)

       reg_opcode_hb = (5,5)

       r_m           = (0,2)

       row           = (4,7)

       col           = (0,2)

       page          = (3,3)

       cond          = (0,3)

       reg8          = (3,5)

       reg16         = (3,5)

    ➋ reg32         = (3,5)

       reg64         = (3,5)

       reg8_x0       = (3,5)

define语句 ➊ 声明了一个 8 位的标记,名为modrm。SLEIGH 标记是用来表示构成被建模指令的字节大小组件的语法元素。^(7) SLEIGH 允许在标记中定义任意数量的位字段(一个或多个连续的位范围)。当你在 SLEIGH 中定义指令时,这些位字段提供了一种方便、符号化的方式来指定相关的操作数。在这个列表中,一个名为reg32的位字段 ➋ 跨越了modrm的第 3 到 5 位。这个 3 位字段的值可以是 0 到 7,并可以用来选择八个 32 位 x86 寄存器中的一个。

如果我们转到文件中reg32的下一个引用,我们将看到以下有趣的代码行:

# attach variables fieldlist registerlist;

  attach variables [ r32   reg32   base   index ]   [ EAX  ECX  EDX  EBX  ESP  EBP  ESI  EDI ];

#                                                      0    1    2    3    4    5    6    7

列表的第一行和最后一行包含注释,显示了此语句的 SLEIGH 语法和每个寄存器的序号值。attach variables语句将字段与一个列表关联(在此情况下是 x86 通用寄存器的列表)。结合前面的modrm定义,代码行的粗略解释如下:reg32的值是通过查看modrm标记的第 3 到 5 位来确定的。得到的值(0 到 7)然后用作索引,从列表中选择一个寄存器。

我们现在有了一种方法来识别目标寄存器,以便为0xDAB提供值。我们在文件中再次遇到Reg32时,找到了以下代码,它包含了 32 位和 64 位寄存器的Reg32构造器,现在我们可以看到reg32Reg32之间的关联:^(8)

Reg32:    reg32  is rexRprefix=0 & reg32   { export reg32; } #64-bit Reg32

Reg32:    reg32  is reg32                  { export reg32; } #32-bit Reg32

让我们回到开始这个小冒险的命令:

:VMXPLODE Reg32➊is vexMode=0 & byte=0x0f; byte=0x01; byte=0xc5; Reg32➋

                                           { Reg32=0xDAB; vmxplode(); }

我们将在调用VMXPLODE时包含一个操作数,用于确定哪个寄存器获得值0xDAB。我们将通过移除第一个NOP并将值0x08附加到我们手工组装的指令中,进一步更新我们的测试二进制文件。前 3 个字节是操作码(0f 01 c5),接下来的字节(08)将是指定要使用的寄存器的操作数:

".byte 0x0f, 0x01, 0xc5, 0x08;"            // hand assembled with operand

图 18-9 展示了从操作数到基于ia.sinc文件中的信息确定寄存器的逐步翻译过程。

image

图 18-9:从操作数到寄存器的翻译路径

原始操作数值,如第一行所示,是0x08 ➊。该值被解码为其二进制形式 ➋,并与modrm标记的字段叠加 ➌。提取位 3 到 5,得到Reg32001 ➍。这个值用于索引序号映射 ➎ 以选择ECX寄存器 ➏。因此,操作数0x08指定ECX将获得值0xDAB

当我们保存更新后的ia.sinc文件,重启 Ghidra,然后加载并分析该文件时,生成了以下列表,显示了我们新指令的使用情况。正如预期的那样,ECX是被选中来存储0xDAB的寄存器:

00100721 f3 0f c7 75 fc VMXON    qword ptr [RBP + local_c]

                                            $U620:8 = INT_ADD RBP, -4:8

                                            $Ua50:8 = LOAD ram($U620)

                                            CALLOTHER "vmxon", $Ua50

00100726 0f 01 c5 08    VMXPLODE ECX

                                            ECX = COPY 0xdab:4

                                            CALLOTHER "vmxplode"

0010072a 90 NOP

0xDAB不再出现在反编译器窗口中,因为反编译器假设返回值位于EAX中。在这种情况下,我们使用的是ECX,因此反编译器没有识别到返回值。

现在我们可以让选择的寄存器成为 dab,让我们添加一个 32 位立即数作为第二个操作数。这将使我们的庆祝潜力翻倍。

选项 3:寄存器和值操作数

为了扩展我们指令的语法,使其可以接收两个操作数(目标寄存器和源常量),请按照这里所示更新VMXPLODE的定义:

:VMXPLODE Reg32,imm32 is vexMode=0 & byte=0x0f; byte=0x01; byte=0xc5;

          Reg32; imm32                     { Reg32=imm32; vmxplode(); }

向指令中添加一个 32 位立即常量需要额外的 4 个字节进行编码。因此,我们用正确编码我们的imm32的小端顺序替换接下来的四个 NOPs,如下所示:

".byte 0x0f, 0x01, 0xc5, 0x08, 0xb8, 0xdb, 0xee, 0x0f;"

"nop;"

"nop;"

当我们重新加载文件时,VMXPLODE以另一种华丽的方式退出。正如以下列表中所示(显示了 p-code),ECX现在的值是0xFEEDBB8(这可能是科幻迷更吸引的退出华彩):

00100726 0f 01 c5       VMXPLODE ECX,0xfeedbb8

         08 b8 db

         ee 0f

                                            ECX = COPY 0xfeedbb8:4

                                            CALLOTHER "vmxplode"

示例 3:向处理器模块添加寄存器

我们通过向架构中添加两个全新的寄存器来结束我们的处理器模块示例。^(9) 回顾一下本章前面提到的 32 位通用寄存器定义:

define register offset=0  size=4  [EAX ECX EDX EBX ESP EBP ESI EDI];

寄存器的定义需要一个偏移量、大小和寄存器列表。我们在审查当前已分配的偏移量并找到为两个 4 字节寄存器所需的空间后,选择了寄存器内存地址空间中的起始偏移量。我们可以利用这些信息在ia.sinc文件中定义两个新的 32 位寄存器,分别为VMIDVMVER,如下所示:

# Define VMID and VMVER

define register offset=0x1500 size=4 [ VMID VMVER ];

我们的指令需要一种方式来识别它们正在操作的哪个新寄存器(VMIDVMVER)。在之前的示例中,我们使用了一个 3 位字段来选择八个寄存器之一。要在两个新寄存器之间选择,只需要一个位。以下语句定义了modrm标记中的一个 1 位字段,并将该字段与vmreg关联:

# Associate vmreg with a single bit in the modrm token.

vmreg = (3, 3)

以下语句将vmreg附加到包含两个寄存器的序号集合,其中 0 表示VMID,1 表示VMVER

attach variables [ vmreg ]   [ VMID  VMVER ];

指令定义可能会在指令中引用vmreg,当任何附加的寄存器在该指令内有效时,汇编语言程序员可能会将VMIDVMER作为操作数,出现在任何允许vmreg操作数的指令中。让我们比较以下两个VMXPLODE的定义。第一个来自我们之前的示例,其中我们从通用寄存器中选择了一个寄存器,第二个则选择了我们的两个寄存器之一,而不是任何通用寄存器:

:VMXPLODE Reg32,imm32 is vexMode=0 & byte=0x0f; byte=0x01; byte=0xc5;

          Reg32, imm32                     { Reg32=imm32; vmxplode(); }

:VMXPLODE vmreg,imm32 is vexMode=0 & byte=0x0f; byte=0x01; byte=0xc5;

          vmreg, imm32                     { vmreg=imm32; vmxplode(); }

在第二个列表中,Reg32被替换为vmreg。如果我们使用相同的输入文件并执行测试指令vmxplode 0x08,0xFEEDBB8,立即数操作数0xFEEDBB8将被加载到VMVER中,因为输入值0x08映射到序号值 1(因为位 3 被设置),正如我们在图 18-10 中展示的那样,VMVERvmreg中的寄存器 1。在加载测试文件后(保存ia.sinc并重新启动 Ghidra),我们可以看到列表窗口中的 p-code 显示立即数操作数已被加载到VMVER中:

00100726 0f 01 c5       VMXPLODE   VMVER,0xfeedbb8

         08 b8 db

         ee 0f

                                              VMVER = COPY 0xfeedbb8:4

                                              CALLOTHER "vmxplode"

相关的指令信息,如图 18-10 所示,也确认了这一变化。

image

图 18-10:选择新寄存器 VMVER 的 VMXPLODE 指令信息

总结

本章虽然只介绍了 x86 处理器文件内容的一小部分,但我们查看了处理器模块的主要组成部分,包括指令定义、寄存器定义、标记,以及如何使用 Ghidra 特定的语言 SLEIGH 来构建、修改和增强 Ghidra 处理器模块。如果你有意愿(或需要)为 Ghidra 添加一个新处理器,我们强烈建议你查看一些最近添加到 Ghidra 中的处理器。(SuperH4.sinc文件尤其有良好的文档说明,并且该处理器的复杂性远低于 x86 处理器。)

我们不能过多强调耐心和实验在任何处理器开发过程中的重要性。当你能够在每个收集到的新二进制文件中重用你的处理器模块,并可能将其贡献回 Ghidra 项目以造福其他逆向工程师时,那份辛勤的工作将获得丰厚的回报。

在下一章中,我们将深入探讨与 Ghidra 反编译器相关的功能。

第二十二章:GHIDRA 反编译器**

Image

到目前为止,我们已经将逆向工程分析集中在列表窗口,并通过反汇编列表的视角介绍了 Ghidra 的特性。在本章中,我们将焦点转向反编译窗口,研究如何使用反编译器及其相关功能完成熟悉的分析任务(以及一些新的任务)。我们将从反编译过程的简要概述开始,然后深入反编译窗口中可用的功能。接着我们将通过一些示例,帮助你发现如何利用反编译窗口改进你的逆向工程过程。

反编译分析

合理的假设是,反编译窗口中的内容来自于列表窗口,但令人惊讶的是,列表窗口和反编译窗口的内容是独立生成的,这就是为什么它们有时不一致的原因,因此,在试图确定真实情况时,必须在上下文中评估这两者。Ghidra 反编译器的主要功能是将机器语言指令转换为 p-code(见 第十八章),然后将 p-code 转换为 C 语言,并在反编译窗口中显示。

从简化的视角来看,反编译过程包括三个不同的阶段。在第一阶段,反编译器使用 SLEIGH 规范文件创建 p-code 草图,并推导出相关的基本块和控制流。第二阶段专注于简化:删除不需要的内容,如不可达代码,然后根据变化调整和优化控制流。在最后阶段,进行一些修整,进行最终检查,并通过格式化算法生成最终结果,最终呈现在反编译窗口中。当然,这大大简化了一个非常复杂的过程,但主要的要点如下:^(1)

  • 反编译器是一个分析器。

  • 它从二进制文件开始工作,并生成 p-code。

  • 它将 p-code 转换为 C 语言。

  • C 代码和相关消息显示在反编译窗口中。

我们将详细讨论这些步骤,并深入探讨 Ghidra 的反编译功能。让我们从分析过程开始,了解它释放的主要功能。

分析选项

在自动分析过程中,有几个分析器与反编译窗口相关。反编译分析选项通过“编辑 ▸ 工具选项”菜单管理,如 图 19-1 所示,默认选项已选择。

我们接下来将讨论其中两个选项:消除不可达代码和简化预测。对于其他选项,你可以实验它们的结果或参考 Ghidra 帮助文档。

image

图 19-1:Ghidra 反编译器分析选项,已选择默认值

消除不可达代码

“消除无法访问的代码”选项会将无法访问的代码排除在反编译器列表之外。例如,以下 C 函数有两个永远不可能满足的条件,这使得相应的条件块变得无法访问:

int demo_unreachable(volatile int a) {

    volatile int b = a ^ a;

 ➊ if (b) {

        printf("This is unreachable\n");

        a += 1;

    }

 ➋ if (a - a > 0) {

        printf("This should be unreachable too\n");

        a += 1;

    } else {

        printf("We should always see this\n");

        a += 2;

    }

    printf("End of demo_unreachable()\n");

    return a;

}

变量b以一种或许不太明显的方式初始化为零。当测试b时➊,它的值永远不会为非零,因而对应的if语句块将永远不会执行。同样,a - a永远不可能大于零,因此第二个if语句的条件➋也永远无法评估为真。当选择“消除无法访问的代码”选项时,反编译器窗口会显示警告信息,提醒我们它已经移除了无法访问的代码。

/* WARNING: Removing unreachable block (ram,0x00100777) */

/* WARNING: Removing unreachable block (ram,0x0010079a) */

ulong demo_unreachable(int param_1)

{

  puts("We should always see this");

  puts("End of demo_unreachable()");

  return (ulong)(param_1 + 2);

}
简化预测

该选项通过合并共享相同条件的if/else块来优化代码。在以下列表中,前两个if语句共享相同的条件:

int demo_simppred(int a) {

    if (a > 0) {

          printf("A is > 0\n");

    }

    if (a > 0) {

          printf("Yes, A is definitely > 0!\n");

    }

    if (a > 2) {

          printf("A > 2\n");

    }

    return a * 10;

}

启用简化预测后,生成的反编译器列表显示合并后的代码块:

ulong demo_simppred(int param_1)

{

  if (0 < param_1) {

    puts("A is > 0");

    puts("Yes, A is definitely > 0!");

  }

  if (2 < param_1) {

    puts("A > 2");

  }

  return (ulong)(uint)(param_1 * 10);

}

反编译器窗口

现在你已经了解了反编译器分析引擎如何填充反编译器窗口,接下来我们来看一下如何利用窗口来辅助分析。导航反编译器窗口相对简单,因为它一次只显示一个函数。为了在函数之间移动或查看函数的上下文,最好与列表窗口进行关联。由于反编译器窗口和列表窗口默认是链接的,你可以通过使用 CodeBrowser 工具栏中的可用选项同时在两个窗口间导航。

反编译器窗口中显示的函数有助于分析,但刚开始时可能不太容易阅读。由于缺乏关于函数反编译的数据类型的信息,Ghidra 需要自行推断这些数据类型。因此,反编译器可能会过度使用类型转换,正如你在以下示例语句中看到的那样:

printf("a=%d, b=%d, c=%d, d=%d, e=%d, f=%d, g=%d\n", (ulong)param_1,

      (ulong)param_2,(ulong)uVar1,(ulong)uVar2,(ulong)(uVar1 + param_1),

      (ulong)(uVar2 * 100),(ulong)uVar4);

uStack44 = *(undefined4 *)**(undefined4 **)(iStack24 + 0x10);

当你通过反编译器编辑选项提供更精确的类型信息时,你会注意到反编译器越来越少依赖类型转换,生成的 C 代码也变得更易读。在接下来的示例中,我们将讨论一些反编译器窗口最有用的功能,用于清理生成的源代码。最终目标是生成更易于理解的可读源代码,从而减少理解代码行为所需的时间。

示例 1:在反编译器窗口中编辑

考虑一个程序,该程序从用户那里接受两个整数值,然后调用以下函数:

int do_math(int a, int b) {

    int c, d, e, f, g;

    srand(time(0));

    c = rand();

    printf("c=%d\n", c);

    d = a + b + c;

    printf("d=%d\n", d);

    e = a + c;

    printf("e=%d\n", e);

    f = d * 100;

    printf("f=%d\n", f);

    g = rand() - e;

    printf("g=%d\n", g);

    printf("a=%d, b=%d, c=%d, d=%d, e=%d, f=%d, g=%d\n", a, b, c, d, e, f, g);

    return g;

}

该函数使用两个整数参数和五个局部变量来生成其输出。它们之间的相互依赖关系可以总结如下:

  • 变量c依赖于rand()的返回值,直接影响de,并间接影响fg

  • 变量d依赖于abc,并直接影响f

  • 变量e依赖于ac,并直接影响g

  • 变量f直接依赖于d,间接依赖于abc,但不影响任何内容。

  • 变量g直接依赖于e,间接依赖于ac,但不影响任何内容。

当关联的二进制文件加载到 Ghidra 中并对函数进行分析时,你会在反编译器窗口中看到do_math函数的以下表示:

ulong do_math(uint param_1,uint param_2)

{

    uint uVar1;

    uint uVar2;

    int iVar3;

    uint uVar4;

    time_t tVar5;

    tVar5 = time((time_t *)0x0);

    srand((uint)tVar5);

    uVar1 = rand();

    printf("c=%d\n");

    uVar2 = uVar1 + param_1 + param_2;

  ➊ printf("d=%d\n");

    printf("e=%d\n");

    printf("f=%d\n");

    iVar3 = rand();

    uVar4 = iVar3 - (uVar1 + param_1);

    printf("g=%d\n");

    printf("a=%d, b=%d, c=%d, d=%d, e=%d, f=%d, g=%d\n", (ulong)param_1,

          (ulong)param_2,(ulong)uVar1,(ulong)uVar2,(ulong)(uVar1 + param_1),

          (ulong)(uVar2 * 100),(ulong)uVar4);

    return (ulong)uVar4;

  }

如果你想使用反编译器进行分析,你需要确保反编译器生成的代码尽可能准确。通常,这通过提供尽可能多的关于数据类型和函数原型的信息来完成。接受可变数量参数的函数,如printf,尤其对反编译器来说是棘手的,因为反编译器需要完全理解所需参数的语义,才能估算提供的可选参数的数量。

覆盖函数签名

你可以看到一些printf语句 ➊ 看起来不太对。每个语句都有一个格式字符串,但没有额外的参数。由于printf接受可变数量的参数,你可以在每个调用位置覆盖函数签名,并(根据格式字符串)指示该printf语句应接受一个整数参数。^(2) 要进行此更改,请右键单击printf语句,并从上下文菜单中选择覆盖签名,以打开图 19-2 所示的对话框。

image

图 19-2:覆盖签名对话框

将第二个参数类型int添加到每个printf语句的签名中(如图所示),结果如下所示:

 ulong do_math(uint param_1,uint param_2)

 {

➊ uint uVar1;

   uint uVar2;

   uint uVar3;

   int iVar4;

   uint uVar5;

   time_t tVar6;

   tVar6 = time((time_t *)0x0);

   srand((uint)tVar6);

   uVar1 = rand();

   printf("c=%d\n",uVar1);

   uVar2 = uVar1 + param_1 + param_2;

   printf("d=%d\n",uVar2);

➋ uVar3 = uVar1 + param_1;

   printf("e=%d\n",uVar3);

   printf("f=%d\n",uVar2 * 100);

   iVar4 = rand();

➌ uVar5 = iVar4 - uVar3;

   printf("g=%d\n",uVar5);

➍ printf("a=%d, b=%d, c=%d, d=%d, e=%d, f=%d, g=%d\n", (ulong)param_1,

 (ulong)param_2,(ulong)uVar1,(ulong)uVar2,(ulong)(uVar1 + param_1),

         (ulong)(uVar2 * 100),(ulong)uVar4);

   return (ulong)uVar4;

 }

除了更新的具有正确参数的printf调用外,由于覆盖了printf函数 ➋ ➌,反编译器列表中还添加了两行新代码。这些语句之前没有包括进来,因为 Ghidra 认为这些结果未被使用。一旦反编译器明白这些结果在每个printf中都有使用,这些语句就变得有意义,并会显示在反编译器窗口中。

编辑变量类型和名称

在修正完函数调用之后,你可以继续通过重命名(快捷键 L)和重新输入(快捷键 CTRL-L)参数以及变量 ➊,根据在printf格式字符串中找到的名称来清理列表。顺便提一下,格式字符串是任何程序中关于变量类型和用途的极其宝贵的信息来源。

在完成这些更改后,最终的printf语句 ➍ 仍然有点繁琐:

printf("a=%d, b=%d, c=%d, d=%d, e=%d, f=%d, g=%d\n", (ulong)a,

      (ulong)(uint)b, (ulong)(uint)c, (ulong)(uint)d, (ulong)(uint)e,

      (ulong)(uint)(d * 100),(ulong)(uint)g);

右键单击此语句允许你覆盖函数签名。此printf语句中的第一个参数是格式字符串,它无需修改。将其余参数更改为int类型后,结果会得到如下更简洁的代码(Listing 19-1)显示在反编译器窗口中。

int do_math(int a, int b)

{

  int c;

  int d;

  int e;

  int g;

  time_t tVar1;

  tVar1 = time((time_t *)0x0);

  srand((uint)tVar1);

  c = rand();

  printf("c=%d\n",c);

  d = c + a + b;

  printf("d=%d\n",d);

  e = c + a;

  printf("e=%d\n",e);

  printf("f=%d\n",d * 100);

  g = rand();

  g = g - e;

  printf("g=%d\n",g);

 printf("a=%d, b=%d, c=%d, d=%d, e=%d, f=%d, g=%d\n",a,b,c,d,e,d * 100➊,g);

  return g;

}

列表 19-1:带有更新签名的反编译函数

这与我们原始的源代码非常相似,并且比原始的反编译器列表示更加易读,因为函数参数的修改已经传播到整个列表中。反编译器列表示和我们原始源代码之间的一个区别是,变量f已经被等效的表达式➊所替代。

高亮切片

现在你有了更易理解的反编译器窗口,你可以开始进一步的分析。假设你想知道某个变量如何影响其他变量,或者如何被其他变量影响。一个程序切片是指一组影响某个变量值(回溯切片)或被某个变量值影响(前向切片)的语句。在漏洞分析场景中,这可能表现为“我控制了这个变量,它的值在哪些地方被使用?”

Ghidra 在其右键菜单中提供了五个选项,用于高亮函数中变量与指令之间的关系。如果你在反编译器窗口中右键点击一个变量,你可以从以下选项中进行选择:

高亮 Def-use 该选项会高亮函数中变量的所有使用位置。(你也可以通过中键单击来实现相同的效果。)

高亮前向切片 该选项会高亮所有受选定变量值影响的内容。例如,如果你在列表 19-1 中选择变量b并选择此选项,则所有出现bd的位置都会被高亮,因为b的值变化可能也会导致d值的变化。

高亮回溯切片 这是前一个选项的反向操作,会高亮所有对某个特定值有贡献的变量。如果你右键点击列表 19-1 中最后一个printf语句中的变量e并选择此选项,所有影响e值的变量(在这种情况下是eac)都会被高亮。修改ac也可能改变e的值。

高亮前向语句切片 该选项会高亮与“高亮前向切片”选项相关的整个语句。在列表 19-1 中,如果你在选中b变量时使用此选项,所有涉及bd的语句都会被高亮。

高亮回溯语句切片 该选项会高亮与“高亮回溯切片”选项相关的整个语句。在列表 19-1 中,选择此选项并高亮e变量时,所有涉及ace的语句都会被高亮。

现在我们对如何操作反编译器窗口以及如何在分析中使用它有了大致了解,接下来我们看一个更具体的示例。

示例 2:无返回的函数

通常,Ghidra 可以安全地假设函数调用会返回,因此会将函数调用视为在基本块内呈现顺序流。然而,一些函数,如源代码中标记为 noreturn 关键字的函数,或在恶意软件中以混淆的跳转指令结束的函数,是不返回的,这可能导致 Ghidra 生成不准确的反汇编或反编译代码。Ghidra 提供了三种处理非返回函数的方法:两种非返回函数分析器和手动编辑函数签名的功能。

Ghidra 可以根据已知的 noreturn 函数列表(如 exitabort)来识别非返回函数,使用的是“非返回函数-已知分析器”。此分析器在自动分析时默认选中,其工作原理非常简单:如果函数名出现在该列表中,它会将该函数标记为非返回函数,并尽最大努力修复任何相关问题(例如,将相关调用设置为非返回函数、查找可能需要修复的流程等)。

“非返回函数-已发现”分析器会寻找可能表明函数不返回的线索(例如,调用后面的数据或错误指令)。它如何处理这些信息,主要由与分析器相关的三个选项控制,如图 19-3 所示。

image

图 19-3:非返回函数-已发现分析选项

第一个选项 ➊ 允许自动创建分析书签(这些书签会出现在列表窗口的书签栏上)。第二个选项 ➋ 允许你指定一个阈值,该阈值通过一系列检查,判断是否将某个函数标记为非返回函数。最后,还有一个复选框 ➌ 用于修复相关的流程损坏。

当 Ghidra 无法识别非返回函数时,你可以选择自己编辑函数签名。如果你完成分析并有错误书签,这些书签用于标记错误指令,这通常是 Ghidra 自身分析出错的一个良好指示。如果错误指令紧随 CALL,如

00100839                 CALL          noReturnA

0010083e                 ??            FFh

然后,你可能会在反编译器窗口看到一个相关的后置注释,警告你关于该情况,如下所示:

  noReturnA(1);

  /* WARNING: Bad instruction - Truncating control flow here */

  halt_baddata();

如果你在反编译器窗口点击函数名(此例中为 noReturnA),然后选择“编辑函数签名”,你将有机会修改与该函数相关的属性,如图 19-4 所示。

image

图 19-4:编辑函数属性

勾选“无返回”框,将该函数标记为非返回函数。Ghidra 然后会在反编译器窗口中插入一个前置注释,并在列表窗口中插入一个后置注释,如下所示:

  /* WARNING: Subroutine does not return */

  noReturnA(1);

纠正该错误后,你可以继续处理其他问题。

示例 3:自动化结构创建

在分析反编译后的 C 源代码时,你可能会遇到看起来包含结构体字段引用的语句。Ghidra 可以帮助你创建一个结构体,并根据反编译器检测到的相关引用填充它。让我们从源代码和 Ghidra 的初步反编译开始,逐步了解这个过程。

假设你有源代码,定义了两个结构体类型,并为每个类型创建了一个全局实例:

➊ struct s1 {

      int a;

      int b;

      int c;

   };

➋ typedef struct s2 {

       int x;

       char y;

       float z;

   } s2_type;

   struct s1 GLOBAL_S1;

   s2_type GLOBAL_S2;

一个结构体 ➊ 包含同质元素,另一个 ➋ 包含异质类型的集合。源代码还包含三个函数,其中一个函数(do_struct_demo)声明了每个结构体类型的本地实例:

void display_s1(struct s1* s) {

    printf("The fields in s1 = %d, %d, and %d\n", s->a, s->b, s->c);

}

void update_s2(s2_type* s, int v) {

    s->x = v;

    s->y = (char)('A' + v);

    s->z = v * 2.0;

}

void do_struct_demo() {

    s2_type local_s2;

    struct s1 local_s1;

    printf("Enter six ints: ");

    scanf("%d %d %d %d %d %d", (int *)&local_s1, &local_s1.b, &local_s1.c,

          &GLOBAL_S1.a, &GLOBAL_S1.b, &GLOBAL_S1.c);

    printf("You entered: %d and %d\n", local_s1.a, GLOBAL_S1.a);

    display_s1(&local_s1);

    display_s1(&GLOBAL_S1);

    update_s2(&local_s2, local_s1.a);

}

do_struct_demo的反编译版本出现在示例 19-2 中。

void do_struct_demo(void)

{

   undefined8 uVar1;

   uint local_20;

   undefined local_1c [4];

   undefined local_18 [4];

   undefined local_14 [12];

   uVar1 = 0x100735;

   printf("Enter six ints: ");

   __isoc99_scanf("%d %d %d %d %d %d", &local_20, local_1c, local_18,

                  GLOBAL_S1,0x30101c,0x301020,uVar1);

   printf("You entered: %d and %d\n",(ulong)local_20,(ulong)GLOBAL_S1._0_4_);

➊ display_s1(&local_20);

➋ display_s1(GLOBAL_S1);

   update_s2(local_14,(ulong)local_20,(ulong)local_20);

   return;

}

示例 19-2:do_struct_demo的初步反编译

双击反编译器窗口中的函数调用 ➊➋,进入display_s1函数,将显示如下内容:

void display_s1(uint *param_1)

{

  printf("The fields in s1 = %d, %d, and %d\n", (ulong)*param_1,

        (ulong)param_1[1],(ulong)param_1[2]);

  return;

}

因为你怀疑display_s1的参数可能是一个结构体指针,你可以要求 Ghidra 自动为你创建一个结构体。只需右键点击函数参数列表中的param_1,并从上下文菜单中选择“自动创建结构体”。作为回应,Ghidra 会跟踪param_1的所有使用,将对指针执行的所有算术运算视为对结构体成员的引用,并自动创建一个新结构体类型,包含每个引用偏移量的字段。这会在反编译器的列表中改变一些内容:

void display_s1(astruct *param_1)

{

  printf("The fields in s1 = %d, %d, and %d\n",(ulong)param_1->field_0x0,

        (ulong)param_1->field_0x4,(ulong)param_1->field_0x8);

  return;

}

参数的类型已经改变,现在是astruct*,并且调用printf时已经包含了字段引用。新类型也已经添加到数据类型管理器中,鼠标悬停在结构体名称上会显示字段定义,如图 19-5 所示。

image

图 19-5:数据类型管理器中的自动结构体

你可以通过右键点击上下文菜单中的“重新类型变量”选项,将local_20GLOBAL_S1的类型更新为astruct。结果如下所示:

 void do_struct_demo(void)

 {

   undefined8 uVar1;

➊ astruct local_20;

   undefined local_14 [12];

   uVar1 = 0x100735;

   printf("Enter six ints: ");

   __isoc99_scanf("%d %d %d %d %d %d", &local_20, &local_20.field_0x4➋,

             ➌ &local_20.field_0x8, &GLOBAL_S1, 0x30101c, 0x301020, uVar1);

   printf("You entered: %d and  %d\n", (ulong)local_20.field_0x0,

      ➍ (ulong)GLOBAL_S1.field_0x0);

   display_s1(&local_20);

   display_s1(&GLOBAL_S1);

   update_s2(local_14,(ulong)local_20.field_0x0,(ulong)local_20.field_0x0);

   return;

 }

将其与示例 19-2 进行比较,可以看到local_20的类型被修改 ➊,并且为local_20 ➋ ➌和GLOBAL_S1 ➍添加了字段引用。

让我们将焦点转向第三个函数update_s2的反编译,如示例 19-3 所示。

void update_s2(int *param_1,int param_2)

{

  *param_1 = param_2;

  *(char *)(param_1 + 1) = (char)param_2 + 'A';

  *(float *)(param_1 + 2) = (float)param_2 + (float)param_2;

  return;

}

示例 19-3:update_s2的初步反编译

你可以使用之前的方法,自动为param_1创建一个结构体。只需右键点击函数中的param_1,并从上下文菜单中选择自动创建结构体

void update_s2(astruct_1 *param_1,int param_2)

{

  param_1->field_0x0 = param_2;

  param_1->field_0x4 = (char)param_2 + 'A';

  param_1->field_0x8 = (float)param_2 + (float)param_2;

  return;

}

数据类型管理器现在与此文件关联了第二个结构体定义,如图 19-6 所示。

image

图 19-6:数据类型管理器窗口中的附加自动结构体

这个结构包含一个int、一个char、三个undefined字节(可能是编译器插入的填充字节)和一个float。要编辑该结构,右键点击astruct_1并从上下文菜单中选择“编辑”,这将打开结构编辑器窗口。如果我们选择将int字段命名为x,将char字段命名为y,将float字段命名为z,然后保存更改,那么新的字段名称将在反编译器列表中反映出来:

void update_s2(astruct_1 *param_1,int param_2)

{

  param_1->x = param_2;

  param_1->y = (char)param_2 + 'A';

  param_1->z = (float)param_2 + (float)param_2;

  return;

}

这个列表比列表 19-3 中的原始反编译结果更容易阅读和理解。

总结

反编译器窗口与列表窗口类似,都为你提供了二进制文件的视图,每种方式都有各自的优缺点。反编译器提供了一个更高层次的视图,可以帮助你比查看反汇编代码更快速地理解单个函数的结构和功能(特别是对于那些没有多年阅读反汇编列表经验的人)。列表窗口则提供了整个二进制文件的更低层次视图,包含所有可用的细节,但这可能使得很难从整体上获取洞察。

Ghidra 的反编译器可以与列表窗口以及我们在本书中介绍的所有其他工具有效配合,帮助你进行逆向工程过程。最终,决定解决当前问题的最佳方法是逆向工程师的职责。

本章重点介绍了反编译器窗口以及与反编译相关的问题。许多挑战可以追溯到各种各样的编译器及其相关的编译器选项,这些直接影响生成的二进制文件。在下一章中,我们将看一些特定编译器的行为和编译器构建选项,以更好地理解生成的二进制文件。

第二十三章:编译器差异

Image

到目前为止,如果我们做得对,你应该已经掌握了有效使用 Ghidra 的基本技能,更重要的是,学会如何让它为你所用。下一步是学习如何应对二进制文件(而不是 Ghidra)给你带来的挑战。根据你研究汇编语言的动机,你可能对自己正在查看的内容非常熟悉,或者你根本无法预知会面临什么。如果你花费所有时间分析在 Linux 平台上使用gcc编译的代码,你将非常熟悉它生成的代码风格,但你可能会对使用微软 C/C++编译器编译的调试版本程序感到困惑。如果你是恶意软件分析师,你可能会在同一个下午看到使用gcc、clang、微软 C++编译器、Delphi 等编写的代码。

和你一样,Ghidra 对某些编译器的输出比其他编译器更熟悉,而对某个编译器生成的代码的熟悉,并不能保证你能够识别使用完全不同编译器(甚至是同一编译器家族的不同版本)编译的高级构造。与其完全依赖 Ghidra 的分析能力来识别常用的代码和数据结构,你应该始终准备好运用自己的技能:你对特定汇编语言的熟悉程度、你对编译器的知识,以及你的研究能力,以正确解读反汇编代码。

在本章中,我们将介绍编译器差异如何在反汇编清单中体现出来。我们主要使用编译后的 C 代码作为示例,因为 C 编译器和目标平台的多样性提供了基础概念,这些概念可以扩展到其他编译语言。

高级构造

在某些情况下,编译器之间的差异可能只是表面上的,但在其他情况下,它们的差异可能更为显著。在本节中,我们将探讨高级语言构造,并演示不同的编译器和编译器选项如何显著影响生成的反汇编清单。我们从switch语句开始,讨论解决switch case 选择时最常用的两种机制。接着,我们将研究编译器选项如何影响常见表达式的代码生成,然后再讨论不同编译器如何实现 C++特定构造并处理程序启动。

switch 语句

C 语言的switch语句是编译器优化的常见目标。这些优化的目标是以最有效的方式将switch变量匹配到有效的 case 标签,但switch语句的 case 标签分布限制了可以使用的查找类型。

由于搜索的效率是通过所需的比较次数来衡量的,我们可以追踪编译器可能用来确定最优方式表示switch表的逻辑。常数时间算法,如表查找,是最有效的。^(1) 在最不理想的情况下是线性搜索,它在最坏情况下需要将switch变量与每个 case 标签进行比较,直到找到匹配项或回退到默认值,因此它是最不高效的。^(2) 二分查找的效率通常优于线性搜索,但也引入了额外的限制,因为它需要一个已排序的列表。^(3)

为了选择最有效的实现方式,对于特定的switch语句,了解 case 标签的分布如何影响编译器的决策过程是很有帮助的。当 case 标签紧密聚集时,如列表 20-1 中的源代码所示,编译器通常通过执行表查找来解决switch变量,将switch变量与其相关联的 case 地址匹配——具体而言,是通过使用跳转表。

switch (a) {

/** NOTE: case bodies omitted for brevity **/

    case 1:  /*...*/ break;

    case 2:  /*...*/ break;

    case 3:  /*...*/ break;

    case 4:  /*...*/ break;

    case 5:  /*...*/ break;

    case 6:  /*...*/ break;

    case 7:  /*...*/ break;

    case 8:  /*...*/ break;

    case 9:  /*...*/ break;

    case 10: /*...*/ break;

    case 11: /*...*/ break;

    case 12: /*...*/ break;

}

列表 20-1:一个switch语句,具有连续的 case 标签

跳转表是一个指针数组,数组中的每个指针都指向一个可能的跳转目标。在运行时,表中的动态索引每次被引用时都会选择多个潜在跳转中的一个。跳转表在switch case 标签紧密排列(即密集)时表现良好,大多数 case 标签都形成一个连续的数字序列。编译器在决定是否使用跳转表时会考虑这一点。对于任何switch语句,我们可以通过以下方式计算跳转表中最少的条目数:

num_entries = max_case_value – min_case_value + 1

跳转表的密度或利用率可以通过以下方式计算:

density = num_cases / num_entries

如果每个值都被表示为一个完全连续的列表,那么该列表的密度值为 100%(1.0)。最后,存储跳转表所需的总空间如下:

table_size = num_entries * sizeof(void*)

一个具有 100%密度的switch语句将通过跳转表来实现。一个密度为 30%的 case 集合可能无法通过跳转表实现,因为跳转表条目仍然需要为缺失的 case 分配空间,而这些缺失的 case 占了跳转表的 70%。如果num_entries为 30,则跳转表将包含 21 个未引用的 case 标签的条目。在 64 位系统上,这将占用跳转表分配的 240 字节中的 168 字节,虽然开销不大,但如果num_entries跳到 300,则开销变为 1680 字节,这对于 90 个可能的 case 来说可能不值得这种权衡。优化速度的编译器可能更倾向于使用跳转表实现,而优化尺寸的编译器则可能选择一个内存开销更小的替代实现:二分查找。

当 case 标签分布较广(低密度)时,二分查找非常高效,如在清单 20-2 中所示(密度为 0.0008)。^(4) 由于二分查找仅适用于排序过的列表,编译器必须确保在开始使用中位值进行查找之前,先对 case 标签进行排序。这可能导致在反汇编中查看到的 case 块的顺序与它们在源代码中的顺序不同。^(5)

switch (a) {

/** NOTE: case bodies omitted for brevity **/

    case 1:     /*...*/ break;

    case 211:   /*...*/ break;

    case 295:   /*...*/ break;

    case 462:   /*...*/ break;

    case 528:   /*...*/ break;

    case 719:   /*...*/ break;

    case 995:   /*...*/ break;

    case 1024:  /*...*/ break;

    case 8000:  /*...*/ break;

    case 13531: /*...*/ break;

    case 13532: /*...*/ break;

    case 15027: /*...*/ break;

}

清单 20-2:示例 switch 语句,包含非连续的 case 标签

清单 20-3 显示了一个通过固定数量常量值进行非迭代二分查找的概要框架。这是编译器用来实现来自清单 20-2 的switch语句的粗略框架。

if (value < median) {

    // value is in [0-50) percentile

    if (value < lower_half_median) {

        // value is in [0-25) percentile

        // ... continue successive halving until value is resolved

    } else {

        // value is in [25-50) percentile

        // ... continue successive halving until value is resolved

   }

} else {

    // value is in [50-100) percentile

    if (value < upper_half_median) {

        // value is in [50-75) percentile

 // ... continue successive halving until value is resolved

    } else {

        // value is in [75-100) percentile

        // ... continue successive halving until value is resolved

    }

}

清单 20-3:通过固定数量常量值进行非迭代二分查找

编译器还能够在一系列 case 标签之间执行更精细的优化。例如,当面对 case 标签时,

label_set = [1, 2, 3, 4, 5, 6, 7, 8, 50, 80, 200, 500, 1000, 5000, 10000]

一个不那么激进的编译器可能会看到 0.0015 的密度,并对所有 15 个 case 进行二分查找。而一个更激进的编译器可能会生成一个跳转表来解决 case 1 到 8,并对剩余的 case 进行二分查找,从而实现对超过一半 case 的最佳性能。

在我们查看清单 20-1 和清单 20-2 的反汇编版本之前,先来看看与这些清单对应的 Ghidra 函数图窗口,两个窗口并排显示在图 20-1 中。

image

图 20-1:Ghidra 函数图 switch 语句示例

在左侧,清单 20-1 的图表显示了一个整齐的垂直堆叠的 case。每个堆叠的代码块位于相同的嵌套深度,这正是switch语句中的 case 的特点。该堆叠表明我们可以使用索引快速从多个块中选择一个(类似数组访问)。这正是跳转表解析的工作原理,左侧的图表为我们提供了一个视觉提示,表明这一点,即使我们还没有查看反汇编的任何一行代码。

右侧的图形是 Ghidra 仅根据其对示例 20-2 反汇编结果的理解所得到的结果。由于缺少跳转表,这使得识别switch语句变得更加困难。你看到的是使用 Ghidra 的嵌套代码布局的switch语句的可视化表示。这是 Ghidra 中函数图形的默认布局,旨在表示程序中的流程结构。图中的水平分支表示条件执行(if/else)分支到互斥的替代路径。垂直对称性表明,替代执行路径被非常仔细地平衡,确保图形的每个垂直半部分中包含相等数量的块。最后,图形水平遍历的距离是搜索深度的指示器,这又由switch中存在的总案例标签数量决定。对于二分查找,这个深度通常是log[2]``(num_cases)的数量级。图形表示的缩进与示例 20-3 中概述的算法之间的相似性很容易观察到。

将注意力转向反编译窗口,图 20-2 显示了图 20-1 中展示的函数的部分反编译结果。左侧是示例 20-1 的反编译版本。与图形一样,二进制文件中存在跳转表有助于 Ghidra 将代码识别为switch语句。

右侧是示例 20-2 的反编译版本。反编译器将switch语句呈现为与二分查找一致的嵌套if/else结构,且结构与示例 20-3 类似。你可以看到,第一次比较是与 719 进行的,这是列表中的中位数,随后的比较则继续将搜索空间对半分。参考图 20-1(以及示例 20-3),我们可以再次观察到,每个函数的图形表示与反编译窗口中观察到的缩进模式紧密对应。

现在你对高层的情况有了了解,我们来看一下二进制文件,调查低层发生了什么。由于本章的目标是观察编译器之间的差异,我们将这个例子呈现为gcc与微软 C/C++编译器之间的比较系列。^(6)

image

图 20-2:Ghidra 反编译的 switch 语句示例

示例:比较 gcc 与微软 C/C++编译器

在这个例子中,我们比较了为清单 20-1 生成的两个 32 位 x86 二进制文件,这两个文件分别由两个不同的编译器生成。我们将尝试识别每个二进制文件中的switch语句组件,定位每个二进制文件中的相关跳转表,并指出这两个二进制文件之间的重要差异。让我们从查看使用gcc构建的二进制文件中的清单 20-1 的switch相关组件开始。

0001075a  CMP➊  dword ptr [EBP + value],12

0001075e  JA     switchD_00010771::caseD_0➋

00010764  MOV    EAX,dword ptr [EBP + a]

00010767  SHL    EAX,0x2

0001076a  ADD    EAX,switchD_00010771::switchdataD_00010ee0       = 00010805

0001076f  MOV    EAX,dword ptr [EAX]=>->switchD_00010771::caseD_0 = 00010805

        switchD_00010771::switchD

00010771  JMP    EAX

        switchD_00010771::caseD_1➌             XREF[2]:      00010771(j), 00010ee4(*)

 00010773  MOV    EDX,dword ptr [EBP + a]

00010776  MOV    EAX,dword ptr [EBP + b]

00010779  ADD    EAX,EDX

0001077b  MOV    dword ptr [EBP + result],EAX

0001077e  JMP    switchD_00010771::caseD_0

;--content omitted for remaining cases--

       switchD_00010771::switchdataD_00010ee0➋  XREF[2]: switch_version_1:0001076a(*),

                                                          switch_version_1:0001076f(R)

00010ee0  addr    switchD_00010771::caseD_0➎

00010ee4  addr    switchD_00010771::caseD_1

00010ee8  addr    switchD_00010771::caseD_2

00010eec  addr    switchD_00010771::caseD_3

00010ef0  addr    switchD_00010771::caseD_4

00010ef4  addr    switchD_00010771::caseD_5

00010ef8  addr    switchD_00010771::caseD_6

00010efc  addr    switchD_00010771::caseD_7

00010f00  addr    switchD_00010771::caseD_8

00010f04  addr    switchD_00010771::caseD_9

00010f08  addr    switchD_00010771::caseD_a

00010f0c  addr    switchD_00010771::caseD_b

00010f10  addr    switchD_00010771::caseD_c

Ghidra 识别switch边界测试 ➊,跳转表 ➍,以及通过值区分的单个case块,例如switchD_00010771::caseD_1 ➌。在编译时,生成了一个包含 13 个条目的跳转表,尽管清单 20-1 中只有 12 个case。额外的case,即case 0(跳转表中的第一个条目 ➎),与值范围 1 到 12 以外的所有值共享目标地址。换句话说,case 0是默认case的一部分。尽管看起来负数被排除在默认情况之外,但CMPJA指令序列实际上对无符号值进行比较;因此,-10xFFFFFFFF)会被视为4294967295,这个值远大于 12,因此被排除在有效范围之外,无法用来索引跳转表。JA指令将所有这些情况引导至默认位置:switchD_00010771::caseD_0 ➋。

现在我们理解了由gcc编译器生成的代码的基本组件,接下来我们将注意力转向微软 C/C++编译器在调试模式下生成的代码中的相同组件:

00411e88  MOV    ECX,dword ptr [EBP + local_d4]

00411e8e  SUB➊  ECX,0x1

00411e91  MOV    dword ptr [EBP + local_d4],ECX

00411e97  CMP➋  dword ptr [EBP + local_d4],11

00411e9e  JA     switchD_00411eaa::caseD_c

00411ea4  MOV    EDX,dword ptr [EBP + local_d4]

        switchD_00411eaa::switchD

00411eaa  JMP    dword ptr [EDX*0x4 + ->switchD_00411eaa::caseD      = 00411eb1

        switchD_00411eaa::caseD_1                XREF[2]: 00411eaa(j), 00411f4c(*)

00411eb1  MOV    EAX,dword ptr [EBP + param_1]

00411eb4  ADD    EAX,dword ptr [EBP + param_2]

 00411eb7  MOV    dword ptr [EBP + local_c],EAX

00411eba  JMP    switchD_00411eaa::caseD_c

;--content omitted for remaining cases--

        switchD_00411eaa::switchdataD_00411f4c   XREF[1]: switch_version_1:00411eaa(R)

00411f4c  addr   switchD_00411eaa::caseD_1➌

00411f50  addr   switchD_00411eaa::caseD_2

00411f54  addr   switchD_00411eaa::caseD_3

00411f58  addr   switchD_00411eaa::caseD_4

00411f5c  addr   switchD_00411eaa::caseD_5

00411f60  addr   switchD_00411eaa::caseD_6

00411f64  addr   switchD_00411eaa::caseD_7

00411f68  addr   switchD_00411eaa::caseD_8

00411f6c  addr   switchD_00411eaa::caseD_9

00411f70  addr   switchD_00411eaa::caseD_a

00411f74  addr   switchD_00411eaa::caseD_b

00411f78  addr   switchD_00411eaa::caseD_c

在这里,switch变量(在此案例中为local_d4)被递减 ➊,以将有效值的范围从 0 变为 11 ➋,从而不再需要为值 0 创建一个虚拟的表项。因此,跳转表中的第一个条目(即索引为 0 的条目)实际上指向的是switch语句的第 1 个分支。

另一个可能更微妙的区别是跳转表在文件中的位置。gcc编译器将switch跳转表放置在二进制文件的只读数据(.rodata)部分,从而在与switch语句相关的代码和实现跳转表所需的数据之间提供了逻辑上的分离。另一方面,微软 C/C++编译器将跳转表插入到.text部分,紧接着包含相关switch语句的函数之后。这种跳转表的定位对程序的行为几乎没有影响。在这个例子中,Ghidra 能够识别两种编译器生成的switch语句,并在相关标签中使用switch一词。

这里的一个关键点是,没有单一的正确方式将源代码编译成汇编代码。因此,你不能仅仅因为 Ghidra 没有将某个部分标记为switch语句,就假设它不是switch语句。理解switch语句的特点,这些特点会影响编译器的实现,能够帮助你更准确地推断出原始源代码。

编译器构建选项

编译器将解决特定问题的高级代码转换为解决相同问题的低级代码。多个编译器可能采用截然不同的方式来解决同一问题。此外,同一个编译器可能会根据相关的编译器选项以非常不同的方式来解决一个问题。在本节中,我们将查看使用不同编译器和不同命令行选项时生成的汇编语言代码。(一些差异有明确的解释;另一些则没有。)

Microsoft 的 Visual Studio 可以构建程序二进制文件的调试版本或发布版本。^(7) 要了解这两种版本的区别,可以对比它们各自的构建选项。发布版本通常会经过优化,而调试版本则不会,调试版本会链接附加的符号信息和调试版本的运行时库,而发布版本则不会。^(8) 与调试相关的符号使调试器能够将汇编语言语句映射回源代码,并确定局部变量的名称(否则此类信息在编译过程中会丢失)。Microsoft 运行时库的调试版本还包括了调试符号、禁用了优化,并启用了额外的安全检查,以验证某些函数参数的有效性。

使用 Ghidra 反汇编时,Visual Studio 项目的调试版本与发布版本看起来有显著不同。这是由于仅在调试版本中指定的编译器和链接器选项所导致的,例如基本的运行时检查(/RTCx),这些选项会在生成的二进制文件中引入额外的代码。^(9) 让我们直接进入,看看这些反汇编中的一些差异。

示例 1:取模运算符

我们以一个简单的数学运算——取模作为示例。以下列出了一个程序的源代码,该程序的唯一目标是接受用户输入的整数值,并演示整数除法和取模运算符:

int main(int argc, char **argv) {

    int x;

    printf("Enter an integer: ");

    scanf("%d", &x);

    printf("%d %% 10 = %d\n", x, x % 10);

}

让我们研究一下不同编译器在处理此示例中的取模运算符时,反汇编是如何变化的。

使用 Microsoft C/C++ Win x64 调试版本的取模

以下列表显示了当配置为构建调试版本的二进制文件时,Visual Studio 生成的代码:

1400119c6 MOV    EAX,dword ptr [RBP + local_f4]

1400119c9 CDQ

1400119ca MOV    ECX,0xa

1400119cf IDIV➊ ECX

1400119d1 MOV    EAX,EDX

1400119d3 MOV➋  R8D,EAX

1400119d6 MOV    EDX,dword ptr [RBP + local_f4]

1400119d9 LEA    RCX,[s_%d_%%_10_=_%d_140019d60]

1400119e0 CALL   printf

一个直接的 x86 IDIV 指令 ➊ 将商保存在 EAX 寄存器中,余数保存在 EDX 寄存器中。结果随后被移动到 R8 的低 32 位(R8D) ➋,这是调用 printf 函数时的第三个参数。

使用 Microsoft C/C++ Win x64 发布版本的取模

发布版本通过优化软件的速度和大小来提高性能并最小化存储需求。在优化速度时,编译器作者可能会采用一些不明显的常见操作实现方式。以下列表展示了 Visual Studio 如何在发布二进制文件中生成相同的取模操作:

140001136 MOV    ECX,dword ptr [RSP + local_18]

14000113a MOV    EAX,0x66666667

14000113f IMUL➊ ECX

140001141 MOV    R8D,ECX

140001144 SAR    EDX,0x2

140001147 MOV    EAX,EDX

140001149 SHR    EAX,0x1f

14000114c ADD    EDX,EAX

14000114e LEA    EAX,[RDX + RDX*0x4]

140001151 MOV    EDX,ECX

140001153 ADD    EAX,EAX

140001155 LEA    RCX,[s_%d_%%_10_=_%d_140002238]

14000115c SUB➋  R8D,EAX

14000115f CALL➌ printf

在这种情况下,使用的是乘法➊而不是除法,经过一长串算术操作,最终模运算的结果被存储在R8D中➋(同样是printf调用中的第三个参数➌)。直观吧?接下来我们将通过下一个示例来解释这段代码。

Linux x64 的 gcc 模运算

我们已经看到,通过仅仅改变编译时选项,编译器的行为可以有很大的不同。我们可能会期望一个完全不同的编译器会生成完全不同的代码。以下的反汇编展示了相同模运算的gcc版本,结果看起来有些熟悉:

00100708  MOV    ECX,dword ptr [RBP + x]

0010070b  MOV    EDX,0x66666667

00100710  MOV    EAX,ECX

00100712  IMUL➊ EDX

00100714  SAR    EDX,0x2

00100717  MOV    EAX,ECX

00100719  SAR    EAX,0x1f

0010071c  SUB    EDX,EAX

0010071e  MOV    EAX,EDX

00100720  SHL    EAX,0x2

00100723  ADD    EAX,EDX

00100725  ADD    EAX,EAX

00100727  SUB    ECX,EAX

00100729  MOV➋  EDX,ECX

这段代码与 Visual Studio 发布版生成的汇编非常相似。我们再次看到的是乘法➊而不是除法,后面跟着一系列算术操作,最终结果存储在EDX中➋(最终作为printf的第三个参数)。

该代码使用乘法逆运算来执行除法,因为硬件乘法比硬件除法更快。你也可能看到乘法是通过一系列加法和算术移位来实现的,因为这些操作在硬件中比乘法显著更快。

你能将这段代码识别为模 10 运算,取决于你的经验、耐心和创造力。如果你以前见过类似的代码序列,你可能会更容易识别出这里发生的事情。如果缺乏这样的经验,你可能会手动通过样本值来推算代码,希望能从结果中识别出某种模式。你甚至可能花时间提取汇编语言,将其封装在 C 测试框架中,进行高速数据生成来辅助你。Ghidra 的反编译器也是一个很有用的资源,可以将复杂或不寻常的代码序列简化为更易识别的 C 语言等效代码。

作为最后的手段,或者是第一选择(别害羞),你可能会转向互联网寻找答案。但你应该搜索什么呢?通常,独特且具体的搜索能够带来最相关的结果,而在这段代码序列中,最独特的特征就是整数常量0x66666667。当我们搜索这个常量时,排名前三的结果都很有帮助,但其中一个特别值得收藏:flaviojslab.blogspot.com/2008/02/integer-division.html*。独特常量在加密算法中也经常使用,快速的互联网搜索可能正是识别你正在查看的加密程序的关键。

示例 2:三元运算符

三元运算符会计算一个表达式,然后根据该表达式的布尔值,返回两种可能结果之一。从概念上讲,三元运算符可以被看作是一个 if/else 语句(甚至可以用 if/else 语句替代)。下面的未经优化的源代码演示了如何使用此运算符:

int main() {

    volatile int x = 3;

    volatile int y = x * 13;

  ➊ volatile int z = y == 30 ? 0 : -1;

}

注意

volatile 关键字要求编译器不要优化涉及相关变量的代码。如果这里没有使用它,一些编译器可能会优化掉整个函数体,因为其中的语句不会影响函数的结果。这是你在为自己或他人编写示例代码时可能遇到的挑战之一。

至于未经优化的代码,赋值给变量 z ➊ 可以用以下 if/else 语句替代,而不改变程序的语义:

    if (y == 30) {

        z = 0;

    } else {

        z = -1;

    }

让我们看看三元运算符代码是如何被不同编译器和不同编译选项处理的。

带有 gcc 的三元运算符(Linux x64)

gcc 在没有选项的情况下,为 z 的初始化生成了以下汇编代码:

00100616  MOV    EAX,dword ptr [RBP + y]

00100619  CMP➊  EAX,0x1e

0010061c  JNZ    LAB_00100625

0010061e  MOV    EAX,0x0

00100623  JMP    LAB_0010062a

        LAB_00100625

00100625  MOV    EAX,0xffffffff

        LAB_0010062a

0010062a  MOV➋  dword ptr [RBP + z],EAX

这段代码使用了 if/else 实现。局部变量 y30 ➊ 进行比较,以决定是将 EAX 设置为 0 还是 0xffffffff,然后在 if/else 的不同分支中将结果赋值给 z ➋。

带有 Microsoft C/C++ Win x64 发布版的三元运算符

Visual Studio 对包含三元运算符的语句进行了非常不同的实现。在这里,编译器识别到可以使用单条指令有条件地生成 0-1(且没有其他可能的值),并用这条指令替代我们之前看到的 if/else 结构:

140001013 MOV    EAX,dword ptr [RSP + local_res8]

140001017 SUB➊  EAX,0x1e

14000101a NEG➋  EAX

14000101c SBB➌  EAX,EAX

14000101e MOV    dword ptr [RSP + local_res8],EAX

SBB 指令 ➌ (subtract with borrow,带借位相减) 从第一个操作数中减去第二个操作数,然后减去进位标志 CF(它只能是 0 或 1)。SBB EAX,EAX 的等效算术表达式是 EAX – EAX – CF,简化为 0 – CF。这反过来只能得到 0(当 CF == 0 时)或 -1(当 CF == 1 时)。为了使这个技巧有效,编译器必须在执行 SBB 指令之前正确设置进位标志。这是通过将 EAX 与常量 0x1e(即 30) ➊ 进行比较实现的,比较操作会使得只有当 EAX 初始值为 0x1e 时,EAX 的值才为 0。接下来的 NEG 指令 ➋ 会为后续的 SBB 指令设置进位标志。^(10)

带有优化的 gcc 三元运算符(Linux x64)

当我们请求 gcc 通过优化其代码(-O2)来稍微加把劲时,结果与前面示例中的 Visual Studio 代码类似:

00100506  MOV    EAX,dword ptr [RSP + y]

0010050a  CMP    EAX,0x1e

0010050d  SETNZ➊AL

00100510  MOVZX  EAX,AL

00100513  NEG➋  EAX

00100515  MOV➌  dword ptr [RSP + z],EAX

在这种情况下,gcc 使用 SETNZ ➊ 根据前面的比较结果(零标志的状态)有条件地将 AL 寄存器设置为 0 或 1。然后,该结果被取反 ➋,变为 0-1,并赋值给变量 z ➌。

示例 3:函数内联

当程序员标记一个函数为inline时,他们是在向编译器建议,任何对该函数的调用应该被整个函数体的副本所替代。其目的是通过消除参数和堆栈帧的设置与拆卸来加速函数调用。其权衡之处在于,多个副本会使二进制文件变大。内联函数在二进制文件中非常难以识别,因为独特的call指令被去除了。

即使没有使用inline关键字,编译器也可能主动决定将一个函数内联。在我们的第三个示例中,我们调用了以下函数:

int maybe_inline() {

    return 0x12abcdef;

}

int main() {

    int v = maybe_inline();

    printf("after maybe_inline: v = %08x\n", v);return 0;

}
在 Linux x86 上使用 gcc 的函数调用

在使用gcc构建没有优化的 Linux x86 二进制文件后,我们对其进行反汇编,得到了如下的清单:

00010775  PUSH   EBP

00010776  MOV    EBP,ESP

00010778  PUSH   ECX

00010779  SUB    ESP,0x14

0001077c  CALL➊ maybe_inline

00010781  MOV    dword ptr [EBP + local_14],EAX

00010784  SUB    ESP,0x8

00010787  PUSH   dword ptr [EBP + local_14]

0001078a  PUSH   s_after_maybe_inline:_v_=_%08x_000108e2

0001078f  CALL   printf

我们可以清楚地看到在这个反汇编代码中对maybe_inline函数的调用 ➊,尽管它仅是一个返回常量值的单行代码。

在 Linux x86 上使用 gcc 优化的函数调用

接下来,我们来看一下相同源代码的优化(-O2)版本:

0001058a  PUSH   EBP

0001058b  MOV    EBP,ESP

0001058d  PUSH   ECX

0001058e  SUB    ESP,0x8

00010591  PUSH➊ 0x12abcdef

00010596  PUSH   s_after_maybe_inline:_v_=_%08x_000108c2

0001059b  PUSH   0x1

0001059d  CALL   __printf_chk

将这段代码与未优化的代码进行对比时,我们看到对maybe_inline的调用已经被移除,并且maybe_inline返回的常量值 ➊ 被直接压入栈中,作为调用printf时的参数使用。这个优化后的函数调用与如果该函数被指定为内联时所看到的完全一致。

在审视了优化如何影响编译器生成的代码之后,我们接下来将关注编译器设计者在语言设计者将实现细节留给编译器编写者时,选择实现语言特定功能的不同方式。

特定编译器的 C++实现

编程语言是程序员为程序员设计的。一旦设计过程尘埃落定,编译器编写者就需要构建工具,将用新高级语言编写的程序忠实地转换为语义上等效的机器语言程序。当一种语言允许程序员执行 A、B 和 C 时,编译器编写者的任务是找到一种方法使这些操作成为可能。

C++给了我们三个很好的例子,展示了语言所要求的行为,但其实现细节留给了编译器编写者来解决:

  • 在类的非静态成员函数中,程序员可以引用一个名为this的变量,而这个变量从未在任何地方显式声明过。(关于编译器如何处理this,请参见第六章和第八章)

  • 函数重载是允许的。程序员可以根据需要自由地重用函数名,但必须遵循其参数列表的限制。

  • 通过使用dynamic_casttypeid运算符,支持类型反射。

函数重载

C++中的函数重载允许程序员为函数命名相同的名称,但有一个前提条件,任何两个共享名称的函数必须具有不同的参数序列。第八章中介绍的名称混淆是一个幕后机制,它通过确保在链接器进行工作时,没有两个符号共享相同的名称,从而使得重载得以实现。

通常,当你发现正在处理 C++二进制文件时,最早的迹象之一就是出现了混淆的名称。最流行的两种名称混淆方案是微软方案和 Intel Itanium ABI 方案。^(11) Intel 标准已被其他 Unix 编译器,如g++和 clang,广泛采用。以下展示了一个 C++函数名以及该名称在微软和 Intel 方案下的混淆版本:

函数 void SubClass::vfunc1()

微软方案 ?vfunc1@SubClass@@UAEXXZ

Intel 方案 _ZN8SubClass6vfunc1Ev

允许重载的大多数语言,包括 Objective-C、Swift 和 Rust,在实现层面都包含某种形式的名称混淆。对名称混淆样式的简单了解可以为你提供有关程序原始源语言以及用于构建程序的编译器的线索。

RTTI 实现

在第八章中,我们讨论了 C++运行时类型识别(RTTI)以及编译器实施 RTTI 的标准缺失。实际上,C++标准中没有提到运行时类型识别,因此,实施方式的差异并不令人惊讶。为了支持dynamic_cast操作符,RTTI 数据结构不仅记录类的名称,还记录其完整的继承层次结构,包括任何多重继承关系。定位 RTTI 数据结构在恢复程序的对象模型时非常有用。在二进制文件中自动识别 RTTI 相关构造是 Ghidra 在不同编译器间能力差异的另一个领域。

微软的 C++程序不包含嵌入的符号信息,但微软的 RTTI 数据结构已经被充分理解,Ghidra 会在存在时定位它们。Ghidra 定位到的任何 RTTI 相关信息都会在符号树的Classes文件夹中汇总,该文件夹将包含每个类的条目,这些类是通过 Ghidra 的 RTTI 分析器定位的。

使用g++构建的程序包含符号表信息,除非它们已经被剥离。对于未剥离的g++二进制文件,Ghidra 完全依赖于它在二进制文件中找到的被混淆的名称,并使用这些名称来识别与 RTTI 相关的数据结构及其关联的类。与微软的二进制文件一样,任何与 RTTI 相关的信息都会被包含在符号树的Classes文件夹中。

理解编译器如何为 C++ 类嵌入类型信息的一种策略是编写一个简单的程序,使用包含虚函数的类。编译程序后,可以将生成的可执行文件加载到 Ghidra 中,并搜索包含程序中使用的类名的字符串实例。无论使用什么编译器构建二进制文件,RTTI 数据结构的共同点是它们都以某种方式引用一个包含它们所表示的类的 mangled 名称的字符串。通过提取的字符串和数据交叉引用,应该可以在二进制文件中定位候选的 RTTI 相关数据结构。最后一步是将候选 RTTI 结构链接回相关类的 vftable,最佳方法是通过从候选 RTTI 结构向后跟踪数据交叉引用,直到找到函数指针表(vftable)。让我们通过一个示例来演示这种方法。

示例:定位 Linux x86-64 g++ 二进制文件中的 RTTI 信息

为了演示这些概念,我们创建了一个包含 BaseClassSubClassSubSubClass 以及每个类独有的虚函数的小程序。以下清单展示了我们用于引用类和函数的部分主程序:

    BaseClass *bc_ptr_2;

    srand(time(0));

    if (rand() % 2) {

        bc_ptr_2 = dynamic_cast<SubClass*>(new SubClass());

    }

    else {

        bc_ptr_2 = dynamic_cast<SubClass*>(new SubSubClass());

    }

我们使用 g++ 编译程序,构建了一个包含符号的 64 位 Linux 二进制文件。分析程序后,符号树提供了如 图 20-3 所示的信息。

image

图 20-3:未剥离二进制文件的符号树类

Classes 文件夹包含了我们所有三个类的条目。展开的 SubClass 条目揭示了 Ghidra 发现的更多信息。剥离后的版本同一二进制文件包含的信息少得多,如 图 20-4 所示。

image

图 20-4:剥离二进制文件的符号树类

在这种情况下,我们可能会错误地认为二进制文件中没有值得关注的 C++ 类,尽管基于对一个核心 C++ 类(basic_ostream)的引用,它很可能是一个 C++ 二进制文件。由于剥离操作仅去除符号信息,我们仍然可以通过在程序的字符串中搜索类名,逆向跟踪到任何 RTTI 数据结构来查找 RTTI 信息。字符串搜索的结果如 图 20-5 所示。

image

图 20-5:字符串搜索结果显示类名

如果我们点击 "8SubClass" 字符串,就会跳转到列出窗口中的这一部分:

        s_8SubClass_00101818               XREF[1]:   00301d20(*)

00101818  ds "8SubClass"

g++ 二进制文件中,RTTI 相关的结构包含对相应类名字符串的引用。如果我们沿着第一行的交叉引用追溯到其来源,就可以看到以下反汇编清单部分:

           PTR___gxx_personality_v0_00301d18  XREF[2]: FUN_00101241:00101316(*)➊,

                                                       00301d10(*)➋

➌ 00301d18  addr   __gxx_personality_v0      = ??

➍ 00301d20  addr   s_8SubClass_00101818      = "8SubClass"

   00301d28  addr   PTR_time_00301d30         = 00303028

交叉引用的源代码 ➍ 是SubClasstypeinfo结构中的第二个字段,它从地址00301d18 ➌开始。不幸的是,除非你愿意深入研究g++的源代码,否则像这样的结构布局只是需要通过经验来学习。我们最后的任务是定位SubClass的 vftable。在这个例子中,如果我们追踪到来源于数据区域 ➋ 的孤立交叉引用到typeinfo结构(另一个交叉引用 ➊ 来自一个函数,显然不可能是 vftable),我们会碰到死胡同。简单的数学运算告诉我们,交叉引用源自于typeinfo结构前面的地址(00301d188 == 00301d10)。在正常情况下,vftable 到typeinfo结构应该存在交叉引用;然而,由于缺少符号,Ghidra 未能创建该引用。由于我们知道,另一个指向我们typeinfo结构的指针必须存在某个地方,我们可以请求 Ghidra 的帮助。在结构的开始位置 ➌,将光标定位后,我们可以使用菜单选项“搜索 ▸ 寻找直接引用”,让 Ghidra 为我们找到当前内存地址。搜索结果如图 20-6 所示。

image

图 20-6:直接引用搜索结果

Ghidra 找到了两个额外的引用指向这个typeinfo结构。对每个引用进行调查,最终我们找到了一个 vftable:

➊ 00301c60       ??      18h                 ?➋ -> 00301d18

  00301c61       ??      1Dh

  00301c62       ??      30h                 0

  00301c63       ??      00h

  00301c64       ??      00h

  00301c65       ??      00h

  00301c66       ??      00h

  00301c67       ??      00h

          PTR_FUN_00301c68                 XREF[2]: FUN_00101098:001010b0(*),

                                                    FUN_00101098:001010bb(*)

➌ 00301c68  addr   FUN_001010ea

  00301c70  addr   FUN_00100ff0

  00301c78  addr   FUN_00101122

  00301c80  addr   FUN_00101060

  00301c88  addr   FUN_0010115a

Ghidra 没有将typeinfo交叉引用的源代码➊格式化为指针(这解释了为何没有交叉引用),但它确实提供了一个行尾注释,暗示它是一个指针➋。虚拟函数表(vftable)本身在 8 个字节后开始➌,并包含指向SubClass所属的五个虚拟函数的指针。该表中没有经过名称修饰的名字,因为二进制文件已经被剥离。

在接下来的部分,我们将应用这种“追踪面包屑”分析技术,帮助识别由几个编译器生成的 C 语言二进制文件中的main函数。

定位 main 函数

从程序员的角度来看,程序执行通常从main函数开始,因此从main函数着手分析一个二进制文件并不是一个坏策略。然而,编译器和链接器(以及库的使用)会添加在到达main之前执行的代码。因此,假设二进制文件的入口点就是程序作者编写的main函数往往是不准确的。事实上,所有程序都有main函数的这一概念其实是 C/C++编译器的一种约定,而不是编写程序的硬性规定。如果你曾经编写过 Windows GUI 应用程序,你可能对main函数的变体WinMain有所了解。一旦你离开 C/C++,你会发现其他语言为其主入口函数使用了不同的名称。我们通常将这个函数泛称为main函数。

如果你的二进制文件中有一个名为 main 的符号,你可以直接让 Ghidra 带你去该位置,但如果你正分析一个被剥离的二进制文件,你将会被直接带到文件头部,然后必须自己找到 main。只要稍微理解一下可执行文件的工作原理,并积累一些经验,这个任务不应该太难。

所有可执行文件都必须指定一个地址,该地址是二进制文件映射到内存后执行的第一条指令。Ghidra 将这个地址称为 entry_start,具体取决于文件类型和符号的可用性。大多数可执行文件格式在文件的头部区域指定此地址,Ghidra 加载器能准确找到它。在 ELF 文件中,入口点地址在名为 e_entry 的字段中指定,而 PE 文件则包含名为 AddressOfEntryPoint 的字段。无论可执行文件运行在哪个平台,编译后的 C 程序在入口点处都有代码,由编译器插入,用于将进程从刚创建的状态转换为正在运行的 C 程序。这一过渡的一部分是确保内核在创建进程时提供的参数和环境变量被收集并传递给 main,并使用 C 调用约定。

注意

你的操作系统内核既不知道也不关心任何可执行文件是用什么语言编写的。你的内核仅知道一种将参数传递给新进程的方式,而这种方式可能与程序的入口函数不兼容。这正是编译器的任务,它负责弥合这一差距。

现在我们知道执行从一个公开的入口点开始,并最终到达 main 函数,我们可以查看一些编译器特定的代码,了解如何实现这一过渡。

示例 1:在 Linux x86-64 上使用 gcc 从 _start 到 main

通过检查未剥离的可执行文件中的启动代码,我们可以确切了解在给定操作系统上,如何通过特定的编译器到达 main。Linux gcc 提供了一个相对简单的方法:

     _start

004003b0  XOR    EBP,EBP

004003b2  MOV    R9,RDX

004003b5  POP    RSI

004003b6  MOV    RDX,RSP

004003b9  AND    RSP,-0x10

004003bd  PUSH   RAX

004003be  PUSH   RSP=>local_10

004003bf  MOV    R8=>__libc_csu_fini,__libc_csu_fini

004003c6  MOV    RCX=>__libc_csu_init,__libc_csu_init

004003cd  MOV    RDI=>main,main➊

004003d4  CALL➋ qword ptr [->__libc_start_main]

main 的地址在调用到名为 __libc_start_main 的库函数之前,会被加载到 RDI ➊ 中,这意味着 main 的地址作为第一个参数传递给了 __libc_start_main。掌握了这个信息后,我们就可以轻松地在剥离的二进制文件中定位到 main。以下是剥离二进制文件中调用 __libc_start_main 的前导代码:

004003bf  MOV    R8=>FUN_004008a0,FUN_004008a0

004003c6  MOV    RCX=>FUN_00400830,FUN_00400830

004003cd  MOV    RDI=>FUN_0040080a,FUN_0040080a➊

004003d4  CALL   qword ptr [->__libc_start_main]

尽管代码中引用了三个命名通用的函数,但我们可以得出结论,FUN_0040080a 必定是 main,因为它作为第一个参数传递给了 __libc_start_main ➊。

示例 2:在 FreeBSD x86-64 上使用 clang 从 _start 到 main

在当前版本的 FreeBSD 中,clang 是默认的 C 编译器,并且 _start 函数比简单的 Linux _start 存根要更为复杂,且更难以跟踪。为了简化操作,我们将使用 Ghidra 的反编译器来查看 _start 的尾部。

    //~40 lines of code omitted for brevity

    atexit((__func *)cleanup);

    handle_static_init(argc,ap,env);

    argc = main((ulong)pcVar2 & 0xffffffff,ap,env);

                    /* WARNING: Subroutine does not return */

    exit(argc);

}

在这种情况下,main_start中倒数第二个被调用的函数,main的返回值立即传递给exit以终止程序。使用 Ghidra 的反编译器对相同二进制文件的剥离版本进行反编译,得到以下列表:

    // 40 lines of code omitted for brevity

    atexit(param_2);

    FUN_00201120(uVar2 & 0xffffffff,ppcVar5,puVar4);

    __status = FUN_00201a80(uVar2 & 0xffffffff,ppcVar5,puVar4)➊;

                    /* WARNING: Subroutine does not return */

    exit(__status);

}

再一次,我们可以从人群中挑选出main ➊,即使二进制文件已经被剥离。如果你想知道为什么这个列表显示了两个没有被剥离的函数名,原因是这个特定的二进制文件是动态链接的。atexitexit这两个函数并不是二进制中的符号;它们是外部依赖。这些外部依赖即使在剥离后仍然存在,并且在反编译后的代码中仍然可见。下面展示的是这个二进制文件的静态链接、剥离版本的相应代码:

    FUN_0021cc70();

    FUN_0021c120(uVar2 & 0xffffffff,ppcVar13,puVar11);

    uVar7 = FUN_0021caa0(uVar2 & 0xffffffff,ppcVar13,puVar11);

                    /* WARNING: Subroutine does not return */

    FUN_00266d30((ulong)uVar7);

}

示例 3:Microsoft C/C++编译器的 _start 到 main

Microsoft 的 C/C++编译器的启动存根要复杂一些,因为 Windows 内核的主要接口是通过kernel32.dll(而不是大多数 Unix 系统上的libc)提供的,它不提供 C 库函数。因此,编译器通常会将许多 C 库函数静态链接到可执行文件中。启动存根使用这些函数以及其他函数与内核交互,以设置 C 程序的运行时环境。

然而,最终,启动存根仍然需要在main返回后调用main并退出。追踪main通常是通过识别一个带有三个参数的函数(main),其返回值被传递给一个带有一个参数的函数(exit)。以下是这种类型的二进制文件的片段,包含了我们正在寻找的两个函数的调用:

140001272 CALL   _amsg_exit➊

140001277 MOV    R8,qword ptr [DAT_14000d310]

14000127e MOV    qword ptr [DAT_14000d318],R8

140001285 MOV    RDX,qword ptr [DAT_14000d300]

14000128c MOV    ECX,dword ptr [DAT_14000d2fc]

140001292 CALL➋ FUN_140001060

140001297 MOV    EDI,EAX

140001299 MOV    dword ptr [RSP + Stack[-0x18]],EAX

14000129d TEST   EBX,EBX

14000129f JNZ    LAB_1400012a8

1400012a1 MOV    ECX,EAX

1400012a3 CALL➌ FUN_140002b30

在这里,FUN_140001060 ➋ 是带有三个参数的函数,结果是main,而FUN_140002b30 ➌ 是带有一个参数的exit。注意,Ghidra 能够恢复启动存根调用的静态链接函数之一的名称 ➊,因为该函数与 FidDb 条目匹配。我们可以利用任何已识别符号提供的线索,在寻找main时节省一些时间。

总结

编译器特定行为的数量太多,无法在一章(甚至一本书)中涵盖。除了其他行为外,编译器在实现各种高级结构时选择的算法和优化生成代码的方式各不相同。由于编译器的行为受到构建过程中提供的参数的强烈影响,因此当使用相同的源代码并选择不同的构建选项时,一个编译器可能会生成完全不同的二进制文件。

不幸的是,应对所有这些变体只能通过经验积累,而在特定的汇编语言结构上寻求帮助通常非常困难,因为很难构造出能针对你特定情况产生有效结果的搜索表达式。当这种情况发生时,你最好的资源通常是一个专门讨论逆向工程的论坛,在那里你可以发布代码,并从那些有类似经历的人的知识中受益。

第二十四章:**第五部分

真实世界应用**

第二十五章:混淆代码分析**

Image

即使在理想的情况下,理解反汇编列表也是一项困难的任务。高质量的反汇编对于任何试图理解二进制文件内部工作原理的人都是至关重要的,这也是我们在过去 20 章中讨论 Ghidra 及其相关功能的原因。可以说,Ghidra 在它的工作中如此高效,以至于它降低了进入二进制分析领域的门槛。虽然这绝不是 Ghidra 独有的成就,但二进制逆向工程的近期进展并没有被那些不希望自己的软件被分析的人忽视。因此,在过去的几年里,程序员与逆向工程师之间为了保持代码的机密性,进行了一场某种意义上的技术竞赛。

本章我们将探讨 Ghidra 在这场技术竞赛中的作用,并讨论一些已采取的保护代码的措施,以及应对这些措施的方法。最后,我们将介绍 Ghidra 的Emulator类,并举例说明仿真脚本如何帮助我们在这场技术竞赛中占得先机。

反向工程防护

反向工程防护是一个涵盖所有软件开发人员可能采用的技术的总称,这些技术的目的是使得逆向工程他们的产品变得更加具有挑战性。许多工具和技术存在,旨在帮助开发者实现这一目标,而且每天都有新的技术出现。RE/反 RE 生态系统类似于恶意软件作者与杀毒软件供应商之间的日益升级的动态。

作为一名逆向工程师,你可能会遇到从简单到几乎无法攻克的各种技术。你需要使用的方法也会根据你遇到的反逆向技术的性质而有所不同,可能需要一定程度上熟悉静态和动态分析技术。在接下来的章节中,我们将讨论一些常见的反逆向技术、它们为何被采用以及如何克服这些技术的方法。

混淆

各种词典定义表明,混淆是指将某物变得模糊、复杂、混乱或令人困惑,以防止他人理解该混淆的项目。在本书的背景下,以及在使用 Ghidra 时,被混淆的项目是二进制可执行文件(而不是源文件或硅片等)。

混淆本身过于广泛,不能被视为一种反逆向工程技术。它也无法涵盖所有已知的反逆向工程技术。具体的、单独的技术通常可以描述为混淆或非混淆技术,适用时,我们将在以下部分指出这些技术。需要注意的是,没有单一正确的方式来分类这些技术,因为一般类别的描述常常是重叠的。此外,新的反逆向工程技术正在不断发展,因此无法提供一个包罗万象的列表。

因为 Ghidra 主要是一个静态分析工具,我们发现将技术讨论分为两个大类更为有用:反静态分析反动态分析。这两个类别都可能包含混淆技术,但前者更可能会困扰静态工具,而后者通常是针对调试器和其他运行时分析工具的。

反静态分析技术

反静态分析技术旨在防止分析人员在不实际运行程序的情况下理解程序的性质。这正是针对像 Ghidra 这样的反汇编工具的技术,因此在使用 Ghidra 进行二进制逆向工程时,这类技术是最值得关注的。这里讨论了几种反静态分析技术。

反汇编同步化

一种较旧的技术旨在通过创造性地使用指令和数据,防止反汇编工具找到一个或多个指令的正确起始地址。强制反汇编工具失去方向通常会导致反汇编失败,或者至少是错误的反汇编结果。列表 21-1 显示了 Ghidra 在反汇编 Shiva 反逆向工程工具部分代码时的努力。^(1)

  0a04b0d1 e8 01 00 00 00 CALL➊ FUN_0a04b0d7

  0a04b0d6 c7             ??     C7h➋

       ************************************************************

       *                         FUNCTION                         *

       ************************************************************

       undefined FUN_0a04b0d7()

          undefined AL:1 <RETURN>

       FUN_0a04b0d7                           XREF[1]: FUN_0a04b0c4:0a04b0d1(c)

  0a04b0d7 58             POP➌  EAX

  0a04b0d8 8d 40 0a       LEA➍  EAX,[EAX + 0xa]

          LAB_0a04b0db+1                      XREF[0,1]: 0a04b0db(j)

➎ 0a04b0db eb ff          JMP   LAB_0a04b0db+1

  0a04b0dd e0             ??➏   E0h

列表 21-1:初步 Shiva 反汇编示例

这个示例执行了一个 CALL ➊,紧接着是一个 POP ➌。这个序列在自修改代码中并不罕见,代码通过它来发现自己在哪个内存位置运行。调用指令的返回地址 ➋ 是 0a04b0d6,它位于栈顶,当执行到 POP 指令时。POP 指令从栈中移除返回地址并将其加载到 EAX 寄存器中,紧接着的 LEA 立即将 0xa(即 10)加到 EAX 中,这样 EAX 现在存储的是 0a04b0e0(请记住这个值,我们稍后会用到)。

被调用的函数不太可能返回到原始的调用点,因为原始返回地址已经不在栈顶(它需要被替换才能 RET 回原来的返回位置),而且 Ghidra 无法在返回地址 ➋ 处形成指令,因为 C7h 不是有效的指令起始字节。

到目前为止,代码可能有点不寻常或者难以理解,但 Ghidra 展示的是正确的反汇编。当达到 JMP ➎ 指令时,情况就发生了变化。这条跳转指令长 2 个字节,地址是 0a04b0db,跳转目标是 LAB_0a04b0db+1。标签中的 +1 后缀对我们来说是新的。标签的地址部分与跳转本身的地址相同。+1 告诉你,跳转目标在 LAB_0a04b0db 之后 1 个字节。换句话说,跳转指令正好跳到了 2 字节跳转指令的中间。虽然处理器不在乎这种不寻常的情况(它会很高兴地获取指令指针所指向的内容),但 Ghidra 无法处理这个问题。Ghidra 没有办法同时显示 0a04b0db 处的字节(ff)既是跳转指令的第二个字节,又是另一条指令的第一个字节。因此,Ghidra 无法继续进行反汇编,这在 0a04b0dd 处的未定义数据值 ➏ 中得到了体现。(这种行为不仅限于 Ghidra:几乎所有反汇编器,无论是使用递归下降算法还是线性扫描算法,都无法避免这种技术带来的问题。)

Ghidra 在反汇编过程中会通过创建错误书签来记录它遇到的任何问题。图 21-1 显示了两个这样的书签(位于有问题地址左侧的 X 图标),它们出现在列表窗口的左侧边距中。将鼠标悬停在错误书签上会显示相关的详细信息。此外,你可以通过使用窗口 ▸ 书签来打开当前二进制文件中所有书签的列表。

Ghidra 对第一个错误的提示是“无法解析位于 0a04b0d6 的构造函数(来自 0a04b0d1 的流程)”,大致意思是“我认为 0a04b0d6 处应该存在一条指令,但我没能创建它。”Ghidra 对第二个错误的提示是“由于 0a04b0db 处的指令冲突(来自 0a04b0db 的流程),无法在 0a04b0dc 处进行反汇编”,大致意思是“我无法在现有指令中反汇编一条指令。”

image

图 21-1:Ghidra 错误书签

作为 Ghidra 用户,对于第一个错误你没有解决方案。一个字节序列要么是有效的指令,要么不是。对于第二个错误,通过一些努力你可以处理。解决这种情况的正确方法是取消定义包含目标调用字节的指令,然后在调用目标地址处定义一条指令,尝试重新同步反汇编。你将失去原始的指令,但可以留一个注释来提醒你原始指令是什么。以下是前面列出的包含重叠指令错误的部分:

          LAB_0a04b0db+1                       XREF[0,1]:   0a04b0db(j)

➊ 0a04b0db eb ff    JMP    LAB_0a04b0db+1

  0a04b0dd e0        ??     E0h

右键点击 JMP 指令 ➊ 并从上下文菜单中选择清除代码字节(快捷键 C)将显示以下未定义字节的列表:

  0a04b0db eb        ??     EBh

➊ 0a04b0dc ff       ??     FFh

  0a04b0dd e0        ??     E0h

作为JMP指令目标➊的字节现在可以进行重新格式化。通过右键点击指令的起始字节并选择反汇编(快捷键 D),原始字节将被更改为代码。此时,列表已更新为以下内容:

➊ 0a04b0dc ff e0    JMP    EAX

  0a04b0de 90        ??     90h

  0a04b0df c7        ??     C7h

跳转指令的目标结果是另一个跳转指令➊。 然而,在这种情况下,跳转对于反汇编器来说是无法跟踪的(并且可能会让人工分析员感到困惑),因为跳转的目标存储在寄存器(EAX)中,并在运行时计算得出。这是另一种反静态分析技术的示例,接下来的“动态计算目标地址”部分将讨论这一点。我们之前已经确定,在到达此跳转时,EAX的值为0a04b0e0,而这是我们必须从此地址恢复反汇编过程的位置。洗头、冲水、重复。

参考清单 21-1,作为手动跳转到0a04b0e0地址恢复反汇编的替代方法,您可以通过右键点击地址➌并选择设置寄存器值来将EAX的值设置为已知值。Ghidra 将为该指令添加一个称为寄存器过渡的特殊标记,以指示JMP目标EAX假定值。随后从此位置清除(快捷键 C)并反汇编(快捷键 D)将重新启动递归下降反汇编过程,从JMP到目标0a04b0e0,并继续(包括在这些代码块之间创建交叉引用)。

这种方法的一个优点是代码被注释,以显示JMP指令的目标,从而使其他分析人员能够轻松跟踪此部分的有效控制流。(当与0a04b0d8处的LEA指令的后续覆盖结合使用时,这一点更加清晰,见清单 21-1)。这种替代方法会产生以下的列表:

0a04b0d7 58        POP    EAX

0a04b0d8 8d 40 0a  LEA    EAX,[EAX + 0xa]

                      -- Fallthrough Override: 0a04b0dc

0a04b0db eb        ??     EBh

            assume EAX = 0xa04b0e0

        LAB_0a04b0dc                         XREF[1]:     0a04b0d8  

0a04b0dc ff e0     JMP    EAX=>LAB_0a04b0e0

            assume EAX = <UNKNOWN>

0a04b0de 90        ??     90h

0a04b0df c7        ??     C7h

 LAB_0a04b0e0                         XREF[1]:     0a04b0dc(j)  

0a04b0e0 58        POP    EAX 0a04b0e0 POP EAX

另一个来自不同二进制文件的去同步化示例展示了处理器标志如何被用来将条件跳转转变为绝对跳转。以下反汇编示例展示了 x86 Z标志如何用于这种目的:

  00401000  XOR➊  EAX,EAX

  00401002  JZ➋   LAB_00401009+1

  00401004  MOV    EBX,dword ptr [EAX]

  00401006  MOV    dword ptr [param_1 + -0x4],EBX

       ➌ LAB_00401009+1                       XREF[0,1]: 00401002(j)

➍ 00401009  CALL   SUB_adfeffc6

  0040100e  FICOM  word ptr [EAX + 0x59]

在这里,XOR指令➊用于清零EAX寄存器并设置 x86 的Z标志。程序员知道Z标志已被设置,因此利用跳转零(JZ)指令➋,该指令将始终被执行,从而实现无条件跳转的效果。因此,跳转指令➋与跳转目标➌之间的指令将永远不会被执行,仅用于迷惑任何没有意识到这一点的分析员。这个例子还通过跳转到00401009处的CALL指令的中间➍来模糊实际的跳转目标。正确反汇编后,代码应如下所示:

  00401000  XOR    EAX,EAX

  00401002  JZ     LAB_0040100a

  00401004  MOV    EBX,dword ptr [EAX]

  00401006  MOV    dword ptr [param_1 + -0x4],EBX

➊ 00401009  ??    E8h

          LAB_0040100a                         XREF[1]: 00401002(j)

➋ 0040100a  MOV    EAX,0xdeadbeef

  0040100f  PUSH   EAX

  00401010  POP    param_1

跳转的实际目标➋已经揭示,同时导致最初不同步的额外字节➊也被揭示。确实可能使用更加间接的方法来设置和测试标志位,在执行条件跳转之前。分析这种代码的难度随着在测试处理器标志位之前可能影响标志位的操作数量增加而增加。

动态计算的目标地址

动态计算这一术语简单地意味着执行流向的地址是在运行时计算的。在本节中,我们讨论了几种计算该地址的方式。这些技术的目的是隐藏(混淆)二进制文件将要遵循的实际控制流路径,以避免静态分析过程的窥探。

本节前面展示了这一技术的一个示例。该示例使用了调用指令将返回地址压入栈中。返回地址被直接从栈中弹出到寄存器,并将常量值加到寄存器中以推导最终的目标地址,最终通过执行跳转指令跳转到寄存器内容指定的位置。

可以开发出无限多种类似的代码序列来推导目标地址并将控制权转移到该地址。以下代码也是在 Shiva 中使用的,展示了动态计算目标地址的另一种方法:

  0a04b3be  MOV    ECX,0x7f131760              ; ECX = 7F131760

  0a04b3c3  XOR    EDI,EDI                     ; EDI = 00000000

  0a04b3c5  MOV    DI,0x1156                   ; EDI = 00001156

  0a04b3c9  ADD    EDI,0x133ac000              ; EDI = 133AD156

  0a04b3cf  XOR    ECX,EDI                     ; ECX = 6C29C636

  0a04b3d1  SUB    ECX,0x622545ce              ; ECX = 0A048068

  0a04b3d7  MOV    EDI,ECX                     ; EDI = 0A048068

  0a04b3d9  POP    EAX

  0a04b3da  POP    ESI

  0a04b3db  POP    EBX

  0a04b3dc  POP    EDX

  0a04b3dd  POP    ECX

➊ 0a04b3de  XCHG   dword ptr [ESP],EDI         ; TOS =   0A048068

  0a04b3e1  RET                                ; return to 0A048068

分号右侧的注释记录了每条指令对各种处理器寄存器所做的更改。该过程最终将计算出的值移到栈的顶部位置(TOS)➊,这导致返回指令将控制权转移到计算得到的位置(此例为0A048068)。分析员本质上必须手动运行代码,以确定程序实际执行的控制流路径。

混淆的控制流

近年来,已经开发和使用了更为复杂的控制流隐藏方法。在最复杂的情况下,一个程序会使用多个线程或子进程来计算控制流信息,并通过某种形式的进程间通信(对于子进程)或同步原语(对于多个线程)来接收这些信息。

在这种情况下,静态分析可能变得非常困难,因为不仅需要理解多个可执行实体的行为,还需要理解这些实体交换信息的具体方式。例如,一个线程可能会等待一个共享信号量对象,而第二个线程计算值或修改代码,第一线程将在第二个线程通过信号量发出完成信号后使用这些数据。^(2)

另一种技术,通常用于 Windows 恶意软件中,涉及配置一个异常处理程序,^(3)故意触发一个异常,然后在处理异常时操控进程寄存器的状态。以下示例是 tElock 反逆向工程工具用于掩盖程序实际控制流的方式:

➊ 0041d07a  CALL   LAB_0041d07f

          LAB_0041d07f                         XREF[1]: 0041d07a(j)

➋ 0041d07f  POP    EBP

➌ 0041d080  LEA    EAX,[EBP + 0x46]

➍ 0041d083  PUSH   EAX

  0041d084  XOR    EAX,EAX

➎ 0041d086  PUSH   dword ptr FS:[EAX]

➏ 0041d089  MOV    dword ptr FS:[EAX],ESP

➐ 0041d08c  INT    3

  0041d08d  NOP

  0041d08e  MOV    EAX,EAX

  0041d090  STC

  0041d091  NOP

  0041d092  LEA    EAX,[EBX*0x2 + 0x1234]

  0041d099  CLC

  0041d09a  NOP

  0041d09b  SHR    EBX,0x5

  0041d09e  CLD

  0041d09f  NOP

  0041d0a0  ROL    EAX,0x7

  0041d0a3  NOP

  0041d0a4  NOP

➑ 0041d0a5  XOR    EBX,EBX

➒ 0041d0a7  DIV    EBX

  0041d0a9  POP    dword ptr FS:[0x0]

该序列首先通过CALL ➊调用下一个指令 ➋;CALL指令将0041d07f推入栈中作为返回地址,随后该地址被弹出并存入EBP寄存器 ➋。接下来,EAX寄存器 ➌被设置为EBP46h的和,即0041d0c5,该地址作为异常处理程序函数的地址被推入栈中 ➍。异常处理程序的其余设置发生在➎和➏处,这完成了将新的异常处理程序链接到由FS:[0]引用的现有异常处理程序链中的过程。^(4)

下一步是故意生成一个异常➐,在本例中是一个INT 3,它是一个软件陷阱(中断),用于调试器。(在 x86 程序中,INT 3指令被调试器用来实现软件断点。)通常在此时,附加的调试器会获得控制权,因为调试器有机会首先处理该异常。在本例中,程序完全预期会处理该异常,因此任何附加的调试器必须被指示将异常传递给程序。不允许程序处理异常可能导致程序运行不正常或崩溃。如果没有理解如何处理INT 3异常,就无法知道程序接下来会发生什么。如果我们假设执行在INT 3之后简单地恢复,那么看起来除法零异常将在指令➑和➒之后最终被触发。

与前面的代码相关联的异常处理程序的反汇编版本从地址0041d0c5开始。该函数的第一部分如下所示:

int FUN_0041d0c5(EXCEPTION_RECORD *param_1,void *frame,➊CONTEXT *ctx) {

  DWORD code;

➋ ctx->Eip = ctx->Eip + 1;

➌ code = param_1->ExceptionCode;

➍ if (code == EXCEPTION_INT_DIVIDE_BY_ZERO) {

    ctx->Eip = ctx->Eip + 1;

  ➎ ctx->Dr0 = 0;

    ctx->Dr1 = 0;

    ctx->Dr2 = 0;

    ctx->Dr3 = 0;

    ctx->Dr6 = ctx->Dr6 & 0xffff0ff0;

    ctx->Dr7 = ctx->Dr7 & 0xdc00;

  }

异常处理程序函数 ➊的第三个参数是一个指向 Windows CONTEXT结构的指针(在 Windows API 头文件winnt.h中定义)。CONTEXT结构通过包含异常发生时所有处理器寄存器的内容来初始化。异常处理程序有机会检查并在需要时修改CONTEXT结构的内容。如果异常处理程序认为它已修复导致异常的问题,它可以通知操作系统允许出现问题的线程继续执行。此时,操作系统从提供给异常处理程序的CONTEXT结构中重新加载线程的处理器寄存器,然后线程的执行将恢复,就像什么都没有发生过一样。

在前面的例子中,异常处理程序首先访问线程的CONTEXT,以便递增指令指针 ➋,允许执行继续进行,跳过生成异常的那条指令。接下来,获取异常的类型代码(EXCEPTION_RECORD中的一个字段) ➌ ,以确定异常的性质。异常处理程序的这一部分处理了前面例子中产生的除以零错误 ➍,通过将所有 x86 硬件调试寄存器清零 ➎ 并禁用硬件断点。^(5) 在没有检查剩余的 tElock 代码之前,无法立即理解为什么要清空调试寄存器。在这种情况下,tElock 清除了来自之前操作的值,其中它使用调试寄存器设置了四个断点,除了之前看到的INT 3。除了混淆程序的真实执行流,清空或修改 x86 调试寄存器可能会对软件调试工具(如 OllyDbg 或 GDB)造成严重干扰。此类反调试技术在第 487 页的“反动态分析技术”中有讨论。

操作码混淆

虽然到目前为止所描述的技术确实为理解程序的控制流提供了——实际上,它们旨在提供——一定的障碍,但没有一种方法能够完全防止你观察正在分析的程序的正确反汇编形式。反同步化对反汇编影响最大,但通过重新格式化反汇编以反映正确的指令流,轻松战胜了这一技术。

防止正确反汇编的一个更有效的技术是在可执行文件创建时对实际指令进行编码或加密。混淆的指令必须在被处理器取出执行之前恢复为原始形式。因此,程序的至少一部分必须保持未加密状态,以作为启动例程,在混淆程序的情况下,这通常负责将程序的其余部分部分或全部进行反混淆。图 21-2 展示了混淆过程的一个非常通用的概览。

image

图 21-2:通用混淆过程

如图所示,输入到该过程的是一个需要混淆的程序。在许多情况下,输入程序使用标准编程语言和构建工具(如编辑器、编译器等)编写,对即将进行的混淆几乎没有考虑。生成的可执行文件被输入到一个混淆工具,该工具将二进制文件转换为一个功能上等效但被混淆的二进制文件。如图所示,混淆工具负责混淆原始程序的代码和数据部分,并添加额外的代码(去混淆存根),该存根在运行时能够执行去混淆操作,使得原始功能能够被访问。混淆工具还会修改程序头信息,将程序入口点重定向到去混淆存根,确保执行从去混淆过程开始。去混淆后,执行通常会转移到原始程序的入口点,程序开始执行,就好像它从未被混淆过一样。

这个过于简化的过程根据所使用的混淆工具而有很大差异。现有越来越多的工具可用于处理混淆过程。这些工具提供了从压缩到反反汇编和反调试等技术的各种功能。例如,像 UPX(压缩工具,也支持 ELF;* upx.github.io/)、ASPack(压缩工具; www.aspack.com/)、ASProtect(反逆向工程,由 ASPack 开发者制作)、tElock(压缩和反逆向工程; www.softpedia.com/get/Programming/Packers-Crypters-Protectors/Telock.shtml)等程序适用于 Windows PE 文件。混淆工具的功能已经发展到某些反逆向工程工具(如 VMProtect)可以与整个构建过程集成,使得程序员能够在开发的每个阶段,从源代码到编译后的二进制文件后处理,集成反逆向工程功能( vmpsoft.com/*)。

沙箱环境

反向工程的沙箱环境的目的是让你以一种能够观察程序行为的方式执行程序,而不会让这种行为对反向工程平台的关键组件或其连接的任何内容产生不利影响。沙箱环境通常使用平台虚拟化软件构建,但也可以在能够在执行任何恶意软件后恢复到已知良好状态的专用系统上构建。

沙箱系统通常会进行大量的监控,以便观察和收集运行在沙箱内的程序的行为信息。收集的数据可能包括程序的文件系统活动、(Windows)程序的注册表活动以及程序生成的任何网络活动信息。一个完整的沙箱环境的例子是 Cuckoo (cuckoosandbox.org/),它是一个专门用于恶意软件分析的流行开源沙箱。

和任何进攻性技术一样,防御措施也已被开发出来,以应对许多反反向工程工具。在大多数情况下,这些工具的目标是恢复原始的、未保护的可执行文件(或一个合理的副本),然后可以使用更传统的工具,如反汇编器和调试器,进行分析。

一个旨在去混淆 Windows 可执行文件的工具叫做 QuickUnpack (qunpack.ahteam.org/?p=458;该网站为俄语)。QuickUnpack 与许多其他自动解包器一样,通过充当调试器,允许一个混淆的二进制文件通过其去混淆阶段执行,然后捕获内存中的进程镜像。请注意,这种类型的工具实际上是运行潜在的恶意程序,目的是在这些程序解包或去混淆后但尚未做出恶意行为之前拦截它们的执行。因此,您应该始终在沙箱类型的环境中执行此类程序。

使用纯静态分析环境来分析混淆代码是一项具有挑战性的任务。在无法执行去混淆存根的情况下,必须先解包或解密二进制文件中的混淆部分,然后才能开始反汇编。右侧的 Ghidra 地址类型概览栏(见图 21-3)显示了使用 UPX 打包器打包的可执行文件的布局。Ghidra 在概览栏中对内容进行颜色编码,以便指示二进制文件中相关内容。概览栏的常见类别包括以下内容:

  • 功能

  • 未初始化

  • 外部引用

  • 指令

  • 数据

  • 未定义

通过图中的概览栏,我们可以看到 Ghidra 对二进制文件各个部分的初步评估。将鼠标悬停在概览栏的任何部分上,都会提供关于该二进制文件相应区域的附加信息。这个特别的导航栏的异常外观是一个提示,表明这个二进制文件以某种方式被混淆了。让我们更仔细地查看概览栏中的一些部分。

Ghidra 在文件开始处识别出了一个数据段➊。检查此内容后,可以看到文件的头信息以及一些指示文件使用的混淆类型的内容:

  This file is packed with the UPX executable packer http://upx.tsx.org

  UPX 1.07 Copyright (C) 1996-2001 the UPX Team. All Rights Reserved.

image

图 21-3:使用 UPX 压缩的二进制文件的 Ghidra Listing 窗口和地址类型概览栏

本节后面是一个未定义内容块 ➋,类似于以下内容,它出现在 Listing 窗口中:

004008a3 72        ??     72h    r

004008a4 85        ??     85h

004008a5 6c        ??     6Ch    l

最大的部分 ➌ 包含未初始化的数据,在 Listing 窗口中显示如下:

004034e3  ??       ??

004034e4  ??       ??

在文件稍后的部分,Ghidra 识别出了另一个未定义的内容块 ➍。该数据的末尾是 Ghidra 识别为一个函数的区域 ➎。该函数很容易识别为 UPX 解压缩存根,Ghidra 已将其标识为二进制文件的入口点,如 图 21-3 左侧的 Listing 窗口所示。我们观察到的未定义内容块 ➋➍ 是 UPX 压缩过程的结果。解压存根的任务是将这些数据解包到未初始化区域 ➌ 中,最后将控制权转移到解压后的代码。

地址类型概览栏提供的信息可以与二进制文件中每个段的属性相关联,以确定每个显示的信息是否一致。此二进制文件的内存映射如 图 21-4 所示。

image

图 21-4:UPX 压缩二进制文件的内存映射

在这个特定的二进制文件中,包含在 UPX0 ➊ 和 UPX1 ➋ 段中的整个地址范围(0040100000408fff)被标记为可执行的(X 标志已设置)。鉴于这一点,我们应该期望看到整个地址类型概览栏的颜色被标记为表示函数。然而,事实上我们没有看到这一点,并且 UPX0 段的整个范围未初始化且可写,这应被视为非常可疑,并为我们提供了关于该二进制文件的重要线索,帮助我们继续进行分析。

使用 Ghidra 在静态环境中(不实际执行二进制文件)对此类文件进行解压操作的技术,讨论了“使用 Ghidra 对二进制文件进行静态去混淆”的内容,见 第 491 页。

导入函数混淆

反静态分析技术也可能隐藏二进制文件使用的共享库和库函数,以避免泄露关于二进制文件可能执行的潜在操作的信息。在大多数情况下,可以使像 dumpbinlddobjdump 这样的工具在列出库依赖项时失效。

这种混淆对 Ghidra 的影响在符号树中最为明显。我们之前提到的 tElock 示例的完整符号树如 图 21-5 所示。

image

图 21-5:混淆二进制文件的符号树

只有两个被引用的导入函数:GetModulehandleA(来自kernel32.dll)和MessageBoxA(来自user32.dll)。从这个简短的列表中几乎无法推测程序的行为。这里采用的技巧多种多样,但本质上归结为程序本身必须加载它所依赖的任何附加库,并且一旦这些库被加载,程序必须在这些库中找到所需的函数。在大多数情况下,这些任务由去混淆存根执行,在将控制权转交给去混淆后的程序之前。最终目标是使程序的导入表已正确初始化,就像操作系统自己的加载器执行的那样。

对于 Windows 二进制文件,一种简单的方法是使用LoadLibrary函数按名称加载所需的库,然后使用GetProcAddress函数在每个库中执行函数地址查找。要使用这些函数,程序必须显式链接到它们,或者有其他方式进行查找。tElock 示例的符号树中不包含这两个函数,而 UPX 示例的符号树,如图 21-6 所示,包含了这两个函数。

image

图 21-6:UPX 示例的符号树

负责重建导入表的实际 UPX 代码见于清单 21-2。

        LAB_0040886c                         XREF[1]: 0040888e(j)

0040886c  MOV    EAX,dword ptr [EDI]

0040886e  OR     EAX,EAX

00408870  JZ     LAB_004088ae

00408872  MOV    EBX,dword ptr [EDI + 0x4]

00408875  LEA    EAX,[EAX + ESI*0x1 + 0x8000]

0040887c  ADD    EBX,ESI

0040887e  PUSH   EAX

0040887f  ADD    EDI,0x8

00408882  CALL➊ dword ptr [ESI + 0x808c]=>KERNEL32.DLL::LoadLibraryA

00408888  XCHG   EAX,EBP

        LAB_00408889                         XREF[1]: 004088a6(j)

00408889  MOV    AL,byte ptr [EDI]

0040888b  INC    EDI

0040888c  OR     AL,AL

0040888e  JZ     LAB_0040886c

00408890  MOV    ECX,EDI

00408892  PUSH   EDI

00408893  DEC    EAX

00408894  SCASB.REPNE ES:EDI

00408896  PUSH    EBP

00408897  CALL➋ dword ptr [ESI + 0x8090]=>KERNEL32.DLL::GetProcAddress

0040889d  OR     EAX,EAX

 0040889f  JZ     LAB_004088a8

004088a1  MOV➌  dword ptr [EBX],EAX ; save to import table

004088a3  ADD    EBX,0x4

004088a6  JMP    LAB_00408889

清单 21-2:UPX 中的导入表重建

这个示例包含一个外部循环,负责调用LoadLibrary ➊,以及一个内部循环,负责调用GetProcAddress ➋。每次成功调用GetProcAddress后,新的函数地址会被存储到重建的导入表中 ➌。

这些循环是在 UPX 去混淆存根的最后部分执行的,因为每个函数都采用指向库名或函数名的字符串指针参数,而相关的字符串被保存在压缩数据区域中,以避免被strings工具检测到。因此,UPX 中的库加载无法在所需的字符串被解压之前进行。

回到 tElock 示例,出现了一个不同的问题。只有两个导入函数,而且都不是LoadLibraryGetProcAddress,那么 tElock 工具如何执行 UPX 所执行的函数解析任务呢?所有 Windows 进程都依赖于kernel32.dll,这意味着它在所有进程的内存中都存在。如果一个程序能够定位到kernel32.dll,那么可以遵循一个相对简单的过程来定位该 DLL 中的任何函数,包括LoadLibraryGetProcAddress。如前所示,拥有这两个函数后,就可以加载进程所需的任何附加库,并在这些库中找到所有必需的函数。

在他的论文《理解 Windows Shellcode》中,Skape 讨论了执行此操作的技术。^(6) 虽然 tElock 并未使用 Skape 详细描述的具体技术,但两者之间有许多相似之处,最终的效果是掩盖加载和链接过程的细节。如果没有仔细追踪程序的指令,很容易忽略库的加载或函数地址的查找。以下小段代码展示了 tElock 尝试定位 LoadLibrary 地址的方式:

0041d1e4  CMP    dword ptr [EAX],0x64616f4c

0041d1ea  JNZ    LAB_0041d226

0041d1ec  CMP    dword ptr [EAX + 0x4],0x7262694c

0041d1f3  JNZ    LAB_0041d226

0041d1f5  CMP    dword ptr [EAX + 0x8],0x41797261

0041d1fc  JNZ    LAB_0041d226

很明显,多个比较操作迅速接连进行。可能不太明显的是这些比较的目的。重新格式化每个比较中使用的操作数(右键点击,然后选择 转换字符序列)能稍微揭示一些代码的含义,如下所示的清单所示。

0041d1e4  CMP    dword ptr [EAX],"Load"

0041d1ea  JNZ    LAB_0041d226

0041d1ec  CMP    dword ptr [EAX + 0x4],"Libr"

0041d1f3  JNZ    LAB_0041d226

0041d1f5  CMP    dword ptr [EAX + 0x8],"aryA"

0041d1fc  JNZ    LAB_0041d226

每个十六进制常量实际上是一组四个 ASCII 字符,Ghidra 能够将其显示为引用的 ASCII 字符,并一起拼写出 LoadLibraryA。^(7) 如果三个比较成功,tElock 就找到了 LoadLibraryA 的导出表项,并通过几次简单的操作获取该函数的地址,以加载更多的库。tElock 的函数查找方式在一定程度上抵抗字符串分析,因为嵌入程序指令中的 4 字节常量不像标准的以 null 终止的字符串,因此除非你更改默认设置(例如,在字符串搜索时取消勾选“需要 null 终止”选项),否则这些常量不会被包含在 Ghidra 生成的字符串列表中。

通过仔细分析程序代码手动重建程序的导入表,在 UPX 和 tElock 的情况下会更加容易,因为最终,这两者都包含 ASCII 字符数据,你可以利用这些数据来准确确定所引用的库和函数。Skape 的论文详细描述了一个函数解析过程,其中代码中根本没有出现任何字符串。论文中讨论的基本思路是为每个需要解析的函数名称预先计算一个唯一的哈希值。^(8) 要解析每个函数,你可以在一个库的导出名称表中进行查找。表中的每个名称都被哈希化,你可以将生成的哈希值与预先计算的哈希值进行比较。如果哈希值匹配,你就找到了所需的函数,并可以轻松地在库的导出地址表中定位其地址。

要静态分析以这种方式混淆的二进制文件,你需要理解每个函数名使用的哈希算法,并将该算法应用于程序正在搜索的库所导出的所有名称。有了完整的哈希表,你可以简单地查找在程序中遇到的每个哈希,以确定该哈希所引用的函数。以下是为kernel32.dll生成的哈希表的一部分,可能像这样:

➊ GetProcAddress : 8A0FB5E2

  GetProcessAffinityMask : B9756EFE

  GetProcessHandleCount : B50EB87C

  GetProcessHeap : C246DA44

 GetProcessHeaps : A18AAB23

  GetProcessId : BE05ED07

请注意,哈希值是特定于在特定二进制文件中使用的哈希函数的,且可能在不同的二进制文件之间有所不同。使用这个特定的表格,如果在程序中遇到哈希值8A0FB5E2 ➊,你可以快速确定该程序试图查找GetProcAddress函数的地址。

Skape 使用哈希值来解析函数名最初是为 Windows 漏洞的利用负载开发和记录的;然而,它们也已被用于混淆程序中。

反动态分析技术

过去几节中介绍的反静态分析技术对程序是否真正执行没有任何影响。实际上,虽然反静态分析技术可能使你仅使用静态分析技术难以理解程序的真实行为,但它们无法阻止程序执行,否则程序从一开始就会变得无法使用,从而根本不需要分析程序。

鉴于程序必须运行才能执行任何有用的工作,动态分析旨在观察程序的运行行为(即在程序运行时),而不是观察程序静止时的状态(即在程序未运行时使用静态分析)。在本节中,我们简要总结了几种常见的反动态分析技术。在大多数情况下,这些技术对静态分析工具几乎没有影响;然而,当有重叠时,我们会指出这一点。

检测虚拟化

沙箱环境通常使用虚拟化软件,如 VMware,为恶意软件(或任何其他感兴趣的软件)提供执行环境。这种环境的优势是它们通常提供检查点和回滚功能,有助于将沙箱迅速恢复到已知的干净状态。主要的缺点是恶意软件可能能够检测到沙箱。在假设虚拟化等同于观察的情况下,许多希望保持隐匿的程序一旦确定自己正在虚拟机中运行,就会直接关闭。鉴于虚拟化在生产中的广泛使用,这一假设今天已经不如历史上那么有效。

以下列表描述了一些在虚拟化环境中运行的程序所使用的技术,这些技术用于判断它们是否在虚拟机中运行,而不是在本地硬件上运行:

检测虚拟化特定的软件

用户通常会在虚拟机中安装辅助应用程序,以促进虚拟机与其主机操作系统之间的通信,或仅仅是为了提高虚拟机内的性能。VMware Tools 集合就是这类软件的一个例子。此类软件的存在很容易被虚拟机内运行的程序检测到。例如,当 VMware Tools 被安装到 Microsoft Windows 虚拟机中时,它会创建 Windows 注册表键,这些键可以被任何程序读取。恶意软件检测到这些键时,可能会选择在展示任何显著行为之前关闭自己。另一方面,虚拟化如今已被广泛使用,因此在没有安装 VMware Tools 的 VMware 镜像,可能在恶意软件看来同样令人怀疑。

检测虚拟化特定的硬件

虚拟机使用虚拟硬件抽象层提供虚拟机与主机计算机本地硬件之间的接口。虚拟硬件的特征通常很容易被运行在虚拟机内的软件检测到。例如,VMware 已为其虚拟化的网络适配器分配了独有的组织标识符(OUI)。^(9) 观察到 VMware 特定的 OUI 是程序正在虚拟机中运行的一个良好指示。因而,因这个原因而关闭的程序可能通过修改与虚拟机相关的虚拟网络适配器的 MAC 地址来诱导其执行。

检测处理器特定的行为变化

完美的虚拟化是难以实现的。理想情况下,一个程序不应该能察觉虚拟化环境与本地硬件之间的任何区别。然而,这种情况很少发生。Joanna Rutkowska 在观察到本地硬件上 x86 sidt 指令与在虚拟机环境中执行相同指令之间的行为差异后,开发了她的 Red Pill VMware 检测技术。^(10)

检测工具

在创建沙盒环境并执行任何程序之前,您需要确保已部署适当的工具来收集和记录有关您分析的程序行为的信息。有多种工具可用于执行此类监控任务。两个广泛使用的例子是来自微软 Sysinternals 组的进程监视器Wireshark。11 进程监视器是一个实用工具,能够监控与任何正在运行的 Windows 进程相关的某些活动,包括访问 Windows 注册表和文件系统活动。Wireshark 是一个网络数据包捕获和分析工具,通常用于分析恶意软件生成的网络流量。

拥有足够偏执的恶意软件作者可能会将其软件编程为搜索运行中的此类监控程序实例。已采用的技术包括扫描活动进程列表,查找与此类监控软件相关的进程名称,或扫描所有活动 Windows 应用程序的标题栏文本,以搜索已知字符串。更深入的搜索可以执行,一些软件甚至会搜索与某些仪器化软件中使用的 Windows GUI 组件相关的特定特征。

检测调试器

超越简单的程序观察,调试器允许分析人员完全控制需要分析的程序的执行。调试器通常用于运行一个混淆程序,直到完成任何解压或解密任务,然后利用调试器的内存访问功能从内存中提取去混淆的进程映像。在大多数情况下,可以使用标准的静态分析工具和技术来完成对提取进程映像的分析。

混淆工具的作者充分意识到这些调试器辅助的去混淆技术,因此他们已经采取措施,试图阻止使用调试器执行其混淆程序。检测到调试器存在的程序通常选择终止,而不是继续进行任何可能让分析人员确定程序行为的操作。

检测调试器的方法从通过知名 API 函数(如 Windows IsDebuggerPresent 函数)向操作系统发出简单查询,到检查由于使用调试器而产生的内存或处理器伪影。后者的一个例子包括检测处理器的跟踪(单步)标志是否已设置。

只要你知道应该查找什么,试图检测调试器其实并不复杂,而且在静态分析过程中,检测的尝试很容易被发现(除非同时使用反静态分析技术)。有关调试器检测的更多信息,请参阅文章《带有示例的反调试检测技术》,它提供了 Windows 反调试技术的全面概述。^(12)

防止调试

即便是无法检测到的调试器,也可以通过一些额外的技术来阻碍,尝试通过引入虚假的断点、清除硬件断点、妨碍反汇编使选择合适的断点地址变得困难,或者防止调试器附加到进程来进行干扰。之前提到的反调试文章中讨论的许多技术,都是为了防止调试器正确运行。

故意生成异常是程序可能尝试阻碍调试的一种方式。在大多数情况下,附加的调试器会捕获到异常,调试器的用户必须分析异常发生的原因,并决定是否将异常传递给正在调试的程序。在如 x86 INT 3 这样的软件断点的情况下,很难区分是由底层程序生成的软中断,还是由实际的调试器断点引起的。正是这种混淆效果是混淆程序的创建者所希望达到的效果。在这种情况下,尽管更困难,但仍然可以通过仔细分析反汇编列表来理解真实的程序流程。

对程序的编码部分具有双重效果:一方面会阻碍静态分析,因为无法进行反汇编;另一方面会妨碍调试,因为设置断点变得困难。即使已知每条指令的起始位置,也无法在指令实际解码之前设置软件断点,因为通过插入软件断点修改指令可能导致混淆代码解密失败,从而在执行到预定断点时导致程序崩溃。

用于 Linux 的 Shiva ELF 混淆工具采用一种叫做互相 ptrace的技术来防止使用调试器分析 Shiva 的行为。

进程追踪

ptrace,或称进程跟踪(process tracing),是许多类 Unix 系统提供的 API,它允许一个进程监视并控制另一个进程的执行。GNU 调试器(gdb)是使用 ptrace API 的著名应用之一。通过 ptrace API,一个 ptrace 父进程可以附加到并控制一个 ptrace 子进程的执行。为了开始控制一个进程,父进程必须首先附加到它想要控制的子进程。一旦父进程附加,子进程在接收到信号时会被停止,父进程通过 POSIX 的wait函数接收到这个通知,父进程可以选择在指示子进程继续执行之前,修改或检查子进程的状态。一旦父进程附加到子进程,其他进程就无法再附加到该子进程,直到跟踪父进程选择从子进程上分离。

Shiva 利用了每次只能有一个其他进程附加到一个进程的事实。在其执行的早期,Shiva 进程会派生出一个自己的副本。原始的 Shiva 进程立即对新派生的子进程执行 ptrace 附加操作。新派生的子进程则立即附加到其父进程。如果任何一次附加操作失败,Shiva 会终止,并假设有调试器正在监视 Shiva 进程。如果两次附加操作都成功,其他调试器无法附加到正在运行的 Shiva 对,这样 Shiva 就能继续运行,而不必担心被观察到。在这种方式下运行时,任一 Shiva 进程可以更改另一个进程的状态,这使得使用静态分析技术很难确定 Shiva 二进制文件的确切控制流路径。

使用 Ghidra 进行二进制文件的静态去混淆

到这时,你可能会想,考虑到所有可用的反逆向工程技术,如何分析那些程序员有意隐瞒的软件呢?由于这些技术既针对静态分析工具,也针对动态分析工具,那么揭示程序隐藏行为的最佳方法是什么呢?不幸的是,没有一种解决方案能够适用于所有情况。

在大多数情况下,解决方案取决于你的技能和可用的工具。如果你选择的分析工具是调试器,你需要开发绕过调试器检测和防护措施的策略。如果你偏好的分析工具是反汇编器,你需要开发获取准确反汇编结果的策略,并且在遇到自修改代码的情况下,需要模拟该代码的行为,以便正确更新反汇编列表。

在这一节中,我们讨论了两种在静态分析环境中处理自修改代码的技术(即不执行代码的情况下)。静态分析可能是您的唯一选择,当您因敌意代码而不愿意(或因缺乏硬件而无法)在调试器控制下分析程序时。如果这些概念让您觉得自己正在陷入一个困境,不要气馁。Ghidra 具有一些秘密(或者说不那么秘密的)武器,我们可以在静态解混淆的军备竞赛中加以利用。

面向脚本的解混淆

由于 Ghidra 可以用于反汇编针对越来越多的处理器开发的二进制文件,因此分析为与您运行 Ghidra 的平台完全不同的另一个平台开发的二进制文件并不罕见。例如,您可能被要求分析一个 Linux x86 二进制文件,即使您恰好在 macOS 上运行 Ghidra,或者您可能被要求分析一个 MIPS 或 ARM 二进制文件,即使您在 x86 平台上运行 Ghidra。

在这种情况下,您可能无法访问合适的工具,例如调试器,用于动态分析二进制文件。当这样的二进制文件经过程序部分编码的混淆时,您可能别无选择,只能创建一个 Ghidra 脚本,模拟程序的解混淆阶段,以正确解码程序并反汇编解码后的指令和数据。

这看起来可能是一个令人生畏的任务;然而,在许多情况下,混淆程序的解码阶段只使用处理器指令集的一小部分,因此,熟悉必要的操作可能不需要理解目标处理器的整个指令集。

第十四章介绍了一种开发脚本以模拟程序部分行为的算法。在接下来的示例中,我们将利用这些步骤开发一个简单的 Ghidra 脚本,以解码一个使用 Burneye ELF 加密工具加密的程序。在我们的示例程序中,执行从第 21-3 节中的指令开始。

➊ 05371035  PUSH   dword ptr [DAT_05371008]

➋ 0537103b  PUSHFD

➌ 0537103c  PUSHAD

➍ 0537103d  MOV    ECX,dword ptr [DAT_05371000]

  05371043  JMP    LAB_05371082

  ...

          LAB_05371082                         XREF[1]:     05371043(j)

➎ 05371082  CALL   FUN_05371048

  05371087  SHL    byte ptr [EBX + -0x2b],1

  0537108a  PUSHFD

  0537108b  XCHG   byte ptr [EDX + -0x11],AL

  0537108e  POP    SS

  0537108f  XCHG   EAX,ESP

  05371090  CWDE

  05371091  AAD    0x8e

  05371093  PUSH   ECX

➏ 05371094  OUT    DX,EAX

  05371095  ADD    byte ptr [EDX + 0xa81bee60],BH

  0537109b  PUSH   SS

  0537109c  RCR    dword ptr [ESI + 0xc],CL

  0537109f  PUSH   CS

  053710a0  SUB    AL,0x70

  053710a2  CMP    CH,byte ptr [EAX + 0x6e]

  053710a5  CMP    dword ptr [DAT_cbd35372],0x9c38a8bc

  053710af  AND    AL,0xf4

  053710b1  SBB    EBP,ESP

  053710b4  POP    DS

➐ 053710b5  ??    C6h

第 21-3 节:Burneye 启动序列和混淆代码

程序开始时将内存位置 05371008h 的内容压入栈中 ➊,然后压入处理器标志 ➋ 和所有处理器寄存器 ➌。这些指令的目的尚不清楚,因此我们将这些信息暂时记录下来以备后用。接下来,ECX 寄存器被加载了内存位置 05371000h 的内容 ➍。根据第十四章中提出的算法,我们需要在这一点上声明一个名为 ECX 的变量,并通过 Ghidra 的 getInt 函数从内存中初始化它,如下所示:

int ECX = getInt(toAddr(0x5371000));    // from instruction 0537103d

在绝对跳转后,程序调用函数FUN_05371048 ➎,并将地址05371087h(返回地址)压入栈中。紧跟着CALL指令的反汇编指令开始变得越来越不合常理。OUT指令 ➏通常不出现在用户空间代码中,而 Ghidra 无法反汇编位于地址053710B5h的指令 ➐。这些都表明该二进制文件可能存在问题(另外,符号树中只列出了两个函数:entryFUN_05371048)。

此时,分析需要继续进行,接下来的函数调用是FUN_05371048,如 Listing 21-4 所示。

  FUN_05371048                                 XREF[1]:     entry:05371082(c)

➊ 05371048  POP    ESI

➋ 05371049  MOV    EDI,ESI

➌ 0537104b  MOV    EBX,dword ptr [DAT_05371004] = C09657B0h

  05371051  OR     EBX,EBX

➍ 05371053  JZ     LAB_0537107f

➎ 05371059  XOR    EDX,EDX

        ➏ LAB_0537105b                         XREF[1]:  0537107d(j)

  0537105b  MOV    EAX,0x8

        ➐ LAB_05371060                         XREF[1]:  05371073(j)

  05371060  SHRD   EDX,EBX,0x1

  05371064  SHR    EBX,1

  05371066  JNC    LAB_05371072

  0537106c  XOR    EBX,0xc0000057

          LAB_05371072                         XREF[1]:  05371066(j)

  05371072  DEC    EAX

  05371073  JNZ    LAB_05371060

  05371075  SHR    EDX,0x18

  05371078  LODSB  ESI

  05371079  XOR    AL,DL

  0537107b  STOSB  ES:EDI

  0537107c  DEC    ECX

  0537107d  JNZ    LAB_0537105b

          LAB_0537107f                         XREF[1]:  05371053(j)

  0537107f  POPAD

  05371080  POPFD

  05371081  RET

Listing 21-4: 主 Burneye 解码函数

这不是一个典型的函数:它开始时立即将返回地址从栈中弹出到ESI寄存器 ➊。回想一下,保存的返回地址是05371087h,并考虑到EDI ➋、EBX ➌和EDX ➎的初始化,我们的脚本扩展为如下:

int ECX = getInt(toAddr(0x5371000));   // from instruction 0537103D

int ESI = 0x05371087;                  // from instruction 05371048

int EDI = ESI;                         // from instruction 05371049

int EBX = getInt(toAddr(0x5371004));   // from instruction 0537104B

int EDX = 0;                           // from instruction 05371059

在这些初始化之后,函数会先对EBX寄存器中的值进行测试 ➍,然后进入外部循环 ➏和内部循环 ➐。函数的剩余逻辑在以下完成的脚本中得到了体现。脚本中使用注释将脚本操作与前述反汇编列表中的相应操作关联起来。

public void run() throws Exception {

   int ECX = getInt(toAddr(0x5371000));   // from instruction 0537103D

   int ESI = 0x05371087;                  // from instruction 05371048

   int EDI = ESI;                         // from instruction 05371049

   int EBX = getInt(toAddr(0x5371004));   // from instruction 0537104B

   if (EBX != 0) {                        // from instructions 05371051 

                                          //   and 05371053

      int EDX = 0;                        // from instruction 05371059

      do {

         int EAX = 8;                     // from instruction 0537105B

         do {

                                          // mimic x86 shrd instruction

                                          //   using several operations

            EDX = EDX >>> 1;              // unsigned shift right one bit

            int CF = EBX & 1;             // remember the low bit of EBX

            if (CF == 1) {                // CF represents the x86 carry flag

               EDX = EDX | 0x80000000;    // shift in low bit of EBX if it's 1

            }

            EBX = EBX >>> 1;              // unsigned shift right one bit

            if (CF == 1) {                // from instruction 05371066

               EBX = EBX ^ 0xC0000057;    // from instruction 0537106C

            }

            EAX--;                        // from instruction 05371072

         } while (EAX != 0);              // from instruction 05371073

         EDX = EDX >>> 24;                // unsigned shift right 24 bits

      ➊ EAX = getByte(toAddr(ESI));      // from instruction 05371078

         ESI++;

         EAX = EAX ^ EDX;                 // from instruction 05371079

         clearListing(toAddr(EDI));       // clear byte so we can change it

      ➋ setByte(toAddr(EDI), (byte)EAX); // from instruction 0537107B

         EDI++;

         ECX--;                           // from instruction 0537107C

      } while (ECX != 0);                 // from instruction 0537107D

   }

}

每当你尝试模拟一条指令时,应该特别注意数据大小和寄存器别名。在这个例子中,我们需要选择合适的数据大小和变量,以正确实现 x86 的LODSB(加载字符串字节)和STOSB(存储字符串字节)指令。这些指令分别对EAX寄存器的低 8 位进行写入(LODSB)和读取(STOSB),^(13)而不改变上 24 位。在 Java 中,除了通过各种按位操作来掩码并重新组合变量的部分外,无法将变量分割为位大小的部分。具体来说,对于LODSB指令 ➊,一个更忠实的模拟应如下所示:

EAX = (EAX & 0xFFFFFF00) | (getByte(toAddr(ESI)) & 0xFF);

这个例子首先清除EAX变量的低 8 位,然后通过OR操作将新的低 8 位值合并到其中。在 Burneye 解码示例中,每次外部循环开始时,整个EAX寄存器被设置为 8,这样就会将EAX的上 24 位置零。因此,我们决定简化LODSB ➊的实现,忽略赋值对EAX上 24 位的影响。对于STOSB ➋的实现无需过多思考,因为setByte函数要求我们将第二个参数转换为byte

执行 Burneye 解码脚本后,我们的反汇编将反映所有那些通常在混淆程序在 Linux 系统上执行时才可观察到的变化。如果解混淆过程顺利进行,我们很可能会在 Ghidra 的“搜索 ▸ 字符串...”选项中看到更多清晰的字符串。为了观察这一点,你可能需要在字符串搜索窗口中选择刷新图标。

剩下的任务包括:(1) 确定解码函数将返回到哪里,因为它在函数的第一条指令中弹出了返回地址,(2) 引导 Ghidra 正确地显示解码后的字节值,作为指令或数据,具体取决于情况。Burneye 解码函数以以下三条指令结束:

0537107f  POPAD

05371080  POPFD

05371081  RET

回想一下,该函数从弹出自己的返回地址开始,这意味着剩余的栈值是由调用者设置的。这里使用的POPADPOPFD指令是与 Burneye 启动例程开头使用的PUSHADPUSHFD指令相对的,正如这里所示:

       entry

➊ 05371035  PUSH   dword ptr [DAT_05371008]

  0537103b  PUSHFD

  0537103c  PUSHAD

最终结果是,栈上唯一剩下的值是第一行entry ➊推送的那个值。Burneye 解码例程会返回到这个位置,接下来对 Burneye 保护的二进制文件的进一步分析也必须从这个位置继续。

上面的示例可能让人觉得编写一个脚本来解码或解开一个混淆的二进制文件是一件相对容易的事。对于 Burneye 来说,这是真的,因为它并没有使用非常复杂的初始混淆算法。像 ASPack 和 tElock 这类更复杂的工具的解混淆代码,使用 Ghidra 实现时需要更多的工作。

基于脚本的解混淆的优点包括:被分析的二进制文件从不需要执行,而且有可能创建一个功能完整的脚本,而无需完全理解解混淆所使用的确切算法。这个后者的说法可能看起来违反直觉,因为似乎在你能够用脚本模拟该算法之前,你需要对解混淆算法有完整的理解。然而,按照这里描述的开发过程以及在第十四章中的说明,你真正需要的是对解混淆过程中涉及的每个处理器指令有完整的理解。通过忠实地使用 Ghidra 实现每个处理器动作,并根据反汇编列表正确地排列每个动作,你将获得一个脚本,能够模拟程序的行为,即使你不完全理解这些动作作为整体所实现的高级算法。

使用基于脚本的方法的缺点之一是脚本相当脆弱。如果去混淆算法因为去混淆工具的升级或通过对混淆工具使用替代命令行设置而发生变化,那么以前对该工具有效的脚本可能需要进行相应修改。例如,虽然可以为使用 UPX 打包的二进制文件开发通用解包脚本,但随着 UPX 的演变,这些脚本需要不断调整。

最后,脚本化去混淆存在缺乏通用解决方案的问题。没有一种超级脚本可以去混淆所有的二进制文件。从某种意义上来说,脚本化去混淆存在与基于签名的入侵检测和杀毒系统相似的缺点。每种新的打包器类型都需要开发新的脚本,并且现有打包器的细微变化可能会导致现有脚本失效。让我们转移焦点,看看一种更通用的去混淆方法。

面向模拟的去混淆

在创建用于执行去混淆任务的脚本时,常常遇到的一个主题是需要模拟处理器的指令集,以便与正在去混淆的程序行为一致。指令模拟器使我们能够将这些脚本执行的一部分或全部工作转交给模拟器,从而大幅减少 Ghidra 进行去混淆所需的时间。模拟器可以填补脚本与调试器之间的空白,并且比调试器更具灵活性。例如,模拟器可以在 x86 平台上模拟 MIPS 二进制文件,或者在 Windows 平台上模拟 Linux ELF 二进制文件的指令。

模拟器的功能各不相同。最低要求是模拟器需要处理指令字节流,并有足够的内存用于堆栈操作和处理器寄存器的分配。更为复杂的模拟器可能还会提供对模拟硬件设备和操作系统服务的访问。

Ghidra 的模拟器类

幸运的是,Ghidra 提供了丰富的Emulator类和EmulatorHelper,后者提供了常见模拟器功能的高级抽象,并便于快速轻松地创建模拟脚本。在第十八章中,我们介绍了 p-code 作为底层汇编的中间表示,并描述了它如何使反编译器能够针对多种目标架构进行工作。同样,p-code 也支持模拟器功能,Ghidra 的ghidra.pcode.emulate.Emulate类提供了模拟单个 p-code 指令的能力。

我们可以使用 Ghidra 的模拟器相关类来构建模拟器,允许我们模拟多种处理器类型。与 Ghidra 的其他包和类一样,这个功能在随 Ghidra 提供的 Javadoc 文档中有详细说明,并且可以通过点击脚本管理器窗口中的红色加号工具调出作为参考。如果你有兴趣编写模拟器,建议你查看以下示例中使用的模拟器方法的 Javadoc 文档。

破解我,自己破解

crackme 是逆向工程师为逆向工程师构建的谜题。这个名字来源于破解一段软件以绕过复制或使用限制——这是逆向工程技能的一种恶意用途。破解题为练习这些技能提供了合法手段,也为破解题的作者和分析破解题的人提供了展示其才华的机会。

一种常见的破解题风格是接受用户输入,对输入进行某种转换,然后将转换后的结果与预计算的输出进行比较。当你尝试解决一个破解题时,通常只有一个包含执行转换的代码和一个用于未知输入的最终输出的已编译可执行文件。破解题的解决方法是推导出用于生成二进制文件中输出的输入,这通常需要理解转换过程到足以推导出逆转换函数。

示例:SimpleEmulator

假设我们有一个与以下破解挑战相关的二进制文件,其中在文件开头有一些编码内容,这些内容最终作为函数体的一部分。在这个示例中,我们构建了一个模拟器脚本来自动化解码解决破解问题所需的信息的过程:

➊ unsigned char check_access[] = {

      0xf0, 0xed, 0x2c, 0x40, 0x2c, 0xd8, 0x59, 0x26, 0xd8,

      0x59, 0xc1, 0xaa, 0x31, 0x65, 0xaa, 0x13, 0x65, 0xf8, 0x66

  };

 unsigned char key = 0xa5;

  void unpack() {

      for (int ii = 0; ii < sizeof(check_access); ii++) {

        ➋ check_access[ii] ^= key;

      }

  }

  void do_challenge() {

      int guess;

      int access_allowed;

      int (*check_access_func)(int);

    ➌ unpack();

      printf("Enter the correct integer: ");

      scanf("%d", &guess);

      check_access_func = (int (*)(int))check_access;

      access_allowed = check_access_func(guess)➋;

      if (access_allowed) {

          printf("Access granted!\n");

      } else {

          printf("Access denied!\n");

      }

  }

  int main() {

      do_challenge();

      return 0;

  }

即使源代码可用,这个破解题仍然需要一些努力才能解决,因为它包含了编码内容 ➊。Ghidra 的反编译器常常是解决破解题的得力伙伴,但这个例子有一些有趣的特性使得过程变得复杂。Ghidra 只能看到编码后的函数体,但在解决挑战之前,我们需要了解函数的实际功能。在运行时,unpack ➌ 函数调用会解码 check_access ➋ 函数,然后 check_access 才会被调用 ➍。这个破解题的答案是混淆的,我们可以在 Ghidra 中构建一个模拟器脚本来帮助我们攻克这个挑战。与前一个示例不同,这个模拟器不仅仅能为这个特定案例解决问题,而是能够模拟一些任意的代码。

步骤 1:定义问题

我们的任务是设计和开发一个简单的模拟器,允许我们选择反汇编区域并模拟该区域的指令。该模拟器需要被添加到 Ghidra 中,并作为脚本可用。例如,如果我们为 crackme 挑战选择unpack函数并运行脚本,我们的模拟器应该使用密钥来解包check_access数组,并告诉我们 crackme 挑战的解决方案。该脚本将把解包后的代码字节写入 Ghidra 程序的内存中。

步骤 2:创建 Eclipse 脚本项目

我们可以使用 GhidraDev ▸ New ▸ Ghidra 脚本项目创建一个名为SimpleEmulator的项目。这样,我们将在 Eclipse 中创建一个SimpleEmulator文件夹,并在其中创建一个名为Home scripts的文件夹(参见图 15-16,位于第 325 页),等待我们的新脚本。我们仍然需要创建实际的脚本并输入相关的元数据,以确保我们的脚本有文档并可以被分类。脚本创建对话框中收集的元数据将包含在文件中,正如图 21-7 所示,我们只需要做一件事:在此处添加脚本代码

image

图 21-7:SimpleEmulator 的脚本模板

步骤 3:构建模拟器

我们知道,Eclipse 在我们开发代码时,如果需要导入内容,会自动推荐导入语句,因此我们可以直接开始编码任务,并在 Eclipse 检测到我们需要时添加推荐的import语句。在功能实现方面,我们将在整个SimpleEmulator类中依赖以下实例变量声明:

private EmulatorHelper emuHelper;   // EmulatorHelper member variable object

private Address executionAddress;   // Initially the start of the selection

private Address endAddress;         // End of the selected region

与每个声明相关的注释描述了每个变量的目的。executionAddress最初将被设置为选定范围的起始地址,但也将用于在选择区域中推进。

步骤 3-1:设置模拟器

我们将在脚本的run方法中做的第一件事是实例化我们的模拟器辅助对象,并激活对模拟器中任何内存写入操作的跟踪,以便我们可以将更新后的值写回当前程序。实例化操作充当了一个锁,类似于 CodeBrowser 在打开二进制文件时所施加的锁:

emuHelper = new EmulatorHelper(currentProgram);

emuHelper.enableMemoryWriteTracking(true);
步骤 3-2:选择要模拟的地址范围

因为我们希望用户选择要模拟的代码段,所以我们需要确保他们在列表窗口中选择了某些内容。如果没有选择,我们将生成错误消息。

if (currentSelection != null) {

    executionAddress = currentSelection.getMinAddress();

    endAddress = currentSelection.getMaxAddress().next();

} else {

    println("Nothing selected");

    return;

}
步骤 3-3:准备模拟

在选择的区域内,我们希望确保查看到的是一条指令,以便建立初始的处理器上下文,初始化堆栈指针,并在选定区域的末尾设置一个断点。continuing标志指示我们是刚开始模拟还是继续模拟,并决定在步骤 3-4 中调用emuHelper.run的哪个版本:

Instruction executionInstr = getInstructionAt(executionAddress);

if (executionInstr == null) {

    printerr("Instruction not found at: " + executionAddress);

    return;

}

long stackOffset = (executionInstr.getAddress().getAddressSpace().

                    getMaxAddress().getOffset() >>> 1) - 0x7fff;

emuHelper.writeRegister(emuHelper.getStackPointerRegister(), stackOffset);

// Setup breakpoint at the end address

emuHelper.setBreakpoint(endAddress);

// Set continuing to false as we are just starting the emulation

boolean continuing = false;;
步骤 3-4:执行模拟

在本节中,您应该能认识到一些 Ghidra API 函数的使用,这些函数在 第十四章 中有介绍(例如 monitor.isCancelled)。我们需要一个循环来驱动仿真,直到达到我们定义的终止条件:

➊ while (!monitor.isCancelled() &&

         !emuHelper.getExecutionAddress().equals(endAddress)) {

      if (continuing) {

 emuHelper.run(monitor);

      } else {

          emuHelper.run(executionAddress, executionInstr, monitor);

      }

    ➋ executionAddress = emuHelper.getExecutionAddress();

      // determine why the emulator stopped, and handle each possible reason

    ➌ if (emuHelper.getEmulateExecutionState() ==

          EmulateExecutionState.BREAKPOINT) {

          continuing = true;

      } else if (monitor.isCancelled()) {

          println("Emulation cancelled at 0x" + executionAddress);

          continuing = false;

      } else {

          println("Emulation Error at 0x" + executionAddress +

                  ": " + emuHelper.getLastError());

          continuing = false;

      }

    ➍ writeBackMemory();

      if (!continuing) {

          break;

      }

  }

在这个示例中,模拟将继续进行,直到监视器检测到用户取消操作、我们尚未到达所选指令范围的结束,或未触发错误条件 ➊。当模拟器停止时,我们需要更新当前执行地址 ➋,然后适当处理停止条件 ➌。最后一步是调用 writeBackMemory() 方法 ➍。

步骤 3-5:将内存写回程序

writeBackMemory() ➍ 的实现如图所示。此模拟器将在一个解包例程中进行测试,该例程最终会更改内存中的字节。模拟器所做的内存更改仅存在于其工作内存中。内容需要写回到二进制文件,以便列出和其他用户界面能够准确反映通过执行解包例程中的指令所导致的更改。Ghidra 在其 emulatorHelper 中提供了相关功能来简化此过程。

private void writeBackMemory() {

    AddressSetView memWrites = emuHelper.getTrackedMemoryWriteSet();

    AddressIterator aIter = memWrites.getAddresses(true);

    Memory mem = currentProgram.getMemory();

    while (aIter.hasNext()) {

        Address a = aIter.next();

        MemoryBlock mb = getMemoryBlock(a);

        if (mb == null) {

            continue;

        }

 if (!mb.isInitialized()) {

            // initialize memory

            try {

                mem.convertToInitialized(mb, (byte)0x00);

            } catch (Exception e) {

                println(e.toString());

            }

        }

        try {

            mem.setByte(a, emuHelper.readMemoryByte(a));

        } catch (Exception e) {

            println(e.toString());

        }

    }

}
步骤 3-6:清理资源

在此步骤中,我们需要清理资源并释放对当前程序的锁定。这两项操作可以通过一个简单的语句完成:

emuHelper.dispose();

由于此模拟器仅用于演示目的,我们在脚本中做了一些简化。为了节省空间,我们最小化了通常在生产脚本中包含的注释、功能、错误检查和错误处理。现在剩下的只是确认我们的模拟器脚本能够完成我们的目标。

步骤 4:将脚本添加到我们的 Ghidra 安装中

将脚本添加到我们的 Ghidra 安装中只需要将其放置在 Ghidra 可以找到的地方。如果您将脚本项目设置为链接项目,Ghidra 已经知道在哪里找到它。如果您没有链接脚本项目(或如果您在另一个编辑器中创建了模拟器脚本),则需要将其保存在 Ghidra 的某个脚本目录中,具体情况请参见 第十四章。

步骤 5:在 Ghidra 中测试脚本

为了测试脚本,我们将加载与 crackme 挑战源代码相关的二进制文件。当我们加载二进制文件并导航到 unpack 函数时,注意到它包含对 check_access 标签的引用:

0010077d 48 8d 05 8c 08 20 00  LEA    RAX,[check_access]

Decompiler 窗口中的代码包含以下内容,这并没有让我们更接近解决 crackme:

check_access[(int)local_c] = check_access[(int)local_c] ^ key;

在 Listing 窗口中双击 check_access 会将我们引导到地址 00301010,该地址看起来不像是函数内的指令。

00301010 f0 ed 2c 40 2c d8 59  undefined1[19]

         26 d8 59 c1 aa 31 65

         aa 13 65 f8 66

如果我们选择反汇编这个内容,我们会在 Ghidra 中收到坏数据错误。反编译器窗口对此位置也没有任何帮助。所以让我们使用脚本看看能否仿真 unpack 函数。我们选择构成 unpack 函数的指令,打开脚本管理器并运行我们的脚本。我们没有看到 unpack 函数或反编译器窗口中有任何可观察的变化。但是,如果我们导航到 check_access00301010),内容已经发生了变化!

00301010 55 48 89 e5 89 7d     undefined1[19]

         fc 83 7d fc 64 0f

         94 c0 0f b6 c0 5d c3

我们可以清除这些代码字节(快捷键 C),然后反汇编(快捷键 D),并获得以下结果:

     check_access

00301010 55                    PUSH   RBP

00301011 48 89 e5              MOV    RBP,RSP

00301014 89 7d fc              MOV    dword ptr [RBP + -0x4],EDI

00301017 83 7d fc 64           CMP    dword ptr [RBP + -0x4],100

0030101b 0f 94 c0              SETZ   AL

0030101e 0f b6 c0              MOVZX  EAX,AL

00301021 5d                    POP    RBP

00301022 c3                    RET

这里是反编译器窗口中对应的代码:

ulong UndefinedFunction_00301010(int param_1)

{

  return (ulong)(param_1 == 100);

}

这只是一个概念验证脚本,用于展示如何利用仿真器帮助代码去模糊化,但它确实展示了如何通过使用 Ghidra 的仿真器支持类在 Ghidra 中构建一个相对通用的仿真器。还有其他情况下,开发和使用仿真器是合适的做法。与调试相比,仿真有一个直接的优势,即潜在的恶意代码永远不会被仿真器实际执行,而调试器辅助的去模糊化必须至少允许恶意程序的某部分执行,以便获得去模糊化后的程序版本。

总结

目前,模糊化程序在恶意软件中是常态,而非例外。任何尝试研究恶意软件样本内部操作的行为,几乎都需要某种形式的去模糊化。无论你是采用调试器辅助的动态去模糊化方法,还是更倾向于不运行潜在恶意代码而选择使用脚本或仿真来去模糊化二进制文件,最终的目标是生成一个可以完全反汇编并正确分析的去模糊化二进制文件。

在大多数情况下,这一最终分析将使用像 Ghidra 这样的工具进行。考虑到最终目标(使用 Ghidra 进行分析),从头到尾使用 Ghidra 是合理的。本文中介绍的技术旨在展示 Ghidra 能做的不仅仅是生成反汇编清单,接下来的章节将基于此,探讨我们如何使用 Ghidra 来修补我们的反汇编清单。

第二十六章:修补二进制文件

Image

在反向工程一个二进制文件时,你可能会决定修改原始二进制的行为。行为修改通常通过修补二进制文件来完成,方法是插入、删除或修改现有的指令。进行此类修改的动机有很多—其中一些比较有争议—包括以下几点:

  • 修改恶意软件样本以消除防调试技术,防止恶意软件被研究

  • 修补没有源代码的软件漏洞

  • 自定义应用程序的启动画面或字符串内容

  • 修改游戏逻辑以进行作弊

  • 解锁隐藏功能

  • 绕过许可检查或其他反盗版保护措施

本章我们并不打算教授任何不道德的行为,但我们讨论了修改二进制文件以反映你在 Ghidra 中所做更改的高层次挑战。第十四章 介绍了 setByte API 函数,第二十一章 展示了不同风格的仿真脚本如何修改加载到 Ghidra 中的程序内容。这些技术修改了导入到 Ghidra 中的内容,对原始二进制文件没有任何影响。为了完成修补过程,你将学会如何让 Ghidra 将更改写回磁盘上的文件。我们还将讨论不同类型补丁可能带来的挑战。

规划你的补丁

修补过程通常涉及以下步骤:

  1. 确定你打算进行的修补类型。这通常由你修补的理由决定,如前所述。

  2. 确定需要修补的程序位置。这通常需要对程序进行一定的研究和分析。

  3. 规划补丁的内容。内容更改可能需要新的数据、新的机器代码或两者兼具。在任何情况下,你的更改必须经过深思熟虑,以防程序出现任何意外行为。

  4. 使用 Ghidra 替换现有的程序内容(数据或代码)为你的替代内容。

  5. 使用 Ghidra 验证你的更改是否已正确实施。

  6. 使用 Ghidra 将你的更改导出为一个新的二进制文件。

  7. 验证新的二进制文件是否按预期行为运行,必要时重复第 2 步。

在某些修补场景中,许多步骤可能显得几乎微不足道;而在其他场景中,它们将更加具有挑战性。在接下来的章节中,我们将回顾 Ghidra 可以帮助你完成的步骤,并讨论可能将你或 Ghidra 推向极限的情况。我们将从第 2 步开始,回顾 Ghidra 在修补过程中帮助你找到感兴趣项的几种方式。

寻找要更改的内容

补丁的具体性质将决定你需要修补的内容。定制启动画面或字符串需要你定位需要更改的原始数据。改变程序的逻辑需要修改或插入代码来改变程序的行为。在这种情况下,可能需要大量的逆向工程,仅仅是为了找到需要修改的程序位置。Ghidra 的许多功能都可以帮助完成这些任务,前面章节中已经介绍过。让我们回顾一下对于补丁制作有用的一些功能。

搜索内存

当你的补丁涉及修改程序数据时,你识别应用补丁位置的主要方式将是某种形式的内存搜索。最常见的内存搜索是 CodeBrowser 的“搜索 ▸ 内存”菜单选项(快捷键 S),如图 22-1 所示(展开了高级选项)。“搜索内存”对话框在第六章中已讨论过。

image

图 22-1:搜索内存对话框

“搜索内存”对话框在补丁制作的上下文中最为有用,尤其是在你要查找二进制文件中已知的特定数据时,如已知的字符串或十六进制序列。成功的搜索将使所有相关显示重新定位到匹配字节的位置,或者在“搜索所有”情况下,打开一个新对话框,列出所有可以找到匹配内容的地址。对于非常大的二进制文件,可能需要将搜索范围限制在程序中的特定区域(如指令、已定义数据、未定义数据等),通过取消选择任何无关的代码单元类型来减少搜索的范围。

注意

虽然“搜索 ▸ 内存”提供了 Ghidra 中最为灵活的一般性搜索功能,但它是对数据库原始字节内容的搜索,其他类型的搜索可能更适合你正在查找的数据类型。例如,如果你想在你输入到程序中的注释内容中进行搜索,那么“搜索 ▸ 内存”就不适合你。有关在反汇编列表中搜索的更多信息,请参见“搜索程序文本”,第 115 页。

搜索直接引用

在第二十章中,我们使用“搜索 ▸ 直接引用”扫描程序的二进制内容,查找特定地址的所有出现位置。此类搜索最常见的用途是定位指向感兴趣数据的指针,尤其是在 Ghidra 未能为这些数据创建交叉引用时。在补丁制作的上下文中,这通常用于全面理解并更新所有数据或代码位置的引用,以保持补丁二进制文件中代码和数据之间的正确关系。

搜索指令模式

Ghidra 的“搜索 ▸ 查找指令模式”功能通过匹配模式来查找特定的指令序列。定义指令模式时,需要在过于具体的模式和过于通用的模式之间找到微妙的平衡。让我们看一个例子来说明这个概念。假设我们有一个列出包含cleanup_and_exit函数的清单,该函数会退出程序:

   int test_even(int v) {

       return (v % 2 == 0);

   }

   int test_multiple_10(int v) {

       return (v % 10 == 0);

   }

   int test_lt_100(int v) {

       return v < 100;

   }

   int test_gte_20(int v) {

       return v >= 20;

   }

➊ void cleanup_and_exit(int rv, char* s) {

       printf("Result: %s\n", s);

       exit(rv);

   }

   void do_testing() {

       int v;

       srand(time(0));

       v = rand() % 150;

       printf("Testing %d\n", v);

     ➋ if (!test_even(v)) {

           cleanup_and_exit(-1, "failed even test");

       }

       if (test_multiple_10(v)) {

            cleanup_and_exit(-2, "failed not multiple of 10 test");

 }

       if (!test_lt_100(v)) {

           cleanup_and_exit(-3, "failed <100 test");

       }

       if (!test_gte_20(v)) {

           cleanup_and_exit(-4, "failed > 20 test");

       }

       // all tests passed so do interesting work here

     ➌ system("/bin/sh");

       cleanup_and_exit(0, "success!");

   }

   int main() {

       do_testing();

       return 0;

   }

函数do_testing执行一系列测试 ➋。如果任何测试失败,cleanup_and_exit函数 ➊ 会被调用,程序执行结束。如果所有测试都通过,某些非常有趣的代码 ➌ 将被执行。我们的修补挑战是确定需要在哪些地方修补,以确保所有测试都通过,从而使我们能够执行有趣的代码。

如果我们将二进制文件加载到 Ghidra 中,我们可以搜索所有对cleanup_and_exit的调用,以确定需要修补什么,以确保所有测试都通过,无论测试数量如何。我们有几个选项需要考虑:

  • 我们可以直接进入该函数并修补它,使其返回,以便失败的测试不会退出程序,而是继续执行。这不是一个最佳解决方案,因为该函数也用于在程序完成有趣的工作后,合法地退出程序。

  • 我们可以使用搜索功能或 XREFs 来查找cleanup_and_exit。这将给我们所有的调用,但我们只希望修补其中的一些。

  • 我们可以识别出这些调用所共有的指令模式,并使用“搜索 ▸ 查找指令模式”来找到需要修补的正确调用。

要使用此搜索功能,我们需要确定一个有用的模式。我们尝试通过的每个测试在“清单”窗口中都有以下形式:

001008af  CALL   test_even

001008b4  TEST   EAX,EAX

001008b6  JNZ    LAB_001008c9

001008b8  LEA    RSI,[s_failed_even_test_00100a00]

001008bf  MOV    EDI,0xffffffff

001008c4  CALL   cleanup_and_exit

让我们通过选择指令序列并点击“搜索 ▸ 查找指令模式”来尝试搜索该序列。这将自动填充指令模式搜索对话框,如图 22-2 所示。

image

图 22-2:选择所有字段的指令模式搜索对话框

如果我们点击“搜索所有”,我们将看到只有一个结果(即我们在开始搜索时选择的特定位置),如图 22-3 所示。

image

图 22-3:选择所有字段后的指令模式搜索结果

我们的问题是,我们包含了一些在测试案例之间不会保持不变的操作数。例如,第一个调用的操作数是某个特定测试函数的地址。我们可以取消选择任何指令模式中的单个组成部分(助记符和操作数),使其变得更加通用,如图 22-4 所示。任何被取消选择的部分都会在随后的搜索中被视为通配符。

image

图 22-4:选择部分操作数的指令模式搜索对话框

如果我们点击禁用操作数字段的“搜索所有”按钮,我们将看到图 22-5 中显示的三个结果。

image

图 22-5:指令模式搜索结果,部分操作数被取消选择

搜索仍未能识别到对test_multiple_10的调用,该调用使用的是JZ指令而不是JNZ指令。取消选择JNZ指令的助记符字段并重新运行搜索,得到的结果如图 22-6 所示,其中包括了我们希望补丁的四个调用,并且没有包括我们不想补丁的最终调用cleanup_and_exit

image

图 22-6:指令模式搜索结果,带有JNZ并且部分操作数被取消选择

这种搜索功能不仅用于定位补丁候选指令模式。它还可以用于漏洞分析、查找特定功能以及其他搜索,以识别对逆向工程师重要的指令模式。

查找特定行为

程序的行为由它执行的指令和执行这些指令时使用的数据共同定义。当你的补丁任务涉及修改程序行为时,定位你想要修改的确切行为通常比定位你希望更改的数据要困难得多。因为我们永远无法预测编译器可能为任何源代码生成的确切指令序列,所以使用 Ghidra 的自动化搜索功能来精准定位应用代码补丁的位置是具有挑战性的。定位特定行为归根结底还是要通过本书中介绍的各种技术对程序中的函数进行常规分析。

除了对二进制文件中所有函数的仔细分析或从一个熟悉的函数(如main)开始进行调用树的仔细遍历外,识别感兴趣函数的两种最常见方法是依赖于函数的名称(假设二进制文件有符号信息)以及利用来自“感兴趣”数据的交叉引用反向追溯到可能感兴趣的函数。例如,如果我们想定位二进制文件中的身份验证相关函数,我们可以搜索与身份验证相关的常见字符串,如"请输入您的 密码:""身份验证失败"。类似的字符串通常出现在身份验证过程的开头和结尾,找到引用这些字符串的函数可以显著减少我们搜索其他身份验证相关函数的范围。

在这里,可能会导致你找到感兴趣函数的数据的性质,取决于你特定的补丁场景。无论你使用何种方法定位一个函数作为补丁的候选,你都应始终验证该函数是否真正实现了你希望修改的行为。特别是,你应该始终对程序员为函数指定的名称保持警惕,因为函数的行为并不一定与其名称相符。

应用你的补丁

终于,你的辛勤工作和坚持不懈得到了回报,你找到了你希望修改的代码或数据。接下来怎么办?假设你已经开发了要替换到二进制中的内容,并且知道你准确的位置,现在是时候使用 Ghidra 的功能来修改程序了。

首先,你需要考虑的是新内容相对于你要替换的内容的大小。如果新内容的大小小于或等于原始内容的大小,那么你就很顺利,因为你的补丁会适应原始内容的内存占用。然而,当你的补丁大于原始内容时,事情就会变得有点棘手,我们稍后会专门讨论这种情况。

进行基本更改

无论你手头有一堆字节,还是需要借助汇编器的帮助,最终你都需要将内容导入 Ghidra。对于短小的字节序列,你可能会觉得使用 Ghidra 内置的字节编辑器或汇编器更为方便。对于较长的字节序列,你可能会想要自动化处理。接下来的几节将介绍 Ghidra 的一些字节级编辑功能。

字节查看器

Ghidra 字节查看器(窗口 ▸ 字节),如 图 22-7 所示,提供了当前列出位置的原始字节内容的标准十六进制转储视图,并与每个其他链接的窗口同步。

image

图 22-7:Ghidra 字节查看器

字节查看器也可以通过切换编辑模式工具 ➊ 作为十六进制编辑器使用,这是当你需要一次修改几个字节时的一个便捷选项。

不便之处在于,Ghidra 不允许你编辑任何现有指令的一部分。解决这一限制的方法是,在列表窗口中清除相关指令(右键点击清除代码字节或按快捷键 C)。字节查看器选项工具 ➋ 用于打开 图 22-8 所示的对话框,在此你可以自定义字节查看器的显示。

image

图 22-8:字节查看器选项对话框

选择 ASCII 选项会将 ASCII 转储添加到字节查看器中(见 图 22-9),这样在编辑模式下它就会同时充当 ASCII 编辑器。

image

图 22-9:启用 ASCII 转储的字节查看器

一旦你完成了新值的输入,应该退出编辑模式并返回到列表窗口,验证你的更改是否正确。

脚本化你的更改

除非你的补丁非常短,否则在 Ghidra 中修改原始字节的最有效方法是让脚本为你完成。给定一个字节数组形式的补丁,以及补丁的起始地址,以下函数将在 Ghidra 中应用补丁:

public void patchBytes(Address start, byte[] patch) throws Exception {

    Address end = start.add(patch.length);

  ➊ clearListing(start, end);

    setBytes(start, patch);

}

你可以将此功能包含在一个脚本中,该脚本根据你选择的来源创建补丁字节数组(例如,通过声明已初始化的数组或加载文件内容)。clearListing调用 ➊ 是必要的,因为 Ghidra 不允许你修改已经是现有指令或数据项一部分的字节。脚本完成后,你需要手动将补丁字节格式化为代码或数据,并验证补丁的正确性。

使用汇编器

当你想在二进制文件中打补丁时,你很可能会考虑用另一条汇编语言指令替换原有指令(例如,将CALL _exit替换为NOP),这并不一定是错误的,但通常忽略了与补丁代码相关的一些复杂性。实际上,当你准备将补丁应用到程序时,你不能直接粘贴替换的汇编语言语句;你必须粘贴相应的机器码字节,这意味着你可能需要使用汇编器来生成所有替换指令的机器码版本。

一种方法是使用外部编辑器编写替换的汇编语句,使用外部汇编器(例如,nasmas)进行汇编,提取原始机器码^(1),最后将其打补丁到程序中,可能会像之前讨论的那样使用脚本。另一种方法是使用 Ghidra 内置的汇编器功能,可以通过右键单击任何指令并选择“补丁指令”菜单选项来访问。

就像 SLEIGH 规范告诉 Ghidra 如何将机器代码转换为汇编语言一样,它们还使 Ghidra 能够执行汇编到机器代码的转换——也就是说,像一个汇编器一样工作。第一次为特定架构选择“补丁指令”选项时,Ghidra 会基于该架构的 SLEIGH 规范构建一个汇编器。最初,你将看到类似于图 22-10 所示的消息。

image

图 22-10:汇编器评分对话框

Ghidra 的开发者已经对 Ghidra 生成的汇编器的准确性进行了测试。如果一个处理器的汇编器已经过测试,它将被分配以下之一的评分(按准确性递减顺序):铂金、黄金、白银、青铜和差。任何未测试的汇编器将标记为未评分。有关 Ghidra 汇编器评分的更多信息,以及所有可用汇编器的当前评分,可以在 Ghidra 帮助文档中找到。

一旦你关闭汇编器评分对话框,Ghidra 会根据当前处理器的 SLEIGH 规范构建所需的汇编器功能。在等待汇编器构建的过程中,Ghidra 会显示类似于图 22-11 所示的等待对话框。

image

图 22-11:汇编等待对话框

一旦你的汇编器构建完成,Ghidra 会将列表窗口中选中的指令替换为两个文本输入框(见 图 22-12),允许你编辑指令的助记符和操作数。按 ESC 键会丢弃你的更改,在指令汇编之前,按 ENTER 键则会汇编新指令,并用新指令的机器码字节替换原指令的字节。

image

图 22-12:汇编新指令

因为它们源自与对应反汇编器相同的规范,Ghidra 的汇编器识别 Ghidra 列表窗口中使用的相同汇编语法。Ghidra 的汇编器区分大小写,并且在输入新指令时提供自动补全选项。输入指令后,Ghidra 会将你带回正常的列表窗口视图,如果有其他指令需要修改,你可以重新选择“Patch Instruction”。对于小型补丁,Ghidra 的汇编器提供了一种方便的方法,可以同时汇编指令并修改程序。

指令替换陷阱

虽然 Ghidra 的汇编器能迅速修改单个指令,但新的替换指令可能比原指令更短、更长,或者与原指令相同大小。第三种情况,即替换指令和原指令大小相同,并不有趣。(前两种问题仅会出现在没有固定指令大小的架构上,例如 x86。)

考虑第一种情况,其中替换指令比原指令短,如下列表所示:

;BEFORE:

0804851b  83 45 f4 01   ADD➊  dword ptr [EBP + local_10],0x1

0804851f  83 45 f0 01   ADD    dword ptr [EBP + local_14],0x1

;AFTER

0804851b  66➋ 90       NOP➋

0804851d  f4            ??➋   F4h

0804851e  01            ??     01h

0804851f  83 45 f0 01   ADD    dword ptr [EBP + local_14],0x1

;FIXED:

0804851b  66 90         NOP

0804851d  90            NOP➎

0804851e  90            NOP

0804851f  83 45 f0 01   ADD    dword ptr [EBP + local_14],0x1

在这种情况下,一个 4 字节的 ADD 指令 ➊ 被一个 2 字节的 NOP ➌ 替换。Ghidra 的汇编器尽力通过在 x86 NOP 操作码(90)前插入一个 x86 前缀字节(66)➋ 来填充可用空间。不幸的是,替换指令仍然太短,无法完全填补原指令的剩余两个字节 ➍,其中一个转化为 HLT(按 D 键反汇编查看),另一个是 Ghidra 无法反汇编的,表示它不是有效指令。如果你用这种方式修补原始二进制并运行,它几乎肯定会在到达该位置时崩溃。

Ghidra 除了列出中的??字符外,并不会提示可能存在的问题,因为 Ghidra 并不了解你做出此更改的动机,且“正确”的解决方案依赖于你特定的使用案例。如果你在不打算导出的情况下修改列表中的指令,你可以使用 Ghidra 的“穿透覆盖”选项(右键菜单中的选项)来绕过不需要的字节。^(2) 或者,你可以请求 Ghidra 反汇编未定义的字节,但它们不太可能被反汇编成你会发现有用的指令。在这种情况下,最常见的解决方案是用NOP指令替换掉原始指令中多余的字节 ➎,并填充到下一个指令的起始位置。

当你的替换指令比原始指令更长时,它会引入一系列新的挑战,如下所示:

;BEFORE:

08048502 6a 01          PUSH➊ 0x1

08048504 ff 75 f0       PUSH➋ dword ptr [EBP + local_14]

08048507 ff 75 08       PUSH   dword ptr [EBP + param_1]

0804850a e8 51 fe ff ff CALL   read

;AFTER:

08048502 68 00 01 00 00 PUSH➌ 0x100

08048507 ff 75 08       PUSH   dword ptr [EBP + param_1]

0804850a e8 51 fe ff ff CALL   read

在这个例子中,补丁的目标是读取 256(0x100)字节,而不是 1 字节。原始的 2 字节PUSH指令 ➊,将第三个参数(长度参数)推送到read函数的栈上,被一个 5 字节的PUSH指令 ➌替换,以推送一个更大的常量。替换指令中的额外字节完全覆盖了负责read函数第二个参数(读取缓冲区) ➋的原始指令。

结果代码不仅无法为read提供足够的参数,还将一个整数传递给预期为指针的地方。与之前的例子一样,这几乎肯定会导致补丁程序崩溃。解决这个特定补丁问题的潜在方案并不简单,我们将在下一节中讨论。

进行非平凡的修改

当你的补丁大小超过你要替换的指令或数据时,你的工作变得更加复杂。在大多数情况下,这并不意味着你的补丁无法实现,但需要更多的思考和努力来正确实现补丁。在本节中,我们将讨论几种处理“补丁过大”问题的方法,取决于补丁是包含代码还是数据。

超大代码补丁

当你的补丁过大,无法直接覆盖你想要修改的指令时,你只能选择定位或创建一个足够大的未使用区域,将代码补丁放入这个空白区域,然后在原始补丁位置插入一个跳转(称为钩子)来将控制权转移到实际的补丁位置。在大多数情况下,你还需要在替换代码后追加一个跳转,以便将控制权转移回钩子函数中的适当位置。图 22-13 展示了安装了跳转钩子的补丁函数的大致流程。

image

图 22-13:安装补丁的函数

可用的未使用空间必须足够容纳你的超大代码补丁

  • 至少要与补丁一样大

  • 位于运行时可执行的地址上

  • 从文件内容初始化;否则,您的补丁在运行时将无法加载。

寻找大型未使用可执行字节块的最简单方法是检查二进制文件中可能存在的 代码洞。当二进制文件中的可执行部分(如 .text 部分)被填充以遵守可执行文件格式规定的部分对齐要求时,就会形成代码洞。代码洞在 Windows PE 二进制文件中非常常见,因为它们通常要求每个部分的大小都是 512 字节的倍数。

寻找代码洞的第一个地方通常是 .text 部分的末尾。您可以通过在 CodeBrowser 的 Program Trees 窗口中双击该部分名称,然后滚动到 Listing 窗口的末尾,轻松导航到 .text 部分的末尾(或任何其他部分)。

在我们的示例 PE 二进制文件中,Listing 窗口显示了 .text 部分末尾的以下内容:

140012df8 ??     00h

140012df9 ??     00h

140012dfa ??     00h

140012dfb ??     00h

140012dfc ??     00h

140012dfd ??     00h

140012dfe ??     00h

140012dff ??     00h

列表显示了以下内容:

  • 这些字节在 Ghidra 中被标记为未分类(??)。

  • 这些字节已初始化为 00h

  • .text 部分结束于地址 140012dff,这满足文件对齐要求,即该部分的大小是 512 字节的倍数(140012e000x200 的倍数)。

通过向上滚动(或在 CodeBrowser 中选择 I 工具,并将搜索方向设置为“向上”),我们到达了以下位置:

140012cbd POP    RBP

140012cbe RET➊

140012cbf ??     CCh

140012cc0 ??     00h

RET ➊ 是该二进制文件中最后一条有意义的指令,我们现在可以计算该二进制文件代码洞的大小为 0x140012e00 - 0x140012cbf = 0x141(或 321 字节)。这意味着我们可以轻松地将多达 321 字节的新代码补丁到该二进制文件中。假设我们在地址 0x140012cbf 打上新代码补丁,我们需要在二进制文件现有的代码中某个地方补上跳转到 0x140012cbf,以确保执行流最终能够到达我们的补丁。

当找不到代码洞或代码洞不够大以容纳您的补丁时,您需要发挥一些创造力,找到足够的空间来适应您的补丁。根据构建二进制文件时使用的编译选项,您可能能够通过 函数间对齐间隙 来分散您的补丁。函数间对齐间隙是指编译器将每个函数的起始地址对齐到 2 的倍数(通常是 16)时所产生的间隙。当强制函数对齐时,每个函数之间将插入 align / 2 字节,最多插入 align - 1 字节的填充。以下列表显示了两个相邻函数之间的最佳(从补丁的角度来看)对齐间隙(align = 16):

  1400010a0 RET

➊ 1400010a1 ??     CCh

 1400010a2 ??     CCh

  1400010a3 ??     CCh

  1400010a4 ??     CCh

  1400010a5 ??     CCh

  1400010a6 ??     CCh

  1400010a7 ??     CCh

  1400010a8 ??     CCh

  1400010a9 ??     CCh

  1400010aa ??     CCh

  1400010ab ??     CCh

  1400010ac ??     CCh

  1400010ad ??     CCh

  1400010ae ??     CCh

➋ 1400010af ??     CCh

       **************************************************************

       *                   FUNCTION                                 *

       **************************************************************

1400010a1 ➊ 到 1400010af ➋ 的所有字节可以安全地用补丁代码覆盖。

存在一些额外的方法可以将补丁代码压缩到二进制文件中——有些方法涉及扩展现有的程序段,或者完全注入新的程序段。任何以这种方式操作段的技术也要求你更新二进制文件的段头,以确保它们与所做的修改保持一致。因此,这些技术非常依赖于文件格式,并且需要详细了解文件头数据结构。

过大的数据补丁

在某些方面,数据补丁比代码补丁更容易,但在其他方面则更难。对于结构化数据类型,你的主要关注点是结构中每个成员的正确大小和字节顺序,且由于结构的大小是在编译时确定的,因此你不需要担心替换结构的过大问题。在补丁字符串数据时,建议任何替换数据完全适应原始字符串的空间。如果你的新字符串比原始字符串大,你可能幸运地在字符串末尾和下一个数据项之间找到一些填充字节,但你必须小心不要破坏程序依赖的任何数据。如果你的数据根本无法适应原始数据的内存空间,你将不得不为其找到新的位置,但正确地移动数据可能会很困难。

所有全局数据项都通过它们与程序代码或数据段的偏移量来引用。为了重新定位一个数据项,除了找到足够的未使用空间外,你还需要找到每个引用原始数据项的位置,并将其修改为引用新的数据项。Ghidra 的交叉引用功能在识别全局变量的每个引用时非常有用,但它无法识别派生指针(通过指针运算生成的指针)。

一旦你所有的补丁都已输入到 Ghidra 中,并且你对生成的程序列表感到满意,你将需要将更改推送回原始二进制文件,以验证补丁是否按预期工作。

导出文件

为了确认你的更改会对二进制文件的行为产生预期效果,你需要更新原始二进制文件以反映你的更改。在本节中,我们将讨论一些 Ghidra 的导出功能,它们与补丁相关。

Ghidra 的文件 ▸ 导出程序菜单选项提供了将程序信息导出为多种格式的能力。结果导出对话框如 图 22-14 所示。

image

图 22-14:Ghidra 导出对话框

导出对话框也可以通过在项目管理器中右键点击您希望导出的文件并从上下文菜单中选择“导出”来访问。在对话框中,您需要指定导出格式和输出文件位置,并指示是否希望将导出的范围限制为您在 CodeBrowser 中选择的范围。某些导出格式提供额外的选项,以便更细粒度地控制导出过程。

Ghidra 导出格式

Ghidra 支持以下导出格式,尽管其中只有一个(二进制)对于二进制修补特别有用:

Ascii Ascii 导出格式可用于保存程序的文本表示,类似于在 Listing 窗口中显示的内容,并提供选择要包含在输出文件中的字段的选项。

二进制 二进制导出格式会生成一个二进制文件,是修补应用程序时最有用的格式,并且在其独立部分中进行了讨论。

C/C++ C/C++导出格式用于保存反编译器生成的程序源代码表示,并包含所有已知数据类型的声明。此选项也可以从反编译器窗口中访问。

Ghidra Zip 文件 Ghidra zip文件是程序的序列化 Java 对象表示,适用于导入到其他 Ghidra 实例中。

HTML HTML 导出格式生成程序列表的 HTML 表示。与 Ascii 导出器中类似的选项使您可以选择要包含在输出文件中的字段。标签和交叉引用作为超链接表示,提供基本的导航功能,以便于在生成的输出中进行浏览。

Intel Hex Intel Hex 格式定义了一个二进制数据的 ASCII 表示,通常用于编程 EEPROM。

XML XML 导出器以结构化的 XML 格式输出程序内容,并提供选择应包含哪些程序构造在生成文件中的选项。此功能也可用于反编译器窗口中的单个函数,以便于调试函数反编译。尽管 Ghidra 包括相应的 XML 加载器,但该导出器包含以下警告:“警告:XML 是有损的,仅用于将数据传输到外部工具。建议使用 GZF 格式来保存和共享程序数据。”

二进制导出格式

Ghidra 的二进制导出用于将程序的底层二进制内容写入文件。程序的所有已初始化内存块(见窗口 ▸ 内存映射)都会连接在一起,形成输出文件。输出文件是否与导入的原始文件完全相同,取决于用于导入文件的加载器模块。Raw Binary 加载器可以保证重新创建原始输入文件,因为它将原始文件的每个字节加载到一个单独的内存块中。其他加载器可能会或可能不会加载每个文件字节(例如,PE 加载器会,ELF 加载器则不会)。

当需要应用你在 Ghidra 中所做的更改时,你需要确保生成的文件包含你的修补,并且可以执行。如果你正在修补一个 PE 文件,二进制导出将生成修补后的原始二进制版本。同样,如果你使用 Raw Binary 加载器导入了程序,二进制导出也将生成修补后的原始二进制版本。当然,正如在第十七章中讨论的那样,使用 Raw Binary 加载器时,你可能需要手动执行大部分程序的内存布局,因此存在权衡。幸运的是,完全可以编写脚本,解决任何加载器的问题。

脚本辅助导出

我们可以创建一个 Ghidra 脚本,自动保存修补后的程序版本,而不是对每个 Ghidra 加载器进行详尽的测试,以了解加载器创建的内存块是否覆盖了文件字节的整个范围。这个脚本为使用 Ghidra 生成修补文件提供了与加载器无关的功能。无论 Ghidra 当前已知的内存映射布局如何,它都会处理原始文件字节的整个范围。

public void run() throws Exception {

    Memory mem = currentProgram.getMemory();

  ➊ java.util.List<FileBytes> fbytes = mem.getAllFileBytes();

    if (fbytes.size() != 1) {

        return;

    }

  ➋ FileBytes fb = fbytes.get(0);

  ➌ File of = askFile("Choose output file", "Save");

    FileOutputStream fos = new FileOutputStream(of, false);

    writePatchFile(fb, fos);

    fos.close();

}

脚本首先通过获取程序的 FileBytes 列表 ➊ 开始。FileBytes 对象封装了从导入的程序文件中获取的所有字节,并跟踪文件中每个字节的原始值和修改后的值。由于 Ghidra 允许将多个文件导入到一个程序中,因此该脚本仅处理导入到程序中的第一个文件的字节(第一个字节范围) ➋。

在提示输出文件 ➌ 后,FileBytes 对象和打开的 OutputStream 被传递到我们的 writePatchFile 函数中,以处理生成修补后的可执行文件的细节。

为了呈现程序的映射内存视图,Ghidra 加载器可能会像运行时加载器一样处理程序的重定位表条目。此处理的结果是,标记为修正位置的程序位置(具有重定位表条目的位置)会被 Ghidra 修改,从原始文件值更改为正确的重定位值。当我们生成修补后的二进制版本时,我们不希望包括任何 Ghidra 为重定位目的而修改的字节。

接下来的writePatchFile函数首先根据程序的重定位表生成运行时(以及由 Ghidra 修补)时修补的地址集合:

public void writePatchFile(FileBytes fb, OutputStream os) throws Exception {

    Memory mem = currentProgram.getMemory();

    Iterator<Relocation> relocs;

  ➊ relocs = currentProgram.getRelocationTable().getRelocations();

    HashSet<Long> exclusions = new HashSet<Long>();

    while (relocs.hasNext()) {

        Relocation r = relocs.next();

      ➋ AddressSourceInfo info = mem.getAddressSourceInfo(r.getAddress());

        for (long offset = 0; offset < r.getBytes().length; offset++) {

          ➌ exclusions.add(info.getFileOffset() + offset);

        }

    }

  ➍ saveBytes(fb, os, exclusions);

}

在获得程序重定位表的迭代器 ➊ 后,获取每个重定位条目的AddressSourceInfo ➋。

AddressSourceInfo对象提供了程序地址到磁盘文件的映射,以及该文件中的偏移量,从中加载了相应的程序字节。每个重定位字节的文件偏移量被添加到一个偏移量集合 ➌ 中,以便在生成最终的修补文件时忽略它们。该函数通过调用saveBytes函数 ➍ 来写入当前程序文件的最终修补版本。saveBytes函数在以下列表中显示:

public void saveBytes(FileBytes fb, OutputStream os, Set<Long> exclusions)

                      throws Exception {

    long begin = fb.getFileOffset();

    long end = begin + fb.getSize();

  ➊ for (long offset = begin; offset < end; offset++) {

      ➋ int orig = fb.getOriginalByte(offset) & 0xff;

      ➌ int mod = fb.getModifiedByte(offset) & 0xff;

        if (!exclusions.contains(offset) && orig != mod) {

          ➍ os.write(mod);

        }

        else {

          ➎ os.write(orig);

        }

    }

}

该函数遍历整个文件字节范围 ➊,以确定是保存原始字节还是修改后的字节到输出文件。

在每个文件偏移位置,FileBytes类的方法用于获取从导入文件加载的原始字节值 ➋ 和当前字节值 ➌,后者可能已被 Ghidra 或 Ghidra 用户修改。如果原始值与当前值不同 该字节不与重定位条目相关联,则将修改后的字节写入输出文件 ➍;否则,将原始字节写入输出文件 ➎。

为了总结这一部分内容,让我们看一个示例,演示如何修补一个二进制文件并确认补丁按预期运行。

示例:修补二进制文件

让我们看一个示例,演示在上下文中如何修补。假设你有一段恶意软件代码,它检查是否存在调试器,如果检测到调试器,则退出,不允许你查看其行为。以下源代码概述了该功能的一个简单程序:

int is_debugger_present() {

    return ptrace(PTRACE_TRACEME, 0, 0, 0) == -1;

}

void do_work() {

  ➊ if (is_debugger_present()) {

         printf("No debugging allowed - exiting!\n\n");

         exit(-1);

    }

 // do interesting things here

    printf("Confirmed that there is no debugger, so do\n"

           "interesting things here that we don't want\n"

           "analysts to see!\n\n");

}

int main() {

    do_work();

    return 0;

}

该代码检查是否存在调试器 ➊,如果找到了则退出。否则,它继续执行其不轨的操作。以下显示了程序独立运行(没有调试器)的输出:

# ./debug_check_x64

  Confirmed that there is no debugger, so do

  interesting things here that we don't want

  analysts to see!

当程序在调试器下运行时,我们会看到不同的响应:

# gdb ./debug_check_x64

  Reading symbols from ./debug_check_x64...(no debugging symbols found)...done.

  (gdb) run

  Starting program: /ghidrabook/CH22/debug_check_x64

  No debugging allowed - exiting!

  [Inferior 1 (process 434) exited with code 0377]

  (gdb)

如果我们将二进制文件加载到 Ghidra 中,我们会在列表窗口中看到以下内容:

     undefined do_work()

        undefined  AL:1 <RETURN>

001006f8  PUSH   RBP

001006f9  MOV    RBP,RSP

001006fc  MOV    EAX,0x0

00100701  CALL   is_debugger_present

00100706  TEST   EAX,EAX

00100708  JZ     LAB_00100720

0010070a  LEA    RDI,[s_No_debugging_allowed_-_exiting!_001007d8]

00100711  CALL   puts

00100716  MOV    EDI,0xffffffff

0010071b  CALL   exit

   -- Flow Override: CALL_RETURN (CALL_TERMINATOR)

        LAB_00100720

00100720  LEA    RDI,s_Confirmed_that_there_is_no_debug_001008

00100727  CALL   puts

0010072c  NOP

0010072d  POP    RBP

0010072e  RET

反编译窗口提供了以下相应代码:

void do_work(void)

{

  int iVar1;

  iVar1 = is_debugger_present();

  if (iVar1 != 0) {

    puts("No debugging allowed - exiting!\n");

                    /* WARNING: Subroutine does not return */

    exit(-1);

  }

  puts("Confirmed that there is no debugger, so do\n"

       "interesting things here that we don't want\n"

       "analysts to see!\n"

      );

  return;

}

要修补这个二进制文件以绕过检查,可以将对is_debugger_present函数的调用替换为NOP,改变测试条件,或者修改is_debugger_present函数的内容。如果你使用右键菜单中的“Patch Instruction”选项,可以轻松地将JZ替换为JNZ(实际上是翻转条件,使其仅在调试时运行),如[图 22-15 所示。

image

图 22-15:替换JZJNZ后的 Patch Instruction 选项

这将在反编译窗口中产生如下代码:

void do_work(void)

{

  int iVar1;

  iVar1 = is_debugger_present();

  if (iVar1 == 0) {

    puts("No debugging allowed - exiting!\n");

                    /* WARNING: Subroutine does not return */

 exit(-1);

  }

  puts("Confirmed that there is no debugger, so do\n"

       "interesting things here that we don't want\n"

       "analysts to see!\n"

      );

  return;

}

如果我们使用导出脚本将文件导出为二进制并重新运行它,我们会看到以下两个列表,这些列表展示了我们希望通过补丁实现的行为:

# ./debug_check_x64.patched

  No debugging allowed - exiting!

# gdb ./debug_check_x64.patched

  Reading symbols from ./debug_check_x64.patched...(no debugging symbols found)...done.

  (gdb) run

  Starting program: /ghidrabook/CH22/debug_check_x64.patched

  Confirmed that there is no debugger, so do

  interesting things here that we don't want

  analysts to see!

  [Inferior 1 (process 445) exited normally]

  (gdb)

尽管有许多外部工具(例如,VBinDiff)可以用来确认在这个示例中文件仅改变了 1 个字节,但你也可以使用 Ghidra 的内部工具来得出相同的结论。下一章将重点介绍实现这一目标的方法。

总结

无论你补丁二进制文件的具体动机是什么,你的补丁都需要仔细规划和部署。Ghidra 提供了你所需要的一切来规划补丁;通过十六进制编辑、Ghidra 内置的汇编器或脚本来草拟补丁;查看每次更改的效果;以及在生成原始二进制补丁版本之前,可能通过撤销操作来还原更改。下一章将演示如何使用 Ghidra 比较未打补丁和已打补丁的二进制文件版本,并讨论 Ghidra 在更高级的二进制差异比较和版本跟踪方面的功能。

第二十七章:二进制差异和版本跟踪

Image

我们在前几章中向你介绍了 Ghidra 如何帮助你进行逆向工程分析的方式。在这个过程中,我们介绍了许多方法,帮助你转化和注释你的工作,以便记录并便于理解二进制文件。

本章介绍了二进制差异和 Ghidra 的版本跟踪工具,帮助你识别文件和函数之间的相似性和差异,并促进将以前的分析结果应用到新文件中。我们还从三个角度讨论了文件差异:二进制差异、函数比较和版本跟踪。

二进制差异

在前一章中,我们打补丁修改了一个二进制文件,改变了函数的流程,绕过了对exit的调用,通过修改一条指令中的一个字节:JZ(74)改为JNZ(75)。为了确认更改并准确记录修改的内容,我们可以使用外部工具如VBinDiffWinDiff,在字节级别比较这两个文件。然而,要在指令级别进行文件比较,我们需要一个更为复杂的工具:Ghidra 的“程序差异”工具,这个工具可以在列表窗口中使用。一旦计算出差异,它们可以通过定制显示来查看,这些显示旨在突出差异,帮助理解每个更改,并提供根据差异类型采取行动的机会。

若要比较已导入项目并处于相同状态(例如,两个文件都已分析或两个文件都未分析)的两个文件,打开其中一个文件在 CodeBrowser 中,然后选择工具程序差异,并选择项目中的另一个文件进行比较。或者,你可以使用图 23-1 中所示的列表窗口工具图标。这个图标作为切换按钮,打开或关闭程序差异工具。

image

图 23-1:CodeBrowser 差异视图切换图标

在这个示例中,我们将从打开未打补丁的文件版本开始,选择“差异视图”图标,并选择打补丁后的文件版本。这将打开图 23-2 中所示的“确定程序差异”对话框。

image

图 23-2:差异工具的确定程序差异对话框

虽然所有可用的差异字段默认都会被选中,但在这种情况下,选择“字节”是确认补丁是否正确工作的适当选择,因此它是唯一被选中的差异选项。当你点击“确定”时,你可以在列表窗口中看到这两个二进制文件,窗口被分为左右两栏,每个文件显示在其中一栏。默认情况下,列表是同步的,因此在一个栏中导航时,另一个栏也会同步导航。本工具提供了多种方式来浏览检测到的差异,我们将在本章后面介绍这些方法。

当你打开两个文件进行差异比较时,Ghidra 最初将列表视图定位在每个文件的开头。你可以使用列表窗口工具栏上的向下箭头工具(或 CTRL-ALT-N)导航到两个文件之间的第一个差异。为了引起你对不同代码的注意,变化会通过颜色高亮的反汇编行在每个文件的列表窗口中显示,如果差异位于某个函数内,反汇编结果也会在反编译器窗口中显示。(反编译器窗口与两个文件中的第一个文件同步。)导航到第一个检测到的差异时,会显示原始列表中的单字节JZ(74)和第二个列表中的JNZ(75)。

要查看更多细节,请选择窗口差异差异详情。你将在 CodeBrowser 窗口底部的差异详情窗口中看到以下报告:

Diff address range 1 of 1.

Difference details for address range: [ 00100708 - 00100709 ]

Byte Diffs :

    Address   Program1  Program2

    00100708    0x74       0x75

Code Unit Diffs :

    Program1 CH23:/DiffDemo/debug_check_x64 :

            00100708 - 00100709    JZ 0x00100720

                                   Instruction Prototype hash = 16af243b

    Program2 CH23:/DiffDemo/debug_check_x64.patched :

            00100708 - 00100709    JNZ 0x00100720

                                   Instruction Prototype hash = 176d4e0c

第一行显示包含差异的地址范围的数量。在这个例子中,文件中只有一个范围包含差异,因此你可以确定这两个程序仅在一个字节上有所不同。这个简单的例子只是触及了 Ghidra 程序差异工具的能力表面,因此让我们花一些时间来探讨这个工具提供的其他功能。

程序差异工具

图 23-2 顶部的九个选项构成了你的比较基础,你可以选择其中的任何一个或全部。默认情况下,程序差异工具对每个文件的整个程序进行操作。如果你希望将比较限制在特定的地址范围内,你必须在打开工具之前先在第一个文件中高亮选择该范围。一旦你做出了选择并点击“确定”,你看到的分屏列表窗口就叫做程序差异。

程序差异

程序差异视图让你同时查看两个文件。基本上,列表窗口现在有两个列表,左侧一个,右侧一个。当你打开差异详情窗口时,它会出现在 CodeBrowser 窗口的底部。在差异详情窗口中,左侧文件被视为程序 1(你最初打开的文件),而右侧文件被视为程序 2(你选择与程序 1 进行比较的文件)。反编译器窗口反映的是程序 1 的内容。当你比较两个文件时,Ghidra 可以计算两个文件之间的差异,可以是任意方向。你需要记住哪个文件是哪个,使用程序差异工具时自行区分。

一个常见的工作流程是开始分析一个文件后,发现部分或全部代码看起来很熟悉,这可能会促使你打开一个之前分析过的文件来开始进行差异比较。幸运的是,程序差异会通过必要时插入空行来保持两个文件之间的对齐。差异会被高亮显示,程序差异工具栏为你提供了导航功能,并帮助你确定如何处理这些差异。

程序差异工具栏

程序差异工具栏通过添加图 23-3 中显示的工具,扩展了列表窗口的工具栏选项。

image

图 23-3:程序差异工具栏选项

差异应用设置

差异应用设置定义了当两个文件之间存在差异时,你希望采取的操作。选择“显示差异应用设置”选项,将显示图 23-4 中显示的窗口。

image

图 23-4:差异应用设置窗口

每个设置指定了你希望如何将第二个程序的操作应用于你打开的第一个程序,并且如何应用该选项。以下四个选项可以从每个下拉菜单中选择:

忽略 不更改第一个程序(适用于所有情况)。

替换 将第一个程序的内容更改为与第二个程序的内容匹配(适用于所有情况)。

合并 将第二个程序中的差异添加到第一个程序中。如果应用于标签,这将不会改变哪个标签被设置为主标签(仅适用于评论和标签)。

合并并设置为主标签 该操作与“合并”相同,但如果可能,主标签将设置为第二个程序的标签(仅适用于标签)。

在图 23-4 的顶部,有两个工具栏图标。保存为默认图标保存当前的差异应用设置。箭头图标打开一个菜单,允许你通过选择图 23-5 中显示的选项,一次性更改所有设置。

image

图 23-5:差异应用设置下拉菜单

如果你选择“设置合并”选项,并且“合并”不是特定设置的有效选项,则该选项将更改为“设置替换”。对于标签,将更改为“合并并设置为主标签”。

如果你希望应用所有默认更改,请从工具栏中选择“应用差异”。完成程序差异工具后,切换列表窗口中的差异视图图标,你将看到图 23-6 中显示的对话框。

image

图 23-6:关闭差异会话确认对话框

确认你希望关闭当前差异会话后,将关闭第二个文件的显示,并返回到正常的列表窗口,显示第一个文件(以及你从差异分析中选择的所有更改)。

程序差异工具设计了两个主要用例:首先,用于比较由两个不同用户分析的文件,这些用户没有共享 Ghidra 服务器实例;其次,用于比较由同一个源代码库的不同版本生成的代码(例如,未修补和修补的共享库版本)。在以下示例中,我们将演示如何使用此工具来协调两份相同二进制文件的差异,每个文件都是独立分析的。

示例:合并两个已分析的文件

假设你正在分析一个包含加密例程的二进制文件。一位同事提到她正在分析一个似乎也包含加密例程的二进制文件,且很可能来自同一家恶意软件家族。她同意将她的项目提供给你,以便你可以比较这两个文件。当你在“差异视图”中查看这些文件时,你立刻注意到你们似乎正在分析相同的二进制文件。

挑战在于,你们各自根据个人分析进展并修改了文件内容。你们需要合并两个已分析的文件,以便互相受益。你们已同意承担这一责任,并在 CodeBrowser 中打开了你的二进制文件,启动了程序差异会话,并添加了同事的二进制文件进行比较。

在“程序差异”工具栏上选择向下箭头将带你到此文件中的第一个差异。在此时,你可以通过选择“程序差异”工具栏中的选项(或快捷键 F5)打开“差异详情”窗口。此窗口会为你提供以下列表(分为两部分以便讨论)。在“差异详情”顶部的第一部分,你将看到以下内容:

Diff address range 1 of 4\. ➊

Difference details for address: 0010075a ➋

Function Diffs : ➌

  Program1 CH23:/Crypto/diff_sample1 :

    Signature: void encrypt_rot13(char * inbuffer, char * outbuffer) ➍

    Thunk? : no

    Stack Frame:

       Parameters: ➎

         DataType    Storage        FirstUse Name      Size Source

         /char *     RDI:8          0x0      inbuffer  8    USER_DEFINED

         /char *     RSI:8          0x0      outbuffer 8    USER_DEFINED

       Local Variables: ➏

         DataType    Storage        FirstUse Name      Size Source

         /int        EAX:4          0xc0     length    4    USER_DEFINED

         /int        Stack[-0x1c]:4 0x0      idx       4    USER_DEFINED

         /char       Stack[-0x1d]:1 0x0      curr_char 1    USER_DEFINED

  Program2 CH23:/Crypto/diff_sample1a : ➐

    Signature: void encrypt(char * param_1, long param_2)

    Thunk? : no

    Stack Frame:

       Parameters:

         DataType    Storage        FirstUse Name      Size Source

         /char *     RDI:8          0x0      param_1   8    DEFAULT

         /long       RSI:8          0x0      param_2   8    DEFAULT

       Local Variables:

         DataType    Storage        FirstUse Name      Size Source

         /undefined4 Stack[-0x1c]:4 0x0      local_1c  4    DEFAULT

         /undefined1 Stack[-0x1d]:1 0x0      local_1d  1    DEFAULT

该文件中识别出的四个差异地址范围中的第一个➊正在显示,并与当前地址0010075a ➋相关联。列表首先详细描述了两个二进制文件的函数头部差异➌。对于你的二进制文件,你已经为函数及其签名中的参数提供了有意义的名称 ➍。此外,每个参数都有适当定义的类型 ➎。类似地,本地变量也赋予了有意义的名称和类型 ➏。在第二个程序中 ➐,分析师没有对相应函数的默认 Ghidra 头部进行任何修改。

你希望保留你对函数定义和本地变量所做的更改版本。你可以使用工具栏图标来拒绝此更改,但这将拒绝与该地址相关的所有差异。由于你还没有审查所有差异,因此只需向下滚动到“差异详情”窗口中的下一个差异即可。

与第一个地址范围相关的下一个差异部分包含标签和注释的差异。

➊ Label Diffs :

    Program1 CH23:/Crypto/diff_sample1 at 0010075a :

      0010075a is an External Entry Point.

        Name           Type           Primary  Source        Namespace

      ➋ encrypt_rot13  Function       yes      USER_DEFINED  Global

    Program2 CH23:/Crypto/diff_sample1a at 0010075a :

      0010075a is an External Entry Point.

        Name           Type           Primary  Source        Namespace

      ➌ encrypt        Function       yes      USER_DEFINED  Global

➍ Plate-Comment Diffs :

➎ Program1 CH23:/Crypto/diff_sample1 at 0010075a :

      ****************************************************************

      *                      FUNCTION                                *

      * This is a crypto function originally named cryptor. Renamed  *

      * to use our standard format encrypt_rot13\. Changed the        *

      * function parameters to char *. Added meaningful variable     *

      * names. Function first seen in fileC13d by Ken H              *

      ****************************************************************

    Program2 CH23:/Crypto/diff_sample1a at 0010075a :

      No Plate-Comment.

➏ EOL-Comment Diffs :

    Program1 CH23:/Crypto/diff_sample1 at 0010075a :

      No EOL-Comment.

  ➐ Program2 CH23:/Crypto/diff_sample1a at 0010075a :

      This looks like an encryption routine. TODO: Analyze to get more information.

在标签差异中 ➊,唯一的差异是函数的名称 ➋ ➌,这一点已经讨论过。在Plate-Comment部分 ➍,你的文件有详细的注释 ➎,而另一个文件没有板注释。在EOL-Comment部分 ➏,另一位分析师有一个简短的注释 ➐,而你的文件中没有。在查看该注释时,你会发现它是一个TODO操作项,而你在文件中已经完成了该操作。

在评估了两个文件之间的所有差异后,你决定保留你的内容,并不接受来自另一个二进制文件的任何新内容。你通过选择“忽略差异并移动”图标来完成这个操作。这将带你到下一个差异。由于你已经打开了差异详情窗口,一旦你导航,它的内容会立即更新,你会看到以下内容:

Diff address range 1 of 3\. ➊

Difference details for address range: [ 0010081a - 0010081e ]

Reference Diffs :

  Program1 CH23:/Crypto/diff_sample1 at 0010081a :

    Reference Type: WRITE  From: 0010081a  Mnemonic  To: register: 

      RAX  USER_DEFINED  Primary

  Program2 CH23:/Crypto/diff_sample1a at 0010081a :

    No unmatched references.

你通过拒绝之前的差异➊,减少了包含差异的范围数量。再次强调,你的文件比第二个文件包含更多信息。这一次,你将通过点击下箭头来导航到下一个差异。这将带你到以下内容:

Diff address range 2 of 3\. ➊

Difference details for address: 00100830

Function Diffs :

  Program1 CH23:/Crypto/diff_sample1 :

    Signature: undefined display_message()

    Thunk? : no

    Calling Convention: unknown

    Return Value :

         DataType    Storage        FirstUse Name      Size Source

         /undefined  AL:1           0x0      <RETURN>  1    IMPORTED

      Parameters:

        No parameters.

  Program2 CH23:/Crypto/diff_sample1a :

    Signature: void display_message(char * message) ➋

    Thunk? : no

    Calling Convention: __stdcall

    Return Value :

         DataType    Storage        FirstUse Name      Size Source

         /void       <VOID>         0x0      <RETURN>  0    IMPORTED

      Parameters:

         DataType    Storage        FirstUse Name      Size Source

         /char *     RDI:8          0x0      message   8    USER_DEFINED

注意到差异范围的数量没有变化➊。你只是被移动到下一个差异范围,而没有影响总的差异范围数量。评估这个新的差异显示第二个文件包含了由另一个分析员提供的信息,而这些信息在你的文件中并不存在。函数签名有一个返回类型,并且已经在函数签名中添加了一个参数➋。你可以通过右键点击右侧列表窗口中的差异,并选择“应用选择”(快捷键 F3),或者点击工具栏上的“应用差异”图标,将其包含到你的二进制文件中。

导航到下一个差异后,你会看到以下详细信息:

➊ Diff address range 2 of 2.

  Difference details for address range: [ 00100848 - 0010084c ]

  Pre-Comment Diffs :

    Program1 CH23:/Crypto/diff_sample1 at 00100848 :

      No Pre-Comment.

    Program2 CH23:/Crypto/diff_sample1a at 00100848 :

    ➋ This is a potential vulnerability.  The parameter is being passed

      in to printf as the first/only parameter which may result in a format

      string vulnerability.

差异范围的数量减少了,因为你在之前的范围中应用了差异➊。在这个最终的差异中,你看到另一个文件的预注释部分➋中有一个有趣的条目。分析员发现了一个潜在的漏洞。为了确保这个信息被包含到你的文件中,你选择应用差异

现在你已经完成了两个文件的比较,你可以点击“差异视图”图标,并确认你要关闭当前的程序差异会话。你的列表视图现在反映了来自两个二进制文件的综合分析,你可以保存并关闭文件。

Ghidra 程序差异工具提供了调查同一文件两个版本之间差异的功能。尽管它会尝试对比两个无关的文件,但任何结果很可能仅反映出巧合的相似性。让我们将注意力转向另一个工具,它便于在相同或不同程序之间比较选定的函数。

比较函数

如果你看到一个函数,它让你想起你以前分析过的某个函数,那么直接比较这两个函数会很有帮助,这样你就能在适当的时候将你初步分析的结果应用到当前函数上。Ghidra 提供了这个功能,通过其函数比较窗口,你可以同时查看两个函数,如图 23-7 所示。

image

图 23-7:函数比较窗口中的列表视图

函数比较窗口

要使用函数比较窗口,请在代码浏览器中打开包含函数的一个或多个二进制文件,通过在活动代码浏览器标签中突出显示一个函数来加载初始函数,然后从右键上下文菜单中选择“比较选定函数”(快捷键 SHIFT-C)。函数比较窗口将并排显示两个函数,并突出显示潜在的差异,如图 23-7 所示。(如果您只选择了一个函数,它将在两个窗口中显示,直到您加载更多函数。)

要添加更多函数进行比较,请选择添加函数图标 ➊。这将显示活动程序中所有函数的列表,您可以从列表中选择一个函数,或切换到代码浏览器窗口,通过选择列表窗口中的其他程序标签来更改活动程序。

在活动列表的左侧(由框住的列表 ➏ 表示)是一个光标箭头 ➐。若函数匹配,箭头也将在另一个窗口的相同位置出现。在图 23-7 中,主窗口中的指令与另一个窗口中的指令不匹配,因此光标箭头在两个窗口中都没有显示。

函数比较窗口提供了加载多个二进制文件中的多个函数的机会。您可以根据需要从每个面板中添加或删除函数。一个有用的下拉菜单让您选择在关联窗口 ➋ ➌ 中显示的函数。

该窗口让您轻松在反编译视图 ➍ 和列表视图 ➎ 之间切换,並可以更改每个窗口中显示的函数。该示例的反编译视图如图 23-8 所示。

image

图 23-8:函数比较窗口中的反编译视图

该窗口中可用的探索功能与程序差异工具有很大的重叠,唯一的区别是您一次只比较两个函数,并且可以轻松在反编译代码和列表之间切换。该窗口的工具栏菜单如图 23-9 所示。

image

图 23-9:函数比较工具栏选项

让我们通过一个示例来演示一些额外的函数比较工具功能。

示例:比较加密例程

恭喜晋升!基于您在加密例程分析和使用程序差异工具方面的成功,您现在已经被标记为您所在工作组的加密专家。每次同事怀疑他们有加密例程时,他们都会将二进制文件发给您,看看是否是您认出的加密例程。

现在,你有了一个同事发来的新文件,想要确定这个文件中使用的加密例程是新例程还是你之前已经识别过的例程。与其加载并将每个加密例程与新函数进行比较,你设置了一个特别的 Ghidra 项目,其中包含了你之前分析和记录的所有加密例程。你的目标是将你的加密例程加载到函数比较窗口的一侧,然后将新文件导入到另一侧进行比较。(为了简化此示例,你目前的收藏中只有一个已分析的加密例程:你在之前示例中合并的 ROT13 例程。)

在你将完整的已分析加密文件集加载到 CodeBrowser 并在函数比较窗口中加载了你的函数encrypt_rot13之后,你需要将新文件加载到同一个 CodeBrowser 实例中(文件 ▸ 打开)并将其设置为活动文件。此时,你可以探索文件,但并不是必须的。如果你找不到需要的函数,你始终可以切换回 CodeBrowser 窗口。在这种情况下,选择函数比较工具栏中的“添加函数”选项,你会看到新二进制文件中完整的函数列表,在列表中间有一个名字引起你兴趣的函数encrypt,如图 23-10 所示。

image

图 23-10:选定 encrypt 函数的选择函数窗口

从图 23-11 中显示的已加载函数的反编译视图中,粗略一看,这两个函数似乎非常不同。

image

图 23-11:两个加密例程的反编译视图的函数比较窗口

在图 23-12 中显示的列表视图确认了这两个函数有显著的差异。

image

图 23-12:显示差异的两个加密例程的函数比较窗口的列表视图

进一步分析后,你发现新例程将每个字节与常数值0xa5进行异或。这与当前的加密例程明显不同,因此你为这个新函数命名并进行文档记录,并将其添加到你的收藏中(这样你的收藏将有两个成员!)。回到 CodeBrowser,你更新了函数签名并添加了注释,以文档化这个新的加密例程。你所做的更改也会在函数比较窗口中反映出来。

在记录时,你注意到新二进制文件中有一个名为display_message的函数,与你正在比较的二进制文件中的函数相同。你回忆起在当前二进制文件中这个函数被识别为存在漏洞,因此你决定比较这两个函数。你将它们加载到功能比较窗口中,看看它们是否在常见名称之外还有相似之处。在反编译视图和列表视图中,它们似乎是不同的,如图 23-13 所示。

image

图 23-13:display_message函数的反编译和列表视图

在第二个示例中,param_1被传递给puts进行输出,从而修复了漏洞。

现在你已经记录了这个加密例程,你发现又收到了来自同事的另一个二进制文件。为了重新开始你的加密比较过程,你可以使用功能比较工具栏图标,移除窗口中的display_message函数,留下你的加密例程集合,现在它有两个不同的成员:encrypt_rot13encrypt_XOR_a5

对这个新文件的初步探索表明,三个函数似乎涉及加密:encryptencrypt_strongencrypt_super_strong。你将它们加载到功能比较窗口中,以便将它们与你现有的加密例程进行比较。比较encrypt_rot13与每个新函数后,你注意到以下几点:

encrypt_rot13 vs. encrypt 几乎完全不同。encrypt例程只是一个测试,可能会调用另外两个加密例程中的一个。

encrypt_rot13 vs. encrypt_strong 几乎完全相同。

encrypt_rot13 vs. encrypt_super_strong 非常不同。更仔细地看这两个函数之间的差异后,你认为它们不是同一个函数。

更仔细地看差异后发现,encrypt_rot13encrypt_strong中的指令是完全相同的——差异主要是地址标签,如图 23-14 所示。

image

图 23-14:功能比较窗口中的地址标签差异

你不期望地址标签在这种情况下完全匹配,因为函数在二进制文件中的位置不同。位置是相对于当前地址保持一致的,所以我们可能正在处理相同的函数。唯一的其他差异是与调用strlen相关的 1 个字节,如图 23-15 所示。这是一个类似的问题,可以通过加密函数和strlen在每个二进制文件中的相对位置差异来解释。

image

图 23-15:功能比较窗口中调用strlen时的字节差异

确定这两个是相同的函数后,你可以右键点击之前分析的函数,并从右键菜单中选择“将函数签名应用于另一边”。这将更新所有需要的地方的函数签名,包括列表窗口和符号树。请注意,函数比较窗口并没有提供 Diff View 中所有可用的功能。要复制更多的信息(如与函数相关的详细评论),请使用程序差异工具。

在完成 encrypt_rot13 的比较分析后,你将注意力转向 encrypt_XOR_a5,并观察到与每个新函数之间的以下关系:

encrypt_XOR_a5 encrypt 完全不同。

encrypt_XOR_a5 encrypt_strong 非常不同。更仔细地观察这两个函数之间的差异,也使你相信它们不是同一个函数。

encrypt_XOR_a5 encrypt_super_strong 几乎完全相同。

encrypt_XOR_a5encrypt_super_strong 之间的差异仅仅是地址标签和在调用 strlen 时的一些字节。你可以像处理之前匹配的函数一样处理这个情况。

虽然这是一个微不足道的例子(并且不太可能与实际中你可能遇到的加密例程一致),但它展示了如何使用函数比较来减少分析工作的重复性,当你在新二进制文件中遇到熟悉的例程时。

调查两个文件的最终工具是最复杂的:版本跟踪工具。

版本跟踪

想象一下,你已经花了几个月的时间分析一个非常大的二进制文件。这个二进制文件包含了数百或数千个函数,并且没有符号。在你的分析过程中,你已经为大多数函数提供了有意义的名称;重新命名了数据、局部变量和函数参数;并且添加了大量评论,这些评论需要几天甚至更长的时间才能重新创建。

现在想象一下,一个新版本的二进制文件发布了,而全世界不再使用你所熟悉的版本。你可以继续分析旧版本,假设新版本的行为类似,从中获取更多的信息,但你将无法了解新版本中新增或修改的行为。相反,你决定开始处理新版本的二进制文件,并迅速意识到,你花费了大量时间阅读旧版本中的标记,以帮助你理解新版本的内容。

在两个 CodeBrowser 窗口之间来回切换并不是时间的最佳利用方式。是时候从 CodeBrowser 切换到 Ghidra 在项目工具箱中提供的另一个默认工具,如 图 23-16 所示。

image

图 23-16:项目工具箱中的版本跟踪工具(痕迹)

Ghidra 的版本追踪工具旨在帮助您处理这种情况。通过使用各种关联器,Ghidra 尝试将源二进制文件中的项目(如函数或数据)与目标二进制文件中的相应版本进行匹配。一旦在两个二进制文件之间匹配了函数,Ghidra 可以自动将信息(包括标签和注释)从源二进制文件迁移到目标二进制文件。除了快速迁移现有分析外,版本追踪工具还使得您能够轻松识别哪些内容没有改变,哪些内容仅有轻微变化(通过差异化检测),以及哪些内容是全新的。

版本追踪工具是 Ghidra 中最具可配置性的工具之一,这使得它可以轻松适应特定的调查方向。它也是一个具有挑战性的工具,难以完整呈现。在接下来的章节中,我们将为您简要介绍版本追踪过程,并指引您使用资源来发现正确的设置和组件,帮助您利用版本追踪工具发现两个文件之间的关系。

版本追踪概念

虽然函数比较和程序差异工具回答了有关两个文件或函数之间原子差异的具体问题,但版本追踪工具为您提供了一个更全面的问题答案:这两个二进制文件有多相似?您能否突出显示并提供它们之间相似性的洞察?基础工作单元称为会话,每个会话都配置为识别和处理两个文件之间的关联

关联器

从高层次看,版本追踪工具在寻找两个文件之间的关联。有七种类型的关联器可以在两个二进制文件之间生成匹配:

  • 数据匹配关联器

  • 函数匹配关联器

  • 传统导入关联器

  • 隐含关联器

  • 手动匹配关联器

  • 符号名称匹配关联器

  • 引用关联器

与其仅仅列出每个类别中的具体差异,版本追踪工具通过扩展两个文件之间的关联,识别不同精确度级别的匹配项:

完全匹配 这是两个文件之间的一对一匹配,可以匹配数据、函数字节、函数指令或函数助记符(例如,当两个二进制文件包含完全相同的函数时)。

重复数据匹配 这些是精确匹配,但并非一对一匹配(例如,当一个字符串在一个文件中出现一次,而在另一个文件中出现七次时)。

相似匹配 这些是通过用户控制的相似度阈值的匹配。该匹配方法类似于第十三章中描述的词模型方法,但使用的是四元组和三元组。

通过设置阈值以及接受和拒绝匹配,这个工具提供了一个强大的功能,可以将你之前的分析迁移到新版本的二进制文件。此外,与每个会话相关的信息提供了有效的分析审计跟踪,可以帮助捕捉二进制文件的增量变化或恶意软件家族的演变。

会话

尽管全面介绍完整会话需要相当长的时间,但一个基本的版本追踪会话可能包括以下步骤:

  1. 打开 Ghidra 的版本追踪工具。

  2. 通过选择源文件和目标文件来创建一个新会话。

  3. 对于所有适用的相关工具:添加到现有会话中,选择相关工具,选择所有匹配结果,接受所有匹配项并应用其标记项。

  4. 保存会话。

  5. 关闭会话。

上述工作流提供了一个非常概括的概述,与相关步骤相关的组合潜力非常广泛。这个工具的潜力及其细微差别无法在一个章节中全面涵盖。Ghidra 团队已经在 Ghidra 帮助文档中提供了示例工作流(以及关于版本追踪工具的重要文档)。如何在反向工程工作流中最有效地应用此工具的功能,取决于你自己。

总结

在本章中,我们从单个二进制文件转向使用程序差异、函数比较和版本追踪工具来识别二进制文件之间的差异和相似之处。这些工具是将现有工作移植到新二进制文件、合并同事的注释以及快速识别两个版本之间的变化的宝贵节省时间的工具。

当我们结束对 Ghidra 丰富功能的探索时,请知道我们仅仅触及了 Ghidra 能力的表面。你现在应该对 Ghidra 有了更深入的了解,知道如何将其应用于你所面临的反向工程挑战。当你有疑问时,Ghidra 社区可以通过 GitHub、Stack Exchange、Reddit、YouTube 和许多其他论坛为你提供帮助。

更重要的是,你现在应该能够通过回答问题并为他人提供帮助来作出贡献。Ghidra 是一个由社区支持的软件,且在不断发展。我们希望你能参与其中,发布教程、编写和发布 Ghidra 脚本和模块、识别并解决问题,甚至为 Ghidra 本身开发新功能。Ghidra 的未来将由社区决定,而现在你也是其中的一员。欢迎加入,祝你反向工程愉快!

第二十八章:GHIDRA 对于 IDA 用户

Image

如果你是一个有经验的 IDA Pro 用户,想要尝试 Ghidra,无论是出于好奇心还是更永久的过渡,你可能已经熟悉本书中介绍的许多概念。本附录旨在将 IDA 的术语和用法映射到 Ghidra 中的类似功能,而不提供 Ghidra 功能的使用指导。对于本书中提到的任何 Ghidra 特性的具体使用,请参考相关章节,这些章节将详细讨论这些特性。

我们不会试图比较这两款工具的性能,也不会主张某一款工具优于另一款。你选择使用哪一款可能受价格或某一款工具提供的特定功能的影响,而另一款工具没有相同功能。接下来将从 IDA 用户的角度,带你快速浏览本书中的主题。

基础知识

在你开始这段旅程时,你可能会发现携带一本指南来帮助你学习一整套新的快捷键非常有用。Ghidra 备忘单(*ghidra-sre.org/CheatSheet.html)是一本有用的三折页,列出了常见的用户操作及其相关的快捷键和/或工具按钮。不久后,我们将介绍如何重新映射快捷键,以防你怀念你信赖的 IDA 常用功能。

数据库创建

而 IDA 将一个二进制文件导入到一个数据库中,且天生是单用户的,Ghidra 则是面向项目的,可以在一个项目中包含多个文件,并支持多个用户协作反向工程,共同在同一个项目上工作。IDA 数据库的概念最接近 Ghidra 项目中的单个程序。Ghidra 的用户界面分为两个主要部分:项目CodeBrowser

你与 Ghidra 的首次互动是创建项目(共享或非共享),并通过项目窗口将“程序”(二进制文件)导入这些项目。当你使用 IDA 打开一个新二进制文件,并最终创建一个新数据库时,你和 IDA 将执行以下操作:

  1. (IDA) 查询所有可用的加载器,以了解哪些加载器识别新选择的文件。

  2. (IDA) 显示加载文件对话框,列出可接受的加载器、处理器模块和分析选项。

  3. (用户) 选择应当用于将文件内容加载到新数据库中的加载器模块,或接受 IDA 的默认选择。

  4. (用户) 选择应当在反汇编数据库内容时使用的处理器模块,或接受 IDA 的默认选择(该选择可能由加载器模块决定)。

  5. (用户) 选择在创建初始数据库时应使用的任何分析选项,或接受 IDA 的默认选择。你也可以选择在此时完全禁用分析。

  6. (用户) 点击确定确认你的选择。

  7. (IDA) 所选的加载器模块将使用来自原始文件的字节内容填充数据库。IDA 加载器通常不会将整个文件加载到数据库中,并且通常无法从新数据库中的内容重新创建原始文件。

  8. (IDA) 如果启用了分析,所选的处理器模块将用于将加载器识别的代码以及任何选定的分析器进行反汇编(IDA 将分析器称为 内核选项)。

  9. (IDA) 结果数据库将在 IDA 的用户界面中显示。

Ghidra 有类似的步骤;然而,该过程分为两个不同的阶段:导入和分析。Ghidra 的导入过程通常从项目窗口开始,包括以下步骤:

  1. (Ghidra) 查询所有可用的加载器,了解哪些加载器识别新选择的文件。

  2. (Ghidra) 显示导入对话框,呈现可接受的格式(大致是加载器)和语言(大致是处理器模块)列表。

  3. (User) 选择将文件导入当前项目的格式,或接受 Ghidra 的默认选择。

  4. (User) 选择用于反汇编程序内容的语言,或接受 Ghidra 的默认选择。

  5. (User) 通过点击 确定 来确认您的选择。

  6. (Ghidra) 与所选格式关联的加载器将来自原始文件的字节内容加载到当前项目中的新“程序”中。加载器创建程序段并处理二进制文件的符号、导入和导出表,但不会执行任何涉及反汇编的分析。Ghidra 加载器通常将整个文件加载到您的 Ghidra 项目中,尽管文件的某些部分可能不会在 CodeBrowser 中显示。

尽管此过程与 IDA 数据库创建类似,但缺少一些步骤。使用 Ghidra 时,分析发生在 CodeBrowser 中。一旦成功导入文件,双击项目视图中的该文件即可在 Ghidra 的 CodeBrowser 中打开该文件。首次打开程序时,Ghidra 执行以下步骤:

  1. (Ghidra) 打开 CodeBrowser 并显示导入过程的结果,询问您是否希望分析该文件。

  2. (User) 决定是否分析该文件。如果您选择不分析该文件,您将进入 CodeBrowser,可以浏览字节内容,但不会有反汇编。在这种情况下,您可以随时选择 分析自动分析 来分析该文件。无论哪种情况,当您决定分析文件时,Ghidra 会显示与当前文件格式和语言设置兼容的“分析器”列表。您可以选择要运行的分析器,然后在允许 Ghidra 执行初始分析之前修改分析器使用的任何选项。

  3. (Ghidra) 执行所有选定的分析器,并将用户带入 CodeBrowser,以便开始处理已完全分析的程序。

有关导入和分析阶段的更多信息,请参阅本书中的相关章节。IDA 没有类似于 Project view 的功能,也没有任何协作反向分析功能,除了共享的 Lumina 数据库之外。Project view 在第四章中介绍。共享项目和对协作反向工程的支持在第十一章中讨论。CodeBrowser 在第四章中介绍,更多深入内容从第五章开始,直到本书的其余部分。

CodeBrowser 是 Ghidra 的工具,是你分析程序的主要界面。因此,它是最类似于 IDA 用户界面的 Ghidra 组件,因此我们将花一些时间将 IDA 的用户界面元素与它们在 CodeBrowser 中的等效项进行比较。

基础窗口和导航

在默认配置中,CodeBrowser 是一个容器,包含多个显示程序功能信息的特殊窗口。关于 CodeBrowser 的详细讨论从第五章开始,并继续涵盖相关数据展示,直到第十章。

列表视图

CodeBrowser 的核心是 Ghidra 列表窗口,它提供了类似于 IDA 文本模式的经典反汇编。要自定义列表的格式,浏览器字段格式化器允许你修改、重新排列和删除单个列表元素。与 IDA 一样,在列表窗口中的导航主要通过双击标签(IDA 名称)来实现,跳转到与标签关联的地址。右键点击时,上下文敏感菜单提供了与标签相关的常用操作,包括重命名和重新输入。

与 IDA 类似,列表中的每个函数都有一个头部注释,列出了函数的原型,提供了函数局部变量的摘要,并显示了指向该函数的交叉引用。Ghidra 相当于 IDA 的堆栈视图的功能只能通过右键点击函数头部并选择 函数 ▸ 编辑堆栈帧 来访问。

如果你喜欢 IDA 高亮显示你点击的字符串的所有出现位置(如寄存器名称或指令助记符),你可能会失望地发现这在 Ghidra 中不是默认行为。要启用此功能,请访问 编辑 ▸ 工具选项 ▸ 列表字段 ▸ 光标文本高亮,并将鼠标按钮从中键更改为左键。另一个你可能喜欢或讨厌的功能是标记寄存器变量引用,它会使 Ghidra 自动重命名用于存放函数传入参数的寄存器。要禁用此功能并让 Ghidra 使用寄存器名称指令操作数,请导航到 编辑 ▸ 工具选项 ▸ 列表字段 ▸ 操作数字段,并取消选中标记寄存器变量引用。

最后,如果你希望 Ghidra 在肌肉记忆让你使用最喜欢的 IDA 快捷键序列时能够“做正确的事”,你将需要花时间在 编辑 ▸ 工具选项 ▸ 键绑定 中重新分配默认的 Ghidra 快捷键,以匹配你在 IDA 中使用的快捷键序列。对于 IDA 用户来说,这是一个非常常见的任务,第三方键绑定文件已被发布,用于自动重新分配你最喜爱的快捷键序列。^(1)

图形视图

Ghidra 的列表窗口是一个仅显示文本的视图。如果你更喜欢在 IDA 的图形视图中工作,你将需要在 Ghidra 中打开一个单独的函数图窗口。像 IDA 的图形视图一样,Ghidra 的函数图窗口每次只能显示一个函数,并且你可以像在列表窗口中一样操作函数图窗口中的项目。

默认情况下,Ghidra 的图形布局算法可能会将边缘路由到基本块节点后面,这可能会使追踪边缘变得更加困难。你可以通过访问 编辑 ▸ 工具选项 ▸ 函数图 ▸ 嵌套代码布局 并勾选 "绕过顶点路由边缘" 来禁用此行为。

反编译器

Ghidra 为所有受支持的处理器提供反编译功能。默认情况下,反编译窗口会显示在列表窗口的右侧,并且每当你的光标位于列表视图中的某个函数时,它将显示反编译后的 C 源代码。如果你想在生成的 C 源代码中添加并查看行尾注释,你需要在 编辑 ▸ 工具选项 ▸ 反编译器 ▸ 显示 中勾选 "显示行尾注释"。在同一选项卡中,你还会找到 "禁用类型转换打印",这可以通过显著减少结果代码的杂乱程度来提高可读性。

反编译器也倾向于对它生成的代码进行积极优化。如果你发现自己在阅读某个函数的反汇编版本时,感觉反编译版本中缺少了某些行为,反编译器可能已删除它认为是无用代码的部分。要在反编译器窗口中显示这些代码,请导航至 编辑 ▸ 工具选项 ▸ 反编译器 ▸ 分析,并取消选择 "消除死代码"。反编译器将在 第十九章 中进一步讨论。

符号树

CodeBrowser 的符号树窗口提供了程序中所有符号的层次视图。符号树包含六个顶级文件夹,代表程序中可能存在的六种符号类。点击任何符号树文件夹中的名称将使列表窗口导航到相应的地址:

导入 导入 文件夹适用于动态链接的二进制文件,并提供程序引用的外部函数和库的列表。这与 IDA 的导入标签最为相似。

导出 导出 文件夹列出了程序中任何对外可见的符号。这些符号通常与 nm 工具输出的符号类似。

函数 该文件夹包含程序列表中每个函数的条目。

标签 该文件夹包含程序中任何附加的非本地标签的条目。

该文件夹包含 Ghidra 找到运行时类型识别(RTTI)的任何 C++ 类的名称。

命名空间 该文件夹包含 Ghidra 在程序分析过程中创建的每个命名空间的条目。有关 Ghidra 命名空间的更多信息,请参见 Ghidra 帮助。

数据类型管理器

数据类型管理器维护着 Ghidra 关于数据结构和函数原型的所有知识。数据类型管理器中的每个文件夹大致相当于 IDA 类型库 (.til)。数据类型管理器承担了 IDA 的结构体、枚举、局部类型和类型库窗口的角色,详细内容请参见第八章。

脚本

Ghidra 是用 Java 实现的,其本地脚本语言是 Java。除了常规脚本外,Ghidra 的主要 Java 扩展包括分析器、插件和加载器。Ghidra 的分析器和插件共同承担了 IDA 插件的角色,而 Ghidra 的加载器则基本上与 IDA 的加载器承担相同的角色。Ghidra 支持处理器模块的概念;然而,Ghidra 的处理器是使用一种名为 SLEIGH 的规范语言定义的。

Ghidra 包含一个用于常规脚本任务的基础脚本编辑器,以及一个 Eclipse 插件,以方便创建更复杂的 Ghidra 脚本和扩展。通过 Jython 支持使用 Python。Ghidra API 实现为一个类层次结构,表示二进制文件的特性作为 Java 对象,并提供了方便的类以便轻松访问一些最常用的 API 类。Ghidra 脚本在第十四章和第十五章中进行了讨论,扩展内容则在第十五章、第十七章和第十八章中进行讨论。

摘要

Ghidra 的功能显然与 IDA 非常相似。在某些情况下,Ghidra 的显示方式与 IDA 相似,以至于唯一可能让你慢下来的是新的热键、工具按钮和菜单。在其他情况下,信息的呈现方式与 IDA 有所不同,你的学习曲线将更陡峭。无论如何,无论你是利用 Ghidra 的自定义功能使其像 IDA 一样工作,还是花时间学习一种新的工作方式,你可能会发现 Ghidra 能够满足你大部分的逆向工程需求,甚至在某些情况下打开了全新的工作方式。

posted @ 2025-11-25 17:06  绝不原创的飞龙  阅读(24)  评论(0)    收藏  举报