内存越界引用和缓冲区溢出

C对于数组引用不进行任何边界检查,而且局部变量和状态信息(如保存的寄存器值和返回地址)都存放在栈中。这两种情况结合到一起就能导致严重的程序错误,对越界的数组元素的写操作会破坏存储在栈中的状态信息。

当程序使用这个被破坏的状态,试图重新加载寄存器或执行ret指令时,就会出现很严重的错误。
一种特别常见的状态破坏称为缓冲区溢出(buffer overflow)。
例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* Implementation of library function gets()*/
char *gets(char *s){
int c;
char *dest = s;
while((c = getchar()) != '\n' && c != EOF)
*dest++ = c;
if(c == EOF && dest == s)
/* No characters read */
return NULL;
*dest++ = '\0'; /* Terminate string */
return s;
}

/* Read input line and write it back */
void echo(){
char buf[8]; /* way too small */
gets(buf);
puts(buf);
}

上面的代码给出了库函数gets的一个实现,用来说明这个函数的严重问题。它从标准输入读入一行,在遇到一个回车换行字符或某个错误情况时停止。它将这个字符串复制到参数s指明的位置,并在字符串结尾加上null字符。在函数echo中,我们使用了gets,这个函数只是简单的从标准输入中读入一行,再把它回送到标准输出。

gets的问题是它没有办法确定是否为保存整个字符串分配了足够的空间。在echo示例中,我们故意将缓冲区设置的非常小——只有8个字节长。任何长度超过7个字符的字符串都会导致写越界。

检查GCC为echo产生的汇编代码,看看栈是如何组织的:

1
2
3
4
5
6
7
8
echo:
subq $24, %rsp ; allocate 24 bytes on stack
movq %rsp, %rdi ; compute buf as %rsp
call gets ; call gets
movq %rsp, %rdi ; compute buf as %rsp
call puts ; call puts
addq $24, %rsp ; deallocate stack space
ret ; return

下图画出了echo执行时栈的组织。
echo函数的栈组织
该程序把栈指针减去了24,在栈上分配了24个字节。字符数组buf位于栈顶,可以看到,%rsp被复制到%rdi作为调用gets和puts的参数。这个调用的参数和存储的返回指针之间的16字节是未被使用的。只要用户输入不超过7个字符,gets返回的字符串(包括结尾的null)就能够放进为buf分配的空间里。不过,长一些的字符串就会导致gets覆盖栈上存储的某些信息。随着字符串变长,下面的信息会被破坏:

输入的字符数量 附加的被破坏的状态
0-7
9-23 未被使用的栈空间
24-31 返回地址
32+ caller中保存的状态

字符串到23个字符之前都没有严重的后果,但是超过以后,返回指针的值以及更多可能的保存状态会被破坏。如果存储的返回地址的值被破坏了,那么ret指令会导致程序跳转到一个完全意想不到的位置。如果只看C代码,根本就不可能看出会有上面这些行为。只有通过研究机器代码级别的程序才能理解像gets这样的函数进行的内存越界的影响。

我们的echo代码很简单,但是有点太随意了。更好一点的版本是使用fgets函数,它包括一个参数,限制待读入的最大字节数。通常,使用gets或其他任何能导致存储溢出的函数,都是不好的编程习惯。不幸的是,很多常用的库函数,包括strcpy、strcat和sprintf,都有一个属性——不需要告诉它们目标缓冲区的大小,就产生一个字节序列。这样的情况就会导致缓冲区溢出漏洞。

缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数。这是一种最常见的通过计算机网络攻击系统安全的方法。通常,输入给程序一个字符串,这个字符串包含一些可执行代码的字节编码,称为攻击代码(exploit code),另外,还有一些字节会用一个指向攻击代码的指针覆盖返回地址。那么,执行ret指令的效果就是跳转到攻击代码。

在一种攻击形式中,攻击代码会使用系统调用启动一个shell程序,给攻击者提供一组操作系统函数。在另一种攻击形式中,攻击代码会执行一些未授权的任务,修复对栈的破坏,然后第二次执行ret指令,(表面上)正常返回到调用者。

对抗缓冲区溢出攻击

缓冲区溢出攻击的普遍发生给计算机系统造成了许多的麻烦。现代的编译器和操作系统实现了很多机制,以避免遭受这样的攻击,限制入侵者通过缓冲区溢出攻击获得系统控制的方式。

栈随机化

为了在系统中插入攻击代码,攻击者既要插入代码,也要插入指向这段代码的指针,这个指针也是攻击字符串的一部分。产生这个指针需要知道这个字符串放置的栈地址。在过去,程序的栈地址非常容易预测。对于所有运行同样程序和操作系统版本的系统来说,在不同的机器之间,栈的位置是相当固定的。因此,如果攻击者可以确定一个常见的web服务器所使用的栈空间,就可以设计一个在许多机器上都能实施的攻击。许多系统都容易受到同一种病毒的攻击,这种现象常被称作安全单一化(security monoculture)。

栈随机化的思想使得栈的位置在程序每次运行时都有变化。因此,即使许多机器都运行同样的代码,它们的栈地址都是不同的。

实现的方式是:程序开始时,在栈上分配一段0-n字节之间的随机大小的空间,例如,使用分配函数alloca在栈上分配指定字节数量的空间。程序不使用这段空间,但是它会导致程序每次执行时后续的栈位置发生了变化。分配的范围n必须足够大,才能获得足够多的栈地址变化,但是又要足够小,不至于浪费程序太多的空间。

下面的代码是一种确定“典型的“栈地址的方法:

1
2
3
4
5
int main() {
long local;
printf("local at %p\n",&local);
return 0;
}

这段代码只是简单的打印出main函数中局部变量的地址。在32位Linux上运行这段代码10000次,这个地址的变化范围为0xff7fc59c到0xffffd09c,范围大小大约是2^23。在64位Linux上运行,这个地址的变化范围为0x7fff0001b698到0x7ffffffaa4a8,范围大小大约是2^32。

在Linux系统中,栈随机化已经变成了标准行为。它是更大的一类技术中的一种,这类技术称为**地址空间布局随机化(Address-Space Layout Randomization),或者简称ASLR。采用ASLR,每次运行时程序的不同部分,包括程序代码、库代码、栈、全局变量和堆数据,都会被加载到内存的不同区域。这就意味着在一台机器上运行一个程序,与在其他机器上运行同样的程序,它们的地址映射大相径庭。这样才能够对抗一些形式的攻击。

然而,一个执着的攻击者总是能够用蛮力克服随机化,他可以反复的用不同的地址进行攻击。一种常见的方式是在实际的攻击代码前插入很长一段的nop指令。执行这种指令除了对程序计数器加一,使之指向下一条指令之外,没有任何效果。只要攻击者能够猜中这段序列中的某个地址,程序就会经过这个序列,到达攻击代码。这个序列常用的术语是“空操作雪橇(nop sled)”,意思是程序会“滑过”这个序列。如果我们建立一个256字节的nop sled,那么枚举2^15=32768个起始地址,就能破解n = 2^23的随机化,这对于一个顽固的攻击者来说,是完全可行的。对于64位的情况,要尝试枚举2^24=16777216就有点令人畏惧了。我们可以看到栈随机化和其他一些ASLR技术能够增加成功攻击一个系统的难度,因而大大降低了病毒的传播速度,但也不能提供完全的安全保障。

栈破坏检测

计算机的第二道防线是能够检测到何时栈已经被破坏。我们在echo函数示例中看到,破坏通常发生在当超越局部缓冲区的边界时。在C语言中,没有可靠的方法来防止对数组的越界写。但是,我们能够在发生了越界写的时候,在造成任何有害结果之前,尝试检测到它。

GCC在产生的代码中加入了一种**栈保护者(stack protector)**机制,来检测缓冲区越界。其思想是在栈帧中任何局部缓冲区与栈状态之间存储一个特殊的金丝雀(canary)值。这个金丝雀值,也称为哨兵值(guard value),是在程序每次运行时随机产生的,因此,攻击者没有简单的办法能够知道它是什么。在恢复寄存器状态和从函数返回之前,程序检查这个金丝雀值是否被该函数的某个操作或者该函数调用的某个函数的某个操作改变了。如果是的,那么程序异常中止。

GCC会试着确定一个函数是否容易遭受栈溢出攻击,并且自动插入这种溢出检测。实际上,对于前面的栈溢出展示,我们不得不用命令行选项“-fno-stack-protector”来阻止GCC产生这种代码。当不用这个选项来编译echo时,也就是允许使用栈保护者,得到下面的汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
1  echo:
2 subq $24, %rsp ; allocate 24 bytes on stack
3 movq %fs:40, %rax ; retrieve canary
4 movq %rax, 8(%rsp) ; store on stack
5 xorl %eax, %eax ; zero out register
6 movq %rsp, %rdi ; compute buf as %rsp
7 call gets ; call gets
8 movq %rsp, %rdi ; compute buf as %rsp
9 call puts ; call puts
10 movq 8(%rsp), %rax ; retrieve canary
11 xorq %fs:40, %rax ; compare to stored value
12 je .L9 ; if =, goto ok
13 call __stack_chk_fail ; stack corrupted
14 .L9:
15 addq $24, %rsp ; deallocate stack space
16 ret

函数从内存中读出一个值(汇编第三行),再把它存放在栈中相对于%rsp偏移量为8的地方。

指令参数%fs:40 指明金丝雀值是用段寻址(segmented addressing)从内存中读入的,段寻址机制可以追溯到80286的寻址,而现代系统上运行的程序中已经很少见到了。

将金丝雀值存放在一个特殊的段中,标志为只读,这样攻击者就不能覆盖存储的金丝雀值。在恢复寄存器状态和返回前,函数将存储在栈位置的值与金丝雀值做比较(汇编第十一行)。如果两个数相同,xorq指令就会得到0,函数会按照正常的方式完成。非零的值表明栈上的金丝雀值被修改过,那么代码就会调用一个错误处理函数。

栈保护很好的防止了缓冲区溢出攻击破坏存储在程序栈上的状态。它只会带来很小的性能损失,特别是因为GCC只在函数中有局部char类型缓冲区的时候才插入这样的代码。当然,也有其他一些方法会破坏一个正在执行的程序的状态,但是降低栈的易受攻击性能够对抗许多常见的攻击策略。

限制可执行代码区域

最后一招是消除攻击者向系统中插入可执行代码的能力。

一种方法是限制哪些内存区域能够存放可执行代码。在典型的程序中,只有保存编译器产生的代码的那部分内存才需要是可执行的。其他部分可以被限制为只允许读和写。虚拟内存空间在逻辑上被分成了页(page),典型的每页是2048或者4096个字节。硬件支持多种形式的内存保护,能够指明用户程序和操作系统内核所允许的访问形式。
许多系统允许控制三种访问形式:读(从内存读数据)、写(存储数据到内存)和执行(将内存的内容看作机器级代码)。以前,x86体系结构将读和执行访问控制合并成一个1位的标志,这样任何被标记为可读的页也都是可执行的。栈必须是既可读又可写的,因而栈上的字节也都是可执行的。已经实现的很多机制,能够限制一些页是可读但不可执行的,然而这些机制通常会带来严重的性能损失。

AMD为它的64位处理器的内存保护引入了“NX”(No-Execute,不执行)位,将读和执行访问模式分开,Intel也跟进了。有了这个特性,栈可以被标记为可读可写,但是不可执行,而检查页是否可执行由硬件来完成,效率上没有损失。

有些类型的程序要求动态产生和执行代码的能力。例如,“即时(just-in-time)”编译技术为解释语言编写的程序动态的产生代码,以提高执行性能。是否能够将可执行代码限制在由编译器在创建原始程序产生的那个部分中,取决于语言和操作系统。

我们上面总结的这些技术——随机化、栈保护和限制哪部分内存可以存储可执行代码——是用于最小化程序缓冲区溢出攻击漏洞三种最常见的机制。它们都具有这样的属性,既不需要程序员做任何特殊的努力,带来的性能代价都非常小,甚至没有。单独每一种机制都降低了漏洞的等级,而组合起来,它们变得更加有效。