计算机系统使用了多种不同形式的抽象,利用更简单的抽象模型来隐藏实现的细节。
对于机器级编程来说,其中两种抽象尤为重要:
1、指令集体系结构(Instruction set architecture ISA)
它定义了处理器状态、指令的格式,以及每条指令对状态的影响。
IA32将程序的行为描述成好像每条指令时按顺序执行的,一条指令结束后,下一条再开始。(实际上处理器并发地执行许多指令,但是可以采取措施保证整体行为与ISA指定的顺序执行完全一致)
2、机器级程序使用的存储器地址是虚拟地址
提供的存储器模型看上去是一个非常大的字节数组。存储器系统的实际实现是将多个硬件存储器和操作系统软件组合起来。
程序存储器(program memory)包含:程序的可执行机器代码、操作系统需要的一些信息、栈、堆。程序存储器用虚拟地址来寻址(此虚拟地址不是机器级虚拟地址)。操作系统负责管理虚拟地址空间(程序级虚拟地址),将虚拟地址翻译成实际处理器存储器中的物理地址(机器级虚拟地址)。
IA32程序用程序栈来支持过程调用。机器用栈来传递过程参数、存储返回信息、保存寄存器用于以后恢复,以及本地存储。
栈向低地址方向增长,而栈指针%esp指向栈顶元素。
程序寄存器组是唯一能被所有函数共享的资源。
虽然在给定时刻只能有一个函数是活动的,但是我们必须保证当一个函数调用另一个函数时,被调用者不会覆盖某个调用者稍后会用到的值。为此,IA32采用了一组统一的寄存器使用规则,所有的函数都必须遵守,包括程序库中的函数。
根据惯例:寄存器%eax、%edx、%ecx被划分为调用者保存寄存器。当过程P调用Q时,Q可以覆盖这些寄存器,不会破坏任何P所需要的数据。
另一方面,寄存器%ebx、%esi、%edi被划分为被调用者寄存器。这意味着Q必须在覆盖这些寄存器的值之前,先把它们保存到栈中,并在返回前恢复它们。此外还必须保持寄存器%ebp和%esp。
call Label 过程调用
call *Operand 过程调用
leave 为返回准备栈
ret 从过程调用中返回
call指令的效果是将返回地址入栈,并跳转到被调用过程的起始处。(返回地址是在程序正文中紧跟在call后面的那条指令的地址,这样当被调用过程返回时,执行流会从此处继续。)
ret指令从栈中弹出地址,并跳转到这个位置。(使用这条指令前,要使栈做好准备,栈顶指针要指向前面call指令存储返回地址的位置)
leave指令 使栈做好返回的准备。它等价于:
movl %ebp, %esp ; 把寄存器%ebp中的值复制到寄存器%esp中(回收本函数的栈空间)
popl %ebp
leave指令的使用在返回前,既重置了栈指针,也重置了基址指针。
【示例】
int
swap_add(int* xp, int yp)
{
int x = *xp ;
int y = *yp ;
*xp = y ;
*yp = x ;
return x+y ;
}
int
caller()
{
int arg1 = 534 ;
int arg2 = 1057 ;
int sum = swap_add(&arg1, &arg2) ;
int diff = arg1 - arg2 ;
return sum*diff ;
}
函数caller的汇编代码:
_caller:
pushl %ebp ;保存基址指针
movl %esp, %ebp ;把基址指针设置为当前的栈顶(意味着call函数栈的开始)
subl $24, %esp ;分配24个字节作为栈空间
movl $534, -12(%ebp) ;在距离基址12字节处 作为参数arg1的空间(此间有8字节的空档)
movl $1057, -16(%ebp) ;在距离基址16字节处 作为参数arg2的空间
leal -16(%ebp), %eax ;
movl %eax, 4(%esp) ;把参数2的地址值放到离栈顶指针的4字节处
leal -12(%ebp), %eax
movl %eax, (%esp) ;把参数1的地址值放到栈顶指针处
call _swap_add ;调用函数swap_add(把当前程序正文地址压栈,跳转到函数swap_add地址处)
movl %eax, -8(%ebp) ;把返回值%eax放到距离基址8字节处 作为参数sum的空间
movl -12(%ebp), %edx
movl -16(%ebp), %eax
movl %edx, %ecx
subl %eax, %ecx ;arg1 - arg2
movl %ecx, %eax
movl %eax, -4(%ebp) ;把arg1 - arg2的结果值放到距离基址4字节处 作为参数diff的空间
movl -8(%ebp), %eax
imull -4(%ebp), %eax ;sum*diff
leave
ret
//从以上代码中可看出,函数的返回值是放在寄存器%eax中的。
//gcc分配了从不使用的空间
GCC为caller参数的代码在栈上分配了24个字节,但是只使用了其中的16个。因为,GCC坚持一个X86编程指导方针,也就是一个函数使用的所有栈空间必须是16字节的整数倍。
包括保存%ebp值的4字节和返回值的4字节,caller一共使用了32字节。采用这个规则是为了保证访问数据的严格对齐(alignment)。
在返回前,函数必须将栈恢复到原始条件,可以恢复所有的被调用者保存寄存器(手动) 和%ebp,并且重置%esp使其指向返回地址。(由leave指令完成)
然后,ret指令把返回地址弹出到pc寄存器,CPU继续从原调用处开始执行。
通常,在栈中分配某个字节数组来保存一个字符串,但是字符串的长度超出了为数组分配的空间。C对于数组引用不进行任何边界检查,而且局部变量和状态信息,都存在栈中。这样,对越界的数组元素的写操作会破坏存储在栈中的状态信息。当程序使用这个被破坏的状态,试图重新加载寄存器或执行ret指令时,就会出现很严重的错误。
【示例】
void echo()
{
char buf[8] ;
gets(buf) ;
puts(buf) ;
}
由于栈是向地地址增长的,数组缓冲区是向高地址增长的。故,长一些的字符串会导致gets覆盖栈上存储的某些信息。
随着字符串变长,下面的信息会被破坏:
输入的字符数量 被破坏的状态
0---7 无
8---11 保存的%ebx的值
12---15 保存的%ebp的值
16---19 返回地址
20+ caller中保存的状态
如果破坏了存储%ebp的值,那么基址寄存器就不能正确地恢复,因此调用者就不能正确地引用它的局部变量或参数。
如果破坏了存储的返回地址,那么ret指令会使程序跳转到完全意想不到的地方。
缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数。这是一种最常见的通过计算机网络攻击系统安全的方法。通常,输入给程序一个字符串,这个字符串包含一些可执行代码的字节编码,称为攻击代码,另外还有一些字节会用一个指向攻击代码的指针覆盖返回地址。那么,执行ret指令的效果就是跳转到攻击代码。
通常,使用gets或其他任何能导致存储溢出的函数,都不是好的编程习惯。不幸的是,很多常用库函数,包括strcpy、strcat、sprintf,都有一个属性——不需要告诉它们目标缓冲区的大小,就产生一个字节序列。
1、栈随机化
为了在系统中插入攻击代码,攻击者不但要插入代码,还要插入指向这段代码的指针,这个指针也是攻击字符串的一部分。产生这个指针需要知道这个字符串放置的栈地址。在过去,程序的栈地址非常容易预测,在不同的机器之间,栈的位置是相当固定的。
栈随机化的思想使得栈的位置在程序每次运行时都有变化。因此,即使许多机器都运行相同的代码。它们的栈地址都是不同的。
实现的方式是:程序开始时,在栈上分配一段0--n字节之间的随机大小空间。程序不使用这段空间,但是它会导致程序每次执行时后续的栈位置发生了变化。
在Linux系统中,栈随机化已经变成了标准行为。(在linux上每次运行相同的程序,其同一局部变量的地址都不相同)
2、栈破坏检测
在C语言中,没有可靠的方法来防止对数组的越界写,但是,我们能够在发生了越界写的时候,在没有造成任何有害结果之前,尝试检测到它。
最近的GCC版本在产生的代码中加入了一种栈保护者机制,用来检测缓冲区越界,其思想是在栈中任何局部缓冲区与栈状态之间存储一个特殊的金丝雀值。这个金丝雀值是在程序每次运行时随机产生的,因此,攻击者没有简单的办法知道它是什么。
在恢复寄存器状态和从函数返回之前,程序检查这个金丝雀值是否被该函数的某个操作或者函数调用的某个操作改变了。如果是,那么程序异常终止。
3、限制可执行代码区域
限制那些能够存放可执行代码的存储器区域。在典型的程序中,只有保存编译器产生的代码的那部分存储器才需要是可执行的,其他部分可以被限制为只允许读和写。
现在的64位处理器的内存保护引入了”NX”(不执行)位。有了这个特性,栈可以被标记为可读和可写,但是不可执行,检查页是否可执行由硬件来完成,效率上没有损失。