由于网络安全课的缓冲区溢出实验,简单学习了程序执行过程对应的栈的变化过程。接下来叙述一个函数在被调用执行时,栈都为这个函数做了什么。
以以下程序为例,我们主要关注read_req()这个函数。
# include <stdio.h>
int read_req(void) {
char buf[128];
int i;
gets(buf);
i = atoi(buf);
return i;
}
int main(int ac, char **av) {
int x = read_req();
printf("x=%d\n", x);
}
将上述文件保存成read_req.c,执行以下命令生成可执行文件:readreq
gcc -m32 -static -g -Wno-deprecated-declarations -fno-stack-protector readreq.c -o readreq
其中
-m32
编译为32位程序-static
静态链接-g
加入调试信息-Wno-deprecated-declarations
不对deprecated(废弃)内容发出警告,gets()
已经被废弃-fno-stack-protector
不注入用于防止缓冲区溢出的代码
紧接着进行gdb调试,在调试前首先说明几点:
1.栈从高地址向低地址增长。
2.x86汇编有两种语法,intel语法和AT&T语法,对于AT&T的mov a,b是将a的值放到b中,而intel语法刚好相反。
3.栈在对函数的参数填充时,从右向左填充,如test(int i,int j),可以理解为j先入栈,位于相对于i的较高地址。
4.linux中对数据的字节序按little-endian填充,比如int型变量i=0xd4c3b2a1,在栈中的存储从高地址向地址依次为d4 4c 3b a1。
通过gdb命令进行调试
$ gdb ./readreq
(gdb) b read_req [在read_req函数设置断点]
Breakpoint 1 at 0x8048e4d: file readreq.c, line 6.
(gdb) r [运行]
Starting program: /home/httpd/lecture1/readreq
Breakpoint 1, read_req () at readreq.c:6
6 gets(buf);
(gdb) info registers [查看寄存器信息,[十六进制,十进制/翻译,说明]]
eax 0x1 1 [累加器]
ecx 0x52987f86 1385725830 [计数器]
edx 0xbffff704 -1073744124 [数据]
ebx 0x80481a8 134513064 [基址,ebp]
esp 0xbffff610 0xbffff610 [栈指针]
ebp 0xbffff6b8 0xbffff6b8 [基址指针]
esi 0x0 0 [源索引]
edi 0x80eb00c 135180300 [目标索引]
eip 0x8048e4d 0x8048e4d <read_req+9> [指令指针]
eflags 0x28 [ SF IF ] [标志寄存器,符号标记,中断允许标记]
cs 0x73 115 [代码段]
ss 0x7b 123 [堆栈段]
ds 0x7b 123 [数据段]
es 0x7b 123 [附加段]
fs 0x0 0 [无明确定义,字母表中f在e之后]
gs 0x33 51 [无明确定义, g在f之后]
反汇编
(gdb) disass read_req [反汇编,AT&T风格]
Dump of assembler code for function read_req:
0x08048e44 <+0>: push %ebp [将旧%ebp入栈*]
0x08048e45 <+1>: mov %esp,%ebp [将%esp赋给%ebp,用%esp来设定新%ebp,新的(子程序read_req)ebp在子程序的局部变量和返回值之间]
0x08048e47 <+3>: sub $0xa8,%esp [栈增长此子程序所需的栈帧大小0xa8字节,留出局部变量空间]
=> 0x08048e4d <+9>: lea -0x8c(%ebp),%eax [&buf[0],获取buf起始地址顶,向gets传递参数]
0x08048e53 <+15>: mov %eax,(%esp) [移入栈顶,向gets传递参数]
0x08048e56 <+18>: call 0x804fc90 <gets> [将%eip入栈并调用]
0x08048e5b <+23>: lea -0x8c(%ebp),%eax [&buf[0],获取buf起始地址顶,向gets传递参数]
0x08048e61 <+29>: mov %eax,(%esp) [移入栈顶,传递参数]
0x08048e64 <+32>: call 0x804dd10 <atoi>
0x08048e69 <+37>: mov %eax,-0xc(%ebp) [将atoi结果写入i]
0x08048e6c <+40>: mov -0xc(%ebp),%eax [将i写入函数返回值]
0x08048e6f <+43>: leave [弹出整个栈帧*]
0x08048e70 <+44>: ret [弹出栈中%eip,并跳转执行]
End of assembler dump.
push
指令将操作数压入栈中。在压栈前,将esp
值减4(X86栈增长方向与内存地址编号增长方向相反),然后将操作数压入esp
指示位置。pop
指令与push
指令相反。先将esp
指示地址中内容出栈,然后将esp
值加4。- 栈增长(168 bytes)要超过局部变量大小之和(4+128 bytes),并按16 bytes对齐
lea
: load effective address, 拷贝地址(而不是内容)leave
相当于mov %ebp,%esp
,pop %ebp
,此时esp
指向返回地址ret
执行pop %eip
,将返回地址写入eip
查看一下寄存器和栈帧中的内容,以此绘制栈帧结构图。
(gdb) p $ebp
$1 = (void *) 0xbffff6b8
(gdb) p $esp
$2 = (void *) 0xbffff610
(gdb) p &i
$3 = (int *) 0xbffff6ac
(gdb) p &buf
$4 = (char (*)[128]) 0xbffff62c
(gdb) x $ebp+4
0xbffff6bc: 0x08048e7
read_req()
栈帧示例:
+———————————————————————-+
| arguments |
+———————————————————————-+
| return address |<——— +4 =0xbffff6bc %eip=0x08048e7f
+———————————————————————-+
| main() ebp |<——— %ebp =0xbffff6b8
+———————————————————————-+
| |
+————————————————————————+
| int i |<——— -0x0c (-12) =0xbffff6ac
+————————————————————————+
|buf[127] ^ |
| | |
| | buf[0]|<——— -0x8c(-140)=0xbffff62c
+————————————————————————+
| |
+———————————————————————-+
| &buf for gets() |<——— -0xa8(-168)=0xbffff610 new %esp
+———————————————————————-+
1.这是read_req的完整栈帧,首先是arguments,父函数在调用read_req前,首先将read_req的参数填到栈中。
2.return address是父函数调用子函数read_req的call指令产生的,call指令将父函数的下一条(即执行read_req后的下一条指令)指令地址%eip填到此处(push %eip),放在这里为了read_req执行完成后,返回到调用点后继续执行父函数的下一条指令。
3.main() ebp 即是父函数mian()的基地址,用于返回到父函数时从此处读取父函数基地址,因此填到此处。此处也为read_req的栈帧的基地址位置(mov %esp,%ebp)。
4.接下来应该填充子函数的局部变量i,但是从栈可以看出,i和main()ebp之间存在大小8字节的空间,经过测试发现,当程序中有gets函数时,ebp和局部变量之间就会空出8个字节,不知道是什么作用;当没有gets函数时,ebp之后就直接是局部变量,这其中的区别暂时没搞明白。本程序有gets函数,因此ebp向下扩展8字节后,从0xbffffba0处开始为局部变量分配空间。接着栈为局部变量i和buf分配所需大小,根据i和buf的大小,可以计算buf的起始地址为0xbffff62c。
5.填充完局部变量后,如果read_req中调用了其他函数,应用填充被调用函数的参数,这里read_req调用了gets(buf),gets的参数为buf,因此将buf的地址填到栈中。但是经过调试发现,在填充完read_req的局部变量后,并没有紧接着填充gets函数的参数,而是空出了0xbffff62c-(0xbffff610+4)一共0x18个字节,然后填充gets的参数。测试中将gets函数替换为自定义函数后,空出了0x08个字节,暂时没有搞明白。
read_req在调用gets函数时,栈的变化和父函数main调用read_req的过程一样。在第5条已经在栈中设置了gets的参数,然后接下来的栈的变化就和2-5步类似了。
在一层层的函数调用中,栈就是这样增长的。
总的来说,在某个函数被调用前到被调用的函数执行完成,栈的变化依次是:
1.将被调用函数的参数压栈(个人认为它其实还是调用者栈帧的最后一部分)
2.将调用者执行完函数调用后的下一条要执行的指令地址入栈
3.将调用者的栈基地址ebp入栈
4.编译器经过计算后得出被调用函数所需要的栈帧大小stack_f bytes,esp向低地址移动stack_f bytes大小。
5.将被调用者的局部变量填到栈中
6.如果被调用者又调用的其他函数,那么在局部变量之后的栈填上那个函数的参数(同1)。
编译器在计算应该为某个函数分配多大栈帧时,不仅考虑函数的局部变量、函数是否调用其他函数等,还要考虑内存对齐的问题,比如如果函数中定义局部变量
char buf[3];
那么,给buf不仅仅只分配3个字节的空间,至少要是4的倍数,可以使用gdb调试查看。
从1-6的叙述中可以看处,与函数相关的不同类别的数据依次入栈,但是个人认为,“依次入栈”这种表述不合适,其实是编译器为这个函数计算好它所需要的栈大小stack_frame(栈帧),为其分配stack_frame,然后再将各种数据根据计算精确地填到这个栈帧里的指定位置。是一种先布局,后填数据的构建方式,这点可以从反汇编之后对栈的操作可以看到。
另外,笔者并没有对此做深入的研究,文中还有未解决的问题,用红色标出,因此某些地方可能存在错误,甚至误导,欢迎批评指正!
参考资料:
栈帧 Stack Frame
缓冲区溢出:原理与实验