文章目录
- 前言
- 预备知识
- demo及其汇编代码
- 汇编代码讲解
- 返回值篡改(1+1=1 ???)
前言
复习时遇到一些问题,可能是关于左值右值的概念理解不够透彻,于是转头去研究左值右值的问题,但是想要参透左右值,我又觉得需要研究一下函数栈帧,所以写下这篇博客。一年前已经写过关于函数栈帧的博客,但是由于当时初学,理解肯定没有现在透彻,虽然那篇博客已经标明是自用笔记,没想其他人看懂,但是现在我也有些看不懂,可能当时太浮躁了吧,经过一年的学习现在也能更好的理解这块知识点,同时心态也好了许多,这篇博客更像是一种成长吧
预备知识
由于函数栈帧要从汇编的角度讲解,所以先介绍一些简单汇编指令以及一些通用寄存器
eax,通用寄存器,保存临时数据,常用于函数返回值
ebx,通用寄存器,保存临时数据
ebp,栈底寄存器,保存栈底的地址
esp,栈顶寄存器,保存栈顶的地址
eip,指令寄存器,保存需要执行的下一条指令
mov,数据转移指令
push,将数据入栈,同时修改esp的内容,使其始终指向栈顶
pop,将数据出栈,同时修改esp的内容,使其始终指向栈顶
sub,add,给数据做减法与加法
call,函数调用指令,将函数调用完成需要指向的下一条指令入栈,并且跳转到函数地址
jump,修改eip寄存器,使程序跳转到eip的地址处
ret,恢复返回地址,将当前栈顶的地址出栈并把数据保存到eip中,类似pop eip
单执行流的程序运行后,其ebp和esp之间就形成了一个栈帧结构,最开始的函数栈帧肯定是main函数的,之后main函数可能调用其他函数,此时就要重新建立栈帧结构,函数执行完会返回到调用它的函数中,向下继续执行其他代码,所以建立的栈帧结构需要被销毁,这篇博客将对函数栈帧的创建与销毁进行探讨。一个项目的main函数肯定会进行函数的调用,这是毋庸置疑的,函数可以使用特定功能的封装,使代码与main函数解耦,理解函数的调用过程无疑能增加我们对编译型语言的理解
demo及其汇编代码
int my_add(int a, int b)
{
int c = 0;
c = a + b;
return c;
}
int main()
{
int x = 0xA;
int y = 0xB;
int z = my_add(x, y);
return 0;
}
demo很简单,定义了两个int变量,将它们作为my_add的参数进行函数的调用,再创建一个int变量接收函数的返回值
这是一个进程的地址空间,其中栈区的地址较高,并且向下(低地址)增长,这篇博客的讲解的函数栈帧就位于栈区中
汇编代码讲解
int x = 0xA,创建一个int变量x并用10对其初始化,可以看到代码翻译成汇编也只有一条语句,dword是一个修饰符,表示双字类型,这里是2个字节。ptr是pointer的缩写,也是一个修饰符,表示该数据是一个地址,两个一起就表示数据是一个指针,指向的数据大小为2字节
把显示符号名的选项去掉,x其实对应了一个地址,该地址的数值等于ebp所保存的地址再减去8
点击寄存器的选项,就能看到寄存器保存的值
然后调用内存窗口,输入EBP的值,我们可以看到EBP保存的地址上存储的数据。再回到int x = 0xA的汇编代码上,mov指令将逗号右边的数据移动到逗号左边,显然逗号左边是一个地址,所以该地址上的数据就被修改为逗号右边的数据,由于字节序为小端,所以低地址处存储低字节数据,a被放到的低地址上。然后是第二条汇编,将b存储到EBP-20的地址处(14h是十六进制),然后也是小端存储,上图是两个变量创建并初始化完成后的地址相关情况。
这是创建完两个变量后的main函数栈帧,大概画一下,一格的大小是4字节,从栈底到栈顶省略了一些空间
接着是int z = my_add(x, y),要调用my_add函数了,调用函数需要先传参,函数要用形参接收实参,汇编中的mov和push就是一次传参,一次mov+push就创建了一个形参。第一个形参的形成:将EBP-20地址处的4字节数据移动到通用寄存器eax中
接着push将eax入栈,此时的栈顶ESP的值为00AFFDC0,将一个4字节数据入栈,栈顶的地址也会发生变化,由于栈的增长方向是低地址,所以ESP的值会减少4,表示栈增长4字节
画图直观的感受数据的入栈
可以看到ESP向上移动了一格(4字节),然后数据入栈(b进入了栈顶),并且ESP寄存器的值减小4。最后入栈的是a,过程与b是一样的,这里不再赘述
并且我们可以看到EIP寄存器(指令寄存器)始终保存了程序要执行的下一条指令的地址
调用call指令之前程序的栈帧结构,一个是main函数的栈帧,一个是push参数形成的栈帧,并且是b先入栈,a再入栈,所以函数调用的形参形成顺序是从右向左,my_add(x, y),y先入栈,x再入栈。形参创建完成后,接下来就要调用call指令,call 001D1465,call指令会修改EIP的值为指定地址,使程序的执行流发生跳转,跳转到另一个函数,当函数执行完程序需要跳转回原来的函数继续执行,所以call会将当前函数栈的下一条指令保存(入栈)。
调用call之前,注意EIP,ESP的值以及下一条指令add的地址
调用call之后,EIP被修改为001D1465,程序即将执行该地址处的代码
两点。一是EIP的值被修改为001D1465,这是call指令对EIP的修改,使程序指向流发生跳转,即将执行的代码是jmp。二是ESP减小了4,说明有数据入栈,通过内存观察ESP所指向的栈顶,发现栈顶数据是001D1950(内存中的数据是小端存储),再看调用call之前的图片,001D1950不就是call指令的下一条指令add的地址吗?说明了,call指令会保存当前函数栈的下一条地址。
这是当前的栈结构,可以看到一串地址被入栈。call指令的下一条是jmp,jmp指令修改EIP寄存器的内容,使程序执行流发生跳转,与call不同,jmp没有push地址,只有修改EIP。
调用jmp之前
调用jmp之后
EIP的值被修改,程序接下来要执行的指令所在地址为001D1A00,该地址在my_add函数内部,所以经过jmp程序成功的跳转到函数内部
但是在执行my_add函数之前还有一些指令要完成,这些指令呢,就是函数栈帧的创建,这也是调用函数的资源消耗。有些代码对这篇文章主要内容的理解不大影响,所以先忽略它们,其中前三条:push,mov和sub这三条指令最为重要,作为重点讲解。
push ebp,将ebp寄存器的值push入栈,注意EBP和ESP的值
push之后,EBP的值入栈,ESP的值减小4,栈空间增长,此时ESP指向的栈顶存储了EBP的值(注意字节序)
(栈帧情况)
接着是mov ebp, esp,将ESP的值移动到EBP中,什么意思呢?ESP指向栈顶,EBP指向栈底,将栈顶的值移动到栈底寄存器中,此时的栈底和栈顶指向相同的地方
然后是sub esp, 0CCh,将ESP寄存器的值减去CC(十六进制),什么意思呢?ESP是栈顶指针,指向了函数栈的顶部,将其减小(栈向低地址处增长),表示函数栈的增长。至于为什么增长的空间是CC,一个函数栈帧要使用多大的空间?编译器要怎么知道这个函数会使用多大的空间,其实函数的栈帧存储的都是数据,具体的说,这些数据由变量承载,变量对应了地址,数据存储在地址上,这些左值变量决定了一个函数栈帧的大小。编译器当然知道变量的类型有几个字节,知道一个函数中所有变量要使用多少字节的空间,就能大概的开辟栈帧空间,编译器预估了my_add函数中的变量(存储的数据)会使用的空间,所以减去CC,使栈帧增长CC字节的空间。
注意ESP的值
my_add的函数栈帧就这样被开辟了,由于之后的指令不影响主要内容的理解,我们先跳过这些指令,到int c = 0
与之前变量的创建一样,将0移动到ebp-8的地址处
指令较简单,之前讲解过,这里不再赘述
之后的c = a + b被翻译成三条汇编,将EBP+8地址处的数据移动到eax中,再将EBP+12(注意图片上是十六进制)地址处的数据add(相加)到eax寄存器中,这里就完成了a + b,最后将eax的数据(a + b)的结果移动到EBP-8地址处。
EBP+8,EBP+12以及EBP-8是谁的地址?
通过函数栈帧我们可以得知,EBP+8是形参a的地址,存储的数据是a(10),EBP+12是形参b的地址,存储的数据是b(11),EBP-8是函数中定义的参数c的地址,存储的数据是0。
注意eax寄存器中的值
mov执行完成,形参a的值被移动到eax中,然后是add,继续观察eax的值
最后是将eax的数据移动到形参c中,注意ebp-8地址处的值
这三条汇编执行完,ebp-8地址处的值就变成了21,a + b = 21(注意十六进制),ebp-8地址处的值就是函数需要返回的值
函数的主要功能完成了,最后的工作就是返回,return c。可以看到return c的汇编就是将ebp-8的值移动到eax寄存器中,eax是一个最经常被用来返回数据的寄存器
此时的eax存储的就是函数返回值,return执行完,函数需要将栈帧销毁,所以还有指令需要执行。这里注意一下,后面的指令中三次pop数据,再对esp寄存器add,与栈帧创建时我们忽略的指令中,一次sub和三次push的指令相对应,sub和add的值都是0CCh(十六进制),这是函数栈帧的销毁
三次pop和一次add都会使栈顶寄存器的值发生变化,但是最重要的是之后的mov指令,我们直接从这里开始讲
将ebp存储的值移动到esp中,什么意思呢?与栈帧的创建相同,栈帧的销毁也需要修改栈顶寄存器和栈底寄存器的值,ebp是栈底寄存器,指向了栈底,esp是栈顶寄存器,将esp的值修改为ebp的值,此时的esp不再指向原来的栈顶,而是原来栈帧(my_add)的栈底
注意此时的EBP和ESP(因为之前的pop和add使两者相同,指向了同一地址处,mov之后两者的值肯定是相同的)
调用mov之后,两者的值相同
(两寄存器指向了同一地址处)
之后的pop ebp就是将栈顶的数据弹出,将弹出的数据存储到ebp中,而栈顶的数据是什么呢?
显然,是创建栈帧时压入的main函数栈帧的栈底地址,将该地址存储到ebp寄存器中,由于ebp寄存器指向的是函数栈帧的栈底,所以现在的ebp指向了main函数栈帧的栈底
注意ebp的值,esp指向的栈顶数据要被pop到ebp中
pop之后,esp的值增加4,表示栈帧的减小,ebp的值被修改,指向了main函数栈帧的栈底
pop之后ebp指向原栈帧的栈底,esp也增加4字节。最后是ret指令,ret可以等价于pop eip,将栈顶的数据弹出,并存储到eip寄存器中,eip是指令寄存器,保存了下一条要执行的指令
而此时栈顶保存的数据是什么呢?
回到之前的main函数中,程序因为call的跳转,进入了my_add函数,现在要使程序跳转回来,怎么跳?正是由于之前保存了call指令的下一条指令的地址,001D1950,现在我们把这串地址pop到eip中,使程序接下来执行该地址上的指令,程序不就跳转回来了吗?
(main函数的汇编代码,注意call指令后的add所在地址是现在的栈顶地址)
ret执行之前,注意EIP中的值
ret之后,EIP为main函数中的add指令的地址,ESP的值也增加4
ESP寄存器的值也增加4,表示栈帧的减小
至此,执行流执行完my_add函数,跳转回main函数栈
然后的add esp, 8是什么意思?我们注意到esp指向的栈顶位置不是main函数之前的栈顶,现在的栈顶还有因为调用函数而压入的两个形参
所以将esp的值加8就是是esp回到main函数的栈顶,将两个形参的空间释放
调用add前
调用add后
(栈帧结构)
最后不是要用变量接收函数的返回值吗,所以接下来的汇编指令是mov,将eax寄存器的数据以4字节的形式移动到ebp-32(注意十六进制)的地址处,ebp-32就是变量z的首地址,而eax保存的是之前的函数返回值21
至此,int z = my_add(x, y);调用完成。关于一个函数栈帧的创建与销毁也讲解完成,可以看到函数栈帧的创建与两个寄存器息息相关,esp和ebp承担了非常重要的角色,维护着函数栈帧的顶部和底部,因为栈帧的增长方向是低地址方向,所以esp寄存器的增加,表示资源的释放,esp的减小,表示资源的使用
返回值篡改(1+1=1 ???)
函数调用的过程中,同样很重要的是跳转,如果跳转出错,程序也就崩溃(或者说不能按照正确的逻辑执行),而函数跳转依赖于一串代码地址,该地址是调用它的函数的栈帧地址(大概是这样,只要明白了之前的讲解,这里也就很好理解)也就是形参形成后,压入的那一串地址,这串地址之上就是原调用函数的栈底地址,所以我们可以通过形参的地址,找到并修改这串地址,使函数不返回调用它的函数栈帧,而是跳转到其他函数栈帧中。
void my_change()
{
printf("void my_change()\n");
Sleep(10000000);
}
int my_add(int a, int b)
{
printf("int my_add(int a, int b)\n");
int c = 0;
c = a + b;
int* p_ret = &a - 1; // 得到返回栈帧的地址
*p_ret = (int)&my_change; // 篡改返回栈帧地址
return c;
}
int main()
{
int x = 1;
int y = 1;
int z = my_add(x, y);
printf("1 + 1 = %d\n", z);
return 0;
}
我们可以通过函数的形参来推断函数的返回地址,函数形参的形成顺序是从右向左,对于my_add函数来说,先将b入栈,再将a入栈,然后call指令使函数的返回地址入栈,所以和a紧挨着的就是函数的返回地址。&a得到形参a的地址,&a - 1,因为类型是int*,所以-1减去了4字节,int* p_ret = &a - 1,就得到了和a紧挨着的函数返回地址,将my_change的地址强转成int类型,因为编译环境是32位,所以地址的长度是32位,和int的长度相同,这样转换不会丢失精确度,如果编译器的检查比较严格,就不允许这样的转换,但是vs下是可以编译通过的。强转之后就可以用这串地址替换原来的函数返回地址,最后调用ret时,pop eip,eip得到的数据就是我们篡改后的地址,eip保存的指令是程序要执行的下一条指令,所以程序此时不会返回main函数栈帧,而是被我们“拐跑”了,进入了my_change中。
在my_change函数里,我加了一行Sleep使程序进入休眠,如果不这样,在vs2022的编译环境下,程序会运行崩溃,但是在Dev c++5.51下不会运行崩溃。
但是我还观察到一个现象,就是函数的返回值大多是使用eax寄存器作为载体返回的,而在一些运算中eax也会参与进来,所以我可以在my_change中进行一些运算,篡改my_add的返回值,然后使my_change正确的返回main函数,做到篡改其他函数的返回值。而由于我们不能显示的调用my_change函数,所以我们无法将正确的返回main函数栈帧的地址作为参数传入my_change函数,因此我定义了一个全局变量main_retaddr,my_add将正确的返回地址传给这个全局变量,然后my_change至少要有一个形参,通过形参的地址得到存储函数返回地址的地址,将其修改为正确的返回地址,使my_change返回main函数,并打印my_add的返回值,看其是否被篡改
#include <stdio.h>
int main_retaddr;
void my_change(int centre)
{
int* p_ret = ¢re - 1; // 得到返回栈帧的地址
*p_ret = main_retaddr; // 篡改返回栈帧的地址
int x = 0; // 进行运算,覆盖eax的值
int y = x + 1;
return;
}
int my_add(int a, int b)
{
int c = 0;
c = a + b;
int* p_ret = &a - 1;
main_retaddr = *p_ret; // 得到原栈帧的地址
*p_ret = (int)my_change; // 篡改原栈帧地址
return c;
}
int main()
{
int x = 1;
int y = 1;
int z = my_add(x, y);
printf("1 + 1 = %d\n", z);
return 0;
}
运行结果虽然被我们篡改了,但是由于编译器的检查,发现了我们的越界访问,所以程序崩溃了