Reference
在许多C语言的实现中,有可能通过写入程序中所声明的数组的结尾部分来破坏可执行的堆栈.践踏堆栈使用的代码可以造成程序的返回异常,从而跳到任意的地址.这导致了一些极为 险恶的数据相关漏洞(已人所共知).其变种包括堆栈垃圾化(trash the stack),堆栈乱写(scribble the stack),堆栈毁坏(mangle the stack); 术语mung the stack并不使用,因为这从来不是故意造成的.
缓冲区,简单说来是一块连续的计算机内存区域, 可以保存相同数据类型的多个实例.
C语言中所有的变量, 可以被声明为静态或动态的. 静态变量在程序加载时定位于数据段. 动态变量在程序运行时定位于堆栈之中.
溢出, 说白了就是灌满, 使内容物超过顶端, 边缘, 或边界. 我们这里只关心动态缓冲区的溢出问题, 即基于堆栈的缓冲区溢出.
为了理解什么是堆栈缓冲区, 我们必须首先理解一个进程是以什么组织形式在内存中存在的. 进程被分成三个区域: 文本, 数据和堆栈. 我们把精力集中在堆栈区域, 但首先按照顺序简单介绍一下其他区域.
文本区域是由程序确定的, 包括代码(指令)和只读数据. 该区域相当于可执行
文件的文本段. 这个区域通常被标记为只读, 任何对其写入的操作都会导致段错误(segmentation violation).
数据区域包含了已初始化和未初始化的数据. 静态变量储存在这个区域中. 数据区域对应可执行文件中的data-bss
段. 它的大小可以用系统调用brk(2)来改变.如果bss数据的扩展或用户堆栈把可用内存消耗光了, 进程就会被阻塞住, 等待有了一块更大的内存空间之后再运行. 新内存加入到数据和堆栈段的中间.
堆栈(栈)是一个经常使用的抽象数据类型. 堆栈中的物体具有一个特性:LIFO 队列
现代计算机被设计成能够理解人们头脑中的高级语言. 在使用高级语言构造程序时最重要的技术是过程(procedure)和函数(function). 从这一点来看, 一个过程调用可 以象跳转(jump)命令那样改变程序的控制流程, 但是与跳转不同的是, 当工作完成时,函数把控制权返回给调用之后的语句或指令. 这种高级抽象实现起来要靠堆栈的帮助.
堆栈也用于给函数中使用的局部变量动态分配空间, 同样给函数传递参数和函数返回值也要用到堆栈.
堆栈是一块保存数据的连续内存. 一个名为堆栈指针(SP)的寄存器指向堆栈的顶部.
堆栈的底部在一个固定的地址. 堆栈的大小在运行时由内核动态地调整. CPU实现指令PUSH和POP, 向堆栈中添加元素和从中移去元素.
堆栈由逻辑堆栈帧组成. 当调用函数时逻辑堆栈帧被压入栈中, 当函数返回时逻辑堆栈帧被从栈中弹出. 堆栈帧包括函数的参数, 函数地局部变量, 以及恢复前一个堆栈
帧所需要的数据, 其中包括在函数调用时指令指针(IP)的值.
堆栈既可以向下增长(向内存低地址)也可以向上增长, 这依赖于具体的实现. 在我们的例子中, 堆栈是向下增长的. 这是很多计算机的实现方式, 包括Intel, Motorola,SPARC和MIPS处理器. 堆栈指针(SP)也是依赖于具体实现的. 它可以指向堆栈的最后地址,或者指向堆栈之后的下一个空闲可用地址. 在我们的讨论当中, SP指向堆栈的最后地址.
除了堆栈指针(SP指向堆栈顶部的的低地址)之外, 为了使用方便还有指向帧内固定地址的指针叫做帧指针(FP). 有些文章把它叫做局部基指针(LB-local base pointer). 从理论上来说, 局部变量可以用SP加偏移量来引用. 然而, 当有字被压栈和出栈后, 这些偏移量就变了. 尽管在某些情况下编译器能够跟踪栈中的字操作, 由此可以修正偏移 量, 但是在某些情况下不能. 而且在所有情况下, 要引入可观的管理开销. 而且在有些机器上, 比如Intel处理器, 由SP加偏移量访问一个变量需要多条指令才能实现.
因此, 许多编译器使用第二个寄存器, FP, 对于局部变量和函数参数都可以引用,因为它们到FP的距离不会受到PUSH和POP操作的影响. 在Intel CPU中, BP(EBP)用于这
个目的. 在Motorola CPU中, 除了A7(堆栈指针SP)之外的任何地址寄存器都可以做FP.
考虑到我们堆栈的增长方向, 从FP的位置开始计算, 函数参数的偏移量是正值, 而局部变量的偏移量是负值.
当一个例程被调用时所必须做的第一件事是保存前一个FP(这样当例程退出时就可以恢复). 然后它把SP复制到FP, 创建新的FP, 把SP向前移动为局部变量保留空间. 这称为
例程的序幕(prolog)工作. 当例程退出时, 堆栈必须被清除干净, 这称为例程的收尾(epilog)工作. Intel的ENTER和LEAVE指令, Motorola的LINK和UNLINK指令, 都可以用于
有效地序幕和收尾工作.
下面我们用一个简单的例子来展示堆栈的模样:
exapmle1
void function(int a,int b){
char buffer1[5];
char buffer2[10];
}
void main(){
function(1,2,3);
}
用gcc -S
编译产生汇编大妈看看发生了什么
对于function翻译成了
pushl $3
pushl $2
pushl $1
call function
以从后往前的顺序将function的三个参数压入栈中,然后调用function。指令call会把指令指针IP雅茹栈中。我们把这个被保存的IP称为返回地址RET。在函数中所做的第一件事就是prolog工作:
pushl %ebp
movl %esp,%ebp
subl $20,%esp
将栈帧指针EBP压入栈中,然后把当前的Sp复制到EBP,使其成为新的栈帧指针。我们把这个被保存的FP叫做SFP。接下来将SP的值减小,为局部变量保留空尽。
note:内存智能以字为单位寻址。这里一个字是4个字节,32位,因此5个单位的char的buf会占用8个字节(2个字)的内存空间。而10个单位的char会占用12个字节(3个字)的内存空间。这就是为什么Sp要减掉20的原因。这样我们就可以想象function()被调用的时候堆栈的模样(每个空格代表一个byte)
缓冲区溢出是向一个缓冲区填充超过他空间的数据所造成的结果,一般会利用这个来执行任意代码
example2.c
void function(char *str) {
char buffer[16];
strcpy(buffer,str);
}
void main() {
char large_string[256];
int i;
for( i = 0; i < 255; i++)
large_string[i] = 'A';
function(large_string);
}
这里的函数就含有一个典型的缓冲区编码错误,它没有做辩解检查就复制所提供的字符串,错误的使用了strcpy而没有使用strncpy。如果你运行这个就有可能产生段错误。我们看看函数调用的时候堆栈的模样:
这里发生了什么呢?我们为什么会得到段错误呢?因为strcpy将str的内容复制到buffer里面,直到字符串碰到'\0'
但是buffer只有16的字节,但是我们却向里面塞了256个字节,这意味着buffer之后,堆栈的250个字节全部被覆盖,包括sfp,ret,甚至str自己。
我们已经把str全部填成了A,A的十六进制为0x41,这意味着我们现在返回的地址是0x41414141。这已经在进程的地址空间外面了。当函数返回的时候,程序试图读取返回地址的下一个指令,就会得到段错误。
因此缓冲区溢出允许我们更改函数的返回地址。这样我们就可以改变程序的流程。现在回到第一个例子,当时堆栈的模样是
我们试着修改第一个例子,让它可以覆盖返回的地址,而且使得它执行任意代码。
堆栈中的buffer1[]之前的是sfp,sfp之前的是返回地址,ret从buffer[]的结尾算起是4个字节。应该记住的是buffer1的实际长度2个字,8个字节。因此返回地址从buffer1的开头算起时12个字节
我们会用这个方法修改返回地址,跳过函数调用后面的赋值语句’x=1’.为了做到这一点我们把返回地址加上8个字节,那么代码就是
example3.c
void function(int a,int b,int c){
char buffer1[5];
char buffer2[10];
int *ret;
ret=buffer1+12;
(*ret+=8);
}
voi main(){
int x;
x=0;
function(1,2,3);
x=1;
printf("%d\n",x);
}
我们把buffer1的地址加上12,所得到的新地址是返回地址存储的地方。我们想跳过赋值语句而直接执行printf调用。如果知道应该給返回地址加上8个字节呢?我们现前使用过一个实验值,编译该程序,用gdb工具
我们看到当调用function()时候,ret会是0x80004a8,我们希望跳过0x80004ab的赋值指令,下一个想要执行的指令在0x80004b2,简单的计算告诉我们它们差了8个字节