当前位置: > 热评

C|函数调用的栈帧机制与数组越界、缓冲区溢出

时间:2022-05-09 08:26:58 热评 我要投稿

0 前置知识0.1 程序加载和数据存储

程序运行前要将代码加载到内存的代码区,包括全局变量和静态变量也要同时加载。

堆区内存可以在程序运行时动态申请。

栈区是由程序重复利用的存储区域,通过两个寄存器ebp和esp存储栈区的相对地址来控制栈区空间的重复使用。函数调用时,开辟一个函数需要的栈区空间,称为一个栈帧。函数返回时,偏移ebp和esp的值,让栈空间可以重新使用此前函数调用占用的空间。

以下是windows平台一个程序运行时的内存分配机制:

0.2 函数调用

函数调用看似简单,其实是一个挺复杂的过程。当涉及到逐级调用时,代码需要能够回到最初的调用点。

例如一个人旅行,当经过若干十字路口时,如何正确回到最初开始位置?一个可行的方案是,每经过一个路口,用一张扑克牌记下当时路口的状态,每经过一个路口即使用一张牌,并放在已使用的扑克牌上面,当返回时,依次从上面拿牌读取信息即可回溯到原来位置。这种扑克牌的放与取就是所谓的栈的“后进先出”机制。

函数调用也要使用类似的机制,由编译器完成。每一级函数调用对应一个栈帧(如同扑克牌),记录参数值,返回地址,一些寄存器状态,局部变量值。主调函数与被调函数如何分工完成这些任务,不同的调用约定有不同的分工。

0.3 数组名在一定的上下文中转换为指向数组首元素的指针

数组也是如此,看似简单,实则复杂。

数组名在一定上下文中会转换为一个指向数组首元素的指针,为什么会这样,当然有其合理性。

首先了解一下指针的算术运算。(栈内存空间地址是逆增长的)

void foo(int a){ int f = 45; int *ptr = &f; *(ptr+1) = 12; // 编译通过,运行时错误,试图修改ebp *(ptr+2) = 34; // 编译通过,运行时错误,试图修改函数返回地址 *(ptr+3) = 56; // 试图修改保存函数实参值的栈内存单元 int *p = (int*)malloc(sizeof(int)*5); *(p+3) = 23; // p[3] = 23;}p+n,是一个相对于ptr的偏移n个int地址空间的地址值。

编译器会计算p+sizeof(int),至于偏移的是否是有效合法的地址,C编译器不管。

ptr+n也是如此。

如有数组int arr[64] = {0};

数组名是一个基地址,其索引表示地址的偏移量。arr[i]写法是指针算术运算*(arr+i)的语法糖。编译器要考虑指针的算术运算有实用的意义,C编译器的规定是arr+i,其地址不是简单偏移 i 个字节,这样没有意义,而是sizeof(指向的类型)的长度的字节数,这样才有意义,其计算由编译器完成,当指针类型是数组时,偏移 i 个数组的长度没有任何意义,偏移 i 个数组元素的长度才有实际意义,这也是C编译器的规定。如:

int a[3][4][5]; // a的类型是int[3][4][5],元素的类型是int[4][5],int (*p)[4][5] = a; a + 2; // 偏移的地址是a + 2* sizeof(int)*4*5 int b[4][5]; // b的类型是int[4][5],元素的类型是int[5],int (*p)[5] = b; b + 2; // 偏移的地址是b + 2* sizeof(int)*5 int c[5];// c的类型是int[5],元素的类型是int,int *p = c; c + 2; // 偏移的地址是b + 2* sizeof(int)C编译器只负责偏移地址的计算,对于数组是否越界,地址是否合法并不在编译时检查。

arr[i]的 i 可以是任意signed int,越界时就会访问到相邻栈内存。

0.4 一些简单的汇编代码

1、mov——传送指令

定义:把一个字节、字或双字的操作数从源位置传送到目的位置,可以实现立即数到通用寄存器或主存的传送,通用寄存器与通用寄存器、主存或段寄存器之间的传送,主存与段寄存器之间的传送。

举例:mv ebp, esp

解释:相当于C语言中赋值语句=,ebp=esp

2、push——进栈指令

定义:进栈指令push先将ESP减小作为当前栈顶,然后可以将立即数、通用寄存器和段寄存器或存储器操作数传送到当前栈顶。

格式:push src

举例:push ebp

解释:相当于C语言中esp+=4, *esp=ebp

作用:为ebp当前存放的地址,在栈顶开辟空间存入它,用作调用子函数时的现场保护

3、pop——出栈指令

定义:与入栈指令相反,它先将栈顶的数据传送到通用寄存器、存储单元或段寄存器中,然后ESP增加作为当前栈顶。

格式:pop src

举例:pop ebp

解释:相当于C语言中 ebp=*esp, esp+=4

作用:调用子函数结束后,恢复主函数的ebp

4、add——加法指令

格式:add dest, src

解释:相当于dest+=src

5、sub——减法指令

格式:sub dest, src

解释:相当于dest-=src

6、call——函数调用指令

格式:call 函数名

作用:(1)将程序当前执行的位置IP压入堆栈中;(2)转移到调用的子程序。

其它:

RET 指令从堆栈把返回地址弹回到指令指针寄存器。ret 相当于 pop EIP。

rep stos dword ptr [edi] //rep是重复其上面的指令,ECX是重复的次数。

ILT是INCREMENTAL LINK TABLE的缩写,这个@ILT其实就是一个静态函数跳转的表,它记录了一些函数的入口然后跳过去,每个跳转jmp占一个字节,然后就是一个四字节的内存地址,加起为五个字节。

LEA(Load Effective Address )取有效地址指令,取源操作数地址的偏移量,并把它传送到目的操作数所在的单元

0.5 函数栈

0.5.1 栈空间增长方式:从高地址向低地址扩展,是一片让程序重复利用的数据空间;

栈空间由编译器维护(需要在Debug模式下才可以跟踪);

0.5.2 栈空间对齐方式:X86按4个字节来对齐,X64按8个字节来对齐;

0.5.3 两个跟踪栈空间的寄存器ESP和EBP

对栈的操作由ESP跟踪,用来指示栈顶。push、call时,esp -= 4,pop、ret时,esp += 4

EBP用来引用函数参数和局部变量。EBP相当于一个“基准指针”。从主调函数传递到被调函数的参数以及被调函数本身的局部变量都可以通过这个基准指针为参考,加上偏移量找到。

*ebp(表示栈地址ebp对应的值) = 上一个ebp的值(栈地址);

ebp-4 = 第一个局部变量地址;

ebp+4 = 函数返回地址;

ebp+8 = 函数第一个参数地址;

0.5.4 不同的函数调用约定,在参数的入栈顺序,堆栈的恢复(由caller还是callee负责)、函数的命名上会有所不同。

0.5.5 一个完整的函数帧包括:函数参数、返回地址,上一个EBP的值,局部变量空间,3个寄存器。在函数内部代码执行前,一个完整的函数帧已经建立。

下面通过一个完整实例来理解函数调用的栈帧机制与数组向栈底方向越界的分析:

看以下代码:

#includeint arrayBound(int a){ int b = -1; // [ebp-4] int arr[98] = {0}; // [ebp-18Ch] int c = 1; // [ebp-190h],栈是逆增长,栈顶地址值>栈顶 arr[-1] = c*a; // 数组向栈顶方向越界访问,arr[-1]对应int c arr[98] = b*a; // 数组向栈底方向越界访问,arr[98] 对应int b //printf("%d %dn",b,c); // -5 5 return b;}int main(){ int e = arrayBound(5); // -5 //printf("%dn",e); return 0;}主函数main()的栈帧:

主函数main()调用arraybound(),此时的汇编代码:

15: int e = arrayBound(5); // -50040D4D8 push 50040D4DA call @ILT+10(arrayBound) (0040100f)0040D4DF add esp,40040D4E2 mov dword ptr [ebp-4],eax16: //printf("%dn",e);17: return 0;0040D4E5 xor eax,eax18: }1 主调函数调用被调函数时函数参数压栈此时的栈指针值:

ebp 0x0012ff48 // ebp是栈底指针

esp 0x0012fef8 // esp是栈底指针,栈的push和pop操作会同时改变esp的值(esp移动)

此时的栈顶指针附近的内存映像:

0012FEEC 30 2F 42 00 83 00 00 00 68 20 1F 00 0/B.....h ..

0012FEF8 00 00 00 00 00 00 00 00 00 F0 FD 7F .........瘕.

0012FF04 CC CC CC CC CC CC CC CC CC CC CC CC 烫烫烫烫烫烫

执行汇编:

0040D4D8 push 5  // esp += 4 = 0x0012fef4 , *esp = 5栈帧内存:

0012FEEC 30 2F 42 00 83 00 00 00 05 00 00 00 0/B.........

2 返回地址压栈汇编指令call对应两个操作:push 返回地址和jmp指令。

0040D4DA call @ILT+10(arrayBound) (0040100f) 0012FEEC 30 2F 42 00 DF D4 40 00 05 00 00 00 0/B.咴@..... // 返回地址是0040D4DF

esp = 0x0012fef0,*esp = 0040D4DF

0040100F jmp arrayBound (0040d820)代码跳转:

0040D820 push ebp0040D821 mov ebp,esp0040D823 sub esp,1D0h0040D829 push ebx0040D82A push esi0040D82B push edi0040D82C lea edi,[ebp-1D0h]0040D832 mov ecx,74h0040D837 mov eax,0CCCCCCCCh0040D83C rep stos dword ptr [edi]5: int b = -1; // [ebp-4]0040D83E mov dword ptr [ebp-4],0FFFFFFFFhebp值压栈:

0040D820 push ebp // ebp赋值前先压栈保存先前状态 esp = 0x0012feec, *esp = ebp0012FEEC 48 FF 12 00 DF D4 40 00 05 00 00 00 H...咴@.....

0040D821 mov ebp,esp // ebp = esp = 0x0012feec栈区:

3 函数栈帧空间分配0040D823 sub esp,1D0h // 1D0h = 464 = 400+64 , esp = 0x0012fd1c此时栈顶指针附近的内存随机值:

0012FD10 FE FF FF FF FE 60 75 77 76 A3 71 77 ....uwvw

0012FD1C 00 00 1F 00 63 01 00 50 D3 5D 6E 77 ....c..P覿nw

0012FD28 CF 79 14 75 00 00 00 00 00 00 00 00 蟳.u........

3.1 寄存器压栈

寄存器状态保持(压栈)

0040D829 push ebx0040D82A push esi0040D82B push edi // esp = 0012FD10, *esp = edi栈内存:

0012FD10 48 FF 12 00 00 00 00 00 00 F0 FD 7F H........瘕.

此时esp = 0x0012fd10,三个寄存器使用的栈内存是464个字节以外的栈内存。

栈区:

3.2 栈帧分配的空间每个字节全部置为0xCC

0040D82C lea edi,[ebp-1D0h]0040D832 mov ecx,74h0040D837 mov eax,0CCCCCCCCh0040D83C rep stos dword ptr [edi]此时esp和ebp之间的栈空间:

0012FD10 48 FF 12 00 00 00 00 00 00 F0 FD 7F H........瘕.

0012FD1C CC CC CC CC CC CC CC CC CC CC CC CC 烫烫烫烫烫烫

0012FEE0 CC CC CC CC CC CC CC CC CC CC CC CC 烫烫烫烫烫烫

0012FEEC 48 FF 12 00 DF D4 40 00 05 00 00 00 H...咴@.....

3.3 栈空间为局部变量从ebp处开始偏移进行初始化操作

5: int b = -1; // [ebp-4]0040D83E mov dword ptr [ebp-4],0FFFFFFFFh此时的栈空间:

0012FEE0 CC CC CC CC CC CC CC CC FF FF FF FF 烫烫烫烫....

0012FEEC 48 FF 12 00 DF D4 40 00 05 00 00 00 H...咴@.....

汇编代码继续:

6: int arr[98] = {0}; // [ebp-18Ch]0040D845 mov dword ptr [ebp-18Ch],00040D84F mov ecx,61h0040D854 xor eax,eax0040D856 lea edi,[ebp-188h]0040D85C rep stos dword ptr [edi]7: int c = 1; // [ebp-190h],栈是逆增长,栈顶地址值>栈顶0040D85E mov dword ptr [ebp-190h],1此时的栈空间:

0012FD50 CC CC CC CC CC CC CC CC CC CC CC CC 烫烫烫烫烫烫

0012FD5C 01 00 00 00 00 00 00 00 00 00 00 00 ............

0012FD68 00 00 00 00 00 00 00 00 00 00 00 00 ............

8: arr[-1] = c*a; // 数组向栈顶方向越界访问

0040D868 mov eax,dword ptr [ebp-190h]0040D86E imul eax,dword ptr [ebp+8]0040D872 lea ecx,[ebp-18Ch]0040D878 mov dword ptr [ecx-4],eax此时的栈空间:

0012FD50 CC CC CC CC CC CC CC CC CC CC CC CC 烫烫烫烫烫烫

0012FD5C 05 00 00 00 00 00 00 00 00 00 00 00 ............

0012FD68 00 00 00 00 00 00 00 00 00 00 00 00 ............

9: arr[98] = b*a; // 数组向栈底方向越界访问

0040D87B mov edx,dword ptr [ebp-4]0040D87E imul edx,dword ptr [ebp+8]0040D882 mov dword ptr [ebp-4],edx此时的栈空间:

0012FEDC 00 00 00 00 00 00 00 00 00 00 00 00 ............

0012FEE8 FB FF FF FF 48 FF 12 00 DF D4 40 00 ....H...咴@.

0012FEF4 05 00 00 00 00 00 00 00 00 00 00 00 ............

栈区:

3.4 值返回

10: //printf("%d %dn",b,c); // -5 511: return b;0040D885 mov eax,dword ptr [ebp-4] // 返回值保存在寄存器eax中3.5 寄存器状态恢复

此时 esp = 0x0012fd10

0040D888 pop edi //esp += 4, edi = *esp0040D889 pop esi0040D88A pop ebx以上的栈空间是栈帧空间464以外的空间。

此时 esp = 0x0012fd1c

ebp = 0x0012feec

0040D88B mov esp,ebp  // 栈顶指针更新,栈空间回收此时ebp对应的栈空间

0012FEEC 48 FF 12 00 DF D4 40 00 05 00 00 00 H...咴@.....

0040D88D pop ebp此时 esp = 0x0012fef0

ebp = 0x0012ff48

0040D88E ret函数返回:

0040D4DF add esp,4 // 4是实参压栈时使用的字节数。如果压了3个int,则是ch0040D4E2 mov dword ptr [ebp-4],eax0012FF3C CC CC CC CC CC CC CC CC FB FF FF FF 烫烫烫烫....

0012FF48 88 FF 12 00 F9 11 40 00 01 00 00 00 ......@.....

ebp-4是主调函数局部变量e的栈内存保存位置。

如果函数的返回值是一个复合类型,超过了两个寄存器所能容纳的大小,会在主调函数的栈帧开辟空间,以供值返回。

4 数组越界访问向栈底方向越界1个int空间:对应局部变量int b;

向栈底方向越界2个int空间:对应ebp本身;

向栈底方向越界3个int空间:对应函数返回地址[ebp+4];

向栈底方向越界4个int空间:对应函数实参存储位置[ebp=8]:

#includevoid arrayBound(int a){ int b = -1; int arr[98] = {0}; int c = 1; arr[98] = a; arr[101] = 555; b=a; printf("%dn",b); // 555}int main(){ int c = 5; arrayBound(c); printf("%dn",c); // 5 while(1); return 0;}向栈底方向越界3个int空间:对应函数返回地址:

arr[100] = 0040D4DF; // 数组向栈底方向越界访问

当arr[100] 被赋的值是一个合法的内存地址时,正常运行,否则,运行出错。

5 缓冲区溢出有如下代码:

#includevoid func(){ char buff[4] = {0}; printf("some input:"); gets(buff); puts(buff);}int main(){ func(); return 0;}当运行到gets(buff)时的栈区:

如果输入"abc",则刚好填充buff,其中buff[3] = "0"

如果输入“abcdefg”,则"efg"会填充[ebp]指向的值。

如果输入“abcdefghijk”,则“ijk”会填充[ebp+4]的值,也就是函数func()的返回地址。

-End-