内核01

段描述符

数据段描述符

1566800109674

代码段描述符

1566800127026

系统段描述符

1566800138206

    A - 访问                             E - 向下扩展
     AVL - 供程序员使用                   G - 粒度
     B - BIG                           P - 段是否有效
     C - CONFORMING                        R - 可读
     D - 默认                              W - 可写
     DPL - 描述符特权级

通用描述符

1566800368599

   L     - 64位代码段(IA-32E模式)
   AVL   - 可供系统软件使用
   BASE   - 段基地址
   D/B   - 默认操作大小(0=16位段;1=32位段)
   DPL   - 描述符特权级别
     G   - 粒度
LIMIT   - 段限长
     P   - 段是否有效
     S   - 描述符类型(0=系统段 , 1=代码段或数据段)
   TYPE   - 段的类型

描述符概述

在实模式中(以前16位CPU所使用的模式) , 在内存中的任何地址上都能够执行代码, 所有的内存地址都是可以被读写的, 这非常不安全. 然而并没有其它手段能够限制或者说禁止代码去读写某个内存地址(例如,在保护模式下,读写地址0是错误的,会导致程序崩溃,在实模式下就不会崩溃). CPU为了提供限制/禁止的手段, 提出了保护模式. 保护模式实际上就是保护了内存, 使得内存中能够被执行代码,能够被读写的地址可以被人为得控制. 在保护模式中, 系统和用户进程是被隔离开的. 用户进程无法修改系统的内存,也无法执行系统的代码.这些也是通过保护模式达成的功能. 保护模式所实现的功能, 很大程度上依赖分段机制. 在实模式下, 分段机制很简单, 它没有限制段是否的读,写,执行属性, 也没有限制一段内存有什么权限,能够在什么权限下被读,写,执行, 只是规定了, 要使用内存, 就需要使用段基地址*16 + 段内偏移的方式来寻址. 在保护模式下, 它兼容实模式下的寻址方式(段基地址*16 + 段内偏移),并且在这基础之上给一个段增加了段基地址, 段的长度, 段的属性这三个属性以实现保护模式下的部分功能. 在保护模式下, 描述一段的基地址在哪里, 段有多长,段有何属性的结构被称之为段描述符.其结构如下所示:

段描述符结构

typedef struct Descriptor{
   unsigned int base; // 段基址
   unsigned int limit; // 段限长
   unsigned short attribute; // 段属性
}

在保护模式下, 增加了很多机制, 使得段产生了不少种类:

  • 数据段(用于存储数据,供程序读写)

  • 代码段(用于执行代码)

  • 系统段(用于操作系统提供特殊功能)

每个段描述既能够描述出一段内存从哪开始,到哪结束, 还能描述这个段是什么类型(代码段,数据段,系统段) ,当然, 也能够描述这个段是否可读,是否可写,是否可执行, 甚至还能描述这个段的权限是什么, 在什么权限下才能使用这一段内存.

段基址

在描述中总长度为32位(4字节) . 表示段的开始地址.

段限长

在描述中总长度为24位, 表示段的最大长度, 也就所, 此位段表示的值最大为: 0xFFFFF, 也就是1Mb , 此长度表示的是单位, 至于一个单位的长度到底是多少字节, 依赖于段属性的G位(粒度位)

如果粒度位(G位)等于0 , 段的大小范围为1字节1Mb(0~0xFFFFF) , 每个单位为1字节.

如果粒度位(G位)等于1 , 段的大小范围是1字节4Gb(0~0xFFFFFFFF), 每个单位为4Kb

段属性

P位

P位用于记录当前段描述符是否有效.

p==0 : 无效, 系统不会使用该段描述符

p==1 : 段描述符有效.

S位

描述符类型(0=系统段 , 1=代码段或数据段), 这个位的值决定了Type字段的值是何种含义.

Type

当S位等于1的时候, Type字段描述的是数据段或代码段

当Type字段的最高位(在段描述符中的11位)等于0时, 是数据段

当一段是数据段时, Type字段的(8,9,10位分别为A,W,E, 其中,

A - 数据段是否已经被访问, 等于1表示已经被访问

W - 数据段是否可写, 等于1表示可写.否则为只读

E - 数据段的扩展方向, 等于1表示向下扩展(向下扩展也可称为向外扩展) , 等于0时表示向上扩展(向内扩展)

1566800429313

代码段/数据段

当Type字段的最高位(在段描述符中的11位)等于1时, 是代码段

当一个段是代码段时, Type字段的8,9,10位分别为A,W,C, 其中, A,W的作用和数据段一样.

C` - 段是否是一致代码段, 等于1表示是`一致代码段`, 等于0表示是`非一致代码段

1566800481129

系统段

当S位等于0的时候 , Type字段的值描述的是系统段. 系统段中的描述符类型一般都是门描述符. 当这个描述符是一个段描述符之时 , Type字段就没有像代码段或数据段中的A,W,E标志了, Type字段的值决定了这个描述符的作用: 1566800498168

D/B位

D/B位的会作用到代码段(CS段寄存器) , 栈段(SS段寄存器),数据段(DS,ES段寄存器),当使用这些段寄存器时, 将会受到不同的影响:

  • 可执行代码段(CS段)

    此标志位被称为D位, 这个位会影响指令和操作数的寻址模式.

    D/B == 1 - 指令默认的寻址模式是32位,操作数默认为32位或8位.

    D/B == 0 - 指令默认使用16位寻址模式, 操作默认大小为16位或8位.

    指令前缀67H可以用来切换寻址模式. 例如, 当前D==1时, 寻址模式是32位, 切换之后,寻址模式就变成16位模式

    指令前缀66H可以用来切换操作数大小, 例如, 当前D==0时, 操作数大小默认为32,切换之后,操作数大小为16位.

  • 栈段(SS段)

    此标志位被称为B位,

    B == 1 - 默认使用32位的ESP寄存器操作栈

    B== 0 - 默认使用16位的SP寄存器操作栈

  • 向下展开的数据段

    CPU虽然提供了这个机制, 但是操作系统并没有使用这个机制

段寄存器和段描述符

CPU提供了段描述之后, 操作系统就可以使用这种机制来做出各种各样的限制. 例如, 以下汇编代码

mov eax , dword ptr ds:[0x403000]

在这条汇编指令当中, 保护模式的分段机制在无形中产生对该指令产生了影响:

  • 指令正在访问的地址是 段基地址*16 + 段内偏移

    如果在16位的实模式下,一般就是ds*16+0x403000 , 但是在32位的保护模式下, 16位的段寄存器并不能存储一个64位的段描述符.

  • 段寄存器保存的值被称为段选择子.

  • 真正的段描述符存储在内存中, 由GDTR寄存器记录其基地址和大小. 那么 , 16位的段寄存器如果才能和64位的段描述符对应起来?

全局描述符表(GDT)

在一个系统中, 描述符的种类有多个, 分别有数据段,代码段, 系统段. 系统段又分为多种,有调用门,中断门,陷阱门,任务门. 因此, 在一个系统中, 描述符是存在多个的. 这些描述符被统一打包存储在内存中, 它们所形成的一个数组被称之为全局描述符表. 全局描述符的小标则保存于16位的段寄存器中. 一个16位的段寄存器实际由以下部分组成: 1566800540924 段寄存器实际的长度为96位, 16位的值, 只是寄存器的可见部分, 段寄存器还有80位是隐藏部分 , 这个隐藏部分只能被CPU所操作,无法通过任何指令来操作它. 这可见部分的16位的值也并非全部用于保存全局描述符的下标, 它被划分为以下格式: 1566800552386 也就是说, 只有13位是用于保存全局描述符表的下标. T1 - 用于记录,保存的下标是GDT(全局描述符表)的还是LDT(本地描述符表)的(windows操作系统没有LDT) RPL - 当前请求级别 , 用作权限检查. 一共有4个值: 0~3 , 数值越小,权限越大, 0代表最高权限.

由于段寄存器用于保存段选择子, 因此, 给一个段寄存器赋值,就不单单是赋值一个数字了,例如:

mov ax,2Bh
mov ds,ax

这条指令可看成将0x2B赋值给ds寄存器, 实际不是. 将0x2b的二进制展开: 0000 0000 0010 1011 , 段选择子的格式为: 13 : 1 : 3. 那么在0x2b这个数中, 描述符表索引,T1位,RPL分别为:

               0000 0000 00101 0  11
               \_____________/ - --
                       |       |   |
                       |       |   +---> RPL = 3
                       |       +-------> T1 = 0(GDT)
                       +----------------> 索引 = 5        

也就说, 0x2b这个数代表的是GDT表中第5个段描述符. 当前请求级别为最低权限的2. mov ds,ax这条指令执行之后做了什么? CPU执行这条指令后, 会将GDT表中第5个段描述符存储在段寄存器隐藏部分, 将段选择子存储到16位可见部分. 当然, 在做这些之前, CPU还需要做权限检查.

权限检查

段描述符中 , 有一个属性是DPL , 这个属性总共有2个二进制位. 大小和段选择子中RPL一样. 这二者正是用于作权限检查的. DPL指的是描述符特权级别, 它决定了在什么特权下,才能够访问此描述符. 其值从0~3共4个,0最表示最高权限, 3表示最低权限, 只有权限高于等于此DPL所记录的权限,才能访问描述符. RPL指的是请求级别, 值的是以何种权限去请求GDT表中的段描述符. 也就是说, 当指令mov ds,ax; // ax==2B 执行是, CPU会将数值2B作为段选择子,使用这个数的低两个二进制位作为RPL, 使用这个数的高13为作为描述符表的索引, 去获取描述符, 但如果要获取的描述符的DPLRPL要小(值越小权限越高) , 这条指令就无法取出这个段描述符,就完成不了赋值, CPU还会报一个异常.

除此之外, CPU还有其它检查, 权限检查是最后一项 , 这些检查依次为:

  • 段描述符有效位检查

    检查段描述符的P为是否为1 , 如果为0 , 说明该描述符无效,CPU会触发一个异常.

  • 段类型检查

    例如, 将一个可读可写但不可执行的段加载到CS段寄存器是错误的,因为CS + IP执行的是代码,如果这个段不能执行,那就没有意义. 将一个只读的段加载到SS是错误的, 因为栈段是可以被改写的, 如果这个段加载到SS却不能修改,也是没有意义的.

  • 段权限数据读写检查

    无论当前执行的是什么指令, 都会使用CS段选择子中的低2位来作为CPL(表示当前执行级别) , 使用被加载的段选择子的低2位作为RPL(表示当前请求级别) , 使用被加载的段选择子的高13位作为描述符表的下标,并从表中取出段描述符,得到该描述符表的DPL(表示描述符特权级别). 当CPL > DPL 或者 RPL > DPL 时, 表示权限不够, 段描述符就会加载是被 也就是说, 当CPLRPL只要其中一个比DPL要大, 操作就会失败. 这是因为操作系统不希望用户程序能够随意切换段描述符, 在32位保护模式下, 每个段描述符的基地址都是0, 段限长都是4Gb, 但其类型是不同的. 系统在创建一个用户进程的时候, 会将用户线程的CS段寄存器的低2位置为2 ,也就是最低的权限的CPL. 这时, 这个系统就永远不能通过正常方式来获取高权限的段描述符, 也就无法访问和修改系统的内存了.

  • 段权限执行检查 在汇编中, 有一些指令是可以跨段跳转的. 例如:

jmp 33:401000 
call 33:401000

上述两条指令被称为远跳转指令和远调用指令, 指令后的操作数分为两部分 : 段选择子和段内偏移. 这两条指令在执行后, 会将操作数中的段选择子对应的段描述符加载到CS中, 此时, CPU也会做检查:

1. 检查请求的段描述符的`S`是否为1, 如果是1, 表示是请求的段是一个数据段或代码段, 再检查`Type`的高位是否       是1 , 如果是1 , 表示请求的段是代码段. 如果其中一个不是,就无法加载,指令无法执行,CPU还会报异常.
2. 继续检查,`Type`的`C`标志, 如果是一致代码段, 则要求`CPL`>=`DPL` , 也就是只能低权限转移到高权限, 如果是非一致代码段, 则要求 ` CPL==DPL` 并且, `RPL<=DPL`, 也就是平级才能转移.
     但是转移之后, `CPL`和`RPL`不会改变.

3. 如果检查`S`位是0 , 则表示请求的是系统段.              

下面是伪代码:

//段选择子的结构:
// [描述符下标:13   | T1:1 | RPL:2]
unsigned short segSel = 0x33;// 0x33就是要切换的段选择子,在指令jmp 33:401000 中给出。
unsigned int RPL = segSel & 0x11;  // 取段选择自的低2位作为RPL
unsigned int CPL = CS & 0x11 ; // 取`CS`段寄存器的值的低2位作为CPL
if( SegDes.S == 1 && SegDes.Type & 0x1000 ){
    if(SegDes.Type.E == 1){ // 一致代码段
        if( CPL >= segDes.DPL ){
            CS = segSel; // 可以切换。
       }else{
            throw "异常";
       }
   }else{ // 非一致代码段
        if(CPL == SegDes.DPL && RPL <= SegDes.DPL){
            CS = segSel;
       }else{
            throw "异常";
       }
   }
}else{
    throw "异常";
}

系统段描述符 - 门描述符

很多时候, 用户层的代码需要切换到内核层执行代码。 因为有些代码执行时需要用到0环权限.

切换0环权限实际就是将CS段寄存器的CPL改成0(也就是0环权限).

但在3环时,CSCPL是2, 是无法直接修改的(如果要修改,就需要切换段选择子, 切换段选择子,就需要使用CPL,RPL和段描述符中的DPL比较)

当段描述符中的S位等于0 , 表示这个描述符是一个门描述符。

门描述符一般用于从3环进入到0环,并能够将3环权限切换成0环权限。

门描述符的种类有:

  • 调用门(Windows操作系统没有使用此机制)

  • 中断门(IDT表中的中断处理函数就是这种门描述符)

  • 陷阱门(IDT表中的陷阱处理函数就是这种门描述符)

  • 任务门(用于任务切换)

调用门

调用门的出现是为了便于在不同的权限直接切换.

1566800688420

一个调用门中, 保存了以下信息:

  • 要执行的函数的地址

  • 要执行的函数的参数个数

  • 段选择子 (这个段选择子用于切换权限)

字段解析:

  • Offset in Segment - 函数在段内的偏移, 实际就是函数的地址, 这个地址的被拆分成高16位和低32位保存.

  • Segment Selector - 段选择子, 使用调用门时, 这个选择子就是被切换成CS的选择子.

  • Param Count - 调用门中保存的函数的参数个数. 注意, 每个参数应当是4字节的.

如果发生了权限切换, 系统也会将用户栈切换成内核栈, 也就是权限切换时, CS段寄存器的值会被改掉(不改掉切换不了切换段描述符) , 还会将SS段选择子,切换为内核的段选择子, 将ESP切换成内核的ESP, 切换前,SS,ESP的值都会被保存. 在切换回来之后,才进行还原.

因为栈要进行切换, 当函数被调用时, 用户栈中的函数参数会被拷贝到内核栈, 因此, 需要在调用门描述符中指定参数的个数是多少, 否则系统将无法为函数拷贝参数到内核栈中.

调用门的设置和使用

因为在Windows中没有使用调用门, 因此, 在GDT表中是没有调用门描述符的.

想要试验一个调用门,就需要自己使用windbg开启双击调试,并自行在GDT中设置一个.

设置的方式如下:

  1. 构造如下结构体:

typedef struct _CALLGATEDESCRIPT{
       unsigned int functionAddrLow : 16; // 被调用函数地址的低16位
       unsigned int SegmentSelector : 16; // 想要切换的段选择子
       unsigned int paramCount : 5; // 参数个数
       unsigned int none : 3; // 无
       unsigned int Type : 4; // 系统段类型, 必须为12(1100b: 32位调用门)
       unsigned int S : 1; // S位,必须为0
       unsigned int DPL : 2; // 描述符特权级别
       unsigned int P : 1; // 描述符有效位
       unsigned int functionAddrHig : 16; //被调用函数地址的高16位
   }CALLGATEDESCRIPT;
  1. 设置如下:

unsigned long long createCallGateDescript(unsigned short selector,/要切换的段选择子/
                                           unsigned int functionAddr,/要通过调用门执行的函数/
                                             int functionParamSize/函数参数所占用的字节数/)
   {
       CALLGATEDESCRIPT cgd = { 0 };
       cgd.P = 1; // 描述符有效位, 必须设置为1
       cgd.S = 0; // 系统段描述符, 必须设置0
       cgd.Type = 12; // 调用门描述符,必须设置为12
       cgd.DPL = 3; // 设置为3,表示此描述符可被3环所使用.
       cgd.SegmentSelector = selector;// 要切换的段选择子
       cgd.functionAddrHig = (functionAddr & 0xFFFF0000) >> 16;
       cgd.functionAddrLow = functionAddr & 0x0000FFFF;
       cgd.paramCount = functionParamSize / 4; // 参数个数,如果函数没有参数,填0,如果有参数,则填参数占用栈的字节数 / 4
       return (unsigned long long)&cgd;
   }
  1. 使用调用门的之前, 需要将调用门的描述符写进系统的GDT表中, 操作如下:

    1. 使用windbg双机调试, 输入指令rgdtr获取GDT表的地址

kd> rgdtr
   gdtr=80b95000
  1. dq命令查看GDT表中哪些是空闲的, 值为0的都是空闲的.1566800760465

  2. 构造一个可以在3环代码中使用的段选择子, 构造规则:

    1. 段选择子的格式为: [ 描述符在表中的下标:13 | T1:1 | RPL:2]

    2. 假如在GDT表,第9项是空闲的, 门描述符将保存在此处, 则对应的段选择子为:

      十进制: 9-0-3 ==> 二进制: 1001-0-11 ==> 合并为 100 1011 ,十六进制数值为0x4B

    1. 在代码中使用此描述符:

// 前4字节是EIP,后2字节是CS
// 后2字节是0x004B, 此数值就是在第二步中构造出来的.
char buff[ ] = { 0,0,0,0,0x4b,00 };
_asm call fword ptr ds:[buff];

代码:

#include <iostream>
#include <iomanip>


#pragma pack(1)
typedef struct _CALLGATEDESCRIPT {
    unsigned int functionAddrLow : 16; // 被调用函数地址的低16位
    unsigned int SegmentSelector : 16; // 想要切换的段选择子

    unsigned int paramCount : 5; // 参数个数
    unsigned int none : 3; // 无
    unsigned int Type : 4; // 系统段类型, 必须为12(1100b: 32位调用门)
    unsigned int S : 1; // S位,必须为0
    unsigned int DPL : 2; // 描述符特权级别
    unsigned int P : 1; // 描述符有效位
    unsigned int functionAddrHig : 16; //被调用函数地址的高16位
}CALLGATEDESCRIPT;

struct SELECTOR {
    unsigned short index : 13;
    unsigned short T1 : 1;
    unsigned short RPL : 2;
};

unsigned long long createCallGateDescript(unsigned short selector,unsigned int functionAddr, int functionParamSize)
{
    CALLGATEDESCRIPT cgd = { 0 };
    cgd.P = 1; // 描述符有效位, 必须设置为1
    cgd.S = 0; // 系统段描述符, 必须设置0
    cgd.Type = 12; // 调用门描述符,必须设置为12
    cgd.DPL = 3; // 设置为3,表示此描述符可被3环所使用.
    cgd.SegmentSelector = selector;// 要切换的段选择子
    cgd.functionAddrHig = (functionAddr & 0xFFFF0000) >> 16;
    cgd.functionAddrLow = functionAddr & 0x0000FFFF;
    cgd.paramCount = functionParamSize / 4; // 参数个数,如果函数没有参数,填0,如果有参数,则填参数占用栈的字节数 / 4
    return *(unsigned long long*)&cgd;
}

int g_num;
short g_ss;
int g_esp;

//通过调用门调用的 函数
void _declspec(naked) GateFun()
{
    g_num = 100;
    _asm mov [ g_esp ] , esp;
    _asm mov ax , ss;
    _asm mov word ptr [g_ss],ax
    _asm retf;
}

int main()
{
    printf( "调用门函数地址:%08X\n" , GateFun );
    printf( "切换的段选择子:%04X\n" , 8 );/*8是内核中的代码 段选择子*/

    unsigned long long descript =
        createCallGateDescript(8/*8是内核中的代码段选择子*/ , ( unsigned int )GateFun , 0 );


    printf("请将这个段描述符写入到GDT[9]中: ");
    std::cout << std::hex <<std::uppercase<< std::setfill('0')<<std::setw(8)<< descript<<'\n';
    system( "pause" );



    // 获取当前寄存器的值.
    _asm mov[ g_esp ] , esp;
    _asm mov ax , ss;
    _asm mov word ptr[ g_ss ] , ax

    printf( "调用前 esp=%08X, ss=%04X\n" , g_esp , g_ss );

    // 前4字节是EIP,后2字节是CS(0x004b)
    char buff[ ] = { 0,0,0,0,0x4b,00 };

    // 执行流程:
    // 1. 从buff这块内存中取出段选择子:0x4b
    //
    //       解释                       SEL T RPL
    //     十进制                         9 0 3
     // 2. 将段选择子分解,100 1011 ==> 1001 0 11, 得到GDT表中的下标:9
    // 3. 取出GDT表中第9项描述符, 是一个调用门描述符.
    // 4. 将调用门描述符中的段选择子加载到CS段寄存器
    // 5. 将调用门描述符中的函数地址设置到EIP寄存器.
    _asm call fword ptr ds:[buff];

    printf( "调用后 esp=%08X, ss=%04X\n" , g_esp , g_ss );
    printf( "g_num=%d\n" , g_num );
    system( "pause" );
}

windbg 输入eq 80b95068(上图右边红色框的地址).

1566819613281

任务门,中断门和陷阱门

中断门和陷阱门的描述符其实和调用门一模一样.

1566800820799

1566800833104

任务门描述符, 中断门描述符和陷阱门描述符都是保存在IDT(中断描述符表)中.

其使用的过程是:

产生中断或异常后,

  1. CPU会使用中断号找到IDT表中的中断描述符/陷阱描述符,

  2. 取出描述符后, 得到门描述符中的段选择子.

  3. 通过此段选择子找到GDT表中的段描述符,

  4. 从GDT表中取出的段描述符中得到段基地址

  5. 使用段基地址 + 门描述符中的函数偏移 , 得到函数地址.

  6. 调用该函数.

猜你喜欢

转载自www.cnblogs.com/ltyandy/p/11414646.html
今日推荐