栈帧 抬栈与平栈 (逆向分析)

目录

前言

[栈帧生命周期完整指南:压栈、抬栈、平栈、调用约定与 x86/x64 差异](#栈帧生命周期完整指南:压栈、抬栈、平栈、调用约定与 x86/x64 差异)

[抬栈(Raising the Stack)](#抬栈(Raising the Stack))

[平栈(Flat Stack)](#平栈(Flat Stack))

栈结构构造过程

栈帧构造(Prologue)

栈帧析构(Epilogue)

栈帧生命周期文本图

抬栈与平栈的关系

调试器中的行为分析

抬栈:

[跳过参数区域" 的本质(对应 __stdcall 调用约定)](#跳过参数区域" 的本质(对应 __stdcall 调用约定))

平栈:

["参数仍留在栈上" 的本质(对应 __cdecl 调用约定)](#"参数仍留在栈上" 的本质(对应 __cdecl 调用约定))

对比

为什么需要两种模式?

调试器实操验证(OllyDbg/x64dbg)


前言

在逆向分析、漏洞利用以及免杀对抗中,理解函数调用背后的栈行为至关重要。

无论是分析程序执行流程,还是编写稳定的Shellcode与Loader,栈帧的构建与销毁都是最基础却最容易被忽视的核心机制。

在x86架构下,不同调用约定决定了函数参数由谁负责清理,从而形成了两种典型模式:

一种是由被调用函数清理参数(如 __stdcall),另一种是由调用者清理参数(如 __cdecl)。

这两种模式在汇编层面分别体现为 ret nret + add esp, n 的差异,看似只是指令形式不同,实际上却直接影响:

  • 栈帧生命周期的管理方式
  • 可变参数函数的实现能力
  • 以及逆向分析时对调用关系的判断

本文将从栈帧结构入手,结合调试器行为,系统梳理"压栈、构建、恢复、清理"这一完整生命周期,并深入分析平栈与抬栈在底层执行上的本质区别。


栈帧生命周期完整指南:抬栈、平栈、调用约定

栈是一种后进先出 (LIFO) 数据结构

  • 用于管理函数调用中的临时数据(参数、局部变量、返回地址、寄存器状态)。

  • 在 x86/x64 架构中,通过栈指针 (ESP/RSP)基指针 (EBP/RBP) 实现精确控制。

    高地址
    +-----------------------+
    | |
    | 调用者栈帧 |
    | |
    +-----------------------+ <-- EBP (调用者基指针)
    | |
    | 参数 N (最后压入) | <-- 参数从右向左压栈
    +-----------------------+
    | ... |
    +-----------------------+
    | 参数 1 (最先压入) |
    +-----------------------+
    | 返回地址 (Return Addr)| <-- CALL指令自动压入
    +-----------------------+ <-- EBP (当前函数基指针)
    | 旧 EBP 值 | <-- 保存调用者的 EBP
    +-----------------------+
    | 局部变量 |
    +-----------------------+
    | 保存的寄存器 (如EBX) |
    +-----------------------+
    | ... |
    +-----------------------+ <-- ESP (当前栈顶指针)
    | |
    | |
    +-----------------------+
    低地址

核心操作有三种:

  • 压栈 (Push):分配空间,ESP/RSP 减小。

  • 抬栈 (Pop):恢复数据,ESP/RSP 增大(常指 pop 指令恢复上下文)。

  • 平栈 (Stack Cleanup):批量调整 ESP/RSP 清理局部变量或参数(mov esp, ebp 或 add esp, N)。

这些操作共同构成栈帧生命周期:构建 → 使用 → 恢复上下文 → 清理。


抬栈(Raising the Stack)

  1. 抬栈是指**被调用函数(callee)**在返回时负责清理栈上的参数。

  2. 栈指针 ESP 通过 ret n 指令(n 为参数字节数)自动调整,不仅弹出返回地址,还跳过参数区域,恢复调用前的栈状态。

    ; 被调用函数
    my_function:
    push ebp
    mov ebp, esp
    mov eax, [ebp + 8] ; 参数1
    mov ebx, [ebp + 12] ; 参数2
    mov esp, ebp
    pop ebp
    ret 8 ; 抬栈:清理 8 字节参数

    ; 调用者
    push 2
    push 1
    call my_function ; 无需额外清理

    初始: ESP → [空]
    push 2: ESP → [2]
    push 1: ESP → [1 | 2]
    call: ESP → [返回地址 | 1 | 2]
    push ebp: ESP → [旧EBP | 返回地址 | 1 | 2]
    ...函数体...
    mov esp,ebp
    pop ebp: ESP → [返回地址 | 1 | 2]
    ret 8: ESP → [干净] ← 直接跳过8字节参数

平栈(Flat Stack)

  1. 平栈是指**调用者(caller)**在函数返回后负责清理栈上的参数。

  2. 被调用函数仅使用 ret 指令返回,弹出返回地址,栈指针 ESP 不变,参数清理任务留给调用者。

    ; 被调用函数
    my_function:
    push ebp
    mov ebp, esp
    mov eax, [ebp + 8]
    mov ebx, [ebp + 12]
    mov esp, ebp
    pop ebp
    ret ; 只返回

    ; 调用者
    push 2
    push 1
    call my_function
    add esp, 8 ; 平栈:调用者清理

    call 后: ESP → [返回地址 | 1 | 2]
    ...函数体 (ret 后)...
    ESP → [返回地址 | 1 | 2] ← 参数仍存在
    add esp, 8: ESP → [干净]

为什么效果看似类似但本质不同?

抬栈:被调用者用 ret n 批量清理 + 返回(数据恢复+空间跳过)。

平栈:调用者用 add esp, n 纯空间清理(不读取数据)。抬栈针对固定参数场景更高效,平栈灵活支持变参。


栈结构构造过程

  • 无论抬栈还是平栈,函数调用中的栈帧构造和析构遵循相似的流程,但在参数清理上有关键差异。

栈帧构造(Prologue)

复制代码
push ebp          ; 压栈:保存调用者基指针
mov ebp, esp      ; 建立当前栈帧基址
sub esp, 12       ; 平栈/压栈:分配 12 字节局部变量空间

栈帧析构(Epilogue)

复制代码
mov esp, ebp      ; 平栈:撤销局部变量空间
pop ebp           ; 抬栈:恢复调用者 EBP
ret / ret n       ; 返回 + 可能清理参数
  • 差异 :抬栈约定中 ret n 负责参数;平栈约定中调用者后续 add esp, n 负责。

栈帧生命周期文本图

复制代码
初始 (调用前): ESP=0xFFFFFFF0 [干净]

压栈参数+调用:
push 20          ESP=0xFFFFFFE8 [20]
push 42          ESP=0xFFFFFFE4 [42 | 20]
call B           ESP=0xFFFFFFE0 [返回地址 | 42 | 20]

函数B序言(压栈):
push ebp         ESP=0xFFFFFFDC [旧EBP | 返回地址 | 42 | 20]
mov ebp, esp
sub esp, 12      ESP=0xFFFFFFD0 [局部变量12B | 旧EBP | ...]

函数体:通过 [ebp+8]、[ebp-4] 访问

尾声:
mov esp, ebp     ESP=0xFFFFFFDC [旧EBP | 返回地址 | 42 | 20]  ← 平栈清理局部变量
pop ebp          ESP=0xFFFFFFE0 [返回地址 | 42 | 20]         ← 抬栈恢复EBP
ret              ESP=0xFFFFFFE4 [42 | 20]                     ← 抬栈弹出返回地址

调用者平栈:
add esp, 8       ESP=0xFFFFFFEC [干净]

抬栈与平栈的关系

  • 本质 :两种栈管理策略是对栈清理责任的划分,依赖于调用约定(如 cdeclstdcall)。

  • 选择依据:由应用程序二进制接口(ABI)和函数需求决定。

调试器中的行为分析

抬栈

  • ret n 执行后,ESP 直接跳过参数区域,栈恢复干净。
跳过参数区域" 的本质(对应 __stdcall 调用约定)
  • 在函数返回时执行 ret n 指令后,栈指针 ESP 直接向前移动 n 字节(n 为参数的总大小)。

  • 栈指针从返回地址的位置"跃迁"到参数区域上方的调用者数据部分,参数数据虽然仍留在内存中,但已脱离栈的管理范围(ESP 不再指向它们)。

    ret 8 执行前:
    ESP → [返回地址]
    [参数2] (4字节)
    [参数1] (4字节) ← 参数区域(共8字节)

    ret 8 执行后:
    [返回地址] ← 已弹出到 EIP,作为返回地址
    [参数2] ← 被"跳过",ESP 不再指向这里
    [参数1] ← 被"跳过"
    ESP → [调用者数据] ← 直接跳到参数区域上方,栈顶恢复为调用前的状态

  • 为什么叫"跳过"

    • ESP 指针没有经过参数区域 ,而是从返回地址位置直接跃迁到参数区域上方的调用者数据,参数数据仍在内存中,但逻辑上已从栈中"消失",不再属于当前栈帧的管理范围。

      高地址
      +-----------------------+
      | 调用者局部变量 | ← ESP 位置(函数调用前)
      +-----------------------+
      | |
      +-----------------------+
      | 其他数据 |
      +=======================+ ← 函数调用前栈状态
      | 参数2 (4字节) | ← 压栈方向
      +-----------------------+ ↓
      | 参数1 (4字节) |
      +-----------------------+
      | CALL指令压入的 |
      | 返回地址 | ← ESP 位置(函数内)
      +-----------------------+
      | 旧EBP |
      +-----------------------+
      | 局部变量 |
      +-----------------------+
      | ... |
      +=======================+ ← 函数执行中栈状态
      | 参数2 (4字节) |
      +-----------------------+
      | 参数1 (4字节) |
      +-----------------------+
      | 返回地址 (已弹出) | ← ret执行时的ESP起点
      +-----------------------+
      | 旧EBP |
      +-----------------------+
      | 局部变量 (已释放) |
      +-----------------------+
      | |
      +=======================+ ← ret 8 执行后
      | 调用者局部变量 | ← ESP 位置(ret 8 后)→ 直接跳过参数区域
      +-----------------------+
      | |
      +-----------------------+
      ▲ 关键路径:
      函数内:ESP 指向局部变量底部
      ret 8 执行时:
      步骤1:弹出返回地址 → ESP 移动到 [旧EBP]
      步骤2:ESP += 8 → 直接跳到参数区域上方(箭头所示位置)
      结果:栈顶回到函数调用前状态,参数区域逻辑上消失

平栈

  • ret 执行后,ESP 仅移动到返回地址位置,参数仍留在栈上,需观察调用者的清理操作。
"参数仍留在栈上" 的本质(对应 __cdecl 调用约定)
  • 函数返回时仅执行 ret 指令(不指定参数大小),栈指针 ESP 在弹出返回地址后停在参数区域的顶部(即参数2的位置)。

  • 参数数据仍然在栈指针覆盖的范围内,被视为有效栈数据,需要调用者后续手动清理。

    ret 执行前:
    ESP → [返回地址]
    [参数2] (4字节)
    [参数1] (4字节) ← 参数区域(共8字节)

    ret 执行后:
    [返回地址] ← 已弹出到 EIP,作为返回地址
    ESP → [参数2] ← 仍指向参数区域顶部!
    [参数1]

  • 参数数据仍在栈指针(ESP)的覆盖范围内 ,属于当前栈帧的一部分。

    • 虽然函数已返回,但参数数据未被清理,后续如果进行新的压栈操作(如 push),新数据会覆盖这些参数。

    • 因此,参数仍被视为"有效栈数据",但需要调用者手动调整 ESP 来清理(否则会造成栈不平衡)。

      高地址
      +-----------------------+
      | 调用者局部变量 | ← ESP 位置(函数调用前)
      +-----------------------+
      | |
      +-----------------------+
      | 其他数据 |
      +=======================+ ← 函数调用前栈状态
      | 参数2 (4字节) | ← 压栈方向
      +-----------------------+ ↓
      | 参数1 (4字节) |
      +-----------------------+
      | CALL指令压入的 |
      | 返回地址 | ← ESP 位置(函数内)
      +-----------------------+
      | 旧EBP |
      +-----------------------+
      | 局部变量 |
      +-----------------------+
      | ... |
      +=======================+ ← 函数执行中栈状态
      | 参数2 (4字节) |
      +-----------------------+
      | 参数1 (4字节) | ← 参数仍留在栈上!
      +-----------------------+
      | 返回地址 (已弹出) | ← ret执行后的ESP位置
      +-----------------------+
      | 旧EBP |
      +-----------------------+
      | 局部变量 (已释放) |
      +-----------------------+
      | |
      +=======================+ ← ret 执行后状态
      | 调用者局部变量 |
      +-----------------------+
      | |
      +-----------------------+
      ▲ 关键路径:
      函数内:ESP 指向局部变量底部
      ret 执行时:
      仅弹出返回地址 → ESP 停在参数2位置

      add esp, 8 ; 手动将ESP移动到参数区域上方


对比

操作 抬栈模式 (__stdcall) 平栈模式 (__cdecl)
函数调用前 ESP → [调用者数据] ESP → [调用者数据]
[其他数据] [其他数据]
压入参数后 ESP → [参数1] ESP → [参数1]
[参数2] [参数2]
CALL执行后 ESP → [返回地址] ESP → [返回地址]
函数内执行时 ESP → [局部变量底部] ESP → [局部变量底部]
ret 指令执行时 1. 弹出返回地址 1. 弹出返回地址
2. ESP += 8 → 直接跳到调用者数据 ESP 保持不变 → 停在参数2位置
关键区别 参数区域被逻辑跳过 参数区域仍被栈包含
栈是否干净 ✅ 立即恢复调用前状态 ❌ 需调用者手动 add esp,8

为什么需要两种模式?

  1. 抬栈模式优势

    • 适合固定参数函数(如 WinAPI)
    • 被调用函数知道参数大小,可自行清理
    • 减少调用者代码量(每个调用点无需重复清理指令)
  2. 平栈模式必要性

    • 必须用于可变参数函数 (如 printf
    • 被调用函数无法预知参数数量
    • 只有调用者知道实际压栈了多少参数,必须自行清理

调试器实操验证(OllyDbg/x64dbg)

  1. 抬栈模式观察

    • ret 8 指令处下断点
    • 执行前:ESP = 0012FFC0
    • 执行后:ESP = 0012FFC8(直接 +8
  2. 平栈模式观察

    • ret 指令处下断点
    • 执行前:ESP = 0012FFC0
    • 执行后:ESP = 0012FFC4(停在参数顶部)
    • 继续单步:看到调用者执行 add esp,8

用快递比喻:

  • 抬栈 = 快递员送货后直接带走包装箱(栈恢复干净)
  • 平栈 = 快递员只放下包裹,包装箱留在你门口(需你自己处理)
  • "跳过参数区域" = 快递员离开时完全无视包装箱(ESP指针不经过它)
  • "参数仍留在栈上" = 包装箱还堆在你家门口(ESP仍指向它)

相关推荐
浩浩测试一下1 天前
汇编 16位32位64位通用寄存器(逆向分析)
汇编·windows·stm32·单片机·嵌入式硬件·逆向·二进制
浩浩测试一下1 天前
汇编常用的(JCC 串 判断)指令 通用寄存器 标志寄存器 段寄存器(逆向分析)
汇编·通用寄存器·逆向二进制·标志寄存器·段寄存器·串 jcc 常用指令
hhcgchpspk2 天前
Windows API线程学习
c语言·windows·学习·多线程·windows api
浩浩测试一下2 天前
汇编 标志位寄存器 (逆向分析 )
c语言·汇编·逆向·windows编程·标志寄存器
浩浩测试一下2 天前
汇编 数组与串指令(逆向分析)
汇编·逆向·二进制·免杀·串指令·汇编数组
浩浩测试一下2 天前
汇编 内联汇编与混合编程 (逆向分析)
汇编·混合编程·windows编程·内联汇编·二进制逆向·c语言混合汇编
浩浩测试一下2 天前
汇编 结构体与宏
汇编··免杀·结构体·windows编程·逆向二进制
浩浩测试一下3 天前
汇编中的JCC指令 (逆向分析)
汇编·逆向·标志位·jcc指令·跳转指令·标志位寄存器
浩浩测试一下3 天前
汇编中的段与段寄存器(大小)段序 (逆向分析)
汇编·逆向·二进制·字节序·windows编程·内存地址排序
浩浩测试一下4 天前
汇编 call与ret 函数与堆栈 (逆向分析)
汇编·push·函数·pop·call·ret·堆栈逆向