Windows内核-保护模式

以下内容皆基于x86 CPU进行学习

x86 CPU 模式

x86 CPU存在3个模式:

  • 实模式
  • 保护模式
  • 虚拟8086模式

目前大多数的PC都运行在保护模式下,保护模式的特点:

通过段页机制,保护系统的一些重要的数据结构

段寄存器

例:

mov dword ptr ds:[0x123456], eax

真正读写的地址为:ds.base + 0x123456

在x32 dbg中显示的有6个段寄存器:

image20200928210939778.png

实际上段寄存器共有8个

GS FS ES DS CS SS LDTR TR

段寄存器的结构

段寄存器的长度共为96

其中只有低16位是可见的,剩下的高80位皆为不可见部分

通过上图也可以发现,在x32 dbg中显示的段寄存器有32位,但实际上可见的只有低16位,高16位会显示为0

段寄存器的结构体:

struct SegMent {
    WORD Selector;
    WORD Attributes;
    DWORD Base;
    DWORD Limit;
}

由于可见部分只有16位,故在读取段寄存器的值时也只能读取16位

image20200928230624605.png

image20200928230645690.png

读的时候是读16位,但是写的时候是写了96位

段寄存器属性探测

段寄存器成员简介:

段寄存器 Selector Attribute Base Limit
ES 002B 可读,可写 0 0xFFFFFFFF
CS 0023 可读,可执行 0 0xFFFFFFFF
SS 002B 可读,可写 0 0xFFFFFFFF
DS 002B 可读,可写 0 0xFFFFFFFF
FS 0053 可读,可写 0x7FFDE000 0xFFF
GS 002B - - -

Attribute属性探测

可以发现CS是不可写的,正常的情况下,该汇编代码是可以成功执行的

#include <iostream>

int main() {
    int var = 0;

    _asm {
        xor eax, eax
        mov ax, ss
        mov ds, ax
        mov dword ptr ds:[var], eax
    }

    printf("%x\n", var);

    system("pause");
    return 0;
}

如果将ds,修改为cs:

image20200928232035274.png

Base属性探测

#include <iostream>

int main() {
    int var = 0;

    _asm {
        xor eax, eax
        mov eax, fs:[0]
        mov dword ptr ds:[var], eax
    }

    printf("%x\n", var);

    system("pause");
    return 0;
}

从图中也可以发现,mov eax, dword ptr fs:[0]** 是可以正确读取的,说明**fsBase值是不为0

image20200928232941888.png

当访问ss:[0]就会出现0xC0000005中断,说明该位置不可读

image20200928233304587.png

Limit属性探测

limit属性记录的是当前段寄存器可以接受的最大偏移,在fs中的最大偏移为0xFFF

那么此时就可以尝试代码:

#include <iostream>

int main() {
    int var = 0;

    _asm {
        xor eax, eax
        mov eax, dword ptr fs:[0x1000]
        mov dword ptr ds:[var], eax
    }

    printf("%x\n", var);

    system("pause"); 
    return 0;
}

直接报错:

image20200928233900034.png

段描述符与段选择子

GDT(全局描述符表):GDT表里存放的是段描述符,段描述符的大小为8个字节

** **r gdtr -> GDT表所在的位置

** **r gdtl -> GDT表的长度

** **dd [gdtr] -> 真正GDT表中存放的内容

LDT(局部描述符表):

段描述符

通过MOV等指令,往段寄存器中填充值时, 需要先通过段寄存器中可见的16位也就是段选择子, 查找GDT表或者LDT表中的段描述符,再通过段描述符中的各种属性,往段寄存器中填充值, 段寄存器的位数为96位.

通过dq查看GDT表中的数据时,每8个字节对应一个段描述符,

高4个字节对应图中上面的四个字节,低4个字节对应图中下面的四个字节

image20200929152540959.png

  • AVL:
  • BASE: 如图所示, 将三个区域的Base Address拼接, 得到的就是该段的首地址
  • D/B: 该标志位的具体功能取决于该段所指的段类型,包括可执行代码段,向下扩展的数据的段,以及堆栈段
  • DPL: 描述符的特权级别, 控制对该段的访问.
  • G: 影响段限长扩展的增量。
    • G=0:该段大小可以从1字节到1M字节,段长增量单位为字节
    • G=1:该段大小可以从4K字节到4G字节,段长增量单位为4K字节
  • LIMIT: 指定段的大小,CPU将这两个段限长域组合成一个20位的段限长值。根据G位的不同,CPU按两种不同的方式处理段限长
  • P: 表示该段是否在内存中, 1表示在内存中, 0表示不在内存中
  • S: 确定该段描述符的类型, 0表示系统描述符, 1表示代码或数据描述符
  • TYPE: 指明段或门的类型, 确定段的范围权限和增长方向, 与S位配合使用, 如何解释该域, 需要看S的值为0还是1

通过图中的段描述符的分布,可以发现段描述符是分成一块一块的,特别是Base,直接给32位不就完了,为什么还要分成三个部分,再进行拼接呢,这里主要是因为Intel的CPU会考虑到向下兼容的问题,所以现在我们所看到的段描述符,是在以前的基础上扩展得到,像以前的MS-DOS系统,是16位的,那么现在的32位的程序,就需要Base扩展到32位才行,为了向下兼容,就不能破坏以前的CPU结构,所以就在原有的基础上进行扩展,直接扩充16位的Base,最后进行拼接得到Base

以上也可以发现,段描述符总共有64位,而段寄存器的大小为96位,除去可见的16位段选择子,还需要填充80位。

高4字节的第8位到第23位,填充为段寄存器中的Attribute属性

高4字节的的24-31以及0到7再加上低四字节的16-31 填充为段寄存器中的Base属性

可见的16位段选择子就填充为段寄存器中的Selector属性

低4字节的0-15以及高4字节的16-19为填充为段寄存器中Limit属性的连续五个16进制,剩下的三位需要看Attribute属性当中的G位,

若G位为0,则表示段限长以字节为单位,若G位为1,则表示段限长以4KB为单位,假设此时0-15拼接16-19后得到0xFFFFF,那么当G为0时,填充的Limit值为0x000FFFFF,当G为1时,填充的Limit值为0xFFFFF FFF,0xFFF表示的是以4KB为单位进行增加,这个位置的值是不改变的

image20201002003359427.png

当S位的值为1时,说明该段为数据段或者代码段,具体是哪一种段,需要通过TYPE域来进行说明,通过上图可以发现当11位的值为1时,该段为代码段,否则为数据段。假设表示该域的16进制为x,若x>=8,则该段为Code段,若x<8,则该段为Data段

向上扩展与向下扩展:

image20201002011327650.png

D/B位对该段的影响:

  • 对CS段的影响:
    • D=1,32位寻址方式
    • D=0,16位寻址方式
  • 对SS段的影响:
    • D=1,隐式堆栈访问指令(如:PUSH POP CALL)使用32位堆栈指针寄存器ESP
    • D=0,隐式堆栈访问指令(如:PUSH POP CALL)使用16位堆栈指针寄存器SP
  • 向下扩展的数据段:
    • D=1,段上限为4GB
    • D=0,段上限为64KB

段选择子

段选择子是一个16位的段描述符,段选择子指向了该段的段描述符在GDT表中的位置

image20200929154730594.png

  • RPL:请求的特权级别,00 01 10 11
  • TI:查找的表,值为0时查找GDT表,值为1时查找LDT表,在Windows中基本不使用LDT表,所以在Windows中的TI值基本上都为0
  • Index:索引值,表示的是选择LDT表/GDT表中的第几个段描述符,由于每个段描述符的长度为8个字节,所以需要先乘8,然后加上LDT表/GDT表的基地址,得到的就是该段所对应的段描述符所在的真正地址。

example:

假设此时段选择子的值为 002B -> 0000 0000 0010 1011
那么需要将段选择子拆分成3个部分:RPL、TI、Index
Index: 0000 0000 0010 1
TI: 0
RPL: 11

触发通过段选择子查找段描述符的指令:

  • MOV SS/DS/FS/GS/ES, AX
  • LES
  • LGS
  • LSS
  • LFS
  • LDS

CS没有对应的LCS指令,也无法通过MOV指令进行修改,是因为CS与EIP是绑定的,如果修改CS,需要将EIP也同时修改,否则会导致错误

char buffer[6] = { 0 };

// 高2字节的值给es, 低4字节的值给ecx
_asm {
    les ecx, fword ptr ds:[buffer] 
}

RPL <= DPL,LES等指令才能正确执行

RPL代表段选择子的特权级别,DPL代表段描述符的特权级别,需要注意的是数值越小,特权级别越高,所以如果要通过段选择子去读段描述符的值,需要段选择子的级别大于或者等于段描述符的特权级别。

**假设RPL=3,此时可以理解为段选择子的特权级别为3,即应用层级别的权限,而DPL=0,段描述符的特权级别为0,即内核权限,那么此时段选择子是无法直接读取段描述符的内容的,那么LES等指令就会执行失败,访问权限错误.**

段权限检查

通过CS段寄存器的段选择子的低2位,正常的3环应用程序中,CS的段选择值低2位的值肯定为3,并且CS与其他段寄存器不同的在于,CS的段选择子的低两位直接称为CPL,也就是Current Privilege Level,当前特权级。

PS:可以通过查看CS或者SS后两位的值来判断当前程序处于几环,那么就可以得出,CS和SS段选择子中后两位的值是相同的

image20201003111846667.png

数据段的权限检查:

首先是通过CS或者SS可以得出当前的程序处于0环或者3环(Windows系统下已经不在使用1环和2环)

之后执行汇编指令:

mov ax, 000B //1011 那么此时的RPL = 3

mov ds, ax // ax 指向的段描述符的DPL = 0

想要成功执行以上指令,必须具备这样的条件:

CPL <= DPL 并且 RPL <= DPL

CPL、DPL、RPL:

CPL:Current Privilege Level当前程序的特权级别,通过CS或者SS的段选择子查看得到

DPL:Decriptor Privilege Level段描述符的特权级别,表示需要大于或等于该特权级别时才能被加载

RPL:段选择子的特权级别,该特权级别是可以随意更改的,但是也需要注意只有大于或等于DPL时才能读取段描述符中的数据

通俗一点讲:

CPL:表示当前程序的特权级别

DPL:表示需要通过什么样的特权才能访问该段

RPL:表示选择用什么样的特权访问段描述符

代码跨段跳转流程

CS与EIP同时修改的指令:

  • JMP FAR
  • CALL FAR
  • RETF
  • INT
  • IRETED

只修改EIP的指令:

  • JMP
  • CALL
  • JCC
  • RET

例:

JMP FAR:

JMP 0x20:0x004183D7

CPU执行这条指令的时候

  1. 拆分段选择子,得到关键数据:
    0x20代表的就是段选择子,拆分之后得到:
    RPL = 00
    TI = 0
    Index = 4
  2. 通过TI查GDT表,Index=4找到段描述符的位置
    符合跳转的描述符:代码段、调用门、TSS任务段、任务门
  3. 权限检查,对CPL、DPL、RPL进行检查
    非一致代码段:要求CPL==DPL 并且 RPL <= DPL
    一致代码段:要求CPL >= DPL
    一致代码段用于将数据共享,这里可以发现CPL的权限要低于DPL,说明该段是可以让低权限的程序进行访问的,而非一致代码段是要求程序的特权级别等于段描述符可以访问的特权级别的,说明该段多用于内核程序和数据等,不让3环的程序可以随意就访问到
  4. 加载段描述符
    以上三步都是对段的一个检查,当前面三步检查通过后,就可以将该段描述符加载到CS寄存器中
  5. 代码执行
    CPU将CS.Base + Offset的值写入EIP,然后执行CS: EIP处的跳转,段间跳转结束。

ps:直接对代码段进行JMP或者CALL操作时,是不会改变CPL的值的,也就是说权限并不会发生改变,如果想要权限发生改变,只能通过调用门

avatar