机器级代码

计算机系统使用了多种不同形式的抽象,利用更简单的抽象模型来隐藏实现的细节。

对于机器级编程来说,其中两种抽象尤为重要:

  1. 指令集体系结构(Instruction set architecture ISA)

它定义了处理器状态、指令的格式,以及每条指令对状态的影响。

IA32将程序的行为描述成好像每条指令时按顺序执行的,一条指令结束后,下一条再开始。(实际上处理器并发地执行许多指令,但是可以采取措施保证整体行为与ISA指定的顺序执行完全一致)

  1. 机器级程序使用的存储器地址是虚拟地址

提供的存储器模型看上去是一个非常大的字节数组。存储器系统的实际实现是将多个硬件存储器和操作系统软件组合起来。

程序存储器(program memory)包含:程序的可执行机器代码、操作系统需要的一些信息、栈、堆。程序存储器用虚拟地址来寻址(此虚拟地址不是机器级虚拟地址)。操作系统负责管理虚拟地址空间(程序级虚拟地址),将虚拟地址翻译成实际处理器存储器中的物理地址(机器级虚拟地址)。

寄存器使用惯例

程序寄存器组是唯一能被所有函数共享的资源。

虽然在给定时刻只能有一个函数是活动的,但是我们必须保证当一个函数调用另一个函数时,被调用者不会覆盖某个调用者稍后会用到的值。为此,IA32采用了一组统一的寄存器使用规则,所有的函数都必须遵守,包括程序库中的函数。

根据惯例:寄存器%eax、%edx、%ecx被划分为调用者保存寄存器。当过程P调用Q时,Q可以覆盖这些寄存器,不会破坏任何P所需要的数据。

另一方面,寄存器%ebx、%esi、%edi被划分为被调用者寄存器。这意味着Q必须在覆盖这些寄存器的值之前,先把它们保存到栈中,并在返回前恢复它们。此外还必须保持寄存器%ebp和%esp。

控制

条件码

除了整数寄存器,CPU还维护着一组单个位的条件码(condition code)寄存器,它们描述了最近的算术或逻辑操作的属性。可以检测这些寄存器来执行条件分支指令。最常用的条件码有:

  • CF :进位标志。最近的操作使最高位产生了进位。可以用来检查无符号操作的溢出。
  • ZF :零标志。最近操作得出的结果为0。
  • SF :符号标志。最近的操作得到的结果为负数。
  • OF :溢出标志。最近的操作导致一个补码溢出——正溢出或者负溢出。

leaq 指令不改变任何条件码,因为它是用来进行地址计算的。

有两类指令(有8、16、32和64位形式),它们只设置条件码而不改变任何其他寄存器。

  • CMP 指令根据两个操作数之差来设置条件码。除了只设置条件码而不更新目的寄存器之外, CMP 指令与 SUB 指令的行为是一样的。
  • TEST 指令的行为与 AND 指令一样,除了它们指设置条件码而不改变目的寄存器的值。

条件控制和条件传送

条件控制:当条件满足时,程序沿着一条执行路径执行,而当条件不满足时,就走另一条路径。这种机制比较简单,但在现代处理器上可能会非常低效

条件传送:这种方法计算一个条件操作的两种结果,然后再根据条件是否满足,从中选取一个。只有在一些受限制的情况中,这种策略才可行,但是如果可行,就可以用一条简单的条件传送指令来实现的条件传送指令更符合现代处理器的性能特性(契合流水线操作)。

过程

运行时栈

  • 机器用栈来传递参数、存储返回信息、保存寄存器用于以后恢复以及本地存储等。

  • 为单个过程分配的那部分栈称为栈帧(stack frame)。

  • 栈帧以两个指针界定,寄存器%ebp为帧指针,寄存器%esp为栈指针,当程序执行时,栈指针可以移动,因此大多数信息的访问都是相对于帧指针(即帧指针为当前栈帧的固定起点)的。

    image-20210723155508051

call 指令和 ret 指令

  • call指令的效果是将返回地址入栈,并跳转到被调用过程的起始处。(返回地址是在程序正文中紧跟在call后面的那条指令的地址,这样当被调用过程返回时,执行流会从此处继续。)

  • ret指令从栈中弹出地址,并跳转到这个位置。(使用这条指令前,要使栈做好准备,栈顶指针要指向前面call指令存储返回地址的位置)

缓冲区溢出

通常,在栈中分配某个字节数组来保存一个字符串,但是字符串的长度超出了为数组分配的空间。C对于数组引用不进行任何边界检查,而且局部变量和状态信息,都存在栈中。这样,对越界的数组元素的写操作会破坏存储在栈中的状态信息。当程序使用这个被破坏的状态,试图重新加载寄存器或执行ret指令时,就会出现很严重的错误。

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

通常,使用gets或其他任何能导致存储溢出的函数,都不是好的编程习惯。不幸的是,很多常用库函数,包括strcpy、strcat、sprintf,都有一个属性——不需要告诉它们目标缓冲区的大小,就产生一个字节序列。

对抗缓冲区溢出攻击

  1. 栈随机化

    为了在系统中插入攻击代码,攻击者不但要插入代码,还要插入指向这段代码的指针,这个指针也是攻击字符串的一部分。产生这个指针需要知道这个字符串放置的栈地址。在过去,程序的栈地址非常容易预测,在不同的机器之间,栈的位置是相当固定的。

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

    实现的方式是:程序开始时,在栈上分配一段0–n字节之间的随机大小空间。程序不使用这段空间,但是它会导致程序每次执行时后续的栈位置发生了变化。

    在Linux系统中,栈随机化已经变成了标准行为。(在linux上每次运行相同的程序,其同一局部变量的地址都不相同)

  2. 栈破坏检测

    在C语言中,没有可靠的方法来防止对数组的越界写,但是,我们能够在发生了越界写的时候,在没有造成任何有害结果之前,尝试检测到它。

    最近的GCC版本在产生的代码中加入了一种栈保护者机制,用来检测缓冲区越界,其思想是在栈中任何局部缓冲区与栈状态之间存储一个特殊的金丝雀值。这个金丝雀值是在程序每次运行时随机产生的,因此,攻击者没有简单的办法知道它是什么。

    在恢复寄存器状态和从函数返回之前,程序检查这个金丝雀值是否被该函数的某个操作或者函数调用的某个操作改变了。如果是,那么程序异常终止。

  3. 限制可执行代码区域

    限制那些能够存放可执行代码的存储器区域。在典型的程序中,只有保存编译器产生的代码的那部分存储器才需要是可执行的,其他部分可以被限制为只允许读和写。

    现在的64位处理器的内存保护引入了”NX”(不执行)位。有了这个特性,栈可以被标记为可读和可写,但是不可执行,检查页是否可执行由硬件来完成,效率上没有损失。