博猫娱乐深入理解逆向工具之架构规范_HUC惠仲娱乐

博猫娱乐

作为一个边缘 pwn 手,一直很喜欢研究逆向工具的原理。不久前 Ghidra 的发布终于给了我一个动力开始深入研究,毕竟在此之前开源社区基本上没有能用的逆向工具。Radare2 虽然一直也致力于做一个能用的逆向工具,但是从我自己用下来的感觉来说,他基本上还达不到能够成为主力逆向工具的程度,最多只能作为其他逆向工具的辅助。关于对这些工具的对比,如果有空,我会写一篇文章发表一下我自己了解的一些看法,大家也不需要因为这个吵起来,毕竟工具这个东西,各有优劣。

这一个系列我不确定我会写多少,主要是基于我现在学习的一些东西,觉得有意思的地方,或者比较难懂,比较难记的东西,我会写一下。据我所知,国内对逆向工具研究很深的并不太多,所以也希望通过我的介绍,能够揭开逆向工具的神秘面纱,让大家都理解其中的原理,在必要时候能够在开源基础上做出改动,或是为开源社区做出贡献。

本系列内容目前以 Ghidra 作为背景,其中内容会以 Ghidra 为例。

背景知识

本文假设读者拥有二进制方面的基础,主要是逆向或是 pwn 方面的基础,至少需要了解汇编语言、处理器工作方式等内容。

逆向工具、逆向框架简介

作为这个系列的第一篇,需要先简要介绍一下逆向工具本身。

作为二进制选手,大家都使用过逆向工具,包括目前业界标准的 IDA pro,NSA 新开源的 Ghidra ,价格不算太贵但是收费的 BinaryNinja 还有一直活跃的 Radare2 ,那么一个逆向工具都包括什么?需要有什么功能?

这个我想大家心里都有数,这里我主要介绍下一个逆向工具(逆向框架、逆向平台),都包含哪些部分。

这是一个典型的逆向工具包含的内容:

+------+  +------+  +-------+     | 插件 |  | 插件 |  |  ...  |     +------+  +------+  +-------+         |         |        |         +---------+--------+                   v             +--------------+             |   插件管理   |             +--------------+      +--------------^--------------+      |              |              |      v              v              v +----------+   +----------+   +----------+ | 文件格式 |-->|  反汇编  |-->|   界面   | +----------+   +----------+   +----------+

其中主要的部分是文件格式解析、反汇编和界面,当然光有这些还无法成为一个完整的逆向工具,在这几个部分中也有许多具体的内容,这里只是说明一个逆向工具需要的几个大体部分。

文件格式解析部分负责分析二进制文件,按照其特点分析为不同的文件,按照不同的文件进行分类,解析之后,还会构造程序的映射,这样才能够确认哪些部分是可执行部分,从而为后续的反汇编流程提供必要条件。

反汇编部分功能就比较显然了,负责将文件格式解析后,确认为将要执行的内容进行分析,包括找到指令所在位置等。之后,将指令进行解码,识别出二进制机器码对应的具体指令。

界面部分当然就是负责显示了,这一部分其实可以脱离前面的核心部分,从而支持所谓的 headless analysis,也就是不启动界面进行分析。

最后,也是最关键的部分,就是插件管理。作为“框架”,或是“平台”,只有支持了插件才算是完整的。插件的支持为逆向工具提供了无限的可能,我们熟知的 Hexrays 反编译工具就是在 IDA pro 上的一个插件。对于一个设计合格的逆向工具,插件能够插入到功能当中的每一个部分,甚至扩展功能本身。事实上,虽然我将逆向工具划分了三个主要功能,但是理论上只需要有插件就足够了,因为其他部分均可以通过插件的方式进行管理,并且提供。

另外,需要声明这里的归类,包括三类主要功能,都是我自己归类的,事实上如果具有适当的插件管理,那么其他所有部分均可以通过插件进行处理。但是由于这三类功能在我自己看来足够重要,所以单独提出。

架构规范(Architecture Specification)

既然这一篇是讲架构规范,我们肯定首先需要了解什么是架构规范(Architecture Specification)。

这里的架构是指处理器架构,架构规范,指的是处理器规范,可以理解为通过形式化语言将一个架构描述下来。大家最熟悉的例子应该是 x86 架构,或是 arm 架构,这些都是处理器架构,而处理器规范的意义,就是通过一个形式化的语言,或者更通俗地讲,就是通过一个更加规范的语言将处理器的架构描述出来。

所以为什么需要描述处理器架构呢?我们不是有 Intel 手册可以了解 x86 架构的功能、用法、指令集了吗?的确,手册是处理器厂商官方给出的,非常权威,我们完全可以依赖手册了解我们面对的架构,但是手册的问题也很明显:不够规范。官方的手册都是用自然语言描述的,虽然我们可以更容易地看懂,但是也带来了问题。自然语言描述是不利于机器处理的,换言之,官方的手册,从来没有考虑过需要你用机器处理。

这就引出了对处理器架构用规范语言描述,即,架构规范的一大意义:便于编程处理

那为什么编程自动化处理架构这么重要呢?因为,我们在写逆向工具呀!:)

思考一下,逆向工具需要什么功能?反汇编、反编译?交叉引用?分析跳转目标?这些似乎都是逆向工具需要的功能,其中最为重要的一个环节,反汇编,是一个非常繁重的工作。一个逆向工具增加对一个架构的支持,就需要对其架构细节进行处理,包括它的指令集、它的特点(例如一些架构具有的寄存器窗口、delay slot、或是像 arm 具有 thumb 模式),因为只有处理了这些特点,才能够正确分析出一些有价值的信息,包括交叉引用等。交叉引用,只有在分析出了跳转的目标,或是引用的目标,才能够正确找出来对吧?如果没有正确处理这些信息,逆向工具又如何找出来跳转目标呢?更不用说反汇编本身指令集的处理了,连指令都无法识别,这些功能就无从谈起了。

现在,我们思考一下,逆向工具对一个架构的支持有什么方式。我们以最基础的部分,指令解码为例。指令解码指将二进制机器码形式的内容识别为某条汇编指令的过程。事实上,这个过程是最复杂的,因为再简单的架构,也会拥有大量指令(例如会包含向量指令扩展、浮点数计算扩展等,导致指令数量不会太少),更不用说像 x86 这样的 CISC 架构,指令本身设计时就非常多,扩展起来就更多了。

一种最简单的方式,就是通过写代码直接进行指令解码。这种方式看似简单,也是最容易想到的方式,事实上工作量非常大。一大原因就是指令数量实在太多,识别到具体指令的过程非常复杂,特别是像 x86 这样的变长指令集。

那么还有什么方式呢?有一句名言,”软件工程的所有问题,都可以通过加一层的方式解决“,在这也不例外。我们通过加入新的一层,也就是 —— 架构规范,来解决这个问题。在之前,我们的指令解码过程如下:

+------+    解码逻辑(代码)    +------+ | bin  |   --------------->     | 汇编 | +------+                        +------+

现在,加入架构规范之后,我们的解码过程:

+-----------+  生成   +---------+ |  架构规范 | ------> | 解码器  | +-----------+  (代码)+---------+                            |                            | +-----------+              v          +-------+ |  bin      | ------------>+--------->|  汇编 | +-----------+                         +-------+

区别是什么?区别在于,我们实现生成解码器过程的代码是复用的!所以在支持不同的架构的时候,原来我们需要写针对不同架构的解码逻辑,现在,我们只需要编写不同的架构规范!

好吧,看起来没什么不同,但是事实上是,由于架构规范不止能够生成解码器,甚至可以生成编码器(在汇编过程中用到),或是指明语义(用于生成中间语言,或是其他语言,例如从 x86 翻译到 arm),这种情况下,原本我们需要针对不同的架构编写不同的解码器、编码器,现在,我们只需要写一个足够好的,从架构规范生成解码器和生成器的”生成代码“,之后,我们支持其他架构,只需要加入架构规范了。

这种方式,我们得到两个好处:

  1. 加入新架构时,减少了代码量
  2. 更容易维护现有架构,因为架构规范本身不像代码那样难以阅读(想象一下分析指令过程中的层层 if ..)

除此以外,一些研究方向也注重于使用规范的方式描述架构,从而进行形式化验证(一般是 Instruction Set Specification,不过在本文中我没有区别两者)。

在我自己看来,使用架构规范的方式进行解码器和编码器的生成是反汇编的一个关键。一个不恰当的例子就是llvm的反汇编器。虽然 llvm 使用了类似架构规范的描述语言,但是因为其设计之初只考虑了编译过程,没有考虑生成解码器的过程(编译过程不需要解码器,只需要编码器,而且编译器理论上不需要支持所有指令,一些冷门指令可以被其他指令代替,所以指令支持不全),导致解码器编写过程非常复杂。

SLEIGH 语言

SLEIGH 语言是 Ghidra 逆向工具中用到的架构描述语言(架构规范),在这里我们介绍 SLEIGH 语言的一些基本要素,从而让大家了解架构规范在现实中的用法情况。

另外,这一部分也可作为 SLEIGH 语言的基础教程,帮助大家了解 SLEIGH 语言的基本结构,对于我自己来说,主要是对这个语言起总结作用。

注意,本文的内容远不足以当做 SLEIGH 语言教程,只可当做 SLEIGH 语言总览,需要继续学习,请参照官方文档,本文只可用于辅助理解。

启发

SLEIGH 语言的思路来源于UQBT: adaptable binary translation at low cost,据说 IDA pro 的设计也参考了文章中的一些内容。

这篇文章主要通过定义架构描述语言,实现了两个架构之间的转换。其中用到了Specifying Representations of Machine Instructions一文中的 SLED 描述语言的一些设计,还用到了Specifying the semantics of machine instructions的一些设计。

SLEIGH 看起来参考了其中的一些设计,特别是 SLED 语言的一些基本要素,官方文档并没有太多介绍其思路来源,所以结合上文提到的思路来源会更加容易理解 SLEIGH 语言的一些设计决定,在必要时可以参阅。

设计目标

SLEIGH 语言的设计主要考虑到了反汇编和 IR (Intermediate Representation,中间语言)提升的两个功能,所以可以用于进行生成解码器,同时可以将二进制机器码提升到 IR。提升到 IR 的部分需要对指令的语义进行描述,也就是说明具体某条指令所做的事情。

一种简单暴力的方式用来达到描述指令与机器码对应关系以及指令语义就是直接列举所有的指令,然后分别列出其语义。但是不用想也知道,这样的方法非常低效,所以在设计时,需要考虑到可以复用的部分。

举例来讲,在汇编中,取址的部分就是可复用的,因为很多指令可能都会采用相同的取址方式。类似的还有寄存器索引,也就是指明指令中所用到的寄存器,在汇编中,很多指令采用了相同的寄存器索引方式,也就是对于指令中特定的几个二进制位,将其翻译为数字,作为一个数组的索引,而这个数组里的内容就是寄存器。这个数组对于很多指令来说是一样的,所以没有必要单独指明每一条指令可能出现的所有情况(所以没有必要将一条指令采用不同寄存器列作不同的指令,这样太繁琐了)。

SLEIGH 的设计,包括其前辈 SLED ,就是考虑了这些情况,然后提出了一些概念,从而更加容易生成解码器。
不过,SLEIGH 的设计并没有在意汇编部分,虽然这不意味着无法生成指令编码器(事实上 SLEIGH 自己有一个实验性的指令编码器),但是会导致生成过程不如指令解码器容易(这一部分在 Ghidra 的实验性编码器生成过程中也提到了)。

下面我们通过理解 SLEIGH 语言中的几个概念,来理解这种架构描述语言。

符号(Symbol)

符号,用我自己的理解,就是 SLEIGH 中用到的各种标识符。如果换到其他常规编程语言中,例如 C 语言中,下面这一句定义:

int a = 1; 

其中的 a 是 标识符 ,同时是一个 变量 。如果用我的理解,换到 SLEIGH 中,这里的标识符就对应符号,变量的概念对应后文中的其他概念。(这里其实并不严谨,标识符属于语法概念,变量属于语义概念,而符号在 SLEIGH 中和其他部分一样都属于语义概念,只是便于理解所以如此举例)

符号在 SLEIGH 中主要有两个作用:

  • 显示作用:这个符号在反汇编中应该如何显示?
  • 语义作用:这个符号在生成 IR 的时候应该如何使用?

举例来讲,一个寄存器,就是一个符号。以 x86 架构 rax 寄存器为例,显示作用决定了在反汇编显示的时候, rax 寄存器将被显示为字符串 “rax”,而语义作用决定了生成 IR 的时候,会编码为一个寄存器。

在 SLEIGH 中将符号分为两类:

  • 具体符号(Specific Symbol)
  • 组符号(Family Symbol)

这里的 rax 寄存器,就是一个具体符号,组符号描述指令编码与具体符号之间的对应关系。事实上在我第一次查看文档的时候,这一部分让我非常困扰,难以理解。后来我将这两者的关系当做面向对象中,对象和类的关系,具体符号就是一个对象,而组符号是一个类,这样就容易理解了许多。

至于“组符号决定指令编码与具体符号对应关系”这句话,主要是针对 SLEIGH 作为解码器使用的时候。举例来讲,如果我们将“寄存器”这个概念认为是组符号,将具体的寄存器,例如 rax 寄存器,rbx 寄存器等等认为是具体符号,在编写规范的时候,我们指定指令中某一个位置是寄存器,但具体解码的时候,需要根据具体指令将位于“寄存器”所在位置的部分翻译为具体的某个寄存器。所以这就体现了组符号将指令编码对应到具体符号上。

标记(Token)和域(Field)

这两个概念与符号有一定交叉,并不冲突,一个标记或是域也可以被认为是某类型的符号。

标记的概念在 SLED 语言中就提及了,在 SLEIGH 中也基本上延用了。SLED 中将二进制机器码认为是标记的流,解码就是识别各个标记的过程,域则是标记中的一部分,是标记中的一些二进制位。

在 SLEIGH 语言中,标记被限定为 8 个二进制位对齐,也就是以字节(又不是 defcon ,干嘛考虑一个字节不是 8 个二进制位的情况,是吧?对,我就是针对 defcon 25。不过,真有这种情况,应该可以利用修改 SLEIGH 语言对反汇编过程的控制进行模拟)为单位对齐,域则是标记的一部分。

只要我们记住标记和域的概念和符号是有交叉的(并不冲突),其用途也就不难理解了。

举例来讲,我们可以将一条完整的指令当做一个标记,域就是组成这条指令的各个部分,例如其操作码(opcode),第一个操作数,等等。

如果结合符号的概念,域就是一个组符号,因为域将标记中的一部分(一部分二进制位)映射到了具体的符号上(例如具体的某个寄存器,具体的操作数等等)。

在默认情况下,域通通被当做数字处理,SLEIGH 语言通过 attach 关键字,改变域的语义。例如可以将一个域的数字当做寄存器的索引,这种情况下的 SLEIGH 语言:

attach variables fieldlist registerlist;

其中 fieldlist 是一系列 field 名,这些 field 将被认为是寄存器,而不再被当做简单的数字。 registerlist 则是寄存器的列表,以后这些 field 的含义就会变为这个寄存器列表当中的索引。

构造器(Constructor)和表(Table)

对于熟悉编译的同学,以下内容更加便于理解:

构造器用于构造新的符号,如果用上下文无关语法中的概念来对比,这就像是语法中的规则(Rule)。构造器在指明如何构造新的符号的同时,也指出了这个过程的语义(是不是想到了语法制导翻译?)。表则是构造器的一个集合,同样用上下文无关语法来对比,表就像是语法中的非终结符。

我觉得这一部分几乎就是从编译中解析器部分抄过来的,大概也就是改了个名字。不过由于这个名字在 SLED 中就开始用了,所以就由他去吧。。只要大家可以理解就行了。

不过构造器和语法中的规则相比,还多了一个前提要求,也就是指令模式(Instruction Pattern),因为只有在符合特定情况下的时候,才能够应用这个规则。例如如果要应用 add 指令对应的构造器,就需要操作码满足要求。

由于 SLEIGH 语言的设计目标是指令解码,所以存在一个提前定义好的”根表“,名为 instruction,这一点非常类似语法解析中的根,也没有什么太特别的,就不再赘述了。

解码过程

在 Ghidra 中,解码的过程是自顶向下的,也就是先匹配将要应用的构造器,然后逐步向下直到整个指令被确定。

例如某个 SLEIGH 规范定义如下(只保留了条件,省略了语义、显示等内容)

寄存器:

reg1 和 reg2 为寄存器,列表 [ r0 r1 r2 r3 r4 r5 r6 r7 ]

标号 instr 共 16 位:

  • 域 op 10 到 15 位
  • 域 mode 6 到 9 位
  • 域 reg1 3 到 5 位
  • 域 imm 0 到 2 位
  • 域 reg2 0 到 2 位

instruction表(根):

  • and reg1,op2 需要满足 op = 0x10,且存在 reg1,op2
  • xor reg1, op2 需要满足 op = 0x11,且存在 reg1,op2
  • or reg1, op2 需要满足 op = 0x12,且存在 reg1,op2

op2 表:

  • reg2,满足 mode = 0,且存在 reg2
  • imm,满足 mode = 1,且存在 imm
  • [reg2],满足 mode = 2,且存在 reg2

假设一条指令如下:

(10, 15): 0x10
(6, 9): 0x2
(3, 5): 0x3
(0, 2): 0x4

其解析过程,首先通过 instruction 表,确定 op 为 0x10,reg1 为 0x3,对应寄存器为 r2,此时满足 and 对应的构造器(事实上构造器没有名字,这里的 and 是显示部分的内容)。该表使用到了 op2 表,所以接下来解析 op2 表, op2 表中,mode 为 2,满足 [reg2] 构造器,reg2 再根据寄存器,对应到 r4。

最终指令被解析为:and r2, [r4]。显示形式可以通过解析后的显示部分推导出来,而具体如何翻译为 IR,可以通过其语义部分进行推导。

总结

本文首先对逆向工具进行了简要介绍,主要关注其重点的几个功能,之后介绍了逆向工具中架构规范的相关内容,最后通过 SLEIGH 实例介绍了架构规范,或是架构描述语言的一种形式。

就我个人而言,我认为 SLEIGH 语言还有一定改进的空间。经过思考,我认为 SLEIGH 语言在一定程度上借鉴了上下文无关语法的内容,主要是构造器和表的部分,但是其语法和上下文无关语法的常用形式差距较大,这样就增大了学习成本,所以这是一个可改进的点,通过采用更贴近上下文无关语法的表示形式对 SLEIGH 语法进行改进。这一点比较容易,可以通过翻译到 SLEIGH 语言进行,只是需要一定量的设计,使得语法更加浅显易懂(尽量避免引入难以理解的概念)。