第16章 任务和特权级保护
段保护是不够的,恶意的程序可以通过修改GDT来进行攻击。多任务系统,对任务之间的隔离和保护,以及任务和操作系统之间的隔离和保护都提出了要求,这可以看作对段保护机制的进一步强化。
该章节的内容我感觉比较难,其中涉及了很多新的知识:调用门、LDT、TSS、TCB、特权级、IO特权级等,并且调用门还涉及特权级检查和变更等。
我也看了很多遍,才弄清楚,建议是一个一个知识点厘清。
任务的隔离和特权级保护
任务、任务的LDT和TSS
任务(task):程序(Program)是记录在载体上的指令和数据,总是为了完成某个特定的工作,其正在执行中的一个副本,叫作任务(Task),有时候也称之为进程(Process)。
LDT(Local Descriptor Table):为了有效地在任务之间实施隔离,处理器建议每个任务都应当具有自己的描述符表,称为LDT(Local Descriptor Table,局部描述符表),并且把专属于自己的那些段放到LDT中。
LDTR(LDT Resister):在一个多任务的系统中,会有很多任务在轮流执行,正在执行中的那个任务,称为当前任务(Current Task)。因为寄存器LDTR只有一个,所以,它只用于指向当前任务的LDT。每当发生任务切换时,LDTR的内容被更新,以指向新任务的LDT。和GDTR一样,LDTR包含了32位线性基地址字段和16位段界限字段,以指示当前LDT的位置和大小。
访问LDT:根据段选择子的第3位TI位(位2)。0表示访问GDT;1表示访问LDT。
mov cx,0x0008 ;0000 0000 0000 1000 T1为0,访问GDT,索引号1
mov ds,cx
mov cx,0x005c ;0000 0000 0101 1100 T1为1,访问LDT,索引号11
mov ds,cx
LDT的最大数量:段选择子只有高13位用来索引访问LDT,所以每个LDT所能容纳的描述符个数为2^13,即8192个,又因为每个描述符的长度是8字节,LDT的长度最大为64KB。这个和GDT是一样的。
任务状态段(Task State Segment):当任务切换发生时,用于保存旧任务的运行状态,包括通用寄存器、段寄存器、栈指针寄存器ESP、指令指针寄存器EIP、状态寄存器EFLAGS等等。
任务状态段格式如下:
任务寄存器(Task Register,TR):处理器用TR(Task Register)来指向当前任务的TSS。当任务切换时,寄存器TR的内容页会跟着指向新任务的TSS。
这个过程如下:
- 首先,处理器将当前任务的现场信息保存到由寄存器TR指向的TSS;
- 再使寄存器TR指向新任务的TSS,并从新任务的TSS中恢复现场。
全局空间和局部空间
内核态:当任务需要使用内核的服务时,要进入内核的代码执行,此时处于内核态。
用户态:当任务执行自己的代码时,它处于用户态。
全局部分和私有部分:每个任务实际上包括两个部分:全局部分和私有部分。
- 全局部分是所有任务共有的,含有操作系统的软件和库程序,以及可以调用的系统服务和数据;
- 私有部分则是每个任务各自的数据和代码,与任务所要解决的具体问题有关,彼此并不相同。
全局空间和局部空间:任务实际上是在内存中运行的,全局部分和私有部分其实是地址空间的划分,即全局地址空间和局部地址空间,简称全局空间和局部空间。
地址空间的访问是依靠分段机制来进行的,就是需要先在描述符表中定义各个段的描述符,然后再通过描述符来访问它们。
- 全局地址空间是用全局描述符表(GDT)来指定的;
- 局部地址空间则是由每个任务私有的局部描述符表(LDT)来定义的。
全局地址空间大小:全局地址空间可以定义8192个段描述符,段描述符的偏移地址是32位的,段的最大长度是4GB。因此一个任务的全局地址空间总大小为 8192*4GB=32TB。
局部地址空间大小:局部地址空间大小和全局地址空间大小是一样的。
内存和硬盘交换:当执行或者访问一个新的段时,如果它不在物理内存中,而且也没有空闲的物理内存空间来加载它,那么,操作系统将挑出一个暂时用不到的段,把它换出到磁盘中,并把那个腾出来的空间分配给马上要访问的段,并修改段的描述符,使之指向这段内存空间。
Linux中的swap就是这个原理。
特权级保护概述
在分段机制的基础上,处理器引入了特权级,并由固件负责实施特权级保护。
特权级(Privilege Level):也叫特权级别,是存在于描述符及其选择子中的一个数值,当这些描述符或者选择子所指向的对象要进行某种操作,或者被别的对象访问时,该数值用于控制它们是否允许进行这样的操作和访问。
INTEL处理器可以识别4个特权级别:0、1、2、3,较大的数值意味着较低的特权级别。
描述符特权级(DPL):段描述符都有一个2比特的DPL字段,可以取值为00、01、10和11,分别对应特权级0、1、2和3。DPL是每个描述符都有的字段,故又称描述符特权级(Descriptor Privilege Level)。
比如数据段,DPL=2,那么特权级0、1、2的程序才能访问。当一个特权级为3的程序也试图去读写该段时,将会被处理器阻止,并引发异常中断。
当前特权级(Current Privilege Level,CPL):当处理器正在一个代码段中取指令和执行指令时,那个代码段的特权级叫作当前特权级(Current Privilege Level, CPL)。在执行的这个代码段,其选择子位于段寄存器CS中,其最低两位就是当前特权级的数值。
- 操作系统的CPL为0;
- 用户程序的CPL一般为3;
特权指令(Privileged Instructions):只有在当前特权级CPL为0时才能执行的指令,称为特权指令。如加载全局描述符表的指令lgdt、加载局部描述符表的指令lldt、加载任务寄存器的指令ltr、读写控制寄存器的mov指令、停机指令hlt等十几条。
输入输出特权级(I/OPrivilege Level):处理器的标志寄存器EFLAGS中,位13、位12是IOPL位,也就是输入/输出特权级(I/OPrivilege Level),它代表着当前任务的I/O特权级别。
每个任务的任务段TSS中有保存寄存器EFLAGS的副本。
控制转移:控制转移只允许发生在两个特权级相同的代码段之间。为了让特权级低的应用程序可以调用特权级高的操作系统例程,处理器也提供了相应的解决办法。
1)第一种方法是将高特权级的代码段定义为依从的。
代码段描述符的TYPE字段有C位,如果C=0,这样的代码段只能供同特权级的程序使用;否则,如果C=1,则这样的代码段称为依从的代码段,可以从特权级比它低的程序调用并进入。
其他条件:当前特权级<=目标代码段描述符的特权级。在数值上:CPL >= 目标代码段描述符的DPL。例如:一个依从的代码段,其描述符的DPL为1,则只有特权级别为1、2、3的程序可以调用,而特权级为0的程序则不能。
依从的含义:依从的代码段不是在它的DPL特权级上运行的,而是在调用程序的特权级上运行的。当控制转移到依从的代码段上执行时,不改变当前特权级CPL,段寄存器CS的CPL字段不发生变化,被调用过程的特权级依从于调用者的特权级,这就是为什么它被称为“依从”代码段。
2)第二种方法在特权级之间转移控制的方法是使用门。
门(Gate)是另一种形式的描述符,称为门描述符,简称门。和段描述符不同,段描述符用于描述内存段,门描述符则用于描述可执行的代码,比如一段程序、一个过程(例程)或者一个任务。
门的类型有好几种:
- 不同特权级之间的过程调用可以使用调用门;
- 中断门/陷阱门是作为中断处理过程使用的;
- 任务门对应着单个的任务,用来执行任务切换。
调用门(Call Gate):有描述符都是64位的,调用门描述符也不例外。在调用门描述符中,定义了目标过程(例程)所在代码段的选择子,以及段内偏移。要想通过调用门进行控制转移,可以使用jmp far或者call far指令,并把调用门描述符的选择子作为操作数。
- jmp far:使用jmp far指令,可以将控制通过门转移到比当前特权级高的代码段,但不改变当前特权级别。
- call far:如果使用call far指令,则当前特权级会提升到目标代码段的特权级别。也就是说,处理器是在目标代码段的特权级上执行的。
但是,除了从高特权级别的例程(通常是操作系统例程)返回,不允许从特权级高的代码段将控制转移到特权级低的代码段,因为操作系统不会引用可靠性比自己低的代码。
特权级保护机制应用:特权级保护机制只在保护模式下才能启用。处理器建议,在进入保护模式后,执行的第一条指令应当是跳转或者过程调用指令,以清空流水线和乱序执行的结果,并串行化处理器,就像第15章使用的:
jmp 0x0010:flush ;选择子0000_0000_0001_0000,
;选择子被加载到CS
; 索引号=2
; TI=0,描述符在GDT中
; CPL=0,特权级位0
对应的段描述符如下图,DPL=00。
进入保护模式后,当前CPL=00,执行代码段DPL=00,从特权级0到0这个是可以的。
请求特权级(Requested Privilege Level):RPL也就是指请求者的特权级别(Requestor’s Privilege Level)。
CPL=RPL的情况:在绝大多数时候,请求者都是当前程序自己,因此CPL=RPL。例如第15章的这两块代码:
RPL!=RPL的情况:特权级为3的应用程序希望从硬盘读一个扇区,并传送到自己的数据段,因此,数据段描述符的DPL同样会是3。
由于I/O特权级的限制,应用程序无法自己访问硬盘。好在位于0特权级的操作系统提供了相应的例程,但必须通过调用门才能使用,因为特权级间的控制转移必须通过门。假设,通过调用门使用操作系统例程时,必须传入3个参数,分别是
- 寄存器CX中的数据段选择子;
- 寄存器EBX中的段内偏移;
- 寄存器EAX中的逻辑扇区号;
高特权级别的程序可以访问低特权级别的数据段,这是没有问题的。因此,操作系统例程会用传入的数据段选择子代入段寄存器,以便代替应用程序访问那个段:
mov ds,cx ;操作系统访问应用程序的数据段
;cx段选择子,请求特权级RPL=3
;当前特权级CPL=0
;RPL和CPL并不相同
特权级安全问题:应用程序无法直接访问内核的数据段,但是如果把cx的选择子改为内核的数据段选择子,就可以通过调用门间接访问到内核数据段。
进入到内核时:
- 当前特权级CPL = 0
- 请求特权级RPL = 0
- 内核数据段特权级DPL = 0
完全是可以的。
解决特权级安全问题:由操作系统来处理,CPU只负责特权级检查。
1)操作系统处理:因为操作系统知道选择子来源,需要确保RPL的值和请求者身份相符。比如上例:请求者身份特权级为3,请求的数据段选择子RPL=0,这个就不行。
2)处理器的检查规则:每当处理器执行一个将段选择子传送到段寄存器(DS、ES、FS、GS)的指令,如:mov ds,cx 时会检测以下两个条件是否都能满足:
- 当前特权级CPL高于或者等于数据段描述符的DPL,即在数值上,CPL<=数据段描述符的DPL。(简单说就是:当前运行的特权级更高,才能访问对应的数据段)
- 请求特权级RPL高于或者等于数据段描述符的DPL,即在数值上,RPL<=数据段描述符的DPL。(简单说就是:请求者的特权级更高,才能访问对应的数据段)
总得来说:就是当前程序和应用程序的特权级都要高于要访问的数据段特权级。
总结特权级检查规则:
1)将控制直接转移到非依从的C代码段,要求当前特权级CPL和请求特权级RPL都等于目标代码段描述符的DPL。即在数值上:
- CPL=目标代码段描述符的DPL
- RPL=目标代码段描述符的DPL
一个典型的例子就是使用jmp指令进行控制转移:
jmp 0x0012:0x00002000 ;选择子0000_0000_0001_0010
;两个代码段的特权级相同,特权级不变。
2)要将控制权转移到依从的代码段,要求当前特权级CPL和请求特权级RPL都低于,或者和目标代码段描述符的DPL相同。即在数值上:
- CPL>=目标代码段描述符的DPL
- RPL>=目标代码段描述符的DPL
控制转移后,当前特权级保持不变。
3)高特权级的程序可以访问低特权级的数据段,但低特权级别的程序不能访问高特权级别的数据段。访问数据段之前,肯定要对段寄存器DS、ES、FS和GS进行修改,比如:
mov fs,ax
在这个时候,要求当前特权级CPL和请求特权级RPL都必须高于或者和目标数据段的描述符的DPL相同,即在数值上:
- CPL<=目标数据段描述符的DPL
- RPL<=目标数据段描述符的DPL
4)栈段的特权级必须和当前特权级CPL相同。
mov ss,ax
在对段寄存器SS进行修改时,要求当前特权级CPL和请求特权级RPL必须等于目标段描述符的DPL。即在数值上:
CPL=目标栈段描述符的DPL
RPL=目标栈段描述符的DPL
代码清单16-1
该章代码的实现功能和上一章一样,只是使用了任务、LDT、TSS和特权级等最新的处理器特性和工作机制。
内核程序的初始化
主引导程序加载完内核后,对它进行了前期的初始化工作。相关GDT表如下图,各段描述符的特权级DPL均位0。
调用门
为什么要用调用门:用户程序运行时的特权级别将会是3。由于处理器禁止将控制从特权级低的程序转移到特权级高的程序,因此,如果还像以前那样直接调用内核例程,百分之百不会成功,一定会引发处理器异常中断,为此需要安装调用门。
调用门(Call Gate):调用门(Call-Gate)用于在不同特权级的程序之间进行控制转移。本质上,它只是一个描述符,一个不同于代码段和数据段的描述符,可以安装在GDT或者LDT中。格式如下:
- 所在代码段选择子:调用门描述符给出了例程所在代码段的选择子,而不是32位线性地址。有了段选择子,就能访问描述符表得到代码段的基地址,这样做无非是间接了一点,但却可以在通过调用门进行控制转移时,实施代码段描述符有效性、段界限和特权级的检查。
- 段内偏移量:例程在代码段中的偏移量也是在描述符中直接指定的,只是被分成了两个16位的部分。(两个偏移量估计也是历史问题)
- TYPE:描述符中的TYPE字段用于标识门的类型,共4比特,值“1100”表示调用门。
- P位:描述符中的P位是有效位,通常应该是“1”。当它为“0”时,调用这样的门会导致处理器产生异常中断。
调用门特权级控制转移:通过调用门实施特权级之间的控制转移时,可以使用jmp far指令,也可以使用call far指令。如果是后者,会改变当前特权级CPL。
调用门参数:通过调用门使用高特权级的例程服务时,调用者会传递一些参数给例程。如果是通过寄存器传送,这没有什么可说的。不过,要传递的参数很多时,更经常的做法是通过栈进行。调用者把参数压入栈,例程从栈中取出参数。在高级语言里,这是一贯的做法。
使用调用门特权级:调用门描述符中的DPL和目标代码段描述符的DPL,用于决定哪些特权级的程序可以访问此门。具体的规则是必须同时符合以下两个条件才行:
- 当前特权级CPL和请求特权级RPL高于或者和调用门描述符特权级DPL相同。即在数值上:CPL<=调用门描述符的DPL;RPL<=调用门描述符的DPL;
- 当前特权级CPL低于或等于目标代码段描述符特权级DPL。即在数值上:CPL>=目标代码段描述符的DPL。
本质上来说,调用门相当于多了一层。
调用门的安装和测试
安装调用门:遍历每一个内核公共例程,安装相应的调用门,主要步骤:
- 根据内核公共例程的段选择子和32位偏移地址生成门描述符;
- 将生成的门描述符选择子替换掉内核公共例程的选择子。
;以下开始安装为整个系统服务的调用门。特权级之间的控制转移必须使用门
mov edi,salt ;C-SALT表的起始位置
mov ecx,salt_items ;C-SALT表的条目数量
.b3:
push ecx
mov eax,[edi+256] ;该条目入口点的32位偏移地址
mov bx,[edi+260] ;该条目入口点的段选择子
mov cx,1_11_0_1100_000_00000B ;特权级3的调用门(3以上的特权级才
;允许访问),0个参数(因为用寄存器
;传递参数,而没有用栈)
call sys_routine_seg_sel:make_gate_descriptor
call sys_routine_seg_sel:set_up_gdt_descriptor
mov [edi+260],cx ;将返回的门描述符选择子回填
add edi,salt_item_len ;指向下一个C-SALT条目
pop ecx
loop .b3
调用门 1_11_0_1100_000_00000B 的格式参考如下:
很显然,P=1,DPL=3,即,特权级高于或等于3的代码段可以调用此门,参数的数量为0,也就是不需要通过栈传递参数。
生成门描述符make_gate_descriptor:这个不难理解,就是根据门的格式进行拼凑和组装。
make_gate_descriptor: ;构造门的描述符(调用门等)
;输入:EAX=门代码在段内偏移地址
; BX=门代码所在段的选择子
; CX=段类型及属性等(各属
; 性位都在原始位置)
;返回:EDX:EAX=完整的描述符
push ebx
push ecx
mov edx,eax ;组装调用门的高双字部分
mov dx,cx
;组装调用门的低双字部分
and eax,0x0000FFFF ;清除高16位的部分
shl ebx,16 ;左移16位
or eax,ebx ;合并两个寄存器
pop ecx
pop ebx
retf
之后通过 set_up_gdt_descriptor 过程安装描述符。安装调用门后的GDT布局:
对安装好的调用门进行测试:
mov ebx,message_2
call far [salt_1+256] ;通过门显示信息(偏移量将被忽略)
; salt_1 db '@PrintString'
; salt_1 256~259 4个字节是偏移量
; salt_1 260~261 2个字节是选择子
; 这里偏移量会被忽略,选择子是门描述选择子。TYPE字段为1100
; 偏移量将被忽略,因为门描述符里包含了目的代码偏移量。
检测点:
1)通过调用门转移控制时,CPL、RPL和目标代码段描述符的DPL必须在数值上符合 大于或等于(大于或等于代表特权级不高于) 的条件;CPL、RPL和调用门描述符的DPL必须在数值上符合 小于或等于(小于或等于代表特权级不低于) 的条件。即,只能通过调用门将控制转移到与当前特权级相同或者更高的代码段。
为什么CPL和RPL要大于等于目标代码段描述符?
要进入依从的代码段,要求当前特权级CPL必须比目标代码段特权级低。即 CPL >= 目标代码段描述符的DPL
2)调用门描述符只能安装在GDT中吗?如果某调用门描述符的值是0x0000CC0000552FC0,那么,目标代码段的选择子是 0x0055,段内偏移量为 0x00002FC0,描述符的特权级是10B,目标代码段的特权级是 01B,要通过此门转移控制,CPL和RPL要符合什么条件才行?
0x0055 = 00000000_0101_0101
0xCC00 = 1_10_0_ 1100_000_00000
CPL和RPL <= 门描述符的DPL
加载用户程序并创建任务
任务控制块和TCB链
直接调用内核公共例程:内核可以直接调用公共例程,这里调用put_string显示信息。
mov ebx,message_3
call sys_routine_seg_sel:put_string ;在内核中调用例程不需要通过门
直接调用也会进行特权检查,上面两行代码是从0特权级的内核代码段进入同样是0特权级的公共例程段,能够通过特权级检查。
任务控制块(Task Control Block,TCB):任务控制块存储了任务的信息和状态,方便内核对任务进行回收和切换等操作。任务控制块并不是处理器要求,而是自行设计的。
书中任务控制块的结构如下:
任务控制块链表(tcb_chain):为了能够追踪到所有任务,应当把每个任务控制块TCB串起来,形成一个链表。
任务控制块链表结构:
1)在内核数据段中声明标号 tcb_chain 并初始化为一个双字,初始的数值为零。实际上,它是第一个指针,用来指向第一个任务的TCB线性基地址。当它为0时,表示任务的数量为0,也就是没有任务。在创建了第一个任务后,应当把该任务的TCB线性基地址填写到这里。
;声明任务控制块(TCB)链,初始值
tcb_chain dd 0
2)每个TCB的第一个双字,也是一个双字长度的指针,用于指向下一个任务的TCB。如果该位置为0表明没有下一个任务,这个是链上的最后一个任务,否则它的数值就是下一个任务的TCB线性基地址。
创建任务控制块:分配创建TCB所需要的内存空间,并将其挂在TCB链上。
mov ecx,0x46 ;要分配46个字节的空间,TCB占用0x46个字节。
call sys_routine_seg_sel:allocate_memory ;执行分配内存的过程,返回ECX起始的线性地址
call append_to_tcb_link ;将任务控制块追加到TCB链表
分配内存的方法 allocate_memory 和上一章节一样,分配在 0x00010000 之后的可用空间里。
追加到TCB链表使用 append_to_tcb_link 过程,主要思路就是:遍历整个链表,找到最后一个TCB,在它的TCB指针域例写入新TCB的首地址。大体流程如下图:
整体代码不难理解。
append_to_tcb_link: ;在TCB链上追加任务控制块
;输入:ECX=TCB线性基地址
push eax
push edx
push ds
push es
mov eax,core_data_seg_sel ;令DS指向内核数据段
mov ds,eax
mov eax,mem_0_4_gb_seg_sel ;令ES指向0..4GB段
mov es,eax
mov dword [es: ecx+0x00],0 ;当前TCB指针域清零,以指示这是最
;后一个TCB
mov eax,[tcb_chain] ;TCB表头指针
or eax,eax ;链表为空?
jz .notcb
.searc:
mov edx,eax
mov eax,[es: edx+0x00] ;获取下一个TCB的前4个字节,即指向下一个TCB的地址的值
or eax,eax ;验证是否为0,为0则为最后一个
jnz .searc ;不为0,继续搜索
mov [es: edx+0x00],ecx ;为0,表示最后一个tcb,则设置前4个字节指向新TCB的基地址
jmp .retpc
.notcb:
mov [tcb_chain],ecx ;若为空表,直接令表头指针指向TCB
.retpc:
pop es
pop ds
pop edx
pop eax
ret
不难发现,TCB任务快也是通过 allocate_memory 分配内存,也是分配在 0x00010000 起始的空闲空间。用户程序也是这样。TCB单独存放会不会更好?
使用栈传递过程参数
接下来是加载和重定位用户程序,过程是 load_relocate_program,和上一张不同,参数使用栈进行传递。
将参数压入栈:调用 load_relocate_program 过程前先将参数压入栈。
push dword 50 ;用户程序位于逻辑50扇区
push ecx ;压入任务控制块起始线性地址
开始加载用户程序:接着进入到 load_relocate_program 执行。
load_relocate_program: ;加载并重定位用户程序
;输入: PUSH 逻辑扇区号
; PUSH 任务控制块基地址
;输出:无
pushad ;push all data,用于将当前程序的所有寄存器的值依次压入堆栈中。
push ds
push es
mov ebp,esp ;为访问通过堆栈传递的参数做准备
mov ecx,mem_0_4_gb_seg_sel ;es指向4G空间
mov es,ecx
mov esi,[ebp+11*4] ;ebp默认用ss栈段寄存器,从堆栈中取得TCB的线性地址
..
pop es
pop ds
podad
ret 8 ; why? ;丢弃调用本过程前压入的参数
执行mov ebp,esp指令后的栈状态:
一些疑问解答:
1)为什么取得TCB线性地址是ebp+11*4?
因为栈的的内容从高到低分别是扇区号、TCB线性地址、EIP、8个双字、DS、ES,然后栈又是往低地址走的。
2)为什么DS和ES占用两个空间?
在32位模式下,访问栈用的是栈指针寄存器ESP,而且,每次栈操作的默认操作数大小是双字。
3)为什么有一个EIP?
因为前面 call oad_relocate_program 指令,要压入返回地址,因为是相对近调用,所以只需要栈EIP内容,而不用段寄存器CS的内容。
加载用户程序
和上一章加载用户程序不同,该章使用LDT来安装用户程序自己的段描述符。每个任务都允许有自己的LDT,而且可以定义在任何内存位置。
主要步骤:
- 分配一块内存,作为LDT来用,为创建用户程序各个段的描述符做准备;
- 将LDT的大小和起始线性地址登记在任务控制块(TCB)中;
- 分配内存并加载用户程序,并将它的大小和起始线性地址登记到TCB中。
创建LDT:申请分配160字节的内存空间用于创建LDT,并登记LDT的初始界限和起始线性地址到TCB中。
mov ecx,160 ;分配160个字节的内存,每个段描述符8字节,允许安装20个LDT描述符
call sys_routine_seg_sel:allocate_memory
mov [es:esi+0x0c],ecx ;登记LDT基地址到TCB中
mov [es:esi:0x0a],0xffff ;登记LDT初始的界限到TCB中
其中:0x0a为LDT界限,0x0c为LDT基地址这个可以参考TCB模块信息。
开始加载用户程序:和上一章节类似,有几点不太一致。
- 扇区号是从栈中得到的。
- 加载完成后,需要将程序基地址写入到TCB中。
创建局部描述符表
上一张是在GDT中创建用户程序描述符,本章在LDT中创建段描述符。
获得程序加载基地址:后面创建段描述符需要用到。
mov edi,[es:esi+0x06] ;从TCB中获取到程序加载基地址
安装程序头部描述符:需要参数头部段起始线性地址eax、头部段界限ebx和属性ecx。
mov eax,edi ;程序头部起始线性地址
mov ebx,[edi+0x04] ;程序头部段长度
dec ebx ;段长度减1就是段界限
mov ecx,0x0040F200 ;字节粒度的数据段描述符,特权级3
;二进制:00000000_0_1_0_0_0000_1_11_1_0010_0000_0000
;格式: 段基址_G_D_L_AVL_段界限_P_DPL_S_TYPE_段基地址23~16
call sys_routine_seg_sel:make_seg_descriptor ;创建段描述符
;安装头部段描述符到LDT中
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt
;登录头部段选择子到TCB中和头部内
or cx,0000_0000_0000_0011B ;设置选择子的特权级为3
mov [es:esi+0x44],cx ;登记程序头部段选择子到TCB
mov [edi+0x04],cx ;和头部内
make_seg_descriptor 过程同15章的类似。
fill_descriptor_in_ldt 过程用来在LDT中安装一个新的描述符。
fill_descriptor_in_ldt: ;在LDT内安装一个新的描述符
;输入:EDX:EAX=描述符
; EBX=TCB基地址
;输出:CX=描述符的选择子
;用到的寄存器需要保存
push eax
push edx
push edi
push ds
mov ecx,mem_0_4_gb_seg_sel ;获取4GB数据段描述符
mov ds,ecx
mov edi,[ebx+0x0c] ;从TCB中获得LDT基地址
xor ecx,ecx
mov cx,[ebx+0x0a] ;从TCB中获取LDT界限,
inc cx ;LDT的总字节数、即新描述符偏移地址
;安装第一个时界限是0xFFFF,加1后就是0x0000开始。
mov [edi+ecx+0x00],eax ;安装描述符低位
mov [edi+0cx+0x04],edx ;安装描述符低位
add cx,8 ;LDT的总字节长度
dec cx ;得到LDT新的界限值,就是总字节长度-1
mov [ebx+0x0a],cx ;更新新界限值到TCB
;计算选择子
mov ax,cx ;计算选择子的索引号,余数可以不管
xor dx,dx
mov cx,8
div cx
mov cx,ax
shl cx,3 ;左移3位,并且
or cx,0000_0000_0000_0100B ;使TI位=1,指向LDT,最后使RPL=00
pop ds
pop edi
pop ebx
pop eax
ret ;近调用用ret
之所把程序头部段选择子更新到TCB中,主要应该还是考虑到应用程序头部段包含了很多应用程序本身的信息。
安装用户程序代码段:和安装头部段类似,就是不用登记到TCB中了。
mov eax,edi
add eax,[edi+0x0c] ;代码起始线性地址
mov ebx,[edi+0x10] ;段长度
dec ebx ;段界限
mov ecx,0x0040f800 ;字节粒度的代码段描述符,特权级3
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0011B ;设置选择子的特权级为3
mov [edi+0x0c],cx ;登记代码段选择子到头部
安装用户程序数据段:和安装用户程序代码段一致。
;建立程序数据段描述符
mov eax,edi
add eax,[edi+0x14] ;数据段起始线性地址
mov ebx,[edi+0x18] ;段长度
dec ebx ;段界限
mov ecx,0x0040f200 ;字节粒度的数据段描述符,特权级3
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0011B ;设置选择子的特权级为3
mov [edi+0x14],cx ;登记数据段选择子到头部
安装用户程序栈段:
;建立程序堆栈段描述符
mov eax,edi
add eax,[edi+0x1c] ;数据段起始线性地址
mov ebx,[edi+0x20] ;段长度
dec ebx ;段界限
mov ecx,0x0040f200 ;字节粒度的堆栈段描述符,特权级3
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0011B ;设置选择子的特权级为3
mov [edi+0x1c],cx ;登记堆栈段选择子到头部
重定位U-SALT表
用户程序各个段的描述符位于LDT中,尽管已经安装,但还没有生效(还没有加载局部描述符表寄存器LDTR)。在这种情况下只能通过4GB的段来访问U-SALT。
mov eax,mem_0_4_gb_seg_sel ;这里和前一章不同,头部段描述符
mov es,eax ;已安装,但还没有生效,故只能通
;过4GB段访问用户程序头部
指向核心数据段
mov eax,core_data_seg_sel
mov ds,eax
中断向量表尚未建立,故关中断。
cld
获取U-SALT条目数和段内偏移
mov ecx,[es:edi+0x24] ;U-SALT条目数(通过访问4GB段取得)
add edi,0x28 ;U-SALT在4GB段内的偏移
;edi在前面已经设置为指向用户程序起始加载地址的。
重定位过程就是找到名字相同的C-SALT条目,把它的地址部分复制到U-SALT的对应条目中。第15章里,复制的是6位的代码段选择子和32位的段内偏移。在这里,这些地址是调用门选择子和段内偏移。
当时,在创建这些调用门时,选择子的RPL字段是0。也就是说这些调用门选择子的请求特权级是0。当它们被复制到U-SALT中时,应当改为用户程序的特权级为3。
mov ax,[esi+4]
or ax,0000000000000011B ;以用户程序自己的特权级使用调用门
;故RPL=3
mov [es:edi-252],ax ;回填段选择子
创建0、1和2特权级的栈
通过调用门的控制转移 通常会改变当前特权级CPL,同时还要切换到与目标代码段特权级相同的栈。因此必须为每个任务定义额外的栈。对于当前的3特权级任务来说,应当创建特权级0、1和2的栈。而且应当将它们定义在每个任务自己的LDT中。
这些额外的栈是动态创建的,而且需要登记在任务状态段(TSS)中,以便处理器固件能够自动访问到它们。但是现在还没有创建TSS,需要先将这些栈信息登记在任务控制块(TCB)中暂时保存。
先取得TCB基地址:
mov esi,[ebp+11*4] ;从堆栈中取得TCB的基地址
创建0特权级栈:开始创建0特权级的栈段。
1)声明段界限:要创建的栈段时向上扩展的段,段界限的粒度是4KB。如果栈的尺寸是4KB,那么段界限就是0。
mov ecx,0 ;粒度为4KB,尺寸为4KB,段界限为0
mov [es:esi+0x1a],ecx ;写入TCB
2)申请内存空间:申请一个4KB的内存空间,并用作栈段。
inc ecx ;将界限+1
shl ecx,12 ;左移12位,相当于乘以4096,这样就得到栈段的长度,字节为单位
push ecx ;因为申请内存要使用ecx,所以这个先压栈
call sys_routine_seg_sel:allocate_memory ;申请内存空间
mov [es:esi+0x1e],ecx ;将线性地址保存到TCB中
3)创建描述符:可以开始创建这个栈段的描述符了。
mov eax,ecx ;段基地址:上面ecx保存了段线性地址。
mov ebx,[es:esi+0x1a] ;段界限
mov ecx,0x00c09200 ;段属性:向下扩展,4KB粒度,读写,特权级0
; 0000_0000_1100_0000_1001_0010_0000_0000
call sys_routine_seg_sel:make_seg_descriptor ;创建描述符
4)安装描述符:安装到到局部描述符表LDT中。
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt ;参数 edx:eax描述符,ebx为TCB基地址
5)登记到TCB中。
;cx是 fill_descriptor_in_ldt 返回的选择子
mov [es:esi+0x22],cx ;登记特权级堆栈选择子到TCB中。
pop dword [es:esi+0x24] ;登记ESP到TCB中。
;此处内核栈顶是前面push的ecx,是栈的总字节大小。
;因为是向下扩展的栈段,所以栈初始指针就是字节总大小,即4096,十六进制0x1000
创建1特权级的栈:和创建0特权级栈类似,就是特权级为1
;创建1特权级堆栈
mov ecx,0
mov [es:esi+0x28],ecx ;登记1特权级堆栈尺寸到TCB
inc ecx
shl ecx,12 ;乘以4096,得到段大小
push ecx
call sys_routine_seg_sel:allocate_memory
mov [es:esi+0x2c],ecx ;登记1特权级堆栈基地址到TCB
mov eax,ecx
mov ebx,[es:esi+0x28] ;段长度(界限)
mov ecx,0x00c0b200 ;4KB粒度,读写,特权级1
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0001 ;设置选择子的特权级为1
mov [es:esi+0x30],cx ;登记1特权级堆栈选择子到TCB
pop dword [es:esi+0x32] ;登记1特权级堆栈初始ESP到TCB
创建2特权级的栈:和创建0特权级栈类似,就是特权级为2
;创建2特权级堆栈
mov ecx,0
mov [es:esi+0x36],ecx ;登记2特权级堆栈尺寸到TCB
inc ecx
shl ecx,12 ;乘以4096,得到段大小
push ecx
call sys_routine_seg_sel:allocate_memory
mov [es:esi+0x3a],ecx ;登记2特权级堆栈基地址到TCB
mov eax,ecx
mov ebx,[es:esi+0x36] ;段长度(界限)
mov ecx,0x00c0d200 ;4KB粒度,读写,特权级2
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0010 ;设置选择子的特权级为2
mov [es:esi+0x3e],cx ;登记2特权级堆栈选择子到TCB
pop dword [es:esi+0x40] ;登记2特权级堆栈初始ESP到TCB
安装LDT描述符到GDT中
为什么安装LDT描述符到GDT中?
局部描述符(LDT)和全局描述符(GDT)都用来存放各种描述符,但是它们也是内存段,但是因为它们用于系统管理故称为系统的段或系统段。
全局描述符(GDT)是唯一的,全局只有一个,所以只需要用寄存器GDTR存放线性基地址即可;但LDT不同,每个任务一个。所以为了追踪它们,处理器要求在GDT中安装每个LDT的描述符。当要使用这些LDT时,可以用它们的选择子来访问GDT,将LDT描述符加载到寄存器LDTR。
LDT描述符的格式:和常规的段描述符格式类似, 就是一些位有固定值。
- LDT本身也是一种特殊的段,最大尺寸是64KB。段基地址指示LDT在内存中的起始地址,段界限指示LDT的范围;
- 描述符的G位是粒度位,适用于LDT描述符,以表示LDT的界限值是以字节为单位,还是以4KB为单位。
- D位(或者叫B位)和L位对LDT描述符来说没有意义,固定为0。
- AVL和P位的含义和存储器的段描述符相同。
- LDT描述符中的S位固定为0,表示系统的段描述符或者门描述符,以相对于存储器的段描述符(S=1),因为LDT描述符属于系统的段描述符。
- 在描述符为系统的段描述符时,即,在S=0的前提下,TYPE字段为0010(二进制),表明这是一个LDT描述符。
安装LDT描述符到GDT中:调用公共例程段的过程make_seg_descriptor创建LDT描述符,而后通过 set_up_gdt_descriptor 进行安装。
;在GDT中登记LDT描述符
;创建描述符需要段基地址eax、段界限ebx和段属性ecx。
mov eax,[es:esi+0x0c] ;从TCB中取得LDT起始的线性地址
movzx ebx,word [es:esi+0x0a] ;LDT段界限,带0扩展的传送
mov ecx,0x00008200 ;LDT描述符属性,特权级为0
;0000_0000_0000_0000_1000_0010_0000_0000
call sys_routine_seg_sel:make_seg_descriptor ;创建LDT描述符
call sys_routine_seg_sel:set_up_gdt_descriptor ;安装LDT描述符到GDT
mov [es:esi+0x10],cx ;登记选择子到TCB中。
书上写ECX是0x00408200,代码里是0x00008200?估计是书上写错了。
任务状态段TSS的格式
TSS的格式:格式如下图在16章前面章节介绍TSS的有画图了:
- TSS内偏移0处是前一个任务的TSS描述符选择子。和LDT一样,TSS也有对应的描述符(TSS描述符),而且必须安装在全局描述符表GDT中。
- SS0、SS1和SS2分别是0、1和2特权级的栈段选择子,ESP0、ESP1和ESP2分别是0、1和2特权级栈的栈顶指针。这些内容应当由任务的创建者填写,且填写后一不变更,进行特权级之间的控制转移时,处理器用来这些信息来切换栈。
- CR3和分页有关,如果没有分页,可以以为0。
- 偏移32~92的区域是处理器各个寄存器的快照部分,用于在进行任务切换时,保存处理器的状态以便将来恢复现场。
- LDT段选择子是当前任务的LDT描述符选择子。由内核或操作系统填写,以指向当前任务的LDT。该信息由处理器在任务切换时使用,在任务运行期间保持不变。
- T位用于软件调试。在多任务的环境中,如果T位是“1”,每次切换到该任务时,将引发一个调试异常中断。
- I/O映射基地址用于决定当前任务是否可以访问特定的硬件端口。
IO权限处理:IO权限的处理涉及EFLAGS的IO权限位和和IO许可位串,判断的流程如下:
1)寄存器EFLAGS:寄存器EFLAGS的IOPL位决定了当前任务的I/O特权级别。如果当前特权级CPL高于,或者和任务的I/O特权级IOPL相同时,即,在数值上:CPL<=IOPL 时,所有I/O操作都是允许的,针对任何硬件端口的访问都可以通过。
2)IO许可位串:如果当前特权级CPL低于任务的I/O特权级IOPL,也并不意味着所有的硬件端口都对当前任务关上了大门。事实上,处理器的意思是总体上不允许,但个别端口除外。至于个别端口是哪些端口,要找到当前任务的TSS,并检索I/O许可位串。
2.1)IO许可位串的结构:
每比特的取值决定了相应的端口是否允许访问。
- 为0时,允许访问;
- 为1时,禁止访问;
处理器检查I/O许可位的方法是先计算它在I/O许可位映射区的字节编号,并读取该字节,然后进行测试。比如,当执行指令
out 0x09,al ;0000 1001,即第9个端口
;该端口对应着I/O许可位映射区第2字节的第2比特(位1)
;第1个字节8位,表示端口0~端口7
;那么第9个端口,就是第二个字节的第2比特(位1)
处理器通过计算就可以知道,该端口对应着I/O许可位映射区第2字节的第2比特(位1)。于是,它读取该字节,并测试那一位。
2.2)IO许可位串在TSS中的存储:同其他和任务相关的信息一样,I/O许可位串位于任务的TSS中。
在TSS内偏移为102的那个字单元(2个字节,16位),保存着I/O许可位串(I/O许可位映射区)的起始位置,从TSS的起始处(0)算起。
如果该字单元的内容大于或者等于TSS的段界限(在TSS描述符中),则表明没有I/O许可位串。
如果有IO许可位串,那么TSS的段界限会包含许可位串,一定会大于IO映射基地址(IO映射基地址指向IO许可位串起始地址)。
所以如果IO映射基地址的内容大于或者等于TSS的段界限,那么可以说明没有IO许可位串。
如果没有IO许可位串,并且当前特权级CPL低于当前的I/O特权级IOPL,执行任何硬件I/O指令都会引发处理器异常中断。
和LDT一样,必须在GDT中创建TSS的描述符,TSS描述符中包括了TSS的基地址和界限,该界限值包括I/O许可位映射区在内。
2.3)IO映射区最后一个字节是0XFF的原因:
非常重要的一点是,I/O端口是按字节编址的。这句话的意思是,每个端口仅被设计用来读写一字节的数据,当以字或者双字访问时,实际上是访问连续的2个或者4个端口。比如,当从端口n读取一个字时,相当于同时从端口n和端口n+1各读取一字节。即,
in ax,0x3F8 ;从端口0x3F8读取2个字节的数据
;上行代码相当于同时执行:
in al,0x3F8 ;从0x3F8读取一个字节
in ah,0x3F9 ;从0x3F9读取一个字节
;以上只是一个例子,x86处理器不允许使用寄存器AH
由于这个原因,当处理器执行一个字或者双字I/O指令时,会检查许可位串中的2个,或者4个连续位,而且要求它们必须都是“0”,否则引发异常中断。麻烦在于,这些连续的位可能是跨字节的。即,一些位于前一字节,另一些位于后一字节。为此,处理器每次都要从I/O许可位映射区读连续的2字节。
这种操作方式直接导致了另一个问题。即,如果要检查的比特在最后一字节中,那么这个2字节的读操作将会越界。为防止这种情况,处理器要求I/O许可位映射区的最后必须附加额外的一个字节,并要求它的所有比特都是“1”,即0xFF。当然,它必须位于TSS的界限之内。
处理器不要求为每个端口都提供映射。对于那些没有在该区域内映射的位,处理器假定它对应的比特是“1”。例如,I/O许可位映射区的长度是11字节,那么,除去最后一个所有比特都是“1”的字节,前10字节映射了80个端口,分别是端口0到端口79,访问更高地址的端口将引发异常中断。
创建任务状态段TSS
申请104字节的内存用于创建TSS,并将起始基地址和界限值记录到TCB中。
; esi指向TCB基地址
mov ecx,104 ;TSS的基本尺寸
mov [es:esi+0x12],cx ;将TSS界限值记录到TCB中,后面要减1
dec dword [es:esi+0x12] ;界限值等于尺寸减1
call sys_routine_seg_sel:allocate_memory ;ecx返回起始基地址
mov [es:esi+0x14],ecx ;将TSS基地址记录到TCB中。
界限值必须至少是103,任何小于该值的TSS,在执行任务切换时,都会引发处理器异常中断。
登记0、1和2特权级栈的段选择子,以及它们的初始栈指针。
; esi指向TCB基地址,ecx指向TSS基地址。
mov edx,[es:esi+0x24] ;从TCB中获取0特权级栈初始ESP
mov [es:ecx+4],edx ;登记到TSS中
mov dx,[es:esi+0x22] ;从TCB中获取0特权级栈段选择子
mov [es:ecx+8],dx ;登记到TSS中
mov edx,[es:esi+0x32] ;从TCB中获取1特权级栈初始ESP
mov [es:ecx+12],edx ;登记到TSS中
mov dx,[es:esi+0x30] ;从TCB中获取1特权级栈段选择子
mov [es:ecx+16],dx ;登记到TSS中
mov edx,[es:esi+0x40] ;从TCB中获取2特权级栈初始ESP
mov [es:ecx+20],edx ;登记到TSS中
mov dx,[es:esi+0x3e] ;从TCB中获取2特权级栈段选择子
mov [es:ecx+24],dx ;登记到TSS中
登记当前任务的LDT描述符选择子:
; esi指向TCB基地址,ecx指向TSS基地址。
mov dx,[es:esi+0x10] ;从TCB中获取任务的LDT选择子
mov [es:ecx+96],dx ;登记到TSS中
填写T位,以及IO许可位映射区的地址:
mov dword [es:ecx+100],0x00670000 ;T=0,I/O位串基地址为103
;0000_0000_0110_0111_0000_0000_0000_0000
;前两个字节表示IO串基地址:0000_0000_0110_0111 ,十进制是103
; IO映射区的值大于或等于TSS的界限,所以没有IO许可位串
;最后1位表示T,为0
安装TSS描述符到GDT中
和局部描述符表(LDT)一样,也必须在GDT中安装TSS的描述符。这样做,一方面是为了对TSS进行段和特权级的检查,另一方面也是执行任务切换的需要。当 call far 和 jmp far 指令的操作数是TSS描述符选择子时,处理器执行任务切换操作。
TSS描述符的格式和LDT描述符差不多,除了 TYPE 位。
TSS描述符中的B位是“忙”位(Busy)。在任务刚刚创建的时候,它应该为二进制的1001,即B位是0表示任务不忙。当任务开始执行时或者挂起状态(临时被中断执行)时,由处理器固件把B位置为1。
先调用公共例程段内的过程 make_seg_descriptor 创建TSS描述符:
;TSS的基地址,传送到寄存器EAX;
;寄存器EBX的内容是TSS的界限;
;寄存器ECX的内容是描述符属性值;
mov eax,[es:esi+0x14] ;TSS起始线性地址
mov ebx,word [es:esi+0x12] ;段(界限)
mov ecx,0x00008900 ;TSS描述符,特权级0
call sys_routine_seg_sel:make_seg_descriptor
安装描述符到GDT中,并登记选择子到TCB中。
call sys_routine_seg_sel:set_up_gdt_descriptor ;安装描述符到GDT中。
mov [es:esi+0x18],cx ;登记TSS选择子到TCB。
带参数的过程返回指令
任务创建完毕,可以从过程 load_relocate_program 返回了。
在执行ret指令之前,需要恢复现场,也就是按相反的顺序将刚进入过程时压入栈的内容出栈。
pop es ;恢复到调用此过程前的es段
pop ds ;恢复到调用此过程前的ds段
popad
因为是近调用进入过程 load_relocate_program 的,故仅将EIP压栈,没有压入段寄存器 CS 的内容。
一旦ret指令执行完毕,控制将返回到调用者,且栈中只剩下两个参数。按道理,这两个参数是由调用者压入的,应该再由调用者弹出即可:
;调用前压入了两个参数,每个都是4字节,共8字节
push dword 50 ;用户程序位于逻辑50扇区
push ecx ;压入任务控制块起始线性地址
;调用过程
call load_relocate_program
;调整栈指针,使其越过参数
add esp,8
不过,最好的解决办法是在过程返回时,顺便弹出参数。这样做是可行的,过程的编写者最清楚栈中有几个参数。如果希望过程在返回时弹出参数,使寄存器ESP指向调用过程前的栈位置(使栈平衡),可以使用带操作数的过程返回指令:
ret imm16 ;近返回,栈指针ESP+imm16
retf imm16 ;远返回,栈指针ESP+imm16
因此当 load_relocate_program 返回时,除了将控制返回到过程的调用者,要调整栈的指针:
ret 8 ; ESP<-ESP+8,直接越过调用方法前压入的两个参数,实现栈平衡。
如果不用ret 8,那么就要在调用返回之后pop出两个调用前push的参数。
用户程序的执行
通过调用门转移控制的完整过程
从0特权级(内核)到3特权级(用户程序)转移:假装从调用门返回。
特权级检查:首先,通过调用门实施控制转移,可以使用 jmp far 和 call far 指令。指令执行时,描述符选择子必须指向调用门,32位偏移量被忽略。
特权级检查规则如下,都是数值上的,数值上越小,权限越大。
- jmp far:
- 目标代码依从:当前特权级低于等于目标代码特权级,运行时不改变当前特权级。
- 目标代码非依从:当前特权级和目标代码段的特权级相同,原因是用jmp far指令通过调用门转移控制时,不改变当前特权级CPL。
- call far:可以通过调用门将控制转移到较高特权级别的代码段。
- 目标代码依从:不改变当前特权级。
- 目标代码非依从:在目标代码段的特权级上执行。
切换栈:其次,当使用call far指令通过调用门转移控制时,如果改变了当前的特权级别,则必须切换栈。即从当前任务的固有栈切换到与目标代码段特权级相同的栈上。栈的切换是由处理器固件自动进行的。
当前栈是由段寄存器SS和栈指针ESP指示的。要切换到的新栈位于当前任务的TSS中,处理器知道如何找到它。在栈切换前,处理器要检查是否有足够的空间完成本次控制转移。
栈切换过程如下:
- 使用目标代码段的DPL(也就是新的CPL)到当前任务的TSS中选择一个栈,包括栈段选择子和栈指针。
- 从TSS中读取所选择的段选择子和栈指针,并用该选择子读取栈段描述符。在此期间,任何违反段界限检查的行为都将引发处理器异常中断(无效TSS)。
- 检查栈段描述符的特权级和类型,并可能引发处理器异常中断(无效TSS)。
- 临时保存当前栈段寄存器SS和栈指针ESP的内容。
- 把新的栈段选择子和栈指针带入寄存器SS和ESP,切换到新栈。
- 将刚才临时保存的SS和ESP的内容压入当前栈。
- 依据调用门描述符参数个数字段的指示,从旧栈中将所有参数都复制到新栈中。如果参数个数为0,不复制参数。
- 将当前栈段寄存器CS和指令寄存器EIP的内容压入新栈,通过调用门实施的控制转移一定是远转移,所以要压入CS和EIP。
- 从调用门描述符中依次将目标代码段选择子和段内偏移传送到寄存器CS和EIP,开始执行被调用过程。
如果没有改变特权级别,则不切换栈,继续使用调用者的当前栈,只在原来的基础上压入当前段寄存器CS和指令指针寄存器EIP的内容。
如果通过jmp far执行发起的,那么就没有返回,没有特权级的变化,也不需要切换栈。
call far发起的,可以使用远返回指令retf把控制返回给调用者。
从同一个特权级返回时,处理器将从栈中弹出调用者的 代码段选择子和指令指针。尽管它们通常是有效的,但是,为了安全起见,处理器依然会进行特权级检查。
要求特权级变化的远返回,只能返回到较低的特权级别上。控制返回的全部过程如下:
- 检查栈中保存的寄存器CS的内容,根据其RPL字段决定返回时是否需要改变特权级别。
- 从当前栈中读取寄存器CS和EIP的内容,并针对代码段描述符和代码段选择子的RPL字段实施特权级检查。
- 如果远返回指令是带参数的,则将参数和寄存器ESP的当前值相加,以跳过栈中的参数部分。最后的结果是寄存器ESP指向调用者SS和ESP的压栈值。
- 如果返回时需要改变特权级,从栈中将SS和ESP的压栈值代入段寄存器SS和指令指针寄存器ESP,切换到调用者的栈。
- 如果远返回指令是带参数的,则将参数和寄存器ESP的当前值相加,以跳过调用者栈中的参数部分。最后的结果是调用者的栈恢复到平衡位置。
- 如果返回时需要改变特权级,检查寄存器DS、ES、FS和GS的内容,根据它们找到相应的段描述符。要是有任何一个段描述符的DPL高于调用者的特权级(返回后的新CPL),即,在数值上, 段描述符的DPL < 返回的新CPL,那么处理器把数值0传到该断寄存器。
特权级检查:特权级检查不是在实际访问内存时进行的,而是在将选择子代入段寄存器时进行的。下面这两条指令可以非常清楚地说明这一点:
mov ds,ax ;进行特权级检查
mov edx,[0x2000] ;不进行特权级检查
处理器只在将选择子代入段寄存器时进行一次特权级检查,而在此之后的普通内存访问时,不进行特权级检查。处理器的意思是,只要你能进入大门,就证明你的确是这里的主人,随后你干什么它都不会干涉。
现在做一个假设,假设一个3特权级的应用程序通过调用门请求0特权级的操作系统服务。在进入操作系统例程后,当前特权级CPL变成0。在该例程内,操作系统可能会访问自己的0特权级数据段以进行某些内部操作。当然,它也必须先执行将选择子代入段寄存器的操作:
mov ds,ax ;操作系统自己的选择子
按道理,安全的做法是先将旧的DS值压栈,用完后再出栈。像这样:
push ds
mov ds,ax
...
pop ds
retf
但是,如果操作系统例程没有这么做,一定有它的道理,而处理器也无权干涉。唯一可以预料的是,当控制返回到应用程序时,段寄存器DS依然指向操作系统数据段。因此,应用程序就可以直接在3特权级下访问操作系统的数据段:
mov edx,[0x000C]
这是因为,特权级检查只在引用一个段的时候进行。即,只在将选择子传送到段寄存器的时候进行。只要通过了这一关,后面那些使用这个段寄存器的内存访问就都是合法的。
为了解决这个问题,在执行retf指令时,要检查数据段寄存器,根据它们找到相应的段描述符。要是有任何一个段描述符的DPL高于调用者的特权级(返回后的新CPL),那么,处理器将把数值0传送到该段寄存器。使用这样的段寄存器访问内存,会引发处理器异常中断。
特别需要注意的是,任务状态段(TSS)中的SS0、ESP0、SS1、ESP1、SS2、ESP2域是静态的,除非软件进行修改,否则处理器从来不会改变它们。举个例子,当处理器通过调用门进入0特权级的代码段时,会切换到0特权级栈。返回时,并不把0特权级栈指针的内容更新到TSS中的ESP0域。下次再次通过调用门进入0特权级代码段时,使用的依然是ESP0的静态值,从来不会改变。这就是说,如果你希望通过0特权级栈返回数据,就必须自己来做这件事,比如,在返回到低特权级别的代码段之前,手工改写TSS中的ESP0域。
进入3特权级的用户程序的执行
ds指向0~4GB内存段:
mov eax,mem_0_4_gb_seg_sel
mov ds,eax
加载任务寄存器TR和局部描述符表寄存器(LDTR):
ltr [ecx+0x18] ;ecx此时是TCB起始线性地址,加载TSS选择子
lldt [ecx+0x10] ;加载LDT的选择子
寄存器TR和LDTR格式:寄存器TR和LDTR都包括16位的选择器部分,以及描述符高速缓存器部分。
- 选择器部分的内容是TR和LDT描述符的选择子;
- 描述符高速缓存器部分的内容则指向当前任务的TSS和LDT,以加速这两个段(表)的访问。
ltr指令:加载任务寄存器TR需要使用指令ltr指令。这条指令的格式:
ltr r/m16 ;16位通用寄存器或16位单元的内存地址。
这条指令的操作数可以是16位通用寄存器,也可以是指向一个16位单元的内存地址。但不管是寄存器还是内存单元,其内容都是16位的TSS选择子。
在将TSS选择子加载到寄存器TR之后,处理器用该选择子访问GDT中对应的TSS描述符,将段界限和段基地址加载到任务寄存器TR的描述符高速缓存器部分。同时,处理器将该TSS描述符中的B位置“1”,也就是标志为“忙”,但并不执行任务切换。
该指令不影响寄存器EFLAGS的任何标志位,但属于只能在0特权级下执行的特权指令。
lldt指令:加载局部描述符表寄存器(LDTR)使用的是lldt指令,其格式和ltr是一样的:
lldt r/m16
其操作数也和ltr指令一样,但是,指向的是16位LDT选择子。ltr和lldt指令执行时,处理器首先要检查描述符的有效性,包括审查它是不是TSS或者LDT描述符。在将LDT选择子加载到寄存器LDTR之后,处理器用该选择子访问GDT中对应的LDT描述符,将段界限和段基地址加载到LDTR的描述符高速缓存器部分。
如果执行这条指令时,代入LTR选择器的选择子,其高14位是全零,寄存器LDTR的内容被标记为无效,而该指令的执行也将不声不响地结束(即不会引发异常中断)。当然,后续那些引用LDT的指令都将引发处理器异常中断(对描述符进行校验的指令除外),例如,将一个指向LDT的段选择子代入段寄存器。
任务全景图:
现在,局部描述符表(LDT)已经生效,可以通过它访问用户程序的私有内存段了。
将用户相关段数据压入栈:访问任务的TCB,从中取出用户程序头部段选择子,并传送到段寄存器DS。该选择子RPL字段的值为3,即,请求特权级为3;TI位是“1”,指向任务自己的LDT。这两条指令执行后,段寄存器DS就指向用户程序头部段。
mov ds,[ecx+0x44] ;ecx此时是TCB起始线性地址,切换到用户程序头部段
;RPL=3
;TI=1
从用户程序头部内取出栈段选择子和栈指针,以及代码段选择子和入口点,并将它们顺序压入当前的0特权级栈中。
;以下假装是从调用门返回。摹仿处理器压入返回参数
push dword [0x1c] ;调用前的堆栈段选择子
push dword 0 ;调用前的esp
push dword [0x0c] ;调用前的代码段选择子
push dword [0x08] ;调用前的eip
模拟返回:执行一个远返回指令retf,假装从调用门返回。于是控制转移到用户程序的3特权级代码开始执行。注意,这里所用的0特权级栈并非是来自TSS。不过,处理器不会在意这个。下次,从3特权级的段再次来到0特权级执行时,就会用到TSS中的0特权级栈了。
retf
jmp问题:
结合上一章,用户程序现在是工作在它的局部空间里。它可以通过调用门请求系统服务来显示字符串,或者读取硬盘数据,这都没有问题。
唯一的问题是,当它最后用jmp far指令将控制权返回到内核时,可能行不通了。这条指令是
jmp far [fs:TerminateProgram] ;将控制权返回系统
这确实是一个调用门。而且,通过jmp far指令使用调用门也没有任何问题。问题在于,当控制转移到内核时,当前特权级没有变化,还是3,因为使用jmp far指令通过调用门转移控制是不会改变当前特权级别的。
return_point: ;用户程序返回点
mov eax,core_data_seg_sel ;因为用户程序是以JMP的方式使用调
mov ds,eax ;用门@TerminateProgram,回到这
;里时,特权级为3,会导致异常。
;当前CPL=3,
;选择子core_data_seg_sel的RPL=0
;目标代码段的DPL=0
异常和异常中断的处理将在后面的章节讲述,我们现在还没有任何接管和处理异常中断的机制,所以,本章的程序运行时,虚拟机将抛出错误信息。要想本章的程序正常执行,需要将上面的jmp指令改成
call far [fs:TerminateProgam]
还需要特别提醒的是,进入3特权级的用户程序局部空间时,任务的I/O特权级IOPL是0,任务没有I/O操作的特权。
检查调用者的请求特权级RPL
检查请求特权级RPL:为了访问一个段,首先需要将段选择子代入段寄存器,这也是处理器进行特权级检查的大好机会:
mov fs,cx
在绝大多数情况下,请求访问一个段的程序也是段选择子的提供者。就是说,当前特权级和请求特权级是相同的,即,RPL=CPL。
一般来说,用户程序的特权级别很低,而且不能执行I/O操作。假设操作系统提供了一个例程,可以从用户程序那里接受三个参数:逻辑扇区号、数据段选择子和段内偏移量,然后读硬盘,并把数据传送到用户程序的缓冲区内。为了使用户程序可以调用此例程,操作系统把它定义成调用门。
用户程序会提供一个RPL为3的段选择子给操作系统例程。通过调用门实施控制转移后,当前特权级CPL变成0,实际的请求者是用户程序,选择子的请求特权级RPL为3,要访问的段属于用户程序,其描述符的DPL为3,在数值上符合CPL<=DPL,并且RPL<=DPL的条件,可以正常执行。
大概流程如下:
用户程序 -》调用门 -》读取硬盘数据 -》放入用户程序缓冲区
RPL=3 -》CPL=0 -》CPL=0 -》DPL=3
符合 CPL<=DPL,RPL<=DPL 的条件
RPL为3的段选择子,就是三个参数之一的:数据段选择子,
内核通过数据段选择子写入用户程序缓冲区。
安全漏洞:借助于刚才那个调用门。提供的是一个RPL为0的选择子,而且该选择子指向操作系统的段描述符。此时,当前特权级CPL为0,请求特权级RPL为0,目标数据段描述符的DPL为0,同样符合在数值上符合CPL≤DPL,并且RPL≤DPL的条件,并且允许向内核数据段写入扇区数据!
大概过程如下:
用户程序 -》调用门 -》读取硬盘数据 -》访问内核数据段
RPL=0 -》CPL=0 -》CPL=0 -》DPL=0
符合 CPL<=DPL,RPL<=DPL 的条件
解决办法:
- 处理器负责检查请求特权级RPL,判断它是否有权访问;
- 内核或者操作系统负责鉴别请求者的身份,保证RPL的值和它的请求者身份相符。
简单说就是用户程序提供数据段选择子的RPL需要操作系统或内核去验证,这样就可以避免这个问题了。
aprl指令:为了帮助内核或者操作系统核查请求者的身份,并提供正确的RPL值,处理器提供了arpl指令。arpl指令的作用是调整段选择子RPL字段的值(Adjust RPL Field of Segment Selector),其格式为
aprl r/m16,r16
该指令比较两个段选择子的RPL字段,目的操作数可以是包含了16位段选择子的通用寄存器,或者指向一个16位单元的内存地址,该字单元里存放的是段选择子;源操作数只能是包含了段选择子的16位通用寄存器。
该指令执行时,处理器检查目的操作数的RPL字段,
- 如果它在数值上小于源操作数的RPL字段,则设置ZF标志,并增加目的操作数RPL字段的值,使之和源操作数RPL字段的值相同。
- 否则,ZF标志清零,而且除此之外什么也不会发生。
arpl是典型的操作系统指令,它通常用于调整应用程序传递给操作系统的段选择子,使其RPL字段的值和应用程序的特权级相匹配。
简单就是说保证安全问题,如果应用程序的特权级是3,如果传递给操作系统的数据段选择子是0,那么操作系统可以通过arpl指令对比应用程序的特权级和数据段选择子特权级,并调整数据段选择子的特权级和用户程序特权级一致。
这样,为了防止恶意的数据访问,操作系统应该从当前栈中取得用户程序的代码段选择子(调用者代码段寄存器CS的内容)作为源操作数,并把作为参数传递进来的数据段选择子作为目的操作数,来执行arpl指令,把数据段选择子的请求特权级RPL调整(恢复)到调用者的特权级别上。
用aprl命令描述就是:
aprl 调用者代码段寄存器CS的内容, 数据段选择子
;调用者代码段寄存器CS的内容可以从栈中取得
; 因为用户程序调用内核例程需要通过call,call会压入调用者的CS和EIP。
一旦调整了请求特权级,那么,当前特权级CPL为0,请求特权级RPL为3,数据段描述符特权级DPL为0,数值上并不符合CPL≤DPL且RPL≤DPL的条件,禁止访问,并引发处理器异常中断。
在Bochs中调试程序的新方法
这章节的内容确实非常复杂,如果是自己编写,少不了要调试程序。
info gdt:查看GDT中的段描述符和门描述符。
info tss:显示TSS状态。
完