C语言对于数组引用不进行任何边界检查,而且局部变量和状态信息都放在栈中。这两种情况结合到一起就能导致严重的程序错误,比如对越界的数组元素的写操作。
缓冲区溢出
一种常见的状态破坏被称为缓冲区溢出。一般在栈中分配一个字符数组来保存一个字符串,但是字符串的长度超出了为数组分配的空间。就像下面这样:
1 | // Implementation of library function gets() |
这是库函数gets
的一个实现。它从标准输入读入一行,在遇到一个回车换行字符或者某个错误情况时停止。然后把这个字符串复制到参数s指定的位置,并在结尾加上null字符。echo
调用了这个函数然后送回到标准输出。
这个函数的问题是它没有办法确定字符串是否有足够的空间,我们将buf
设置的比较小,这样任何长度超过7的字符串都会导致越界。
检查gcc为echo产生的汇编代码:
1 | echo: |
该程序在栈上分配了24个字节。buf位于栈顶,然后把%rsp复制到%rdi作为调用gets和puts的参数。其中有16个字节未被使用。只要输入的字符串超过7个字节,过长的字符串就会覆盖栈上存储的某些信息。
输入的字符串数量 | 附加的被破坏的状态 |
---|---|
0-7 | 无 |
9-23 | 未被使用的栈空间 |
24-31 | 返回地址 |
32+ | caller中保存的状态 |
在23个字符之前都没有严重的后果,但是超过以后就会破坏存储的返回位置,ret指令会导致程序跳转到一个完全意想不到的位置。
缓冲区溢出的更加致命的问题就是会让程序执行它本来不会执行的代码。这是一种最常见的通过计算机网络攻击系统安全的方法。通常,输入一个字符串,这个字符串包含一些可执行的字节编码,称为攻击代码,还有一些字节会用一个指向攻击代码的指针覆盖返回地址。那么,执行ret的效果就是跳转到攻击代码。
常见的攻击形式有两种:
- 使用系统调用启动一个shell程序给攻击者提供操作系统函数
- 执行一些未授权的任务,修复对栈的破坏,然后第二次执行ret指令,正常返回给调用者
对抗缓冲区溢出攻击
现代编译器和操作系统实现了很多机制,以避免遭受这样的攻击。
栈随机化
为了在系统中插入攻击代码,攻击者既要插入代码,也要插入指向这段代码的指针,这个指针也是攻击字符串的一部分。产生这个指针需要知道这个字符串放置的栈地址。在以前,程序的栈地址非常容易预测。对于所有运行同样程序和操作系统版本的系统来说,在不同的机器之间,栈的位置是相当固定的。因此攻击者可以确定一个常见的Web服务器所使用的栈空间,就可以设计一个在许多机器上都能实施的攻击。这种现象被称作安全单一化。
栈随机化的思想使得栈的位置在程序每次运行时都会有变化,这样在不同机器上执行同样的代码,他们的栈地址都是不同的。实现的方式是:程序开始时,在栈上分配一个0-n字节之间的随机大小的空间,程序不使用这段空间,但是它会导致每次执行后续的栈位置发生了变化。可以通过下面的代码确定栈的地址。
1 | int main() { |
在64位的操作系统上的范围大约是$2^{32}$。
在linux系统中,这类技术乘坐**地址空间布局随机化(ASLR)**,采用ASLR,每次运行时程序的不同部分,包括程序代码,库代码,栈,全局变量和堆数据,都会被加载到内存的不同区域。
但是这种技术也有漏洞,攻击者可以在攻击代码前插入很长的一段
nop
。这个指令唯一作用就是让PC加一,指向下一条指令。只要攻击者能够猜中这段序列的某个地址,程序就会“滑过”这个序列。
栈破坏检测
在最新的GCC中加入了一种**栈保护者(stack protector)机制来检测缓冲区越界。其思想时在栈帧中任何布局缓冲区和栈状态之家存储一个特殊的金丝雀(canary)值,也称作哨兵值(guard value)**,在程序每次运行时随机产生,因此攻击者没有简单的办法能知道它时什么。在恢复寄存器状态和从函数返回之前,会检查这个值是否被修改。如果是,那么程序异常中止。
可以通过
-fno-stack-protector
选项来阻止GCC产生这种代码。
栈保护很好地防止了缓冲区溢出攻击破坏存储在程序栈上的状态。它只会带来很小的性能损失,特别是GCC只在函数中有局部char类型缓冲区的时候才插入这样的代码。
限制可执行代码区域
典型的程序中,只有保存编译器产生的代码的那部分内存才需要是可执行的。其他部分被限制为只允许读和写。
有些类型的程序要求动态产生和执行代码的能力,比如使用JIT的Java动态的产生代码,以提高执行性能。是否能够将可执行代码限制在由编译器在创建原始程序时产生的那个部分中,取决于语言和操作系统。