首页
论坛
课程
招聘
[原创]保护模式学习笔记之段机制
2021-11-27 17:35 6926

[原创]保护模式学习笔记之段机制

2021-11-27 17:35
6926

一.概念

内存是计算机系统的关键资源,程序必须被加载到内存中才可以被CPU所执行。程序运行过程中,也要使用内存来记录数据和动态信息。在一个多任务系统中,每个任务都需要使用内存资源,因此系统需要有一套机制来隔离不同任务所使用的内存。要使用这种隔离既安全又高效,那么硬件一级的支持是必须的。IA32 CPU提供了多种内存管理机制,这些机制为操作系统实现内存管理功能提供了硬件基础。

CPU的段机制提供了一种手段可以将系统的内存空间划分为一个个较小的受保护区域,其中每个区域称为一个段。每个段都有字节的起始地址(基地址),边界(limit),和访问权限等属性。当根据(段基址:段偏移)来获取程序的逻辑地址的时候,就需要将段寄存器中保存的基地址加上偏移地址才可以获得,比如ds:[0x12345678]这个地址,最终会获得的逻辑地址就是ds段寄存器中保存的基地址加上0x12345678。下图就是这一过程的说明

而段寄存器中的基地址在何处,如何被加载就需要理解段机制,要理解这一套机制,就需要理解IA-32 CPU的段寄存器,段选择子以及段描述符的内容。

二.段寄存器

为了减少地址转换时间和编码复杂度,处理器提供了可容纳多达6个段寄存器。每个段寄存器都支持一种特定类型的内存引用(代码、堆栈或数据)。对于几乎任何类型的程序执行,至少代码段(CS)、数据段(DS)和堆栈段(SS)寄存器必须加载有效的段选择器。处理器还提供了三个额外的数据段寄存器(ES、FS和GS),它们可用于为当前执行的程序(或任务)提供其他数据段。

要让程序访问段,段的段选择器必须已加载到其中一个段寄存器中。因此,尽管一个系统可以定义数千个分段,但只有6个分段可以立即使用。在程序执行期间,通过将其段选择器加载到这些寄存器中,可以提供其他段。

段寄存器的结构如下图所示

一个段寄存器以供有96位,其中的16位段选择子是可见的,其余的80位不可见的部分中包含着32位的基地址,32位的边界以及16位的段属性。当段寄存器加载16位的段选择子的时候,处理器还会从段描述符中加载不可见的80位的内容。

有两种加载段寄存器的方法:

  1. 直接加载指令,如MOV、POP、LDS、LES、LSS、LGS和LFS指令。这些指令可以显式地引用段寄存器

  2. 隐含的加载指令,如CALL、JMP和RET指令的远跳转版本、系统输入和系统退出指令,以及IRET、INTn、INTO和INT3指令。这些指令将CS寄存器(有时还有其他段寄存器)的内容作为其操作的附带部分进行更改

MOV指令可以将段寄存器的可见部分存储在通用寄存器中。

为了让程序正常运行,操作系统在程序加载进内存要执行之前都会初始化段寄存器的值,不同操作系统初始化的值是不一样的。下图就是在我的Win10系统中对段寄存器的情况:

这些段寄存器的各项值的内容如下:

段寄存器段选择子基地址边界访问权限
ES0x002B0x00xFFFFFFFF可读,可写
CS0x00230x00xFFFFFFFF可读,可执行
SS0x002B0x00xFFFFFFFF可读,可写
DS0x002B0x00xFFFFFFFF可读,可写
FS0x00530x7FFDE0000x00000FFF可读,可写
GS0x002B0x00xFFFFFFFF可读,可写

可以看到这些段寄存器多数内容都一样,除了CS属性可执行不可写,FS的基地址和边界不同和其他段寄存器不同。由于可见的只有16位,所以要证明其他80位的存在,只能通过实验进行证明。

以下代码可以证明段寄存器中有32位的基地址

#include <cstdio>
#include <Windows.h>

DWORD g_dwFlag = 0;

int main()
{
	__asm
	{
		push gs
		mov ax, fs
		mov gs, ax
		mov eax, dword ptr gs:[0x0]
		mov dword ptr ds:[g_dwFlag], eax
		pop gs
	}
	printf("g_dwFlag=%d\n", g_dwFlag);

	return 0;
}

正常情况下,程序是无法对0地址的内容进行读写的,可是当将ds段寄存器的内容改成fs段寄存器的内容的时候,就可以实现对偏移为0的地址的读写,说明此时的段寄存器中包含了基地址,这样最终得到的地址才不是0地址

以下代码可以证明段寄存器中有32为的边界

#include <cstdio>
#include <Windows.h>

DWORD g_dwFlag = 0;

int main()
{
	__asm
	{
		push ds
		mov ax, fs
		mov ds, ax
		mov eax, 1
		mov dword ptr ds:[g_dwFlag], eax
		pop ds
	}
	printf("g_dwFlag=%d\n", g_dwFlag);

	return 0;
}

由于将fs段寄存器的内容赋值到了ds寄存器中,此时ds的边界只到0xFFF,无法访问整个进程空间了,此时对全局变量的读写超出了范围就导致程序运行的崩溃

以下代码可以证明段寄存器中有16位的访问权限

#include <cstdio>
#include <Windows.h>

DWORD g_dwFlag = 0;

int main()
{
	__asm
	{
	        push ds
		mov ax, cs
		mov ds, ax
		mov dword ptr ds:[g_dwFlag], 1
		pop ds
	}

	return 0;
}

根据上表可以知道CS段寄存器是不具有可写的属性的,将ds段寄存器内容修改为cs段寄存器内容以后,在对ds段的内存区域进行写入操作程序就会崩溃。可见,将cs段寄存器并不具有可写的属性

三.段选择子

96位的段寄存器中有16位可见部分是段选择子,处理器就是通过它去段描述符中加载不可见的80位加入到段寄存器中。所以段选择子并不直接指向段,而是指向定义段的段描述符,它结构如下图所示:

从图中可以知道,一个16位的段选择子分成了三个部分,这三部分的描述如下

名称比特位描述
RPL0-1请求者特权级别
TL2

指定要查询的段描述

  • GDT:1

  • LDT:0

Index3-15在GDT或LDT中的8192个描述符中的一个。处理器将索引值乘以8(段描述符中的字节数),并将结果与GDT或LDT的基本地址(分别来自GDTR或LDTR寄存器)相加得到相应的段描述符地址

由此可知,处理器根据段选择子中的Index来获得需要的段描述符的地址,以此来加载隐藏的80位。

处理器不使用GDT的第一个条目。指向GDT的此条目的段选择器(即索引为0且TI标志设置为0的段选择器)被用作“空段选择器”。当段寄存器(除了CS或SS寄存器)加载了空选择器时,处理器不会产生异常。但是,当使用持有空选择器的段寄存器来访问内存时,它确实会产生一个异常。空选择器可用于初始化未使用的段寄存器。加载带有空段选择器的CS或SS寄存器会导致生成通用保护异常。

段选择器作为指针变量的一部分对应用程序可见,但选择器的值通常由链接编辑器或链接加载器分配或修改,而不是应用程序。

四.段描述符表

由上面内容可以知道,处理器要根据段选择子找到需要的段描述符,以此填充段选择子的隐藏的80位的内容,此时的段选择子就代表了一个段。但是,在一个多任务中通常会同时存在着很多个任务,每个任务会涉及多个段,每个段都需要一个段描述符,因此系统中会有很多段描述符,为了方便管理,系统用线性表来存放段描述符。根据用途不同,IA-32处理器有3中描述符表:全局描述符表(GDT),局部描述符表(LDT)和中断描述符表(IDT)。

GDT是全局的,一个系统中通常只有一个GDT,供系统中的所有程序和任务使用。LDT与任务有关,每个任务可以有一个LDT,也可也让多个任务共享一个LDT。IDT的数量是和处理器的数量相关的,系统通常会为每个CPU建立一个IDT。

GDTR和IDTR寄存器分别用来标识GDT和IDT的基地址和边界。这两个寄存器的格式是相同的,在32位模式下,长度是48位,高32位是基地址,低16位是边界。

LGDT和SGDT指令分别用来读取和设置GDTR寄存器。LIDT和SIDT指令分别用来读取和设置IDTR寄存器。操作系统在启动初期会建立GDT和IDT并初始化GDTR和IDTR寄存器。

当创建LDT时,GDT已经准备好,因此,LDT被创建为一种特殊的系统段,其段描述符被放在GDT表中。GDT表本身只是一个数据结构,没有对应的段描述符。

想要知道这两个寄存器中保存的内容,可以使用WinDbg的r命令来查看

kd> r gdtr
gdtr=8003f000
kd> r gdtl
gdtl=000003ff
kd> r idtr
idtr=8003f400
kd> r idtl
idtl=000007ff

从上面的gdtl值可以知道这个GDT的边界是0x3FF(1023),总长度是1024字节(1KB),由于每个段描述符占8个字节,所以一共有1024/8=128个表项。IDT的长度是2KB,共有256个表项。

当然也可也使用下面的代码来获得对应的内容

#include <cstdio>
#include <Windows.h>

#pragma pack(1)
typedef struct _GDT
{
	WORD wLimit;
	DWORD dwBase;
}GDT;

typedef struct _IDT
{
	WORD wLimit;
	DWORD dwBase;
}IDT;
#pragma pack()


int main()
{
	GDT gdt = { 0 };
	IDT idt = { 0 };

	_asm
	{
		sgdt gdt
		sidt idt
	}

	printf("GDT base=0x%08x limit=0x%04x\n", gdt.dwBase, gdt.wLimit);
	printf("IDT base=0x%08x limit=0x%04x\n", idt.dwBase, idt.wLimit);
	system("pause");

	return 0;
}

可以看到输出和WinDbg的输出是一样的

五.段描述符

在保护模式下,每个内存段都有一个段描述符,段描述符是GDT或LDT中的一种数据结构,它为处理器提供段的大小和位置,以及访问控制和状态信息。段描述符通常由编译器、链接器、加载器或操作系统或执行程序创建,但而不是应用程序。下图是段描述符的一般格式,可以看到一个段描述符占8个字节,分为高32位和低32位。

段描述符最基本的内容是这个段的基地址和边界。基地址是以4个字节表示(字节2,3,4和7),它可以是4GB线性地址空间中的任意地址(0x00000000~0xFFFFFFFF)。边界是用20个比特位表示(字节0,1和字节6的低4位),其单位由粒度位(字节6的最高位)决定,当G=0时,段边界的单位是1字节,当G=1时是4KB。因此一个段的最大边界值是(2^20-1),最大长度是2^20*4KB=4GB。

在段描述符中一共只有20位来表示边,最大为0xFFFFF,而要表示32位的边界值就需要使用到G位

  • G位为0:在0xFFFFF高位补3个0x0,此时边界就是0x000FFFFF

  • G位为1:在0xFFFFF低位补3个0xF,此时边界就是0xFFFFFFFF

下表介绍了段描述符的其他各个域的含义

域简称全称含义
S系统(System)S=0代表该描述符是一个系统段,S=1代表该描述符是代码段,数据段或者堆栈段
P存在(Present)

指示该段是否存在于内存中。如果P=0,则当将指向段描述符的段选择器加载到段寄存器中时,处理器将生成段不存在异常。内存管理软件可以使用此标志来控制哪些段实际加载到物理内存中。它除了提供分页外,还提供了一个管理虚拟内存的控件。

图3-9显示了当P=0时段描述符的格式。当P=0,操作系统或执行人员可以自由使用标记为“可用”的位置来存储自己的数据。

DPL描述符特权级(Descriptor Privilege Level)这两位定义了该段的特权级别(0~3),简单来说,仅当要访问该段的程序的特权级别(CPL)等于或高于这个段的级时CPU才允许其访问,否则便会抛出保护性异常(GPF)。
D/BDefault/Big

对于代码段,该位表示的是这个代码段默认的位数(Default Bit)

  • D=0:16位代码段

  • D=1:32位代码段

对于栈段,该位称为B(Big)标志。

  • B=1:使用32位堆栈指针(保存在ESP中)

  • B=0:使用16位堆栈指针(保存在SP中)

对于数据段,该位称为B(Big)标志,指定了段的上界。

  • B=1:段的上界是0xFFFFFFFF(4GB)

  • B=0:段的上界是0xFFFF(64KB)

Type段类型由S位来决定段类型,具体内容见下面的分析
L64-bit代码段

用于描述IA-32e模式下的代码段

  • L=1:代码段包含64位代码

  • L=0:代码段包含兼容模型的代码

AVLAvailible and reserved bits供系统软件(操作系统)使用


1.代码和数据段描述符

当S=1的时候,段类型为代码段,数据段或者堆栈段,此时type各比特的值以及对应的含义如下图

根据上图可以知道,type的最高位来决定这是一个数据段还是代码段,当最高位为0,这就是一个数据段,当最高位为1这就是一个代码段。无论是对数据段还是代码段,最低位(A)都是表示是否访问过,如果是1则已被访问,如果是0则未被访问。

对于数据段来说:

第9位(W)表示该段是否可写,如果是1则该段可读可写,否则可读不可写。

第10位(E)表示该段的扩展方向,如果是1则表示向上扩展,否则表示向下扩展。

对于代码段来说:

第9位(R)表示该段是否可读,如果是1则该段可执行可读,否则该段可执行不可读。

第10位(C)表示该段是否是一致代码段,如果是1则表示是,否则就不是

对于向下扩展的栈数据段,段边界指定的是该段的最小偏移,B标志用来指定偏移的最大有效值(即上边界),当B=1时,最大偏移是0xFFFFFFFF,这样,如果limit=0,那么段总长度就是4GB(G=0),如果B=0,那么上边界便是0xFFFF(64KB)。

2.系统段

当S=0的时候,段类型是系统段,处理器可以识别以下类型的系统段描述:

  • 本地描述符表(LDT)段描述符

  • 任务状态段(TSS)描述符

  • 调用门描述符

  • 中断门描述符

  • 陷阱门描述符

  • 任务门描述符

这些描述符类型可分为两类:系统段描述符和门描述符。系统段描述符指向系统段(LDT和TSS段)。门描述符本身就是“门”,它保存指向代码段中的过程入口点(调用、中断和陷阱门)的指针,或者保存针对TSS(任务门)的段选择器

显示了系统段描述符和门描述符的类型字段的编码。请注意,IA-32e模式下的系统描述符为16字节,而不是8字节

不同的系统段有不同的特点和作用,详细内容后面再说,现在先做几个实验来熟悉上面的内容。

3.实验练习

就以系统默认加载的这几个段寄存器中的段选择子,也就是0x0023,0x001B,0x003B来进行实验。

a.0x0023

0x0023对应的二进制是0000000000100 0 11,此时RPL=11(0x3),意味着请求特权级为3。TI=0,意味着要去GDT表查询相应的段描述符。索引Index=0100(0x4),所以相应的段描述符在内存中的地址就等于GDT表基地址+0x04 * 8。

根据WinDbg的输出可以知道,GDT表的地址是0x8003F000。

kd> r gdtr
gdtr=8003f000

所以段选择子0x0023的段描述符的地址就是0x8003F000 + 0x4 * 8 = 0x8003F020

kd> dq 0x8003F000 + 0x4 * 8
8003f020  00cff300`0000ffff 80008b04`200020ab
8003f030  ffc093df`f0000001 0040f300`00000fff
8003f040  0000f200`0400ffff 00000000`00000000
8003f050  80008955`27000068 80008955`27680068
8003f060  00009302`2f40ffff 0000920b`80003fff
8003f070  ff0092ff`700003ff 80009a40`0000ffff
8003f080  80009240`0000ffff 00009200`00000000
8003f090  00000000`00000000 00000000`00000000

根据WinDbg输出可以知道,此时的段描述符为0x00 CFF3 00 0000 FFFF。根据段描述符的格式可以知道,此时的Base为0x00000000,对于高32位的8-23位的0xCFF3,对应的二进制是1 1 0 0 1111 1 11 1 0011,各个比特位的内容及含义如下:

名称比特位数值含义
Type8~110011(0x3)可读可写已访问的向上拓展的数据段
S121(0x1)代码和数据段描述符
DPL13~1411(0x3)该描述符特权级为3
P151(0x1)该段有效
SegLimit16~191111(0xF)边界
AVL200(0x0)供系统使用,忽略
L210(0x0)非IA-32模式下,忽略
D/B221(0x1)段上界为0xFFFFFFFF(4GB)
G231(0x1)段边界单位为4KB

由于段描述符第32位的低16位为0xFFFF且SegLimit为0xF且G=1,所以此时段边界是0xFFFFFFFF,该段在内存中有效,特权级为3,是一个可读可写且向上扩展的数据段。


b.0x001B

0x001B的二进制为 0000000000011 0 11,TI=0意味着查询GDT表,RPL=3意味着请求者特权级为3,索引Index=3,用上面的方法去WinDbg查看对应的段描述符

kd> dq 8003F000 + 0x3 * 8
8003f018  00cffb00`0000ffff 00cff300`0000ffff
8003f028  80008b04`200020ab ffc093df`f0000001
8003f038  0040f300`00000fff 0000f200`0400ffff
8003f048  00000000`00000000 80008955`27000068
8003f058  80008955`27680068 00009302`2f40ffff
8003f068  0000920b`80003fff ff0092ff`700003ff
8003f078  80009a40`0000ffff 80009240`0000ffff
8003f088  00009200`00000000 00000000`00000000

得到此时段描述符为0x00 CFFB 00 0000 FFFF。此时段的Base为0x00000000,对于高32位的8-23位的0xCFFB对应二进制是1 1 0 0 1111 1 11 1 1011,各个比特位的内容及含义如下

名称比特位数值含义
Type8~111011(0xB)可读可执行已访问的非一致代码段
S121(0x1)代码和数据段描述符
DPL13~1411(0x3)该描述符特权级为3
P151(0x1)该段有效
SegLimit16~191111(0xF)边界
AVL200(0x0)供系统使用,忽略
L210(0x0)非IA-32模式下,忽略
D/B221(0x1)执行的是32位的代码段
G231(0x1)段边界单位为4KB

由于段描述符低32位的低16位为0xFFFF且SegLimit为0xF且G=1,所以此时段边界为0xFFFFFFFF,该段在内存中有效,特权级为3,是一个可读可执行已访问的非一致代码段。

c.0x003B

0x003B的二进制为0000000000111 0 11,TI=1意味着查询的是GDT表,RPL=3意味着请求者特权级为3,索引为7,用上面的方法在WinDbg中得到输出如下所示

kd> dq 8003F000 + 0x7 * 8
8003f038  0040f300`00000fff 0000f200`0400ffff
8003f048  00000000`00000000 80008955`27000068
8003f058  80008955`27680068 00009302`2f40ffff
8003f068  0000920b`80003fff ff0092ff`700003ff
8003f078  80009a40`0000ffff 80009240`0000ffff
8003f088  00009200`00000000 00000000`00000000
8003f098  00000000`00000000 810089f8`33300068
8003f0a8  00000000`00000000 00000000`00000000

得到此时段描述符为0x00 40F3 00 0000 0FFF。此时的Base为0x00000000,对于高32位的8-23位的0x40F3对应的二进制是0 1 0 0 0000 1 11 1 0011,各个比特位的内容及含义如下

名称比特位数值含义
Type8~110011(0x3)可读可写已访问的向上拓展的数据段
S121(0x1)代码和数据段描述符
DPL13~1411(0x3)该描述符特权级为3
P151(0x1)该段有效
SegLimit16~190000(0x0)边界
AVL200(0x0)供系统使用,忽略
L210(0x0)非IA-32模式下,忽略
D/B221(0x1)段上界为0xFFFFFFFF(4GB)
G230段边界单位为1字节

由于低32位的低16位为0x0FFF且SegLimit为0且G=0,所以此时的边界是0x00000FFF,该段在内存中有效,特权级为3,是一个可读可写已访问的向上拓展的数据段。

由此可以得出结论,当对段寄存器赋值的时候,处理器会根据段选择子中的TI会来决定是去GDT还是IDT表中查找相应的段描述符,随后根据索引到相应的表中找到对应的段描述符,在将段描述符中的内容赋值到段寄存器中,下图是对TI的不同的说明,注意这里的GDT表的第一项是没有使用的

可以使用mov,les,lss,lds,lfs,lgs指令修改ES,SS,DS,FS,GS,LDTR,TR这几个段寄存器,但不能修改CS段寄存器。原因是CS段寄存器的改变意味着EIP的改变,所以在修改CS段寄存器的时候需要同时修改EIP,所以无法使用那些指令来修改CS段寄存器,接下来就介绍几种修改CS段寄存器的方法。

六.修改CS段寄存器

上面说过,不能像修改其他段寄存器一样修改CS段寄存器,想要修改CS段寄存器需要通过以下的方法

  • 远跳转

  • 调用门

  • 中断门

  • 陷阱门

  • 任务段

  • 任务门

1.远跳转与长调用

jmp far指令(指令编码为0xEA),也称为远跳转指令,该指令的格式是jmp far cs:eip,其中EIP是随后的4个字节,而cs则是EIP随后的2个字节。也就是说对于0xEA 78563412 3000,其对应的指令是jmp far 0x0030:0x12345678。

而当程序运行这条指令的时候,会按照以下步骤进行:

  1. 将0x0030作为段选择子拆分,得到TI=0。

  2. 此时就要去GDT表中查找段描述符。

  3. 找到相应的段描述符以后,进行权限检查

  4. 通过了权限检查,CPU就会将段描述符中的内容加载到CS寄存器中

  5. CPU将CS寄存器中保存的基地址与偏移0x12345678相加就得到了要执行的代码的地址

通过第三步的权限检查分为两种情况:

  • 如果是非一致代码段,此时要求CPL 数值上等于DPL并且RPL 数值上小于等于 DPL。

  • 如果是一致代码段,此时要求CPL 数值上大于等于DPL。

也就是说,在一致代码段中,特权级低的代码(ring3)可以访问特权级高(ring0)的数据,而在一致代码段中,只能同样级别的代码段可以进行跳转。

接下来就通过几个跳转实验来验证上面的内容,要完成这些实验就需要在GDT表中找到P位为0的段描述符,这样才可以构造我们想要的段描述符来实现功能

kd> r gdtr
gdtr=8003f000
kd> dq 8003F000
8003f000  00000000`00000000 00cf9b00`0000ffff
8003f010  00cf9300`0000ffff 00cffb00`0000ffff
8003f020  00cff300`0000ffff 80008b04`200020ab
8003f030  ffc093df`f0000001 0040f300`00000fff
8003f040  0000f200`0400ffff 00000000`00000000
8003f050  80008955`1b800068 80008955`1be80068
8003f060  00009302`2f30ffff 0000920b`80003fff
8003f070  ff0092ff`700003ff 80009a40`0000ffff

由于p位是段描述符高32位中的第15位,所以要它为0,则需要低位开始第3个16进制小于8,由WinDbg的输出可以知道0x8003F048的位置的p位为0。

a.非一致代码段---DPL等于3

    接下来构造一致代码段,首先段描述符的基址和边界分别是0x0000000和0xFFFFFFFF,所以可以根据下表来构造需要的非一致代码段描述符

名称比特位数值含义
Type8~111011(0xB)可读可执行已访问的非一致代码段
S121(0x1)代码或数据段描述符
DPL13~1411(0x3)该描述符特权级为3
P151(0x1)该段有效
SegLimit16~191111(0xF)边界
AVL200(0x0)供系统使用,请忽略
L210(0x0)非IA-32模式下,请忽略
D/B221(0x1)执行的是32位的代码段
G231(0x1)段边界单位是4KB

由此可以构造出需要的段描述符高32位为0x00CFFB00,低32位为0x0000FFFF,接着在WinDbg中增加这一段描述符

可以看到在0x8003F048的位置成功添加了构造的段描述符。接下来就需要构造相应的段选择子,因为段描述符的地址是0x8003F048,相比于GDT表的基地址0x8003F000来说,它是第10个段描述符,所以此时的选择子的Index=9=1001,TI=0,RPL=11,最终得到的段选择子就是0x004B。

接下来就可以通过下面的代码来实现跳转

#include <cstdio>
#include <windows.h>

typedef struct _ADDRESS
{
	DWORD dwFuncAddr;
	WORD wSelector;
} ADDRESS;

void test()
{
	printf("jmp far success!\n");
}

int main()
{	
	ADDRESS addr = { 0 };

	addr.dwFuncAddr = (DWORD)test;
	addr.wSelector = 0x004B;
	
	__asm 
	{
		jmp fword ptr [addr]
	}
	printf("can not exec this!\n");

	return 0;
}

接下来就在OD中观察跳转前后CS段寄存器的变化,首先跳转前,此时的CS为0x001B。

跳转成功以后,此时的CS段寄存器就变成构造的0x004B了

b.非一致代码段---DPL等于0

此时需要更改段描述符高32位中的第13~14位为0,此时段描述符高32位为0x00CF9B00,低32位为0x0000FFFF,此时在WinDbg中通过命令修改相应的段描述符表的地址

此时再次在OD中运行上面的程序,在跳转前此时的CS依然是0x001B

这次在跳转,跳的不是我们写入的目的地址,CS段寄存器也没有发生改变,且根据OD提示的函数名可以知道,程序接下来要抛出异常,可想知道,这次的跳转失败了,接下来会执行异常程序的代码

c.一致代码段---DPL等于3

要构造一致代码段,只需要第8~11位的Type为1111(0xF)就可以,此时段描述符高32位为0x00CFFF00,低32位为0x0000FFFF,在WinDbg对相应位置进行修改:

继续使用上面的程序在OD中测试,跳转前,此时的CS等于0x001B

成功跳转以后,此时的CS就是0x004B

d.一致代码段---DPL等于0

DPL=0的一致代码段的段描述符高32位为0x00CF9F00,低32位为0x0000FFFF,在WinDbg中对相应位置进行更改

继续用上面的方法验证,跳转前,此时的CS为0x001B

成功跳转以后,此时的CS寄存器的内容变为0x004B

长调用call far对应的指令编码是0x9A,它和长跳转的功能是一样的,只不过在使用call far跳转的时候,程序会将原先的CS中的段选择子和返回地址入栈。用非一致代码段DPL=3的例子来说明,此时将相应的段描述符用WinDbg添加进去:

接着将代码修改为如下的长调用代码

#include <cstdio>
#include <windows.h>

typedef struct _ADDRESS
{
	DWORD dwFuncAddr;
	WORD wSelector;
} ADDRESS;

void test()
{
	printf("jmp far success!\n");
}

int main()
{	
	ADDRESS addr = { 0 };

	addr.dwFuncAddr = (DWORD)test;
	addr.wSelector = 0x004B;
	
	__asm 
	{
		call fword ptr [addr]
	}
	printf("can not exec this!\n");

	return 0;
}

接着在OD中观察在调用之前,cs段寄存器的内容是0x001B,返回地址为0x00401091,esp为0x0012FF2C

接着是进行长调用,可以看到此时的CS寄存器的内容为0x004B,栈中压入了原来的cs段寄存器中的段选择子以及返回地址

也因为此时有了正确地返回地址,所以程序可以正常地退出

总结:

  1. 为了对数据进行保护,非一致代码段是禁止不同级别进行访问的。ring 3代码不能访问ring0的数据,同样,ring0的代码也不能访问ring3的数据.

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

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

2.调用门

上面说到了想要提升CS段寄存器地CPL权限,需要通过调用门的方式来实现。当段描述符的S域为0的时候,此时为系统段描述符,而根据下图可以知道,当Type域为1100(0xC)的时候,此时的段描述符就是一个调用门。

调用门的格式如下图所示:

可以看到此时的段描述符的字段发生了以下的变化:

  • 高32位的16~31位和低32位的0~15位组成了偏移地址

  • 低32位的16~31位变成了一个段选择子

  • 高32位的0~4指定了此次调用是否有参数传递

由此可以知道,此时call far cs:eip的时候,这个指定的eip并没有作用,因为此时偏移地址是由调用门来决定的,而调用门之所以可以提权是因为在调用门中有段选择子,可以段选择子决定了调用完调用门以后CS段寄存器的段选择子。

所以此时调用call far cs:eip的过程如下:

  1. 根据CS段寄存器段选择子查表找到段描述符,发现是个调用门

  2. 获得调用门中的段选择子的内容,作为装载到CS段寄存器中的段选择子

  3. 此时CS寄存器中的段选择子的Base加上调用门的偏移地址就是要执行的目的地址

因为调用门分为有参调用门和无参调用门,所以接下来分别对它们进行实验观察区别。要进行这两个实现,首先要在WinDbg中查找可以用来做实验的段描述符

kd> r gdtr
gdtr=8003f000
kd> dq 8003F000
8003f000  00000000`00000000 00cf9b00`0000ffff
8003f010  00cf9300`0000ffff 00cffb00`0000ffff
8003f020  00cff300`0000ffff 80008b04`200020ab
8003f030  ffc093df`f0000001 0040f300`00000fff
8003f040  0000f200`0400ffff 00000000`00000000
8003f050  80008955`1b800068 80008955`1be80068
8003f060  00009302`2f30ffff 0000920b`80003fff
8003f070  ff0092ff`700003ff 80009a40`0000ffff

根据WinDbg的结果显示,地址0x8003F048的地方可以拥抱保存调用门段描述符,而0x8003F008的段描述符0x00CF9B00`0000FFFF可以用来作为用来提权的段描述符。

此时段选择子的内容根据上面的内容可以确定是0x0008,而执行完调用门以后要执行的程序地址可以通过在编译器中获得,这样就只要根据是否有参数就可以构造需要的调用门描述符。

a.无参调用门

对于无参构造门,调用门的段描述符就是0x0040EC00`00081020,在WinDbg中完成相应的更改

接着使用以下的代码进行测试,查看test函数中的CPL

#include <cstdio>
#include <windows.h>

#pragma pack(1)
typedef struct _ADDRESS
{
	DWORD dwFuncAddr;
	WORD wSelector;
}ADDRESS;
#pragma pack()

void __declspec(naked) test()
{
	__asm
	{
	    int 3
	    retf
	}
}

int main()
{
	ADDRESS addr = { 0 };
	
	addr.wSelector = 0x004B;

	__asm
	{
		call fword ptr [addr]
	}

	return 0;
}

程序运行到test函数中的int 3就会中断,中断以后,在WinDbg查看寄存器信息,可以看到此时的cs和ss的权限都变成了ring0权限,eip也是上面指定的地址

在查看此时栈中的内容:

可以看到通过调用门进入函数的时候,程序会将原来ss段寄存器的段选择子,原esp地址,原来cs段寄存器中的段选择子,返回地址的内容依次入栈,此时栈中内容如下图所示,这也就是为什么在test函数末尾使用retf指令来返回而不是普通的ret指令。

可以使用以下代码来查看test函数是否可以读取高2GB的内存

#include <cstdio>
#include <windows.h>

#pragma pack(1)
typedef struct _ADDRESS
{
	DWORD dwFuncAddr;
	WORD wSelector;
}ADDRESS;
#pragma pack()

DWORD g_dwFlag;

void __declspec(naked) test()
{
	__asm
	{
		pushfd
		pushad
	}

	__asm
	{
		mov eax, 0x8003F00C
		mov eax, [eax]
		mov [g_dwFlag], eax
	}

	__asm
	{
		popad
		popfd
		retf
	}
}

int main()
{
	ADDRESS addr = { 0 };
	
	addr.wSelector = 0x004B;

	__asm
	{
		call fword ptr [addr]
	}
	printf("g_dwFlag=%d\n", g_dwFlag);

	return 0;
}

由输出结果可以知道此时提权成功

b.有参调用门

调用有参构造门的时候,调用门高32位中0~4位指定参数的个数,此时指定2个参数的话,那么调用门段描述就变为0x0040EC02`00081020,依然在WinDbg中对其进行更改

接下来使用以下的代码来测试

#include <cstdio>
#include <windows.h>

#pragma pack(1)
typedef struct _ADDRESS
{
	DWORD dwFuncAddr;
	WORD wSelector;
}ADDRESS;
#pragma pack()

void __declspec(naked) test()
{
	__asm
	{
	    int 3
	    retf 0x08
	}
}

int main()
{
	ADDRESS addr = { 0 };
	
	addr.wSelector = 0x004B;

	__asm
	{
		push 0x1874
		push 0x1900
		call fword ptr [addr]
	}


	return 0;
}

当程序执行int 3中断以后,可以看到cs,ss,eip都成功更改

此时查看栈中内容,可以看到压入的参数被放入了原来esp地址和原cs段寄存器的段选择子的中间

以此可以得出结论,有参数传递时候的栈情况如下图所示,所以此时test函数末尾需要执行retf 0x08来平衡栈。

接着可以用下面代码来把压入的参数读取出来

#include <cstdio>
#include <windows.h>

#pragma pack(1)
typedef struct _ADDRESS
{
	DWORD dwFuncAddr;
	WORD wSelector;
}ADDRESS;
#pragma pack()

DWORD g_dwP1, g_dwP2;

void __declspec(naked) test()
{
	__asm
	{
		pushfd
		pushad
	}

	__asm
	{
		mov eax, [esp + 0x24 + 0x8]
		mov g_dwP1, eax
		mov eax, [esp + 0x24 + 0xC]
		mov g_dwP2, eax
	}
	__asm
	{
		popad
		popfd
	        retf 0x08
	}
}

int main()
{
	ADDRESS addr = { 0 };
	
	addr.wSelector = 0x004B;

	__asm
	{
		push 0x1874
		push 0x1900
		call fword ptr [addr]
	}
	printf("g_dwP1=0x%x g_dwP2=0x%x\n", g_dwP1, g_dwP2);

	return 0;
}

可以看到程序正确的读取了参数

通过调用门提权以后,不仅返回地址与原CS段寄存器的段选择子压入栈中,SS段寄存器的段选择子和原ESP也压入栈中。这是因为CS段寄存器和SS段寄存器的权限需要保持一致,所以在提权以后需要更换SS段寄存器的内容这就需要将原来的SS段寄存器保持起来。

以上就是调用门的内容,但是Windows系统中并没有使用调用门,在系统调用和调试的时候用的是中断门。而中断门描述符保存在中断描述符表IDT中,IDT也由是一系列的段描述符组成,每个段描述符占8个字节,和GDT不同的是,IDT的第一个段描述符不是NULL。

IDT表中包含了三种门描述符:

  • 中断门描述符

  • 陷阱门描述符

  • 任务门描述符

下图显示了任务门、中断门和陷阱门描述符的格式。在IDT中使用的任务门的格式与在GDT或LDT中使用的任务门的格式相同。任务门包含针对异常和/或中断处理程序任务的TSS的段选择器。

3.中断门和陷阱门

中断门和陷阱门与调用门非常相似。处理器用来将程序执行传输到异常或中断处理程序代码段中的处理程序过程,这两个门不同于处理器处理EFLAGS寄存器中的IF标志的方式。

中断门和陷阱门段描述符S域为0且Type域分别是1110(0xE)和1111(0xF)

由上面中断门和陷阱门的段描述符可知,和调用门相比,中断门与陷阱门是不可以传参的。两个门低32位的16~31位中保存了段选择子,调用中断门以后将通过该段选择子在GDT或LDT表中查询相应的段描述符,将查询到的这个段描述符装载到CS寄存器中,低32位的0~15位和高32位的16~31位组成的偏移地址与CS段寄存器的基址组成了要执行的代码的地址。该过程如下图所示

相比于调用门,中断门或陷阱门的是通过int指令来调用的,比如int 3则是使用IDT表基地址偏移3 * 8的地址上的段描述符,所以首先需要使用WinDbg查询IDT表中可用的段描述符,可以看到0x8003F500的段描述符是可以用的,根据它与IDT表基地址0x8003F400的偏移可知,此时要使用int 0x20才可以使用这个段描述符

接下来就根据要求构造相应的中断门段描述符,在我的机器上我构造的是0x0040EE00`00081020,接着就在WinDbg中完成修改

 接着使用以下代码来测试中断门

#include <cstdio>
#include <windows.h>

void __declspec(naked) test()
{
	__asm
	{
		int 3
		iretd
	}
}

int main()
{
	__asm int 0x20

	return 0;
}

当程序触发int 3中断的时候,此时可以在WinDbg中看到CS和SS段寄存器的权限已经成功提升且eip也是指定的偏移,以此eflags寄存器此时是2

此时在看栈中内容

可以看到和调用门相比,中断门还对eflags寄存器进行了修改,而且也把原先的eflags寄存器中的内容压入栈中,也就是说通过中断门提权以后栈中保存的数据如下图所示,这也就是为什么test函数的返回指令是iretd

而这个eflags寄存器的前后改变也只是第9位的1变成0,由eflags寄存器的格式可以知道,是把IF位置0

接下来实验陷阱门,陷阱门和中断门的段描述符区别只是type的不同,所以陷阱门段描述符就是0x0040EF00`00081020,在WinDbg中更改以后

继续用上面的代码进行测试,当程序触发int 3指令的时候可以在WinDbg中看到cs和ss段寄存器的权限已经提升,eip也执行了指定的地址,此时的efl的IF位并没有被置0


继续看栈中内容,可以看到栈保存的内容和中断门是一样的。

中断门和陷阱门唯一的区别就是提权以后中断门的eflags寄存器的IF位会被置0

4.任务段和任务门

a.任务段

上面说过当通过调用门或者中断门提权以后会更换新的ss段寄存器以及esp的值,这两个值其实来源于TSS任务段,该段大小为104字节,格式如下图所示

CPU通过TR寄存器来找到TSS段的基址和边界,如下图所示,可以看到TR寄存器也分为可见部分和不可见部分,其中的不可见部分的基址和边界就是用来说明TSS段在内存中的地址以及其大小,可以使用LTR和STR指令来实现TR寄存器的读写,但是这两条指令只能改变TR寄存器的值,无法改变TSS段中的内容且这两条指令只能在系统层使用

所以在使用调用门等提权的时候,CPU就会去TR寄存器中查询TSS段的地址找到TSS段,在根据TSS段中保存的寄存器的数据来更换寄存器的内容

为了做到使用自己指定的TSS段,就需要使用到TSS段描述符,当段描述符的S位为0且Type位为1011(0xB)的时候,段描述符保存的就是TSS段描述符

此时的段描述符格式如下,此时段描述符的基址和边界描述的是TSS段的基址和边界

当使用jmp far或者call far指令访问到一个TSS段描述符的时候,此时将根据段描述符修改TR寄存器中的内容,接着在由TR寄存器的基址找到TSS段,在使用TSS段中保存的寄存器的值来替换相应的寄存器,而访问段描述符以后要执行的代码地址则是由指令的偏移地址决定的。这样,就实现了任务的切换,当执行指定的代码的时候,CPU会将寄存器的值更换为指定的值。

在构造TSS段描述符的时候,其基地址说明了tss段的位置,所以要指定为我们使用的tss内存的地址,将使用以下代码来进行实验

#include <cstdio>
#include <windows.h>

#pragma pack(1)
typedef struct _ADDRESS
{
	DWORD dwFuncAddr;
	WORD wSelector;
}ADDRESS;
#pragma pack()

void __declspec(naked) test()
{
	__asm
	{
	    pushad
	    pushfd
		push fs
		int 3 
		pop fs
		popfd
		popad
		iretd
	}
}

int main()
{
	DWORD dwCr3 = 0;
	char szStack[0x1000] = { 0 };
	PDWORD pTSS = (PDWORD)VirtualAlloc(NULL, 104, MEM_COMMIT, PAGE_READWRITE);
	ADDRESS addr = { 0 };

	if (pTSS)
	{
		RtlZeroMemory(pTSS, 104);
		printf("tss address:0x%X\n", (DWORD)pTSS);
		printf("input cr3:");
		scanf("%x", &dwCr3);

		pTSS[7] = dwCr3; //cr3
		pTSS[8] = (DWORD)test; // EIP
		pTSS[14] = (DWORD)szStack+0x800; // ESP
		pTSS[18] = 0x00000023; // ES
		pTSS[19] = 0x00000008; // CS 
		pTSS[20] = 0x00000010; // SS
		pTSS[21] = 0x00000023; // DS
		pTSS[22] = 0x00000030; // FS 
		pTSS[25] = 0x20ac0000; // I/O Map Base Address
		
		addr.wSelector = 0x0048;
		
		__asm call fword ptr [addr]

	}

	return 0;
}

在这段代码中对tss进行了必要的赋值,当它输出了以下的内容就获得了要使用的tss的地址

这样就可以构造tss段描述符为0x0000E93A`00000068,那么就可以使用WinDbg在相应位置加入段描述符


接着使用!process 0 0来获得test.exe进程的cr3,这个值会在页机制中讲解,在我的电脑这个值是0x02940240。

随后就在控制台出输入

当触发int 3中断的时候,查看WinDbg中的寄存器,可以看到相应的值都改成了指定的内容

b.任务门

当段描述符的S域为0且type为0101(0x5)的时候,此时的段描述符表示的就是任务门段描述符。

任务门描述符提供了对任务的间接的、受保护的引用,段描述符格式如下图。它可以放置在GDT、LDT或IDT中。任务门描述符中的TSS段选择器字段指向GDT中的TSS描述符。

任务门的运行流程如下图所示,首先从LDT或IDT表中拿到任务门描述符中的段选择子,该段选择子指向GDT表中的TSS段描述符,随后的操作就和任务段说的一样了。

要进行相应实验,只需要在IDT表中将任务门段描述符0x0000E500`00480000加入,随后在程序中通过int指令调用即可,这里就不再演示。

七.总结

Intel提供的段机制可以有效的实现了权限的控制,运行在ring3的程序想要访问或使用ring0的内容需要通过提供的各类门来完成。

八.参考资料

  • 《软件调试》(第二版)卷一:硬件基础

  • 《英特尔白皮书3-1》


【公告】欢迎大家踊跃尝试高研班11月试题,挑战自己的极限!

最后于 2022-1-6 18:52 被1900编辑 ,原因:
收藏
点赞1
打赏
分享
最新回复 (3)
雪    币: 286
活跃值: 活跃值 (200)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
PengLaiDoll 活跃值 2022-1-6 18:31
2
0
作者大大,您好!来自小萌新的疑问:您在第六段第1部分的权限检查的解释中提到两次一致性代码段,但感觉前后有些矛盾,也可能是我个人理解有点问题,恳请您解释一下,谢谢!
雪    币: 8950
活跃值: 活跃值 (9637)
能力值: ( LV13,RANK:400 )
在线值:
发帖
回帖
粉丝
1900 活跃值 2022-1-6 20:03
3
0
PengLaiDoll 作者大大,您好!来自小萌新的疑问:您在第六段第1部分的权限检查的解释中提到两次一致性代码段,但感觉前后有些矛盾,也可能是我个人理解有点问题,恳请您解释一下,谢谢!
没错的,一致代码段可以ring3权限jmp far DLP=0权限的段描述符,非一致代码段不行
雪    币: 286
活跃值: 活跃值 (200)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
PengLaiDoll 活跃值 2022-1-7 10:47
4
0
1900 没错的,一致代码段可以ring3权限jmp far DLP=0权限的段描述符,非一致代码段不行
哦哦!是我个人理解错了,现在明白了,谢谢您!
游客
登录 | 注册 方可回帖
返回