前情提要
我们在这里梳理一下上面几节讲的内容
首先是计算机开机,BIOS接过第一棒,将第一个扇区MBR的内容导入到内存 0x7c00
的位置。
然后就是MBR中我们自己写的内容,将Loader导入到 0x600
的地址,Loader设置了GDT,打开了保护模式,并且开启了内存分页。最后将内核载入到内存的 0xc0001500
的位置,也就是物理内存 0x1500
的位置。
这一节没有代码,我们讲一下特权级的问题。
一、特权级
特权级按照权力从大到小分为0、1、2、3级,数字越小,权力越大,然而实际上Linux中只是用了两个特权级,其中0特权级为内核特权级,3特权级为用户特权级。
为何要设立特权级?为了不让用户程序直接操作硬件,用户程序在操作硬件或者一些危险操作时只能通过操作系统去执行。
二、TSS
TSS,即Task State Segment,意为任务状态段,它是处理器在硬件上原生支持多任务的一种实现方式。
其结构如下
TSS是每个任务都有的结构,它用于一个任务的标识,相当于任务的身份证,程序拥有此结构才能运行,这是处理器硬件上用于任务管理的系统结构,处理器能够识别其中每一个字段。
104字节是TSS的最小尺寸,根据需要,后面IO位图的大小不定。目前只关注28字节之下的部分,这里包括了3个栈指针。
任务是由处理器执行的,任务在特权级变换时,本质上是处理器的当前特权级在变换,由一个特权级变成了另外一个特权级。这就开始涉及栈的问题了,处理器固定,处理器在不同特权级下,应该用不同特权级的栈,所以TSS结构中有三个不同的栈指针,分别对应0,1,2特权级。哎????不对啊,不是四个特权级嘛?先别急
特权级在变换时,需要用到不同特权级下的栈,当处理器进入不同的特权级时,它自动在TSS中找同特权级的栈,你懂的,TSS是处理器硬件原生的系统级数据结构,处理器当然知道TSS中哪些字段是目标栈的选择子及偏移量。
特权级转移分为两类
- 特权级由低到高,通过中断门或者调用门实现
- 特权级由高到低,通过调用返回指令实现
对于第一种,由于不知道目标特权级对应的栈地址在哪里,所以要提前把目标栈的地址记录在某个地方,当处理器向高特权级转移时再从中取出来加载到SS和ESP中以更新栈。处理器会自动地从TSS中找到对应的高特权级栈地址,这一点不需要写程序赋值。
对于第二种,当中断发生时,处理器会自动将当前特权级别的栈指针(SS:ESP)保存到中断堆栈(中断栈)中,并切换到特权级0(内核态)来执行中断服务程序。这个过程中,处理器会自动在中断堆栈顶部保存被中断前的 SS 和 ESP 的值。
也就意味着没有哪个特权级的程序会进入3特权级,那么用户程序也就不需要保存3特权级的栈了。
三、CPL、DPL、RPL
x86访问内存的机制是“段基址:偏移地址”,无论是实模式,还是保护模式,都要遵循此方式。保护模式下,段寄存器中的不再是段基址,而是段选择子,通过该选择子从GDT或LDT中找到相应的段描述符,从该描述符中获取段的起始地址。
选择子的0~1位就是RPL字段,它就是请求特权级。即RPL
计算机中,谁是访问者,谁要去访问计算机资源,代码!所以,就用代码段寄存器CS中选择子的RPL位表示代码请求别人资源能力的等级,那么CS寄存器中选择子低2位的值不仅称为请求特权级,又称为处理器的当前特权级,也就是说处理器的当前特权级是CS.RPL。即CPL
在段描述符中有一个属性还为该内存标明了特权等级,这就是段描述符中的DPL,即描述符特权级。
3.1、处理器当前特权级为什么会变化
当前正在运行的代码所在的代码段的特权级DPL就是处理器的当前特权级,当处理器从一个特权级的代码段转移到另一个特权级的代码段上执行时,由于两个代码段的特权级不一样,处理器当前的特权身份起了变化,这就是当前特权级CPL改变的原因。
其实就是使用了那些能够改变程序执行流的指令,如int、call等,这样就使CS和EIP的值改变,从而使处理器执行到了不同特权级的代码。
不过特权转移并不是随便转移的,那么设置这个特权级的意义就没有了,处理器要检查特权变换的条件。当处理器特权级检查的条件通过后,新代码段的DPL就变成了处理器的CPL,也就是目标代码段描述符的DPL将保存在代码段寄存器CS中的RPL位。
3.2、第一个处理器当前特权级怎么来的
我们的代码中,再打开保护模式前,CS寄存器的值一直为0。在打开了保护模式后,进行了一次远跳程序刷新流水线,其段选择子为SELECTOR_ CODE,这个选择子的特权级为0,也就是说一进入保护模式,我们的当前特权级就是0了。远跳时,由于是从0到0,才能成功。
3.3、受访者是谁
DPL是段描述符所代表的内存区域的“门槛”权限,访问者能否迈过此门槛访问到本描述符所代表的资源,其特权级至少要等于这个门槛,访问者特权能否大于该门槛?这要看受访资源是代码,还是数据。
所以访问者是代码,受访者是资源,门槛是段描述符
3.4、不涉及RPL受访者为数据段
数据段段描述符中type字段中未有X可执行属性
只有访问者的权限大于等于该DPL表示的最低权限才能够继续访问,否则连这个门槛都迈不过去。
3.5、不涉及RPL受访者为代码段
代码段段描述符中type字段中含有X可执行属性
只有访问者的权限等于该DPL表示的最低权限才能够继续访问,即只能平级访问。
对于受访者为代码段一这说法,实际上是指处理器从当前运行的代码段上转移到受访者这个目标代码段上去执行,并不是说把该目标代码段当数据一样访问。为什么呢?
因为高特权级的代码什么都可以干,没必要降低特权级完成一件事,所以高特权级代码不会降到低特权级,低特权级代码又不能访问高特权级,这就导致只能在平级之间转换。
但凡事皆有例外,处理器从中断处理程序中返回到用户态的时候实现了从高特权降到低特权。
中断处理都是在0特权级下进行的,因为中断的发生多半是外部硬件发生了某种状况或发生了某种不可抗力事件而必须要通知CPU导致的,所以,在中断的处理过程中需要具备访问硬件的能力,在大多数情况下只有CPU处于0特权级才能访问硬件,这是因为eflags寄存器中的IOPL位的值通常被设置为0(该位的作用就是限制访问IO端口的最低特权级),并且TSS中不存在 IO位图,有关这部分后面马上会讲到。再者,有些中断处理中需要的指令只能在0特权级下使用,这部分指令称为特权指令,所以中断发生后其处理的过程必须在0特权级下进行。用户进程是在3特权级,在运行用户程序时若发生了中断,CPU会暂停用户程序的执行,随后CPU就会自动由3特权级进入到0特权级,在0特权级下将执行用户程序时的现场环境(也就是著名的概念:上下文)保存起来(这个保存上下文的动作可以由CPU通过TSS完成,这是CPU在硬件上提供的功能,但其效率并不高,所以大多数操作系统都是自己写代码手动保存上下文环境),待中断处理完成后,CPU会恢复用户程序的执行,也就是说会回到3特权级。
3.6、如何实现特权级转移
如果如上面所说,代码段只能平级访问,那初始进入的是哪个特权级,后面就只能在这个特权级了嘛?这一想也不对,要实现特权级转移有两种方式,一种是使用一致性代码段
一致性代码段也称为依从代码段,Conforming,用来实现从低特权级的代码向高特权级的代码转移。一致性代码段是指如果自己是转移后的目标段,自己的特权级(DPL)一定要大于等于转移前的CPL,即数值上CPL≥DPL,也就是一致性代码段的DPL是权限的上限,任何在此权限之下的特权级都可以转到此代码段上执行。代码段可以有一致性和非一致性之分,但所有的数据段总是非一致的,即数据段不允许被比本数据段特权级更低的代码段访问。
但是一般不用这个,linux中使用了中断,除了中断还有3个门,都可以实现特权级的转移。
四、门
门结构是什么呢?就是记录一段程序起始地址的描述符。
任务门
中断门
陷阱门
调用门
除了任务门外,其他三种门都是对应到一段例程,即对应一段函数,而不是像段描述符对应的是一片内存区域。任何程序都属于某个内存段,所以程序确切的地址必须用“代码段选择子+段内偏移量”来描述,可见,门描述符基于段描述符,例程是用段描述符来给出基址的,所以门描述符中要给出代码段的选择子,但光给出基址远远不够,还必须给出例程的偏移量,这就是门描述符中记录的是选择子和偏移量的原因。
任务门描述符可以放在GDT、LDT和IDT(中断描述符表),调用门可以位于GDT、LDT中,中断门和陷阱门仅位于IDT中
任务门、调用门都可以用call和jmp指令直接调用,原因是这两个门描述符都位于描述符表中,要么是GDT,要么是LDT,访问它们同普通的段描述符是一样的。陷阱门和中断门只存在于IDT中,因此不能主动调用,只能由中断信号来触发调用。
4.1、为何提供了四种门
提供了4种门的原因是它们都有各自的应用环境,但它们都用来实现从低特权级的代码段转向高特权级的代码段。
1.调用门call和jmp指令后接调用门选择子为参数,以调用函数例程的形式实现从低特权向高特权转移,可用来实现系统调用。call指令使用调用门可以实现向高特权代码转移,jmp指令使用调用门只能实现向平级代码转移。
2.中断门以int指令主动发中断的形式实现从低特权向高特权转移,Linux系统调用便用此中断门实现。
3.陷阱门以int3指令主动发中断的形式实现从低特权向高特权转移,这一般是编译器在调试时用。
4.任务门任务以任务状态段TSS为单位,用来实现任务切换,它可以借助中断指令发起。当中断发生时,如果对应的中断向量号是任务门,则会发起任务切换。也可以像调用门那样,用call或jmp指令后接任务门的选择子或任务TSS的选择子。
4.2、我们用到了哪个
中断门,我们仿照Linux使用中断门进行设计,任务门切换任务开销过大。调用门实现复杂且需要自己做特权级的转换。陷阱门只用作调试。
五、逻辑上的漏洞
现在看来好像没什么破绽,通过CPL与DPL保证用户程序可以跳到特权级0,且可以返回特权级3,实现了特权级之间的转换。很和谐。但是要有人搞破坏怎么办,不管受访者的DPL是多少,如果特权检查仅仅靠CPL和DPL这两项的话,数值上CPL≤DPL,这个时候处理器可以访问并获得任何资源。
举个例子,调用门A可以帮助用户程序把硬盘某个扇区的数据写入到用户指定的内存缓冲区中,如果指向的缓冲区是内核的内存地址怎么办。这个时候用户程序通过调用门A把权提到了0,直接把读来的硬盘内容覆盖了内核,电脑gg
发生这种事情的主要原因在于受访问者不知道真正请求资源的是谁,在上面的例子中,真正的资源请求者的特权级为3,但是CPU无法知道,只知道CPL为0,那么什么都可以干,干就完了。
请求特权级RPL完美地解决了这个问题,它代表真正请求者的特权级,在上面的例子中就是3
以后在请求某特权级为DPL级别的资源时,参与特权检查的不只是CPL,还要加上RPL,CPL和RPL的特权必须同时大于等于受访者的特权DPL。
用户程序的CPL是不会骗人的,不可能伪造,它起始是由操作系统在加载用户程序时赋予的,记录在段寄存器CS中的低2位,就是RPL的位置,而CS寄存器只能通过call、jmp、ret、int、sysenter等指令修改,即使改的话,用户程序也只能在3级特权下折腾,只要用户进程不请求操作系统服务,它的CPL是不会变的,当它申请了系统服务,如果提交了选择子作为参数,选择子中的RPL也会被操作系统修改为用户进程的CPL。所以,即使用户程序提交了个伪造的选择子也没用,其RPL会被操作系统用其CPL替换,还其“真身”。
除了加载用户程序时,在其他时段的 CPL是由目标代码段的DPL变成的,即切换到新特权代码段后,新代码段的DPL被存储到段寄存器CS中的低2位,就是RPL的位置。其实这再合理不过了,CPU是切换到新的特权级代码段上运行了,身份变了,当然要用新代码段的DPL做CPL。
受访者若为数据,特权级检查会发生在往数据段寄存器中加载段选择子的时候,数据段寄存器包括DS和附加段寄存器ES、FS、GS
举个例子,mov ds,ax时便会触发特权级检查。ax中的值被当作选择子,处理器会拿ax中的低2位,即RPL和CPL分别与ax中选择子所指向的段描述符的DPL做比较,如果满足RPL≤DPL && CPL≤DPL,选择子才能被加载到DS中。
操作系统提供了一致性代码段和门结构来实现从低特权级到高特权级的代码段转移,这会给恶意攻击者用低特权级的程序访问高特权级资源,使攻击者有诸如篡改内核之类的危险操作的机会,因此必须在CPL和DPL的基础上增加条件。
访问者穿越特权屏障是因为操作系统允许通过特定方式实现从低特权级代码段到高特权级代码段的转移,即通过高特权级代码段来间接获得自身无法拥有的权限。由于真正的资源请求者是低特权级代码段,因此需要标识出资源请求者的真实身份,这就是请求特权级(Request Privilege Level,RPL)的作用。
RPL代表了真正资源请求者的特权级,因此在请求特权级为DPL的资源时,需要检查的不仅是CPL,还要加上RPL,CPL和RPL的特权级必须同时大于等于受访者的DPL。
RPL引入的目的是避免低特权级的程序访问高特权级的资源,有了RPL后,访问内存段的特权检查规则如下(不通过调用门):
如果目标为非一致性代码段,要求数值上:CPL=RPL=目标代码段DPL
如果目标为一致性代码段,要求数值上:CPL≤目标代码段DPL && RPL≥目标代码段DPL
如果目标为数据段时,要求数值上:CPL≤目标数据段DPL && RPL ≤ 目标数据段DPL
栈段的特权级检查比较特殊,因为在各个特权级下处理器都有对应的栈,所以往段寄存器中赋予选择子时,要求CPL等于栈段选择子对应的数据段的DPL,即数值上CPL=RPL=用作栈的目标数据段DPL。
六、IO特权级
IO读写特权是由标志寄存器eflags中的IOPL位和TSS中的IO位图决定的,它们用来指定执行IO操作的最小特权级。IO相关的指令只有在当前特权级大于等于IOPL时才能执行,所以它们称为IO敏感指令(I/O Sensitive Instruction),如果当前特权级小于IOPL时执行这些指令会引发处理器异常。这类指令有in、out、cli、sti。
在eflags寄存器中第12~13位便是IOPL(I/O Privilege Level),即IO特权级,它除了限制当前任务进行IO敏感指令的最低特权级外,还用来决定任务是否允许操作所有的IO端口,对,没错,是全部IO端口,IOPL位是打开所有IO端口的开关(用来单独设置端口访问的方式是IO位图,一会儿介绍)。每个任务(内核进程或用户进程)都有自己的eflags寄存器,所以每个任务都有自己的IOPL,它表示当前任务要想执行全部IO指令的最低特权级,也就是处理器最低的CPL,只有任务的当前特权级大于等于IOPL才允许执行全部IO指令,即数值上CPL≤IOPL;
CPL为0时处理器是法力无边的,所以0特权级下处理器是不受IO限制的。
IOPL是所有IO端口的开关,不过,这个开关还留有余地,如果将开关打开,便可以访问全部65536个端口,如果开关被关上,即数值上CPL > IOPL,则可以通过IO位图来设置部分端口的访问权限。也就是说,先在整体上关闭,再从局部上打开。这有点像设置防火墙的规则,先默认为全部禁止访问,想放行哪些端口再单独打开。
65536个端口号,正好占用8KB。
所以TSS结构变成了这个样子
位图的结尾必须是0xFF
,但是位图可以满8KB。
第一,处理器允许I/O位图中不映射所有的端口,即I/O位图长度可以不足8KB,但位图的最后一字节必须为0xFF。如果在位图范围外的端口,处理器一律默认禁止访问。这样一来,如果位图最后一字节的0xFF属于全部65536个端口范围之内,字节各位全为1表示禁止访问此字节代表的全部端口,这并没什么过错。
第二,如果该字节已经超过了全部端口的范围,它并不用来映射端口,只是用来作为位图的边界标记,用于跨位图最后一个字节时的“余量字节”。避免越界访问TSS外的内存。
结束语
这一节讲了很多理论的东西,希望大家没有瞌睡,哈哈哈