保护模式

保护模式

现在操作系统大多是运行在保护模式下的,如果我们要学好操作系统,保护模式是一定要了解的;本章节主要讲解x86的保护模式,由于x64是基于x86拓展的指令集,所以当你具备x86的基础之后再去学习x64也就很简单了。

保护模式的特点就是段、页的机制,这也是保护模式所需要学习的内容。学习保护模式才能真正的理解内核是如何运作的。

段寄存器

如果你学习过初级篇的课程,实际你就已经使用过段寄存器了,如下汇编代码:

mov dword ptr ds:[0x123456], eax

这是向某一地址写入内容,汇编代码中的DS实际上就是段寄存器,真正写入的地址实际上是DS.base+0x123456,base就是DS段寄存器的一个属性。(其他段寄存器的形式也是如此。)

段寄存器一共有8个:ES、CS、SS、DS、FS、GS、LDTR、TR。

结构

段寄存器的结构与我们之前所了解的通用寄存器结构是不一样的,通用寄存器有32位(4字节)宽度,但是段寄存器却有96位(12字节)宽度。

段寄存器的结构如下图所示,虽然它有96位宽度,但我们可见的部分只有16位:

images/download/attachments/1015831/image2022-6-9_17-11-19.png

如下用结构体来表示,有16位可见部分(Selecter),16位属性,32位Base和32位Limit:

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

16位属性指的的是当前段寄存器的可读、可写的属性;32位Base表示当前段是从哪里开始的;32位Limit表示当前段的整个长度。

读写

由于我们只能看见段寄存器的16位,所以我们也只能对段寄存器的这可见部分进行读取,但写入的话是按照96位去写入的。

images/download/attachments/1015831/image2022-6-9_17-53-36.png

如上图所示我们可以使用MOV指令对段寄存器进行读写,但是这2个段寄存器:LDTR、TR,它们是不可以使用MOV指令进行读写的

属性探测

上文中我们知道段寄存器的结构中是有几个成员(属性)的,我们可见的部分是16位的Selecter,其余的都无法看见,那么这些属性都是真实存在的还是一个虚无缥缈的概念呢,我们可以使用代码来证实这些属性的存在。

如下表所示,是我当前Windows XP虚拟机环境中的段寄存器相关的属性,标红部分即表示这些属性对应的值是不固定的:

段寄存器

Selector

Attribute

Base

Limit

ES

0023

可读、可写

0

0xFFFFFFFF

CS

001B

可读、可执行

0

0xFFFFFFFF

SS

0023

可读、可写

0

0xFFFFFFFF

DS

0023

可读、可写

0

0xFFFFFFFF

FS

003B

可读、可写

0x7FFDF000

0xFFF

GS

-

-

-

-

如上表格这些属性值都可以在调试器的寄存器窗口中找到:

images/download/attachments/1015831/image2022-6-10_11-5-4.png

注:GS段寄存器没有属性值是因为Windows XP中并没有使用到它。

探测Attribute

首先我们来探测Attribute属性,如上表中,段寄存器CS的Attribute是可读、可执行,也就表示我们没法对该段寄存器进行写入,所以我们可以通过如下代码去探测它,该代码的意义就是将段寄存器CS的值读到AX寄存器中,再将AX寄存器的值读区到DS段寄存器中,最后通过DS段寄存器去写入内容:

void main()
{
int var = 0;
__asm {
MOV AX,CS
MOV DS,AX
MOV DWORD PTR DS:[var], eax
}
}

images/download/attachments/1015831/image2022-6-10_10-59-45.png

如果这段代码可以执行则表示我们可以对段寄存器CS进行写入内容,反之则证明其Attribute属性的真实性。

编译代码并运行可执行文件,最终提示我们出了问题,由此我们便探测出了段寄存器CS的Attribute属性:

images/download/attachments/1015831/image2022-6-10_11-1-54.png

探测Base

我们之前在了解段寄存器的时候提到,当你以如下这种汇编指令去向地址写值时,实际上写入的地址是段寄存器的Base属性加上0x123456:

mov dword ptr ds:[0x123456], eax

因此,我们想要去探测段寄存器的Base属性,可以使用向0x0地址写值,因为我们都知道正常情况下0x0地址是不可读不可写的,但是FS段寄存器的Base属性值为 0x7FFDF000,所以我们可以使用如下代码去探测

void main()
{
int var = 0;
__asm {
MOV AX,FS
MOV GS,AX
// MOV DWORD PTR DS:[0], EAX
MOV DWORD PTR GS:[0], EAX
}
}

如上代码编译并运行不会有任何报错,因为我们将EAX寄存器值写入到的实际地址是 0x7FFDF000+0x0,如若你将MOV指令后的GS换成DS则编译运行就会报错:

images/download/attachments/1015831/image2022-6-10_11-17-31.png

探测Limit

我们知道Limit表示当前段的整个长度,FS段寄存器的Limit属性值为0xFFF,因此我们可以设定一个超出长度的值去写入来探测Limit属性:

void main()
{
int var = 0;
__asm {
MOV AX,FS
MOV GS,AX
MOV DWORD PTR GS:[0x1000], EAX
}
}

如上代码中,我们向 0x7FFDF000+0x1000写入内容,由于FS段的长度只有0xFFF,0x1000很明显大于0xFFF,所以这段代码编译运行就会报错:

images/download/attachments/1015831/image2022-6-10_11-22-36.png

描述符与选择子

在之前的章节中我们使用了如下这种指令向段寄存器中写入值,并且在最初介绍段寄存器的时候就提到,虽然这里我们是将16位寄存器的值写入进去,但实际上写入的仍然是96位:

MOV GS,AX

那么另外80位的值是从何而来的呢?我们就需要了解这两个概念:GDT(Global Descriptor Table,全局描述符表)、LDT(Local Descriptor Table,局部描述符表)。

当我们执行如上类似指令时,CPU会根据AX的值选择GDT、LDT这两张表中的一张表进行查询,在表中查询出对应的信息给到段寄存器。

双机调试环境

这里我们想要去在查看GDT表就需要使用Windbg调试内核,为了便于未来的学习调试,我们需要来配置一下调试环境。调试模式为双机调试(因为你调试的是内核,本机调试的话,你在内核断点实际上整个系统也就停止了,你也就无法继续调试,所以我们需要使用双机调试模式进行调试。),你需要在你的物理机器上安装VMware虚拟机,接着在VMware中安装Windows XP系统的虚拟机;接着,在物理机器上安装Windbg,你可以通过微软官方地址进行下载安装:https://docs.microsoft.com/zh-cn/windows-hardware/drivers/debugger/debugger-download-tools。环境安装流程大致就是如此,这里不再进行图文赘述。

当你准备好环境之后,设置Window XP虚拟机,添加串行端口并按如下图进行配置:

images/download/attachments/1015831/image2022-6-14_15-49-27.png

然后在Windows XP虚拟机中修改C盘下的boot.ini文件(取消文件隐藏即可看见),添加如下内容即可:

multi(0)disk(0)rdisk(0)partition(1)\WINDOWS="XP DEBUG" /execute=optin /fastdetect /debug /debugport=com1 /baudrate=115200

images/download/attachments/1015831/image2022-6-14_16-4-6.png

接着找到你物理机上的Windbg(x86)文件,并发送快捷方式到桌面,修改快捷方式的目标值为:

"你的目录\windbg.exe" -b -k com:pipe,port=\\.\pipe\com_1,baud=115200,pipe

images/download/attachments/1015831/image2022-6-14_16-6-40.png

这时候我们的环境准备工作已经完成了,重启虚拟机至如下界面,选择XP DEBUG启用调试程序进入系统:

images/download/attachments/1015831/image2022-6-14_16-8-11.png

然后快速的双击你修改后的Windbg快捷方式,如下图所示我们就可以去观察Windows XP的内核了:

images/download/attachments/1015831/image2022-6-14_16-18-23.png

GDT表信息

我们可以使用Windbg去查看GDT表的一些信息,在Windbg中使用如下指令去查看GDT表相关的属性:

r gdtr - 查看GDT表的位置
r gdtl - 查看GDT表的大小
// gdtr、gdtl也都称之为寄存器

images/download/attachments/1015831/image2022-6-14_16-30-10.png

我们知道GDT表的位置之后就可以使用如下指令去找到这张表:

dd gdtr寄存器的值

images/download/attachments/1015831/image2022-6-14_16-31-15.png

如上图中,最左边的800开头的就是地址,右边的就是表中的数据。

这里我们使用dd指令去查看数据实际上不是很方便,因为它表示以4字节一组的方式展示数据,我们为了看起来更加方便我们可以使用dq指令去查看数据,它是以8字节一组的方式展示数据:

images/download/attachments/1015831/image2022-6-14_16-37-7.png

我们通过gdtl寄存器知道当前GDT表的大小,但是使用dq/dd指令去查看表的时候很明显,它展示出来的数据并没有那么多,所以我们可以在之后加上参数来自定义我们返回数据大小:

dq gdtr寄存器的值 L40
// L不区分大小写,40在这里表示十六进制0x40,当前指令的意思是展示0x40组(dq->8字节)GDT表的数据

段描述符

在GDT表中存储的元素就是段描述符,每一个段描述符的宽度是8字节。段描述符的结构如下图所示:

images/download/attachments/1015831/image2022-6-15_15-14-33.png

这是一个从高到低的顺序,同时在Windbg中对应的也可以按图将数据平铺过来:

images/download/attachments/1015831/image2022-6-14_17-35-49.png

dq/dd指令会帮我们自动排好,便于对照着结构直接看,如果你想观察的仔细一些可以使用db指令:

images/download/attachments/1015831/image2022-6-14_17-38-37.png

如下图所示这个对应关系就很容易理解了,其他的以此类推即可:

images/download/attachments/1015831/image2022-6-14_17-41-47.png

段选择子

段选择子是一个16位的段描述符,该描述符指向了定义该段的段描述符。之前我们所举例的AX寄存器就可以称之为段选择子。

如下图所示就是段选择子的结构及对应位的属性说明:

images/download/attachments/1015831/image2022-6-14_17-48-17.png

所以我们可以通过段选择子的TI位知道查询GDT还是LDT表(注意,在Windows XP中没有使用LDT表,所以默认情况下TI位都是0。),Index位可以知道具体要查的表的哪一个段描述符。

加载段描述符至段寄存器

之前我们所举例的MOV指令本质上就是加载段描述符至段寄存器,除了该指令外,我们还可以使用LES、LSS、LDS、LFS、LGS指令完成这一动作。

需要注意的是,CS段寄存器不能通过上述指令进行修改,这是因为CS为代码段,CS的改变会导致EIP的改变;如果你想要修改CS,就需要保证CS和EIP一起修改,这个在之后的章节中会学到。

如下代码所示,我们使用LES指令,它的意思是将buffer的高2个字节(段选择子)给到ES段寄存器,低4个字节给到ECX寄存器:

void main()
{
char buffer[6];
__asm {
LES ECX,FWORD PTR DS:[buffer]
}
}

需要注意的是段选择子的RPL数值要小于段描述符中的DPL。

段描述符的属性

当我们执行MOV类似指令时,CPU会根据段选择子的值选择GDT、LDT这两张表中的一张表进行查询,在表中查询出段描述符给到段寄存器,但是我们知道段描述符只有64位,而除了段选择子本身的值外,段寄存器还需要80位,所以这里64位是如何变成80位的呢?这就需要我们去了解段描述符的相关属性。

P位

P位是段描述符高位4字节的第15位,它的值决定了段描述符是否是有效的。

P = 1 -> 段描述符有效
P = 0 -> 段描述符无效

G位

在了解G位属性之前,我们先来看一下段描述符与段寄存器结构的对应关系,如下图所示我使用颜色方框标记出了它们之间的对应关系(大家可以会觉得段描述符中的属性分布很零碎,这是因为段描述符也是一步一步发展的,Intel需要考虑老版本的系统就需要向下兼容,所以段描述符的结构就不能有变化,只能在以前的结构上进行拓展):

images/download/attachments/1015831/image2022-6-15_16-12-47.png

仔细看这张图,你就会发现在段寄存器结构中的成员Limit对应到段描述符中只有20位,其余的12位没有了,也就表示这里的Limit最大值就是0xFFFFF,而另外12位取决于描述符的属性G位。

当G位的值为0时,Limit最大值就是0xFFFFF,按32位书写就是0x000FFFFF;当G位的值为1时,Limit的单位就是4KB,它的取值公式就是:(根据段描述符中拼接出来的Limit值(20位)+ 1)* 4KB - 1,根据取值公式此时Limit的最大值为0xFFFFFFFF。

至此,我们就知道了CPU是如何将段描述符的对应属性取出来放入段寄存器的结构中,也解释了64位变成80位的逻辑。

S位

S位是段描述符高位4字节的第12位,它的值表示当前描述符是属于什么段的:

S = 1 -> 表示代码段或数据段描述符
S = 0 -> 表示系统段描述符

Type域

S位的值同样也决定了Type域的值, 当S位的值为1时表示当前为代码或数据段描述符,那么Type域的值就是如下这张表格:

images/download/attachments/1015831/image2022-6-15_18-40-27.png

如表格所示,Type域是段描述符高位4字节的第8位至第11位,第11位为0则是数据段,为1则是代码段。在详细了解这张表格之前,我们先来根据实际的GDT表来找一下数据段/代码段的描述符。

首先我们需要根据P位的值为1来确定描述符是有效的,接着DPL在Windows里只能出现两种情况:00/11(后期章节中会讲解该属性),然后S位必须是1才能是数据段/代码段的描述符,所以我们就可以得出P、DPL、S拼接的值为:1001/1111,转为十六进制就是:0x9/0xF。

最后我们来看下Type域名的值,按表格所示,它的值也就是0x0 - 0xF,并且0x8是一个界限点,当值小于0x8则表示当前段是数据段,反之则是代码段。

images/download/attachments/1015831/image2022-6-15_18-55-9.png

综上所示,我们去看GDT表中的内容,如下图所示红色方框标记的就是代码段,绿色的数据段:

images/download/attachments/1015831/image2022-6-15_19-2-59.png

我们回过头来再看Type域的表格,除了第11位外,第8、9、10位在代码和数据段中都有不同的意义。

在数据段时,A(访问位,第8位)表示该段是否被访问过,当处理器将该段描述符置入某个段寄存器时,其值就会被修改为1;W(可写位,第9位)表示当前数据段是否可写;E(拓展位,第10位),当该值为0时向上拓展,当该值为1时向下拓展

在代码段时候,A(访问位,第8位)与数据段一样的意义;R(可读位,第9位)表示当前代码段是否可读;C(一致位,第10位),当该值为1时则表示一致代码段,当该值为0时表示非一致代码段

images/download/attachments/1015831/image2022-6-15_18-40-27.png

说完代码/数据段之后,我们来看下当S位的值为0时候,则表示当前是系统段描述符,系统段分为以下内容:

images/download/attachments/1015831/image2022-6-15_19-30-23.png

DB位

DB位是段描述符高位4字节中的第22位,DB位的值有以下三种场景:

  1. 对CS段的影响:

    • DB = 0:采用32位寻址方式

    • DB = 1:采用16位寻址方式

  2. 对SS段的影响:

    • DB = 1:隐式栈访问指令(如:PUSH POP CALL) 使用32位栈指针寄存器ESP

    • DB = 0:隐式栈访问指令(如:PUSH POP CALL) 使用16位栈指针寄存器SP

    • 隐式栈访问指令:例如PUSH EBP,这句指令没有出现ESP却修改了ESP的值,所以我们就可就称这种指令为隐式栈访问指令

  3. 向下扩展的数据段:

    • DB = 1:段上线为4GB

    • DB = 0:段上线为64KB

    • 实际上是限制扩展有效范围,大致如下:

    • images/download/attachments/1015831/image2022-6-15_19-40-17.png

段权限

权限检查

我们之前所知道当向段寄存器写入时,CPU会根据段选择子去查表,并将对应内容进行检查并填入到就寄存器中,这里面就涉及到段权限检查。

在了解段权限检查之前我们先来看一下CPU的分级,如下图所示就是CPU权限的不同分级:

images/download/attachments/1015831/image2022-6-20_11-14-32.png

在Windows操作系统当中使用了Ring 3(应用层)、Ring 0(内核/驱动层)的权限分级,我们也可以称之为0环、3环。

判断程序权限

我们可以通过CPL(Current Privilege Level,当前特权级)来判断程序的权限等级,这里的CPL实际上就是CS和SS中存储的段选择子的最后2位(该位与其他段寄存器段选择子的RPL位是同一位置)。

如下图所示,我将一个EXE文件丢入DTDebug中,并找到它的CS中的段选择子0x001B:

images/download/attachments/1015831/image2022-6-20_11-35-32.png

所以我们根据B = 1011得出CPL = 0011 = 3,也就表示当前进程的权限是Ring 3(应用层,3环)。

DPL

DPL(Descriptor Privilege Level,描述符特权级别)存储在段描述符中,其规定了访问该段所需要的特权级别是什么。

如下指令中段选择子AX指向的段描述符中的DPL为0,但是当前程序的CPL为3,那么该指令是无法执行成功的:

MOV DS,AX

RPL

RPL(Request Privilege Level,请求特权级别)是针对段选择子而言的,每个段的选择子都有自己的RPL。

如下指令中段选择子指向的是同一个段描述符,但是它们的RPL不一样:

MOV AX,0x0008
MOV DS,AX
 
MOV AX,0x000B
MOV DS,AX

数据段的权限检查

在前面的学习中我们了解了CPL、DPL、RPL,那么它们之间在数据段中又是如何进行检查的呢?如下代码(当前程序处于0环,也就表示CPL=0):

MOV AX,0x00B // 0xB = 1011, RPL = 3
MOV DS,AX // AX指向的段描述符的DPL = 0

在数据段的权限检查中是按照如下公式进行的:

// 数值上的比较
CPL <= DPL && RPL <= DPL

根据这段公式,我们的RPL值并不小于等于DPL,因此我们所执行的向段寄存器写值指令是无法执行成功的。

注意:代码段和系统段描述符中的检查方式是不一样的,这些将于后面的章节中了解到。

为什么需要有RPL

我们有了CPL、DPL实际上已经完成了最基本的段权限检查,为什么还需要RPL呢?我们以文件读写为例,我们可以对一个文件进行读写,但是为了避免出错,在大多情况下,如果你只需要读取权限,那么你默认会使用只读模式去打开文件。因此,在这里也是同样的道理,你有高权限并不代表你一定需要使用高权限,为了稳定性、安全性,以最小权限原则满足对应场景即可

代码跨段

代码跨段本质上就是修改CS段寄存器,我们之前有了解到读写段寄存器可以使用MOV或LXX指令进行,但是由于CS段寄存器的特殊性我们没办法之间修改。

CS是代码段,它的改变就意味着EIP的改变,修改CS的同时就必须要修改EIP,所以微软也没有直接提供以上所述的类似指令来修改CS段寄存器。那么我们想同时修改CS与EIP,可以使用如下这些指令:

JMP FAR
CALL FAR
RETF
INT
IRETED

本章节主要了解一下JMP FAR指令是如何执行的。

流程

JMP FAR指令,你可以称之为长跳转,如下就是JMP FAR指令格式:

// JMP FAR 段选择子:要跳转的地址
JMP FAR 0x20:0x004183D7

那么CPU遇到这样的指令它的流程是怎么样的呢?它一共有5个步骤,我们以如上指令带入看一下:

  1. 将段选择子拆分:0x20 = 0000 0000 0010 0000, RPL = 00, TI = 0, Index = 0100(十进制:4);

  2. 根据段选择子查表获取段描述符:因为TI=0,所以查GDT表,又根据Index=4找到对应的段描述符,如果段描述符为代码段、系统段(调用门、TSS任务段、任务门),那么该指令是允许跳转的;

  3. 权限检查:根据代码段是否一致来做权限检查(XPL数值比较),如果是非一致代码段则要求CPL==DPL&&RPL<=DPL,如果是一致代码段则要求CPL>=DPL;

    1. 非一致代码段:我们又称之为普通代码段,它只允许同级访问,例如Ring3只能访问Ring3(Ring0同理可得),严禁不同级别的访问;

    2. 一致代码段:我们又称之为共享段,特权级高的程序不允许访问特权级低的数据,Ring0不允许访问Ring3的数据,特权级低的程序可以访问到特权级高得数据,但特权级不会发生改变,Ring3还是Ring3。

  4. 加载段描述符:通过权限检查之后,CPU会将段描述符加载到CS段寄存器中;

  5. 代码执行:CPU将CS.Base + Offset的值写入到EIP,然后执行CS:EIP处的代码,段间跳转即结束。

直接对代码段进行JMP或者 CALL的操作,无论目标是一致代码段还是非一致代码段,CPL都不会发生改变。如果要提升CPL的权限,只能通过调用门或类似方法。

实验

之前我们已经了解了JMP FAR指令的执行流程了,我们可以来实际实验一下。

非一致代码段描述符

首先通过Windbg找到一个非一致的代码段描述符(通过段描述符的S位和Type位来确定),然后复制一份写入到GDT表中。

如下图所示标记出的内容,就是一个非一致的代码段描述符(S位=1 && Type域中的C位=0):

images/download/attachments/1015831/image2022-6-22_14-38-12.png

我们使用如下指令将标记的段描述符放入GDT表中的空白部分(因为如果我们直接使用现有的值去测试,看起来不够直观):

eq 8003f048 00cffb00`0000ffff

那么接着我们需要构建一个JMP FAR指令,首先是段选择子部分,我们要让CPU根据段选择子的Index位找到8003f048这个地址的段描述符,又知道CPU根据Index位的值乘以8再加上GDT的基地址,以及满足权限检查要求CPL==DPL&&RPL<=DPL,因此段选择子为:0000 0000 0100 1011。最终指令如下:

JMP FAR 4B:004010CC

我们可以在DTDebug中写入这段汇编指令,然后F7一下你就会发现这时候程序成功跳转到0x004010CC位置,并且EIP和CS段寄存器的值都发生了改变:

images/download/attachments/1015831/image2022-6-22_17-6-42.png

所以我们就完成长跳转实验,接着我们可以来验证一下权限检查部分是否真的按流程所说那样,需要满足CPL==DPL&&RPL<=DPL,可以将之前写入的段寄存器值修改一下(修改了DPL位的值):

eq 8003f048 00cf9b00`0000ffff

并且我们继续使用上面的汇编指令去运行就会发现直接出错了:

images/download/attachments/1015831/image2022-6-22_17-11-29.png

至此我们也就验证了此处的权限检查逻辑。

一致代码段描述符

我们可以修改段描述符,将其变为一致代码段描述符,也就是修改Type域中的C位=1:

eq 8003f048 00cf9f00`0000ffff

images/download/attachments/1015831/image2022-6-22_17-15-45.png

接着我们使用同样的汇编指令进行执行:

JMP FAR 4B:004010CC

而这里是可以执行成功的,这是因为一致代码段描述符的权限校验是允许低权限程序访问高权限数据的,也就是CPL>=DPL:

images/download/attachments/1015831/image2022-6-22_17-18-45.png

总结
  1. 为了对数据进行保护,普通代码段是禁止不同级别进行访问的。用户态的代码不能访问内核的数据,同样,内核态的代码也不能访问用户态的数据;

  2. 如果想提供一些通用的功能,而且这些功能并不会破坏内核数据,那么可以选择一致代码段,这样低级别的程序可以在不提升CPL权限等级的情况下访问。

  3. 如果想访问普通代码段,只有通过调用门等提升CPL权限,才能访问。

长调用与短调用

我们可以通过JMP FAR指令实现段间的跳转,如果要实现跨段的调用就必须要学习CALL FAR指令,也就是长调用。CALL FAR比JMP FAR要复杂,JMP并不影响栈,但CALL会影响。

短调用

短调用就是我们之前所学习的CALL指令,指令格式如下:

CALL 立即数/寄存器/内存

当你执行CALL指令时,它首先会向栈中压入当前CALL指令的下一行地址,这个地址也称之为返回地址。

CALL指令执行会影响2个寄存器,一个是ESP的值(压入地址到栈,栈顶提升),一个是EIP的值(要跳转到某个地址继续执行)。

所以CALL指令也可也分解成这2个指令:

PUSH CALL指令的下一行地址
JMP 立即数/寄存器/内存

我们也知道在汇编中的CALL指令大多是用于调用函数的,而在每个函数调用完之后都会有一个RET指令,它的作用就是跳转回CALL指令的下一行地址,并POP。

POP CALL指令的下一行地址
JMP CALL指令的下一行地址

那么短调用的栈图变化就可以使用如下图来表示:

images/download/attachments/1015831/image2022-6-28_16-18-1.png

所以短调用影响的寄存器是:ESP、EIP

长调用
垮段不提权

长调用就是CALL FAR指令,指令格式如下:

CALL FAR CS:EIP

长调用分为两种情况,我们这里所讲解的长调用是指垮段不提权的。这段指令去执行的时候,地址并不会跳转到EIP,因为这里的EIP是废弃的;实际指令是根据CS段选择子查GDT表,找到表中的段描述符(这个段描述符必须是一个调用门),再根据调用门的符号计算出要调转的地址,最终再跳转过去。

这里我们所说的垮段不提权的,是指当前你的段CPL与要跳转过去的段CPL是同级的。执行长调用时,首先会压入调用者的CS,然后再压入长调用指令的下一行地址。调用完成之后,由于压入栈的内容与短调用不一样了,所以当调用完成之后不可以再使用RET来返回了,而是需要使用RETF指令,这样就可以同时恢复CS、EIP。

images/download/attachments/1015831/image2022-6-28_17-8-5.png

所以跨段不提权的长调用影响的寄存器就是:ESP、EIP、CS

垮段并提权

长调用的第二种情况就是跨段并提权,其与第一种情况的指令是一样的。

CALL FAR CS:EIP

跨段并提权的长调用指令,表示当前执行长调用指令时CPL为3,我们要去调用的段CPL为0,所以这就会产生一个提权操作,提权会 改变 CS的CPL,并且根据Intel的规定, CS和SS的CPL要保持一致 ,所以此时SS的值也会发生改变;除此之外,因为发生了提权,栈从3环的栈变为了0环的栈,因此ESP也会发生改变。所以为了保证我们长调用完成之后还可以恢复,就将SS、ESP、CS、返回地址压入栈中:

images/download/attachments/1015831/image2022-6-28_17-40-33.png

那么在调用结束之后的栈变化就如下图所示:

images/download/attachments/1015831/image2022-6-28_17-50-31.png

所以跨段并提权的长调用影响的寄存器就是:SS、ESP、CS、EIP

总结
  1. 跨段调用时,一旦有权限切换,就会切换栈;

  2. CS的权限一旦改变,SS的权限也要随着改变,CS与SS的等级必须一样;

  3. JMP FAR只能跳转到同级非一致代码段,但CALL FAR可以通过调用门提权,提升CPL的权限,这样就可以调用非同级段。

因此接下来我们需要了解“门”相关的内容。

调用门

无论你是跨段提权或不提权的长调用指令,本质上都是要通过调用门去进行的,长调用指令执行流程:

CALL FAR CS:EIP
  1. 根据CS的值查GDT表,找到对应的段描述符,这个描述符是一个调用门描述符;

  2. 在调用门描述符中找到它存储的另外一个代码段的段选择子;

  3. 找到段选择子指向的段,真正要执行的地址就是指向的段,Base + 调用门描述符中的偏移地址。

调用门描述符的结构如下图所示,我们可以看到它跟段描述符的结构有一些差别,当S位为0则表示这是一个系统段描述符,并且当Type域为1100时则确定这是一个调用门描述符:

images/download/attachments/1015831/image2022-6-30_10-41-47.png

在低32位中的第16到31位就是段选择子,在低32位中第0到第15位及高32位中过的第16到31位存储的就是偏移。

构造调用门

由于Windows中没有使用调用门,所以我们需要自己去构建一个调用门,也就是向GDT表中写一个调用门描述符。

我们按结构图的方式从高位开始构建调用门描述符,首先我们不确定要去的地方是哪,所以偏移31:16先填上0,接着我们之前知道P位必须为1才表示这是一个有效的段描述符,DPL必须为3因为我们要从3环使用长调用指令,S位必须为0,Type域为1100,后面的第5、6、7位默认为0,第0到第4位这里写0即可,因为我们当前不需要传参,所以我们得出高32位为:

0000 0000 0000 0000 -> Offset
1110 -> P、DPL、S
1100 -> Type
0000 0000 -> 0 - 7位
 
0x0000EC00

低32位段选择子可以设为0x0008(对应0环代码段)、0x001B(对应3环代码段),偏移不确定所以填0:

0x00080000

最终我们得出调用门描述符为:

0000EC00`00080000

我们可以在Windbg中使用eq指令覆盖GDT表中没有被用到的段描述符(都为0):

eq 8003f048 0000EC00`00080000

images/download/attachments/1015831/image2022-6-30_11-33-33.png

这样我们就构建好调用门了。

调用门使用
无参数

我们知道如何构建调用门了,接下来我们使用调用门,如下代码所示,我们使用长调用指令,给的段选择子为0x48,这是因为它拆分出来对应的GDT表的描述符正好是我们上面写入的调用门描述符,由于EIP是废弃没用过的,所以我们随便赋值:

#include <windows.h>
#include <stdio.h>
 
void __declspec(naked) GetRegister() {
_asm {
int 3
retf
}
}
 
void main()
{
char buff[6];
*(DWORD*)&buff[0] = 0x12345678; // EIP, 废弃
*(WORD*)&buff[4] = 0x48; // 段选择子
_asm {
call far fword ptr[buff]
}
getchar();
}

而我们之前构建的调用门描述符中的选择子是指向0环的代码段,在Windows里所有数据、代码段的Base都为0,所以我们真正跳转的位置就是偏移位指向的内容。因此,我们要运行如上这段代码的话一定是报错的,因为我们给的偏移位置是0,我们预期效果是想要长跳转到GetRegister函数,所以我们可以下断点先将该函数的地址获取下来。

如下图所示,我们获得GetRegister函数的函数地址0x0040B4B0:

images/download/attachments/1015831/image2022-6-30_14-35-24.png

所以我们可以将调用门描述符按格式重新写入GDT表中:

0040EC00`0008B4B0

images/download/attachments/1015831/image2022-6-30_14-41-47.png

这样我们运行代码时候长跳转到GetRegister函数执行,该函数内有一个INT 3,这是用来断点的(跟软件调试有关,原理在后续章节中讲解),所以代码执行到该函数时候就会中断;并且我们需要注意虽然当前代码是在低2G中,但是通过调用门这段代码的权限就已经提升为0环的权限了,也因此,我们在3环是没法捕捉到这个断点的,只能在0环的调试器中去捕捉,也就是中断到我们的Windbg调试器。

我们执行这段代码会发现,Windbg确实有反应,并且提示我们在这个地址中断了:

images/download/attachments/1015831/image2022-6-30_15-53-26.png

我们也可以对比下长调用前后的寄存器信息,发现SS、ESP、CS寄存器都发生了变化:

images/download/attachments/1015831/image2022-6-30_15-59-35.png

并且我们观察栈的内容,会发现确实是如我们之前所看的长调用权限提升的栈图一样,将SS、ESP、CS、返回地址压入栈中:

images/download/attachments/1015831/image2022-6-30_16-19-51.png

有参数

之前我们使用调用门时没有传递参数,但实际上调用门是支持参数传递的,所以我们来看下如何实现。

根据调用门结构我们知道它的高32位第0位至4位表示的就是参数数量,也就是你要传递的参数的个数:

images/download/attachments/1015831/image2022-6-30_10-41-47.png

所以我们可以给之前构建的调用门描述符添加上参数个数3:

0040EC03`0008B4B0

接下来我们可以使用如下代码去使用参数,通过push压入参数,调用时根据ESP寻址获取参数,有个细节需要注意这里由于我们压入了3个参数,导致栈发生了变化,所以使用RETF指令时候需要加上0xC,这样执行完代码后才能保持栈平衡:

#include <windows.h>
#include <stdio.h>
 
int a,b,c = 0;
int o_eax = 0;
 
void __declspec(naked) GetParam() {
_asm {
mov o_eax, eax
mov eax, [esp+8]
mov c, eax
mov eax, [esp+0xC]
mov b, eax
mov eax, [esp+0x10]
mov a, eax
mov eax, o_eax
retf 0xC
}
}
 
void main()
{
char buff[6] = {0};
*(DWORD*)&buff[0] = 0x12345678;
*(WORD*)&buff[4] = 0x48;
_asm {
push 1
push 2
push 3
call far fword ptr[buff]
}
printf("%d %d %d", a, b, c);
getchar();
}

我们执行这段程序发现确实可以通过这种方式获取到传递的参数值:

images/download/attachments/1015831/image2022-6-30_17-16-52.png

那么为什么代码中使用ESP寻址是这个对应的偏移量呢,我们需要在汇编中写入一个INT 3指令让程序中断在Windbg中,观察一下栈。

images/download/attachments/1015831/image2022-6-30_17-19-16.png

根据栈我们知道我们传递的参数是夹在调用者ESP和调用者CS中间的,也就是如下图展示的这样:

images/download/thumbnails/1015831/image2022-6-30_17-22-35.png

所以在代码中我们就可以分别使用0x8、0xC、0x10偏移量去将参数取出来。

总结
  1. 当通过门,权限不变时,只会PUSH两个值:CS和返回地址,新的CS的值由调用门决定;

  2. 当通过门,权限改变的时候,会PUSH四个值:SS,ESP,CS,返回地址,新的CS由调用门决定,新的SS和ESP由TSS提供(后面会讲TSS);

  3. 通过调用门时,由调用门决定要执行的代码,但使用RETF返回时,由栈中压入的值决定,也就是说,进门时只能按指定路线走,出门时可以翻墙(只要改变栈里面的值就可以想去哪去哪);

  4. 可不可以再建个门出去呢?也就是用CALL指令,当然可以,前门进,后门出。

中断门

Windows没有使用调用门,但是使用了中断门,当我们有了调用门的基础之后,中断门的学习就很轻松了。

中断门有两个场景:1.系统调用:大家在开发应用程序时都会使用到Windows提供的API,这些API在执行的过程中需要从3环一步一步进入到0环,这一过程就是系统调用,在这个过程中也用到了中断门(一些比较老的API使用的是中断门,但是新API中使用的都是快速调用);2.调试:大家使用OD调试程序时候会使用断点,断点本质上就是在你选中的这一行中写入一个字节0xCC,也就是INT 3指令,这个指令就是用来执行中断门的。

中断门也有一张表,我们称之为IDT(中断描述符表),与GDT一样,IDT也是由一系列描述符组成的,每个描述符占8字节,需要注意的是IDT表中的第一个元素不是NULL(GDT是)。

与GDT一样,我们也可以在Windbg中查看IDT表的基址和长度:

r idtr
r idtl

在IDT表中有3种门描述符:1.任务门描述符;2.中断门描述符;3.陷阱门描述符。我们来看一下中断门描述符的结构:

images/download/attachments/1015831/image2022-6-30_17-50-47.png

我们会发现中断门描述符与调用门描述符差别不是很大,其中它多了一个D位,该值为0时表示16位中断门,为1时表示32位中断门,其他的都一样。

接着我们来看一下中断门的执行流程,实际上和调用门差别不是很大,可以类比的来看:

  • 执行调用门的指令:CALL CS:EIP,其中CS是段选择子,包含了查找GDT表的是一个索引;

  • 执行中断门的指令:INT N,其中N是IDT表的一个索引。

执行流程就只有这个差别,当CPU通过N这个索引在IDT表中找到了中断门描述符后,执行的步骤就和调用门的步骤完全一样了,可以参考调用门的执行流程。

当找到中断门描述符后,CPU还是会通过中断门描述符里的段选择子,去GDT表中找到对应的段描述符,段描述符的基址加上当前中断门描述符的段内偏移就是需要跳转代码段的地址。所以说中断门的执行会查找两张表,先查找IDT表,再查找GDT表。

与调用门使用长返回RETF不同,中断门使用中断返回指令:IRET/IRETD。

INT N指令:

  • 在没有权限切换时,会向栈压入3个值,分别是CS,EFLAG,返回地址;

  • 在有权限切换时,会向栈压入5个值,分别是SS,ESP,EFLAG,CS,返回地址。

这也是与调用门不同的地方,中断门会多压入一个值,这也就说明EFLAG通过中断门跨段时,值会改变。

陷阱门

陷进门与中断门几乎是一样的,陷阱门描述符也存储在IDT表中,其与中断门描述符唯一不同的是高32位的第8位为1:

images/download/attachments/1015831/image2022-7-2_16-40-36.png
除此之外 陷阱门和中断门的区别在于,中断门执行后EFLAG寄存器的值发生了改变,而陷阱门不会改变EFLAG的值。

中断门会修改EFLAG寄存器中的IF位的值, IF标志是用于控制处理器对可屏蔽中断请求的响应。 置1以响应可屏蔽中断 反之则禁止可屏蔽中断 。IF标志 对不可屏蔽中断没有影响

任务段

在以上章节中我们了解到当使用调用、中断、陷阱门时,当出现权限切换的时候栈也会随之切换,并且由于CS的CPL发生了改变,也就导致SS必须进行切换。我们知道CS的值是由门来指定的,但是ESP和SS呢?它们基于TSS(Task-state segement,任务段)得到的值。

TSS结构

TSS本质上就是一块内存,这块内存的大小是104字节,如下图所示就是它的结构,我们可以看见TSS里包含了所有寄存器的值:

images/download/attachments/1015831/image2022-7-6_9-58-44.png

TSS作用

Intel的设计思想就是当你要去创建一个新的任务(线程)时应该有一个新的环境,而不是基于老的环境去做,这也就需要换掉你的所有寄存器,因此你可以从TSS中把各值拉过来给到寄存器,并且将原先的寄存器的值再放入TSS中,这样可以直接恢复原先的环境。

但这只是Intel的设计思想,操作系统并没有这样做,所以我们理解TSS的话,只需要知道它可以一次性换掉所有的寄存器即可

那么我们也可以根据TSS的结构知道,里面的ESP0、ESP1、ESP2就表示0环、1环、2环,SS[0-2]也是如此,所以我们也就知道权限切换时ESP、SS的值是从TSS中获取的。

流程

CPU取值的流程如下图所示,根据TR寄存器(Task Register)的段选择子到GDT表中找到TSS段描述符,得到了TSS的基址、以及段大小,这样就能定位到TSS这块内存:

images/download/attachments/1015831/image2022-7-6_10-22-27.png

TSS段描述符

如下图所示就是TSS段描述符的结构,TSS段描述符也属于系统段描述符的一种,所以高4字节的第12位为0,当Type位为1001或1011就表示当前是TSS段描述符,前者表示当前段描述符没有被加载到TR寄存器中,后者则反之

images/download/attachments/1015831/image2022-7-6_10-46-30.png

TR段寄存器

我们想读写TR段寄存器的话不能使用MOV指令,需要使用到LTR、STR指令。

LTR指令可以将TSS段描述符加载到TR寄存器中,但只能改变TR寄存器的值(96位),并不会改变TSS;LTR指令只能在系统层使用;加载后TSS段描述符的状态位(高4字节的第9位,或可以理解为Type域从0x9/1001变成了0xB/1011)会发生改变。

STR指令可以读取TR段寄存器的内容,但是该指令只能读取TR段寄存器的16位,也就是选择子

实现任务切换

在Windows操作系统中没有使用TSS进行任务(线程)切换,我们可以手动实现一下。主要分为几个步骤:

  1. 构造完整的TSS;

  2. 构造TSS段描述符;

  3. 使用CALL FAR/JMP FAR指令修改TR寄存器。

构造完整的TSS

如下我们使用代码构造了一个完整的TSS:

DWORD tss[0x68] = {
0x00000000, // Previous Task Link
// 不同权限对应的ESP、SS,如果不涉及到权限切换所以可以将这些寄存器的值全部填0
(DWORD)stack, // ESP0
0x00000010, // SS0
0x00000000, // ESP1
0x00000000, // SS1
0x00000000, // ESP2
0x00000000, // SS2
(DWORD)iCr3, // Cr3,与页的知识有关,必须要赋值
0x00401020, // EIP,下一次执行代码的位置,必须要赋值
0x00000000, // EFLAGS
0x00000000, // EAX
0x00000000, // ECX
0x00000000, // EDX
0x00000000, // EBX
(DWORD)stack, // ESP,任务切换时也需要切换栈,所以在代码中我们可以声明一个数组,将其地址作为一块栈
0x00000000, // EBP
0x00000000, // ESI
0x00000000, // EDI
0x00000023, // ES
0x00000008, // CS,切到0环的代码段描述符
0x00000010, // SS,CS与SS需要保持一致
0x00000023, // DS
0x00000030, // FS,切到0环就是0x30,3环就是0x3B
0x00000000, // GS,Windows没有使用这个段寄存器所以永远是0
0x00000000, // LDT,填0
0x20ac0000 // IO_MAP,Windows2000以后不用了
};
构造TSS段描述符

根据TSS段描述符的结构构造描述符,首先获取TSS的地址,在代码中下断点然后查看地址即可:

images/download/attachments/1015831/image2022-7-6_16-36-42.png

其地址为0x0012fd70,我们就得出对应TSS段描述符中Base的值,接着Limit就是TSS的大小0x68,DPL为3(3环程序访问),Type域为0x9即表示当前描述符没有被加载过。最终得出TSS段描述符为:0000e912`fd700068

接着我们使用eq指令在Windbg中向GDT表中写入我们构造好的TSS段描述符:

images/download/attachments/1015831/image2022-7-6_16-43-24.png

然后我们的完整代码如下所示,:

#include <stdio.h>
#include <windows.h>
 
int o_eax, n_esp;
short n_cs, n_ss;
 
void __declspec(naked) GetValue() {
// 将寄存器保存到全局变量中
_asm {
mov o_eax, eax
mov n_esp, esp
mov ax, cs
mov n_cs, ax
mov ax, ss
mov n_ss, ax
mov eax, o_eax
iret
}
}
 
void main()
{
char stack[100] = {0}; // 栈
char buffer[6] = {0x0, 0x0, 0x0, 0x0, 0x4B, 0x0}; // 选择子
int iCr3 = 0;
 
printf("Input: ");
scanf("%x", &iCr3);
getchar();
 
DWORD tss[0x68] = {
0x00000000, // Previous Task Link
// 不同权限对应的ESP、SS,如果不涉及到权限切换所以可以将这些寄存器的值全部填0
0x00000000, // ESP0
0x00000000, // SS0
0x00000000, // ESP1
0x00000000, // SS1
0x00000000, // ESP2
0x00000000, // SS2
(DWORD)iCr3, // Cr3,与页的知识有关,必须要赋值
0x00401020, // EIP,下一次执行代码的位置,必须要赋值,在代码中就是GetValue函数的地址
0x00000000, // EFLAGS
0x00000000, // EAX
0x00000000, // ECX
0x00000000, // EDX
0x00000000, // EBX
(DWORD)stack, // ESP,任务切换时也需要切换栈,所以在代码中我们可以声明一个数组,将其地址作为一块栈
0x00000000, // EBP
0x00000000, // ESI
0x00000000, // EDI
0x00000023, // ES
0x00000008, // CS,切到0环的代码段描述符
0x00000010, // SS,CS与SS需要保持一致
0x00000023, // DS
0x00000030, // FS,切到0环就是0x30,3环就是0x3B
0x00000000, // GS,Windows没有使用这个段寄存器所以永远是0
0x00000000, // LDT,填0
0x20ac0000 // IO_MAP,Windows2000以后不用了,默认值
};
 
_asm {
call far fword ptr[buffer] // 长调用
}
 
// 输出寄存器的值证明完成了任务切换
printf("ESP: %x, CS: %x, SS: %x \n", n_esp, n_cs, n_ss);
 
getchar();
}

接着我们需要运行程序,然后在Windbg中断点输入!process 0 0指令获取Cr3的值填入到程序中。但是我们需要导入符号表才能使用Windbg这个指令,所以根据我们的系统版本找到对应的离线符号表安装包(这里我是Windows XP SP2,安装包链接:https://pan.baidu.com/s/1j0oDzGHVviMvilWgWhdoxg,提取码:keey)。

安装完成之后接着在Windbg中按如下图所示填入符号表的目录即可加载:

images/download/attachments/1015831/image2022-7-7_15-21-28.png

接着我们按照流程来,获取到当前程序的Cr3值0x219cd000,并且输入到程序中:

images/download/attachments/1015831/image2022-7-7_15-29-37.png

images/download/attachments/1015831/image2022-7-7_15-30-25.png

输入之后回车成功,程序输出的寄存器值就是我们TSS中赋予的,则实验成功:

images/download/attachments/1015831/image2022-7-7_15-33-16.png

任务门

除了之前所提到的JMP/CALL指令进行任务切换外,我们还可以使用任务门,它的优势如下:

  • 任务门可以放在GDT表中,也可以放在IDT表中,还能放在当前线程的LDT表中,而TSS段描述符只能在GDT表中;

  • 任务门可以让低权限的线程进行任意切换,通过任务门,TSS段描述符就不再进行权限检查了(CPL=3的程序使用DPL=3的任务门,可以访问到DPL=0的TSS段描述符,最终可以完成任务切换);

  • 由于任务门可以位于IDT表中,所以当遇到中断或者异常时,可以切换到独立的任务去处理异常。

下面为不同表中,任务门进行任务切换的过程:

images/download/attachments/1015831/image2022-7-8_10-24-54.png

描述符

任务门描述符也存储在IDT表中,它的结构如下所示,灰色的部分保留(填0), DPL一般设置为3,方便3环应用程序访问,Type域名是固定的0101,TSS段选择子,指向位于GDT表中TSS段描述符的位置

images/download/attachments/1015831/image2022-7-8_10-17-59.png


实现任务切换

在做"使用任务门实现任务切换"实验之前,我们先来看一下任务门的执行过程如下所示:

  1. 使用INT N指令;

  2. 查IDT表,找到任务门描述符;

  3. 通过任务门描述符的TSS段选择子,在GDT表中找到TSS段描述符;

  4. 使用TSS段中的值修改TR寄存器;

  5. IRETD返回。

所以按照流程来,我们需要先构建TSS段描述符,这里与任务段实验一样,不过为了论证之前所说的通过任务门找到TSS段描述符就不检查权限这个说法,我们需要将TSS段描述符中的DPL位修改为0,所以得出TSS段描述符为:00008912`fd780068,在Windbg中写入GDT表:

images/download/attachments/1015831/image2022-7-8_10-49-18.png

接着构建任务门描述符,根据TSS段描述符的位置得出选择子带入并结合门描述符的结构,最终得出任务门描述符为:0000e500`004b0000,在Windbg中写入IDT表:

images/download/attachments/1015831/image2022-7-8_10-54-2.png

接着我们在代码中加入INT N指令,N为0x20(我们写入的任务门描述符在IDT表中过的索引值),完整代码如下:

#include <stdio.h>
#include <windows.h>
 
int o_eax, n_esp;
short n_cs, n_ss;
 
void __declspec(naked) GetValue() {
_asm {
mov o_eax, eax
mov n_esp, esp
mov ax, cs
mov n_cs, ax
mov ax, ss
mov n_ss, ax
mov eax, o_eax
iret
}
}
 
void main()
{
char stack[100] = {0}; // 堆栈
int iCr3 = 0;
printf("Input: ");
scanf("%x", &iCr3);
getchar();
 
DWORD tss[0x68] = {
0x00000000, // Previous Task Link
// 不同权限对应的ESP、SS,如果不涉及到权限切换所以可以将这些寄存器的值全部填0
0x00000000, // ESP0
0x00000000, // SS0
0x00000000, // ESP1
0x00000000, // SS1
0x00000000, // ESP2
0x00000000, // SS2
(DWORD)iCr3, // Cr3,与页的知识有关,必须要赋值
0x00401020, // EIP,下一次执行代码的位置,必须要赋值
0x00000000, // EFLAGS
0x00000000, // EAX
0x00000000, // ECX
0x00000000, // EDX
0x00000000, // EBX
(DWORD)stack, // ESP,任务切换时也需要切换堆栈,所以在代码中我们可以声明一个数组,将其地址作为一块堆栈
0x00000000, // EBP
0x00000000, // ESI
0x00000000, // EDI
0x00000023, // ES
0x00000008, // CS,切到0环的代码段描述符
0x00000010, // SS,CS与SS需要保持一致
0x00000023, // DS
0x00000030, // FS,切到0环就是0x30,3环就是0x3B
0x00000000, // GS,Windows没有使用这个段寄存器所以永远是0
0x00000000, // LDT,填0
0x20ac0000 // IO_MAP,Windows2000以后不用了
};
 
_asm {
int 0x20
}
// 输出寄存器的值证明完成了任务切换
printf("ESP: %x, CS: %x, SS: %x \n", n_esp, n_cs, n_ss);
 
getchar();
}

编译运行程序,在Windbg中输入!process 0 0指令找到Cr3值输入到程序中即可,最终我们可以看到获取到的寄存器值确实为我们所构建的TSS中对应的值:

images/download/attachments/1015831/image2022-7-8_11-3-28.png

保护模式下有段、页机制,本章节就来了解页的机制,它相对于段来说更加重要。

10-10-12分页

images/download/attachments/1015831/image2022-7-8_15-23-56.png

术语概念

为了便于更好的学习,我们需要了解一下线性地址、有效地址、物理地址分别是什么。我们看汇编指令时经常会看见如下类似的指令:

MOV EAX, DWORD PTR DS:[0x12345678]

在这里指令中,0x12345678就是有效地址,根据之前段的学习,我们知道这段指令真正读取的地址是DS.Base+0x12345678,这个地址就是线性地址(有效地址+段寄存器的Base)。有了线性地址之后CPU会将其转为物理地址,这样才能找到真正的数据。

images/download/attachments/1015831/image2022-7-8_15-35-0.png

32位系统上将线性地址转为物理地址有2种方式:10-10-12、2-9-9-12,我们本节要学的就是前者。

我们之前进行双机调试配置时候设置了C:\boot.ini这个文件,在写入的内容中有一个参数,如下图所示,当该参数为execute时则表示当前的分页机制就是10-10-12,noexecute则表示当前的分页机制为2-9-9-12:

images/download/attachments/1015831/image2022-7-18_9-46-19.png

修改该参数然后重启系统即可。

寻找物理地址

在做实验之前我们需要知道从线性地址寻找到物理地址的流程,如下所示,CPU通过进程的CR3(CR3是一个寄存器,CR3指向的页有4096字节(4KB),每4字节作为一个成员存储,一共有1024个成员)找到第一级页,再通过第一级找到第二级,最后找到物理页:

images/download/attachments/1015831/image2022-7-18_10-27-38.png

我们可以将第一级、第二级理解为是一个目录,通过目录找到物理页(内容),而目录的索引就是10-10-12分页机制所划分出来的。

所谓10-10-12分页机制,本质上就是将线性地址按位分为三个部分,分别是10位、10位、12位,然后通过这三个部分作为索引找到物理页。

接下来我们可以手动做实验来从线性地址寻找到物理地址,首先打开一个记事本输入一段内容,然后打开CE软件找到该进程,搜索我们输入的内容,找到该字符串对应的线性地址,如下图所示线性地址即为0x000AAFF8

images/download/attachments/1015831/image2022-7-18_10-8-27.png

按10-10-12分页机制分为三个部分,前两部分需要*4:

0000 0000 00 // Hex: 0*4 = 0
00 1010 1010 // Hex: AA*4 = 2A8
1111 1111 1000  // Hex: FF8

接着我们找到该记事本进程的CR3寄存器值:0x16bcf000,使用dd指令(dd前需要加上感叹号)加上第一部分的值在第一级页找到第二级页地址:

!dd 16bcf000+0

images/download/attachments/1015831/image2022-7-18_11-6-0.png

获得第二级页地址0x16d0e867,这个地址不是真正的地址,低12位为属性,需要将其填0变为0x16d0e000,接着再加上第二部分的值找到物理页地址:

!dd 16d0e000+2A8

images/download/attachments/1015831/image2022-7-18_11-6-44.png

获取到0x16d45867,低12位置0,地址为0x16d45000,再加上第三部分的值即可获取到物理地址,这里我们可以以字节的形式查看数据:

images/download/attachments/1015831/image2022-7-18_11-8-9.png

PDE与PTE

术语概念

上一章节中我们简单了解了80x86的10-10-12分页机制,大致的流程我们已经清楚了,但是我们之前所提到的第N级,实际上它们有自己的名字:

第一级叫页目录表(PDT,P age Directory Table),它有4KB的大小,每个成员大小是4字节,每个成员的名字叫页目录项(PDE,Page Directory Entry);

第二级叫页表(PTT,Page Table),它同样有4KB的大小,每个成员也是4字节大小,每个成员的名字叫页表项(PTE,Page Table Entry)。

images/download/attachments/1015831/image2022-7-18_16-0-50.png

10-10-12分页机制可以根据成员数来理解,物理页存储的是数据,以字节为成员单位,物理页有4096个字节,也就是2的12次方,而页表、页目录表成员单位都是4字节,有1024个成员,所以就是2的10次方

实验

PTE有这几个特征:PTE可以没有物理页;一个PTE只能对应一个物理页;多个PTE可以对应同一个物理页。

为了论证观点,我们可以做一个实验,以线性地址0x0为例,拆分并观察其PTE是否有物理页。

我们可以随便起一个进程然后找到CR3来查看,如下图所示我们看到它没有物理页:

images/download/attachments/1015831/image2022-7-18_16-26-58.png

这也就表示PTE可以没有物理页,那么这个0x0的线性地址我们是否可以进行读写呢?实际上是可以的,我们已经了解0x0这个地址不能读写的原因是因为其对应PTE没有物理页,那我们可以给它一个物理页。

用如下代码来实验,我们运行这段代码,先获取变量a的线性地址,然后找到其物理页地址,赋值给0x0的PTE,然后回车即可向0x0地址写入内容,并读取:

#include <stdio.h>
 
void main()
{
int a = 10;
printf("a address: %x \n", &a);
getchar();
*(int*)0 = 123;
printf("0 address data: %x \n", *(int*)0);
getchar();
}

获取变量a的线性地址为0x0012FF7C:

images/download/attachments/1015831/image2022-7-18_16-44-22.png

按10-10-12分页机制拆分为三部分:

0000 0000 00 // Hex: 0*4 = 0
01 0010 1111 // Hex: 12F*4 = 4BC
1111 0111 1100 // Hex: F7C

接着我们找到PTE的值为0x25D97867:

images/download/attachments/1015831/image2022-7-18_16-58-14.png

接着我们将该值添加到0x0地址对应的PTE中:

!ed 25f01000 25d97867

images/download/attachments/1015831/image2022-7-18_16-59-53.png

这时候在回到程序按回车执行就会发现我们成功的向0x0线性地址读写了:

images/download/attachments/1015831/image2022-7-18_17-0-31.png

低12位属性

在之前的实验中我们知道PDE/PTE的低12位表示属性,这两个属性进行与运算得出的结果就是物理页的属性

images/download/attachments/1015831/image2022-7-18_17-7-45.png

P位

P位表示当前物理页的有效性,为1表示有效,为0则表示无效,由于物理页的属性是PDE和PTE属性进行与运算的来的所以需要PDE和PTE属性的P位均为1,才能表示物理页是有效的。

R/W位

R/W位表示物理页的读写权限,为1表示可读可写,为0则表示只读。例如我们之前所学习的常量,它就是一个只读的内容,对应的物理页属性的R/W位值为0,我们可以通过Windbg修改这个值来达到写入的目的。

U/S位

U/S位表示物理页的访问权限,为1表示普通用户可以访问,为0则表示特权用户可以访问。

PS位

PS位(Page Size)只对PDE有意义,为1表示PDE直接指向物理页(没有PTE),低22位就是物理页的页内偏移。因此,线性地址就只能拆分为2部分,而它的一个物理页就不再是4KB大小,而是4MB大小,我们也称之为大页

A位

A位表示当前是否被访问过,为1表示访问过,为0则表示没有访问过。

D位

D位表示当前是否被写入过,为1表示写入过,为0则表示没有写入过。

表基址

之前的实验我们可以在Windbg中通过线性地址拆分,找到PDE、PTE,最终找到物理页,对属性进行修改等操作。但是这样的操作都是基于手动调试器的,我们要想通过代码去完成就需要通过表基址(页目录表、页表)。

页目录表

页目录表基址就是0xC0300000,通过它找到的物理页就是页目录表,这个物理页既是页目录表也是页表。这里的页目录表是一张特殊的页表,每一项PTE指向的不是普通的物理页,而是指向其他的页表。

images/download/attachments/1015831/image2022-7-19_11-3-21.png

我们可以做个实验来论证这些观点,随便启动一个程序找到它的PDT表:

images/download/attachments/1015831/image2022-7-19_11-10-39.png

将0xC0300000按10-10-12分页机制进行拆分:

1100 000 00 // Hex: 300*4 = C00
11 0000 000 // Hex: 300*4 = C00
0000 0000 0000 // Hex: 0

按这个拆分的出来的偏移找到物理页,我们就会发现这里找到的物理页内容与上面的PDT表内容是一致的,并且你抛开偏移也会发现,它们的基址都是一样的,所以也就论证了这个物理页既是页目录表也是页表

images/download/attachments/1015831/image2022-7-19_11-12-19.png

页表

基于页目录表基址,我们可以访问某个线性地址的PDT,但是这样仅仅可以操控PDT表的内容(PDE),并不能操控PTE,也就是无法访问PTT表。

我们要想访问PTT表就需要页表基址,页表基址就是0xC0000000,与页目录表基址做的实验一样,我们先找到某个线性地址的PTT表:

images/download/attachments/1015831/image2022-7-19_14-53-29.png

拆分0xC0000000为三部分,带入查找物理页:

1100 0000 00 // Hex: 300*4 = C00
00 0000 0000 // Hex: 0*4 = 0
0000 0000 0000 // Hex: 0

最终结果如下图所示,我们可以看到查找出来的物理页内容与上图中的PTT表内容是一致的:

images/download/attachments/1015831/image2022-7-19_14-36-52.png

我们也由图得知,!dd 200a7000+0指令的结果,0x0开始是第一个PTT表的地址,0x4开始就是第二个PTT表的地址,也就是需要指令变为!dd 200a7000+4指令,我们再根据这个指令进行反推:

!dd 200a7000+C00 // Hex: C00 / 4 = 300, 1100 0000 00
!dd 200a7000+4 // Hex: 4 / 4 = 1, 00 0000 0001
!dd 1ff20000+0 // Hex: 0, 0000 0000 0000
 
-> 1100 0000 0000 0000 0001 0000 0000 0000
-> Hex: 0xC0001000

最终得出线性地址为:0xC0001000,那么根据这个规律我们就知道在0xC0000000的基础上每递增0x1000,即表示获取下一个PTT,0x1000正好是4096字节(一个页的大小4KB)。

总结

当学习完这部分的内容之后,我们对10-10-12分页机制又有了新的认知:

  1. 页表(PTT)被映射到了从0xC0000000到0xC03FFFFF的4MB地址空间(一个表4KB,一共有1024个表);

  2. 在这1024个表中有一张特殊的表,就是页目录表(PDT);

  3. 页目录表被映射到了从0xC0300000开始处的4KB地址空间。

images/download/attachments/1015831/image2022-7-19_14-59-57.png

掌握了2个表基址,就相当于掌握了一共进程所有的物理内存读写权限,你可以通过如下公式去访问页目录表、页表:

// 定义
10-10-12拆分就是如下:
PDI(10):Page Dictory Index,页目录索引
PTI(10):Page Table Index,页表索引
PPI(12):Physical Page Index, 物理页索引
 
// 访问页目录表公式
0xC0300000 + PDI * 4
 
// 访问页表公式
0xC0000000 + PDI * 4096 + PTI * 4

2-9-9-12分页

在之前的课程中我们讲解了10-10-12分页机制,在这种机制方式下物理地址最多可达4GB。但随着硬件发展,4GB的物理地址范围已经无法满足需求,Intel在1996年就已经意识到这个问题了,所以设计了新的分页机制,这也就是我们本节课要讲的2-9-9-12分页机制,又称为PAE(物理地址扩展)分页

分页设计

在了解2-9-9-12分页机制之前我们需要从10-10-12分页机制开始了解,它们的分页设计的逻辑,这样便于我们掌握和学习。

10-10-12

10-10-12的分页设计逻辑如下:

  1. Intel认为一张页的大小为4KB是比较合理的,先确定了页的大小为4KB(4096个字节,也就是2的12次方),此时10-10-12中的12就确定了;(页内偏移,找到任一一个字节)

  2. 当初的物理内存比较小,4个字节的PTE成员就够了,页的大小是4KB,所以一个页能存储1024个PTE(也就是2的10次方),此时10-10-12中的第二个10也确定了;(寻找PTE)

  3. 因为整个线性地址是32位的,我们已经确定了12、10位,那么最后就剩下10位了,此时10-10-12中的第一个10也就确定了。(寻找PTI)

2-9-9-12

我们再来看2-9-9-12的分页设计逻辑:

  1. 页的大小是确定的,4KB大小不能随便更改,所以2-9-9-12中的12就确定了;(页内偏移,找到任一一个字节)

  2. 如果想增大物理内存的访问范围,就需要增大PTE,由于需要考虑对齐,所以增加到8个字节,这里增加的只是PTE结构中的物理地址部分,属性部分并没有增加;那么这里一个页可以存储512个PTE(也就是2的9次方),此时2-9-9-12中的第二个9确定了

    images/download/attachments/1015831/image2022-7-20_14-35-39.png
  3. 同理PDE也由原来的4个字节变成了8个字节,PDT表也由1024个成员变成了512个成员,也就与PTE一样了,此时2-9-9-12中的第一个9确定了

  4. 线性地址是32位的,这样算下来就还剩下2位,这2位就是新拓展出来PDPI(Page Directory Point Table Index,页目录指针表索引),也就有了一张新的表叫PDPT(Page Directory Point Table,页目录指针表),其中的成员就是PDPTE(Page Directory Point Table Entry,页目录指针表项,同样是8字节)

如下图所示就是2-9-9-12的分页结构,在10-10-12分页结构中CR3指向的就是PDT,而在2-9-9-12的分页结构中,在PDT之前,也就是CR3指向的是PDPT表,这张表有四个成员(因为PDPI只有2位,最多能交叉4个结果:00 01 11 10):

images/download/attachments/1015831/image2022-7-20_14-44-55.png

在之前的实验中我们修改了C:\boot.ini文件,现在我们可以修改参数为noexecute,然后重启系统,这样就可以开启2-9-9-12分页机制:

images/download/attachments/1015831/image2022-7-18_9-46-19.png

PDPTE结构

如下图所示,灰色部分为保留部分,不一定填0。接着我们来看下这个结构,第0位填1;PWT、PCD位需要等到之后的内容学完再学习;第9-11位Available是给操作系统软件用的,CPU不使用;从第12-35位就是页目录表基址。

images/download/attachments/1015831/image2022-7-20_16-26-47.png

当你想要使用这个地址去寻找PDT表时,与之前10-10-12的实验一样,属性部分(低12位)要填0。

PDE结构

PDE的结构与10-10-12的结构差不多,灰色部分为保留部分,当PS位为1时是大页(下图中的第一个结构),第35-21位是大页的物理地址,这样36位的物理地址的低21位为0,这就意味着页的大小为2MB,且都是2MB对齐;并且多了一个PAT位(页属性表),也就是第12位,该位与CPU相关,但并不是所有CPU都支持该位,如果不支持,该位会填0。

当P位为0时(下图中的第二个结构),第35-12位是页表基址,低12位填0,共36位。

images/download/attachments/1015831/image2022-7-20_16-35-51.png

PTE结构

PTE的结构很简单,第12-35位就是物理页基址,低12位填0,共36位。物理页基址加12位的页内偏移,就可以找到具体的数据。

images/download/attachments/1015831/image2022-7-20_16-45-35.png

XD标志位

XD标志位在AMD中称之为NX,即No Exection。它的设计是因为大多数漏洞产生的原因是因为数据被当成指令去执行了,为了防范这一问题,Intel做了硬件保护,做了一个不可执行位。当XD位为1时,你的软件存在溢出漏洞也没关系,因为即使你的EIP跳到了危险的"数据区",也是不可执行的。

在2-9-9-12分页机制下,PDE与PTE的最高位为XD/NX位:

images/download/attachments/1015831/image2022-7-20_16-55-37.png

TLB

我们都知道通过一个线性地址访问一个物理页,在10-10-12的分页机制下,需要先读PDE在读PTE,最后读4字节的页,这样从本质上来说读取的内容有12个字节,很影响效率;并且在2-9-9-12分页机制下由于物理地址的拓展,会导致读取的字节更多,甚至在某些情况下,数据不在同一物理页,就会存在跨页的情况。

为了提高效率,CPU在内部做了一张表,来记录这些东西,这就是TLB(Translation Lookaside Buffer,转译查找缓冲区),它和寄存器一样快,但同样它的大小也不会太大。

结构

如下图所示就是TLB的结构(TLB中存储的成员结构,不同的CPU下,TLB表的大小是不一样的),线性地址、物理地址我们都知道:

images/download/attachments/1015831/image2022-7-22_13-44-4.png

属性(ATTR)是PDPE、PDE、PTE三个属性进行AND运算出来的结果,如果是10-10-12分页机制的话就是PDE、PTE进行AND运算的结果。

Cr3改了就会直接刷新TLB(也就是进程切换,因为在进程切换后TLB原先存储的线性地址和物理地址的对应关系就没有意义了);由于操作系统中的高2G映射基本不变,所以为了不重复的建立高2G的TLB对应关系,在PDE和PTE中有个标志位G位,它表示其物理页是否为全局页,当该值为1时则不会刷新TLB(只有当PDE的PS位为1时,即其物理页为大页,G位才有效);当TLB表存满了之后,将根据统计(LRU)信息(统计信息内存储了每个地址的读写情况)将不常用的地址废弃,最近常用的保留下来。

种类

TLB在x86体系的CPU里的实际应用最早是从Intel的486CPU开始的,在x86体系的CPU里,一般都设有如下4组TLB:

  1. 缓存一般页表(4KB字节页面)的指令页表缓存(Instruction-TLB);

  2. 缓存一般页表(4KB字节页面)的数据页表缓存(Data-TLB);

  3. 缓存大尺寸页表(2MB/4MB字节页面)的指令页表缓存(Instruction-TLB);

  4. 缓存大尺寸页表(2MB/4MB字节页面)的数据页表缓存(Instruction-TLB)。

中断与异常

中断

中断通常是由CPU外部的输入或输出设备(硬件)所触发的,供外部设备通知CPU有事情需要处理,因此又称之为中断请求(IRQ-Interrupt Request)。

中断请求的目的是希望CPU暂时停止执行当前正在执行的程序,转去执行中断请求所对应的中断处理程序(中断处理程序在哪由IDT表决定)。

80x86有两条中断请求线:

  1. 不可屏蔽中断线,称为NMI(NonMaskable Interrupt);

  2. 可屏蔽中断线,称为INTR(Interrupt Require)。

不可屏蔽中断线

当不可屏蔽中断产生时,CPU会在IDT表中找到下标为0x2的门,通过这个门就可以找到中断处理程序;不可屏蔽中断不受EFLAG寄存器中的IF位的影响,当产生时,CPU必须处理。

images/download/attachments/1015831/image2022-7-22_14-39-34.png

可屏蔽中断线

在硬件级层面,可屏蔽中断是由一块专门的芯片来管理的,我们称之为中断控制器;它负责分配终端资源和管理各个中断源发出的中断请求;为了便于标识各个中断请求,中断管理器通常在IRQ(Interrupt Request)后面加上数字来表示不同的请求,例如在Windows中时钟中断的IRQ编号为0,也就是IRQ0。

我们可以在通过这个路径:右键我的电脑-管理-设备管理器-System timer-属性-资源,找到Windows时钟的中断编号(其他设备也是一样的):

images/download/attachments/1015831/image2022-7-22_14-49-2.png

当可屏蔽中断产生时,CPU会在IDT表中找到下标为0x30/0x31-0x3F的门,通过这个门就可以找到中断处理程序,如下图所示,时钟中断和其硬件设备的中断下标是不一样的:

images/download/attachments/1015831/image2022-7-22_15-2-28.png

如果我们自己的程序执行时不希望CPU去处理这些中断,可以使用CLI指令清空EFLAG寄存器中的IF位,或使用STI指令设置EFLAG寄存器中的IF位。

需要注意,硬件设备的中断与IDT表中的对应关系不是固定的,可以参考APIC(Advanced Programmable Interrupt Controller,高级可编程中断控制器)。

异常

异常通常是CPU在执行指令时检测到的某些错误,比如除0、访问无效页面等。

中断与异常的区别:

  1. 中断来自于外部设备,是中断源(键盘、鼠标等等)发起的,CPU是被动触发的;

  2. 异常来自于CPU本身,是CPU主动产生的;

之前我们所了解的INT N指令虽然被称之为软件中断,但其本质是异常,ELFAG的IF位对该指令是无效的。

异常处理

无论是由硬件设备触发的中断请求还是由CPU产生的异常,处理程序都在IDT表中。

常见的异常处理程序如下:

images/download/attachments/1015831/image2022-7-22_15-11-56.png

缺页异常

缺页异常在操作系统中是无时不刻都在发生的,比如当PDE/PTE的P位为0PDE/PTE的属性为只读但程序试图写入,这时候就会产生缺页异常。

一旦发生缺页异常,CPU就会执行IDT表中0xE号中断处理程序,也就是由操作系统来接管。

images/download/attachments/1015831/image2022-7-22_15-15-46.png

控制寄存器

控制寄存器主要用于控制和确定CPU的操作模式,一共由5个控制寄存器,分别是Cr0-4,Cr1是保留的,Cr3就是我们之前经常使用到的,通过Cr3可以找到页目录表基址(分页机制决定)。

Cr0寄存器

Cr0寄存器,主要包括一些控制操作系统模式以及处理器状态的控制标志位。

images/download/attachments/1015831/image2022-7-22_15-24-29.png

我们来看下几个主要的标志位:

  1. PE:Cr0下标为0的位是启用保护(Protection Enable)标志。PE为1时为保护模式,PE为0时为实地址模式,这个标志仅开启段级保护,而并没有启用分页机制保护。若要启用分页机制保护,那么PE和PG标志都要设值。

  2. PG:当该位开启时即开启了分页机制保护,在开启这个标志之前必须已经或者同时开启PE标志

    • PG=0且PE=0:处理器工作在实地址模式下;

    • PG=0且PE=1:处理器工作在没有开启分页机制的保护模式下(不存在这样的操作系统);

    • PG=1且PE=0:在PE没有开启的情况下无法开启PG,所以这种情况是不存在的;

    • PG=1且PE=1:处理器工作在开启了分页机制的保护模式下。

  3. WP:对于Intel 80486或以上的CPU,CR0的第16位是写保护(Write Proctect)标志,当设置该标志时,处理器会禁止超级用户程序(例如特权级0的程序)向用户级只读页面执行写操作。

    • 对于0环程序,如果WP=0,可以读写任意用户级物理页,只要线性地址有效;

    • 对于0环程序,如果WP=1,可以读取任意用户级物理页,但对于只读的物理页,则不能写。

Cr2寄存器

当CPU访问某个无效页时,会产生缺页异常,此时CPU会将引起异常的线性地址存放至Cr2寄存器中,以便操作系统处理完缺页异常后,返回到原本执行的位置继续执行:

images/download/attachments/1015831/image2022-7-22_15-31-58.png

Cr4寄存器

images/download/attachments/1015831/image2022-7-22_15-40-57.png

PAE位为1时,是PAE(2-9-912)分页;为0时,是10-10-12分页。之前在C:\boot.ini文件中设置execute/noexecute参数,其本质就是就是修改PAE位;PSE位用于控制PDE中PS位的开关,当PSE为1时,PS位才有效。具体如下:

images/download/attachments/1015831/image2022-7-22_15-43-41.png

PWT与PCD位

在之前学习PDE、PDT结构中我们没有讲解PWT、PCD位,现在我们来了解以下。

CPU缓存

在了解这两个位之前我们需要有一个前置知识,那就是CPU缓存。CPU缓存是位于CPU和物理内存之间的临时存储器,它的容量比内存小很多,但是交换速度比内存快很多。

CPU缓存可以做的很大,我们听着它似乎与TLB有着某些相似之处,但实际上是由差异的:TLB缓存的是线性地址和物理地址的对应关系,CPU缓存的是物理地址和实际内容的对应关系。

images/download/attachments/1015831/image2022-7-22_15-50-45.png

PWT/PCD

PWT(Page Write Through,页直写),该值为1时不仅写入缓存中,也会写入到内存中;为0时只会写入缓存。

PCD(Page Cahe Disable,页缓存可编辑),该值为1时禁止写入缓存,直接写入内存;比如页表所在的页已经存储在TLB中了,并不需要再缓存了,所以该位就可以设为1。