3.汇总-反编译器中的反汇编

这是第三章的最后一个小节;这一节将会把之前的内容进行整理,并分析这些之前的组件在IDA和Ghidra中的具体实现方法。

并且,这一章节与下一章节是从反编译器前端到中端的过渡章节,会涉及到一部分中端优化的情况。

当然,先假定一个场景:“有一套新的指令集或者字节码,需要编写反编译器插件,让反编译器支持新指令集架构的反汇编功能”。

那么如何在在IDA和Ghidra中实现这个场景,就是这一章节的主要内容。

IDA与反汇编

ida的中间语言使用的是micorocode,这个中间语言在30年前就已经设计出来了,IDA从7.0版本开始提供中间语言的相关API。具体的中间语言的相关内容知识,将在下一章细说。

IDA的指令集支持插件,称为“IDA processor module”,这个在栈平衡中章节已经介绍过了。如果需要支持一套新的指令集,需要在这个模块中完成以下的组件。

  • 指令的二进制数据与中间语言的转换(体力活,要定义所有的指令、寄存器等等)

  • 注册所有的跳转指令,声明跳转指令的类型,是绝对跳转、条件跳转、间接跳转、函数调用、函数返回等等

  • 处理所有可能的数据段引用场景

  • 处理所有可能的指令段引用场景

  • (可选)定义栈指针,并定义所有的对栈的操作,IDA将基于这个定义计算栈平衡

  • (可选)处理并分析间接跳转,将switch所产生的间接跳转还原

  • (可选)分析并还原栈帧。

这么一看,会发现,如果要完整的支持一套新的指令集,那要做的事情也太多了吧!而且好难。

毕竟很少有人从事专业的反编译开发工作;尤其是还原switch语句以及还原栈帧等复杂场景,从无到有要做很多功课也不一定写的好。

函数识别

IDA的函数识别使用了通用的方法,即通过call指令以及符号和文件入口识别函数。

这也是为什么我们经常能在IDA中看到红色的代码的原因,因为它虽然能够正常的进行反汇编,但是却无法

栈指针与栈平衡

参考上一章的内容

Ghidra与反汇编

ghidra的中间语言来自于QUBT,称为pcode,可以说是学术界教科书式的中间语言设计,这一部分将在下一章进一步说明。

Ghidra工程本身的设计存在一个严重的缺陷,它的函数级的反编译器和上层的是割裂的,中间通过符号流进行交互。可以说是解耦结果头了,反编译器的调试的确方便了,很多功能却不好实现了。

主要问题在于,底层的反编译是不支持文件结构的解析与函数识别的功能;需要在上层java中实现,而反编译器本体只支持单个函数的分析,跨函数的全局程序分析的反编译变得更为复杂。

流敏感的反编译

Ghidra反编译器内部实现了流敏感的反编译能力;因此只需要翻译好指令到中间语言的部分,其它的事情内部引擎会处理。

注意,由于流敏感的识别的所有工作,都是在Ghidra内部引擎完成的,因此从机器码到中间语言的翻译必须足够准确。尤其是函数的返回本身依赖于中间语言的翻译。

switch间接跳转

Ghidra内置了后向切片与部分控制流分析的能力,以此来实现将间接跳转中还原出jump-table的技术。

也就是说Ghidra内置了switch语句的还原能力,这个工作反编译器帮你完成了,你看这不比IDA舒服的多。

Ghidra将switch间接跳转特征分为三个部分:Guard, jump-table, jup-register

并常识通过后向切片与copy-propagation (拷贝传播),在ghidra中也被称为还原高层变量varnode。依次识别这三个元素

参考论文:http://www.sciencedirect.com/science/article/pii/S0167642301000144/pdf?md5=ed116622d77cdb914fb644cad3604bb3&pid=1-s2.0-S0167642301000144-main.pdf

快速解读原理:

这种恢复技术的前提是,switch的实现符合论文中的三类模型。

这三类模型实际上都有这么些特征,伪代码举例

int a = input()
switch(a) {
	case 1:
		// something
	case 2:
		// something
......
	default:
		// something
}
  • 第一步,最开始,一定有个判断jcond,判断switch的对象是否太大,而走到default或者一个都匹配不上

  • 第二步,通过三类之一的跳转表,计算目标地址

  • 第三步,完成跳转

跳转表的识别方法,在跳转点生成后向切片,获得程序的状态。

通过上述的切片,使用程序分析技术(主要是copy传播),找到第一步的判断逻辑(称为guard)

最后,使用论文中的模型,找到switch中跳转的计算方法,还原跳转表的形态以及大小。

函数识别

使用了两套函数识别机制,都在上层中定义

Ghidra支持配置指令特征进行函数起始位置识别,例如在x86_64指令集中,几种常见的函数起始汇编格式为:

push    rbp
mov     rbp, rsp
==========================
push    r15
push    r14
push    r13
push    r12
========================
push    rbp
push    rbx
mov     rxx, rxx

Ghidra有自定义的一套二进制匹配的函数头识别功能,例如x86_64体系下是的就在

\Ghidra\Processors\x86\data\patterns\x86-64gcc_patterns.xml

文件中

较为常见指令集的都已经支持了这种匹配方法。具体可以去每个指令集下的patterns目录里找。

剩下的函数入口识别组件就是通用的做法了,可以在主页的analysis功能中查看,也可以自己勾选上跑一下试试。相关信息在“递归下降的反汇编”章节已经有说明。

栈指针与栈偏移计算

与IDA不同,Ghidra的栈局部变量展示是基于栈顶(rsp)的。

这种做法的好处是反编译器全程只需要跟踪栈顶以及栈空间的大小,这只需要一个寄存器,毕竟在绝大多数场景,栈帧大小在函数头开辟后就固定了。

缺点是,有时候,有的人看栈上的局部变量偏移会觉得有些困惑。可以参考ref2中的配置进行修改。

ref1:https://github.com/NationalSecurityAgency/ghidra/issues/223

ref2:https://liwugang.github.io/2020/08/01/ghidra.html

并且,Ghidra的内部反编译器在反编译过程中不动态计算栈平衡,且不处理动态开辟栈指针的情况。在某些alloc的情况下会导致生成奇怪的反编译代码。它默认认为,栈顶除了函数调用情况,在一个函数内部作不会再有变化。

不转换中间语言的反编译器

在通用反编译器中,中间语言的主要功能之一,是为了兼容多种指令集架构;

与编译器类似,反编译器前端将各种指令集架构翻译为中间语言,后续的中端在中间语言上直接执行各种优化。那么支持一个新指令集的工作就会简单很多,因为很多的事情框架帮你实现了。

但是,如果反编译器的目标明确且固定,那么它也可以不需要执行“中间语言转换”。

最典型的就是java的JVM字节码、android的Dex字节码等,这一类完全特化的反编译器,它的目标十分明确就是输出尽可能精确的源码。

注意,不转换中间语言,不意味着不使用中间语言;

或者说,学术界认为jvm的字节码本身就是一种中间语言,因此这一类的反编译器“直接拿来用就行”,不再需要额外的转换,因此后续的中端优化还是通用的。

不做额外的转换,也是这一类完全特化的反编译器能输出和源码十分相近的主要因素。例如jadx工作已经支持直接把apk导出成gradle工程了。

Last updated