4.调用约定识别-参数与返回值

下面这个图十分重要,是我们会在反编译过程中遇到的最常见的各种调用约定。这整一张的内容就从这个图开始吧。

ref: https://www.agner.org/optimize/calling_conventions.pdf

什么是调用约定

调用约定,或者说是ABI,实际上是编译器+指令集+平台的一类特定规范;使用这种规范,调用约定确保了子函数调用的局部性。

ref:https://en.wikipedia.org/wiki/Application_binary_interface

ref:https://www.agner.org/optimize/calling_conventions.pdf

为什么调用约定的识别那么重要?

当然是因为调用约定包含了大量的编译器高层抽象信息啊!!

要知道,反编译反编译,反的是什么,当然是“编译器”啊。哪个功能包含的编译器信息多,哪个功能就重要。所以调用约定放到最前面来。

对于反编译器,识别出编译器的调用约定不仅对于还原函数参数原型,对于其它分析也有很重要的意义,下面是一个简单的例子。

EAX = 1
EBX = 2
ECX = 3
//进行了一次函数的调用
call func_test
//函数调用返回后,寄存器的值会变吗?(寄存器会被kill掉吗
EAX = ? // EAX==1 ??
EBX = ? // EBX==2 ??
ECX = ? // ECX==3 ??

调用约定的其它功能就是处理函数调用前后的各种事情,比如:

  • 什么寄存器会被kill掉(在子函数中会被覆盖)

  • 什么寄存器会被保留(在子函数不被覆盖)

  • 哪些寄存器由调用者维护

  • 哪些寄存器由被调用者维护(维护,一般就是指push入栈,出来后pop回去)

  • 函数参数如何进行传递

  • 函数返回值如何传递

  • 函数栈平衡在哪里维护,调用者和被调用者怎么管理栈

调用约定—反编译的困难

主要的困难在于:调用约定有很多种,不同的语言,不同的编译器、不同的平台上都用的不一样。

windows和linux有各自的ABI;还有编译器的问题,对这很麻烦,比如:

  • gcc编译器

  • LLVM编译器

  • Go语言

  • Rust语言

  • C++语言的this指针

  • objective-C的msgSend

等等,无论是编译器/编程语言/执行平台,它们的调用约定都是可能不同的。

对于调用约定的识别,本身是对二进制编写的语言平台架构、编写语言、程序上下文、栈平衡、跨函数的程序分析一些列的混合工作。

判断以下的情况:

  • 反编译如何自动判断二进制是用什么编译器编出来的?

  • 如何自动判断二进制是哪种语言写的?

  • 正确的处理的处理子函数调用约定

  • 混合更多识别方法的调用约定识别场景

  • 对于无法正确识别的调用约定,怎么处理?

  • 用户如何自定义调用约定

反编译器调用约定处理的通用方案

调用约定的推导,从文件载入的时候就开始了,通过文件格式,能够知道文件运行的平台是linux、windows、mac等等。

绝大多数可执行文件格式,会声明执行文件的指令集abi等等,例如elf文件就可以使用readelf -h 指令查看:

╰─$ readelf -h a.out
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              DYN (Position-Independent Executable file)
  Machine:                           Advanced Micro Devices X86-64

完成对于指令集和平台的判断,对于C、C++这一类低级语言,我们已经可以进行调用约定推导了。

但是对于Go、rust等高级编译型语言的支持。业界对于这部分的研究是不足的,通用的反编译器支持方法不同。

Ghidra中的调用约定推导

Ghidra中的调用约定来自于用户的配置,用户需要在二进制导入时选择使用哪个调用约定描述文件。

调用约定描述文件CSPES

Ghidra的配置项中提供了xxxx.cspec文件,专门用于描述调用约定。

ref:https://spinsel.dev/assets/2020-06-17-ghidra-brainfuck-processor-1/ghidra_docs/compiler_spec/index.htm

这个文件详细的描述了:平台+指令集+编译器,对不同的架构都有对应的配置文件

例如x86指令集的gcc编译器在Ghidra的Processors\x86\data\languages\x86gcc.cspec中。

下面这段代码是EBPF的调用约定描述(不愧是开源,Ghidra居然支持这玩意?)大家可以参考并体验一下。

<?xml version="1.0" encoding="UTF-8"?>
<compiler_spec>
  <data_organization> 
     <absolute_max_alignment value="0" />
     <machine_alignment value="2" />
     <default_alignment value="1" />
     <default_pointer_alignment value="8" />
     <pointer_size value="8" />
     <wchar_size value="2" />
     <short_size value="2" />
     <integer_size value="4" />
     <long_size value="4" />
     <long_long_size value="8" />
     <float_size value="4" />
     <double_size value="8" />
     <long_double_size value="8" />
     <size_alignment_map>
          <entry size="1" alignment="1" />
          <entry size="2" alignment="2" />
          <entry size="4" alignment="4" />
          <entry size="8" alignment="8" />
     </size_alignment_map>
  </data_organization>
   <global> 
   	  <range space="ram"/>
   	  <range space="syscall"/>
   </global> 
  <stackpointer register="R10" space="ram"/>
   <default_proto>
    <prototype name="__fastcall" extrapop="0" stackshift="0">
      <input>
        <pentry minsize="1" maxsize="8">
          <register name="R1"/>
        </pentry>
        <pentry minsize="1" maxsize="8">
          <register name="R2"/>
        </pentry>
        <pentry minsize="1" maxsize="8">
          <register name="R3"/>
        </pentry>      
         <pentry minsize="1" maxsize="8">
          <register name="R4"/>
        </pentry>
         <pentry minsize="1" maxsize="8">
          <register name="R5"/>
        </pentry>
      </input>
      <output>
        <pentry minsize="1" maxsize="8">
          <register name="R0"/>
        </pentry>
       </output>
      <unaffected>
        <varnode space="ram" offset="8" size="8"/>
        <register name="R6"/>       
	    <register name="R7"/>
        <register name="R8"/>
        <register name="R9"/>       
		<register name="R10"/> 	
      </unaffected> 
    </prototype>
  </default_proto>
 </compiler_spec>

但是Ghidra并不支持对语言的识别,而是将这个工作交给了用户,当导入二进制时需要手动选择语言;

如下图,我导入了一个32位的程序,Ghidra支持go、gcc、VS、Delphi等编译器。用户选择了哪个编译器,就会使用哪个.cspec文件中的调用约定配置。

不对!这不是甩锅给用户吗?Ghidra你是不是不太偷懒了啊??这个功能都不愿意做吗??

IDA中的调用约定推导

你这IDA都已经8.3版本了,对自定义的调用约定的支持可以说是几乎没有。

虽然IDA支持通过usercall等关键词来指定自己的调用约定,可是用过的都知道这玩意一点也不好用,临时应急都不好使。

ref:https://hex-rays.com/blog/igors-tip-of-the-week-51-custom-calling-conventions/

翻看IDA-sdk-processor-module ,会发现IDA的宏写死了对调用约定(在IDA中称为CC)的类型。其中最新的一个是对Go语言的支持。

至于IDA的调用约定推导机制更是不公开的,在ida/ida64.dll中,因此没办法通过正常方法知道它是怎么实现的。

函数的参数识别

存在两种典型场景,第一种,函数识别自己本体的参数,第二种,识别调用的函数的参数。

现在的反编译器,识别自己本身的函数参数准确度已经比较高了。但是由于缺少跨函数的分析,哪怕是IDA在对调用函数的参数判断上,也经常会出问题。

一个通用的本函数参数的识别算法描述(来自与angr的实现):

1、构造函数本体的define-use关系

2、从函数入口开始,列举所有有ues却没有define的“寄存器”

3、将这些寄存器依次放入二进制架构与编译器中可能调用约定的进行匹配,选出最适合的。

如下图就能确定使用的是fastcall的rdi、rsi、rdx三个参数

返回值识别

与参数相反,准确的识别函数自己本身的返回值,比较困难。因为传入的函数参数参数会被直接使用;

而对于返回值,比如x86_64指令集最常使用的rax作为返回值,

反编译器往往选择比较保守的做法,只要返回值的寄存器在函数退出时被定义过,那就反编译器就认为反编译的函数有返回值。

一个通用的调用子函数的返回值算法描述,

1、构建define-use关系

2、优先通过传入参数,判断可能的调用约定(如果函数未进行分析)

3、基于调用约定与define-use关系,增加对返回值define的信息。

栈平衡

在X86_64的平台,最典型的的做法,在函数开头将寄存器push到栈中,在函数结束的时候再pop出来。反编译器也会依照调用约定中的“不受影响寄存器”,尝试处理栈平衡。

例如上图使用了IDA作为示例,IDA的寄存器栈平衡处理在转换中间语言前就完成了,IDA在转换micorocode前会将能够相互对应的入栈与出栈的语句全部删去,这样后续的优化就不用烦恼这些问题了。

Last updated