从一道题到堆栈平衡

499 阅读11分钟

在程序运行中,堆栈(Stack)是一种用于管理函数调用、局部变量和函数返回地址的内存结构。堆栈是先进后出的(LIFO),函数调用和返回遵循这一特性。在函数调用和返回过程中,堆栈的平衡非常重要,因为如果堆栈失衡(stack imbalance),会导致程序运行异常,如崩溃、逻辑错误。在进行栈溢出操作,尤为要注意。

堆栈平衡(Stack Balance)概述

堆栈平衡指的是在函数调用结束时,堆栈指针(ESPRSP)的位置应当与函数调用前的位置一致。换句话说,每个函数在进入(call 指令)和退出(ret 指令)时,堆栈上的数据应当正确地匹配,保证函数执行完毕后,堆栈指针指向正确的地址。

在函数调用期间,通常会在堆栈中保存以下内容:

  1. 函数的返回地址:由 call 指令自动压入堆栈,函数返回时由 ret 指令弹出。
  2. 函数参数:调用函数时压入堆栈,函数执行过程中可以访问这些参数。
  3. 局部变量和保存的寄存器值:在函数内部使用 pushsub 指令将局部变量和寄存器值保存到堆栈中。
  4. 返回值:某些架构(如 x86)在堆栈上存储返回值。

在函数执行完毕后,这些内容必须按照一定规则弹出或清除,确保堆栈指针恢复到调用之前的状态。这就是堆栈平衡的核心原则。

示例

纯粹的概念较为抽象,通过示例才更容易理解,案例就是pwn 题目本身难度不高,就是一个简单的栈溢出,但是我尝试了构造ROPchain和直接改return_address,都失败了。

image.png

最后用了劫持输出流才做出来。

image.png

然后看了一下师傅们的WP,惊讶的发现这里需要堆栈平衡,只需要在直接改return_address前先填一个retn,即可正常栈溢出,便去了解了一下堆栈平衡。

函数调用和堆栈平衡

考虑以下伪代码和汇编代码示例,来理解堆栈平衡是如何被维护的:

void func(int a, int b) {
    int c = a + b;
    printf("%d\n", c);
}

int main() {
    func(5, 10);
    return 0;
}

当调用 func(5, 10); 时,汇编层面堆栈的变化可以分为以下几个阶段:

  1. 函数调用前

    • ESP 指向当前栈顶(假设地址为 0x100)。
  2. 压入函数参数

    • 压入 10push 10ESP 变为 0xFC
    • 压入 5push 5ESP 变为 0xF8
  3. 调用函数

    • 执行 call func,此时返回地址(0x105)被压入堆栈:ESP 变为 0xF4
  4. 进入 func 函数内部

    • func 函数开始执行,将局部变量 c 压入堆栈,并保存寄存器值。堆栈指针会进一步减少。
    • 执行完 func 函数的代码后,准备返回主函数。
  5. 返回 main 函数

    • 执行 ret 指令,从堆栈弹出返回地址 0x105ESP 恢复为 0xF8
    • 函数返回后,由于堆栈指针没有清理参数,此时 ESP 仍然指向 5 的位置。
  6. 清理参数

    • 有些函数返回后会自动清理堆栈中的参数(通常是 cdecl 调用约定),比如在 ret 时使用 ret 8,表示弹出 8 个字节的参数。
    • ESP 恢复到 0x100,与 func 调用前的位置一致。

堆栈失衡(Stack Imbalance)现象

如果函数在调用和返回过程中没有正确清理或弹出堆栈上的数据,可能会导致堆栈失衡。堆栈失衡的几种常见现象如下:

  1. 堆栈指针位置错误

    • 如果函数退出时未恢复堆栈指针(如参数未弹出),堆栈指针将指向错误的位置。之后的函数调用可能覆盖原先的堆栈内容,导致程序崩溃。
  2. 返回地址错误

    • 如果堆栈失衡,ret 指令弹出的值不是函数调用时压入的返回地址,可能导致程序跳转到不正确的位置,造成程序崩溃或被恶意利用。
  3. 局部变量或参数内容被篡改

    • 如果堆栈指针失衡,可能会导致局部变量或参数的值被其他函数调用意外覆盖,造成数据篡改或逻辑错误。

调用约定(Calling Convention)与堆栈平衡

在不同的调用约定下,函数的调用者和被调用者在维护堆栈平衡时的责任不同:

  1. cdecl(C Declaration)

    • 调用者负责清理堆栈(caller cleans the stack)。
    • 调用函数时,参数从右到左压入堆栈。
    • 函数返回时,调用者通过 add esp, <size> 来恢复堆栈平衡。
  2. stdcall(Standard Calling Convention)

    • 被调用者负责清理堆栈(callee cleans the stack)。
    • 函数返回时,通过 ret <size> 来自动弹出参数,从而恢复堆栈平衡。
  3. fastcall

    • 前两个参数通过寄存器传递,其他参数使用堆栈传递。
    • 被调用者负责清理堆栈(类似 stdcall)。
  4. thiscall(C++ 调用约定)

    • 专用于 C++ 成员函数调用,this 指针通过寄存器传递。
    • 通常由被调用者负责清理堆栈。

堆栈平衡示例分析

假设我们有以下 C 代码示例,并查看其汇编代码:

void my_function(int a, int b) {
    int c = a + b;
}

int main() {
    my_function(1, 2);
    return 0;
}

其汇编代码可能如下(x86 平台,cdecl 调用约定):

main:
    push    2               ; 将第二个参数 2 压入堆栈
    push    1               ; 将第一个参数 1 压入堆栈
    call    my_function     ; 调用 my_function
    add     esp, 8          ; 调用者恢复堆栈,弹出 8 字节的参数
    ret

my_function:
    push    ebp             ; 保存上一个帧指针
    mov     ebp, esp        ; 建立新的栈帧
    sub     esp, 4          ; 为局部变量 c 分配空间
    mov     eax, [ebp+8]    ; 将第一个参数 a 的值加载到 eax
    add     eax, [ebp+12]   ; 将 eax 加上第二个参数 b 的值
    mov     [ebp-4], eax    ; 将结果存储到局部变量 c
    leave                   ; 恢复栈帧
    ret                     ; 被调用者不负责恢复参数,直接返回
  • main 调用 my_function 时,push 了两个参数。
  • my_function 返回时没有恢复堆栈中的参数。
  • main 中执行 add esp, 8 恢复堆栈的平衡,确保 ESP 恢复到调用 my_function 之前的状态。

如果 main 中没有执行 add esp, 8,堆栈将失衡,ESP 会指向错误的位置。

堆栈平衡(Stack Balance)和函数调用约定的概念适用于所有架构,包括 32 位和 64 位系统。不过,在 32 位(x86)和 64 位(x86_64 或 amd64)架构下,堆栈管理的具体实现细节和调用约定存在一些差异。这些差异会影响堆栈平衡的实现方式,但总体原则是相同的,即函数调用前后的堆栈指针位置应保持一致。

下面我将详细解释 32 位和 64 位架构下的堆栈平衡和调用约定的区别,以及它们如何影响堆栈平衡。

32 位与 64 位架构的差异

  1. 寄存器数量和宽度

    • 32 位(x86):使用 EAX, EBX, ECX, EDX 等寄存器,堆栈指针为 ESP
    • 64 位(x86_64):使用 RAX, RBX, RCX, RDX 等寄存器,堆栈指针为 RSP,寄存器宽度增加到 64 位。
  2. 调用约定(Calling Convention)

    • 32 位架构(x86)

      • 通常使用 cdeclstdcallfastcall 调用约定。
      • 函数参数通过堆栈传递(push 指令),堆栈指针 ESP 负责管理堆栈平衡。
    • 64 位架构(x86_64)

      • 大部分系统使用 System V AMD64 ABI 或 Windows 下的 Microsoft x64 调用约定。
      • 前 6 个整数类型参数通过寄存器传递(RDI, RSI, RDX, RCX, R8, R9),而浮点数类型的参数通过 XMM 寄存器传递。
      • 只有超过寄存器数量的参数才使用堆栈传递,堆栈指针 RSP 负责管理堆栈平衡。
  3. 函数调用与返回

    • 32 位:使用 callret 指令,参数通过堆栈传递。堆栈平衡需要通过 add esp, <size>cdecl)或者 ret <size>stdcall)来恢复。
    • 64 位:同样使用 callret 指令,但寄存器传递参数使得堆栈操作变得较少。返回值通常通过 RAX 返回。

32 位(x86)架构的堆栈平衡

在 32 位架构中,函数的调用约定和堆栈平衡管理方式如下:

  1. cdecl 调用约定(C Declaration):

    • 堆栈平衡:由调用者(Caller)负责清理堆栈。
    • 函数参数从右向左压入堆栈(push 指令),函数调用结束后,调用者通过 add esp, <size> 恢复堆栈平衡。

    示例(x86 汇编):

    main:
        push    2              ; 将参数 2 压入堆栈
        push    1              ; 将参数 1 压入堆栈
        call    my_function    ; 调用 my_function
        add     esp, 8         ; 恢复堆栈平衡,弹出 8 字节的参数
        ret
    
    my_function:
        push    ebp            ; 保存上一个栈帧指针
        mov     ebp, esp       ; 建立新的栈帧
        sub     esp, 4         ; 为局部变量分配空间
        leave                  ; 恢复栈帧
        ret                    ; 函数返回,`esp` 指针未弹出参数
    
  2. stdcall 调用约定(Standard Calling Convention):

    • 堆栈平衡:由被调用者(Callee)负责清理堆栈。
    • 函数参数从右向左压入堆栈,函数返回时通过 ret <size> 指令自动弹出堆栈中的参数。

    示例(x86 汇编):

    main:
        push    2              ; 将参数 2 压入堆栈
        push    1              ; 将参数 1 压入堆栈
        call    my_function    ; 调用 my_function
        ; 无需 add esp, 8 恢复堆栈平衡,因为 my_function 自己清理了堆栈
        ret
    
    my_function:
        push    ebp            ; 保存上一个栈帧指针
        mov     ebp, esp       ; 建立新的栈帧
        sub     esp, 4         ; 为局部变量分配空间
        leave                  ; 恢复栈帧
        ret     8              ; 从堆栈中弹出 8 字节(2 个参数)的内容
    

64 位(x86_64)架构的堆栈平衡

在 64 位架构中,由于大部分参数通过寄存器传递,因此堆栈操作相对较少,调用约定和堆栈管理方式如下:

  1. System V AMD64 ABI(Linux/Unix 平台上的标准调用约定):

    • 堆栈平衡:由被调用者(Callee)负责管理堆栈平衡。
    • 前 6 个整数类型参数通过 RDI, RSI, RDX, RCX, R8, R9 传递,超过 6 个的参数通过堆栈传递。
    • 函数调用时,调用者必须在堆栈中为返回地址和被调用者可能使用的局部变量预留空间(16 字节对齐)。
    • 函数返回时,堆栈指针(RSP)应当恢复到调用前的位置。

    示例(x86_64 汇编):

    main:
        sub     rsp, 8             ; 为栈对齐预留 8 字节(使 rsp 16 字节对齐)
        mov     edi, 1             ; 将第一个参数 1 传递给 `RDI`
        mov     esi, 2             ; 将第二个参数 2 传递给 `RSI`
        call    my_function        ; 调用 my_function
        add     rsp, 8             ; 恢复堆栈平衡
        ret
    
    my_function:
        push    rbp                ; 保存上一个栈帧指针
        mov     rbp, rsp           ; 建立新的栈帧
        ; 局部变量操作省略
        leave                      ; 恢复栈帧
        ret                        ; 返回,`rsp` 恢复到 `main` 中调用之前的位置
    
  2. Microsoft x64 ABI(Windows 下的标准调用约定):

    • 堆栈平衡:由调用者(Caller)负责清理堆栈。
    • 前 4 个整数类型参数通过 RCX, RDX, R8, R9 传递,超过 4 个的参数通过堆栈传递。
    • 调用者需确保堆栈指针在函数调用前对齐(16 字节对齐)。
    • 函数返回时,由调用者(主函数)清理堆栈上的参数。

    示例(x86_64 汇编):

    main:
        sub     rsp, 28h            ; 调用前,堆栈指针 rsp 对齐到 16 字节
        mov     ecx, 1              ; 将第一个参数 1 传递给 `RCX`
        mov     edx, 2              ; 将第二个参数 2 传递给 `RDX`
        call    my_function         ; 调用 my_function
        add     rsp, 28h            ; 恢复堆栈平衡
        ret
    
    my_function:
        push    rbp                 ; 保存上一个栈帧指针
        mov     rbp, rsp            ; 建立新的栈帧
        ; 局部变量操作省略
        leave                       ; 恢复栈帧
        ret                         ; 返回,`rsp` 恢复到 `main` 中调用之前的位置
    
  • 堆栈平衡原则在 32 位和 64 位架构中均适用,只是实现方式

总结

堆栈平衡是函数调用过程中保证程序稳定性和正确性的基础。做题遇到问题可以试试去平衡一下或者调试着做别想偷懒。