X86保护机制
阅读原文时间:2023年07月09日阅读:2

目录

在x86体系结构中,段的保护机制在CPU进入保护模式是自动开启,没有相应的关闭机制;页的保护机制在开启分页内存管理后自动开启,没有相应的关闭机制。如果需要关闭段、页的保护机制,可以通过将段、页的访问特权降到最低实现。本文不涉及页机制下的保护机制。

CPU使用选择子获取段描述符,然后再使用段描述符访问内存。在使用选择子访问描述符时,必须保证选择子指向的段描述符在相应的描述符表中,因此要进行选择子和描述符表限长检查。

  • 全局结构:GDT和IDT

在GDTR和IDTR中有16位存放表限长,在使用选择子访问GDT或IDT时将选择子中记录的描述符位置(描述符表中的下标)和表限长比较即可。

  • 局部结构:LDT和TSS

    在LDTR和TR的不可见部分(shadow part)存储着对应LDT和TSS的表限长,在访问LDT或TSS时选择子描述符位置(描述符表中的下标)和表限长比较。

在IA-32e模式下仍然会进行描述符表限长的检查。

在获取到段描述符后生成线性地址时,还必须保证线性地址在被访问的段内,所以必须进行偏移地址和段限长的检查。

对于非向下拓展(expand down)的段,段限长就代表了程序能够访问的最高有效地址。如果段中G flag为0(段限长以字节为单位,段最长为1MB),那个20比特的段限长就是段中的最大偏移,所有的偏移都应该小于等于段限长;如果段中G flag为1(段限长以4KB为单位,段最长为4G),还要注意在计算时段限长会被拓展(scalling),类似与C语言中数组元素地址的计算。比如,G flag为1,段限长为0,此时段的最大长度为4KB,偏移必须在[0,4KB-1]中。

对于向下拓展的数据段是专门为堆栈准备的,因为堆栈从高地址到低地址增长,段限长有着不同的机制,但在intel手册·[1]中我没有读到详细的介绍,网上我只找到了How to Use Expand Down Segments on Intel 386 and Later CPUs,但是没有读懂。如果有人读懂了,欢迎指教。

在IA-32e模式下,32位兼容模式会进行段限长的检查,64位模式不进行段限长的检查(毕竟根本就不使用段)。

在对段和段选择子进行操作时,还可能会发生对段的类型信息的检查。这里的类型信息不仅仅指段的类别还包括读写等权限。

类型信息的存储

在段描述符中类型信息存储在两个地方

  • S标志位:为0表示描述符指向的段是系统段(TSS、LDT),为1表示描述符指向的段是应用段(数据段、代码段)。
  • TYPE域:设置段的特性,比如读写权限。

类型检查

对段的类型的检查在以下情形中发生(Intel手册并没有完全列举):

  • 当选择子被加载到段寄存器中时,特定的寄存器只能存放指向特定类型的段描述符的选择子

  • 当段选择子被加载到LDTR或者TR中时:LDTR只能存放指向LDT的选择子,TR只能存放指向TSS的选择子。

  • 当指令访问相应的段时:指令不能向代码段写入数据,不能向只读的数据段写数据。

  • 当指令以选择子作为运算对象时:选择子必须和指令匹配,比如far JMP不应该以代码段选择子为对象。

  • 某些内部操作中:比如,通过TSS执行任务切换时,会检查TSS指向的对应的段。

空选择子的检查

有时选择子可能是空的(相当与C语言中的NULL),在加载空选择子到CS或SS中会产生#GP异常,加载到DS、ES、FS、GS中不产生异常,但在访问时会导致#GP异常。

在IA-32e的64位模式中不使用段寄存器选择描述符,因此不进行空段选择子的检查。

在X86中,处理器有4个特权级(level 0到level 3),特权级数值越小级别越高。通常,系统仅使用两个特权级,操作系统处于level 0, 应用程序处于level 3。从程序的角度看这就是内核态用户态

由于特权级的数值和级别恰好相反,为了避免混淆,在本文中使用高低一词时特指级别,使用大小一词时特指数值。

在通过段或门访问内存时,会比较当前程序/任务的特权级和要访问的内存中的代码/数据特权级(可以类比UNIX的用户权限)。

X86中的特权级实际上指的是CPL(Current privilege level)、DPL(descriptor privilege level)和RPL(requested privilege level)。

CPL代表当前执行的程序/任务的特权级。CPL存储在CS[2]中(第0、1位)。通常CPL等于正在执行的指令所在的代码段的特权级。当程序的控制传送到不同特权级的非一致(non-conforming)代码段时,CPL也会被相应地修改。但是当控制传送到一致(conforming)代码段时,CPL不会修改。

DPL代表段或门的特权级,存储在相应的描述符中。当正在执行的程序试图访问段或门时,DPL就会被拿来和CPL和RPL比较。对于不同的门和段,DPL有着不同的含义:

  • 数据段:DPL代表能够访问该段的最低特权级。比如,某个数据段的特权级是1,那么就只有在特权级0/1的程序可以访问这个段。
  • 非一致代码段(不通过调用门):DPL代表能够访问该段的程序所处的特权级。比如,某个非一致代码段在特权级0,就只有特权级0的程序可以访问它。
  • 一致代码段(不通过调用门):DPL代表能够访问该段的最高特权级。
  • 调用门:DPL代表能够访问该门的最低特权级。
  • 一致或不一致代码段(通过调用门访问):DPL代表能够访问该代码段的最高特权级。比如,某个一致代码段在特权级2,在特权级0或1的程序不能访问该段。
  • TSS:DPL代表能够访问该TSS的最低特权级。

RPL存储在其他段寄存器的低0、1位,它和CPL搭配使用。处理器会检查CPL和RPL的值,来决定程序能否访问某个段。RPL的作用会在下文逐步介绍。

特权级检查发生在段选择子被加载到段寄存器中时。对于数据段的检查与程序控制转移涉及到的数据段不同,所以分开介绍。

访问数据段时的特权级检查

段选择子被加载到段寄存器时,处理器通过比较CPL(当前执行的程序的特权级)、RPL(段选择子的特权级)和DPL(数据段的特权级)来判断程序能否访问该段。CPL和RPL必须小于或等于对应的DPL,否则无法访问数据段,产生#GP异常。

这几个例子详细说明了访问数据段时的检查过程。

  • 代码段C的CPL和选择子E的RPL大于数据段E,所以代码段C中的程序不可以访问数据段E。
  • 代码段A、B的CPL、RPL均小于数据段E,所以代码段A、B中的程序可以访问数据段E。
  • 代码段D的CPL小于数据段E,但RPL大于数据段E,所以代码段D即使特权级更高也无法访问数据段E。

由于数据段寄存器是可以直接被用户修改的,RPL和CPL的“重写”机制避免了处于低特权级的程序访问高特权级的数据段。

访问代码段中的数据

有些情况下需要访问代码段中的数据结构,X86有以下几种可能的方法:

  1. 将非一致可读代码段的选择子加载到数据段寄存器中。
  2. 将一致可读代码段的选择子加载到数据段寄存器中。
  3. 使用_代码段前缀(code-segment override prefix)_CS来读取可读代码段。

三种方法都进行上文介绍的特权级检查。第一种方法可能无效(CPL为3,DPL为0,RPL为0,最终无法访问),第二、三种方法肯定成功。

堆栈寄存器SS的特权级检查

堆栈段是特殊的数据段,SS中的选择子特权级(RPL)必须同时和CPL、DPL相等。

在不同代码段之间进行程序控制转移时的特权级检查

X86可以通过JMP、CALL、RET、SYSENTER、SYSEXIT、SYSCALL、SYSRET、INT n和IRET指令进行控制的转移。X86多样的控制转移机制(有些还涉及到中断处理机制)增加了特权级检查的复杂度。这篇文章不介绍涉及中断的处理的控制转移机制。

直接调用或跳转到代码段(Dirct Calls or Jumps to Code Segments)

近转移形式的JMP、CALL、RET指令仅将程序的控制从现在的执行点传送到当前代码段的另一个执行点,因为不直接其他的代码段,所以不需要进行特权级检查。

当将程序的控制传送到另一个代码段(不通过调用门)时,处理器会检查以下四种信息:

  • CPL:当前执行程序的特权级
  • DPL:目标代码段的特权级
  • RPL:目标代码段选择子的特权级
  • C flag:目标代码段是非一致(C flag为0)的还是一致(C flag为1)的

处理器使用CPL、RPL和DPL判断能否访问代码段的规则取决于C flag的值,也就是说对于一致代码段和非一致代码段有不同的规则。

RPL在目标代码段的选择子中,这个选择子是JMP、CALL的运算对象而不是CS中的值。

访问非一致代码段

在访问非一致代码段时,CPL必须等于DPL,并且RPL必须小于等于CPL,否则产生 #GP异常。当选择子被加载到CS中时,不管RPL是多少都不会修改CPL

下图中的例子详细说明了控制转移到一致代码段时的特权级检查。

  • 代码段C是非一致的,因为A代码段CPL是2,等于C1和D1的RPL,并且等于代码段C的DPL,所以A代码段中的过程可以调用C中的过程。
  • 因为代码段B的CPL不等于代码段C的DPL,所以代码段B中的过程不能调用代码段C中的过程。

RPL在其中起的作用非常有限,主要还是看CPL和DPL。因为RPL只需要小于等于CPL就可以了,所以C1设置为0、1都可以让代码段A中的过程成功调用代码段C中的过程。因为控制转移到非一致代码段时CPL不改变,所以不能够试图通过选择子修改CPL。

访问一致代码段

在访问一致代码段时,调用过程的CPL必须大于等于目标代码段的DPL,否则产生#GP异常,RPL不被检查

一致代码段中的DPL代表能够访问该代码段的程序的最高特权级,只要特权级低于一致代码段就可以成功访问。

在上图中,代码段A、B的特权级都低于代码段D,所以A、B中的程序可以访问D中的代码。

当程序控制被传送到一致代码段,即使目标代码段的DPL小于CPL,CPL也不会改变。访问一致代码段是唯一一种可能导致当前代码段的DPL不等于CPL的情况(非一致代码段要求CPL和DPL相等)。因为没有发生CPL的切换,所以没有堆栈的切换。

一致代码段被用于支持应用程序但是却不需要访问受保护的资源的代码模块,比如数学函数库、异常处理例程。这些代码可能是内核的一部分,但是却允许低特权级的程序使用。在控制传送到更高特权级的一致代码段时保持CPL不变,避免了程序CPL提升到目标代码段DPL后去访问其他同特权级的非一致代码段的情况,阻止了低特权级的程序访问更多数据。

大部分代码段都是非一致的,毕竟只有一小部分情况下希望低特权级程序执行高特权级代码。对于一致代码段,只有同特权级的程序可以直接,不同特权级的程序只能通过调用门间接访问。

通过调用门访问代码段

调用门[3]用来实现不同特权级之间的控制传送,通常被操作系统或者其他使用特权级保护机制的特权程序中。

调用门描述符在GDT和LDT中,其他三种门描述符在IDT中。

IA-32架构下的调用门

IA-32[4]架构下的调用门描述符结构如下:

调用门描述符和其他门描述符结构基本相同。记录了目标代码段的选择子,目标过程在目标代码段中的偏移,调用门是否有效(是否在内存中),还记录了发生堆栈切换时需要从当前堆栈复制到目标堆栈上的参数个数。

参数个数对于16位调用门是word的个数,对于32位调用门是doubleword的个数。P flag标识的是调用门是否有效,而不是调用门指向的代码段是否有效。如果访问P flag为0的调用门,会产生#NP异常。

64位处理器(IA-32e mode)下的调用门

64位架构(IA-32e模式)下的调用门描述符结构如下:

为了容纳64位模式下的偏移,把原来IA-32架构中的调用门描述符大小翻倍;为了兼容32位程序(32位模式),保持了原先的结构。

在IA-32e模式下,已经没有了之前的32位的描述符,被重新定义成了64位。64位调用门引用的代码段也必须是64位的(CS.L=1,CS.D=0),否则产生#GP异常;把64位调用门描述符当成两个32位描述符会产生#GP异常。

在64位模式下访问调用门和32位模式下访问调用门没有多少区别,只是大小发生了改变,比如堆栈压入的寄存器变成64位。另外,在64位模式下不发生参数的复制。

通过调用门访问代码段的过程

通过调用门访问代码段的JMP和CALL格式和往常一样,但是给这两个指令提供的远指针(far pointer)中只有选择子被使用,偏移被忽略(也不进行检查)。选择子用来在GDT或IDT中选择调用门。

调用门的权限检查的过程复杂不少,涉及:

  • CPL
  • RPL
  • 调用门的DPL
  • 目标代码段的DPL
  • 目标代码段的C flag

​ 调用门实现的是低特权级到高特权级的访问,因此程序特权级应该小于等于目标代码段特权级,否则调用门就没有意义了;另一方面,为了访问调用门,CPL和RPL应该小于等于调用门的DPL。

使用JMP和CALL通过调用门访问代码段的具体规则如下:

CALL指令可以实现从低特权级代码段到高特权级的非一致代码段的控制转移,而JMP指令不允许这样的操作。访问非一致代码段时CPL改变(变为目标代码段DPL),访问一致代码段时CPL不改变。因此,从调用门访问非一致代码段时可能发生堆栈切换,访问一致代码段时不发生堆栈切换。

下面的例子说明通过调用门访问代码段时的特权级检查。

  • 代码段A的CPL大于调用门B的DPL,所以无法访问调用门B,自然也无法访问调用门指向的代码段。
  • 代码段A的CPL、选择子A的RPL都等于调用门A的DPL,所以代码段A中的过程可以访问调用门A;调用门指向的代码段E的DPL小于代码段A的CPL,所以可以使用CALL访问;但是因为CPL不等于E的DPL,不能够使用JMP访问。

堆栈切换

当程序通过调用门将控制传递到更高特权级的代码段时,处理器会自动将堆栈切换为目标特权级的堆栈。通过堆栈切换可以一定程度避免堆栈空间不足导致的程序崩溃和低特权级过程通过修改共享堆栈的内容来修改高特权级过程。

每个任务都应该有4个堆栈,分别对应特权级0 ~ 3,通常使用两个特权级的系统只需要为每个任务准备2个堆栈,分别对应特权级0和特权级3。虽然一个任务拥有多个对应相应特权级的堆栈,但是任意时刻仅使用一个堆栈(只有一个SS和SP)。正在使用的堆栈就是SS:SP指向的堆栈。所有的堆栈的指针都存储在任务对应的TSS中,并且在任务执行过程中不会修改。TSS中记录的堆栈指针仅在堆栈切换时用来创建堆栈,当从被调用过程返回时新创建的堆栈会被废弃,这就类似C语言中的局部变量,也许从函数返回后它还存在,但是我们不应该再使用它。

为各个特权级创建对应的堆栈空间(可能还有堆栈段)并且把堆栈指针保存在TSS中是操作系统的责任。堆栈必须要足够大,尤其是在操作系统支持嵌套中断(nested intetrupt)时,否则后果是致命的。

IA-32下的堆栈切换

当发生特权级切换时会进行堆栈切换,过程如下:

  1. 使用目标代码段的DPL(新CPL)从TSS中选择新堆栈(指针)。
  2. 读取指针,如果有任何发现任何段/表限长错误,产生#TS异常。
  3. 检查堆栈所在段的特权级和类型,出错就产生#TS异常。
  4. 暂存SS和ESP指针的当前值(当前堆栈)。
  5. 加载新堆栈指针到SS、ESP中。
  6. 将原来的SS、ESP压入新栈。
  7. 从原来的堆栈拷贝N个参数到新栈中。N是调用门中指定的参数个数。
  8. 将返回地址(CS、EIP)压入新栈。
  9. 从调用门中将目标地址的选择子和偏移加载到CS、EIP中。

参数个数在调用门中占的比特决定了当发生堆栈切换时最多只能拷贝31个参数,如果需要更多的参数可以通过拷贝一个指向更多参数的指针或者通过访问旧堆栈实现。

IA-32e模式下的堆栈切换

对于32位模式,堆栈切换的过程没有改变;64位模式下的堆栈切换有所不同。

在64位模式下发生堆栈切换时,新的SS(指向的)段描述符不被家在;64位模式只从TSS中加载新的RSP。新的SS被强制处理为NULL(为了处理嵌套远转移),SS选择子的RPL被强制设置为新的CPL。旧的SS和RSP被保存在新堆栈中。新的

64位模式下堆栈切换的布局[5]

在64位模式下压栈时是大小是8字节的,而不是32位时模式的4字节。64位模式不执行32位模式中的参数拷贝,调用门中保存的参数个数也被忽略。

64位模式中,在特定情形下far RET可以合法的加载NULL SS。如果目标模式是64位模式并且目标CPL不等于3,IRET可以允许SS中是空选择子。如果被调用过程自身被中断,空SS会被压栈。在随后的far RET中,栈上的空SS作为标志告知处理器不要加载新的SS描述符。

从被调用过程返回

从被调用过程返回通过RET指令完成,RET指令总是和CALL指令搭配使用,JMP指令没有对应的返回指令。

对于近转移,因为在一个段中,所以返回时之进行段/表限长检查,而不进行特权级检查。

对于同一特权级的远返回(far return),处理器会进行特权检查。

对于不同特权级之间的远返回,只允许从高特权级向低特权级返回(返回的代码DPL大于CPL),处理器会利用堆栈中保存的CS中的RPL[6]来和CPL比较,如果RPL大于CPL,就会发生跨特权级的返回。

从被调用过程返回到调用过程中时执行以下步骤:

  1. 检查保存的CS寄存器中的RPL字段来决定返回时是否要发生特权级转换。
  2. 将堆栈中保存的CS、EIP旧值重新加载到CS、EIP中(CPL已经改变)。(会对代码段描述符和代码段选择子的RPL进行类型检查和特权级检查)
  3. 如果RET指令有操作数N,在已经弹出CS、EIP后向ESP递增N个字节,以弹出调用过程传递过来参数,这时ESP指向调用过程原来栈顶(如果不发生特权级/堆栈切换的话)。注意,如果是通过调用门调用的过程,要小心RET的操作数以字节为单位,而调用门中记录的参数个数以word、doubleword为单位,需要进行转换。

如果没有发生特权级切换,从调用过程返回的过程就完成了;如果发生了特权级转换,还要进行以下步骤:

  1. 将保存的SS和ESP重新加载到SS、ESP中,被调用过程的栈被废弃。加载堆栈指针时任何段/表限长错误都会产生#GP异常。堆栈的描述符也会进行类型和特权级的检查。

  2. 如果RET指令包含操作数N,在堆栈指针已经指向调用过程的堆栈后,递增N字节以清除调用过程传递给被调用过程的参数。ESP不会进行段限长的检查,所以就算ESP超过了段限长,在下一次堆栈操作之前是无法发现的。

  3. 检查DS、ES、FS、GS段寄存器。如果寄存器指向的段的DPL小于CPL(除了一致代码),那么这个寄存器中的选择子会被设置为空选择子。

64位模式的RET过程类似。