3.间接跳转与间接调用的处理

上一章“递归下降的反汇编”还留了另一个问题,即“间接跳转与间接调用”应该如何跟踪控制流。这一章节将针对这个话题进行扩展,尽可能的将反汇编过程中跳转与调用的特殊情况都覆盖了。

问题定义

如下图,如果IDA无法正常的处理间接跳转,它可能会直接内联一个jmp <register> 进来。

典型场景举例1-间接调用:

//省略之前的反汇编结果
call eax // <-- 出现了一个间接调用,他会跳转去哪里呢?
;; < -- 后面的“机器码”应该继续翻译下去吗?
;; < -- 会不会call了之后不再回来了?
;; < -- 会不会,后面的内容根本不是合法的指令?

典型场景举例2-间接跳转

//省略之前的反汇编结果
jmp eax // <-- 出现了一个间接跳转,他会跳转去哪里呢?
;; < -- 这种情况,后面的“机器码”应该继续翻译下去吗?

那么什么情况下,编译器会生成间接跳转或者间接调用?

函数调用一定使用Call指令吗,还可能存在什么特殊的情况?

反编译器又是如何判断这些情况的呢?

在什么情况下,编译器会生成这几类特殊的代码?

那么反编译器又怎么处理与识别?

核心问题:反汇编是如何处理间接跳转?

间接跳转的处理主要包含在二进制翻译以及数据流分析的步骤中,可以说是一个混合功能。当然,前面的章节先不讨论数据流分析的复杂场景;这里我尽量列举所有的可能场景,并尝试说明解决方案。

下图为学术界中对于相对跳转的一种处理方法,我称为”不完全流跟踪技术“,Ghidra就使用了这种技术。将在后面的章节单独描述

0、默认场景

如果反编译器无法推断出间接跳转、间接调用的类型或者目标。一般情况下,反编译器使用默认的规则,即:

  • 对于条件跳转,直接顺着控制流依次翻译两个分支。

  • 对于间接跳转 jmp eax 这种,如果不知道目标位置,停止翻译,进行标记。

  • 对于间接调用 call eax 这种,如果不知道目标位置,则翻译成函数指针,并继续顺着流程翻译。

1、tail-call场景

示例。考虑下面这个代码

#include <stdio.h>
void test(int a, int b, int c)
{
    int ADD = a + b + c + 100;
    printf("%d", ADD);
    int SUB = a - b - c -100;
    printf("%d", SUB);
    printf("%d", ADD * SUB * 100); //<-- 这里最后一句是printf
}

如果使用GCC-X86_64-O3编译,会将最后一个printf优化为jmp,像这样

...省略前面的汇编
lea     rsi, aDDD+8     ; "%d"
pop     r12
xor     eax, eax
pop     r13
pop     r14
jmp     ___printf_chk  ; 直接变成一个jmp-tail-call. 函数结束了

这个场景,我们想要讨论的是控制流依赖场景中如何处理jmp类型的语句。这种情况被称为“tail-call”

根据经验,tail-call可能会有一下的特征,反编译器也是基于这些特征识别的

  • 跳转的位置是一个已经被识别到的”函数起始地址”

  • 跳转的距离很长,而且绝不会跳转到正在翻译的函数体内。

  • 跳转的距离很长,越过了好几个已经被识别到的函数,即跨函数跳转

  • tail-call的跳转不会是条件跳转

  • tail-call之前的汇编,为了平衡栈,往往会执行出栈操作

  • tail-call跳转的目标(target),不会是其它条件跳转的目标

反编译器会智能的识别tail-call场景,并停止进行翻译。

1-1、更为极端的tail-call场景

还存在一种更为极端的tail-call场景,即函数指针参数与tail-call混合的情况,看下面这个代码示例

int som_func( int(*myFuncVar)(int a, int b) )
{
    return myFuncVar(1, 2);
}

这种情况下,生成的函数将会出现一个相对跳转的情况(O3优化)

myMianOp(int (*)(int, int)):
        mov     rax, rdi
        mov     esi, 2
        mov     edi, 1
        jmp     rax

虽然真实世界中存在的可能性不多,但是这个例子很好的展示出了“编译器多种优化混合”导致的复杂场景,也是反编译器的主要麻烦来源。

2、switch语句中的间接跳转

switch语句主要会被翻译成两种形态的汇编

ref: https://github.com/snowcra5h/branch-tables-and-jump-tables

  • 最直接就是将switch翻译成多个同语义的if-else-if语句。

  • 这里讨论的典型场景,基于跳转表的实现场景。

注意,在通用的逆向软件中,switch跳转的实现与恢复都是依赖于目标编译器的模型的。

虽然学术界后续也提出了一些不需要依赖编译器模型的算法,但是这不是这里的重点(少一点算法,多一点示例)。

代码示例:

int test_switch(){
    int i ;
    int a = std::rand();
    switch(a){
        case 0: i = 0;break;
        case 1: i = 1;break;
        case 2: i = 2;break;
        case 3: i = 3;break;
        case 4: i = 4;break;
        case 5: i = 5;break;
        case 6: i = 6;break;
        case 7: i = 7;break;
        case 8: i = 8;break;
        case 9: i = 9;break;
        default: i = 10;break;
    }
    return i;
}

这种每个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的间接跳转的算法有很多,主要的手段都是通过控制流跟踪+规则匹配的方法的方法还原跳转表。具体方法将在后续章节中针对不同的反编译器专门介绍。

2-1、手动定位switch跳转表(以IDA作为示例)

有时候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/

3、no-return 场景

本来这个情况不应该放在这里的,但是别的地方更加不合适,所以放在这里了。

看下面这个代码示例

#include <stdio.h>
#include <stdlib.h>

void some_func() {
    //跳过一大堆代码
	printf("exit");
	exit(-2); // 这里直接退出了, 函数永远不会返回
}
int main() {
  some_func();
	printf("never here");
	return 0;
}

在优化后的代码中,main函数中的some_func后的内容printf("never here");不会被生成,即被优化掉。因为main函数不再返回,后面的代码也就是死代码了。

这种情况在真实代码中很常见,比如各种assert 或者 error 或者 exception函数的使用。

但是反编译器未必知道some_func函数不会再返回,可能还会继续翻译main函数下去,获得错误的内容。

3-1、IDA中的no_return函数判断规则

对于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/

3-2、Ghidra中的no_return函数判断规则

同理,在Ghidra中也有类似的配置文件,不过Ghidra的配置更为细致;对于不同的二进制格式(ELF、PE、Mach-O等)都有独立的判断规则。

规则文件在 “Ghidra\Features\Base\data\noReturnFunctionConstraints.xml”

<noReturnFunctionConstraints>
  <executable_format name="Executable and Linking Format (ELF)">
  	<compiler id="golang">
      <functionNamesFile>GolangFunctionsThatDoNotReturn</functionNamesFile>
  	</compiler>
    <functionNamesFile>ElfFunctionsThatDoNotReturn</functionNamesFile>
  </executable_format>
  <executable_format name="Mac OS X Mach-O">
    <functionNamesFile>MachOFunctionsThatDoNotReturn</functionNamesFile>
  </executable_format>
  <executable_format name="DYLD Cache">
    <functionNamesFile>MachOFunctionsThatDoNotReturn</functionNamesFile>
  </executable_format>
  <executable_format name="Portable Executable (PE)">
  	<compiler id="golang">
      <functionNamesFile>GolangFunctionsThatDoNotReturn</functionNamesFile>
  	</compiler>
    <functionNamesFile>PEFunctionsThatDoNotReturn</functionNamesFile>
  </executable_format>
</noReturnFunctionConstraints>

4、函数指针

最后一种场景,也是比较常见的场景,即函数指针。

函数指针的使用在多态中十分常见,比如C++的虚表指针,或者C语言中各种回调等等。

反编译使用预置的调用约定,很容易识别函数指针。但是在逆向工作中真正的困难是没有高层次的类型结构。

然而很不幸,当前常用的反编译器的反编译维度都只有函数级(我不确定binary-ninja对于C++语言的类型结构恢复能力,我看它的产品宣传有这部分的能力)。即,不支持对于类层次结构的恢复。关于高级语义恢复的话题,只能留到后面讨论了。

3、其它情况:隐式控制流

这一章节虽然讨论了一些主流的场景,但是这些场景的前提都是“显式控制流”,即,有专门的跳转指令(虽然反编译器未必可以正确的推导出来对应的地址)

还有一大部分没有考虑到的隐式控制流场景,他们往往和语言特性以及操作系统特性有关。例如:系统中断、try-catch异常、系统回调的注册、信号处理函数。

当前的常用反编译器往往也不能正确的处理这些场景,我目前没有对这部分有较为深入的研究。如果有时间,以后再把这部分补上。

Last updated