3.间接跳转与间接调用的处理
Last updated
Last updated
上一章“递归下降的反汇编”还留了另一个问题,即“间接跳转与间接调用”应该如何跟踪控制流。这一章节将针对这个话题进行扩展,尽可能的将反汇编过程中跳转与调用的特殊情况都覆盖了。
如下图,如果IDA无法正常的处理间接跳转,它可能会直接内联一个jmp <register> 进来。
典型场景举例1-间接调用:
典型场景举例2-间接跳转
那么什么情况下,编译器会生成间接跳转或者间接调用?
函数调用一定使用Call指令吗,还可能存在什么特殊的情况?
反编译器又是如何判断这些情况的呢?
在什么情况下,编译器会生成这几类特殊的代码?
那么反编译器又怎么处理与识别?
间接跳转的处理主要包含在二进制翻译以及数据流分析的步骤中,可以说是一个混合功能。当然,前面的章节先不讨论数据流分析的复杂场景;这里我尽量列举所有的可能场景,并尝试说明解决方案。
下图为学术界中对于相对跳转的一种处理方法,我称为”不完全流跟踪技术“,Ghidra就使用了这种技术。将在后面的章节单独描述
如果反编译器无法推断出间接跳转、间接调用的类型或者目标。一般情况下,反编译器使用默认的规则,即:
对于条件跳转,直接顺着控制流依次翻译两个分支。
对于间接跳转 jmp eax 这种,如果不知道目标位置,停止翻译,进行标记。
对于间接调用 call eax 这种,如果不知道目标位置,则翻译成函数指针,并继续顺着流程翻译。
示例。考虑下面这个代码
如果使用GCC-X86_64-O3编译,会将最后一个printf优化为jmp,像这样
这个场景,我们想要讨论的是控制流依赖场景中如何处理jmp类型的语句。这种情况被称为“tail-call”
根据经验,tail-call可能会有一下的特征,反编译器也是基于这些特征识别的
跳转的位置是一个已经被识别到的”函数起始地址”
跳转的距离很长,而且绝不会跳转到正在翻译的函数体内。
跳转的距离很长,越过了好几个已经被识别到的函数,即跨函数跳转
tail-call的跳转不会是条件跳转
tail-call之前的汇编,为了平衡栈,往往会执行出栈操作
tail-call跳转的目标(target),不会是其它条件跳转的目标
反编译器会智能的识别tail-call场景,并停止进行翻译。
还存在一种更为极端的tail-call场景,即函数指针参数与tail-call混合的情况,看下面这个代码示例
这种情况下,生成的函数将会出现一个相对跳转的情况(O3优化)
虽然真实世界中存在的可能性不多,但是这个例子很好的展示出了“编译器多种优化混合”导致的复杂场景,也是反编译器的主要麻烦来源。
switch语句主要会被翻译成两种形态的汇编
ref: https://github.com/snowcra5h/branch-tables-and-jump-tables
最直接就是将switch翻译成多个同语义的if-else-if语句。
这里讨论的典型场景,基于跳转表的实现场景。
注意,在通用的逆向软件中,switch跳转的实现与恢复都是依赖于目标编译器的模型的。
虽然学术界后续也提出了一些不需要依赖编译器模型的算法,但是这不是这里的重点(少一点算法,多一点示例)。
代码示例:
这种每个case的条件值很近的情况,有可能生成跳转表形式的汇编指令。
例如上面这个例子,i的取值是1~10,可以用10个元素的静态数组存储switch的跳转地址,这样跳转的目标地址可以直接通过i的值索引到。
最终生成类似与下图的使用间接跳转实现的跳转表
反编译器将在中间语言翻译时,还原上面这种情况的switch语句。
更为准确的说法应该叫做 ”jump-table还原“,因为在这里反编译器的目标是找到并格式化跳转表,并将跳转表关联到中断的控制流上,而不是还原switch-case-break-default这一套语句。
基于跳转表生成的switch语句,一般来说有下面几种形态(限定gcc与clang编译器)
其中jump address是最终的跳转地址,table_X为跳转表,expression是计算跳转的表达式,padding为用于增加的偏移常量,%PC为当前的指令地址。
jump address = table_1[expression * 4];最经典的场景,直接使用一个静态数组存储跳转表。
jump address = table_2[table_1[expression * 4]];不太常见的场景,使用了两级跳转表一般出现在跳转情况较为复杂,case中的代码特别长的场景
jump address = table_1[%PC + expression * 4] + %PC;跳转表存储在代码段的情况,表中存储的将会是一个对于当前地址的相对偏移
jump address = table_1[%PC + expression * 4 - padding] + %PC - padding; 更为复杂的情况,跳转不仅查表,还需要进行进一步的计算,甚至使用减法替代加法。
其它场景更为罕见的情况,这取决于编译器的优化,例如跳转表中不存储地址而是存指令等等。
还原switch的间接跳转的算法有很多,主要的手段都是通过控制流跟踪+规则匹配的方法的方法还原跳转表。具体方法将在后续章节中针对不同的反编译器专门介绍。
有时候IDA不太聪明,无法正确的还原switch的跳转表,就需要我们手动 帮帮忙
指针选中应该是switch的jump <register> 语句上,点击edit->other->specify switch idiom
这个表很复杂,但是利用上面的知识,我们可以知道它的意思
跳转表信息
1、Address of jump table:跳转表的地址。
1、Address of jump table:跳转表的地址。
2、Number of elements:跳转表中元素的个数。
3、Size of table element:跳转表中每个元素的字节数(1/2/4/8)。
4、Element shift amount:一般情况下保持默认的 0 即可。除非跳转表中存储的元素并不是跳转的目标地址,而是需要通过 target = base +/- (table_element << shift) 这个公式计算得出,这种情况需要作为 shift 的值提供。
5、Element base value:参考计算方法中的说明,如果是绝对地址填0,如果是相对偏移填写相对偏移的基址(一般来说是和Address of jump table 相同)。
跳转信息
6、Start of the switch idiom:switch 语句的首个指令的地址,在打开“Specify switch idiom”窗口时,光标处的地址会被自动填写到这里,就是jump <register>的指令地址
7、Input register of switch:存储 switch 语句输入的寄存器,即存储 switch(input) {...} 中input变量的寄存器,也是jump <register>中的寄存器。
8、First(lowest) input value:最小的 case 值,如果有default情况,default会占用0。
9、Default jump address:default case 的跳转目标地址,可以不指定,不指定时对于 default case 以 case 0 的形式显示。
特殊信息
10、Separate value table is present:当switch是以二级跳转表形态表示时,启用这个选项;二级跳转对应这上述的jump address = table_2[table_1[expression * 4]] 这种形态。 11、Signed jump table elements:跳转表中的元素是以有符号值时需要勾选。 12、Subtract table elements:计算跳转表元素时用减法而不是用加法,参考计算方法中的说明。 13、Table element is insn:跳转表中存储的不是目标地址而直接是指令时需要勾选。这种情况比较罕见,在ref中提供了ARM的直接使用跳转表存储jump指令的示例情况。
ref:https://hex-rays.com/blog/igors-tip-of-the-week-53-manual-switch-idioms/
本来这个情况不应该放在这里的,但是别的地方更加不合适,所以放在这里了。
看下面这个代码示例
在优化后的代码中,main函数中的some_func后的内容printf("never here");不会被生成,即被优化掉。因为main函数不再返回,后面的代码也就是死代码了。
这种情况在真实代码中很常见,比如各种assert 或者 error 或者 exception函数的使用。
但是反编译器未必知道some_func函数不会再返回,可能还会继续翻译main函数下去,获得错误的内容。
对于no-return场景当前主要的判断还是基于规则。可以参考IDA-TIPS中对于no_return_function 特征识别的描述:”IDA使用函数名来识别non-returning 函数“规则
大家也可以看一下自己的IDA中“IDA Pro\cfg\noret.cfg”文件的配置,一般来说配置了100多个函数名。
ref:https://hex-rays.com/blog/igors-tip-of-the-week-126-non-returning-functions/
同理,在Ghidra中也有类似的配置文件,不过Ghidra的配置更为细致;对于不同的二进制格式(ELF、PE、Mach-O等)都有独立的判断规则。
规则文件在 “Ghidra\Features\Base\data\noReturnFunctionConstraints.xml”
最后一种场景,也是比较常见的场景,即函数指针。
函数指针的使用在多态中十分常见,比如C++的虚表指针,或者C语言中各种回调等等。
反编译使用预置的调用约定,很容易识别函数指针。但是在逆向工作中真正的困难是没有高层次的类型结构。
然而很不幸,当前常用的反编译器的反编译维度都只有函数级(我不确定binary-ninja对于C++语言的类型结构恢复能力,我看它的产品宣传有这部分的能力)。即,不支持对于类层次结构的恢复。关于高级语义恢复的话题,只能留到后面讨论了。
这一章节虽然讨论了一些主流的场景,但是这些场景的前提都是“显式控制流”,即,有专门的跳转指令(虽然反编译器未必可以正确的推导出来对应的地址)
还有一大部分没有考虑到的隐式控制流场景,他们往往和语言特性以及操作系统特性有关。例如:系统中断、try-catch异常、系统回调的注册、信号处理函数。
当前的常用反编译器往往也不能正确的处理这些场景,我目前没有对这部分有较为深入的研究。如果有时间,以后再把这部分补上。