foo函数汇编详解

我们将把 foo 函数的汇编代码分成几个逻辑部分:函数准备(Prolog)、执行 C 代码 和 函数清理(Epilog)。

这是 foo 函数的完整汇编:

    1 000000000040116e <foo>:
    2   40116e:55                   push   %rbp
    3   40116f:48 89 e5             mov    %rsp,%rbp
    4   401172:48 83 ec 30          sub    $0x30,%rsp
    5   401176:c7 45fc 0c 00 00 00  movl   $0xc,-0x4(%rbp)
    6   40117d:8b55 fc               mov    0x4(%rbp),%edx
    7   401180:8b45 fc               mov    0x4(%rbp),%eax
    8   401183:89 c6                 mov    %eax,%esi
    9   401185:bf20 20 40 00         mov    $0x402020,%edi
   10   40118a:b800 00 00 00         mov    $0x0,%eax
   11   40118f:e8 ac fe ff ff        call   401040 <printf@plt>
   12   401194:48 8d 45 d0           lea    0x30(%rbp),%rax
   13   401198:48 89 c7              mov    %rax,%rdi
   14   40119b:e8 b0 fe ff ff        call   401050 <gets@plt>
   15   4011a0:8b55 fc               mov    0x4(%rbp),%edx
   16   4011a3:8b45 fc               mov    0x4(%rbp),%eax
   17   4011a6:89 c6                 mov    %eax,%esi
   18   4011a8:bf20 20 40 00         mov    $0x402020,%edi
   19   4011ad:b800 00 00 00         mov    $0x0,%eax
   20   4011b2:e889 fe ff ff         call   401040 <printf@plt>
   21   4011b7:90                    nop
   22   4011b8:c9                    leave
   23   4011b9:c3                    ret


  1. 函数准备 (Function Prologue) - 建立栈帧

这部分代码的目的是为 foo 函数建立一个独立、干净的工作空间,也就是它的“栈帧”。

    1   40116e:push   %rbp
    2   40116f:mov    %rsp,%rbp
    3   401172:sub    $0x30,%rsp
  • push %rbp:
    • %rbp 是“基址指针”(Base Pointer),它指向上一个函数(这里是 main)的栈帧底部。这条指令先把 main 函数的基址指针存到栈上,以便 foo 函数结束后能恢复它。
  • mov %rsp, %rbp:
    • %rsp 是“栈指针”(Stack Pointer),它总是指向栈的顶部。这条指令让 %rbp 指向当前的栈顶。从此,%rbp 就成了 foo 函数自己的“基准点”,用来定位自己的局部变量。
  • sub $0x30, %rsp:
    • 这是为局部变量分配空间的关键指令。它将栈顶指针 %rsp 向下移动 0x30 (48) 个字节。这就在栈上开辟了一块 48 字节的内存,用来存放 foo 函数的所有局部变量(包括 fav_number 和 buf)。

栈的当前状态 (地址从高到低):

    +-------------------------+
    | main函数的返回地址      | <-- %rbp + 8
    +-------------------------+
    | main函数的rbp           | <-- %rbp
    +-------------------------+
    |                         | \
    | 48字节的局部变量空间    |  } <-- 由 sub $0x30,%rsp 开辟
    |                         | /
    +-------------------------+ <-- %rsp

  1. 执行C代码

对应 const int fav_number = 12; 和第一个 printf

   1   401176:	movl  $0xc,-0x4(%rbp)
   2   ... (准备参数) ...
   3   40118f:	call  401040 <printf@plt>
  • movl $0xc, -0x4(%rbp):
    • $0xc 是十六进制的 c,即十进制的 12。
    • -0x4(%rbp) 表示“从基准点 %rbp 向下 4 个字节的地址”。
    • 这条指令就是把 12 这个值存入 %rbp - 4 的位置。编译器决定把 fav_number 这个变量放在这里。
  • 接下来的几条 mov 指令是在为 printf 准备参数。在64位系统中,函数的前几个参数通过寄存器传递:
    • mov $0x402020, %edi: 把格式化字符串的地址传给第一个参数寄存器 %edi。
    • mov -0x4(%rbp), %eax 和 mov %eax, %esi: 把 fav_number 的值 (12) 传给第二个参数寄存器 %esi。
    • mov -0x4(%rbp), %edx: 再次把 fav_number 的值传给第三个参数寄存器 %edx。
  • call printf@plt: 调用 printf 函数,打印出 “My favorite number is 12…“。

对应 gets(buf); - 漏洞所在

    1   401194:48 8d 45 d0           lea    0x30(%rbp),%rax
    2   401198:48 89 c7              mov    %rax,%rdi
    3   40119b:e8 b0 fe ff ff        call   401050 <gets@plt>
  • lea -0x30(%rbp), %rax:
    • 这是定位缓冲区的关键指令。lea (Load Effective Address) 的作用是计算地址。
    • 它计算出 %rbp - 0x30 这个地址,并把它存入 %rax 寄存器。这个地址就是我们为局部变量开辟的48字节空间的最底部,也就是 buf 的起始地址。
  • mov %rax, %rdi:
    • 将 buf 的起始地址(刚刚存放在 %rax 中)移动到 %rdi 寄存器。
    • 这是在为 gets 函数准备它的第一个(也是唯一一个)参数:缓冲区的地址。
  • call gets@plt:
    • 调用 gets 函数。程序会在这里暂停,等待你输入。gets 会把你输入的所有内容,无论多长,都复制到 %rdi 指向的地址,也就是 buf 的位置。溢出在这里发生!

对应第二个 printf

这部分代码和第一个 printf 几乎一样,它再次加载 fav_number 的值并打印。

但请注意:如果你输入的字符串足够长,它会溢出 buf,向上覆盖掉存放在 -0x4(%rbp) 的 fav_number。这时,第二个 printf 打印出的将不再是 12,而是你输入数据的一部分!这是缓冲区溢出的一个典型现象。


  1. 函数清理 (Function Epilogue) - 恢复栈帧并返回

这部分代码负责“打扫战场”,恢复 main 函数的栈帧,然后返回。

    1   4011b7:90                    nop
    2   4011b8:c9                    leave
    3   4011b9:c3                    ret
  • nop: No Operation,空指令,什么也不做。通常是编译器为了对齐地址而插入的。
  • leave: 这是一条很方便的指令,相当于执行下面两条指令:
    1. mov %rbp, %rsp: 将栈顶指针 %rsp 拉回到基址指针 %rbp 的位置,瞬间丢弃/释放 foo 函数的所有局部变量空间。
    2. pop %rbp: 从栈顶弹出一个值,恢复到 %rbp 寄存器中。这个值就是我们一开始 push %rbp 保存的 main 函数的基址指针。
  • ret:
    • 这是攻击的引爆点。ret (Return) 指令会从当前栈顶弹出一个地址,然后无条件地跳转到那个地址去执行。
    • 在正常情况下,这个地址就是 main 函数调用 foo 后应该继续执行的下一条指令的地址。
    • 在我们的攻击中,这个地址已经被我们的超长输入给覆盖了。ret 指令会读取我们精心构造的假地址(指向我们 Shellcode 的地址),然后跳转过去,从而让我们夺取程序的控制权。