汇编与寄存器:x86/x64指令集与函数调用栈帧深度剖析

汇编与寄存器:x86/x64指令集与函数调用栈帧深度剖析

如果说 Web 渗透是寻找逻辑漏洞,那么 Pwn(二进制漏洞利用) 则是直接与 CPU 和内存对话的终极黑客艺术。

要在一堆枯燥的十六进制机器码中找到可以执行 system("/bin/sh") 的破绽,我们必须首先理解 CPU 是如何思考的。本文将带你潜入最底层的 x86/x64 架构,解析寄存器的奥秘,并在一张内存切面图上,推演函数调用时“栈帧(Stack Frame)”的生与灭。


1. 寄存器:CPU 的“超高速草稿纸”

内存(RAM)虽然很大,但对于 CPU 来说太慢了。为了高速运算,CPU 内部有一组容量极小但速度极快的存储单元,这就是寄存器(Register)

在 64 位架构(x86_64 或 AMD64)中,常用的通用寄存器有 16 个(rax, rbx, rcx, rdx, rsi, rdi, rbp, rsp, r8 - r15)。其中有几个对 Pwn 至关重要:

  • rsp (Stack Pointer,栈顶指针):永远指向当前栈的最高位置(由于栈向下生长,也就是地址最小的地方)。Pwn 的核心就是控制它。
  • rbp (Base Pointer,栈底指针):指向当前函数栈帧的底部。它是定位局部变量的基准锚点。
  • rip (Instruction Pointer,指令指针)黑客的圣杯。它永远指向 CPU 下一条要执行的指令地址。如果攻击者能劫持 rip,就能让 CPU 执行任意恶意代码。
  • rax:通常用于存放函数的返回值,或者系统调用的调用号。
  • rdi, rsi, rdx, rcx, r8, r9:在 x64 下,这 6 个寄存器用于传递函数的前 6 个参数(超过 6 个的参数才会压入栈中)。

2. 汇编语言:与机器沟通的桥梁

汇编语言是机器码的可读表示。理解基础汇编,是看懂反汇编代码(如 IDA Pro 或 GDB 输出)的前提。

核心指令解析(Intel 语法:指令 目标, 源):

  • mov rax, rbx:将 rbx 的值复制到 rax
  • add rax, 8:将 rax 的值加 8。
  • sub rsp, 0x20:将 rsp 减 0x20。(关键:由于栈向下生长,这代表在栈上开辟了 0x20 字节的空间给局部变量用)
  • push rax:将 rax 的值压入栈。等价于:sub rsp, 8; mov [rsp], rax
  • pop rbx:将栈顶的值弹出到 rbx。等价于:mov rbx, [rsp]; add rsp, 8
  • call 0x4005b6:调用函数。(极度关键:这不仅仅是一个跳转,它会自动将下一条指令的地址(返回地址,Return Address)压入栈中,然后再修改 rip 跳到目标地址)
  • ret:从函数返回。(极度关键:它等价于 pop rip。也就是把栈顶的值弹出来,塞给 rip。这就是栈溢出攻击劫持执行流的核心机制)

3. 栈帧 (Stack Frame):内存的微观切面

栈(Stack)是内存中的一块区域,采用“后进先出(LIFO)”结构,且向低地址方向生长。 当程序执行一个函数时,会在栈上为这个函数分配一块专属的空间,称为栈帧

3.1 函数调用的微观推演 (Prologue & Epilogue)

假设有如下 C 代码:

void funcA() {
    int a = 1;
    funcB(); // 这里调用 funcB
}

funcA 调用 funcB 时,底层的汇编和内存发生了什么?

阶段一:调用前的准备 (funcA 的操作)

当执行到 call funcB 时,CPU 会自动把 call 指令下面那条指令的地址压入栈中。这叫做返回地址(Return Address)

阶段二:建立新栈帧 (funcB 的 Prologue/序言)

进入 funcB 的第一件事,是保存 funcA 的现场,并为自己开辟空间:

push rbp        ; 将 funcA 的 rbp (老栈底) 压入栈中保存
mov rbp, rsp    ; 将当前 rsp 的位置作为 funcB 的新栈底 (rbp)
sub rsp, 0x10   ; rsp 向下移动 16 字节,为 funcB 的局部变量分配空间

此时的内存切面图如下(高地址在下,低地址在上):

       |                 | (低地址)
       |-----------------| <-- rsp (当前栈顶)
       | funcB 局部变量  | 
       |-----------------| <-- rbp (当前 funcB 栈底)
       | 保存的旧 rbp    | (Saved rbp)
       |-----------------|
       | 返回地址 (RET)  | (执行完 funcB 后要跳回 funcA 的地址)
       |-----------------|
       | funcA 的数据    |
       |                 | (高地址)

阶段三:销毁栈帧 (funcB 的 Epilogue/结语)

funcB 执行完毕,准备返回时:

leave           ; 等价于 mov rsp, rbp; pop rbp。这不仅清空了局部变量,还恢复了 funcA 的老栈底
ret             ; 等价于 pop rip。将刚才保存的“返回地址”弹出并赋给 rip,CPU 回到 funcA 继续执行

4. 栈溢出 (Stack Overflow) 的原罪

理解了栈帧结构,你就能立刻看懂栈溢出攻击的本质。

假设在上面的结构中,funcB 的局部变量是一个大小为 16 字节的字符数组 char buf[16]。如果程序使用了危险的函数 gets(buf)strcpy(buf),它不会检查用户输入的长度

攻击推演: 如果攻击者输入了 A * 16 + B * 8 + C * 8。

  • 前 16 个 A 填满了 buf
  • 接下来的 8 个 B,由于越界(向上生长,向高地址覆盖),覆盖了“保存的旧 rbp”
  • 最后的 8 个 C精准地覆盖了“返回地址 (RET)”

funcB 执行到 ret 时,它本该把合法的返回地址弹出给 rip。但现在,它把攻击者覆盖的 8 个 C(地址 0x4343434343434343)弹出给了 rip

CPU 毫不怀疑,直接跳转到了 0x4343434343434343 去执行指令。程序的执行流,就此被黑客彻底接管。