1 段

1.1 段选择子

2-1
段选择子位于段寄存器的低16位,是唯一可见的部分,用来定位GDT或LDT中的段描述符;

  • RPL:请求级别;
  • TI:0时查看GDT,1时查看LDT;
  • Index:GDT或LDT的偏移,需要左移3位;

1.2 段寄存器

2-2
段寄存器分为可见的段选择子部分(16bits),和不可见部分的Base(32bits),Limit(32bits),Attribute(16bits);
处理器通过可见部分的选择子指向的段描述符获取到段寄存器的不可见部分;

1.3 段描述符

2-3
段寄存器位于GDT和LDT中,为处理器提供关于段的各种详细信息;

  • P:1时该段表示有效,0时表示无效;
  • Base:分3部分存储,共32bits,表示段的基地址;
  • Limit:分2部分存储,共20bits,表示段的限长,单位由G决定;
  • G:1时Limit的单位是bit,0时Limit的单位是4KB;
  • S:1时表示该段为代码段或数据段,0时表示该段为系统段;
  • Type:受S影响,标识段的类型,权限和增长方向;
  • DB:标识访问方式,对不同段的影响不同;
  • DPL:段的特权级别;
  • AVL:操作系统使用,指示是否可供系统软件使用;

1.4 代码段跳转

权限划分:

  • CPL:当前CPU权限级别;
  • DPL:要求特权级别;
  • RPL:请求特权级别;
    代码段分类:
  • 一致代码段:允许低特权程序访问高特权代码段,反之不允许;
  • 非一致代码段:只允许同级访问;
    代码间的段间跳转:
  1. 拆分段选择子;
  2. 查找GDT得到段描述符;
  3. 权限检查:
    • 一致代码段:RPL>=CPL
    • 非一致代码段:RPL >= CPL && CPL == DPL
  4. 加载段描述符:CPU将新的段描述符加载到CS中;
  5. 执行代码:将CS.Base + [Offset] 写入EIP;

1.5 任务段

任务段(TSS)是一段固定大小的内存(104 BYTE),本意是用来存储各个寄存器的值,实现任务之间的快速切换:
2-11
任务段描述符和普通段描述符结构类似,同样位于GDT中:
2-12
任务段寄存器(TR)和普通的段寄存器的结构相同:
2-13

2 门

2.1 调用门

2-4
调用门描述符也在GDT中,和段描述符类似,但是包含了一个新的选择子和段偏移。在通过权限检查后,段选择子被加载到CS中,段偏移表明了调用过程的入口地址,在段选择子加载新的段描述符时会再次进行权限检查。

  • Selector:新的段选择子;
  • Offset:新的段偏移;
  • ParamCount:如果长调用涉及栈切换时,标识栈之间拷贝参数的个数;
    短调用:不切换代码段,栈中只压入返回地址;
    长调用:
    如果没有权限提升,栈中依次压入调用者CS,返回地址;
    如果有权限提升,由于SS的特权级必须和CS一致,栈在依次压入的是调用者SS,调用者ESP,调用者CS,返回地址;

实验1:无参调用门提权

手动修改GDT:

eq 8003f090 00cf9a00`0000ffff
eq 8003f098 0040ec00`00901000

代码:

#include <stdio.h>
#include <stdlib.h>
#include <Windows.h>

DWORD g_tmp;
const char buffer[6] = { 0, 0, 0, 0, 0x9b, 0 };

void _declspec(naked) IdtEntry(void)
{
 _asm {
  int 3;
  pushad;
  pushfd;
  mov eax, ds: [0x8003f098] ;
  mov g_tmp, eax;
  popfd;
  popad;
  retf;
 }
}


void go(void)
{
 _asm {
  call fword ptr ds : [buffer] ;
 }
}

int main()
{
 if ((DWORD)IdtEntry != 0x401000) {
  printf("Wrong Address!\n");
  exit(-1);
 }
 go();
 printf("%p\n", g_tmp);
 system("pause");
 return 0;
}

实验2:有参调用门提权

手动修改GDT:

eq 8003f090 00cf9a00`0000ffff
eq 8003f098 0040ec03`00901000

代码:

#include <stdio.h>
#include <stdlib.h>
#include <Windows.h>

DWORD g_tmp;
const char buffer[6] = { 0, 0, 0, 0, 0x9b, 0 };

void _declspec(naked) IdtEntry(void)
{
 _asm {
  int 3;
  pushad;
  pushfd;
  mov eax, ds: [0x8003f098] ;
  mov g_tmp, eax;
  popfd;
  popad;
  retf 0xc; //平栈
 }
}

void go(void)
{
 _asm {
  push 1;
  push 2;
  push 3;
  call fword ptr ds : [buffer] ;
 }
}

int main()
{
 if ((DWORD)IdtEntry != 0x401000) {
  printf("Wrong Address!\n");
  exit(-1);
 }
 go();
 printf("%p\n", g_tmp);
 system("pause");
 return 0;
}

实验3:调用门逃逸

手动修改GDT(和实验1一样,但是这里编译后代码中的IdtEntry地址变了):

eq 8003f090 00cf9a00`0000ffff
eq 8003f098 0040ec00`00901050

代码(0x401000是Backdoor函数的地址):

#include <stdio.h>
#include <stdlib.h>
#include <Windows.h>

DWORD g_tmp;
const char buffer[6] = { 0, 0, 0, 0, 0x9b, 0 };

void _declspec(naked) IdtEntry(void)
{
 _asm {
  int 3;
  pushad;
  pushfd;
  mov eax, ds: [0x8003f098] ;
  mov g_tmp, eax;
  mov dword ptr[esp + 0x24], 0x401000; // 修改返回地址
  popfd;
  popad;
  retf;
 }
}


void go(void)
{
 _asm {
  call fword ptr ds : [buffer] ;
 }
}

int Backdoor(void)
{
 printf("Hello!\n");
 return 0;
}


int main()
{
 if ((DWORD)IdtEntry != 0x401050|| (DWORD)Backdoor != 0x401000) {
  printf("Wrong Address!\n");
  exit(-1);
 }
 printf("Backdoor Addr: %p\n", Backdoor);
 go();
 printf("g_tmp: %p\n", g_tmp);
 system("pause");
 return 0;
}

2-8

2.2 中断门

2-5
位于IDT中,与调用门类似,Type部分是固定的,D在32位模式下就是1;

实验4:中断门提权

手动修改IDT:

eq 8003f500 0040ee00`00081000

代码:

#include <stdio.h>
#include <stdlib.h>
#include <Windows.h>

DWORD g_tmp;
DWORD g_stack[4];
const char buffer[6] = { 0, 0, 0, 0, 0x9b, 0 };

void _declspec(naked) IdtEntry(void)
{
 _asm {
  pushfd
  pop eax
  mov g_tmp, eax
  mov eax, [esp]
  mov g_stack, eax
  mov eax, [esp + 4]
  mov g_stack+4, eax
  mov eax, [esp + 8]
  mov g_stack + 8, eax
  mov eax, [esp + 0xc]
  mov g_stack + 0xc, eax
  iretd
 }
}

void go(void)
{
 _asm {
  int 0x20
 }
}

int main()
{
 if ((DWORD)IdtEntry != 0x401000) {
  printf("Wrong Address!\n");
  exit(-1);
 }
 go();
 printf("g_tmp: %p\n", g_tmp);
 for (int i = 0; i < 4; i++) {
  printf("%p\n", g_stack[i]);
 }
 system("pause");
 return 0;
}

2-9

2.3 陷阱门

2-6
陷阱门也位于IDT中,和中断门的结构完全一致,只有Type位不同,二者的区别是陷阱门不会关中断;
2-10

2.4 任务门

2-7
任务门结构与陷阱门类似,但是少了Offset字段,因为TSS段都是固定结构。任务门的作用是将新的段选择子和其他不可见部分加载到TR寄存器中;

3 页

程序运行后会有独立的4GB的虚拟内存,其中的地址被称为线性地址,而在真正读取内存时会转化成真实的物理地址。而如何由线性地址定位到物理地址就使用到了分页。
每一个进程都包含了一个CR3寄存器的值,里面存放的是一个指向一个4KB大小的物理页的物理地址。

3.1 普通分页

2-14
10-10-12分页,三级页表寻址:

  1. 1KB的PDT,包含1024个PDE,可以寻址1024个PTD;
  2. 1KB的PTT,包含1024个PTE,可以寻址1024个页;
  3. 4KB的页,所以总共可以寻址1KB * 1KB * 4KB = 4GB大小的物理内存空间;
    PDE与PTE的结构:
    2-15

线性地址按照10-10-12的结构将32位分成3组,依次表示:

  1. PDT中的偏移(需要×4);
  2. PTT中的偏移(需要×4);
  3. 页中的偏移;
    每个进程的PDT基址不同,存储在进程中的CR3寄存器的值中(不是真的寄存器),然后使用此基址和PDT偏移定位到PTT,再定位到具体的页和地址即可完成地址的转换。

实验5:线性地址转物理地址

首先使用CE获取到线性地址:
2-16
然后在WinDBG中查看进程对应的CR3:
2-17
通过计算线性地址得到的偏移得到物理地址:
2-19


CPU可以通过CR3的值定位到PDT和PTT,但是操作系统只能访问线性地址,那么如何通过线性地址去访问到PDT和PTT来设置进程的PDE和PTE呢?可以使用两个特殊的线性地址:页目录表基址(0xC0300000)和页表基址(0xC0000000);

实验6:PDT基址和PTT基址

线性地址0xC0300000的寻址过程如下图,可见最后的物理地址转换成了CR3的值,也就是PDT的基址:
2-19
线性地址0xC0000000的寻址过程如下图,可见最后的物理地址转换成了一个当前PTT(第一个PTT)的基址:
2-23
最后得到访问PDE的公式:
0xC0300000 + PDI * 4;
访问PTE的公式:
0xC0000000 + PDI * 4096 + PTI * 4

3.2 PAE分页

PAE分页又称为2-9-9-12分页,之所以采取这种新的分页方式是因为普通的10-10-12分页最多只支持4GB物理内存的寻址,而PAE分页通过扩充PTE的长度,可以支持最多64GB物理内存的寻址。但是不同的分页方式不会影响线性地址的寻址,每个进程依旧是4GB的虚拟地址。
PAE分页的寻址方式是:

  1. 页的大小不变,依然是4KB;
  2. PTE由4BYTE扩展到8BYTE;
  3. PTT依然是4KB,那么每个PTT只能容纳512个PTE;
  4. PTT的总数由1024减少到512,PDE的长度扩充到8BYTE;
  5. 新增一级寻址表PDPTT,只有4个表项,使得PDT的个数从1个增加到4个;
    可以看到,在保持4GB虚拟空间的条件下,对物理内存映射并没有占满(因为8BYTE的PTE有24bits是用来寻址的,那么总共有32KB的PTT,但是每个进程只能占有2KB的PTT);
    各表项结构如下:
    2-21

实验7:PAE寻址

和实验5类似,不过增加了一个PDPTT表:
2-22
2-23
2-24

4 其他

4.1 TLB

快表(TLB)位于CPU内部,提供从相对地址到物理地址的映射关系,结构如下:
2-25

  • ATTR由各个表中的表项决定;
  • LRU用来实现TLB表项的更新算法;
    快表和CR3寄存器的值挂钩,CR3寄存器一旦发生变化,快表也会随之更改。
    可以使用INVLPG指令使得TLB当前缓存无效来刷新TLB。

4.2 cache

缓存同样位于CPU内部,其提供的是物理地址到数据之间的映射;
在PTE等表项中有两个字段和cache相关:

  • PWT:1时写入缓存的同时也要写入内存;
  • PCD:1时禁止缓存页面;

4.3 中断&异常

  • 可屏蔽中断(INTR):会受到 IF 寄存器影响,具有16个不同的IRQ编号,时钟中断为IRQ0,位于IDT中的0x30,其他中断号IRQ1-IRQ15位于0x31-0x3f;
  • 不可屏蔽中断(NMI):不受 IF 影响,固定位于IDT的0x2;
  • 异常又称为软件中断,是由CPU自己产生的,不受 IF 影响;

4.4 控制寄存器

CR0——系统的控制标志:
2-26

  • PE:1时表示保护模式,0时表示实模式;
  • PG:1时表示开启分页;
  • WP:写保护,1时表示只读;
    CR1——保留;
    CR2——引起缺页异常的线性地址:
    2-28
    CR3——页目录基地址和缓存控制标志:
    2-27
    CR4——系统的扩展标志:
    2-29
  • VME:虚拟8086模式;
  • PAE:1时启用PAE分页;
  • PSE:1时开启大页(2MB);

5 写在最后

本文主要讲述了x86保护模式下的三大基础部分:段,门和页。

在保护模式下,段寄存器并不用来寻址,而是用来表明当前段的属性,并且我们看到的16位只是可见部分的选择子,通过选择子定位到GDT表中的特定一项来获取到关于此段的完整信息并将其中一些信息保存到段寄存器的其他80位不可见部分。在Windows系统下,大部分的段都是4GB大小的平坦段,操作系统使用页而不是段来做内存保护,段的主要作用是权限控制。在3环进0环后甚至不需要切换DS寄存器。

门是Windows下提权的主要方式,除了调用门之外的其他门都位于IDT中。调用门通过CALL指令调用,如果切换到具有0环权限的选择子的CS时就可以完成权限提升。中断门和陷阱门类似,都是通过INT进行调用,唯一区别是陷阱门调用后不会关中断。中断门(陷阱门)调用后也会为CS提供新的选择子,此时就可以完成提权。

任务段TSS比较特殊,其是固定大小的一段栈,本意是用来快速保存所有寄存器的值,Windows使用TSS用于在权限切换时获取到0环的SS和ESP。

Window使用分页机制实现内存的读写保护。每一个4KB大小的页都有对应的页描述符(PED, PTE)标识页的属性。10-10-12分页实现了4GB线性地址到物理地址的一一映射,但是其最高只能寻址到4GB物理空间。2-9-9-12分页依旧是4GB线性地址映射到4GB物理地址,但是其最高寻址的物理地址范围增加到64GB。

第一次接触到内核这么底层深入的东西,一切都很陌生,虽然已经学过了汇编和操作系统,但是感觉对保护模式的运行理解起来还是比较吃力。首先在搭建环境上就栽了跟头,可能是因为VMWare设置的全局串口重复,导致使用Windbg连接后很容易就卡死,然后我只能把虚拟机VMX的进程杀了,但是这样做似乎会损坏系统,导致我再次连接时出现问题,重装了3次XP才终于弄清楚问题所在。。。

保护模式的入门学习就先到这里了,感觉学到的东西都只是简单的理解了原理,真正实践起来还是不熟练。感谢各位大佬的教程中的耐心讲解,下一篇应该是驱动和系统调用的学习,加油。

6 参考