剑客
关注科技互联网

Segmentation Protection in Intel Processor

最近继续看Intel手册,第三卷的第3章和第5章,感觉又学到很多,包括一些之前一直困扰我的问题。这里做一个整理和总结吧。

这里先推荐一篇博文, CPU Rings, Privilege, and Protection ,感觉写的非常好,思路很清晰。建议可以先阅读一下。

首先提一个问题,为什么在当前的Linux操作系统里面,用户态的程序不能访问内核的数据,不能执行内核的代码,同时不能执行一些特权指令呢?在回答这个问题之前,首先需要明白一个概念,到底什么是用户态和内核态。

特权级(privilege levels)

特权级是处理器硬件内部维护的一个状态,软件无法直接修改。Intel处理器一共有四个特权级,从高到低分别由0到3表示,即数字越低,表示特权越高,而每个数字即表示了当前CPU运行在哪个特权级上。一般情况下(特别是在64位系统中),系统只会用到这四个特权级中的两个,即0和3,其中0就是所谓的内核态,而3就是用户态。

所以说,一个程序运行在用户态的意思,就是该程序所运行的CPU里面的特权级的状态为3,内核态就表示运行内核的CPU的状态为0,仅此而已。当然,这个特权级会和其它的一些机制组合在一起,在触发某些操作的时候进行检查。这个会在之后详细说明。

软件如何知道自己运行在哪个特权级上

按道理来说,特权级是CPU内部的一个状态,软件是无法对它进行直接修改的。但是Intel为软件提供了一个机制,使得软件能够知道自己当前运行在哪个特权级上,简单来说,就是通过段寄存器 cs 来获得该信息。 cs 说白了就是一个16位的寄存器,长成这样:

Segmentation Protection in Intel Processor

也就是说,它最后两个bits就表示当前CPU所处的特权级状态。软件可以通过直接读取 cs 寄存器获取该信息:

read_cs.c
static inline unsigned int read_cs(void)
{
  unsigned int cs;
  asm volatile ("mov %%cs, %0" : "=r" (cs));
  return cs;
}

如果读出来最后两个bits是0,则表示运行在内核态,如果是3,则表示运行在用户态。

基于特权级的保护机制

有了特权级的概念,我们就可以比较清楚地了解Intel处理器本身提供的那些保护机制了,总体来说,该保护机制主要由三个部分组成。

  • 特权指令的执行
  • Memory的保护
  • I/O的保护

对于I/O的保护,我们这里暂且不表,这里主要考虑的是前面两个部分。

特权指令的执行

在Intel的处理器中,一共有16条指令是用户态不能直接执行的(只有内核态可以执行),这些指令如下:

  • LGDT — Load GDT register.
  • LLDT — Load LDT register.
  • LTR — Load task register.
  • LIDT — Load IDT register.
  • MOV (control registers) — Load and store control registers.
  • LMSW — Load machine status word.
  • CLTS — Clear task-switched flag in register CR0.
  • MOV (debug registers) — Load and store debug registers.
  • INVD — Invalidate cache, without writeback.
  • WBINVD — Invalidate cache, with writeback.
  • INVLPG —Invalidate TLB entry.
  • HLT— Halt processor.
  • RDMSR — Read Model-Specific Registers.
  • WRMSR —Write Model-Specific Registers.
  • RDPMC — Read Performance-Monitoring Counter.
  • RDTSC — Read Time-Stamp Counter.

如果在CPL非0的状态下执行这些指令,将会产生一个general-protection exception (#GP) 。至于这些指令都是做什么的,这里也不一一解释了。

Memory的保护

这是这篇博文重点关注的内容,主要分为两个部分:Segmentation和Paging。

段机制(Segmentation)

下图是利用段机制将内存分成不同段(代码段,数据段等)的一个例子:

Segmentation Protection in Intel Processor

在这个设计中,有很多段寄存器,通过每个段寄存器可以分别找到其对应的段描述符,然后获得相应的段的基地址,大小,权限等信息。

下图是一个利用段机制寻址的例子:

Segmentation Protection in Intel Processor

段寻址,即给定一个logical address,将其转换为linear address,分为以下几步:

  • 第一,根据情况判断需要寻找的是什么类型的地址(代码?还是数据?由logical address中的segment selector获得);
  • 第二,根据所需类型,得到相应的段寄存器( cs ,或者 ds 等);
  • 第三,通过段寄存处的第3位到底15位,得到一个特定的段描述符(segment descriptor);
  • 第四,根据描述符中的信息(base, limit, access)等,对该logical address进行检查,并且获得对应的linear address(base+offset);

下图是段选择子的示意图:

Segmentation Protection in Intel Processor

段选择子会被存在段寄存器中,比如之前提到的 cs ,其中最低2位为RPL(Requested Privilege Level),不过要注意的是, cs 段寄存器的最低位为CPL,其它的都是RPL。另外,第3位表示该从GDT还是LDT寻找对应的段描述符,后面的bits就是对应的index了。

下图是段描述符的示意图:

Segmentation Protection in Intel Processor

这里不一一解释,可以直接去查Intel文档。总的来说,它就是定义了某个特定的段所对应的基地址,大小,以及相应的权限。这里最重要的权限位就是DPL(Descriptor privilege level),会在之后权限检查的时候使用。

需要声明的是,段寻址主要应用在32位的系统中,在64位的系统上,已经不再采用段寻址了,logical address即为linear address。但是这并不意味着段机制被关闭,段机制是通过设置 CR0 上的 PE flag 来开启的,在 PE flag 开启之后,除非将其清空,否则是无法关闭段保护机制的。这里所说的不用段寻址是指将那些不需要的段描述符的base和limit都设为0,同时如果不需要段机制保护的话也将相应的权限位清空即可。虽然如此,在64位的系统中,关于CPL的信息依然还能通过 cs 寄存器读取,而且 gsfg 两个段寄存器被用作了其他用途。

关于段保护机制,主要是对前面提到的CPL,DPL和RPL进行检查,主要发生在以下三个场景中:

  • 当某个进程需要装载一个新的段选择子:

Segmentation Protection in Intel Processor

它会判断当前的CPL和RPL是否权限都比相应的DPL的权限高(Max(CPL, RPL) <= DPL),如果是,则允许加载新的段选择子,否则产生GP。

  • 当某个进程需要访问某个段中的内容:

Segmentation Protection in Intel Processor

该检查的逻辑同上。

  • 当某个进程中一个段的代码需要跳转到另外一个段的代码中。

这个过程比较复杂,它的段描述符被称为门描述符(gate descriptor),一共有四种门描述符: call-gateinterrupt-gatetrap-gatetask-gate 。每一种的检查都有一些细微的区别,但是总体的规则是差不多的,下图举了一个IDT(Interrupt Descriptor Table)的例子:

Segmentation Protection in Intel Processor

这里有两个DPL,一个是 interrupt-gate 描述符中的DPL,还有一个是该门描述符中指向的目标代码段的DPL,在这里为0。在发生一个interrupt的时候,它需要检查CPL是否比目标代码段的DPL更低权限(即CPL >= DPL),因为interrupt不允许高权限往低权限跳转。另外,对于软件产生的interrupt,比如 int n ,还要再检查一下CPL是否比 interrupt-gate 描述符的DPL更高(即CPL <= gate DPL)。否则用户态就可以任意调用interrupt了。

可以说,在64位系统上,关于数据访问的段保护机制已经完全由paging机制代替了,其它两个还会涉及到相应的检查。

RPL (Request Privilege Level)

最后来说一下为什么需要有 RPL 这个概念,它的主要作用是允许高特权级(比如内核态)的代码在为低特权级提供服务的时候,通过较低的权限来加载段。

这里 举了一个例子:

假设有一个设备驱动跑在内核态,它为上层用户态进程提供了一个服务,即能够直接将一部分数据从磁盘拷贝到用户态进程的数据段。因此,用户态的进程需要将数据段的信息(如段选择子,地址和需要拷贝的数据大小)提供给该驱动。

由于该驱动在内核态,因此一个用户态的进程能够通过伪造数据段的信息,欺骗该驱动将数据拷贝到另一个内核态的数据段中。这种攻击就被称为权限提升(privilege escalation)。

那么, RPL 是怎么解决这个问题的呢?

还是上面的例子,当驱动加载目标段的段选择子的时候,将其 RPL 修改成用户态的权限(即3)。由于数据段的检查逻辑保证 CPL <= DPLRPL <= DPL 两个条件同时满足,因此上面提到的权限提升的攻击就会因为 RPL(3) <= DPL(0) 而造成protection-fault。

所以说,这里的关键就是,当内核态的代码为用户态的进程提供服务的时候,应该要预先将其RPL所代表的权限暂时降低。

回答开头的那个问题

用户态的程序之所以不能访问内核的数据,不能执行内核的代码,同时不能执行一些特权指令,在32位系统中,这主要是由特权级、segmentation和paging机制共同决定的,而在64位系统中,这主要是由特权级和paging机制决定的。用户态的进程所能看到的页表,内核页对应页表项的权限位标记了S(upervisor)位,因此,当进程处于用户态时, CPL 为3,当然就不能访问内核数据,执行内核代码,以及执行特权指令了。

总结

最后总结一下,在64位系统还没有出来的时候,段机制是被广泛应用的。当64位出现,能够访问的地址空间变得特别大,就不需要再将内存划分成多个段进行管理了(否则复杂度增加),转而采用一种扁平化的模型(Flat model),另外,页表机制的出现,也使得不同权限级别之间的数据访问保护和隔离也不再需要段保护机制来做,而是直接通过设置页表项中的 U/S , R/W , P , NX 等权限位即可达到原来的目的。因此,segmentation这套机制,在64位系统中的利用价值就不如以前了。不过,如之前所说, cs 段选择子依然可以帮助软件了解当前运行的特权级, gs , fs 两个段选择子也另寻用处,这个就是后话了。另外,对于不同特权级之间的控制流转换,包括不同门描述符的利用,这个还是和原来的机制一样。因此,可以说segmentation这套机制在64位系统中依然还有用武之地,甚至我觉得它们可以找到一些新的利用场景,来优化当前的系统。

以上。

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址