栈溢出和死循环原理.docx
《栈溢出和死循环原理.docx》由会员分享,可在线阅读,更多相关《栈溢出和死循环原理.docx(14页珍藏版)》请在冰豆网上搜索。
栈溢出和死循环原理
题目:
•main()
•{
• inta[10];
• inti;
• for(i=0;i<=10;i++){
• a[i]=1;
• }
•}
该代码在什么情况下有什么问题(2种情况)?
答案:
在栈空间变量按地址递增方式分配的情况下,陷入死循环。
在栈空间变量按地址递减方式分配的情况下,堆栈溢出
预备知识:
什么是堆栈?
~~~~~~~~~~~~~
堆栈是一个在计算机科学中经常使用的抽象数据类型.堆栈中的物体具有一个特性:
最后一个放入堆栈中的物体总是被最先拿出来,这个特性通常称为后进先处(LIFO)队列.
堆栈中定义了一些操作.两个最重要的是PUSH和POP.PUSH操作在堆栈的顶部加入一个元素.POP操作相反,在堆栈顶部移去一个元素,并将堆栈的大小减一.
为什么使用堆栈?
~~~~~~~~~~~~~~~~
现代计算机被设计成能够理解人们头脑中的高级语言.在使用高级语言构造程序时最重要的技术是过程(procedure)和函数(function).从这一点来看,一个过程调用可
以象跳转(jump)命令那样改变程序的控制流程,但是与跳转不同的是,当工作完成时,函数把控制权返回给调用之后的语句或指令.这种高级抽象实现起来要靠堆栈的帮助.
堆栈也用于给函数中使用的局部变量动态分配空间,同样给函数传递参数和函数返回值也要用到堆栈.
堆栈区域
~~~~~~~~~~
堆栈是一块保存数据的连续内存.一个名为堆栈指针(SP)的寄存器指向堆栈的顶部.堆栈的底部在一个固定的地址.堆栈的大小在运行时由内核动态地调整.CPU实现指令
PUSH和POP,向堆栈中添加元素和从中移去元素.
堆栈由逻辑堆栈帧组成.当调用函数时逻辑堆栈帧被压入栈中,当函数返回时逻辑堆栈帧被从栈中弹出.堆栈帧包括函数的参数,函数地局部变量,以及恢复前一个堆栈
帧所需要的数据,其中包括在函数调用时指令指针(IP)的值.
堆栈既可以向下增长(向内存低地址)也可以向上增长,这依赖于具体的实现.在我们的例子中,堆栈是向下增长的.这是很多计算机的实现方式,包括Intel,Motorola,
SPARC和MIPS处理器.堆栈指针(SP)也是依赖于具体实现的.它可以指向堆栈的最后地址,或者指向堆栈之后的下一个空闲可用地址.在我们的讨论当中,SP指向堆栈的最后地址.
除了堆栈指针(SP指向堆栈顶部的的低地址)之外,为了使用方便还有指向帧内固定地址的指针叫做帧指针(FP).有些文章把它叫做局部基指针(LB-localbasepointer).
从理论上来说,局部变量可以用SP加偏移量来引用.然而,当有字被压栈和出栈后,这些偏移量就变了.尽管在某些情况下编译器能够跟踪栈中的字操作,由此可以修正偏移
量,但是在某些情况下不能.而且在所有情况下,要引入可观的管理开销.而且在有些机器上,比如Intel处理器,由SP加偏移量访问一个变量需要多条指令才能实现.
因此,许多编译器使用第二个寄存器,FP,对于局部变量和函数参数都可以引用,因为它们到FP的距离不会受到PUSH和POP操作的影响.在IntelCPU中,BP(EBP)用于这
个目的.在MotorolaCPU中,除了A7(堆栈指针SP)之外的任何地址寄存器都可以做FP.考虑到我们堆栈的增长方向,从FP的位置开始计算,函数参数的偏移量是正值,而局部
变量的偏移量是负值.
当一个例程被调用时所必须做的第一件事是保存前一个FP(这样当例程退出时就可以恢复).然后它把SP复制到FP,创建新的FP,把SP向前移动为局部变量保留空间.这称为
例程的序幕(prolog)工作.当例程退出时,堆栈必须被清除干净,这称为例程的收尾(epilog)工作.Intel的ENTER和LEAVE指令,Motorola的LINK和UNLINK指令,都可以用于
有效地序幕和收尾工作.
下面我们用一个简单的例子来展示堆栈的模样:
example1.c:
------------------------------------------------------------------------------
voidfunction(inta,intb,intc){
charbuffer1[5];
charbuffer2[10];
}
voidmain(){
function(1,2,3);
}
------------------------------------------------------------------------------
为了理解程序在调用function()时都做了哪些事情,我们使用gcc的-S选项编译,以产
生汇编代码输出:
$gcc-S-oexample1.sexample1.c
通过查看汇编语言输出,我们看到对function()的调用被翻译成:
pushl$3
pushl$2
pushl$1
callfunction
以从后往前的顺序将function的三个参数压入栈中,然后调用function().指令call会把指令指针(IP)也压入栈中.我们把这被保存的IP称为返回地址(RET).在函数中所做的第一件事情是例程的序幕工作:
pushl%ebp
movl%esp,%ebp
subl$20,%esp
将帧指针EBP压入栈中.然后把当前的SP复制到EBP,使其成为新的帧指针.我们把这个被保存的FP叫做SFP.接下来将SP的值减小,为局部变量保留空间.
我们必须牢记:
内存只能以字为单位寻址.在这里一个字是4个字节,32位.因此5字节的缓冲区会占用8个字节(2个字)的内存空间,而10个字节的缓冲区会占用12个字节(3个字)的内存空间.这就是为什么SP要减掉20的原因.这样我们就可以想象function()被调用时堆栈的模样(每个空格代表一个字节):
内存低地址 内存高地址
buffer2 buffer1 sfp ret a b c
<------ [ ][ ][ ][ ][ ][ ][ ]
堆栈顶部 堆栈底部
缓冲区溢出
~~~~~~~~~~~~
缓冲区溢出是向一个缓冲区填充超过它处理能力的数据所造成的结果.如何利用这个经常出现的编程错误来执行任意代码呢?
让我们来看看另一个例子:
example2.c
------------------------------------------------------------------------------
voidfunction(char*str){
charbuffer[16];
strcpy(buffer,str);
}
voidmain(){
charlarge_string[256];
inti;
for(i=0;i<255;i++)
large_string[i]='A';
function(large_string);
}
------------------------------------------------------------------------------
这个程序的函数含有一个典型的内存缓冲区编码错误.该函数没有进行边界检查就复制提供的字符串,错误地使用了strcpy()而没有使用strncpy().如果你运行这个程序就会产生段错误.让我们看看在调用函数时堆栈的模样:
内存低地址 内存高地址
buffer sfp ret *str
<------ [ ][ ][ ][ ]
堆栈顶部 堆栈底部
这里发生了什么事?
为什么我们得到一个段错误?
答案很简单:
strcpy()将*str的内容(larger_string[])复制到buffer[]里,直到在字符串中碰到一个空字符.显然,buffer[]比*str小很多.buffer[]只有16个字节长,而我们却试图向里面填入256个字节的内容.这意味着在buffer之后,堆栈中250个字节全被覆盖.包括SFP,RET,甚至*str!
我们已经把large_string全都填成了A.A的十六进制值为0x41.这意味着现在的返回地址是0x41414141.这已经在进程的地址空间之外了.当函数返回时,程序试图读取返回地址的下一个指令,此时我们就得到一个段错误.
现在回到刚开始的题目:
•main()
•{
• inta[10];
• inti;
• for(i=0;i<=10;i++){
• a[i]=1;
• }
•}
如果栈按地址递减方式分配,也就是说栈是向下增长,则栈空间如下:
内存低地址 内存高地址
i a[0]..a[9] sfp
<------ [ ][ ][ ]
堆栈顶部 堆栈底部
当执行
for(i=0;i<=10;i++){
a[i]=1;
}
时,由于a[0]是在内存低地址,a[9]为内存高地址,也就是a[0]靠近i变量的空间,a[9]靠近sfp的空间,当赋值a[10]时,将sfp的空间覆盖,造成栈溢出。
如果栈按地址递增方式分配,也就是说栈是向上增长,则栈空间如下:
内存低地址 内存高地址
sfp a[0]..a[9]i
[ ][ ][ ]---------->
堆栈底部 堆栈顶部
当执行
for(i=0;i<=10;i++){
a[i]=1;
}
时,由于a[0]是在内存低地址,a[9]为内存高地址,也就是a[0]靠近sfp的空间,a[9]靠近i的内存空间,当赋值a[10]时,将i的空间覆盖,使i的值又赋值为1,造成死循环
有这样一个控制台程序,你知道他运行的结果吗?
intmain()
{
inti,array[10];
for(i=0;i<=10;i++)
{
array[i]=0;
}
}
试着运行一下吧,你会发现,死循环了,为什么呢?
跟踪一下,你会发现,array[10]=0;这个操作改变了i的值,使得i值重新变成了0,这样就导致了死循环。
也就是说array[10]就是i。
为什么呢,是巧合吗?
首先,来学习点操作系统的知识,来了解一下地址空间的概念吧。
对于32位Windows下的每一个进程而言,都有一个大小为4G的地址空间,这个地址空间不是物理上的内存的地址,而是逻辑上的地址,也就是说,地址空间内的数据的实际的物理存储地址是由操作系统实现的,你并不知道。
这4G的地址空间并不是都能被进程使用的,因为这4G的地址空间被分成了不同功能的几个区域。
来看一下Windows2000的地址空间长什么样子吧:
4G的地址空间对应的地址范围0x00000000~0xFFFFFFFF。
·0x00000000~0x0000FFFF大小64K
这是一个NULL指针分区,对这个分区的任何操作将导致一个访问违规,设立这个分区的目的是帮助程序员发现NULL指针相关的错误。
比如我们new一块内存的时候,如果空间不够而导致返回的为NULL指针,如果不加判断就使用这个指针,往往会产生致命错误。
----------------------------
int*pInt=newint;
*pInt=5;
----------------------------
这个分区的设立,使得对NULL地址的访问会导致一个访问违规,程序员会收到明显的提示,进而避免相关的错误发生。
·0x0001000~0x7FFEFFF大小约2GB
这个是用户方式分区,维护了进程的,包括代码和数据的大部分内容的地方。
而且,这个分区时进程私有的,其他的进程访问不到,使得系统更加健壮。
·0x8000000~0xFFFFFFFF大小2G
这个是内核方式分区,也就是说,这个分区存放的都是内核的代码和数据,包括:
内核代码、设备驱动程序代码、设备I/O高速缓存、非页面内存池的分配和进程页面表等等。
也就是说,比如线程对用WindowsAPI的时候,访问并执行的就是这个分区内的代码。
其实,每个进程的地址空间的内核方式分区都是相同的,这样,Windows的系统DLL(比如Kernel32.dll、User32.dll、GDI32.dll)可以被所有的进程调用就不奇怪了。
·细心的人是不是发现还少了64KB。
这个就是0x7FFEFFFF~0x7FFFFFFF的禁止进入分区。
这个分区处在用户方式分区和内核方式分区中间,目的是防止因为对用户方式分区的误操作而改变了内核方式分区的数据,进而导致的操作系统崩溃。
比如下面的代码:
BYTEbBuf[70000];
DWORDdwNumBytesWritten;
WriteProcessMemory(GetCurrentProcess(),(PVOID)0x7FFEFF00,bBuf,sizeof(bBuf),&dwNumBytesWritten);
WriteProcessMemory是WindowsAPI,是内核的代码,所以是有权限改写内核方式分区的内容的,执行后,会将bBuf数组的数据写到0x7FFEFF00~0x80001070这个地址范围上,于是就改写了内核分区的数据,将导致不可预知的错误。
而这个禁止进入的分区同NULL分区一样,都是禁止进入的,任何时候任何访问都会导致违规,进而会将上面的代码中止,避免对内核方式分区的修改,保证系统稳定运行。
看了以上的内容,我们知道了进城所能使用的地址空间大约只有2G(除非在特别需要的情况下,Windows可以将内核方式分区压缩到1G,使得进程拥有3G的地址空间)。
我们编写的程序的所有内容都在这个分区范围内。
比如线程的栈。
什么是栈呢?
栈是线程使用内存的一种方式,是分布在用户方式地址空间中的一块连续的区域,用于保存程序运行时的临时数据,包括局部变量和调用函数时使用的参数。
为什么说是线程的栈?
因为进程是没有栈的,因为进程不是代码的执行者,线程才是代码的执行者,进程只是线程执行的容器而已。
栈有什么特别之处?
首先,栈是大小固定的,是在程序编译时就设定好的,Windows下的线程的栈的默认大小为1MB,所以,如果你的局部变量太大,或者函数的递归调用太厉害,都有可能导致栈被用完,从而导致程序运行失败。
其次,每个线程都有一个自己的栈,线程在被创建时就会分配一个栈。
所以,一般情况下,一个进程只能创建2000个左右的线程(栈大小为1M时),因为2G的地址空间都被线程的栈占领了,没有空间可以分配给新的线程了,线程的创建就失败了。
还有一点比较特别的就是栈的排列方式,栈和地址空间内地址的增长是反向排列的。
也就是说,栈底位于高地址,栈顶位于低地址位置。
这点是和上面的死循环问题相关的。
啊,讲了半天,终于回到问题上面来了。
来看一下上面那个程序运行循环时的栈的使用情况:
定义了局部变量i和数组array,于是在栈上为i和array分配了空间,应为i先定义,array后定义,所以i在栈底(之前没有其他的任何局部变量),array紧随其后,如图所示。
数组元素的增加方向同地址递增方向相同,所以array[10]就是i的所在,array[10]=0;导致i始终无法超过10而使得循环不断的进行下去。
小心避免这种莫名其妙的错误吧,如果不明白其中的道理,改对了也不明白原因,那就错过提高自己的机会了。
其实,解释这个死循环的原因,就上面一段的解释就够了,不过如果不把Windows的内存结构和地址空间解释清楚的话,总觉得还有不理解的地方,多学点东西总是好的,这也算程序员的内功吧。
关于Windows内存结构和地址空间的知识,可以看看《Windows核心编程》,上面讲的很详细,很经典的一本书。
栈是从高地址向低地址方向增涨,堆的方向相反。
在一次函数调用中,栈中将被依次压入:
参数,返回地址,EBP。
如果函数有局部变量,接下来,就在栈中开辟相应的空间以构造变量。
在C语言程序中,参数的压栈顺序是反向的。
比如func(a,b,c)。
在参数入栈的时候,是:
先压c,再压b,最后a。
在取参数的时候,由于栈的先入后出,先取栈顶的a,再取b,最后取c。
C语言是不作栈溢出检查,如下代码可以正常编译运行。
?
#include
main(){
charbuf[2];
printf("enterastringshorterthan2.\n");
scanf("%s",buf);
printf("buf=%s\n",buf);
}
如果函数局部变量发生栈溢出,就会依次覆盖重写EBP(4个字节)、返回地址(4个字节)、函数参数。
函数的“返回地址”被重写是非常危险的,因为“返回地址”可能指向了一段恶意代码而我们却毫无察觉。
下面的代码中funcA的局部变量发生栈溢出,使得funcA的返回地址成为funcB的入口地址。
不过幸好在运行的时候发现了这种行为,报告了“segmentationfault”。
?
#include
#include
#defineBUFLENGTH2
voidfuncA(char*str)
{
charbuf[BUFLENGTH];
strcpy(buf,str); //危险,可能造成栈溢出
printf("strlen(buf)=%d\tbuf=%s\n",strlen(buf),buf);
printf("不安全的代码被调用\n");
}
//下面的函数是恶意代码
voidfuncB()
{
printf("恶意代码被调用\n");
}
voidmain()
{
//以不安全的方式调用函数funcA
charbufNasty[BUFLENGTH+8];
memset(bufNasty,'A',sizeof(bufNasty));
int*ptr=(int*)&bufNasty[BUFLENGTH+4];
*ptr=0x65850408;
funcA(bufNasty);
}
当然如何知道funcB的地址是0x65850408呢?
可以使用反汇编工具查看:
objdump -x attack
也可以在使用gdb时通过在funcB处设置断点看到funcB的地址。
gdb>b funcB
堆栈溢出就是不顾堆栈中分配的局部数据块大小,向该数据块写入了过多的数据,导致数据越界,结果覆盖了老的堆栈数据。
或者解释为在长字符串中嵌入一段代码,并将过程的返回地址覆盖为这段代码的地址,这样当过程返回时,程序就转而开始执行这段自编的代码了.