防范缓冲区溢出攻击的方法--备份返回地址

时间:2023-02-04 10:13:14

1 引言

缓冲区溢出是C/C++语言种常见的一种攻击手段,主要是利用了C/C++语言中缺少对数组边界的检查机制。典型的一段代码如下所示:

#include <stdio.h>
#include <string.h>

#defineN256
#define n16

void foo(char *str){
char buf[n];

buf[0] = 'a';
strcpy(buf, str);
return;
}

int main(){
int idx;
char str1[N];
char str2[n];

for(idx=0; idx<N; idx++)
str1[idx] = 'b';
foo(str1);
return 0;
}


库函数strcpy负责把字符串str复制到buf中,但是因为缺乏字符串边界检查,而buf的长度为16,str的长度为256,把str的内容复制到buf中,必然会覆盖栈中某些数据,其中就有可能把函数的返回地址覆盖了。下图中是x86运行时候的栈帧结构虚线之上表示前一个栈的栈帧,虚线下面表示当前栈的栈帧。堆栈的生长方向通常是向下增长(地址减少),而堆栈中数组的增长方向是向上增长。所以当向buff中写入越界的数据时,会首先覆盖帧指针,然后覆盖返回地址。攻击者就能够通过利用此种攻击,改变程序的返回地址,把程序返回到一段恶意的代码区域,从而进行攻击活动,包括非法获得某些权限、窃取信息等。

防范缓冲区溢出攻击的方法--备份返回地址

详细可见《Smashing The Stack For Fun And Profit》一文。

 2. 一些常见的防御措施

1)stack guard

比较常见的一种防范缓冲区溢出攻击的措施是stack guard方法,也是gcc所支持的一种方法(对应选项为-fstack-protector)。此种方法在堆栈中插入一个探测值。若利用缓冲区溢出来改写返回地址的话,通常也会覆盖这个探测值,编译器在编译函数时,分别在函数的入口处生成探测值,出口处比较探测值,通过探测值的被改动与否来判断是否有缓冲区溢出攻击的发生。下图是在stack guard保护下的堆栈结构。

防范缓冲区溢出攻击的方法--备份返回地址

如图中所示,对局部变量的溢出攻击,必然先溢出到stack guard,然后再溢出到帧指针、返回地址等。

2)stack shield

stack shield方法在程序运行时,使用数组来备份返回地址,这样即使用户通过缓冲区溢出攻击手段修改了栈帧中的返回地址,但是很难改变数组中所备份的返回地址。因此,可以通过比较数组中的返回地址和堆栈中的返回地址的不同来判断是否有缓冲区溢出攻击的发生,或者直接使用数组中保存的返回地址,来保证程序正常的控制流。

但是stack shield工具是在汇编码上做转换,在.s文件中增加数组的定义,在.s文件的prolog和epilog中增加把返回地址存入数组和从数组中取回返回地址的操作。这样做

首先是不够方便,必须先用gcc生成.s文件,然后再从处理.s文件,生成新的.s文件,最后才调用汇编器、链接器,生成新的可执行目标文件。另一个不足就是,这种方法的兼容性不够好,因为不同平台的汇编码是不相同的,甚至相同平台下不同系列的芯片也可能是不相同的,因此处理.s文件时,容易出错。

3. 运行时库支持的返回地址备份

为了解决stack shield的问题,我写了一个小小的库,并通过修改gcc(4.8)的源代码,实现了在运行时保存函数返回地址的一个备份。当函数返回时,通过比较备份的返回地址值和堆栈中的返回地址,来判断是否有缓冲区溢出攻击的发生。

具体的思路是:

(1)库中声明一个数组,在函数的入口处,调用库中的入口函数,把当前堆栈中的返回地址备份到数组中;

(2)在函数的出口处,调用库中的出口函数,把当前堆栈中的返回地址和堆栈中的返回地址做比较,判断是否有缓冲区溢出攻击的发生。

库函数很简单,其实现如下:

#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>

#define LENGTH 256
#define _PATH_TTY "/dev/tty"

/*Use retarray to backup the return address*/
void *__retarray[LENGTH];
int __retindex = 0;

void __retarray_prolog(void *cur_addr){
  if(__retindex < LENGTH){
    __retarray[__retindex] = cur_addr;
  }
  __retindex++;
  return;
}

void __retarray_epilog(void *cur_addr){
  int fd;

  fd = open(_PATH_TTY, O_WRONLY);
  __retindex--;
  if(__retindex < LENGTH){
    if(cur_addr != __retarray[__retindex]){
      write(fd, "+++ different return address +++\n", 33);
      close(fd);
      _exit(127);
    }
  }
  return;
  close(fd);
}

在上面的代码中,__retarray是保存返回地址备份的堆栈,__retindex是索引。__retarray_prolog和__retarray_epilog则分别是编译器编译每个函数时,在其入口和出口处调用的函数,其参数都是编译器从当前堆栈中获得的返回地址,分别负责保存它到数组中和比较它与数组中已保存值的差别。

另外也需要修改gcc源码,gcc的修改包括以下几步:

(1) 选项控制,为简单起见,重用了gcc中防缓冲区溢出的选项-fstack-protector,增加了一个子选项-fstack-protector-ret,所修改的文件是common.opt,增加了以下几行:

fstack-protector-ret
Common Report RejectNegative Var(flag_stack_protect, 3)
Use an array to restore the return address for every function
当使用-fstack-protector-ret时,gcc源码中变量flag_stack_protect的值为3。

2) 在gcc-4.8.0/gcc/function.c中的expand_function_start和expand_function_end中,增加两个函数调用ret_addr_backup_prologue()和ret_addr_backup_epilogue(),这两个函数分别负责生成对__retarray_prolog和__retarray_epilog的调用。对应的代码段如下:

if(strcmp(current_function_name(), "main") && (flag_stack_protect == 3) && (cfun->calls_alloca || has_protected_decls))
ret_addr_backup_prologue();
if(strcmp(current_function_name(), "main") && (flag_stack_protect == 3) && (cfun->calls_alloca || has_protected_decls))  ret_addr_backup_epilogue();
static void ret_addr_backup_prologue(void){  rtx addr_rtx;  tree addr_tree;  tree func_tree = ret_prolog_decl;  ......  tree args = build_function_type_list (void_type_node, ptr_type_node, NULL_TREE);  func_tree = build_decl (UNKNOWN_LOCATION, FUNCTION_DECL, get_identifier ("__retarray_prolog"), args);  ......  addr_rtx = RETURN_ADDR_RTX (0, frame_pointer_rtx);  addr_tree = ret_prolog_parm;  if(addr_tree == NULL_TREE){    addr_tree = build_decl (UNKNOWN_LOCATION, PARM_DECL, NULL_TREE, ptr_type_node);    SET_DECL_RTL(addr_tree,addr_rtx);  }  expand_call(build_call_expr(func_tree,1,addr_tree), NULL_RTX, true);  return;}
ret_addr_backup_epilogue函数可以如法炮制。

对于gcc不够熟悉,改动一点代码都花了很久的时间,把心得体会贴出来,供大家参考。