去年,由于工作的需要,我们的团队不得不分析V8字节码。那时,还没有任何工具可以对这些代码进行反编译,因此,这些代码处理起来非常让人头疼。于是,我们决定为Ghidra框架编写一个处理器模块。得益于描述输出指令的语言的特性,我们不仅得到了一套可读的指令,而且还得到了一个类似C语言的反编译器。这篇文章是关于我们的Ghidra插件的系列文章(1,2)的续集。
从编写处理器模块到这篇文章,中间经历了几个月的时间。在这段时间里,SLEIGH规范保持不变,所描述的模块适用于9.1.2-9.2.2版本,这些版本是在过去六个月的时间内发布的。
在ghidra.re网站和Ghidra附带的文档中,对该语言的功能进行了非常详尽的介绍。所以,当您编写自己的模块之前,这些材料是非常值得一读的。由框架开发者发布的处理器模块都是一些非常棒的范例,特别是当您了解它们的体系结构的情况下。
根据相关文档的介绍,Ghidra的处理器模块是用SLEIGH语言编写的,这种语言派生自编码和解码规范语言(the Specification Language for Encoding,SLED),并且是专门为Ghidra而开发的。该语言能够将机器代码翻译成p代码——Ghidra用来构建反编译代码的中间语言。作为一种描述处理器指令的语言,它具有很多限制,不过,我们可以通过作为Java代码实现的p代码注入机制来减少这些限制。
至于本文介绍的新处理器模块的源代码,读者可以在github上进行下载。在本文中,我们将考察使用纯SLEIGH开发处理器模块时的关键概念,并以某些指令为例来展开讲解。关于如何使用常量池、p代码注入、分析器和加载器的内容,我们将在其他文章中进行介绍。此外,读者还可以在《The Ghidra Book: The Definitive Guide》中学习更多关于分析器和加载器的内容。
何处着手
为了完成本文介绍的任务,需要用到Eclipse IDE,并安装Ghidra附带的插件(GhidraDev和GhidraSleighEditor)。然后,创建名称为v8_bytecode的Ghidra模块项目。这个新项目包含了对处理器模块而言非常重要的模板文件;后面,我们将根据需要对其进行相应的修改。
对于我们需要处理的文件的全面概述,我们可以参考官方文档或最近出版的《The Ghidra Book: The Definitive Guide》一书。下面,我们对这些文件进行简要介绍:
*.cspec:编译器规范。
*.ldefs:语言的定义,包括显示在界面中的处理器模块的各个参数,以及与*.sla文件、处理器规范和编译器规范相关的链接。
*.pspec:处理器规格
*.opinion:加载器的配置;鉴于我们将只描述一种文件类型,所以,opinion文件可以留空,因为这里用不到它。
*.slaspec, *.sinc:描述SLEIGH中处理器的寄存器、指令等的文件。
当项目第一次启动后,将出现一个扩展名为.sla的文件。实际上,该文件是在SLASPEC文件的基础上生成的。
在着手开发处理器模块之前,我们需要弄清楚所选的处理器或解释器具有哪些寄存器,以及这些寄存器和堆栈的使用方法等。
V8寄存器
我们感兴趣的JSC文件,是通过bytenode使用JavaScript Node.js 8.16.0运行环境构建的(这个模块有时会随Node.js一起提供,有时则需要通过npm手动安装)。从本质上讲,bytenode是利用Node.js的文档化功能来创建编译文件的。下面是JS中编译JSC文件的函数中的源代码片段:
读者可以同时下载编译后的Node.js及其原始源文件。在详细研究了源文件和指令实例后,我们就能弄清楚寄存器是如何以字节码编码的(其中bytecode-register.cc和bytecode-register.h将有助于理解索引计算)。下面是寄存器索引计算与Node.js一致的V8指令示例:
如果您感觉aX寄存器是用不同的字节来编码的,并且具体取决于函数参数的数量的话,那么恭喜您:您的感觉就对了。下面的图表更清楚地展示了这一点。
这里,X是当前函数忽略传输的< this >的参数个数;aX是存放函数参数的寄存器;rN是用作局部变量的寄存器。对于常规指令,可以使用长度为1字节的值对寄存器进行编码;对于标记为Wide的指令,则需要使用长度为2字节的值进行编码;对于标记为ExtraWide的指令,则需要使用长度为4字节的值进行编码。下面展示的是Wide指令的示例编码及相关解释:
在Sergey Fedonin的文章中,对Node.js和V8进行了更为详尽的介绍。
值得注意的是,SLEIGH语言并不完全适用于这种可解释的字节码,所以,用它编写的处理器模块具有许多限制。例如,定义work定义时,rN寄存器不能超过124个,aX寄存器不能超过125个。对此,我们可以用基于堆栈的寄存器通信模型来解决这个问题,因为它更符合整体概念。然而,在这种情况下,反汇编后的字节码更难阅读。
另外,如果不引入额外的伪指令、寄存器或内存区域,就不可能根据Node.js计算参数寄存器的名称,因为没有关于参数数量的信息可用。有鉴于此,我们决定在函数参数寄存器名称中以相反的顺序插入数字(aX中的X)。这并不妨碍代码的解析,这对我们来说是一个重要的标准。然而,在比较通过不同工具反汇编出来的指令结果时,它可能会引起混淆。
在研究了需要描述的内容之后,我们就可以开始编写处理器模块所需的文件了。
CSPEC文件
您可以在github上的框架源文件中,找到一些关于CSPEC文件中使用的标签的相关信息。根据该文件的描述:
编译器规范是Ghidra语言模块的必需部分,用于支持特定处理器的反汇编和分析。其目的是对有关目标二进制文件的信息进行编码,而这些信息是与生成该二进制文件的编译器紧密相关的。在Ghidra中,SLEIGH规范允许对特定处理器(如Intel x86)的机器指令进行解码,需要注意的是,可以生成这些指令的编译器却不止一个。对于特定的目标二进制文件,了解用于构建它的特定编译器的详细信息对于逆向工程过程非常重要。而编译器规范恰好满足了这一需求,因为它对参数传递约定和堆栈机制等概念给出了正式的描述……
同时,我们也不难看出,这些标签可用于:
· 解释特定于编译器的P代码。
· 组织编译器的数据类型(我们使用的是< data_organization >)。
· 编译器的作用域管理和内存访问(我们使用的是< global >)。
· 编译器的专用寄存器(我们使用的是< stackpointer >)。
· 参数传递(我们使用的是< default_proto >)。
在研究模板结构、文档中描述的标签以及类似文件的例子时,我们可以尝试编译一个适合我们需求的文件。
其中,< data_organization >和< stackpointer >标签是相当典型的,所以这里不再赘述;相反,我们可以仔细考察一下< default_proto >中的< prototype >标签,它提供了关于函数调用的协议的部分信息。为此,我们定义了以下内容:< input >、< output >与< unaffected >。
如上所述,参数是通过aX寄存器传递给函数的。在一个模块中,寄存器必须被定义为在某个空间中偏移的连续字节序列。一般来说,在这种情况下,会使用一个特别指定的空间来命名寄存器。然而,在理论上,只要您喜欢,也可以使用其他空间。当有大量的寄存器执行不同程度相同的功能时,最简单的做法是不单独表示它们,而是可以简单地在一个空间中用一组寄存器指定偏移量,并用它来定义它们。因此,我们在< input >标签中为寄存器空间(space="register")的内存区域做了标记,让参数通过该空间传递到函数中,偏移量为0x14000(注意,0x14000并不是固定的,这只是一个偏移量,aX寄存器将在下面的*.slaspec中定义)。
在默认的情况下,函数调用的结果被保存在累加器(acc)中,这可以通过< output >标签加以表示。对于保存函数返回的值的备用寄存器方案,我们可以在描述指令时定义相应的逻辑。在< unaffected >标签中,我们注意到函数调用对存储堆栈指针的寄存器并不会带来影响。
为了处理某些寄存器,最方便的方法就是把它们定义为全局可改变的。所以,在< global >标签中,我们定义了一个寄存器空间,即从偏移量0x2000处开始的寄存器地址范围。
LDEFS文件
接下来,我们开始考察语言定义文件:这是一个扩展名为.ldefs的文件。该文件需要的描述信息为:字节顺序(这里是le)、关键文件的名称(*.sla、*.pspec和*.cspec)、id和字节码名称,当把文件导入Ghidra时,它们将显示在支持的处理器模块列表中。如果我们需要给为Node.js版本编译的文件添加一个处理器模块,如果这个版本与当前版本有很大不同,那么就需要创建另一个标签来描述它,就像在作为Ghidra一部分提供的*.defs模块中描述处理器系列那样。
在尝试导入文件时,将看到与文件定义无关的信息的实际应用。
PSPEC文件
就文档而言,处理器规范(扩展名为.spec的文件)的情况更为复杂。在这种情况下,我们可以求助于框架本身的现成解决方案或processor_spec.rxg文件。在编写这个处理器模块的时候,还没有更详细的资料可用。将来的话,开发人员也可能会发布官方文档。
在眼下这个项目中,我们目前可能只需要处理器规范中的程序计数器;我们将保留新建项目标准模板中的相应标签(事实上我们可以让< processor_spec >为空)。
SLASPEC文件
现在我们可以在一个扩展名为.slaspec的文件中对SLEIGH的指令进行实际描述。
预处理器的基本定义和宏
在描述它时,首先,我们需要指定字节顺序。此外,我们还可以通过使用预处理器的宏来定义对齐方式和描述过程中可能需要的常数。
对于用来描述字节码的地址空间(这里创建了名称为register和ram的空间)来说,应该以定义空间的方式进行定义,而寄存器则通过定义寄存器的方式进行定义。另外,寄存器定义中的偏移值其实并不重要;相反,最重要的是寄存器必须位于不同的偏移量处。寄存器占用的字节数由参数size进行定义。需要注意的是,如果您引用这些寄存器的话,那么这里定义的信息必须对应于对*.cspec和分析器中类似抽象和值的调用。
关于指令
根据相关文档的介绍,指令是通过表来定义的,这些表由一个和多个构造器组成,并带有相关的族符号标识符。SLEIGH中的表是帮助创建族符号的构件。我们不会在本文中详细介绍符号的定义;我们建议大家阅读文档中的“Introduction to Symbols”部分,但可能读完本文后,也许这些概念就弄清楚了。构造器由五个部分组成。
1、表头
2、显示部分
3、比特模式部分
4、反汇编操作部分
5、语义操作部分
这些看起来有些让人摸不着头脑,所以,先让我们简要介绍一下:
1、表头可以存放可以在其他构造器中使用的标识符,或者为空(在这种情况下,含有指令的描述)。
2、显示部分是用于说明如何将指令显示到Ghidra列表中的模板。
3、比特模式部分是一个标识符列表,这些标识符以程序的实际二进制位为指令,并根据显示部分(有时使用下一个部分)的模式显示在列表中。
4、反汇编操作部分用于通过计算来补充比特模式部分,如果比特模式在纯粹形式方面不够完善的话。
5、语义操作部分用于描述指令的作用,以便在反编译器中显示其作用。
最初,会提供一个根指令表(标识符instruction与其关联在一起),所有表头内容为空的构造器都会成为它的一部分,这些构造器在显示部分中的第一个标识符被识别为指令的助记符。
对于表头中含有标识符的构造器,都会创建一个具有相应名称的新表。如果该表已经存在,那么,这个构造器就会成为该表的一部分。这种情况将在关于寄存器范围的章节中进行介绍。当在另一个表中使用一个表的标识符时,被引用的表被视为当前表的一部分。换句话说,创建一个其标识符没有在任何地方使用的表(它们将与指令的根表完全没有联系)是没有任何实际意义的。
下面是显示部分的一些特征:
· 插入符(^),用于分隔标识符和/或部分字符,它们之间不应该有空格。
· 引号(""),用于插入不被视为标识符的硬编码字符串。
· 当空白字符位于各部分的开始和结束位置时,将被截断——也就是说,由这些字符组成的序列将被压缩成一个空格。
· 会将一些标点符号和特殊字符插入到模板中(它们不用于任何特定的功能,与之不同的是,某些符号是有特定用途的,例如#用于注释)。
标记及其字段
我们需要定义比特字段来描述指令构造器。通过它们,可以将程序的某些部分与将被翻译成的语言的某些抽象联系在一起。例如,助记符或操作数就属于这样的抽象概念。这些字段是在标记的定义过程中定义的,其定义语法如下:
标记的长度(tokenMaxSize)必须是8的倍数。如果使用较小的位数对指令的操作数或细微差别进行编码的话,可能会很不方便。另一方面,这可以通过提供创建不同长度的字段的能力来进行补偿,这样的话,就能在标记指定的大小范围内对任意的二进制位进行位置解码。对于这样的字段来说,必须遵守以下条件:startBitNumX和endBitNumX在0到tokenMaxSize-1的范围内,并且startBitNumX < = endBitNumX。
对于解析后的V8字节码,没有必要创建其长度与标记长度不同的字段。然而,如果存在这样的字段,并且还需要混用的话,则可以通过逻辑运算符“&”或“|”将其统一起来。
注意:即使您在比特模式部分使用了一个字段或一组字段,但是这些字段的位掩码并没有覆盖它们所属的标记的全部大小,由标记大小决定的位数仍将从操作数的程序字节中抽取。
现在让我们来描述一个非常简单的字节码指令,该指令中不含操作数。我们先来定义描述指令操作码的字段。正如我们在上面关于V8的部分所看到的,指令代码由一个字节描述(当然,还存在更长的Wide和ExtraWide指令,但这里不考虑它们,因为从本质上讲,它们只是使用大尺寸的操作数和额外的指令操作码字节)。因此,我们将得到:
现在,使用op字段来确定定义Illegal和Nop指令的第一个也是唯一的一个操作码,我们为它们编写的构造器如下所示:
当我们使用Ghidra输出字节0xa7时,它将被显示为一条没有操作数的Illegal指令。在这个例子中,我们使用了unimpl关键字,表示这是一条未实现的指令,进一步的反编译将被中断,这对于跟踪未实现的语义描述是非常方便的。另外,Nop的语义部分为空,这意味着该命令不会影响反编译器中的显示,而这正是该指令应该做的。实际上,在我们的Node.js版本中,Nop并不作为一条指令存在的;相反,这里我们人为地引入了它,以实现SwitchOnSmiNoFeedback的功能,关于这方面的内容,读者可以参阅Vladimir Kononovich的相关文章。
小结
在这篇文章中,我们将为读者详细介绍如何打造用于分析V8字节码的Ghidra处理器模块,由于篇幅过长,我们将分多篇进行发布,更多精彩内容,敬请期待!
(未完待续)
本文翻译自:https://swarm.ptsecurity.com/creating-a-ghidra-processor-module-in-sleigh-using-v8-bytecode-as-an-example/如若转载,请注明原文地址