二进制基础学习①-函数调用及其栈帧原理

发布于 2022-12-01  117 次阅读


author: bilala

0x00 前言

栈是一个先入后出的结构,如果还不了解的话得先自己百度看看。

本篇在示例中详细解析了汇编层面的指令执行过程,且画了图帮助理解,所以理论部分看的不是很懂的话也不要紧,看示例时候跟一跟就能明白了。

0x01 函数的调用

众所周知,当一个子函数要被调用时,他就会入栈,即push son_fun,执行完成后就会pop出栈。这里还要强调的一点是栈从高地址往低地址生长,接下来看一张图

image-20221130214952663

调用者的帧就是父函数的栈帧,第一个省略号(最上边的)就是正常执行时的栈,当我们在调用子函数时,才开始下面的这些操作,也就是从“参数n”开始都和调用子函数有关,操作如下:

  1. 父函数将子函数的参数从后向前压栈 ,也就是图中的参数n到参数1
  2. 将返回地址压栈保存,对应图中“返回地址”。当子函数调用结束后,我们要告诉程序接下来要回到哪里继续执行命令,这个哪里就是返回地址的地址值,这也是栈溢出实现的关键地方
  3. 跳转到子函数起始地址开始执行
  4. 子函数将父函数栈帧的起始地址入栈,对应着图中的“上一栈帧的%rbp”。注意此时的%rbp指着“调用者栈帧”的栈底,此时的%rsp指向“上一栈帧的%rbp”。
  5. 将%rbp的值设置为%rsp的值,也就是将%rbp也指向了“上一栈帧的%rbp”
  6. 开始执行子函数里的相关内容

上述过程中,保存返回地址和跳转到子函数处执行由 call 一条指令完成,在call 指令执行完成时,已经进入了子程序中,因而将上一栈帧%rbp 压栈的操作,需要由子程序来完成。

汇编层面的指令如下:

...   # 参数压栈
call FUNC  # 将返回地址压栈,并跳转到子函数 FUNC 处执行
...  # 函数调用的返回位置

FUNC:  # 子函数入口
push %rbp  # 保存旧的帧指针,相当于创建新的栈帧
mov  %rsp, %rbp  # 让 %rbp 指向新栈帧的起始位置
sub  $N, %rsp  # 在新栈帧中预留一些空位,供子程序使用,用 (%rsp+K) 或 (%rbp-K) 的形式引用空位

0x02 函数的返回

函数返回时,其返回值存储在%rax中。获取返回值后,只需将栈结构恢复到进入子函数前的结构即可。

操作如下:

  1. 将%rsp指向%rbp,即两个指针指向同个位置:子函数栈帧的栈底
  2. 弹出此栈。%rbp回到上一栈帧的%rbp,%rsp往高地址移一个位置,即指向了“返回地址”处

x86-64 架构中提供了 leave 指令来实现上述两步操作的功能。执行leave后,栈帧如下

image-20221201012200651

leave对应的汇编指令如下:

movq %rbp, %rsp    # 使 %rsp 和 %rbp 指向同一位置,即子栈帧的起始处
popq %rbp # 将栈中保存的父栈帧的 %rbp 的值赋值给 %rbp,并且 %rsp 上移一个位置指向父栈帧的结尾处

调用leave后一般都会跟一个ret指令,作用就是从当前 %rsp 指向的位置(即栈顶)弹出数据,并跳转到此数据代表的地址处,在图中就是跳转到“参数1”处,这时程序会从返回地址处的地方开始继续执行,而这些父栈帧尾部存储的调用参数由编译器自动释放。

0x03 函数调用示例

先写一个.c文件

#include <stdio.h>
#include <stdlib.h>

int add(int a,int b){
    int res = a+b;
    return res;
}

int main()
{
    int data1;
    int data2;
    int result;
    data1 = 111;
    data2 = 222;
    result = add(data1, data2);
    printf("result: %d", result);
    return 0;
}

利用gcc -fno-stack-protector -no-pie test.c -o test编译成可执行文件

然后在gdb中打下断点b main,然后r运行,在main处停下

image-20221201022018708

因为main本身也是一个函数,程序在运行到这个函数前做了很多初始化操作,所以各个通用寄存器中都已经有值了。

在disasm中箭头指向的就是下一条要执行的汇编指令,可以看到RIP寄存器中存储了这条指令的地址,此时的栈是这样的

image-20221201023719833

画个图就是这样

image-20221201023812593

然后回看刚刚那条 0x401144 sub rsp, 0x10 的指令

意思都知道是rsp的值减掉0x10,作用是为当前的栈帧开辟一段新空间。

按下s执行这条指令,可以看到

image-20221201024936450

这里可以留意一下这三个指针寄存器的变化,顺便回顾一下这三个寄存器的作用,此时图如下

image-20221201025307651

开辟完空间后,下两条命令就是将两个int整数的常量保存到内存中,0x6f=111, 0xde=222

运行掉这两条mov命令后,我们再来看栈的变化

image-20221201025932979

此时我们需要运行的两个数就已经被放到了内存中,可以使用x /8wx 0x7fffffffe2f0命令查看

image-20221201030411348

剩下的4个mov就是将这个值移来移去了,不重要,我们一路s命令跟到call指令

image-20221201030717808

此时的下一步就要进入到子函数add了,我们继续按下s命令,查看栈的变化

image-20221201030809903

根据我们刚刚讲的,第一步先将返回地址入栈(因为add函数中的参数都是常量了,所以跳过参数入栈这一步),也就是call的下一条命令的地址0x401165,此时图为

image-20221201031819688

这时候我们继续s往下走,执行push命令,也就是将main栈帧的rbp入栈

image-20221201031912738

图为:

image-20221201031944174

然后再下一步把rbp指向当前的rsp,s查看结果

image-20221201032029678

图为:

image-20221201032103294

之后几条命令就是add子函数的内容了,这里不作详细解析,就是最终结果存储到的eax中

image-20221201032231832

再一路跟进到pop处,这里就是涉及到函数的返回了,刚刚的函数调用过程和我们理论分析时一模一样,再看看返回函数会怎么做

add函数由于不需要空间,所以整个函数过程中,mov、add这些执行时,rbp和rsp指的位置都没变,都还在0x7fffffffe2e0

在pop还未执行时停住

image-20221201032641316

pop执行后,rbp的值会变成当前所在地址的值,也就是上一栈帧的rbp,然后rsp也会往高地址回一个位置

按下s看看栈是否如上述一般变化

image-20221201033007758

完全没毛病,此时图为

image-20221201033039068

然后ret指令,就是rsp往高地址回一个位置,程序回到返回地址的值处继续走,图为

image-20221201033518836

pwndbg中也可以看到,程序又回到了main函数的地方,子函数调用结束

image-20221201033614198

0x04 参考资料

https://zhuanlan.zhihu.com/p/27339191