[Intel汇编-NASM]任务控制以及特权级保护

1. 任务隔离以及LDT的概念:

    1) 任务的概念:程序是记录在载体(也就是硬盘等外存)的指令和数据,而任务则是指其加载在内存中的副本,该副本不是等待执行就是正在执行,一个程序可以有多个任务副本;

    2) 任务的隔离:把所有任务,不管是OS内核还是APP都放在GDT中管理显然是不合理的,容易造成APP对内核数据的破坏等问题,这就需要从两个层面上将它们隔离开来;

         i. 第一个层面就是特权级层面(给予内核高特权级,给予APP低特权级,不同特权级之间相互访问将会受到很多限制,从而提高安全性);

         ii. 第二个层面就是LDT,即Local Descriptor Table,局部描述符表,它是相对于GDT的,GDT是全局的,逻辑上其只有一个并且是由OS管理的,而每个APP都必须拥有一个自己的LDT来管理自己拥有的段,也就是说GDT是共有的(OS、APP都能用) 而LDT是任务私有的,一般只有任务自己使用;

    3) LDT格式:和GDT格式一模一样,LDT中的段描述符也GDT的段描述符格式也完全一样,只不过LDT的0号槽位也可以用来存放描述符;

!注意:一般LDT中描述符的DPL为3(GDT中描述符的DPL一般为0,OS拥有最高特权级),并且指示LDT中描述符的选择子的TI位为1(Table Indicator,表示选择的描述符位于LDT中);


2. 用GDT和LDTR来管理LDT:

    1) GDT和LDT虽说是描述符表,但这也掩盖不了它们是内存段的事实,由于GDT是全局唯一的,所以处理器要求将所有的LDT都要做出一个GDT描述符放在GDT中统一管理也是合情合理的;

    2) CPU要求,为了对所有的LDT统一管理,必须在GDT中安装每一个LDT的描述符(就是把LDT看做一个段,然后做出该段的描述符安装到GDT中);

    3) 考虑到GDT和LDT的特殊性(适用于系统管理的),因此它们也被称为系统的内存段,简称系统段;

    4) 系统段的特征:段描述符中S位为0,这就表示这是一个系统段或者是一个门,但是系统段有两种(一种是LDT段另一种是GDT),而门也有多种(调用门、中断门、陷阱门等),那该如何确定其是哪一种角色呢?那就得看TYPE字段了,当S=0,TYPE=0010时就表示LDT段,当S=0,TYPE=1100时就表示调用门,就把S+TYPE看做一个纯5位类型编码即可;

!其它特征:

         i. G、AVL、P、DPL位和GDT描述符中相应位的意义一样,只不过GDT和LDT的最大范围只能是64KB,因此不管是4KB粒度还是1B粒度,最终大小都不能超过64KB!

         ii. D/B和L位对系统段描述符来说没意义,因此恒为0;

         iii. 一般LDT都是由GDT管理的,因此LDT段的特权级一般为0;

!综上所述:LDT描述符的属性一般为0x00408200(即描述符的高32位,属性位保留在原始位置,其余无关位清零);

    5) 和GDTR一样同样也存在LDTR来指示LDT的位置和大小,只不过LDT在GDT中存在描述符,因此也可以“像访问其它普通GDT段的形式”来访问LDT,之所以打引号是因为指示形式相似而已(毕竟是系统段,不能随意访问的),即LDTR和其它段寄存器一样,也只是一个16位的选择子,但它拥有一个64位的高速缓冲器保存完整的LDT描述符以加速对LDT的访问,因此在加载LDTR的形式和加载GDTR有所不同:

        i. 加载GDTR的指令是:lgdt m48

        ii. 而加载LDTR的指令时:lldt r16/m16,即只需要加载一个LDT在GDT中的选择子即可;

    6) LDTR的唯一性:不是说LDT可以有多个吗?是啊,但是LDTR是只有一个的,毕竟,一个OS是无法预知会有多少个任务会运行(当然任务数量可能非常之多),因此在一个多任务系统中,唯一的LDTR只指向当前正在运行的任务的LDT,因此单核CPU上实现多任务就是通过不停切换LDT指向的方法来模拟”宏观并行微观串行的“;


3. 任务状态段——TSS:

    1) 对于一个多任务系统,在多任务之间切换的时候必定需要保存被切换走的任务当时的处理器信息,以便在切换回来的时候能正确地”继续“刚刚未完成的作业;

    2) 所谓的处理器信息就是指在发生任务切换时通用寄存器、段寄存器、标志寄存器等部件上的数据信息,总结起来至少有104个字节量,而这些数据的总和就是任务状态段(Task Status Segment,简称TSS);

    3) 每个任务都有唯一属于自己的TSS,需要自己开一片额外的内存空间来存放TSS(这些工作都属于OS),对!你猜对了,TSS和LDT一样,也属于系统段,需要在GDT中注册它的描述符!

    4) TSS格式固定,一般最小是104个字节,CPU固件能自动识别它以支持多任务之间的切换,其格式如图:


!可以看到,TSS中有LDT选择子,通过该选择子找到并加载LDTR,再多LDT进行访问;

    5) 在GDT中登记TSS描述符:

         i. TSS也属于系统段,因此S=0,当描述符是TSS描述符时TYPE=10B1,这个B就表示Busy位,如果为0就表示TSS指示的任务不忙(即准备就绪,可以开始执行),如果为1就表示该任务正在执行或者挂起;

         ii. DPL一般为00,P表示存在位,当任务没被换入硬盘时为1,其余设置和LDT描述符一样;

         iii. 因此TSS描述符一般为0x00408900;

         iv. 同样和GDTR、LDTR一样,也有一个TR来指示TSS的基址和大小:TR就是Task Register,和LDTR一样,也是一个16位的选择子(指示TSS的GDT选择子),同样也有64位的高速缓存器存放完整的TSS描述符;

         v. 加载TR的指令:ltr r16/m16

    6) 使用TSS选择子进行任务切换:当call far或jmp far指令的操作数是TSS选择子时就可以实现任务之间的切换(将TSS中的寄存器数据块填入相应寄存器中,并根据LDT选择子加载LDTR等);


4. LDT和TSS不是万能的,还需要——TCB:

    1) 虽然TSS中有LDT选择子,但是在多任务中你肯定要记录所有任务的TSS选择子才能实现多任务之间的切换,一般的做法是使用链表,链表节点中的数据域就是tr,而指针域就是next指针了,但在现实情况中仅仅只有一个tr域是远远不够的,还需要更多的信息,比如tr指向的任务的权限、所属成员、所属组等种种信息,只不过tr是核心而已;

    2) 因此为了管理方便,就需要为每个任务创建一个任务控制块(Task Control Block,简称TCB),每个TCB中则包含了tr等丰富的信息,将所有TCB串成一条链表就形成了任务控制表,这就现代操作系统任务管理的核心,这部分是软件实现部分,也是操作系统内核的关键模块;

    3) 在本次的程序演示中设计的TCB格式如下图所示:

       

    4) 动态管理TCB内存:由于任务数量是无法预知的,但是只要加载一个任务就需要为该任务开一个TCB,因此任务的TCB只能动态加载(和C语言中malloc的概念一样),所以TCB不能传统的一个固定大小的段来存放,因此必须使用一个能访问4G平坦空间的全局描述符来灵活管理,即哪里有合适的空位就忘哪儿塞一个TCB,也就是说TCB存放是灵活的,可以不要求连续存放,但是在本次的程序演示还是使用连续存放的方式来模拟的;


5. 小结——任务控制模块搭建的步骤:

    1) 为任务创建一个TCB;

    2) 再为该任务创建一张LDT;

    3) 将LDT描述符加入GDT中,再将LDT的有关信息填入TCB中(包括LDT选择子,所以要先添加LDT的GDT描述符);

    4) 创建TSS;

    5) 将TSS描述符假如GDT中,再将LDT选择子填入TSS中(所以要先创建LDT才能再执行这步),并把TSS相关信息填入TCB中;

    6) lldt、ltr使LDT和TSS生效并切换到所指向的任务;


6. 特权级保护之当前特权级——CPL:

    1) DPL:再回顾一下,就是指描述符特权级,由于描述符指向对象,因此也就是目标对象的特权级(目标对象就是指相应的段),该特权级指示了能访问目标所必须拥有的最低特权级,比如要访问特权级为1的段,自己本身拥有的特权级至少为1,再高的话就是0,但是不能为2和3(数字越小特权级越高);

!因此一般来说OS内核DPL为0,驱动、数据库等系统软件的DPL一般为1或2,而APP的DPL最低,必为3;

    2) CPL的概念:即Current Privilege Level,即当前特权级,就是指 当 前 正 在 执 行 的  代 码 段 的 DPL,注意几个关键词,首先必须是代码段才能执行,而且必须是当前正在执行的那个代码段的特权级,数值上等于当前cs的最后两位(即RPL位上的值);

    3) CPL的作用:主要有两个方面,一方面是控制转移时的保护,另一方面是对I/O端口访问权限的管理,这里先着重介绍控制转移的保护;

         i. 控制转移是指从一个代码段跳到另一个代码段(即使用jmp或call指令组)的过程,也就是说对象必须是代码段(即可执行的);

         ii. 在全裸的情况下(不考虑CPL只考虑DPL的情况下)处理器只允许相同特权级之间的代码段相互转移,决不允许不同特权级之间的代码段相互转移,也就是调用者和被调者的特权级必须完全相同,但这显然不能满足显示需求,在现实情况下往往要求用户程序能调用OS内核的例程,即特权级低的调用特权级高的;

!注意:在任何情况下都不允许特权级高的把控制权交给特权级低的,因为特权级越低就意味着越不可靠,所以处理器直接在硬件层面上杜绝OS内核调用用户例程;

         iii. 处理器给出的其中一种解决方案就是“CPL+依从代码段”,只要将被调的代码段定义成“依从的”(描述符中TYPE字段的C位为1),并且调用者的CPL低于或等于被调者的DPL就可以实现特权级由低到高的转移了,这里的低于或等于是指特权级低于或等于,也就是说数值上是大于或等于;

!那问题来了,这样控制权能转移到DPL较高的代码段了,那么返回的时候该怎么办呢?不是说控制权不能由高到低转移吗?

!注意:“依从“的意思是指高DPL的代码段被调用过后CPL依从调用者的CPL,也就是说向依从代码段转移时CPL不变,因此返回的时候CPL就和DPL相等了;

!在刚从实模式进入保护模式(cr0.0置1)后处理器会自动将CPL置0,因此可以顺利执行32位的代码;

         iv. 另一种特权级由低到高的转移方式是通过门(Gate)来实现,接下来会详细讲解门的概念;


7. 调用门控制转移:

    1) 门也是一种描述符(简称门),和段描述符相同地方是都用来描述内存段,不同的地方是段描述符既可以描述代码段(可执行段)也可以描述数据段(不可执行段),但是门只能用来描述可执行的代码段(程序、例程或任务);

    2) 门有很多种,比如专门用于在不同特权级(当然相同特权级也行)之间的控制转移的调用门、处理中断的中断/陷阱门、用来切换任务的任务门等,这里我们着重讲解调用门;

    3) 门描述符的格式:

        i. 只要是描述符都是64位的,门也不例外;

        ii. 虽然门的格式和段描述符的格式大不相同,但是恒不变的仍是P、DPL、S、TYPE这三个字段(位置也没变);

        iii. 门描述符也可以只能安装在GDT或LDT中,那么在调用一个描述符的时候处理器是如何判断调用的是段描述符还是门描述符呢?那肯定是看"S+TYPE"的编码了,根据该字段的类型来判断具体属于哪种描述符,除了4个字段,其余的所有位都会根据”S+TYPE“来作解释,如果”S+TYPE“代表的是门,则G、L、AVL等位就废掉了,取而代之的是被解释成门描述符特有的字段,如果”S+TYPE“代表段描述符,则相应地就被解释成之前讲过的段描述符的格式了;


    4) 各字段的意义:

         i. P:仍然是存在位,但是对于门来说意义有所不同,对于门应该称作有效位,如果在P位为0的情况下调用该门处理器会产生异常中断,在P位为1的情况下可以正常调用该门;

!注意:这个存在位并不代表门所指向的代码段存不存在于内存中,我们可以看到门描述符中海油段选择子字段,该字段正是门所指向的代码段的描述符的选择子,而我们知道段描述符中也有P位,该P位才是真正地代表代码段是否存在于内存当中,那问题又来了,门描述符的P位到底有什么用呢?

!该位最主要是给软件利用的,通常用与门调用频率统计等,根据该位的特征,我们把P位置0,则在调用它的时候就会产生中断,而该中断又是故障中断,也就是说你可以为该中断编写自己的中断处理过程,如果在该中断例程中将某个“计数变量”加1,然后在将P位置1,然后再在中断例程中调用该门(P位置1能顺利调用),调用完之后再将P位置0,这样不就每调用一次就能计数一次了吗?

         ii. DPL:这个是指调用门本身的DPL,也就指你想调用该门应该具有的最低权限,注意和段的DPL区分开来,在这里会同时涉及两个DPL,首先是门DPL,其次是门所指向的代码段的DPL,即目标DPL,你要想成功代用该门,首先你自己的权限(即你当前的权限)CPL必须先大于等于门DPL才有资格调用该门,也就是说门DPL就是你的最低门槛,而你想要成功转入目标代码段执行,根据“权利由低到高”的处理器原则,你的CPL又必须小于等于目标DPL才行,也就是说目标DPL是你的上限,所以总结下来就是,必须满足“门DPL ≤ 目标DPL”;

         iii. S+TYPE:01100表示调用门,这里我们着重介绍调用门(即在不同特权级代码之间转移的门);

         iv. TYPE后面的3位是无效位,恒为0;

         v. 段选择子:段中偏移:就是指转移的目标点的cs:eip;

         vi. 参数个数字段:函数传参当然可以使用寄存器了,毕竟寄存器速度最快,但寄存器数量非常少,一旦参数非常多寄存器就无能为力的,普遍的做法就是通过栈传参了,该字段共有5位,因此就意味着最多只能传31个参数;

    5) 调用门的调用形式:

         i. 共有两种,一种是jmp far,另一种是call far;

         ii. 操作数都只有一个,就是门描述符在GDT/LDT中的选择子,因为门描述符中已经包含目标代码的选择子和偏移地址,所以无需在门的调用指令中出现偏移地址了;

         iii. 但是jmp和call都是远调用(即由far作为修饰),所以操作数还必须是48位的(这个形式非常死板说实在的!编译器可以在这点上优化),按照这个规则,门选择子必须在高16位上,那么低32位是啥呢?答案是:随便啥都行,处理器只检查高16位的门选择子,选定后直接去调用,低32位直接不予以理睬,但不过低32位你又不得不给,这不是很蛋疼吗?是的,就是那么蛋疼,所以约定俗称的做法就是,虽然低32位没有用,但是还是要填目标代码入口处的偏移地址,这样显得更加规范并符合逻辑,这也是业内约定俗成的规矩!

         iv. jmp far:无需目标代码依从,跳转过去后直接就能是目标依从调用者的CPL;

         v. call far:进入被调者后CPL提升成目标代码的DPL,比如特权3 call far 特权0,则进入被调后特权相应提升成特权0,那么问题又来了,你返回的时候咋返回啊?不是说处理器禁止特权从高到底转移吗?答案是:处理器为call far调用门开后门了,也只有call far调用门的返回允许特权从高到底转移,并且在该过程中处理器会严格监视这一行为,保证不会出现越权,否则那不就永远也返回不了嘛!

    5) 为啥使用门转移:不是使用jmp far加依从也能达到同样的效果吗?因为调用门将例程用描述符进行抽象,更加方便系统进行功能模块化、集成化管理,并且门调用安全保护严格,使用形式多样,种类也很多,更加适合多种不同的需求,所以要尽量使用调用门;


8. 访问数据段时的特权级保护——RPL(请求特权级):

    1) 前面已经讲过了,cs的最低两位就是CPL,因为CPL的对象是当前正在执行的代码段,而也只有cs指向当前执行的代码段,因此也有且仅有cs的最低两位才能叫CPL,而RPL就是指所有非cs的段寄存器的最低两位了,因为只能通过cs来执行代码,即使使用其它段寄存器指向一些可读的代码段也只能像取数据那样来取指令,得到的就算把它看成指令也不能直接交给CPU执行,因为CPU只把cs指向的数据当做指令来执行;

!总得而言,RPL肯定是跟数据访问有关的;

    2) 要理解RPL首先得体会以下情形:当你要访问一个段的时候首先要做的是什么?对了,那就是把目标段的选择子放入一个特定的段寄存器中,之后再利用该段寄存器并配合偏移地址来访问段中的内容,例如常见的mov ds, eax,别小看这一动作,RPL的关键核心全部都在这简单的一条指令上了;

        i. mov ds, eax这一动作可以看成是一种请求,即当前代码段请求访问eax所代表的选择子指向的那个段,也就是说请求者即为当前代码段,那么RPL(Requestor's Privilege Level)就是指请求者自己的当前特权级CPL;

!注意!请求者必须是当前正在执行的代码段,可以这样理解,请求是一个动作,而动作的核心就是能运动,只有代码段是一种具有动作的段(数据段是死的,只能被会动的代码段摆布),而要能运动起来就必须是正在执行的代码段了,所以请求者必定是当前正在执行的代码段,第二个层面上来讲,DPL是一种静态的概念(而静态的概念就是数据的概念,即静态的DPL只能表现出代码段具有数据的特征,而CPL反应出的是一种动态的特征,即作为一个执行者具有执行代码这一动作的特征),之前讲过的不同特权级代码段的转移其实就是一种正在动态执行的代码段想访问另一个还未被执行的静态的代码段的一种运动,而当成功转移后那个静态的代码段就被激活成为动态的代码段(因此被激活的活动者只有CPL这个概念对其是有意义的),而那个被转移的代码段则从激活状态变成了静态,此时只有DPL才能代表它存在的意义;

!小结:CPL是一个动态的概念,即代表执行者本身,而DPL只能代表静态的数据段(没有被执行的代码段也是一种静态的数据段);

        ii. RPL在这里就是ds、es等最低两位,它按道理来讲应该和CPL相同,这才能真正代表请求者的特权级;

?!什么?按道理来讲应该相同?但是eax里面的东西是可以随意乱填的啊!如果CPL是0,而我ds的RPL填的是1会如何呢?答案是还要看你要访问的那个段的DPL,根据选择子中GDT/LDT索引号以及TI位可以唯一确定要访问的段,处理器找到该段后就会检查该段的DPL(也就是目标段的DPL),处理器要求必须当CPL和RPL都大于等于目标段的DPL时才能正常访问该段,否则就会发生特权级异常而中断,这就是数据段访问的特权级保护规则了!!!

!哦,原来是这样的。只有当当前执行者(也就是请求者)的权利高于或等于目标的时候才能访问它,这非常合情理啊!

!那么问题又来了,既然请求者的RPL就是CPL那还要RPL有啥用,不就直接看CPL是否大于等于目标段的DPL就行了嘛,为啥还要那么麻烦地整出一个RPL呢?那么就请看下面的例子:

       在没有RPL的情况下, 一个用户程序(特权级为3)的代码正在执行,其CPL理所当然地是3,并且该程序自己的数据段的特权级必然也是3,现在它需要调用OS的例程来读写自己的数据段(DPL=3),然后它通过调用门传参的形式调用OS的例程,参数ebx中保存了自己的数据段的选择子(假设最低两位废掉不管用)。首先,通过调用门进入OS例程后CPL变成了OS例程的DPL,即CPL变为0(表示进入了操作系统的代码),然后必定要执行mov ds, ebx或mov es, ebx之类的指令来访问指向用户程序的数据区,(虽然RPL没了,但是CPL的规则还是存在的),此时处理器会检查当前的CPL是否有资格访问目标数据段,一看CPL=0(即当前执行者(即OS)),目标数据段(即用户程序的数据段)的DPL=3,CPL≥DPL,有权访问,没问题!于是该过程就开开心心的完成了!

!别高兴太早!!其实这个过程里面暗藏玄机,试想有一个很恶毒并且非常聪明的程序员通过不法途径知道了OS内核数据段的选择子,然后将该选择子通过ebx传入上述的例程,然后再想想会发生什么?在OS例程执行mov ds, ebx后,处理器发现CPL=0,目标数据段(也就是不怀好意者传入的内核数据段选择子)DPL=0,CPL≥目标段的DPL,也通过了!没问题!可以访问!这不就大错特错了!黑客通过这种方式读取内核数据段中的数据,而该数据段中可能存放着密码等重要信息呢!

!但是如果有RPL这个东西呢?情况是否会好一点呢?在上例中黑客想访问内核数据段,因此ebx所代表的选择子的索引和TI会指向内核数据段描述符,但是如果最低两位的RPL是用户程序本身的特权级3会怎样呢?进入内核执行mov ds, eax后处理器发现CPL=0,目标DPL=0,但RPL=3,即CPL≥目标DPL,但是RPL<目标DPL,根据有RPL规则的情况下这是不能通过的!即黑客的目的不能得逞!诶!看上去RPL还是挺有用的啊!而也正是在这种情况下会导致CPL和RPL不同!

!那么问题又来了!传进去的参数ebx是可以自己任意填写的,假如黑客的RPL就不老老实实地写3而就写0那不是又能得逞了吗?答案是:是的,他又能得逞!

!那RPL不就又没作用了吗?从这个角度看,处理器就是一个大笨蛋,即使给你RPL也是不能判断这个给出的RPL是否真的和用户程序的CPL一致,但是机器是死的人是活的,我们可以通过软件手段保证给出的选择子中的RPL一定是正确的,而这个就是操作系统OS的本职工作了!一般OS都不允许用户修改或赋值选择子,所有段寄存器(即选择子)的管理只能由OS来负责,OS通过自身算法和逻辑判断来保证给出的RPL一定是合理的;

!!!小结:处理器的原则就是”确保特权代码不会代替用户程序访问一个段,除非用户程序自己拥有访问那个段的权利“,特权代码就是指内核的CPL,这里访问的一个段特别特别是指系统要害段(特别是OS的核心段),而用户自己的访问权利就是指RPL,而要实现这一安全机制就必须靠软硬件结合的手段来实现(CPU提供RPL保护机制,而OS用来保证RPL合法);

    3) arpl指令——实现上述的软件实现部分:

        i. 上述介绍的通过OS软件功能来保证RPL的正确性就是通过arpl指令来实现;

        ii. arpl的全称是Adjust RPL Field of Segment Selector,即调整段选择子的RPL字段;

        iii. 上述问题中恶意的程序员利用内核例程时故意提升提供的目的段的选择子的RPL,但是OS可以利用arpl指令让该阴谋破灭,arpl的指令格式为:arpl r16/m16, r16,源操作数和目的操作数可以是16位通用寄存器(但不能是段寄存器),目的操作数还可以是16位内存,但源操作数只能是寄存器,作用是将目的操作数的RPL字段提降低成和源操作数一样;

!这样就可以顺利解决问题了,只要让程序员提供的选择子作为arpl的目的操作数,并将主调例程的cs的值作为源操作数,就能让RPL=主调CPL(让RPL和提供RPL的主调例程的身份保持一致)了,这不就能阻止“恶意谋权”了嘛!那接下来我们再看一下arpl指令的一些具体细节吧!

    4) arpl指令的使用细节:

        i. 该指令会改变ZF标志位,如果源操作数的RPL高于调用者(即有问题,有恶意),就会把源操作数的RPL降低成和目的操作数一样,并且将ZF置1;

        ii. 如果没有问题(即行为合理)则将ZF置0,不做出其他任何行为;

        iii. 因此可以OS可以通过ZF位来判断一个例程的目的是否纯洁,如果不纯洁可以采取相应的警告提示等;

        iv. 目的操作数(即主调提供的目的段选择子)是通过参数传递进来的,而源操作数(即主调的cs的值)好像没有通过参数传进来,调用例程时使用的是call far,因此调用点的cs:eip会被压入栈中,所以源操作数可以从栈中获得并转存到一个通用寄存器中就行了!


9. DPL-CPL-RPL的特权级保护规则汇总:

    1) 控制转移类:特权级由低到高的转移

         i. 直接转移的非依从代码段:CPL = 目标DPL

         ii. 直接转移的依从代码段:CPL ≤ 目标DPL

    2) 数据访问类:CPL ≥ 目标DPL   &&   RPL ≥ 目标DPL

    3) !!栈的特权级保护规则:比较特殊,处理器要求CPL和RPL必须恒等于目标的DPL,其主要目的是为了防止栈空间的不足以及不同特权级之间栈的交叉引用,因此在保护模式中决不能出现[mov ss, xxx,其中xxx的RPL和当前CPL不一样]的指令,也就是说不能显示地将当前栈切换成另一个特权级不同的栈,那一定会问,这该怎么实现任务切换以及不同特权级之间代码的相互调用啊?如果有吊用关系的两个代码特权级不一样那不就不能切栈了吗?答案是通过”call far TSS选择子“以及TSS本身的机制来实现;

!还记得TSS中的内容吗?里面有ss:esp的信息,也就是说在任务切换的时候"call far TSS选择子"指令背后会执行且栈的动作,这就无需显示地"mov ss, xxx"了;

!而对于特权级转移,还记得TSS中ss0、ss1、ss2这几个字段吗?为了向高特权级代码转移时能使用高特权级的栈,低特权级代码必须自己多准备几个高特权级的栈,用于在转移到高特权级代码的时候使用(栈的特权保护规则),比如DPL为3的代码段就必须再多准备3个特权级分别为0、1、2的栈,因为有可能向这三种特权级的代码段转移,但是1特权级的代码段就无需再准备2、3特权级的栈了(因为处理器不允许控制从高特权级流向低特权级)而只需要再多准备一个0特权级的栈就行了;

!而TSS中的ss0、ss1、ss2就是指特权级分别为0、1、2的额外准备的栈了,无需准备的留空白即可(比如特权级为1的就只需要填ss0:esp0就行了,剩下两个清零就好了,而ss:esp则是代表自己特权级下的栈了,即1特权级的栈);

!前面讲过门调用有两种,一种是jmp far,这种的CPL是依从的,另一种是call far,其CPL将会和目标DPL相同,并且call是隐含压栈操作的,因此在此过程中必定需要切栈,切栈的过程是隐式进行的,首先处理器会检查目标代码段的DPL,在根据该DPL在TSS中找到相应的栈选择子和esp进行切栈,该过程全自动,都是固件自动完成的!

!注意:虽然用户程序需要额外为自己添加特权级更高的栈,但这并不意味着用户编写程序的负担会大大增加,一般情况下这种工作肯定都是交由操作系统来处理的,就连用户自己特权级的栈也不需要用户自己开,用户甚至都不需要在编程的时候声明要使用的栈(想想看,在写C语言的时候有自己声明栈吗?),当然也可以自己向编译器建议需要多大的栈(一般情况下,不做任何声明编译器会为你声明一个默认大小的栈),这些建议都会出现在用户程序的头部,用以提示加载器在加载它的时候为其分配多少空间的栈。对!这部分工作都是由操作系统的加载器模块完成的,也就是说用户在编程的时候只需要声明自己需要多大的栈,并在代码中随意使用栈,然后当程序加载的时候加载器更具你的需要在内存中动态分配一定的空间作为你的私有栈,这在我们接下来的程序演示中会有所体现;

    4) 门转移类:这涉及到4中特权级,CPL、RPL(门选择子的最低两位)、门DPL(门描述符中自己的DPL)、目标DPL(门所指向的代码段的DPL),其中门DPL决定了访问该门的最低权限(可以把门描述符当成一个数据段看待),而目标DPL则跟上面的控制转移类的目标代码段DPL的意义一致,所以门转移类的特权保护规则是:

CPL、RPL ≥ 门DPL   &&   CPL ≤ 目标DPL(也表示控制权从低到高转移)

!只不过这里还多了一个门DPL的门槛限制,也就是说门DPL是访问的下限,而目标DPL是转移的上限!    


10. 栈传参——函数调用约定:

    1) 前面在门描述符的参数个数字段中提到过栈传参的问题,我们在这里着重介绍一下这方面的内容;

    2) 栈传递的首要因素:不同特权级栈之间的切换,由于函数调用有可能会发生在两个不同特权级的代码段之间,由于与CPL、RPL必须和是用的栈的DPL相同,所以就不得不切换栈(切换成自己额外准备的特权级更高的栈),既然要切换栈就不得不将参数从自己特权级的栈复制到目标特权级的栈中了,这也就是我们在C语言中的函数栈传参的问题了;

    3) 栈传参带来很多问题,首先是参数应该按照什么样的顺序入栈,其次是参数个数为多少个,还有就是调用返回后由谁来清理栈中的内容(调用者还是被调者),一般来说被调者都是一些库函数,特别是操作系统的内核公用例程、API等,这些代码都是早就写好的,而用户程序千奇百怪,这些早就准备好的代码固然不知道用户是怎么调函数、怎么传参的,因此要解决这一系列问题就必须得在调用者和被调者之间达成一定的协议,这也就是术语“调用约定”了,诸如stdcall、cdecl、thiscall等;

    4) stdcall:Standard Call,即标准调用约定,用的最多最广泛

         i. 参数从右往左入栈;

!什么是左?什么是右?这里的左右就是指你书写函数掉用语句时参数的顺序,比如add(1, 2, 3),而1、2、3这个书写顺序就是上述的从左往右的顺序了,而从右往左入栈就是指按照3、2、1的先后次序压入目标特权级的栈中,比如现在是3级的main函数调用0级的add函数(add(1, 2, 3);),就会按照push 3 ; push 2 ; push 1 ; 的顺序将三个参数压入0级栈中,压完后栈顶刚好是最左边的那个参数;

         ii. 由被调者负责清栈切栈:由被调者负责的前提就是被调者必须知道参数的个数,也就是说参数个数必须固定

!这是指主调者只需提供参数就行了,剩余所有的工作都交由被调者完成,就上述例子将,写完add(1, 2, 3)这条语句主调就完事儿了,后面的切换特权级栈、压栈、执行特权代码(这就不用说了)、特权栈中复制的参数清空、切换会调用者特权级的栈并返回的一系列工作都由被调者来完成;

!既然都由被调者来完成,而一般被调者都是库函数,都是事先就已经编写好的,也就是说压栈弹栈的push $3, push $2, push $1(这里的$n就表示从左往右数第n个参数)这些语句都是事先写好的,既然是事先写好的就必然事先定好了要写多少条,也就意味着必须事先规定好参数的个数,这是理所当然的啦!一般函数如API之类的,函数名、参数个数、参数顺序程序猿们都是要背的,这当然是固定的了!但是!想必你也使用过printf函数吧,它就是一个变长参数函数,即参数个数不固定,在这种情况下就肯定不能使用stdcall这样的参数固定的调用约定了吧?是的,但是可以使用cdecl约定,该约定允许变长参数,这个后面会讲;

    5) cdecl:C Declaration Call,C/C++的调用约定

        i. C/C++程序中如果不加函数调用约定修饰就默认使用这种调用约定;

        ii. 参数从右往左入栈:和stdcall一样;

        iii. 由主调者负责切栈清栈,别调者只负责执行代码:主调者是什么样子对被调者当然是透明的,被调者当然不知道主调者会给出几个参数,除非它们约定好给出多少个,但是对于主调者来说自己给出了多少个参数对自己来说必定是心知肚明的,你被调者不知道就不怪你了,但自己有多少个参数还弄不清楚吗?所以在约定好的情况下可以让被调者清栈,但是在没有约定好参数个数的情况下就只能由主调者来负责切栈、压栈和弹栈了(即多少个push、多少个pop);

!那爱钻牛角尖的同学肯定会问了,如果我在约定好参数个数的情况下也用cdecl呢?当然可啊!凭什么不可以呢?在这种情况下cdecl和stdcall唯一的区别就是cdecl会使主调者的代码量变大,而stdcall则把这些代码量放到被调那里使主调瘦身;

!小结:所有变长参数,如printf之类的函数必须使用cdecl修饰!

    6) fastcall:Fast Call,即快速调用,和stdcall规则一样,只不过头两个参数(即最左边的两个参数)使用ecx和edx传参,剩下的仍然是从右往左压栈,并且切栈/清栈由被调者负责,由于使用了寄存器,所以在速度上会更加快一点;

    7) thiscall:用于C++的成员函数传参,this指针放在ecx中传入,剩下的规则和stdcall一模一样;


11. 调用门的调用和返回全过程:

    1) 在这里回顾一下,使用调用门转移必须使用指令jmp far或者call far,操作数是调用门选择子,其必须指向位于GDT或LDT中的调用门描述符,由于描述符中已经包含目标代码的选择子和偏移地址,因此jmp far和call far将忽略操作数中给出的偏移地址;

    2) 调用的第一步就是特权级检查:不管是jmp far还是call far都必须满足“调用门DPL ≤ CPL ≤ 目标代码的DPL”;

    3) 接下来就是检查目标代码段是否是依从的,如果是依从的,则不管是jmp far还是call far,都将保持原有的CPL在被调代码段上执行,只不过jmp far就是一去不复返了,无法通过retf返回。但如果目标代码是非依从的,则jmp far规定CPL必须等于目标代码段的DPL,而这种情况刚好可以让call far大显身手了,call far专门为非依从目标代码的转移量身打造,允许在目标代码非依从的情况下在目标代码的特权级上运行,即前面讲过的CPL可以提升成目标代码的DPL,当然也只有retf可以允许从高特权级代码返回至低特权级代码上;


!!接下来我们主要讨论目标代码段非依从情况下的call far调用门转移过程,因为该过程将会改变当前特权级,因此必然会发生栈的切换(切换成和目标代码段特权级相同的栈);

!!栈的切换将由处理器固件完成,目标特权级的栈可以从TSS中获得;

    4) 调用过程:

        i. 检查目标代码的DPL(位于目标代码段的段描述符中,可以通过call far的操作数,即调用门选择子找到)是否和当前CPL相等,如果相等则代表不用切换栈,直接跳到vi.步即可,否则就代表需要切换栈,接着执行接下来的步骤;

        ii. 从TSS中读取目标特权级的SS和ESP,在此过程中将对SS(栈段选择子)所指向的栈段描述符进行越界以及特权级方面的检查,如果有异常将引发中断;

        iii. 目标栈检查无误后将当前栈的SS和ESP保存到处理器内部的匿名寄存器中,并将上一步从TSS中读取的目标栈的SS和ESP载入寄存器SS和ESP中,至此栈切换已经完成,接下来将执行一些后续步骤;

        iv. 目前已经在目标栈上了,接着将刚刚备份到匿名寄存器的旧栈的SS和ESP压入当前的新栈中来,以便调用返回时可以恢复旧栈;

        v. 根据调用门描述符中的参数个数字段将旧栈中的参数复制到当前新栈中来,成为调用例程私有的局部参数;

        vi. 接下来就跟实模式下普通的例程调用一样了,那就是保存调用者的CS和EIP为例程的返回提供依据,只不过在这里是将调用者的CS和EIP压入当前新栈中;

        vii. 最后就是将目标代码段的CS和EIP(通过调用门描述符找到)载入寄存器CS和EIP中,至此,彻底进入目标代码中执行,如果是非依从的将会提升CPL,依从的则保持CPL不变;

!以上过程新栈,也就是目标栈,的示意图如下:

0|SS主调

ESP主调

参数1

参数2

......

0|CS主调

EIP主调

!栈是从上往下扩展的,上面的地址大,下面的地址小;

    5) 返回过程:由call far引起的门调用必须通过retf返回,对于依从的代码段,由于CPL不改变,因此无需切换栈,但与非依从的代码段返回时就必须切换栈了,而且是从搞特权及切换回低特权级,而retf是唯一一条允许从高特权到低特权转移的指令;

        i. 从当前栈(即被调者的高特权级栈)中弹出调用时压入的CS和EIP(存入处理器内部的匿名寄存器中保存起来),并检查CS指向的代码段的特权级,看看是否需要切换栈(即使调用的时候已经检查过了,这里处理器还是会强迫地检查一次,因为栈中的内容是可以自由改动的,为避免处于恶意目的的改动所有的检查都是必要的!如无需切换则跳至vi.步执行;

        ii. 检查调用时的参数个数,根据retf提供的操作数让ESP跳过那些调用时传入的参数(retf的操作数是以字节为单位的,其值等于参数个数乘以参数大小);

        iii. 将调用时压入的返回点SS和ESP恢复到寄存器SS、ESP中完成栈的返回切换,期间将对SS、ESP中的内容进行越界、特权级等的检查;

        vi. 将之前保存在匿名寄存器中的返回点CS、EIP恢复到寄存器CS、EIP中去,返回到调用点继续执行(如果特权级改变就降低CPL,如果是特权级不变的依从式门调用则不改变CPL);

        vii. 如果返回时需要改变特权级则还会多一步特殊的检查,就是检查DS、ES、FS、GS这些数据段选择子中的内容,如果当前CPL小于这些选择子所指向的段的DPL则将这些选择子清零(选择子为0就指向了GDT的0号描述符,即空描述符NULL,如果访问该描述符将会发生异常中断,因此可以避免危险的数据访问行为);

!这么做的意图很明显,就是为了防止当前特权级的代码(低特权级)访问高特权级的数据,可是为什么返回之后会发生这样的情况(CPL小于这些选择子指向的段的DPL)呢?还有一个问题就是处理器不是有数据段访问的特权级检查机制嘛,如果发生CPL小于目标DPL的情况不是会产生异常中断吗?为啥还要强制清零呢?

!这里首先解答第一个问题,一个3特权级的例程调用0特权级的例程,在0特权级历程中当然可能会访问0特权级的数据区(这时就会将某个段寄存器,如ds、es等,指向0特权级的数据区)并且理所当然地通过特权级检查,因为被调的例程是运行在0特权级上的,但不过最后在返回的时候没有将ds恢复成修改前的内容就匆匆返回了(这当然是允许的,处理器无法左右代码的逻辑行为),因此返回后就会出现CPL小于ds指向的数据段的DPL的情形;

!要解答第二个问题就得回顾一下之前讲过的“特权级检查时机”这一个重要概念了,处理器只有在“mov 段寄存器, reg”时候会进行特权级检查,其余情况下都不会再做任何检查(都会认为已经检查过了,可以放心运行了),在上面的例子中,对ds的修改是在0特权级的代码上进行的,因此可以通过特权及检查,但是在后面返回主调代码的过程中没有再对ds做任何修改,因此这期间不会对ds的特权级做任何检查,因此返回后即使是“CPL < ds指向的段的DPL”也能正常访问ds指向的段,这不就给软件的安全带来非常大的隐患了嘛!所以,与其留这种隐患,还不如直接在固件层面上就避免这种危险情形发生,因此处理器在调用返回后还会再检查一下数据段选择子的合法性,并对不合法的选择子清零;


12. I/O特权级保护——I/O地址映射许可串:

    1) 在介绍I/O特权级保护之前必须现了解一下特权指令这个概念,由于处理器的脆弱性,随便几条指令就可以让它崩溃,特别是hlt、对cr0的改写之类的指令,因此这样的指令必须由最高特权级的例程才能执行,而特权指令就是指只有CPL=0时才能执行的指令,如hlt、lgdt、lldt、ltr、cr0的读写等等,但其中我们要重点讨论的就是in和out指令,对,这两个指令也是特权指令;

!但不过很多人会反对这样的规定,因为设备驱动程序通常工作在1特权级下,而驱动最为核心的活动就是使用in、out和硬件模块进行通讯,不仅如此,某些特权级为3的用户程序有时也需要使用in、out访问硬件端口,特别是在一些游戏、图形加速等需要快速反应的场合偶尔也是需要使用这两条指令的,这就不是不能满足现实需求了吗?

!不,处理器有专门的I/O特权级保护机制满足上述的所有需求,让即使是特权指令的in、out指令也能被所有低特权级的例程使用;

    2) EFLAGS中的I/O特权级:在I/O特权级机制下其实所有特权级的代码都能使用in、out(即使它俩是特权指令),只不过不同特权级所能访问的端口会有所不同。在EFLAGS寄存器的第12和13位即为当前任务的I/O特权级IOPL(I/O Privilege Level),如果当前CPL大于等于IOPL则允许访问任何端口(即没有任何限制),因此当CPL=0时就可以访问任何端口(0特权级是最高特权级,不可能有比它大的特权级了),但是CPL小于IOPL并不意味着不能访问端口了,准确地讲应该是可以访问一部分同时也会被禁止访问一些端口,而具体哪些可以访问哪些不能访问这得由I/O许可串来决定了;

   3) I/O许可串:

       i. 也称为I/O地址映射许可串,位于TSS中;

       ii. 首先我们先不具体讲解该许可串在TSS中的位置问题,先聊一下许可串的结构:处理器最多有65536个端口,之所以叫做I/O地址映射是因为许可串将65536个端口映射到了65536个比特位上了(就跟QQ号一样,每个QQ号映射到一个比特位上,如果该位为1则表示该QQ号上线,否则就表示下线),这不过许可串上的位为1表示禁止访问相应的端口,为0表示允许访问相应的端口,这样管理的最大好处就是能节省内存空间,就拿QQ号来说吧,要实现检测是否上线,使用上述位表的方式就可以将所有QQ号压缩到只有四五百兆的大小;

!许可串的比特位地址上升的顺序从0开始编号,0号比特位对应着0号端口,1号比特位对应1号端口,以此类推下去,65535号比特位对应65535号端口;

       iii. 处理器检测I/O许可的流程:拿out 0x09, al这条指令来说(对于in也是一样的),首先检查你是否工作在0特权级上,如果是则可以自由使用任何端口而不受限制,接下来就检查你是否满足CPL≥IOPL,如果不是就需要检查I/O许可串上的指示了。由于使用的是9号端口,于是处理器去查询许可串上第2个字节(许可串是按照字节编址的,其实这是废话,许可串位于内存空间必定是按照字节编址的,这点需要牢记),因此会先取出该字节,9号位于该字节的第二个比特位上,然后检测该位是否为0,0就允许,1就禁止;

       iv. I/O许可串在TSS中位置问题:TSS的最小规模是104字节,这在前面演示过了,但是TSS还可以包含I/O地址映射许可串,而该串在内存中的起始位置由TSS102子接触的I/O映射基地址字段来指示;

!?什么??为啥还有指示一下许可串的位置呢?为啥不直接让许可串接在TSS的104字节位置处呢?难道许可串还要跟前面的104字节分离开来(不连续起来)吗?是的!当然了,可以分离也可以不分离,关键取决于你的需求!只要许可串基址字段填104字节后面那个字节的地址不就连续起来了嘛!可是你肯定又会问“那一定还有不连续的情况吧?”我想说,是的!这种不连续的需求往往更多!你想想,如果有很多任务都要访问相同的外设,并且对该外设的操作相似,权限也相似,功能也相似(就比如键盘),那么每个任务的TSS都要准备一段相同的许可串,这不就很浪费内存空间吗?因此,现代操作系统的一般做法就是,在内核数据区只维护几个不同类型的I/O许可串供全体任务使用,每个任务根据自身的需求挑选其中一种许可串作为自己的许可串,这就好比操作系统是政府,颁发(给予)许可证给企业(任务)使用,当然这些许可证都是公用的,多个任务可以用同一个许可串,只需要将各自TSS的许可串基址字段指向那个许可串即可,这不就大大节省内存空间了吗?

       v. 既然TSS的空间可以是不连续的(前104字节必须连续,许可串自身必须连续,这两者之间可以不连续),那TSS的空间大小该如何计算呢?这很简单啊,只要把这两部分的大小相加就行了,事实上处理器也是这么做的,TSS有一个TSS界限的字段,虽然这两部分可以分离,但是仍然可以在逻辑上把这两段合并成同一个连续的线性空间,而TSS界限就等于这两段空间大小相加后减1,而102字节处的许可串基址也是按照该线性空间计算的,从TSS起始位置开始算起(起始位置地址为0),因此如果该字段大于TSS界限值就代表没有I/O许可串存在,如果小于,则表示许可串和前104字节之间还有一段空闲空间,这段空闲空间可以给软件作其它用途;

!问题又来了,按照上述,许可串的长度不是可以通过“TSS界限+1-串的基址”计算得到吗?这不就意味着许可串的长度可能不是固定的65535了吗?大于65535当然是不行的,难道可以小于65535吗?答案是,是的,但是还有一个许可串的内容需要介绍,那就是许可串结束符;

       vi. 许可串结束符:处理器规定,许可串必须以0xFF结束,原因是I/O端口是按照字节编址的,但是存在直接读写一个字(即两字节)的I/O指令,比如in ax, 0xF8等。端口按字节编址就意味着端口被设计成一个端口一个字节,如果读写双字其实背后读写了两个连续的端口,因此上述的in ax, 0xF8其实执行了两步"in al, 0xF8 ; in ah, 0xF9",而为什么需要后缀0xFF呢?就是因为处理器考虑到会有这种读写字或者双字的情况,这就意味着处理器需要检查连续的2个位或者4个位,这同时也意味着这几个位可能会跨越一个字节(最多只会跨越一个字节,因为一个字节8位,最多需要检查连续的4位,4 < 8,所以最多跨越1个字节),这就要求处理器每次要去许可串的两个字节进行检查,那么最致命的问题就是如果被检查的位刚好位于许可串的最后一个字节,由于处理器需要取连续的两个字节,而许可串最后一个字节后面的那个字节已经不是许可串的数据了,那该怎么办啊?所以就要求最后一个字节的每一位都填写1,即最后一个字节是0xFF,该结束符当然也算许可串长度的一部分;

       v. 接下来是第二个问题,就是许可串边长的问题,其实处理器不要求为每一个端口都提供映射,那些没有出现在该映射区域的端口处理器都假设其为1(即禁止的),因此如果TSS中没有许可串就表示该任务没有权限访问任何端口(除非其运行在0特权级上);

!举例子说明,如果I/O许可串长度为11,除去最后一个结束符,实际长度为10,即只有前80个端口(0~79号端口)提供了映射,后面的(65535 - 80)个端口都将会被禁用;


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值