History of Buffer Overflow

Part I 基础知识

Posted by Liuv on November 4, 2015

在缓冲区溢出的攻防上,微软与世界各地的黑客们进行了激烈的较量,难解难分。

0x01 前言


最近读了一篇有关内存重用攻击防御的论文,里面涉及到了ROP,Heap Spray这些常见的攻击技术,在调研的过程中逐渐发现这些技术与缓冲区溢出有着千丝万缕的关系,核心思想都是通过控制堆栈和寄存器来达到改变程序控制流的目的。
因此,我决定开一篇专题,对缓冲区溢出、ROP、Heap Spray这些常见的攻击技术的发展史进行一个梳理。
当然,在安全领域,攻击和防御都是相伴相生的,在介绍攻击的过程中,也会对DEP、ASLR、CFI\CFG这些安全技术进行讲解。在正式介绍这些技术之前,我会先介绍有关操作系统方面的知识。

0x02 程序内存布局(32位)


在进行缓冲区溢出攻击之前,需要对当前操作系统中程序的内存布局有一个清晰的了解。
现代的应用程序都运行在一个虚拟内存空间里,在32位的系统里,这个内存空间拥有4GB的寻址能力。现代的应用程序可以直接使用32位的地址进行寻址,整个内存是一个统一的地址空间,用户可以使用一个32位的指针访问任意内存位置。
从宏观上来看,操作系统将内存分成了代码段、数据段、堆、栈、内核空间四部分,其中代码段、数据段、堆、栈组合成用户空间。Windows默认情况下会将高地址的2GB分配给内核(也可配置为1GB),而Linux默认情况下将高地址的1GB空间分配给内核。
计算机程序本质上是由bss segment、data segment、text segment这三个段组成的,程序运行过程还需要堆、栈来完成内存分配、函数调用等功能。下面对这六个内存区域进行详细介绍。

  • BSS segment:BSS段通常是指用来存放程序中未初始化的全局变量的一块内存区域。BSS是英文Block Started by Symbol的简称。BSS段属于静态内存分配。
  • Ro-data segment:只读数据段通常用来存放字符串常量
  • Data segment:数据段通常是指用来存放程序中已初始化的全局变量的一块内存区域。数据段属于静态内存分配。
  • Text segment:代码段通常是指用来存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读, 某些架构也允许代码段为可写,即允许修改程序。在代码段中,也有可能包含一些只读的常数变量,例如字符串常量等。
  • Heap:堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减。当进程调用malloc等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);当利用free等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减)
  • Stack:栈又称堆栈,是用户存放程序临时创建的局部变量,也就是说我们函数括弧“{}”中定义的变量(但不包括static声明的变量,static意味着在数据段中存放变量)。除此以外,在函数被调用时,其参数也会被压入发起调用的进程栈中,并且待到调用结束后,函数的返回值也会被存放回栈中。由于栈的先进先出特点,所以栈特别方便用来保存/恢复调用现场。从这个意义上讲,我们可以把堆栈看成一个寄存、交换临时数据的内存区。

下图是linux中的地址空间格局。 img
高级语言写出的程序经过编译链接,最终会变成可执行文件。当可执行文件被装载运行后,就成了所谓的进程。可执行文件代码段中包含的二进制级别的机器代码会被装入内存的代码区。处理器将到内存的这个区域一条一条地取出指令和操作数,并送入运算逻辑单元进行运算。如果代码中请求开辟动态内存,则会在内存的堆区分配一块大小合适的区域返回给代码区的代码使用。当函数调用发生时,函数的调用关系等信息会动态地保存在内存的栈区,以供处理器在执行完被调用函数的代码时,返回母函数。
当数据存储时,分为常量和变量两种情况:

  • 常量:对于整型常量和字符型常量,由于不需要写操作,编译器会将其直接编译在代码之中,因此不需要存储。对于字符串常量,编译器将其放入只读数据段.rdata;对于相同的字符串常量,编译器会优化并只存储一次。
  • 变量:
    • 静态变量和全局变量:未初始化的,存储于.bss ; 初始化的,存储于.data
    • 自动变量:局部变量存储于stack ; 动态分配的内存,存储于heap。
    • 寄存器变量:存储位置在CPU寄存器内


在这里额外说一下多线程的内容。多线程程序和普通程序在内存中的不同只要表现在栈的不同,每一个线程一个栈,所以线程的局部变量不会受到其他线程的影响。而.text, .data. .bss等部分段是各个线程共享的,所以线程间通信很简单,可以直接在这些共享内存中存取就可以了。

0x03 函数调用栈过程

上文中已经提到过,调用函数的过程本质上就是一些压栈出栈操作。当然,在此之前先说明几个与栈有关的寄存器。后面会频繁用到。

  • ESP:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。
  • EBP:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。(ebp在当前栈帧内位置固定,故函数中对大部分数据的访问都基于ebp进行)
  • EIP:指令寄存器(extended instruction pointer),其内存放着一个指针,该指针永远指向下一条等待执行的指令地址。 可以说如果控制了EIP寄存器的内容,就控制了进程——我们让eip指向哪里,CPU就会去执行哪里的指令。eip可被jmp、call和ret等指令隐含地改变(事实上它一直都在改变)


接下来继续介绍几个与栈有关的汇编指令。

  • POP :出栈操作。for example:"pop ax",意为将栈顶中的data取出放到ax中,同时esp+4(栈的增长方向是由高地址向低地址)。
  • PUSH:压栈操作。for example:"push bx",意为将bx中的data压入栈顶,同时esp-4。
  • RET:返回操作。将ESP指针所指内存中的data放入EIP中,即跳转到该地址处执行。


接下来介绍一下栈帧(stack frame)的概念。函数调用经常是嵌套的,在同一时刻,堆栈中会有多个函数的信息。每个未完成运行的函数占用一个独立的连续区域,称作栈帧。栈帧是堆栈的逻辑片段,当调用函数时逻辑栈帧被压入堆栈, 当函数返回时逻辑栈帧被从堆栈中弹出。栈帧存放着函数参数,局部变量及恢复前一栈帧所需要的数据(如返回地址、前栈帧EBP指针)等。
编译器利用栈帧,使得函数参数和函数中局部变量的分配与释放对程序员透明。编译器将控制权移交函数本身之前,插入特定代码将函数参数压入栈帧中,并分配足够的内存空间用于存放函数中的局部变量。使用栈帧的一个好处是使得递归变为可能,因为对函数的每次递归调用,都会分配给该函数一个新的栈帧,这样就巧妙地隔离当前调用与上次调用。
栈帧的边界由栈帧基地址指针EBP和堆栈指针ESP界定(指针存放在相应寄存器中)。EBP指向当前栈帧底部(高地址),在当前栈帧内位置固定;ESP指向当前栈帧顶部(低地址),当程序执行时ESP会随着数据的入栈和出栈而移动。因此函数中对大部分数据的访问都基于EBP进行。

说了这么多,用一张图将函数嵌套调用时的栈帧结构描述一下(这是我在网上找的一张图)。 img


好了,理论说了一大堆,现在就以一个简单的C程序为例来具体说明函调调用过程中的栈的变化。这个程序结构很简单,由一个main函数和func子函数组成。首先,main执行,main各个参数从右向左逐步压入栈中,最后压入返回地址。源代码如下(第一次用pygments的代码高亮,代码很酷有木有!)

#include <stdio.h>

int func(int param1 ,int param2,int param3){
    int var1 = param1;
    int var2 = param2;
    int var3 = param3;
   
    return var1;
}
 
int main(int argc, char* argv[]){
    int param1 = 1;
    int param2 = 2;
    int param3 = 3;
    
    int result = func(param1,param2,param3);
 
    return 0; 
}

首先是执行main函数,main各个参数从右向左逐步压入栈中,最后压入返回地址。然后,从现在开始,main函数开始创建自己的栈帧。压入上一个栈帧的EBP值,依次压入局部变量param1、param2、param3。让我们来看看当前栈中的情况是什么样子。 img 现在程序继续运行到16行,开始调用func,终于到重头戏了!在进入func函数前,会先执行以下九条汇编指令(假设执行函数前ESP指针为NN):

//将[ESP-14h]中的内容赋到EAX寄存器中

mov    0x14(%esp),%eax
//将EAX寄存器中的内容放入地址[ESP+8h]内存中,即将param3入栈,地址为NN+8h

mov    %eax,0x8(%esp)
//将[ESP-18h]中的内容赋到EAX寄存器中

mov    0x18(%esp),%eax
//将EAX寄存器中的内容放入地址[ESP+4h]内存中,即将param2入栈,地址为NN+4h

mov    %eax,0x4(%esp)
//将[ESP-1Ch]中的内容赋到EAX寄存器中

mov    0x1c(%esp),%eax
//将EAX寄存器中的内容放入地址[ESP]内存中,即将param1入栈,地址为NN

mov    %eax,(%esp)
//调用func函数,call指令会将当前返回地址入栈,ESP-=4h,ESP = NN - 4h

call   0x401334 <func>

接下来就正式进入func函数中执行,即func开始创建自己的栈帧!此时会先执行以下两条经典的汇编指令,之所以经典,是因为所有的栈帧建立都会先执行这两条指令来保存上一个栈帧的EBP指针。

//保护main函数栈帧的EBP指针。EBP入栈,ESP-=4h, ESP = NN - 8h

push   %ebp
//设置EBP指针指向当前栈顶,即此时EBP = ESP= NN - 8h

mov    %esp,%ebp

然后是func函数中的局部变量赋值操作,将传进来的实参依次赋值给局部变量var1、var2、var3。以var1为例来说明赋值过程,该过程对应的汇编指令为以下几句:

//ESP指针往低地址偏移10h个单位,为后面局部变量入栈预留位置

sub    $0x10,%esp
//将[EBP+8h]地址里的内容赋给EAX,该地址中正好是刚才存储param1的地址

mov    0x8(%ebp),%eax
//把EAX的中的值放到[EBP-4h]这个地址里,即把EAX值赋给var1

mov    %eax,-0x4(%ebp)

现在我们来看看所有局部变量都存储完后栈中的情况。 img 接着执行第9行的返回语句,将变量var1的值返回。在intel 32位体系结构中,函数返回值通常保存在寄存器eax中。因此,19行对应的汇编语句为:

//把[EBP-0x4]地址里的内容放入EAX中,而[EBP-0x4]地址中存储的就是变量var1

mov  -0x4(%ebp),%eax

此时,func函数执行完毕,接下来就该释放func函数栈帧了。在本例中,释放顺序为局部变量var3,var2,var1依次出栈,EBP恢复原值,返回地址出栈,找到原执行地址,param1,param2,param3依次出栈,函数调用执行完毕。

//相当于Set ESP to EBP, then pop EBP,即ESP回到EBP处,然后EBP出栈,此时ESP=NN-4h,指向返回地址

leave
//相等于POP EIP,将ESP指向地址中存储的返回地址放入EIP寄存器中,程序执行流回到main函数中

ret

现在完整的看一下func函数调用过程中所有的汇编指令,该结果是我在本机上的执行结果,IDE为codeblocks。

3  	int func(int param1 ,int param2,int param3){
0x00401334	push   %ebp
0x00401335	mov    %esp,%ebp
0x00401337	sub    $0x10,%esp
4  	    int var1 = param1;
0x0040133A	mov    0x8(%ebp),%eax
0x0040133D	mov    %eax,-0x4(%ebp)
5  	    int var2 = param2;
0x00401340	mov    0xc(%ebp),%eax
0x00401343	mov    %eax,-0x8(%ebp)
6  	    int var3 = param3;
0x00401346	mov    0x10(%ebp),%eax
0x00401349	mov    %eax,-0xc(%ebp)
8  	    return var1;
0x0040134C	mov    -0x4(%ebp),%eax
9  	}
0x0040134F	leave
0x00401350	ret

最后,来看一下func函数的栈帧信息,可以得到保存的EBP和EIP值,以及调用func的实参和func函数内的局部变量的信息。 img
好啦,总算写完了,顺便说一下,在64位系统中,传入函数的实参不再存储在栈里了,而是按规定存储在特定的寄存器中,但是基本原理都是一样的。下一篇博客就要开始正式介绍缓冲区溢出了。
注:这篇博客是我在网上查阅了许多相关内容的博客后进行的总结,中间很多都是引用这些博客的内容,当然,我也加入了很多自己原创性的内容,欢迎拍砖。哈哈。