一个应用程序在运行时,它在内存中的映像可以分为三个部分: 代码段 ,数据段和堆栈段.
1.代码段对应于运行文件中的文本区(Text Section ),其中存放着程序的代码, 这个段在内存中一般被标记为只读 , 任何企图修改这个段中数据的指令将引发一个 Segmentation Violation 错误. 文本区是可以被多个运行该可执行文件的进程所共享的.
2.数据段对应与运行文件中的数据区(Data Section),其可分为初始化数据区(INIT Section)和非初始化数据区(BSS Section),初始化数据(INIT)区用于存放可执行文件里的初始化数据;而非初始化数据(BSS)区用于存放程序的静态变量, BSS区内存都是被初始化为零的.
3.而堆栈段就是我们下面要重点关注的对象了。为了说明堆栈段,我们首先得解释一下函数的栈帧,为了说明函数的栈帧,首先最好还是看一个小例子:
void proc(int i) { int local; local=i; } int main(void) { proc(2); return 0; }
|
这段代码经过编译后,反汇编的结果大致是(这里只是为了说明堆栈段而提取出了反汇编后的核心部分,实际情况还要更复杂一些):
main:push 2
call proc
...
proc:push ebp
mov ebp,esp
sub esp,4
mov eax,[ebp+08]
mov [ebp-4],eax
add esp,4
pop ebp
ret 4
|
我们先有点儿耐心,来分析一下这段代码:
首先, 将函数调用要用到的参数2压入堆栈,然后call proc
注意,这里call proc时,系统将自动将函数的返回地址,即main函数下一条语句(return 0)的地址压栈。所以,此时堆栈段的情况如下图:
(内存高端)
+-----------------+
+ 2 +
+-----------------+
+ 函数的返回地址RET +
+-----------------+
(内存底端)
接下来
proc:push ebp mov ebp,esp
|
我们知道ebp是堆栈基址寄存器,esp是堆栈指针寄存器,esp指向堆栈的顶端.首先将ebp的原值存入堆栈,然后将esp的值赋给ebp。
将esp减4(32位机,一个int占四字节),留出一个int的位置给局部变量 local 使用,
mov eax,[ebp+08] mov [ebp-4],eax
|
就是 local=i;
此时,内存中堆栈段的映像为:
(内存高端)
+----------------+
+ (i) 2 +
+----------------+
+ 返回地址RET +
+----------------+
+ 原ebp +
+----------------+ <—现在的ebp
+ (local)2 +
+----------------+ <—现在的esp
(内存低端)
首先esp加4,收回局部变量的空间,然后pop ebp, 恢复ebp原值,最后 ret 4,从堆栈中取得返回地址,将EIP改为这个地址,并且将esp加4,收回参数所占的空间.
以上分析的就是函数调用时,被调用函数的栈帧结构。总结一下,就是:
(内存高端)
+--------------------+
+ 参数一 +
+--------------------+
+ 参数二 +
+--------------------+
+ 。。。。。。 +
+--------------------+
+ 参数N +
+--------------------+
+ RET +
+--------------------+
+ 原ebp +
+--------------------+
+ local param N +
+--------------------+
+ 。。。。。。 +
+--------------------+
+ local param 二 +
+--------------------+
+ local param 一 +
+--------------------+
(内存低端)
可以看出,函数调用时所建立的栈帧包含了下面的信息:
i)为调用函数的局部变量分配的空间。
ii) 调用函数的返回地址. 返回地址是存放在调用函数的栈帧还是被调用函数的栈帧里, 取决于不同系统的实现.
iii) 调用函数的栈帧信息, 即bsp.
iv) 为被调用函数的参数分配的空间——取决于不同系统的实现.
4.至此,核心部分已经讲完了,我们将各个段串起来再看一下程序运行时内存的情况:
我们假设现在有一个程序, 它的函数调用顺序如下.
main(...) —> func_1(...) —> func_2(...) —> func_3(...)
即: 主函数main调用函数func_1; 函数func_1调用函数func_2; 函数func_2调用函数func_3
当程序被操作系统调入内存运行, 其相对应的进程在内存中的映像如下图所示:
(内存高端)
+--------------------+
+不需要关心的内存 +
+--------------------+
+ Env String (环境变量字符串) +
+--------------------+
+ Argv String (命令行参数字符串) +
+--------------------+
+Env Pointers (环境变量指针) +
+--------------------+
+ Argv Pointers (命令行参数指针) +
+--------------------+
+Argc (命令行参数个数) +
+--------------------+
+main函数的栈帧 +
+--------------------+
+func_1的栈帧 +
+--------------------+
+func_2的栈帧 +
+--------------------+
+func_3的栈帧 +
+--------------------+
+Stack (栈) +
+--------------------+
+Heap (堆) +
+--------------------+
+BSS data (非初始化数据区) +
+--------------------+
+INIT data (初始化数据区) +
+--------------------+
+Text (文本区) +
+--------------------+
(内存低端)
5.缓冲区溢出的机理
我们先举一个例子说明一下什么是 Buffer Overflow :
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); }
|
这段程序中就存在 Buffer Overflow 的问题. 我们可以看到, 传递给function的字符串长度要比buffer大很多,而function没有经过任何长度校验直接用strcpy将长字符串拷入buffer. 如果你执行这个程序的话,系统会报告一个 Segmentation Violation 错误.下面我们就来分析一下为什么会这样?
首先我们看一下未执行strcpy时堆栈中的情况:
(内存高端)
+----------------+
+ large_string的地址 +
+----------------+
+ 返回地址RET +
+----------------+
+ 原ebp +
+----------------+ <—现在的ebp
+ 。。。。。。 +
+----------------+
+ 。。。。。。 +
+----------------+ <—现在的esp
(内存低端)
当执行strcpy时, 程序将256 Bytes拷入buffer中,但是buffer只能容纳16 Bytes,那么这时会发生什么情况呢? 因为C语言并不进行边界检查, 所以结果是buffer后面的250字节的内容也被覆盖掉了,这其中自然也包括ebp, ret地址 ,large_string地址.因为此时ret地址变成了0x41414141h ,所以当过程结束返回时,它将返回到0x41414141h地址处继续执行,但由于这个地址并不在程序实际使用的虚存空间范围内,所以系统会报Segmentation Violation.
从上面的例子中不难看出,我们可以通过Buffer Overflow来改变在堆栈中存放的过程返回地址,从而改变整个程序的流程,使它转向任何我们想要它去的地方.这就为黑客们提供了可乘之机, 最常见的方法是: 在长字符串中嵌入一段代码,并将过程的返回地址覆盖为这段代码的地址, 这样当过程返回时,程序就转而开始执行这段我们自编的代码了. 一般来说,这段代码都是执行一个Shell程序(如\bin\sh),因为这样的话,当我们入侵一个带有Buffer Overflow缺陷且具有suid-root属性的程序时,我们会获得一个具有root权限的shell,在这个shell中我们可以干任何事。因此, 这段代码一般被称为Shell Code.
6.缓冲区在Heap(堆)区或BBS(非初始化数据)区的情况
前面的缓冲区溢出是发生在栈里面的,因为函数局部变量的空间是被分配在栈中的。我们知道进程对内存的动态申请是发生在Heap(堆)里的. 非初始化数据(BSS)区用于存放程序的静态变量, 这部分内存都是被初始化为零的.
i) 如果缓冲区的内存空间是在函数里通过动态申请得到的(如: 用malloc()函数申请), 那 么在函数的栈帧中只是分配了存放指向Heap(堆)中相应申请到的内存空间的指针. 这种 情况下, 溢出是发生在(Heap)堆中的, 想要复盖相应的函数返回地址, 看来几乎是不可 能的. 这种情况的利用可能性要看具体情形, 但不是不可能的.
ii) 如果缓冲区在函数中定义为静态(static), 则缓冲区内存空间的位置在非初始化(BBS)区, 和在Heap(堆)中的情况差不多, 利用是可能的. 但还有一种特姝情况, 就是可以利用它来 复盖函数指针, 让进程后来调用相应的函数变成调用我们所指定的代码.
7.具有suid-root属性的程序
UNIX是允许其他用户可以以某个可执行文件的文件拥有者的用户ID或用户组ID的身份来执行该文件的,这是通过设置该可执行文件的文件属性为SUID或SGID来实现的. 也就是说如果某个可执行文件被设了SUID或SGID, 那么当系统中其他用户执行该文件时就相当于以该文件属主的用户或用户组身份来执行该文件. 如果某个可执行文件的属主是root, 而这个文件被设了SUID, 那么如果该可执行文件存在可利 用的缓冲区溢出漏洞, 我们就可以利用它来以root的身份执行我们准备好的代码. 没有比让它为我们产生一个具有超级用户root身份的SHELL更吸引人了。
ii) 各种端口守护(服务)进程
UNIX中有不少守护(服务)进程是以root的身份运行的, 如果这些程序存在可利用的缓冲区溢出,
那么我们就可以让它们以当前运行的用户身份--root去执行我们准备被好的代码. 由于守护进程已经以root的身份在运行, 我们并不需要相对应的可执行文件为SUID或SGID属性. 又由于此类利用通常是从远程机器上向目标机器上的端口发送有恶意的数据造成的, 所以叫做 "远程溢出"利用.
我们下一步要做的,就是激动人心的缓冲区溢出攻击了。
参考资料:
水木社区的一篇关于buffer overflow的文章。