# 3.10.4 对抗缓冲区溢出攻击

缓冲区溢出攻击的普遍发生给计算机系统造成了许多的麻烦。现代的编译器和操作系统实现了很多机制,以避免遭受这样的攻击,限制入侵者通过缓冲区溢出攻击获得系统控制的方式。在本节中,我们会介绍一些 Linux 上最新 GCC 版本所提供的机制。

# 1 栈随机化

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

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

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

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

这段代码只是简单地打印出 main 函数中局部变量的地址。在 32 位 Linux 上运行这段代码 10 000 次,这个地址的变化范围为 0xff7fc59c0xffffd09c,范围大小大约是 223。在更新一点儿的机器上运行 64 位 Linux,这个地址的变化范围为 0x7fff0001b6980x7ffffffaa4a8,范围大小大约是 232

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

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

练习题 3.47 在运行 Linux 版本 2.6.16 的机器上运行栈检查代码 10 000 次,我们获得地址的范围从最小的 0xffffb754 到最大的 0xffffd754

A. 地址的大概范围是多大?

B. 如果我们尝试一个有 128 字节 nop sled 的缓冲区溢出,要想穷尽所有的起始地址,需要尝试多少次?

# 2 栈破坏检测

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

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

1 术语“金丝雀”源于历史上用这种鸟在煤矿中察觉有毒的气体。

图 3-42 echo 函数具有栈保护者的栈组织

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

void echo()

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:                         ok:
15     addq $24, %rsp           Deallocate stack space
16     ret

这个版本的函数从内存中读出一个值(第 3 行),再把它存放在栈中相对于 %rsp 偏移量为 8 的地方。指令参数 %fs:40 指明金丝雀值是用段寻址(segmented addressing)从内存中读入的,段寻址机制可以追溯到 80286 的寻址,而在现代系统上运行的程序中已经很少见到了。将金丝雀值存放在一个特殊的段中,标志为“只读”,这样攻击者就不能覆盖存储的金丝雀值。在恢复寄存器状态和返回前,函数将存储在栈位置处的值与金丝雀值做比较(通过第 11 行的 xorq 指令)。如果两个数相同,xorq 指令就会得到 0,函数会按照正常的方式完成。非零的值表明栈上的金丝雀值被修改过,那么代码就会调用一个错误处理例程。

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

练习题 3.48 函数 intlenleniptoa 提供了一种很纠结的方式,来计算表示一个整数所需要的十进制数字的个数。我们利用它来研究 GCC 栈保护者措施的一些情况。

int len(char *s) {
    return strlen(s);
}

void iptoa(char *s, long *p) {
    long val = *p;
    sprintf(s, "%ld", val);
}

int intlen(long x) {
    long v;
    char buf[12];
    v = x;
    iptoa(buf, &v);
    return len(buf);
}

下面是 intlen 的部分代码,分别由带和不带栈保护者编译:

int intlen(long x)
x in %rdi

1  intlen:
2      subq $40, %rsp
3      movq %rdi, 24(%rsp)
4      leaq 24(%rsp), %rsi
5      movq %rsp, %rdi
6      call iptoa

a)不带保护者

int intlen(long x)
x in %rdi

1  intlen:
2      subq $56, %rsp
3      movq %fs:40, %rax
4      movq %rax, 40(%rsp)
5      xorl %eax, %eax
6      movq %rdi, 8(%rsp)
7      leaq 8(%rsp), %rsi
8      leaq 16(%rsp), %rdi
9      call iptoa

b)带保护者

A. 对于两个版本:bufv 和金丝雀值(如果有的话)分别在栈帧中的什么位置?

B. 在有保护的代码中,对局部变量重新排列如何提供更好的安全性来对抗缓冲区越界攻击?

# 3 限制可执行代码区域

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

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

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

我们讲到的这些技术——随机化、栈保护和限制哪部分内存可以存储可执行代码——是用于最小化程序缓冲区溢出攻击漏洞三种最常见的机制。它们都具有这样的属性,即不需要程序员做任何特殊的努力,带来的性能代价都非常小,甚至没有。单独每一种机制都降低了漏洞的等级,而组合起来,它们变得更加有效。不幸的是,仍然有方法能够攻击计算机 [85, 97],因而蠕虫和病毒继续危害着许多机器的完整性。