保护模式之“段”
段式管理内存
在8086时代,CPU为了能寻址超过16位地址能表示的最大空间(64KB),引入了段寄存器。
通过将内存空间划分为若干个段,然后采用段基地址+段内偏移的方式访问内存
目前CPU的段机制提供了一种手段可以将系统的内存空间划分为一个个较小的受保护区域,其中每个区域称为一个段。
为了将逻辑地址转为线性地址,大概需要这样的汇编代码ds:[0xdeadbeef]
,其大致表示先从段寄存器中解析出段基址,然后与偏移组成线性地址。
为了深入理解这一过程我们慢慢开始。
段寄存器
有6个段寄存器,分别为: CS, DS, SS, ES, FS, GS
1 | CS : Code Segment |
要让程序访问段,段的段选择子(Segment Selectors)必须已加载到其中一个段寄存器中。因此,尽管一个系统可以定义数千个分段,但只有6个分段可以立即使用。在程序执行期间,通过将其段选择子加载到这些寄存器中,可以提供其他段。
段寄存器结构如下:
一个段寄存器一共有96位,其中的16位段选择子是可见的,其余的80位不可见的部分中包含着32位的基地址,32位的边界以及16位的段属性。当段寄存器加载16位的段选择子的时候,处理器还会从段描述符(segment descriptor)中加载不可见的80位的内容。
也就是说,我们只需要修改16位的段选择子,CPU会自动根据段选择子中的信息补充完整剩下的内容。
段选择子(Segment Selectors)
段选择子是一个段的16位标识符。它不是直接指向段,而是指向定义段的段描述符(segment descriptor)
如图所示,段选择子包括Index 、TI(table indicator)flag 、RPL
。
1 | Index : 描述符索引值 处理器将索引值乘以8在加上GDT或者LDT的基地址(在GDTR或LDTR寄存器中),就是要段描述符(segment descriptor)的地址 |
段描述符表(Segment Descriptor Tables)
处理器要根据段选择子找到需要的段描述符,以此填充段寄存器隐藏的80位的内容,此时的段选择子就代表了一个段。
但是,在一个任务中通常会同时存在着很多个任务,每个任务会涉及多个段,每个段都需要一个段描述符,因此系统中会有很多段描述符,为了方便管理,系统用线性表来存放段描述符。
IA-32处理器有2种描述符表:全局描述符表(GDT),局部描述符表(LDT)。
全局描述符表(GDT)
一个系统只有一个GDT,GDT的基地址和表界限必须加载到GDTR寄存器中。可以通过LGDT指令将GDT的信息装入此寄存器,从此以后,CPU就根据此寄存器中的内容来访问GDT了。
GDTR的结构如下:
GDT的基地址在保护模式下为32位(IA-32e模式下为64位),而表界限为16位
可以通过如下获取一个GDTR的值。
1 |
|
1 | .code |
1 | 0: kd> r gdtr |
经过与Windbg的对比发现完全正确。
局部描述符表(LDT)
与GDT不同的是,LDT在系统中可以存在多个,并且LDT不是全局可见的,它们只对引用它们的任务可见,每个任务最多可以拥有一个LDT。
另外,每一个LDT自身作为一个段存在,它们的段描述符被放在GDT中。
LDT只是一个可选的数据结构。
同样的,LDT也有其寄存器叫LDTR。LDTR全局只有一个。
GDTR的结构如下:
其中包括了16位的段选择子,32位或64位的基地址,表界限,和描述符属性。
LLDT 和 SLDT 指令分别加载和存储 LDTR 寄存器的段选择子部分。包含LDT的段必须在GDT中具有段描述符。当 LLDT 指令在 LDTR 中加载段选择子时:LDT 描述符中的基地址、表界限和描述符属性会自动加载到 LDTR 中。
段描述符(Segment Descriptors)
《Intel白皮书》p3097
段描述符是 GDT 或 LDT 中的一种数据结构,为处理器提供段的大小和位置,以及访问控制和状态信息。段描述符通常由编译器、链接器、加载器、操作系统或执行程序创建,但不包括应用程序。
所有类型段描述符的一般描述符格式:
其中高四字节是用上面那个图,低四字节用下面那个图
部分字段含义
名称 | 含义 |
---|---|
Base Address | 定义在4G字节线性地址空间中0字节段的位置。处理器将三个基地址字段组合在一起,形成一个 32 位值。 |
S flag | 指定段描述符是用于系统段(S = 0)还是代码或数据段(S = 1)。 |
DPL | 权限级别。权限级别从 0 到 3 不等,0 为最高权限级别 |
P flag | 有效指示。1为有效,0为无效 |
Segment limit field | 指定段的大小,其中一共2个这个字段,合并为20位的大小,其单位粒度由G字段决定 |
AVL | 可供系统软件使用 |
L flag | 用于描述IA-32e模式下的代码段。L=1:代码段包含64位代码 L=0:代码段包含兼容模型的代码 |
G flag | G=0:Segment limit字段单位为字节(最大值为0x000FFFFF)G=1:Segment limit字段单位为4KB(最大值为0xFFFFFFFF) |
D/B 字段:
对于代码段(CS),该位表示的是这个代码段默认的位数(Default Bit)。
1 | D=0:16位代码段 |
对于栈段(SS),该位称为B(Big)标志。
1 | D=1:隐式堆栈访问指令(如:PUSH POP CALL)使用32位栈指针寄存器ESP |
对于向下拓展的数据段,该位称为B(Big)标志,指定了段的上界。
1 | B=1:段的上界是0xFFFFFFFF(4GB) |
Type 字段
当S flag为1
此时段类型为代码或数据段,type为下图含义:
文字来说就是:
1 | 第11位为0:段描述符为数据段描述符 |
什么是一致、非一致代码段?
一致代码段:简单理解就是操作系统拿出来被共享的代码段,可以被低特权级的用户程序直接访问的代码段,这些代码段,通常是不去访问受保护的资源和某些类型异常处理。(比如:数学计算的函数库)
非一致代码段:为了避免低特权级的访问而被操作系统保护起来的系统代码。
当S flag为0
此时段类型为系统段其中CPU可识别:
- 本地描述符表(LDT)段描述符
- 任务状态段(TSS)描述符
- 调用门描述符
- 中断门描述符
- 陷阱门描述符
- 任务门描述符
这些描述符类型可分为两类:系统段描述符和门描述符。系统段描述符指向系统段(LDT和TSS段)。门描述符本身就是“门”,它保存指向代码段中的过程入口点(调用、中断和陷阱门)的指针,或者保存针对TSS(任务门)的段选择子
IA-32e模式下的系统描述符为16字节,而不是8字节。
IA-32e模式下的变化
在了解上述内容后,我们便可以快速了解x64模式下的段描述符结构。
图中展示了IA-32e模式下的CS段描述符的结构,其中可以发现取消了Base Addr与limit。这被称为段平坦化。
S位为0时即表示为系统段时,段描述符长度扩展为128位,1时为64位。
其中FS,GS段并没有被平坦化,FS指向32位程序的PEB,内核GS指向KPCR,用户GS指向PEB,他们的基址如下由寄存器存储:
1 | IA32_FS_BASE(下标0xC0000100) |
如下测试了x64下获取内核KRCP:
1 | 0: kd> rdmsr C0000101 |
这里为什么使用IA32_GS_BASE
来获取KPCR呢?是因为当代码发生从r3切换到r0时,GS寄存器的值也需要切换,将使用swapgs
命令将当前PEB放置到IA32_KERNEL_GS_BASE
中,然后等程序从r3返回时,再把GS寄存器的值切换回来。
实验
我们将从一个DS段寄存器和一个CS段寄存器入手,逐步分析这个段。
DS段寄存器
我们首先获得了段寄存器的段选择子的内容。
根据段选择子的格式将其展开:
1 | 0x2B = 00101011 |
那我们根据公式(Index * 8 + GDT基址)
,寻找段描述符:
1 | 1: kd> r gdtr |
其中ffffd001`fe7a1868
的值00cff300`0000ffff
就为段描述符。
在段描述中中低32位中的前16位与高32位中的前8位都为0,即BaseAddr为0
0x00cff300 -> 0000 0000 1100 1111 1111 0011 0000 0000
此时需要将上面的值从后往前看。
名称 | 比特位 | 数值 | 含义 |
---|---|---|---|
BaseAddr | 0~8 | 00000000 | 0 |
Type | 8~11 | 0011(0x3) | 可读可写已访问的向上拓展的数据段 |
S | 12 | 1 | 代码或数据段描述符 |
DPL | 13~14 | 11(0x3) | 特权级为3 |
P | 15 | 1 | 该段有效 |
Seg.Limit | 16~19 | 1111(0xF) | 边界 |
AVL | 20 | 0 | 系统用的 |
L | 21 | 0 | 非代码段无意义 |
D/B | 22 | 1 | 段上界为0xFFFFFFFF(4GB) |
G | 23 | 1 | 段边界单位为4KB |
CS段寄存器
我们首先获得了段寄存器的段选择子的内容。
根据段选择子的格式将其展开:
1 | 0x33 = 00110011 |
那我们根据公式(Index * 8 + GDT基址)
,寻找段描述符:
1 | 1: kd> dq ffffd001fe7a1840+6*8 |
其中ffffd001`fe7a1870
的值0020fb00`00000000
就为段描述符。
由于是运行在IA-32e模式下,所以段描述符属性有所改变。
我们直接关注段描述符的高32位。0x0020fb00 -> 0000 0000 0010 0000 1111 1011 0000 0000
此时需要将上面的值从后往前看。
名称 | 比特位 | 数值 | 含义 |
---|---|---|---|
Type | 8~11 | 1011(0xB) | 可读已访问的代码段 |
S | 12 | 1 | 代码或数据段描述符 |
DPL | 13~14 | 11(0x3) | 特权级为3 |
P | 15 | 1 | 该段有效 |
AVL | 20 | 0 | 系统用的 |
L | 21 | 1 | 代码段以64位模式执行 |
D/B | 22 | 0 | L为1的时候D/B必须为0 |
G | 23 | 0 | 段边界单位为字节 |
实验代码
1 |
|
1 | .code |
CS段寄存器的修改
我们可以使用直接加载指令,例如 MOV、POP、LDS、LES、LSS、LGS 和 LFS 指令。修改除了CS段寄存器以外的寄存器,但是不能显式的修改CS段寄存器。因为改变CS的同时也必须修改EIP/RIP
x86下
想要修改CS段寄存器需要通过以下的方法:
- 远跳转
- 调用门
- 中断门
- 陷阱门
- 任务段
- 任务门
远跳转与长调用
远跳转:
即jmp far(0xEA),该指令的格式是jmp far cs:eip,其中EIP是随后的4个字节,而cs则是EIP随后的2个字节。
也就是说对于硬编码EA DEADBEEF 3000
,其对应的指令是:jmp far 0x0030:0xDEADBEEF
所以在写代码复现时需要注意需要一个结构来构造远跳转或长调用
代码: 点击查看更多
1 |
|
因为我们保护模式的重点在于保护,所以这里强调一下怎么进行的权限检查,其余部分就是正常的段逻辑地址转线性地址。
在权限检查时:
如果是非一致代码段,此时要求CPL 数值上等于DPL并且RPL 数值上小于等于 DPL。即:
CPL <= DPL && RPL <= DPL
如果是一致代码段,此时要求CPL 数值上大于等于DPL。即:
CPL >= DPL
一致代码段与非一致代码段的判断在段描述符的Type字段中
长调用:
即call far(0x9A),它和长跳转的功能是一样的,只不过在使用call far跳转的时候,程序会将原先的CS中的段选择子和返回地址入栈。
小结
非一致代码段 (被保护起来的系统代码) 是禁止不同级别进行访问的。ring 3代码不能访问ring0的数据,同样,ring0的代码也不能访问ring3的数据。
一致代码段 (被系统映射的共享代码) ,低级别的程序可以在不提升CPL权限等级的情况下即可以访问权限高的数据。
直接对代码段进行JMP 或者 CALL的操作,无论目标是一致代码段还是非一致代码段,CPL都不会发生改变。如果要提升CPL的权限,只能通过”门”。
调用门
当段描述符的 S字段为0且Type字段为1100(0xC) 的时候,此时的段描述符就是一个调用门。
- 高32位的16-31位和低32位的0-15位组成了偏移地址
- 高32位的0-4位指定了此次调用是否有参数传递
- 低32位的16-31位变成了一个段选择子
由此可以知道,call far cs:eip的时候,eip其实无效,偏移地址是由调用门来决定的。而调用门之所以可以提权是因为在调用门中有段选择子,段选择子决定了调用完调用门以后CS段寄存器的段选择子。
其中提权之后,SS段寄存器权限也会被修改,所以不仅返回地址会被压入栈中,原SS段选择子、原esp、原CS段选择子、EIP都会被依次压入栈中。
所以此时调用call far cs:eip的过程如下:
- 根据CS段寄存器段选择子查表找到调用门
- 获得调用门中的段选择子的内容,作为装载到CS段寄存器中的段选择子
- 此时CS寄存器中的段选择子的Base加上调用门的偏移地址就是要执行的目的地址
- 调用结束后使用retf返回,将CS、SS寄存器段选择子、esp恢复
有参的情况下参数在原CS段寄存器选择子与原esp之间
中断门与陷阱门
中断门与陷阱门保存在IDT表中(还有任务门)
中断符描述表(IDT)
IDT(Interrupt Descriptor Table)即中断描述符表,其存储着中断与异常的处理程序。IDT同GDT一样,IDT也是由一系列描述符组成的,每个描述符占8个字节,但要注意的是,IDT表中的第一个元素不是NULL。GDT有GDTR寄存器保存GDT表的入口地址,IDT也有着IDTR寄存器保存它的入口地址,IDTL则存储着IDT表边界的偏移值。
中断门和陷阱门与调用门非常相似。处理器用来将程序执行传输到异常或中断处理程序代码段中的处理程序过程,这两个门不同于处理器处理EFLAGS寄存器中的IF标志的方式。
中断门和陷阱门段描述符S域为0且Type域分别是1110(0xE)和1111(0xF)
从上图可知,中断门与陷阱门无法传参。两个门低32位的16-31位中保存了段选择子,调用中断门以后将通过该段选择子在GDT或LDT表中查询相应的段描述符,将查询到的这个段描述符装载到CS寄存器中,低32位的0-15位和高32位的16-31位组成的偏移地址与CS段寄存器的基址组成了要执行的代码的地址。该过程如下图所示
那么如何调用中断门呢?int n
则是调用的方法,比如 int 3
首先会通过IDT表 (可以通过idtr寄存器中获取) 中根据 3 * 8
来计算出中断门描述符,然后根据上图找到相应的代码段,最后用 iretd
返回。
同样的,如果使用中断门进行提权,也必然会引起SS段寄存器的切换与ESP的切换
那么流程则为:
- 根据中断偏移,从IDT表中获取到中断描述符。
- 从 TSS 中获取SS段选择子和栈指针。在新堆栈上,处理器压入被中断过程的SS段选择子和栈指针。
- 处理器将 EFLAGS、CS 和 EIP 寄存器的当前状态保存在新堆栈上。如果异常导致错误代码被保存,则会将其推送到新堆栈上的 EIP 值之后。
不难发现,中断门修改了EFLAGS,其中只修改了EFLAGS中的一个字段,即IF字段。但当使用 iretd
返回时,EFLAGS又被恢复回去。
中断门执行时,会将IF标志位清零,但陷阱门不会。
中断门和陷阱门唯一的区别就是提权以后中断门的EFLAGS寄存器的IF位会被置0
IF是中断允许标志位,它用来控制CPU是否允许接收外部中断请求。若IF=1,能响应外部中断,IF=0屏蔽外部中断。这样的设计使得通过陷阱门进入的服务程序运行嵌套中断,而中断门进入的服务程序不允许嵌套中断的发生。
任务段
由上文可以发现,当CS段发生权限切换时,SS段与栈也会发生切换,那么新SS段与栈来自任务状态段(Task-state segment, TSS)
任务段同样的也有任务段描述符(TSS Descriptor),其中只被放置在GDT中。
任务段描述符的结构如图所示,当S为0且Type为1001、1011时的段描述符为任务段描述符。其与普通段描述符极为相似,这里只有B字段是特殊的。
Busy flag 表示任务是否繁忙。当B = 0为非活动状态,B = 1 时为繁忙状态,任务不递归。
同样的,也有那么一套方法可以定位到任务段描述符,与那些段寄存器一样,TSS段也有自己的寄存器TR
。
如图所示,TR寄存器也分为可见部分和不可见部分,其中的不可见部分的基址和边界就是用来说明TSS段在内存中的地址以及其大小,可以使用LTR和STR指令来实现TR寄存器的读写,但是这两条指令只能改变TR寄存器的值,无法改变TSS段中的内容且这两条指令只能在系统层使用。
接下来我们了解一下TSS的具体内容
如图所示,这就是32位下的TSS段的内容,其中SS2 SS1 SS0 ESP2 ESP1 ESP0
分别表示了2环、1环、0环的SS段选择子、栈指针。
T flag是用于调试的,T = 1当发生任务切换到该任务时,处理器会引发调试异常。
SSP——影子栈指针(Shadow Stack Pointer),这是一个用于对任务切换时溢出检测的工具。
那么如何触发任务切换呢?其实我们以及学习过了,就是用上面的方法即可。
最简单我们可以使用jmp far或者call far指令访问到一个TSS段描述符的时候,此时CPU将根据段描述符修改TR寄存器中的内容,接着在由TR寄存器的基址找到TSS段,再使用TSS段中保存的寄存器的值来替换相应的寄存器,而访问段描述符以后要执行的代码地址则是由指令的偏移地址决定的。
TSS段内容我们并无法通过r3的代码进行修改,只能使用windbg手动修改
任务门
当段描述符的S域为0且type为0101(0x5)的时候,此时的段描述符表示的就是任务门段描述符。
任务门描述符提供对任务的间接、受保护引用。它可以放在 GDT、LDT 或 IDT 中。任务门描述符中的 TSS 段选择子字段指向 GDT 中的 TSS 描述符。不使用该段选择器中的 RPL。
任务门的运行流程如下图所示,首先从LDT或IDT表中拿到任务门描述符中的段选择子,该段选择子指向GDT表中的TSS段描述符,随后的操作就和任务段一样了。
由于任务门也可以存在IDT表中,我们可以使用INT中断的方式来调用任务门。
x64下
x64下不提供任务门
调用门
根据上图可以看出,调用门描述符扩充到了128位低32位跟x86环境下是一模一样,不过不再支持传参。
中断门
上图是x64下的中断(陷阱)门的结构图,其中多了IST字段。
要想知道IST字段,我们必须关注一个变化非常大的东西——TSS段
在x64下TSS段不再提供任务切换的功能。
我们可以发现,之前存储的那么多寄存器,还有其他的东西都没有了。而是只剩下了RSP
与IST
。
IST,即中断栈表其中存储了上文中所说IST字段对应的完整栈指针。
RSP,即 栈指针其中存储了权限级为0-2的栈指针。
综上,中断门的IST即从TSS段中找到对应偏移的IST,为新的栈指针。
为了找到TSS段,我们需要关注TSS段描述符,其中TSS段描述符直接被扩展到了128位。
我们使用Windbg来查实践这一过程。
首先我们获取IDT表的信息:
1 | 0: kd> dq idtr |
根据返回信息,我们可以发现,中断门确实被扩展到了128位。
同时我们可以关注一下2号中断fffff800`1ae9b020 16808e03`00108700 00000000`fffff800
其中不难发现,IST为011(0x3)。
直接去看TSS段,其中TSS描述符存在GDT表0x40(第9个)偏移处。
根据前文的描述,TSS段应该在ffff8001ae9c000
处。
那么我们来看一下TSS段的具体内容来便于我们定位到IST。由于TSS段开头有4字节的0,我们直接在TSS段的地址+4来观察。
1 | 0: kd> dq fffff8001ae9c000+4 |
其中不难发现RSP指针与栈指针。我们要找的IST 3 也就是fffff800`1aecd000
。
我们使用解析IDT的方式来验证一下。
1 | 0: kd> !idt |
经过解析我们发现确实nt!KiNmiInterrupt
使用的栈地址为0xFFFFF8001AECD000
。
参考资料
- 标题: 保护模式之“段”
- 作者: moshui
- 创建于 : 2024-08-07 15:57:52
- 更新于 : 2024-08-16 16:47:31
- 链接: https://www.moshui.eu.org/2024/08/07/WinProtect-segment/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。