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

目录

前言

[栈帧生命周期完整指南:压栈、抬栈、平栈、调用约定与 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仍指向它)

相关推荐
陈eaten1 小时前
win11下nasm编写汇编及链接方案
汇编·链接·nasm·gcc·golink
iCxhust2 小时前
【无标题】8086/8088裸机对于学习微机原理的重要意义
汇编·单片机·嵌入式硬件·嵌入式·微机原理
鸽芷咕3 天前
DOSBox 汇编环境搭建完整教程:安装配置 + MASM/LINK/DEBUG 工具链配置详解
汇编
Gofarlic_OMS3 天前
UG/NX许可证管理高频技术问题解答汇编
java·大数据·运维·服务器·汇编·人工智能
iCxhust3 天前
如何在汇编中修改CS:IP
汇编·单片机·嵌入式硬件·51单片机·微机原理
枷锁—sha4 天前
【CTFshow-pwn系列】03_栈溢出【pwn 073】详解:静态编译下的自动化 ROP 链构建
网络·汇编·笔记·安全·网络安全·自动化
wechatbot8885 天前
极客互动企业微信聚合聊天与接口能力全景展示
汇编·微信·企业微信·ipad
枷锁—sha6 天前
【CTFshow-pwn系列】03_栈溢出【pwn 072】详解:无字符串环境下的多级 Ret2Syscall 与 BSS 段注入
服务器·网络·汇编·笔记·安全·网络安全
iCxhust8 天前
8088汇编测试程序 (MASM/TASM) — 显示 “HELLO 8088!“ + “LCD1602 OK“
汇编·单片机·嵌入式硬件·51单片机·微机原理