试图理解反汇编代码的新手

逆向工程 视窗 二元分析 转储 海湾合作委员会
2021-06-22 16:39:15

我一直在编写小型 C 程序,然后对它们进行反汇编以尝试了解幕后实际发生的情况。

我在 Windows 7 上使用 mingw-w64 来完成我的工作,我遇到了一个问题,即为什么在堆栈上分配了一定数量的空间。

程序1:

int main()
{
  int i,j;
  return 0;
}

main函数的反汇编代码的开头是:

00000000004015b0 <main>:
  4015b0:   55                      push   %rbp
  4015b1:   48 89 e5                mov    %rsp,%rbp
  4015b4:   48 83 ec 20             sub    $0x20,%rsp

最后一行看起来像是在堆栈上分配 32 个字节。

程序 2:相同的程序,只是它为两个变量赋值并返回变量的总和。

int main()
{
  int i,j;
  i = 5;
  j = 6;
  return i+j;
}

反汇编代码开始:

00000000004015b0 <main>:
  4015b0:   55                      push   %rbp
  4015b1:   48 89 e5                mov    %rsp,%rbp
  4015b4:   48 83 ec 30             sub    $0x30,%rsp

为什么这个程序分配了 48 个字节而不是第一个程序的 32 个字节?

在这两种情况下,i 和 j 分别存储在 %rbp-4 和 %rbp-8 中,第二个程序中完成的数学运算全部在寄存器中完成。

注意我使用 gcc 6.3.0 来编译代码和 objdump 2.28 来反汇编它。

所有代码都可以在我的 github 存储库中看到 http://github.com/draikes/rev-eng

2个回答

栈是用来存放数据的,不是用来存放代码的

我想到了。看起来 sub 语句正在为整个程序分配空间,四舍五入到最近的 qword。程序 1 只需要 0x17 字节 23 字节的代码,而程序 2 需要 0x28 或 40 字节的空间。

这是不正确的:堆栈上的空间没有分配给函数的代码(或任何其他类型的代码)。相反,堆栈用作函数使用的变量的存储空间。当一个函数被调用时,在栈上创建一个称为栈帧的空间来容纳传递给该函数的参数以及在该函数中声明的局部变量。这个空间是通过递减堆栈指针来创建的%rsp这是 2 个堆栈帧的视觉描述:

AMD64 调用者和被调用者堆栈帧

这里描绘了 2 个框架:

  • 呼叫者帧,这对于函数创建的堆栈帧已经调用的当前功能
  • 被叫方框架,这是当前正在执行的函数创建的帧

请注意,代码驻留在虚拟内存地址空间中与堆栈不同的区域(图片是 Win32 进程,但也适用于PE32+ 进程地址空间,除了地址空间要大得多):

虚拟内存中的 PE32 布局

堆栈帧应与 16 字节边界对齐

为了充分了解这种情况,应该包括对所讨论的两个功能的完全拆卸,特别是因为在这种情况下拆卸很方便。

main()从 prog1完全反汇编

00000000004015b0 <main>:
  4015b0:   55                      push   %rbp           // save caller's frame base address
  4015b1:   48 89 e5                mov    %rsp,%rbp      // current top of stack becomes base of caller's frame
  4015b4:   48 83 ec 20             sub    $0x20,%rsp     // allocate space for new stack frame
  4015b8:   e8 93 01 00 00          callq  401750 <__main>
  4015bd:   b8 00 00 00 00          mov    $0x0,%eax
  4015c2:   48 83 c4 20             add    $0x20,%rsp     // callee stack frame now out of scope
  4015c6:   5d                      pop    %rbp           // restore caller's base frame pointer
  4015c7:   c3                      retq   
  4015c8:   90                      nop
  4015c9:   90                      nop
  4015ca:   90                      nop
  4015cb:   90                      nop
  4015cc:   90                      nop
  4015cd:   90                      nop
  4015ce:   90                      nop
  4015cf:   90                      nop

main()从 prog2完全反汇编

00000000004015b0 <main>:
  4015b0:   55                      push   %rbp
  4015b1:   48 89 e5                mov    %rsp,%rbp
  4015b4:   48 83 ec 30             sub    $0x30,%rsp
  4015b8:   e8 a3 01 00 00          callq  401760 <__main>
  4015bd:   c7 45 fc 05 00 00 00    movl   $0x5,-0x4(%rbp)
  4015c4:   c7 45 f8 06 00 00 00    movl   $0x6,-0x8(%rbp)
  4015cb:   8b 55 fc                mov    -0x4(%rbp),%edx
  4015ce:   8b 45 f8                mov    -0x8(%rbp),%eax
  4015d1:   01 d0                   add    %edx,%eax
  4015d3:   48 83 c4 30             add    $0x30,%rsp
  4015d7:   5d                      pop    %rbp
  4015d8:   c3                      retq   
  4015d9:   90                      nop
  4015da:   90                      nop
  4015db:   90                      nop
  4015dc:   90                      nop
  4015dd:   90                      nop
  4015de:   90                      nop
  4015df:   90                      nop

这段代码未经优化,这使得解释变得更加容易,并使我们能够清楚地辨别所遵循的调用约定。

根据微软关于函数类型的文档

基本上有两种类型的功能。需要堆栈帧的函数称为帧函数。不需要堆栈帧的函数称为叶函数。

帧函数是分配堆栈空间、调用其他函数、保存非易失性寄存器或使用异常处理的函数。它还需要一个函数表条目。一个框架函数需要一个序言和一个尾声。帧函数可以动态分配堆栈空间并可以使用帧指针。一个框架函数拥有这个调用标准的全部功能。

如果框架函数不调用另一个函数,则不需要对齐堆栈。

上面的函数都调用__main,所以它们会被认为是框架函数因此,main正如我们从上面的反汇编中看到的那样在这两种情况下都分配了一个堆栈帧

根据微软关于堆栈分配的文档

堆栈将始终保持 16 字节对齐,除了在 prolog 内(例如,在返回地址被压入之后),并且除了在函数类型中为特定类别的帧函数指明的地方

这让我们回到最初的问题:

为什么这个程序分配了 48 个字节而不是第一个程序的 32 个字节?

直接回答这个问题并不容易。简短的回答是堆栈帧对齐 + 编译器的变幻莫测。

编译器负责堆栈内存分配(除非alloca在运行时用于动态分配堆栈上的内存,但这不适用于有问题的反汇编)。需要注意的是,main()在程序 1的反汇编中,创建的堆栈帧中没有保存任何内容(除了在调用时%rip被压入堆栈的 32 字节堆栈帧__main),但无论如何分配了 32 字节。

在程序 2 中,__main也被调用,另外 2 个 4 字节整数值被写入为main().

这种“额外”内存的分配通常由 GCC 完成——堆栈帧在 64 位和 32 位运行时环境中没有尽可能紧凑地分配。System V ABI AMD64 Architecture Processor Supplement Draft Version 0.99.7第 3.2.1 节“寄存器和堆栈帧”中提供了有关为什么会出现这种情况的线索

SYS V AMD64 堆栈帧图

具体来说,“线索”是当前堆栈帧中的“未指定”区域。这个区域是不是在AMD64 ABI进一步讨论,但它在讨论System V的ABI,英特尔386架构处理器补充部分3“低层系统信息”,第37页:

其他方面取决于编译器和正在编译的代码。标准调用序列不定义最大堆栈帧大小,也不限制语言系统如何使用标准堆栈帧的“未指定”区域。

如果这也扩展到 AMD64 架构,则意味着编译器可以在函数堆栈帧基指针%rbp和“红色区域”之间的函数堆栈帧区域中以编译器本身确定的方式分配内存,因此只要堆栈正确对齐到 16 字节边界。换句话说,这个区域的内存分配是依赖于编译器的。例如,MSVC 可能会为堆栈帧的这个区域以不同的方式分配内存。更具体地说,不同的编译器可能不会main()像 GCC 那样为程序 2 的函数分配 48 个字节

更新(感谢 Igor Skochinksy 指出这一点):

MS 文档并不完全正确;叶函数是一个不调用其他函数的函数(它是函数调用图上的叶);它可能会或可能不会使用堆栈帧(在 MS ABI 中它没有,但在其他地方不一定如此)。

除此之外,AMD64 ABI 和 Windows x64 ABI 之间还有一些重要的区别:

x64 上的 Windows 实现了自己的 ABI,这与 AMD64 ABI 有点不同……主要区别如下:

  1. 只有 4 个整数/指针参数在寄存器(rcx、rdx、r8、r9)中传递。
  2. 没有任何“红区”的概念。事实上,ABI 明确指出 rsp 以外的区域被认为是不稳定的,使用起来不安全。操作系统、调试器或中断处理程序可能会覆盖该区域。
  3. 相反,调用者在每个堆栈帧中提供了一个“寄存器参数区域”(有时也称为“主空间”)。当一个函数被调用时,在返回地址之前栈上分配的最后一个东西是至少有 4 个寄存器的空间(每个 8 字节)。该区域可供被调用者使用,无需明确分配。它对于可变参数函数以及调试(为参数提供已知位置,而寄存器可重用于其他目的)很有用。尽管该区域最初是为了溢出寄存器中传递的 4 个参数而设计的,但如今编译器也将其用于其他优化目的(例如,如果函数需要少于 32 字节的堆栈空间用于其局部变量,则该区域可能在不接触 rsp 的情况下使用)。

所有这些点都很重要,但我强调第 3 点的第一句话的原因是,它特别与我们讨论的空间如何由编译器分配给堆栈帧的讨论有关。

main() 在程序 1 中没有传递给它的局部变量或参数,但它确实有一个 32 字节(4 个寄存器 * 8 个字节)的寄存器参数区域。

main()在程序 2 中,局部变量使用了 8 字节的空间,因此为其堆栈帧加上 32 字节的寄存器参数区域分配了 16 字节的空间。为局部变量分配了 16 个字节,以保持与 16 字节边界的对齐。

这说明了main()在程序 1 和程序 2 中创建的堆栈帧之间的大小差异

我想到了。看起来 sub 语句正在为整个程序分配空间,四舍五入到最近的 qword。程序 1 只需要 0x17 字节 23 字节的代码,而程序 2 需要 0x28 或 40 字节的空间。