跳转至

栈的概念

栈是理解函数调用、局部变量、调试回溯和栈内存错误的共同基础。本文面向 CTF Pwn 初学者,以 Linux x86-64、ELF、System V AMD64 ABI 为基线。这里的“栈”主要指用户态线程栈,不是抽象数据结构课程中的通用 LIFO 容器。

学习范围

文中的程序和命令只用于自己编写的本地示例、课程实验与授权 CTF 环境。本文解释运行时结构,不提供针对真实目标的利用步骤或武器化 payload。

先建立三个层次

初学者常把源码变量、汇编栈槽和进程内存混为一谈。可以先把问题分成三个层次:

层次 关注对象 典型问题
C 源码 函数、参数、局部变量、作用域 local 在语义上何时存在?
ABI 与机器指令 寄存器、栈指针、调用约定 参数从哪个寄存器进入?
操作系统进程 虚拟地址空间、页权限、线程 栈映射在哪里,是否可执行?

C 标准并不要求所有局部变量都放在机器栈上。具体布局由编译器、优化选项、ABI 和运行环境共同决定。

进程虚拟内存布局

Linux 为进程提供虚拟地址空间。不同区域通常具有不同来源、生命周期和页权限。 一个简化示意如下:

高地址
+------------------------------+
| 线程栈(通常向低地址增长)   |
+------------------------------+
| 内核保留的用户态边界附近     |
+------------------------------+
| 共享库、mmap 映射            |
+------------------------------+
| 堆(传统 brk 区域)          |
+------------------------------+
| .bss:未显式初始化的全局数据 |
| .data:可写的已初始化数据    |
| .rodata:只读常量            |
| .text:可执行代码            |
+------------------------------+
低地址

这只是概念图,不是固定地址表。ASLR、PIE、动态链接器、内核版本和分配行为都会改变实际映射。

区域 常见内容 常见权限 生命周期
.text 机器指令 r-x 程序映像存在期间
.rodata 字符串字面量、只读常量 r-- 程序映像存在期间
.data/.bss 全局变量、静态变量 rw- 进程生命周期
heap / mmap 动态分配对象、映射文件 常见为 rw- 由分配与解除映射决定
thread stack 调用状态、部分局部对象 常见为 rw- 线程生命周期

在本地 Linux 中可以观察映射:

cat /proc/$(pidof demo)/maps

也可以在 GDB 中运行:

info proc mappings

不要从“地址较高”直接推断某地址一定属于栈。应以映射边界、权限和实际对象地址为证据。

每个线程都有自己的栈

一个进程可以包含多个线程。每个线程通常有独立的线程栈和独立的 rsp,但线程共享进程的代码、全局数据和大部分地址空间。 因此:

  • “进程只有一个栈”不准确。
  • 主线程栈通常在进程启动时建立。
  • 新线程的栈通常由线程库通过映射创建。
  • 栈大小有限,深递归或巨大局部数组可能造成栈耗尽。
  • 栈附近通常存在保护页,用于尽早发现越界增长。

线程局部存储 TLS 也与线程相关,但它不是线程栈本身。栈 Canary 的参考值常通过线程相关状态访问,这也不意味着 Canary“存放在 TLS 栈里”。

栈为什么常说向低地址增长

在本文基线下,压入数据通常使 rsp 变小:

push rax

可以近似理解为:

sub rsp, 8
mov [rsp], rax

弹出数据通常使 rsp 变大:

pop rax

近似理解为:

mov rax, [rsp]
add rsp, 8

“向低地址增长”描述的是分配更多当前栈空间时地址方向的常见变化。它不表示数组索引、字符串写入或所有对象内部访问都向低地址进行。

rsp:当前栈顶

rsp 是 x86-64 的栈指针寄存器。在任意时刻,[rsp] 表示当前栈顶地址处的内存。 rsp 会因多种指令变化:

push rbx
sub rsp, 32
call helper
add rsp, 32
pop rbx
ret

分析时要逐条累计:

操作 对 rsp 的典型影响
push reg rsp -= 8
pop reg rsp += 8
sub rsp, N 分配 N 字节栈空间
add rsp, N 释放 N 字节栈空间
call target 压入 8 字节返回地址
ret 取出 8 字节返回地址

不要只在函数入口记录一次 rsp。函数内部的对齐、临时空间和嵌套调用都会改变它。

rbp:可能的帧指针

未优化或显式保留帧指针时,常见函数序言是:

push rbp
mov rbp, rsp
sub rsp, 32

此时 rbp 在函数执行期间提供相对稳定的基准:

[rbp+8]   常见为返回地址
[rbp]     保存的旧 rbp
[rbp-...] 常见为局部栈槽

对应尾声可能是:

leave
ret

leave 近似等价于:

mov rsp, rbp
pop rbp

但 rbp 不保证永远是帧指针。优化构建常使用 -fomit-frame-pointer,把 rbp 当普通 callee-saved 寄存器。现代回溯还可依赖 .eh_frame 等展开信息。

call 到底做了什么

执行:

call target
CPU 完成两个关键动作: 1. 把 call 后下一条指令的地址压入栈。 2. 把控制流转移到 target。 假设调用前:
rsp = 0x7fffffffe000
下一条指令地址 = 0x401185
进入目标函数时可概念化为:
rsp = 0x7fffffffdff8
[rsp] = 0x401185
rip = target
返回地址是控制数据。它不是 C 函数显式声明的局部变量,却决定函数结束后从哪里继续执行。

ret 到底做了什么

普通近返回指令:

ret
可以近似理解为:
rip = [rsp]
rsp = rsp + 8
因此函数返回前必须把 rsp 恢复到返回地址所在位置。若局部空间没有释放、保存寄存器没有弹出或栈已损坏,ret 读取的内容就可能不再是原返回地址。 ret imm16 还会额外调整栈,但在 Linux x86-64 普通 C 函数中不常见。

什么是栈帧

“栈帧”是某次函数调用在栈上使用的逻辑区域。它可能包含: - 返回地址。 - 保存的旧帧指针。 - 保存的 callee-saved 寄存器。 - 局部数组和取地址的局部变量。 - 寄存器溢出槽与临时值。 - 超过寄存器传参数量的参数。 - 为对齐预留的填充。 栈帧不是硬件自动创建的完整对象。硬件只为 call 压入返回地址;其余布局通常由编译器生成的指令建立。 一个带帧指针的概念图:

高地址
+-----------------------+
| 调用者的栈上参数      |
+-----------------------+
| 返回地址              |  [rbp+8]
+-----------------------+
| 保存的旧 rbp          |  [rbp]
+-----------------------+
| 保存寄存器 / 填充     |
+-----------------------+
| 局部变量与临时栈槽    |  [rbp-N]
+-----------------------+ <- rsp(函数体某时刻)
低地址
实际顺序不是源码声明顺序的可靠镜像。编译器可以重排、合并、消除变量,也可以完全不建立传统栈帧。

参数如何进入函数

System V AMD64 ABI 中,普通整数和指针类参数的前六个位置通常是:

rdi, rsi, rdx, rcx, r8, r9
返回值通常放在 rax。第七个及后续适合该分类的参数通常通过栈传递。 例如:
long sum3(long a, long b, long c) {
    return a + b + c;
}
优化后可能没有栈帧:
sum3:
    lea rax, [rdi+rsi]
    add rax, rdx
    ret
这说明“调用函数”不等于“必须把全部参数压栈”。浮点、向量和聚合类型还要按 ABI 分类,不能只套用前六个通用寄存器规则。 更完整的调用约定说明见函数与调用约定。

局部变量不一定在栈上

观察源码:

int twice(int x) {
    int y = x * 2;
    return y;
}
优化编译器可能直接生成:
lea eax, [rdi+rdi]
ret
y 没有独立栈槽。更容易落到栈上的情况包括: - 局部对象地址被取用。 - 存在固定大小数组。 - 调试构建降低优化。 - 寄存器不足,需要 spill。 - 值要跨越函数调用保存。 - ABI 要求保存某些寄存器或对象。 调试信息显示的变量位置也可能随指令范围变化。变量可能先在寄存器中,后来移到栈上,或者被优化掉。

16 字节栈对齐

System V AMD64 ABI 对函数调用边界有对齐要求。实用记忆方式是:调用者在执行 call 前安排好 16 字节对齐。 由于 call 会压入 8 字节返回地址,普通函数刚进入时常见:

rsp % 16 == 8
函数若要继续调用其他函数,通常会通过 push 或 sub rsp, N 重新安排调用前对齐。 示例:
push rbp          ; 再减少 8 字节
mov rbp, rsp
sub rsp, 32       ; 分配局部空间,保持合适边界
call helper
leave
ret
错误对齐可能让依赖对齐的向量指令或库实现失败。不要死记某个固定 sub rsp, 40 模板;应从函数入口和每次调整逐步计算。

red zone

System V AMD64 ABI 为用户态代码定义了 rsp 以下 128 字节的 red zone。符合约定的信号或中断处理不会破坏这一区域。叶函数可在不移动 rsp 的情况下把临时数据放入其中。 概念示例:

leaf:
    mov qword ptr [rsp-8], rdi
    mov rax, qword ptr [rsp-8]
    ret
需要注意: - red zone 位于当前 rsp 的低地址方向。 - 它不是额外映射的保护区域。 - 它不是 Windows x64 的 shadow space。 - Windows x64 ABI 没有 System V red zone。 - 内核代码通常禁用 red zone,例如使用 -mno-red-zone。 - 非叶函数不能假设被调用函数会保留自己放在其栈顶以下的数据。 调试时看到 [rsp-8] 的访问而没有 sub rsp,不一定是越界,可能是在使用 red zone。

源码、汇编与 GDB 对照实验

创建仅供本地观察的 stack_demo.c:

#include <stdio.h>
__attribute__((noinline))
long inspect(long a, long b) {
    long local = a + b;
    printf("local=%ld\n", local);
    return local * 2;
}
int main(void) {
    return inspect(20, 22) == 84 ? 0 : 1;
}
用保留帧指针的调试配置编译:
gcc -g -O0 -Wall -Wextra -fno-omit-frame-pointer \
    stack_demo.c -o stack_demo
查看混合源码与 Intel 语法反汇编:
objdump -d -M intel -S ./stack_demo
启动 GDB:
gdb -q ./stack_demo
建议执行:
set disassembly-flavor intel
break inspect
run
info registers rdi rsi rsp rbp rip
disassemble /m inspect
x/8gx $rsp
nexti
info registers rsp rbp
bt
进入 inspect 时,预期先验证:

观察项 预期含义
$rdi 第一个整数参数 a,值为 20
$rsi 第二个整数参数 b,值为 22
$rsp 当前栈顶,入口处指向返回地址
x/gx $rsp 查看调用者压入的返回地址候选
$rip 当前将执行的指令位置

逐条走过函数序言后,再比较 rsp 与 rbp。如果断点落在源码行而不是函数第一条指令,序言可能已经执行,应使用:

break *inspect
run
然后用 si 逐条观察。 更完整的命令说明见GDB 在 Pwn 中的使用和工具、反汇编与调试。

对照优化构建

再编译一个优化版本:

gcc -g -O2 -Wall -Wextra stack_demo.c -o stack_demo_O2
objdump -d -M intel ./stack_demo_O2
可能看到: - rbp 不再作为帧指针。 - local 长时间保存在寄存器中。 - 指令顺序与源码行顺序不完全一致。 - 常量传播或内联改变函数边界。 - 尾调用使 call; ret 变成 jmp。 noinline 只抑制该函数被内联,不会关闭其他优化。分析 CTF 二进制时,应以实际机器指令为准,而不是期待它长得像 -O0 教材示例。

在 GDB 中画出当前帧

可以按以下顺序记录证据:

info frame
info args
info locals
info registers rsp rbp rip
x/24gx $rsp
disassemble /r inspect
bt
随后手工标注:
地址范围                  暂定含义              证据
0x... - 0x...             当前局部栈槽          汇编 [rbp-N]
0x...                     保存的旧 rbp          函数序言与 info frame
0x...                     返回地址              call 后地址与反汇编匹配
0x...                     调用者区域            超出当前帧边界
“暂定含义”很重要。没有符号的二进制中,内存中的某个 8 字节值看起来像代码地址,并不足以证明它就是返回地址。

常见误区

误区一:栈就是局部变量区

栈还保存返回地址、部分寄存器、对齐填充和临时状态。而局部变量也可能完全在寄存器中。

误区二:rsp 总是指向当前局部变量

rsp 指向当前栈顶。栈顶内容可能是返回地址、保存寄存器、临时空间或对齐区域。

误区三:rbp 总是当前帧基址

只有编译器选择使用帧指针时才成立。优化代码必须结合序言、展开信息和访问模式判断。

误区四:源码声明顺序等于内存顺序

编译器可以重排对象、复用栈槽、加入 Canary 和填充,或删除变量。

误区五:函数参数都在栈上

x86-64 System V 的前六个普通整数或指针参数通常从寄存器进入。

误区六:栈地址每次都相同

ASLR 会随机化栈等映射。调试记录应优先使用相对关系、符号和模块偏移。

误区七:call 创建整个栈帧

call 只压入返回地址并转移控制流。局部空间和保存动作由函数代码完成。

误区八:red zone 是栈保护机制

red zone 是 ABI 允许叶函数使用的临时区域,不是越界检测或安全边界。

误区九:栈一定可执行

现代 Linux 通常通过 NX 把栈映射为不可执行。是否可执行应检查 ELF 与运行时映射,不能凭历史印象判断。

阅读一个函数的检查清单

  1. 确认架构、位数、操作系统和 ABI。
  2. 找到函数入口及所有调用点。
  3. 记录入口处参数寄存器和栈参数。
  4. 识别 push、sub rsp 与保存寄存器。
  5. 判断 rbp 是帧指针还是普通寄存器。
  6. 逐步计算 rsp,不要依赖模糊的“栈顶附近”。
  7. 标记局部栈槽、填充和 red zone 使用。
  8. 在每个 call 前检查参数与 16 字节对齐。
  9. 记录 caller-saved 寄存器可能被破坏。
  10. 检查返回值位置和函数尾声。
  11. 对照 GDB 的寄存器、内存和 info frame。
  12. 用优化版本验证结论是否只依赖教材式序言。

学完后应能回答

  • 进程地址空间中的线程栈与堆、代码段有什么区别?
  • rsp 和 rbp 分别提供什么信息?
  • call 如何产生返回地址,ret 从哪里读取它?
  • 为什么局部变量不一定对应一个固定栈槽?
  • 为什么函数入口常见 rsp % 16 == 8?
  • red zone 能做什么,不能做什么?
  • 如何用源码、反汇编和 GDB 三方验证一个栈帧判断? 下一步可阅读栈溢出基础,理解错误写入如何从普通数据破坏发展为控制数据破坏。同时建议结合面向 Reverse 与 Pwn 的汇编建立完整的二进制分析视角。