总论
权限管理可以分为两个部分:
- 静态:处理器当前权限,各种资源(内存段,内存页)的权限
- 动态:权限的变化,这往往发生在中断,异常,系统调用,进程切换等场景下
这些是每个 CPU 都要实现的功能(当然有些可能需要配合软件),但是 X86 的实现存在两个问题:
- 为了兼容性,强行复用之前的 feature ,导致有些 feature 难以理解(比如说在
CS
中的特权等级) - 硬件语义过强,许多本应该由软件负责的事情,全都变成了由硬件负责(比如上下文切换,系统调用等),这可能也是 CISC 过度设计的一种体现
而 Linux 对此采用的思路就是,尽可能地“架空”段式权限管理。
静态
Ring
X86 一共设计了 4 个特权等级,分别是 Ring0 ~ 3,其中:
- Ring0 的特权等级最高,运行操作系统内核
- Ring1, Ring2 用于运行 OS Service ,并不常用,有一种应用是运行驱动程序
- Ring3 特权级最低,运行用户程序
可以看到数字越小,特权级越高。另外就是,我不知道 hypervisor 或者 security 的功能似乎不在 ring 这个体系中。
CPL 与 DPL
如果我们观察段选择符,就会发现它除了 index
和 TI
字段外,低 2 位还空着呢,这个字段被称为 RPL
(Request Privilege Level),这个字段本身的设计和非平坦模式有关,现在基本上已经不用了,我们会在后面讨论。
而 CS
寄存器中的 RPL
字段就完全脱离了原本的语义(切记,完全脱离!),变成了 Current Privilege Level(CPL),也就类似于 ARM 中的 CurrentEL
寄存器。它只是了当前的 ring 的等级。
而在段描述符上,也记录着一个类似的字段,被称为 Descriptor Privilege Level(DPL),它指的是访问该段所需要的特权等级。也就是当满足 的时候,才能访问。
这个逻辑是挺正确且自然的,一般来说 CPL, DPL
都有 0, 3 两个取值,其具有以下含义:
CPL | DPL | |
---|---|---|
0 | 内核态 | 内核段 |
3 | 用户态 | 用户段 |
相互组合就可以得到:
DPL = 0 | DPL = 3 | |
---|---|---|
CPL = 0 | 允许,内核态访问内核段 | 允许,内核态访问用户段 |
CPL = 3 | 不允许,用户态访问内核段 | 允许,用户态访问用户段 |
RPL
在介绍完常见的权限等级后,我们来介绍 Request Privilege Level(RPL)这个不常见的 feature。总的来说就是这个 feature 只适合非平坦模式的情况。
它指的是这样一件事,当我们去访问一个数据段时,不仅要满足 还要满足 ,也就是说,要经过两次检验。
这个事情是很难理解的,因为在权限管理中,概念就只有“我自己的权限”和“访问资源所需要的权限”两种,这两种概念分别被 CPL 和 DPL 描述了,那 RPL 这个东西就显得很多余。
但是它实际上对应了一个在非平坦模式下才能发生的场景。也就是用户通过系统调用请求内核改写用户数据段的情景。这个情景在非平坦模式下,用户是需要给内核传递一个逻辑地址的,也就是传递一个 [User Seg Selector:Offset]
的,这个是无法避免的,因为如果不传递用户的段寄存器,内核是很难知道用户到底要往哪里写。但是坏就坏在用户是可以传递一个 [Kernel Seg Selector:Offset]
的,当处理用户的系统调用的时候,用户会进入内核,也就是 CPL 会从 3
变成 0
,此时就真的有权限写内核段了,就可以搞破坏了。
所以 X86 想出了这样的一个法子,就是当进入系统调用的时候,内核干的第一件事情(文学化描述)就是根据请求者的 CPL (一般就是 3
)修改 RPL ,这样即使后面 CPL 变成了 0
,也不妨碍 RPL 记住了它原本是 3
的历史。此时如果再去访问一个内核段,虽然 的检验可以通过,但是 是没有办法通过的。
那为什么平坦模式没有这个问题,平坦模式下也存在这种系统调用,即传递一个用户地址让内核去改写。但是平坦模式并不依赖段来区分内核数据还是用户数据,而是按照“高地址的是内核数据,低地址的是用户数据”来进行区分,所以并不会有“区分不了地址的归属”的情况,所以 RPL 在现代 OS 中就没有必要使用了。
Confirming
段的依从性(confirming)是一个 80286 时代留下的一个 legacy feature 。它是内存段的一个特性(在描述符中有 1 bit 来表示)。这个特性主要用于代码段(所有的数据段都是非依从的),而且往往是内核代码段(其实有方便举例子的嫌疑)。
当一个内核代码段是依从的,那么就可以被用户态代码调用。而且不同于系统调用,用户态代码不需要改变它的 CPL (系统调用时 CPL 会变成 0
)。而因为 CPL 依然是 3
,所以这段内核代码在执行的时候,也是不能访问内核数据(数据段都是非依从的)的,相当于执行起来的限制比较多。
那为什么不直接把这个“依从的内核态代码”放到用户态呢?换句话说,这段代码的语义是“内核中不接触机密数据的代码”,那么为什么不直接把这样的代码放到用户态形成一个函数库呢?可能是由于这些代码还是和内核实现联系得比较紧密吧,我甚至感觉到了一丝微内核的设计思想。当然无论怎样,这个特性被架空了,也说明了这个特性在设计上的鸡肋。
最后放一段官方文档上的文字,这里说明了上文阐述的意思:
Conforming segments are used for code modules such as math libraries and exception handlers, which support applications but do not require access to protected system facilities. These modules are part of the operating system or executive software, but they can be executed at numerically higher privilege levels (less privileged levels). Keeping the CPL at the level of a calling code segment when switching to a conforming code segment prevents an application program from accessing nonconforming code segments while at the privilege level (DPL) of a conforming code segment and thus prevents it from accessing more privileged data.
动态
Gate
在 X86 的设计中,权限的转换是必须通过门(Gate)的。门是一个和段相似(数据格式)的元数据,X86 利用 Gate 中记录的元数据,完成对于 CS
和 IP
的修改,进而改变了相关的权限。
比较离谱地是,X86 为每一种场景都设计了不同种类的 Gate:
- 进程切换:任务门(task gate)
- 系统调用:调用门(call gate)
- 异步中断:中断门(interrupt gate)
- 同步异常:陷阱门(trap gate)
其中任务门和调用门的元数据和其他段一样保存在 GDT
或者 LDT
中,而中断门和陷阱门则组成数组保存在另一片内存中,这篇内存被称为 IDT
(Interrupt Descriptor Table),通过 IDTR
作为基地址访问。
X86 这种过强的语义再次受到了 Linux 的架空,在 Linux 中只使用中断门和陷阱门,为了兼容性保持了少数调用门。
写完后感慨一句,历史真的是一个轮回。之前 X86 作出了像 GDT, IDT, TSS
这种特殊的有着明确语义的内存段,最后大部分都被软件所抛弃了;而现在流行的安全飞地,可信内存,TLS ,其本质也是具有明确语义的内存段,怎么不能说是一种历史的重演呢?
非 Gate 不可
权限的转换一定是非通过 Gate 不可吗?很容易想到,如果可以修改选择子(本质是修改段寄存器)或者修改描述符表(本质是修改内存),那么就可以绕过 Gate 完成权限转换。
首先可以很容易想到,修改描述符表并不是容易的事情,因为描述符表的访问需要依赖 GDTR, LDTR, IDTR
这些寄存器,而这些寄存器被禁止在 ring3 中访问。但是修改选择子就是另一码事情了,因为在段式内存管理中, ring3 确实需要修改选择子,比如通过 ljmp, lcall
这种远程跳转操作修改 CS
,进而就可以修改 CS
中的 CPL
,或者修改 DS
去指向一个内核数据段,似乎是无法避免的。不过事实上,在修改段寄存器之前,CPU 会进行权限检查,具体检测如下:
IF DS, ES, FS, or GS is loaded with non-NULL selector
THEN
IF segment selector index is outside descriptor table limits
OR segment is not a data or readable code segment
OR ((segment is a data or nonconforming code segment)
AND ((RPL > DPL) or (CPL > DPL)))
THEN #GP(selector); FI;
IF segment not marked present
THEN #NP(selector);
ELSE
SegmentRegister := segment selector;
SegmentRegister := segment descriptor; FI;
所以基本上 ring3 是无法通过改段寄存器的方式来获得特权的。当然其实即使获得了(之前存在这样的蠕虫病毒),在现在页式权限管理发达的今天,也基本上是没有什么作用的。
Task Gate
Task Gate 是 X86 设计出来的用于进行进程(任务和进程的意思差不多)切换的门。任务门需要配合任务状态段(Task State Segment, TSS),这是一段特殊的内存,用于保存所有与进程有关的寄存器,包括通用寄存器, CR3
,ring0 ~ 2 共 3 个栈指针寄存器。
在 X86 中,会使用 TR
寄存器(本质也是一个段寄存器)来指向当前进程的 TSS
段,而 Task Gate 中记录着要切换的进程的 TSS
段,当使用 call
命令进行进程切换时,CPU 会将当前进程的上下文保存在 TR
指向的 TSS
段中,然后从 Task Gate 指向的 TSS
段中恢复上下文。这个过程和常见的进程切换非常类似。
但是 Linux 并没有采用 Task Gate ,这是因为硬件上下文切换的自由度过低,多架构兼容性过差,难以优化。
Call Gate
Call Gate 是 X86 设计出来用于进行系统调用的门。Call Gate 可以使用 lcall, ljmp
命令使用。但是遗憾得是,Linux 并没有使用 Call Gate 来实现系统调用,而是使用了陷阱门。
Call Gate 相比于陷阱门,并没有明显的缺陷,甚至还要更快一些,我查了一些资料,也没有找到 Linux 不使用 Call Gate 的明确说法,而且似乎还有大量的现代操作系统是使用 Call Gate 的。我感觉是 Linux 处于兼容性和简洁设计的考虑,不愿意使用过于复杂的硬件机制。
当然后来似乎 Linux 也不再用 int 0x80
来当系统调用,而是改成了 Call Gate 的后继者 syscall/sysenter/sysret
机制了。
Interrupte/Trap Gate
中断门和陷阱门的唯一区别在于,进入中断门会让 CPU 屏蔽中断(disable FLAGS.IF
)。中断门和陷阱门在语义上没有太大的差别,比如说使用 int
指令,可以调用中断门,也可以调用陷阱门。
我们在软件中,一般通过 int
指令调用这两个门,这个指令会有一个立即数操作数,被称作“中断向量”。CPU 会用中断向量作为索引去查询中断向量表(也就是 IDT
)中的某个具体的门,然后根据这个门中记录的元数据完成跳转和权限转变。
具体而言,如果发生了 CPL
的转变,那么需要切换栈,CPU 会根据 TR
找到 TSS
进而找到内核态对应的栈指针,完成栈的切换。CPU 将当前 flags
和返回地址 CS:IP
压入栈中,如果该中断是由异常引起的,那么还需要压入错误码。然后如果切换了栈,还需要向当前栈压入之前栈的 SS:SP
。