跳转到内容

x86 反汇编/函数和栈帧

来自维基教科书,开放的书籍,开放的世界

函数和栈帧

[编辑 | 编辑源代码]

在执行环境中,函数通常使用“栈帧”来访问函数参数和自动局部函数变量。栈帧的理念是,每个子程序都可以独立于其在栈中的位置运行,并且每个子程序都可以像栈顶一样运行。

当调用函数时,将在当前esp位置创建一个新的栈帧。栈帧就像栈上的一个分区。来自之前函数的所有项都在栈中更上一层,不应修改。当前函数可以访问从栈帧到栈页面末尾的整个栈。当前函数始终可以访问栈的“顶部”,因此函数不需要考虑其他函数或程序的内存使用情况。

标准入口序列

[编辑 | 编辑源代码]

对于许多编译器来说,标准函数入口序列是以下代码片段(X 是函数中使用的所有自动局部变量的总大小,以字节为单位)

push ebp
mov ebp, esp
sub esp, X

例如,这里是一个 C 函数代码片段及其生成的汇编指令(使用 ABI 标准生成的代码将不会有 "sub esp, 12" 指令,因为存在红色区域)

void MyFunction()
{
  int a, b, c;
  ...
_MyFunction:
  push ebp     ; save the value of ebp
  mov ebp, esp ; ebp now points to the top of the stack
  sub esp, 12  ; space allocated on the stack for the local variables

这意味着可以通过引用 ebp 来访问局部变量。考虑以下 C 代码片段和相应的汇编代码

a = 10;
b = 5;
c = 2;
mov [ebp -  4], 10  ; location of variable a
mov [ebp -  8], 5   ; location of b
mov [ebp - 12], 2   ; location of c

这一切看起来都很好,但是在此设置中ebp的作用是什么?为什么要保存 ebp 的旧值,然后将 ebp 指向栈顶,然后在下一条指令中更改 esp 的值?答案是函数参数

考虑以下 C 函数声明

void MyFunction2(int x, int y, int z)
{
  ...
}

它生成以下汇编代码

_MyFunction2:
  push ebp 
  mov ebp, esp
  sub esp, 0     ; no local variables, most compilers will omit this line

这与预期完全一致。那么,ebp到底做了什么,函数参数存储在哪里?答案是在我们调用函数时找到的。

考虑以下 C 函数调用

MyFunction2(10, 5, 2);

这将创建以下汇编代码(使用称为 CDECL 的从右到左调用约定,稍后解释)

push 2
push 5
push 10
call _MyFunction2

注意:请记住,call x86 指令基本等同于

push eip + 2 ; return address is current address + size of two instructions
jmp _MyFunction2

事实证明,函数参数全部通过栈传递!因此,当我们将栈指针 (esp) 的当前值移入ebp时,我们将 ebp 直接指向函数参数。当函数代码推入和弹出值时,ebp 不会受到 esp 的影响。请记住,推入基本上会执行以下操作

sub esp, 4   ; "allocate" space for the new stack item
mov [esp], X ; put new stack item value X in

这意味着首先将返回值,然后将ebp的旧值放入栈中。因此 [ebp] 指向 ebp 旧值的位置,[ebp + 4] 指向返回值,[ebp + 8] 指向第一个函数参数。以下是对此时栈的(粗略)表示

:    : 
|  2 | [ebp + 16] (3rd function argument)
|  5 | [ebp + 12] (2nd argument)
| 10 | [ebp + 8]  (1st argument)
| RA | [ebp + 4]  (return address)
| FP | [ebp]      (old ebp value)
|    | [ebp - 4]  (1st local variable)
:    :
:    :
|    | [ebp - X]  (esp - the current stack pointer. The use of push / pop is valid now)

在当前函数执行期间,栈指针值可能会发生变化。特别是当

  • 将参数传递给另一个函数时;
  • 使用伪函数“alloca()”时。

[FIXME: 当将参数传递给另一个函数时,esp 变化不是问题。当该函数返回时,esp 将恢复到其旧值。那么为什么 ebp 在这里有帮助呢?这需要更好的解释。(真正的解释在这里,实际上不需要 ESP:https://learn.microsoft.com/en-us/archive/blogs/larryosterman/fpo)] 这意味着esp的值不能可靠地用于确定(使用适当的偏移量)特定局部变量的内存位置。为了解决这个问题,许多编译器使用ebp寄存器的负偏移量来访问局部变量。这使我们能够假设始终使用相同的偏移量来访问相同的变量(或参数)。因此,ebp 寄存器被称为帧指针或 FP。

标准退出序列

[编辑 | 编辑源代码]

标准退出序列必须撤消标准入口序列所做的操作。为此,标准退出序列必须按以下顺序执行以下任务

  1. 通过将esp恢复到其旧值,删除局部变量的空间。
  2. ebp的旧值恢复到其旧值,该值位于栈顶。
  3. 使用ret命令返回调用函数。

例如,以下 C 代码

void MyFunction3(int x, int y, int z)
{
  int a, b, c;
  ...
  return;
}

将创建以下汇编代码

_MyFunction3:
  push ebp
  mov ebp, esp
  sub esp, 12 ; sizeof(a) + sizeof(b) + sizeof(c)
  ;x = [ebp + 8], y = [ebp + 12], z = [ebp + 16]
  ;a = [ebp - 4] = [esp + 8], b = [ebp - 8] = [esp + 4], c = [ebp - 12] = [esp]
  mov esp, ebp
  pop ebp
  ret 12 ; sizeof(x) + sizeof(y) + sizeof(z)

非标准栈帧

[编辑 | 编辑源代码]

通常,逆向工程师会遇到没有设置标准栈帧的子程序。在查看不以标准序列开头的子程序时,需要考虑以下几点

使用未初始化的寄存器

[编辑 | 编辑源代码]

当子程序开始使用未初始化寄存器中的数据时,这意味着子程序期望外部函数在被调用之前将数据放入该寄存器。某些调用约定在寄存器中传递参数,但有时编译器不会使用标准调用约定。

"static" 函数

[编辑 | 编辑源代码]

在 C 中,函数可以选择使用static关键字声明,如下所示

static void MyFunction4();

static关键字使函数仅具有局部作用域,这意味着任何外部函数都无法访问它(它严格地属于给定代码文件内部)。当优化编译器看到一个仅由调用引用的静态函数(没有通过函数指针的引用)时,它“知道”外部函数不可能与静态函数交互(编译器控制对函数的所有访问),因此编译器不会费心将其标准化。

热补丁序言

[编辑 | 编辑源代码]

某些 Windows 函数以如上所述的方式设置常规栈帧,但以看似毫无意义的行开头

mov edi, edi;

此指令被组装成 2 个字节,用作将来函数补丁的占位符。总的来说,这样的函数可能看起来像这样

nop               ; each nop is 1 byte long
nop
nop
nop
nop

FUNCTION:         ; <-- This is the function entry point as used by call instructions 
mov edi, edi      ; mov edi,edi is 2 bytes long
push ebp          ; regular stack frame setup
mov ebp, esp

如果需要在不重新加载应用程序的情况下替换这样的函数(或者在内核补丁的情况下重新启动机器),则可以通过插入指向替换函数的跳转来实现。一个短跳转指令(可以跳转 +/- 127 字节)需要 2 字节的存储空间 - 正好是 “mov edi,edi” 占位符提供的空间。跳转到任何内存位置,在本例中为指向我们的替换函数,需要 5 个字节。这些由函数之前的 5 个无操作字节提供。如果这样修补的函数被调用,它将首先向后跳转 5 个字节,然后进行一个长跳转到替换函数。补丁后,内存可能看起来像这样

LABEL:
jmp REPLACEMENT_FUNCTION ; <-- 5 NOPs replaced by jmp

FUNCTION:
jmp short LABEL          ; <-- mov edi has been replaced by short jump backwards
push ebp          
mov ebp, esp             ; <-- regular stack frame setup as before

在开头使用 2 字节 mov 指令而不是直接放置 5 个 nop 的原因是,为了防止在补丁过程中出现损坏。如果指令指针当前指向其中任何一个指令,那么替换 5 个单独的指令将存在风险。另一方面,使用单个 mov 指令作为占位符可以保证补丁可以作为一个原子操作完成。

局部静态变量

[编辑 | 编辑源代码]

局部静态变量不能在堆栈上创建,因为变量的值在函数调用之间会保留。我们将在后面的章节中讨论局部静态变量和其他类型的变量。

华夏公益教科书