《x86 汇编语言从实模式到保护模式》
第14章:任务和特权级保护
读书笔记
前言
不想把书上的内容复述一遍,除了概念的定义和解析,本文大多是我本人的理解,有不同意见欢迎交流。
原理部分
概念梳理
-
特权级
操作提供的特权级是操作系统特权级保护的基础。(毕竟没有特权级的定义,何来保护呢)
操作系统特权级分为四个等级:
-
ring0 :
操作系统的内核程序都处于ring0的特权级,因为操作系统需要管理和调度所有程序使用的资源,因此需要无障碍的访问所有内存段。
-
ring1 和 ring2:
可靠性低于操作系统内核的系统服务程序。例如设备驱动程序。但是现代操作系统将一些设备驱动器也设置为ring0级别。
-
ring3:
普通的用户程序都是运行在这个最低特权级下的。
-
-
段选择子
保护模式下,任何程序运行过程中,如果想要访问指定内存地址中的数据或代码,则段寄存器中必须存有你想要访问的段的段选择子。
在特权级保护的视角中,主要关注RPL字段:RPL(Requested Privilige Level)字段声明当前段选择子是以何种身份去访问目的段描述符的。举个例子说明上面这句话:
比如我写了一个程序,这个程序中有这么一条指令:
mov ax,0x0010
mov ds,ax
通过对照上面段选择子的图可以看到,RPL的值是0,上面的指令的意思是:当前程序(注意是用户程序,特权级为3),想要以特权级为0来访问GDT表(TI=0)的第2项。
注意到虽然我的程序的特权级是3,但是同样可以使用上面这两行mov来给ds赋值一个和我这个程序”身份不符”的段选择子,这样的赋值自然是不可行的,本章介绍的机制就是用来检测这样的非法赋值的。
- 段描述符
这里只关注段描述符中的DPL(Descriptor Privilige Level)字段,GDT表中的段描述符一般只由操作系统内核定义,因此可以说是绝对可靠的(操作系统作者给你留了个后门那是啥办法都没,所以用开源的linux吧2333),所以其中的DPL字段也应该是操作系统为了实现特权级保护精心设置的。操作系统如何使用DPL来保护特权级将会在下面介绍。
-
段寄存器
段寄存器用于存储需要访问的段的段选择子,x86体系结构下的段寄存器有:DS(Data),SS(Stack),CS(Code),ES(Extra),FS,GS。
FS和GS没有全称,只是按照字母序,ES下面就该是FS和GS,它们的作用是作为扩展段寄存器(比如内核中常ES用来存储显存的段选择子)
-
CPL(Current Privilege Level)
这个值存储在当前正在运行的代码段的段寄存器中。道理也简单:当前特权级,只得就应该是当前执行代码的cs中的最后两位(也就是上面段选择子中的RPL字段对应的值,但是这里不应解释为RPL,而是CPL)
-
CPL 和 RPL 的关系
CPL和RPL可以理解为段选择子的同一位置的值在不同时机的不同含义。即,当数据成功存入cs时(之后会介绍并不是什么数据都能够顺利存入cs的),cs的最低两位的含义就是CPL;而数据仅仅是想要存到段寄存器中,却还没有通过存入前的检测时,这时的最后两位称为RPL(具体原因看完下面的特权级保护机制分析就明白了)
如果是一个正常的程序,RPL和程序的CPL是相同的,但是在一些特殊情况下,这两者会不同,具体将在下文介绍。
-
门(gate)
门指的是一种和段描述符类似但是又有所不同的东西,一般也存储在GDT表中。
这里只关注调用门描述符:描述的是一段可执行代码,比如一个操作系统定义的公用例程(可以粗略地将门理解为一个系统函数代码段的段描述符,但是描述符格式有所不同,描述符中的type和s字段用来区分是段描述符还是门描述符)
-
依从的代码段
上面的段描述符的结构图中有一个type字段,其中有一个位称为C位,这个位如果为0,则表示这个段描述符对应的代码段只能够被同特权级的程序使用,否则如果C=1,则称这样的代码段为“依从的代码段”,可以从特权级比它低的程序调用进入。
如果要调用依从的代码段,有一个条件,就是要求调用者的代码段的CPL >= 目标代码段的DPL。
也就是说,如果一个依从的代码段的DPL为1,那么它只能够被CPL为1,2,3的代码段调用,而不能被CPL为0的代码段调用。(我觉得这样设置的原因应该是书中反复强调的“任何时候都不能将控制从高特权级转移到较低特权级”,但是这种说法似乎和iret这样的中断返回指令矛盾?由于作者之后也提到了中断返回的特例,这里就当做作者笔误吧,但是作者想要强调的应该还是控制流难以从高特权级转向低特权级这一事实)
注意,依从的代码段不是在它的DPL的特权级上运行,而是在调用程序的特权级上运行,也就是说,进入依从的代码段执行后,cs中的CPL字段和进入之前一样。
-
特权级提升
用户程序程序运行时,调用系统函数是常有的事,这就涉及到特权级的问题。通过调用上面说的依存的代码段是一种调用系统函数的方法,但是正如上面说的,依存的代码段具有和调用者相同的特权级,这样诸如一些访问高特权级的设备驱动(比如往屏幕上打印字符串)这样的工作就无法完成了(因为特权级不够)。
因此,需要一种能够使用户程序能够”暂时“提升特权级,以对系统的高级功能进行访问。通过上面介绍的调用门就可以实现。
书中介绍了两种使用调用门的方法:
-
jmp far
使用远跳转调用调用门的方法,效果上类似于使用依存的代码段,因为调用前后CPL仍然没有改变。
-
call far
这种方式会改变CPL到目标代码段的特权级。
按我理解,jmp和依存代码段的方法相当于用户程序告诉操作系统:嘿!我要做xxxx事,你把做这些事的方法交给我,然后我自己做。
而call方法则是这样的情景,用户程序告诉操作系统:嘿!我要做xxxx事,我告诉你一些参数,你帮我做一下吧!于是把控制权交给操作系统(CPL提升为0)操作系统做完之后,说:我做完了,你继续做你的事吧。然后交还控制权给用户(CPL降为3)。
-
x86的特权级保护机制
-
还是从上面的那两行代码讲起,这里需要提醒的是这是我编写的用户程序中的代码,其运行时的特权级(也就是CPL)是3:
mov ax,0x0010 mov ds,ax
第一条mov指令是由于处理器不允许直接用立即数给ds赋值,因此用ax作中间量;那么重点是第二条mov,当处理器看到一个将要改变段寄存器的值的mov指令时会做什么呢?
首先处理器读取并分析ax的值:
通过对照上面那张段选择子的图片,不难得到该段选择子(0x0010)蕴含如下信息:访问的段描述符位于GDT表(TI为0)的第2号偏移处
以特权级为0的身份来访问上述段描述符
假设GDT表的第2号偏移处的段描述符中的DPL的值是0
处理器会比较读取的RPL和段描述符中的DPL值的大小,当RPL,CPL,DPL三者同时满足下列两个条件:
条件1:RPL<= DPL;
条件2:CPL<=DPL;
那么该mov指令允许执行,否则拒绝执行,抛出异常。
首先,因为RPL=0,DPL也等于0,条件1是满足的;
但是条件2不满足,因为用户程序的CPL是3,大于0。
通俗点说,上述mov指令执行时就是这样一个场景:
我现在是特权级为0的“超级VIP”(但是我实际上是特权级为3的菜鸡),现在我要访问GDT表的第二项,处理器你快来看看我有没有资格访问这个内存段!
处理器屁颠屁颠跑过来,看到我声明我的特权级是0,哇超级VIP啊,再一看我身上穿的衣服,咦咋是ring3的菜鸡穿的衣服?滚滚滚,你没资格访问这段内存。
-
讲道理像上面这样具有攻击性的访问(低特权级用户试图访问高特权级的内存空间),处理自然是应该拒绝执行的,但是书中提供了一种利用调用门"提高"自身CPL来实现使低特权级程序访问高特权级内存的方法,如下:
说是”提高”,本质上是利用调用门调用系统函数,并通过系统函数(CPL=0)来实现对内核数据段的操作。下面是书中给出的例子,很清晰地说明了这个问题:
假设有一个系统例程(命名为read_disk),作用是从硬盘中读取某个扇区到指定的内存中,它接收三个参数:需要读入的扇区的逻辑扇区号(通过EAX传递),目标数据段选择子(CX),目标段内偏移(EBX)。也就是说,用户程序将相应的参数传给对应寄存器后,再执行
call read_disk
,操作系统就会到指定的扇区中读取一个扇区的数据到指定的内存空间。注意到传给CX的值是用户程序决定的。因此,一个想改变内核数据段内容的用户程序有可能传入
0x0010
这样的值给CX(假设该用户程序通过某种途径获知内核代码段的段描述符就在GDT表的第2项)。再通过往想要读取的扇区中布置数据,就可修改系统GDT表的内容为自己想要的任意值。这个攻击之所以能够奏效,是因为处理器无法识别这个
0x0010
究竟是系统函数自己产生的,还是用户程序传进系统函数的参数。处理器能做的,仅仅是根据CPL、RPL和DPL的大小关系来确定某个操作是否能够顺利执行罢了,在上面的攻击中,由于修改数据段寄存器的mov操作是在系统代码的特权级中执行的(CPL=0)因此CPL<=DPL;且RPL是由用户程序传进的0x0010(RPL = 0),满足RPL<=DPL。
从上面的分析中能够看到,目前介绍过的特权级保护机制是有漏洞的,因此,为了防止上述攻击,操作系统有自己的措施:
当控制流从用户程序通过调用门进入系统函数时,如果参数中有一个被定义为段选择子,则操作系统检查并调整段选择子的最后两位,使其与调用者的“身份”相符。这样做相当于操作系统给这个段选择子的RPL加了一个”防伪标识”,保证这个段选择子和请求操作这个段选择子的用户程序具有相同的特权级,从而防止用户程序对内核段的越权访问。
操作系统使用系统指令 arpl
(Adjust RPL Field of Segment Selector)来实现上述检查和调整:
arpl指令格式:arpl r/m16,r16
这个指令的目的操作数和源操作数都存储一个段选择子,只不过目的操作数可以从内存和寄存器中获取选择子而源操作数只能从寄存器中获取选择子。这条指令执行时,处理器检查目的操作数的RPL字段,如果它在数值上小于源操作数的RPL字段,则ZF标志置位,并增加目的操作数的RPL值到源操作数的RPL值;否则,将ZF标志位清零。
一般情况下,操作系统将调用者代码段的段寄存器cs的内容作为源操作数(从栈中取得,因为最终要返回到调用者指令空间,因此栈中一定存有调用者代码段cs的内容),将作为参数传进来的数据段选择子作为目的操作数,执行arpl指令后,就完成了RPL的调整。
也就是说,源操作数的RPL是0时,目的操作数的RPL可以是0,1,2,3.(也就是说,调用者的特权级越高,越能够通过arpl的检查);当源操作数是3时,目的操作数不论是0,1还是2,都将被arpl调整为3,因为低特权级的程序无权请求对高特权级的内存空间的访问。
有了上述机制之后,之前的那种通过传参的攻击就失效了,因为传入的参数中的0x0010会被操作系统修改为0x0013,这样,在read_disk函数中,试图执行mov ds,cx
这样的指令时,由于之前说过的RPL、DPL 、CPL之间的关系的要求,这条指令将被处理器拒绝执行。)
实验验证
实验实现了一个操作系统内核,内核代码运行在0特权级上,用户代码由内核加载到内存,且运行在特权级3上。
新概念
由于特权级为3的用户程序调用内核代码(公共例程)时,需要发生特权级的切换,这就涉及到栈切换、现场保护等工作,而在x86处理器中,这些工作需要通过任务切换来实现,因此实验中出现了一些之前没见过的数据结构:
-
TCB
实际情况下,一个单核处理器中可能同时要在多个任务间来回切换,为了记录当前共有多少个任务在处理器上运行,并且能够方便地访问这些任务的相关数据,处理器使用TCB(Task Control Block)来管理正在执行的任务。
TCB的结构如下:
每个任务都有对应的TCB,所有正在执行的任务的TCB构成一个单向链表,每个TCB中的第一个双字字段都存储着下一个TCB的基址,第一个TCB的基址存在一个特定的内存单元中,每次操作系统新建一个任务,都会新建一个TCB,并且链接在TCB链的末尾,这样处理器就可以通过链表访问和管理正在执行的所有任务了。
-
TSS
TSS比较熟了,放一个结构在这里方便查看。
-
调用门描述符格式
调用门描述符、LDT描述符、TSS描述符本质上是不同类型的段描述符,处理器使用不同的标志位来区分各种描述符,并以不同的格式解析描述符内容。
- LDT描述符格式
- TSS描述符格式
加载特权级为3的用户程序
安装系统服务的调用门
如前所述,在有特权级保护的操作系统中,低特权级程序想要“提高”自身特权级来使用高特权级的代码,就必须使用调用门的方式,因此在操作系统运行之初,应该为每个供用户程序使用的公共例程安装相应的调用门。
内核提供的公共例程都记录在C-SALT表中(SALT表内容在引导程序加载内核时被填充)。SALT表中的表项,前256字节为公共例程名,用于用户程序SALT表的重定位。从256字节开始的4个字节是该公共例程的偏移地址,接下来的2个字节是该公共例程的段选择子,从上面的调用门描述符格式中可以看出,调用门描述符中存储了目标代码段(公共例程代码)的段描述符和偏移。因此,安装调用门描述符的过程实质上是将SALT表中的公共例程段选择子和偏移以指定格式存储到GDT表中,作为GDT表中的一项,供低特权级的用户程序调用(低特权级的用户程序不可能直接调用高特权级代码)。
本书的代码中调用名为make_gate_descriptor
的内核代码来从SALT表项构造调用门描述符,调用这个函数需要传入的三个参数分别存储在eax,bx,cx中:
调用之后这个函数会将段描述符的值放在EDX:EAX中返回:
紧接着,程序调用set_up_gdt_descriptor
函数,用得到的EDX:EAX的值构造GDT表的表项,填充到GDT表中。
set_up_gdt_descriptor
调用前的gdt表:
调用后的gdt表,末尾多了一项调用门,注意DPL被设置为3,表明这个调用门可以被特权级小于或等于3的任何程序使用。
安装完成后的GDT表如下:
由于之前的SALT表是由引导程序填充的,其中每一项的段和偏移值都是每个函数在内核代码段中的段选择子和偏移地址。因此应该把刚刚为公共例程安装好的调用门的段选择子回填到C-SALT表中。
这里需要明白的是,C-SALT表中的值是在用户程序重定位时直接赋值给用户的SALT表的,用户程序对系统公共例程的调用也必须通过用户SALT表得到相应的段描述符和偏移值,如果直接把系统段描述符和偏移赋值给用户的SALT表,则在特权级保护的机制下,调用必然失败,因此需要回填调用门的段选择子。
还需要注意到的是,回填的时候仅仅回填段描述符的值到C-SALT表中,这是因为调用门描述符中存着一个偏移值,因此如果处理器检测到当前cs中存储的是一个调用门描述符的段选择子,就会忽略EIP中的偏移值,转而到调用门描述符中寻找偏移地址更新到EIP中。set_up_gdt_descriptor
函数是为操作系统内核设置GDT表的值并返回相应段选择子的函数,因此返回的段选择子的RPL为0,这也是为什么后面重定位用户的SALT表的时候需要将每个C-SALT表项的段描述符的RPL都改为3的原因。
循环整个C-SALT表,按上述步骤安装好所有调用门描述符之后,紧接着是一个调用门测试:
829 mov ebx,message_2
830 call far [salt_1+256] ;通过门显示信息(偏移量将被忽略)
如果调用门描述符被正确地安装到GDT表并回填到了C-SALT中,那么上述命令的执行结果将在屏幕上打印message_2的内容:
建立TCB
接着是为用户程序创建TCB,就像之前介绍的,TCB是管理系统中正在执行的任务的结构单元,但是并不是每个操作系统中都有TCB,只不过本书的作者使用这样的方法来方便管理任务罢了。
这里的TCB和维基百科上的Task Control Block虽然英文是一样的,但是维基百科上的TCB是PCB(Process Control Block)的一个实例,执行的是PCB的关于任务调度、IO管理以及性能监视等功能,和这里说的TCB含义不一致。这里的TCB仅仅是作者自己定义的一个数据结构罢了。
构造和安装TCB的主要过程是:
- 为TCB申请一块固定大小的内存空间(0x46字节),内存空间的第一个双字设置为0.(表示这个TCB之后没有其他TCB了)
- 找到内存中存储第一个TCB的地址的内存单元(代码中命名为tcb_chain),根据这个内存单元指向的单向链表找到链表末尾的TCB,将这个TCB的第一个双字的值修改为上一步申请的TCB的地址。
- 在之后的准备工作中,将会陆续产生一些需要填到TCB中的值,当TCB中的所有表项都填满之后,TCB就能够很方便地描述和控制一个任务了。
安装TCB之后,进入用户程序的加载和重定位阶段。
安装LDT表
代码接着调用了名为load_relocate_program
的函数来执行用户程序的加载和重定位,在调用这个函数之前,首先将两个参数压入栈中(系统栈)。
栈的情况如下:
进入这个函数之后,首先是一系列保护现场的压栈操作,之后是一句mov ebp,esp
将当前esp的值赋值给ebp,这行代码的目的是用ebp记录现在esp的值,方便之后之前压入栈中的参数,这行代码执行结束之后,栈的情况如下:
由于ebp记录了当前栈顶的位置,以后通过访问[ebp + 11 * 4]
就能获取到用户程序对应的任务的TCB基址,访问[ebp + 12 *4]
就能获取到用户程序在硬盘中的逻辑扇区号。
接着给LDT表申请了一块内存空间,本程序中写死了LDT表的空间为160字节,也就是最多能够安装20个LDT表项。由于用户程序比较简单,没有那么多的段,因此这样的设置是合理的。
申请到了LDT的内存之后,需要将LDT表的基址存储到TCB中。
申请到的LDT表基址如下:
存储到TCB之后,TCB的内容如下:
可以看到,LDT基址成功写入了TCB的对应位置。
加载用户程序到内存
接下来是从硬盘中读取用户程序,和13章一样,先读取一个扇区(第一个扇区的扇区号从栈中获得,这一点和13章不同),获知用户程序的大小等信息后,再加载整个用户程序到内存中,具体过程不再详述。
需要注意的一点是,程序加载基址确定后,也被填充到TCB表中:
填充用户程序各段描述符到LDT表
用户程序成功加载到内存之后,开始从用户程序头部开始,构造各段的段描述符,填充到LDT表中。
用户程序头中存储着以下信息:
根据程序头中的信息,可以构造相应段的LDT表,基本操作如下:
大致流程就是从程序头获取段的长度和起始地址信息,通过调用函数make_seg_descriptor
构造一个LDT表的段描述符。
之后再调用fill_descriptor_in_ldt
函数将该段描述符添加到LDT表中。注意到这个函数调用需要传入TCB的基址作为参数,这是为了从TCB表中获取LDT表的基址,从而填充到LDT表的指定位置。(因为此时还不能通过GDT表来访问LDT表)。在修改完LDT表之后,由于LDT表的段限长改变,因此还要将段限长回写到TCB表中。这个函数的返回值是该段在LDT表中的段描述符,or cx,0000_0000_0000_0011B
这条指令将段选择子的RPL字段设置为3,表示特权级数值上小于或等于3的代码都可以使用这个段。在接下来那些需要重定位的代码段和数据段的创建中,这个返回值就作为代码段和数据段的段选择子写回用户程序头。
各段都建立完毕后,可以看到LDT表的内存如下:
TCB表内容如下:
程序头的内容如下:
重定位用户程序的SALT
简单的两层循环+字符串匹配。需要注意的是虽然上一步已经把用户程序头部的描述符安装到用户程序的LDT中,但是LDT仅仅可以通过TCB访问,没有注册到GDT表中,因此要访问存储在用户程序头中的用户SALT表,还是需要通过全局段选择子。
匹配到相应的C-SALT表表项时,还需要取出C-SALT表项的段选择子字段,修改RPL为3,再回填到用户程序的SALT表中:
循环填充每个表项,最终得到重定位后的用户SALT表如下:
以第一项为例图解每一项的结构:
创建高特权级栈段
简单来讲就是调用allocate_memory
函数动态分配各个栈的内存,将段长度和段基址填写到TCB,并通过make_seg_descriptor
函数构造段描述符,填写到LDT表中。注意不同特权级的栈段需要分配不同DPL的段描述符,并且填充到TCB中指定的位置。
在GDT中登记LDT描述符
和之前建立代码段等段的描述符一样,为make_seg_descriptor
函数提供所需参数,紧接着调用set_up_gdt_descriptor
就能够在gdt中建立LDT描述符表项。
执行之后的GDT表如下:
为任务建立TSS段
主要流程是先动态申请0x104大小的TSS基本空间,将之前得到的各个堆栈段地址填充到对应位置,填充各个表项的值(详见随书源码)。完成后,等级TSS到GDT中,记录后的GDT表内容如下:
记录完成后,加载和重定位用户程序的工作完成,接下来终于要将控制权从内核转交到用户程序了。
从内核转移控制流到用户程序
书中通过这段程序从内核转移控制流到用户程序:
首先,从TCB表中取出LDT和TSS在GDT中登记的段选择子,使用ltr
和lldt
指令分别加载到tr和ldtr中,此时直接使用TCB中登记的用户程序头段的段选择子(索引LDT中的段描述符),就能够得到正确的用户程序头地址和偏移,因为LDTR存储的已经是用户程序的LDT的段选择子。
接下来就是如何将控制流从高特权级转向低特权级的问题,目前学过的方法就只用模仿中断门返回,源码中使用模拟从调用门返回的方式做到这一点。
下面是retf前后的各寄存器和堆栈信息对比:
- retf前的堆栈:
retf后的堆栈:
可以看到发生了栈切换,从系统栈切换到了用户栈。
- retf前的CPL
retf后的CPL
CPL从0切换为3.
- retf前的TSS
retf后的TSS
TSS没有变化,这是因为内核首次进入用户程序,不需要改变用户程序TSS的值。
从用户程序返回内核
用户程序的执行结果就是往屏幕上打了一串字符:
用户程序执行结束后,使用以下命令返回内核:
这是一个远跳转,根据之前SALT表的重定位过程,可以知道用户程序实际上跳转到的是这个TerminateProgram函数对应的调用门,书中提供了调用门的特权级检查规则:
我们已经知道jmp指令不改变代码执行的特权级,而从上表可知,CPL(=3)低于目标代码段的DPL(=0),因此这个跳转会被处理器拒绝执行,触发异常,但是在本实验中观察不到这样的异常,因为我们没有安装异常处理。