Loading... 源程序如下: ``` #include<stdio.h> int func_B(int arg_B1, int arg_B2) { int var_B1, var_B2; var_B1 = arg_B1 + arg_B2; var_B2 = arg_B1 - arg_B2; return var_B1 * var_B2; } int func_A(int arg_A1, int arg_A2) { int var_A; var_A = func_B(arg_A1, arg_A2) + arg_A1; return var_A; } int main(int argc, char** argv, char** envp) { int var_main; var_main = func_A(4, 3); return var_main; } ``` 编译为functionCall.exe,以此来学习函数调用时栈的相关情况。 ##### 系统栈的工作原理 这段代码经过编译器编译后,各个函数对应的机器指令在代码区中可能是这样分布的,如图2.1.2所示。 当CPU在调用func_A函数的时候,会从代码区中main函数对应的机器指令的区域跳转到func_A函数对应的机器指令区域,在那里取指并执行;当func_A函数执行完毕,需要返回的时候,又会跳回到main函数对应的指令区域,紧接着调用func_A后面的指令继续执行main寒素的代码。在这个过程中,CPU的取值轨迹如图2.1.3所示。 ![image.png](http://47.117.131.13/usr/uploads/2021/11/2868147914.png) **思考**:那么CPU是怎么知道要去func_A的代码区取指,在执行完func——A后又是怎么知道跳回到main函数(而不是func_B的代码区)的呢?CPU是从哪里获得这些函数的调用及返回的信息的呢? 原来,这些代码区中精确的跳转都是在与系统栈巧妙地配合过程中完成的。**当函数被调用时,系统栈会为这个函数开辟一个新的栈帧,并把它压入栈中**。**当函数返回时,系统栈会弹出该函数所对应的栈帧**。 ##### 寄存器与函数栈帧 每一个函数独占自己的栈帧空间。当前正在运行的函数的栈帧总是在栈顶。Win32系统提供两个特殊的寄存器用于标识位于系统栈顶顶端的栈帧。 (1)ESP:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。 (2)EBP:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。 ``` 注意:EBP指向当前位于系统栈最上边一个栈帧的底部,而不是系统栈的底部。严格来说,"栈帧底部"和"栈底"是不同的概念,本书在叙述中将坚持使用"栈帧底部"这一提法以示区别;ESP所指的栈帧顶部和系统栈的顶部是同一个位置,所以后面叙述中并不严格区分"栈帧顶部"和"栈底"的概念。 ``` 寄存器对栈帧的标识作用如图2.1.5所示。 ![image.png](http://47.117.131.13/usr/uploads/2021/11/18994133.png) 在函数栈帧中,一般包含以下几类重要信息。 (1)局部变量:为函数局部变量开辟的内存空间。 (2)栈帧状态值:保存前栈帧的顶部和底部(实际上只保存前栈帧的底部,前栈帧的顶部可以通过堆栈平衡计算得到),用于在本帧被弹出后恢复上一个栈帧。 (3)函数返回地址:保存当前函数调用前的"断点"信息,也就是函数调用前的指令位置,以便在函数返回时能够恢复到函数被调用前的代码区中继续执行指令。 ``` 题外话:函数栈帧的大小并不固定,一般与其对应函数的局部变量多少有关。在后面调试实验中会发现,函数运行过程中,其栈帧大小也是在不停变化的。 ``` 除了与栈相关的寄存器外,还需要记住另一个至关重要的寄存器。 EIP:指令寄存器(Extended Instruction Pointer),其内存放着一个指针,该指针永远指向下一条等待执行的指令地址,其作用如图2.1.6所示。 可以说如果控制了EIP寄存器的内容,就控制了进程——我们让EIP指向哪里,CPU就会去执行哪里的指令(EIP劫持进程)。 ##### 函数调用约定与相关指令 函数调用约定描述了函数传递参数方式和栈协同工作的技术细节。不同的操作系统、不同的语言、不同的编译器在实现函数调用时的原理虽然基本相同,但具体的调用约定还是有差别的。这包括参数传递方式,参数入栈顺序是从右向左还是从左往右,函数返回时恢复堆栈平衡的操作在子函数中进行还是在母函数中进行。 ![image.png](http://47.117.131.13/usr/uploads/2021/11/4064560018.png) 具体的,对于Visual C++来说,可支持3种函数调用约定,如下: ![image.png](http://47.117.131.13/usr/uploads/2021/11/3236684864.png) 如果要明确使用某一种调用约定,只需要在函数前加上调用约定的声明即可,否则默认情况下,VC会使用__stdcall的调用方式。 ``` 函数调用大致包括以下几个步骤。 1. 参数入栈:将参数从右向左依次压入系统栈中。 2. 返回地址入栈:将当前代码区调用指令的下一条指令地址压入栈中,供函数返回时继续执行。 3. 代码区跳转:处理器从当前代码区跳转到被调用的入口处。 4. 栈帧调整:具体包括: 保存当前栈帧状态值,已备后面恢复本栈帧时使用(EBP入栈); 将当前栈帧切换到新栈帧(将ESP值装入EBP,更新栈帧底部); 给新栈帧分配空间(把ESP减去所需空间的大小,抬高栈顶); ``` 对于__cdecl调用约定,函数调用时用到的指令序列大致如下。 #调用前 ![image.png](http://47.117.131.13/usr/uploads/2021/11/215690008.png) #call 指令 * 返回地址入栈 * jmp到所调用函数入口地址 ![image.png](http://47.117.131.13/usr/uploads/2021/11/2435616419.png) #进入函数后 ``` push ebp //保存旧栈帧的底部 mov ebp,esp //设置新栈帧的底部(栈帧切换) sub esp,xxx //设置新栈帧的顶部(抬高栈顶,为新栈帧开辟空间) ``` ![image.png](http://47.117.131.13/usr/uploads/2021/11/1919688612.png) 退出函数前: a.恢复栈帧 b.平衡堆栈 ``` mov esp,ebp pop ebp //将上一个栈帧底部位置恢复到ebp retn //a.弹出当前栈顶元素,即弹出栈帧中的返回地址;b.让处理器跳转到弹出的返回地址,恢复调用前的代码区 ``` ![image.png](http://47.117.131.13/usr/uploads/2021/11/1744447806.png) 按照这样的函数调用约定组织起来的系统栈结构如下图: ![image.png](http://47.117.131.13/usr/uploads/2021/11/2792455172.png) ##### 修改邻接变量 现在,我们已经知道了函数调用的细节和栈中数据的分布情况。如图2.1.8所示,函数的局部变量在栈中一个挨着一个排列。如果这些局部变量中有数组之类的缓冲区,并且程序中存在数组越界的缺陷,那么越界的数组元素就有可能破环栈中相邻变量的值,甚至破坏栈帧中所保存的EBP的值、返回地址等重要数据。 ``` 题外话:大多数情况下,局部变量在栈中的分布是相邻的,但也有可能出于编译优化等需要而有所例外。具体情况我们需要在动态调试中具体对待,这里出于讲述基本原理的目的,可以暂时认为局部变量在栈中是紧挨在一起的。 ``` 用一个非常简单的例子来说明破坏栈内局部变量对程序的安全性有何种影响。 ``` #include<stdio.h> #include<string.h> #pragma warning(disable:4996) #define PASSWORD "1234567" int verify_password(char* password) { int authenticated; char buffer[8]; authenticated = strcmp(password, PASSWORD); strcpy(buffer, password); return authenticated; } int main() { int valid_flag = 0; char password[1024]; while (1) { printf("please input password:"); scanf("%s", password); valid_flag = verify_password(password); if (valid_flag) { printf("incorrect password!\n\n"); } else { printf("Congratulation! You have passed the verification!\n"); break; } } } ``` 最后修改:2021 年 11 月 09 日 07 : 27 PM © 允许规范转载