返回介绍

6.2 栈帧

发布于 2024-10-11 21:05:42 字数 19888 浏览 0 评论 0 收藏 0

因为 IDA Pro 是一种低级分析工具,要利用它的许多功能和显示窗口,需要用户熟悉低级编译语言,其中许多概念与生成机器语言和管理由高级程序使用的内存有关。为了更好地理解 IDA 中的一些显示,有必要不时介绍一些编译程序理论,以帮助理解相关的 IDA 显示

栈帧(stack frame )就是这样一种低级概念。 栈帧 是在程序的运行时栈中分配的内存块,专门用于特定的函数调用。程序员通常会将可执行语句分组,划分成叫做 函数 (也称 过程子例程方法 )的单元。有时候,这样做是遵照所使用的语言的要求。多数情况下,以这些函数单元为基础构建程序是一种良好的编程实践。

如果一个函数并未执行,通常它并不需要内存。但是,当函数被调用时,它就可能因为某种原因需要用到内存。这源于几方面的原因。其一,函数的调用方可能希望以参数(实参)的方式向该函数传递信息,这些参数需要存储到函数能够找到它们的位置。其二,在执行任务的过程中,函数可能需要临时的存储空间。程序员通常会通过声明局部变量来分配这类临时空间,这些变量将在函数内部使用,完成函数调用以后,就无法再访问它们。

编译器通过栈帧(也叫做 激活记录 )使得对函数参数和局部变量进行分配和释放的过程对程序员透明。在将控制权转交给函数之前,编译器会插入代码,将函数参数放入栈帧内,并分配足够的内存,以保存函数的局部变量。鉴于栈帧的结构,该函数的返回地址也存储在新的栈帧内。使用栈帧使得递归成为可能,因为每个递归函数调用都有它自己的栈帧,这恰好将当前调用与前一次调用分隔开来。下面是调用一个函数时的详细操作步骤。

  1. 调用方将被调用函数所需的任何参数放入到该函数所采用的调用约定(参见 6.2.1 节)指定的位置。如果参数被放到运行时栈上,该操作可能导致程序的栈指针发生改变。

  2. 调用方将控制权转交给被调用的函数,这个过程常由 x86 CALL 或 MIPS JAL 等指令执行。然后,返回地址被保存到程序栈或 CPU 寄存器中。

  3. 如有必要,被调用的函数会配置一个栈指针1 ,并保存调用方希望保持不变的任何寄存器值。

    1. 帧指针 是一个指向栈帧位置的寄存器。通常,栈帧内的变量根据它们与帧指针所指向的位置的相对距离来引用。

  4. 被调用的函数为它可能需要的任何局部变量分配空间。一般,通过调整程序栈指针在运行时栈上保留空间来完成这一任务。

  5. 被调用的函数执行其操作,可能生成一个结果。在执行操作的过程中,被调用的函数可能会访问调用函数传递给它的参数。如果函数返回一个结果,此结果通常被放置到一个特定的寄存器中,或者放置到函数返回后调用方可立即访问的寄存器中。

  6. 函数完成其操作后,任何为局部变量保留的栈空间将被释放。通常,逆向执行第 4 步中的操作,即可完成这个任务。

  7. 如果某个寄存器的值还为调用方保存(第 3 步)着,那么将其恢复到原始值。这包括恢复调用方的帧指针寄存器。

  8. 被调用的函数将控制权返还给调用方。实现这一操作的主要指令包括 x86 RET 和 MIPS JR 。根据所使用的调用约定,这一操作可能还会从程序栈中清除一个或多个参数。

  9. 调用方一旦重新获得控制权,它可能需要删除程序栈中的参数。这时可能需要对栈进行调整,以将程序栈指针恢复到第(1) 步以前的值。

第 3 步和第 4 步通常在进入函数时执行,它们共同称为该函数的 序言 。同样,第 6 步到第 8 步一般在函数结束时执行,它们共同构成该函数的 尾声 。而第 5 步则代表函数的主体,它们是调用一个函数时执行的全部操作。

6.2.1 调用约定

了解栈帧的基本概念后,接下来详细介绍它们的结构。下面的例子涉及 x86 体系结构和与常见的 x86 编译器(如 Mircosoft Visual C/C++或 GNU 的 gcc/g++)有关的行为。创建栈帧时最重要的步骤是,通过调用函数将函数参数存入栈中。调用函数必须存储被调用函数所需的参数,否则可能导致严重的问题。各个函数会选择并遵照某一特定的调用约定,以表明它们希望以何种方式接收参数。

调用约定 指定调用方放置函数所需参数的具体位置。调用约定可能要求将参数放置在特定的寄存器、程序栈、或者寄存器和栈中。同样重要的是,在传递参数时,程序栈还要决定:被调用函数完成其操作后,由谁负责从栈中删除这些参数。一些调用约定规定,由调用方负责删除它放置在栈中的参数,而另一些调用约定则要求被调用函数负责删除栈中的参数。遵照指定的调用约定对于维护程序栈指针的完整性尤为重要。

1. C 调用约定

x86 体系结构的许多 C 编译器使用的默认调用约定叫做 C 调用约定 。如果默认的调用约定被重写,则 C/C++ 程序中常用的 _cdecl 修饰符会迫使编译器利用 C 调用约定。自现在开始,我们把这种调用约定叫做 cdecl 调用约定。 cdecl 调用约定规定:调用方按从右到左的顺序将函数参数放入栈中,在被调用的函数完成其操作时,调用方(而不是被调用方)负责从栈中清除参数。

从右到左在栈中放入参数的一个结果是,如果函数被调用,最左边的(第一个)参数将始终位于栈顶。这样,无论该函数需要多少个参数,我们都可轻易找到第一个参数。因此, cdecl 调用约定非常适用于那些参数数量可变的函数(如 printf )。

要求调用函数从栈中删除参数,意味着你将经常看到:指令在由被调用的函数返回后,会立即对程序栈指针进行调整。如果函数能够接受数量可变的参数,则调用方非常适于进行这种调整,因为它清楚地知道,它向函数传递了多少个参数,因而能够轻松做出正确的调整。而被调用的函数事先无法知道自己会收到多少个参数,因而很难对栈做出必要的调整。

在下面的例子中,我们调用一个拥有以下原型的函数:

void demo_cdecl(int w, int x, int y, int z);

默认情况下,这个函数将使用 cdecl 调用约定,并希望你按从右到左的顺序压入 4 个参数,同时要求调用方清除栈中的参数。编译器可能会为这个函数的调用生成以下代码:

   ; demo_cdecl(1, 2, 3, 4);   //programmer calls demo_cdecl
➊  push   4           ; push parameter z  
   push   3           ; push parameter y  
   push   2           ; push parameter x  
   push   1           ; push parameter w  
   call   demo_cdecl  ; call the function  
➋  add    esp, 16     ; adjust esp to its former value  

从➊ 开始的 4 个 push 操作使程序栈指针(ESP )发生 16 个字节(在 32 位体系结构上为 4*sizeof(int) )的变化,从 demo_cdecl 返回后,它们在➋处被撤销。如果 demo_cdecl 被调用 50 次,那么,每次调用之后,都会发生类似于➋处的调整。下面的例子同样遵照 cdecl 调用约定,但是,在每次调用 demo_cdecl 后,调用方不需要删除栈中的参数。

; demo_cdecl(1, 2, 3, 4);   //programmer calls demo_cdecl  
   mov   [esp+12], 4   ; move parameter z to fourth position on stack  
   mov   [esp+8], 3    ; move parameter y to third position on stack  
   mov   [esp+4], 2    ; move parameter x to second position on stack  
   mov   [esp], 1      ; move parameter w to top of stack  
   call   demo_cdecl  ; call the function  

在这个例子中,在函数的“序言”阶段,编译器已经在栈顶为 demo_cdecl 的参数预先分配了存储空间。在 demo_cdecl 的参数放到栈上时,并不需要修改程序栈指针,因此,在调用 demo_cdecl 结束后,也就不需要调整栈指针。GNU 编译器(gcc 和 g++ )正是利用这种技巧将函数参数放到栈上的。注意,无论采用哪一种方法,在调用函数时,栈指针都会指向最左边的参数。

2. 标准调用约定

这里的 标准 似乎有些用词不当,因为它是微软为自己的调用约定所起的名称。这种约定在函数声明中使用了修饰符 _stdcall ,如下所示:

void _stdcall demo_stdcall(int w, int x, int y);

为避免 标准 一词引起混淆,在本书的剩余部分,我们将这种调用约定称为 stdcall 调用约定。

cdecl 调用约定一样, stdcall 调用约定按从右到左的顺序将函数参数放在程序栈上。使用 stdcall 调用约定的区别在于:函数结束执行时,应由被调用的函数负责删除栈中的函数参数。对被调用的函数而言,要完成这个任务,它必须清楚知道栈中有多少个参数,这只有在函数接受的参数数量固定不变时才有可能。因此, printf 这种接受数量可变的参数的函数不能使用 stdcall 调用约定。例如, demo_stdcall 函数需要 3 个整数参数,在栈上共占用 12 个字节(在 32 位体系结构上为 3 * sizeof(int) )的空间。x86 编译器能够使用 RET 指令的一种特殊形式,同时从栈顶提取返回地址,并给栈指针加上 12,以清除函数参数。 demo_stdcall 可能会使用以下指令返回到调用方:

ret 12     ; return and clear 12 bytes from the stack

使用 stdcall 的主要优点在于,在每次函数调用之后,不需要通过代码从栈中清除参数,因而能够生成体积稍小、速度稍快的程序。根据惯例,微软对所有由共享库(DLL )文件输出的参数数量固定的函数使用 stdcall 约定。如果你正尝试为某个共享库组件生成函数原型或与二进制兼容的替代者,请一定记住这一点。

3. x86 fastcall 约定

fastcall 约定是 stdcall 约定的一个变体,它向 CPU 寄存器(而非程序栈)最多传递两个参数。Microsoft Visual C/C++ 和 GNU gcc/g++(3.4 及更低版本)编译器能够识别函数声明中的 fastcall 修饰符。如果指定使用 fastcall 约定,则传递给函数的前两个参数将分别位于 ECX 和 EDX 寄存器中。剩余的其他参数则以类似于 stdcall 约定的方式从右到左放入栈上。同样与 stdcall 约定类似的是,在返回其调用方时, fastcall 函数负责从栈中删除参数。下面的声明中即使用了 fastcall 修饰符:

void fastcall demo_fastcall(int w, int x, int y, int z);

为调用 demo_fastcall ,编译器可能会生成以下代码:

; demo_fastcall(1, 2, 3, 4);   //programmer calls demo_fastcall  
   push   4              ; move parameter z to second position on stack  
   push   3              ; move parameter y to top position on stack  
   mov    edx, 2         ; move parameter x to edx  
   mov    ecx, 1         ; move parameter w to ecx  
   call   demo_fastcall  ; call the function

注意,调用 demo_fastcall 返回后,并不需要调整栈,因为 demo_fastcall 负责在返回到调用方时从栈中清除参数 yz 。由于有两个参数被传递到寄存器中,被调用的函数仅仅需要从栈中清除 8 字节,即使该函数拥有 4 个参数也是如此,理解这一点很重要。

4. C++ 调用约定

C++ 类中的非静态成员函数与标准函数不同,它们需要使用 this 指针,该指针指向用于调用函数的对象。用于调用函数的对象的地址必须由调用方提供,因此,它在调用非静态成员函数时作为参数提供。C++ 语言标准并未规定应如何向非静态成员函数传递 this 指针,因此,不同编译器使用不同的技巧来传递 this 指针,这点也就不足为奇了。

Microsoft Visual C++ 提供 thiscall 调用约定,它将 this 传递到 ECX 寄存器中,并且和在 stdcall 中一样,它要求非静态成员函数清除栈中的参数。GNU g++编译器将 this 看成是任何非静态成员函数的第一个隐含参数,而在所有其他方面与使用 cdecl 约定相同。因此,对使用 g++ 编译的代码来说,在调用非静态成员函数之前, this 被放置到栈顶,且调用方负责在函数返回时删除栈中的参数(至少有一个参数)。已编译的 C++ 代码的其他特性将在第 8 章中讨论。

5. 其他调用约定

要完整地介绍现有的每一个调用约定,可能需要写一本书。调用约定通常是特定于语言、编译器和 CPU 的。如果遇到由更少见的编译器生成的代码,可能需要你自己进行一番研究。但是,以下这些情况需要特别注意:优化代码、定制汇编语言代码和系统调用。

如果输出函数(如库函数)是为了供其他程序员使用,那么,它必须遵照主流的调用约定,以便程序员能够轻松调用这些函数。另外如果函数仅供内部程序使用,则该函数需要采用只有函数的程序才了解的调用约定。在这类情况下,优化编译器会选择使用备用的调用约定,以生成运行速度更快的代码。这样的例子包括:在 Microsoft Visual C++中使用 /GL 选项,以及在 GNU gcc/g++中使用 regparm 关键字。

如果程序员不怕麻烦,使用了汇编语言,那么,他们就能够完全控制如何向他们创建的函数传递参数。除非他们希望创建供其他程序员使用的函数,否则,汇编语言程序员能够以任何他们认为适当的方式传递参数。因此,在分析自定义汇编代码时,请格外小心。在模糊例程(obfuscation routine )和 shellcode 中经常可以看到自定义汇编代码。

系统调用 是一种特殊的函数调用,用于请求一项操作系统服务。通常,系统调用会造成状态转换,由用户模式进入内核模式,以便操作系统内核执行用户的请求。启动系统调用的方式因操作系统和 CPU 而异。例如,Linux x86 系统调用使用 int 0x80 指令或 sysenter 指令启动,而其他 x86 操作系统可能只使用 sysenter 指令。在许多 x86 系统(Linux 是一个例外)上,系统调用的参数位于运行时栈上,并在启动系统调用之前,在 EAX 寄存器中放入一个系统调用编号。Linux 系统调用接受位于特定寄存器中的参数,有时候,如果可用寄存器无法存储所有的参数,它也接受位于内存中的参数。

6.2.2 局部变量布局

存在规定如何向函数传递参数的调用约定,但不存在规定函数的局部变量布局的约定。编译器的第一个任务是,计算出函数的局部变量所需的空间。编译器的第二个任务,则是确定这些变量是否可在 CPU 寄存器中分配,或者它们是否必须在程序栈上分配。至于具体的分配方式,既与函数的调用方无关,也与被调用的函数无关。值得注意的是,通过检查函数的源代码,通常无法确定函数的局部变量布局。

6.2.3 栈帧示例

以下面这个在 32 位 x86 计算机上编译的函数为例:

void bar(int j, int k);   // a function to call  
void demo_stackframe(int a, int b, int c) {  
   int x;  
   char buffer[64];  
   int y;  
   int z;  
   // body of function not terribly relevant other than  
   bar(z, y);  
}

计算得出,局部变量最少需要 76 字节的栈空间(3 个 4 字节整数和 1 个 64 字节缓冲区)。这个函数可能使用 stdcallcdecl 调用约定,它们的栈帧完全相同。如图 6-3 所示是一个用于调用 demo_stackframe 的栈帧实现,假设它并没有使用帧指针寄存器(因此栈指针 ESP 作为帧指针)。进入 demo_stackframe 时,可以使用下面的一行“序言”配置这个栈帧:

sub   esp, 76     ; allocate sufficient space for all local variables

其中的“偏移量”栏显示的是引用栈帧中的任何局部变量或参数所需的基址+位移地址:

enter image description here

图 6-3 基于 ESP 的栈帧

生成利用栈指针计算所有变量引用的函数需要编译器做更多工作,因为栈指针会频繁变化,编译器必须确保它在引用栈帧中的任何变量时始终使用了正确的偏移量。以对 demo_stack- frame 函数中 bar 的调用代码为例:

➊  push   dword [esp+4]     ; push y  
➋  push   dword [esp+4]     ; push z  
   call   bar  
   add    esp, 8              ; cdecl requires caller to clear parameters

根据图 6-3 中的偏移量,➊ 处的 push 准确地将局部变量 y 压入栈中。初看起来,似乎➋ 处的 push 错误地再次引用了局部变量 y 。但是,因为我们处理的是一个基于 ESP 的帧,且➊ 处的 push 修改 ESP ,所以每次 ESP 发生改变,图 6-3 中的所有偏移量都会临时进行调整。于是,在➊ 之后,➋ 处的 push 中正确引用的局部变量 z 的新偏移量变为 [esp+4] 。在分析使用栈指针引用栈帧变量的函数时,你必须小心,注意栈指针的任何变化,并对所有未来的变量偏移量进行相应调整。使用栈指针引用所有栈帧变量的好处在于:所有其他寄存器仍可用于其他目的。

demo_stackframe 完成后,它需要返回调用方。最终,需要使用 ret 指令从栈顶弹出所需返回地址,并将其插入指令指针寄存器(此时为 EIP)中。在弹出返回地址之前,需要从栈顶删除局部变量,以便在 ret 指令执行时,栈指针正确地指向所保存的返回地址。这个特殊函数的“尾声”如下所示:

add     esp, 76     ; adjust esp to point to the saved return address  
ret                 ; return to the caller

由于专门使用一个寄存器作为帧指针,并通过一段代码在函数入口点配置了帧指针,因此,计算局部变量偏移量的工作变得更加轻松。在 x86 程序中,EBP (extended base pointer , 扩展基址指针 )寄存器通常专门用作栈帧指针。默认情况下,多数编译器会生成代码以使用帧指针,而无视规定应使用栈指针的选项。例如,GNU gcc/g++ 提供了 -fomit-frame-pointer 编译器选项,可生成不依赖于固定帧指针寄存器的函数。

为了解使用专用帧指针的 demo_stackframe 栈帧的结构,我们以下面这段“序言”代码为例:

➌  push    ebp        ; save the caller's ebp value  
➍  mov     ebp, esp   ; make ebp point to the saved register value  
➎  sub     esp, 76    ; allocate space for local variables  

➌ 处的 push 指令保存当前调用方使用的 EBP 的值。遵循用于 Intel 32 位处理器的系统 V 应用程序二进制接口(System V Application Binary Interface)2 的函数可以修改 EAX、ECX 和 EDX 寄存器,但需要为所有其他寄存器保留调用方的值。因此,如果希望将 EBP 作为帧指针,那么,在修改它之前,必须保存 EBP 的当前值,并且在返回调用方时恢复 EBP 的值。如果需要为调用方保存其他寄存器(如 ESI 或 EDI ),编译器可能会在保存 EBP 的同时保存这些寄存器,或者推迟保存操作,直到局部变量已经得到分配。因此,栈帧中并没有用于存储被保存寄存器的标准位置。

2. 参见 http://www.sco.com/developers/devspecs/abi386-4.pdf

EBP 被保存后,就可以对其进行修改,使它指向当前的栈位置。这由➍处的 mov 指令来完成,它将栈指针的当前值复制到 EBP 中。最后,和在非基于 EBP 的栈帧中一样,局部变量的空间在➎处分配。得到的栈帧布局如图 6-4 所示。

enter image description here

图 6-4 基于 EBP 的栈帧

使用一个专用的帧指针,所有变量相对于帧指针寄存器的偏移量都可以计算出来。许多时候(尽管并无要求),正偏移量用于访问函数参数,而负偏移量则用于访问局部变量。使用专用的帧指针,我们可以自由更改栈指针,而不至影响帧内其他变量的偏移量。现在,对函数 bar 的调用可以按以下方式执行:

➏  push   dword [ebp-72]  ; push y  
    push   dword [ebp-76]       ; push z  
    call   bar  
    add    esp, 8               ; cdecl requires caller to clear parameters

在执行➏处的 push 指令后,栈指针已经发生改变,但这不会影响到随后的 push 指令对局部变量 z 的访问。

最后,函数完成其操作后,使用帧指针需要一段稍有不同的“尾声”代码,因为在返回前,必须恢复调用方的帧指针。在检索帧指针的初始值之前,必须从栈中清除局部变量。不过,由于当前的帧指针指向最初的帧指针,这个任务可轻松完成。在使用 EBP 作为帧指针的 x86 程序中,下面的代码是一段典型的“尾声”代码:

mov    esp, ebp      ; clears local variables by reseting esp  
pop    ebp           ; restore the caller's value of ebp  
ret                  ; pop return address to return to the caller

由于这项操作十分常见,因此,x86 体系结构提供了 leave 指令,以轻松完成这个任务。

leave                ; copies ebp to esp AND then pops into ebp  
ret                  ; pop return address to return to the caller

其他处理器体系结构使用的寄存器和指令肯定会有所不同,但构建栈帧的基本过程并无明显差异。无论是何种体系结构,你都需要熟悉典型的“序言”和“尾声”代码,以便迅速开始分析函数中你更感兴趣的代码。

6.2.4 IDA 栈视图

很明显,栈帧是一个运行时概念,没有栈和运行中的程序,栈帧就不可能存在。话虽如此,但这并不意味着你在使用 IDA 之类的工具进行静态分析时,就可以忽略栈帧的概念。二进制文件中包含配置每个函数的栈帧所需的全部代码。通过仔细分析这段代码,我们可以深入了解任何函数的栈帧的结构,即使这个函数并未运行。实际上,IDA 中的一些最复杂的分析,就是为了专门确定 IDA 反汇编的每个函数的栈帧的布局。在初始分析过程中,IDA 会记住每一项 pushpop 操作,以及其他任何可能改变栈指针的算术运算,如增加或减去常量,尽其所能去监控栈指针在函数执行过程中的行为。这项分析的第一个目标是确定分配给函数栈帧的局部变量区域的具体大小。其他目标包括:确定某函数是否使用一个专用的帧指针(例如,通过识别 push ebp/mov ebp, esp 序列),以及识别对函数栈帧内变量的所有内存引用。例如,如果 IDA 在 demo_stackframe 的正文中发现以下指令:

mov    eax, [ebp+8]

它就知道,函数的第一个参数(此时为 a )被加载到 EAX 寄存器中(见图 6-4)。通过仔细分析栈帧的结构,IDA 能够区分访问函数参数(位于被保存的返回地址之下)的内存引用及访问局部变量(位于被保存的返回地址之上)的引用。IDA 还会采取额外的步骤,确定栈帧内的哪些内存位置被直接引用。例如,虽然图 6-4 中栈帧的大小为 96 字节,但我们只会看到 7 个变量(4 个局部变量和 3 个参数)被引用。

了解函数的行为通常归结为了解该函数操纵的数据的类型。在阅读反汇编代码清单时,查看函数的栈帧细目,是你了解函数所操纵的数据的第一个机会。IDA 为任何函数栈帧都提供了两种视图:摘要视图和详细视图。为了解这两种视图,我们以下面使用 gcc 编译的 demo_stackframe 函数为例:

void demo_stackframe(int a, int b, int c) {  
   int x = c;  
   char buffer[64];  
   int y = b;  
   int z = 10;  
   buffer[0] = 'A';  
   bar(z, y);  
}

在这个例子中,我们分别为变量 xy 提供了初始值 cb 。为变量 z 提供了初始值常量 10。另外,64 字节局部数组 buffer 的第一个字符被初始化为字母 'A' 。这个函数对应的 IDA 反汇编代码如下:

  .text:00401090 ; ========= S U B R O U T I N E ===========================
  .text:00401090  
  .text:00401090 ; Attributes:  ➊ bp-based frame  
  .text:00401090  
  .text:00401090 demo_stackframe proc near      ; CODE XREF: sub_4010C1+41↓ p  
  .text:00401090  
➍ .text:00401090 var_60          = dword ptr -60h  
  .text:00401090 var_5C          = dword ptr -5Ch  
  .text:00401090 var_58          = byte ptr -58h  
  .text:00401090 var_C           = dword ptr -0Ch  
  .text:00401090 arg_4           = dword ptr  0Ch  
  .text:00401090 arg_8           = dword ptr  10h  
  .text:00401090  
  .text:00401090 push    ebp  
  .text:00401091 mov     ebp, esp  
  .text:00401093 sub     esp, ➋ 78h  
  .text:00401096 mov     eax, [ebp+ ➎ arg_8]  
  .text:00401099 ➏ mov     [ebp+var_C], eax  
  .text:0040109C ➐ mov     eax, [ebp+arg_4]  
  .text:0040109F ➐ mov     [ebp+var_5C], eax  
  .text:004010A2 ➑ mov     [ebp+var_60], 0Ah  
  .text:004010A9 ➒ mov     [ebp+var_58], 41h  
  .text:004010AD                 mov     eax, [ebp+var_5C]  
  .text:004010B0 ➌ mov     [esp+4], eax  
  .text:004010B4                 mov     eax, [ebp+var_60]  
  .text:004010B7 ➌ mov     [esp], eax  
  .text:004010BA                 call    bar  
  .text:004010BF                 leave  
  .text:004010C0                 retn  
  .text:004010C0 demo_stackframe endp  

下面我们介绍以上代码中的许多内容,以逐步熟悉 IDA 的反汇编代码。首先从➊开始,基于对函数“序言”代码的分析,IDA 认为这个函数使用 EBP 寄存器作为栈指针。从位置➋得知,gcc 在栈帧中分配了 120 字节(78h 等于 120)的局部变量空间,这包括用于向➌处的 bar 传递两个参数的 8 字节;但是,它仍然远大于我们前面估算的 76 字节,这表示编译器有时会用额外的字节填补局部变量空间,以确保栈帧内的特殊对齐方式。从➍开始,IDA 提供了一个摘要栈视图,列出了栈帧内被直接引用的每一个变量,以及变量的大小和它们与帧指针的偏移距离。

IDA 会根据变量相对于被保存的返回地址的位置,为变量取名。局部变量位于被保存的返回地址之上,而函数参数则位于被保存的返回地址之下。局部变量名称以 var_ 为前缀,后面跟一个表示变量与被保存的帧指针之间距离(以字节为单位)的十六进制后缀。在本例中,局部变量 var_C 是一个 4 字节(dword )变量,它位于所保存的帧指针之上,距离为 12 字节( [ebp-oCh] )。函数参数名则以 arg_ 为前缀,后面跟一个表示其与最顶端的参数之间的相对距离的十六进制后缀。因此,最顶端的 4 字节参数名为 arg_0 ,而随后的参数则分别为 arg_4arg_8arg_C ,以此类推。在这个特例中, arg_0 并未列出,因为函数没有使用参数 a 。由于 IDA 无法确定任何对 [ebp+8] (第一个参数的位置)的内存引用,所以 arg_0 并未在摘要栈视图中列出。迅速浏览一下摘要栈视图即可发现,许多栈位置都没有命名,因为在程序代码中找不到对这些位置的直接引用。

说明  IDA 只会为那些在函数中直接引用的栈变量自动生成名称。

IDA 反汇编代码清单与我们前面执行的栈帧分析之间的一个重要区别在于,在反汇编代码清单中无法找到类似于 [ebp-12] 的内存引用。相反,IDA 已经用与栈视图中的符号对应的符号名称,以及它们与栈帧指针的相对偏移量替代了所有常量偏移量。这样做是为了确保 IDA 生成更高级的反汇编代码。与处理数字常量相比,处理符号名称更容易一些。实际上,为方便我们记忆栈变量的名称,IDA 允许任意修改任何栈变量的名称,稍后介绍这一点。摘要栈视图则是从 IDA 生成的名称到它们对应的栈帧偏移量之间的一个“地图”。例如,在反汇编代码清单中出现内存引用 [ebp+arg_8] 的地方,可以使用 [ebp+10h][ebp+16] 代替。如果你更希望看到数字偏移量,IDA 会乐于为你显示。右击➎处的 arg_8 ,将会出现如图 6-5 所示的上下文菜单,它提供了几个可用于更改显示格式的选项。

enter image description here

图 6-5 选择一种替代的显示格式

在这个特例中,由于可以对照源代码,我们可以利用反汇编窗口中的一系列线索,将 IDA 生成的变量名称与源代码中使用的名称对应起来。

  1. 首先, demo_stackframe 使用了 3 个参数: abc 。它们分别与变量 arg_0arg_4arg_8 对应(尽管 arg_0 因没有被引用而被反汇编代码清单忽略了)。

  2. 局部变量 x 由参数 c 初始化。因此, var_Cx 对应,因为 x 由➏处的 arg_8 初始化。

  3. 同样,局部变量 y 由参数 b 初始化。因此, var_5Cy 对应,因为 y 由➐处的 arg_4 初始化。

  4. 局部变量 zvar_60 对应,因为它由➑处的值 10 初始化。

  5. 64 字节的字符数组 buffervar_58 处开始,因为 buffer[0] 由➒处的 A (ASCII 0x41)初始化。

  6. 调用 bar 的两个变量被转移到➌处的栈中,而非压入栈。这是当前版本(3.4 及更高版本)的 gcc 的典型做法。IDA 认可这一约定并选择不为栈帧顶部的两项创建局部变量引用。

除摘要栈视图外,IDA 还提供一个详细栈帧视图,这种视图会显示一个栈帧所分配到的每一个字节。双击任何与某一给定的栈帧有关的变量名称,即可进入详细视图。在前一个列表中,双击 var_C 将打开如图 6-6 所示的栈帧视图(按 ESC 键关闭该窗口)。

enter image description here

图 6-6 IDA 栈帧视图

由于详细视图显示栈帧中的每一个字节,它占用的空间会比摘要视图(仅列出被引用的变量)多许多。图 6-6 中显示的栈帧部分一共跨越 32 字节,但它仅占整个栈帧的一小部分。注意,函数仅为直接引用的字节分配了名称。例如,与 arg_0 对应的参数 a ,在 demo_stackframe 中从未被引用。由于没有内存引用可供分析,IDA 选择不处理栈中的对应字节,它们的偏移量由 +00000008+0000000B 。另一方面,在反汇编代码清单中, arg_4 在➐处被直接引用,且其内容被加载到 32 位 EAX 寄存器中。基于有 32 位数据被转移这一事实,IDA 得出推断, arg_4 是一个 4 字节变量,并将其标记如此( db 定义一个存储字节, dw 定义两个存储字节,也叫做字; dd 定义 4 个存储字节,也叫做 双字 )。

图 6-6 中显示的两个特殊值分别为 sr (前面均带有空格)。这些伪变量是 IDA 表示被保存的返回地址( r )和被保存的寄存器值( s ,在本例中, s 仅代表 EBP )的特殊方法。由于需要显示栈帧中的每一个字节,为体现完整性,这些值也包含在栈帧视图中。

栈帧视图有利于我们深入分析编译器的内部工作机制。在图 6-6 中,很明显,编译器在保存的帧指针 s 与局部变量 x(var_C) 之间额外插入了 8 字节。在栈帧中,这些字节的偏移量为 -00000001-00000008 。另外,对与摘要视图中列出的每一个变量有关的偏移量进行几次算术运算,即可发现:编译器给位于 var_58 的字符缓冲区分配了 76 字节(而非源代码中的 64 字节)。如果你是一名编译器开发者,或者愿意深入分析 gcc 的源代码,否则,你只能推测编译器如此分配这些额外字节的原因。多数情况下,你可以将分配这些额外字节的原因归结成为对齐所做的填补,而且这些字节通常不会影响程序的行为。毕竟,如果程序员要求 64 字节,却得到 76 字节,程序应该不会表现出不同的行为,特别是程序员使用的字节没有超出所请求的 64 字节的情况下。另一方面,如果你是一名破解程序开发人员,并且知道可以使这个特殊的缓冲区溢出;那么,你应该认识到,你至少得提供 76 字节(就编译器而言,这是缓冲区的有效大小),否则你希望看到的事就不会发生。在第 8 章中,我们将再次讨论栈帧视图,以及它在处理数组和结构体等更加复杂的数据类型时的用法。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文