一、分段机制
在 32 位保护模式下,段寄存器就不再直接保存段基址了,而是分为了 可见的 16 位段选择子 部分以及不可见的 80 位 高速缓存部分,对于需要访问的信息(基址、限长以及属性)都被保存在了不可见部分。这一部分的信息无法被人为的查看,由 CPU 自动加载。当段寄存器被修改的时候,CPU 会通过可见部分的段选择子从 GDT 或 LDT(windows没启用)中找到对应的段描述符,在进行权限检查后,将相关内容加载到告诉缓存部分。
二、段选择子
从下面的结构体可以知道,段选择子由 INDEX、TI 以及 RPL 组成。其中 RPL 全称为请求特权级别,表示当前使用什么样的权限去发出请求,TI 指定了当前使用过的是 GDT 还是 LDT,其中的的 INDEX 表示的是 GDT 或 LDT 的下标。通过 TI 和 IDNEX 可以获取到相应的段描述符。
typedef struct _SELECTOR
{
unsigned short index: 13; // index 存在于 GDT 或 LDT 中的下标
unsigned short TI : 1; // TI 当前查 GDT(0) 还是 LDT(1)
unsigned short RPL : 2; // RPL 请求权限级别
} SELECTOR, *PSELECTOR;
三、段描述符
GDT 或 LDT 中保存的就是段描述符,段描述符的结构体如下所示,总大小为 8 字节(64 位)
typedef struct _DESCRIPTOR
{
unsigned int limit1 :16; // 段限长 [0-15]
unsigned int base1 :16; // 段基址 [0-15]
unsigned int base2 : 8; // 段基址 [16-23]
unsigned int TYPE : 4; // 类型
unsigned int S : 1: // 系统段0 \ 用户段1
unsigned int DPL : 2; // 段特权级别
unsigned int P : 1; // 有效位
unsigned int limit2 : 4; // 段限长 [16-19]
unsigned int AVL : 1; // 保留
unsigned int L : 1; // 保留
unsigned int DB : 1; // 默认大小
unsigned int G : 1; // 限长的单位
unsigned int base3 : 8; // 段基址 [24-31]
} DESCRIPTOR, *PDESCRIPTOR;
- P: 表示当前的段描述符是否有效,如果有效则保存的是 1 否则是 0。被加载的段描述符必须有效。
- AVL:所有兼容 i386 的处理器必须提供 AVL 位供系统软件使用,通常我们不做考虑
- L:如果处理器处于 64 位模式,则提供了 L 位,表示当前使用的的是长模式。
- DPL:表示想要访问(切换)当前的段描述符,最少需要拥有什么样的权限。
- Base: 由 3 部分组成,一共描述了一个 32 位的地址,指明段的基址部分。
- Limit 和 G:Limit 由两个部分组成,共描述了 20 位的数据,表示段限长。当 G 位为 0 的时候,LImit 的单位是 1
Byte,如果 G 位为1,就表示 Limit 的单位是 4 KB,可能存在的两种限长分别是 000FFFFF 或 0xFFFFFFFF
若 G 为 1,假设 Limit 为 0xFFFFF,则限长为 0xFFFFF*0x1000 + 0xFFF -> 0xFFFFFFFF
若 G 为 0,假设 Limit 为 0xFFFFF,单位为 1Byte,则表示的是 0x000FFFFF; - D\B:用于设置默认操作数和地址的大小,对于栈来说,这一位叫做 B 位,当 B 位为 1 的时候,默认操作的栈以 4
字节为单位(push 1 实际 sub esp, 4),对于其它段来讲,着一位叫做 D 位,如果 D 为 1,则操作的数据以及默认的地址是
4 字节,mov eax, ds:[0x100]。以上两种情况都可以使用 OPCODE 前缀进行切换。
四、用户段的两种情况
- 如果 S 为 1 那么当前就是用户端,用户段可能存在两种情况,分别是代码段和数据段
- 如果 TYPE 的第一位是 1 那么当前就是代码段,看到的 TYPE 会大于 7
- C: 标识当前是否是一致代码段,如果是一致代码段,那么我们可以使用低权限访问高权限的内容,如果当前是非一致代码段,则要求访问者的权限和当前的权限必须保持一致。
- R: 当前对应的段是否是可读
- A: 表示当前是否被访问了
- 如果 TYPE 的第一位是 0 那么当前就是数据段,看到的 TYPE 会小于 8
- E: 表示当前是 extend-up 或者 enxtend-down 的,如果是向上扩展的
- W: 当前对应的段是否是可写的
- A: 表示当前是否被访问了
五、权限检查
- 使用指令(mov\lxx)去修改段寄存器的时候,首先需要满足以下条件
- 对于 CS 段寄存器,不能直接修改,只能依赖于门,只能加载代码段描述符
- 对于 SS 段寄存器,只能加载可读可写的段描述符
- 对于 DS 段寄存器,可以加载任何一个具有读写权限的段描述符
- 对于 FS 段寄存器,操作系统为其设置了固定的值,R3 保存 TEB,R0 保存 KPCR
- 对于 GS 寄存器,在 x64 使用, x86 保存
- 三种权限的含义
- CPL:当前代码的执行权限,特指 CS 段寄存器的最低两位(RPL)
- RPL:当前实际使用的权限,类似于指定文件的打开方式,应该 >= CPL
- DPL:段描述符特权级别,想要访问当前的段描述符,应该拥有的最低权限
- 在切换段选择子的时候,会进行以下权限的检查
- MAX(CPL, RPL) <= DPL,当前的最低权限数字上需要小于等于 DPL
六、系统描述符
- S位为0时是系统描述符,代表的是门
- 调用门:type为12 C
- 中断们:type为14 E
- 陷进门:type为15 F
- 任务门:type为5 5
- 门的结构
- 偏移:16位
- P位:1 一般为1,为有效的
- DPL:2 当前调用门的权限
- S位:1 系统段时始终为0
- type:4 调用门的类型
- 000:3 就是0
- 参数个数:4
- 段选择子:16 指向切换指定的段的权限
- 偏移:16位
#include <stdio.h>
#include <windows.h>
// 门结构体
typedef struct _CALLGATE {
unsigned int offset1 : 16;
unsigned int selector : 16;
unsigned int paramcount : 5;
unsigned int : 3;
unsigned int type : 4;
unsigned int S : 1;
unsigned int DPL : 2;
unsigned int P : 1;
unsigned int offset2 : 16;
} CALLGATE, *PCALLGATE;
// 在进行权限切换的过程中,栈也会随之改变
short r0_ss = 0;
int r0_esp = 0;
// 裸函数,只生成用户编写的代码,在其中操作高地址空间
_declspec(naked) void r0_function()
{
// 如果是有参数的调用门堆栈布局如下
// 如果有pushad 前32位是8个寄存器
// 如果有pushfd 然后4位是eflag
// 再后4位返回地址
// 再后4位cs
// 再后是参数1-参数2-参数3 等等参数
// 然后是esp
// 最后是ss
__asm
{
int 3
mov r0_ss, ss ; 获取 r0 权限的 ss
mov r0_esp, esp ; 获取 r0 权限的 esp
retf ; 调用门必须使用 retf 进行返回
//中断门的时候使用IRET或IRETD返回,因为中断比调用多push一个eflag
; 实现通过调用门读取到 gdt 中的第 3 项
; sgdt 可以获取 gdt 的地址
}
}
int main()
{
// 1. 根据当前函数的位置,构建一个调用门,用于进行跳转
// offset: 0045???? ????6c20 -> 需要跳转的函数偏移-为上方r0_function函数的地址
// selector: ???????? 0008???? (1 0 00) -> 需要切换的权限 段选择子 GDT表任意一个0环权限的下标索引即可
// P : 1 DPL:11 S:0
// DPL: ????E??? ???????? -> 访问当前调用门的权限
// TYPE: ?????C?? ???????? -> 访问当前调用门的类型
// 0045EC00`00086c20
// 查看GDT表,并找到全0的段描述符 使用构建的门进行填充
// !dq sgdt windbg指令查看sgdt表
// !eq (sgdt+8*9) 0045EC00`00086c20 改(sgdt+8*9)的内容,回车后,输入新内容
// 2. 构建一个远跳的地址,在远跳中,前面4个是偏移是没有意义的,给什么都可以
// 后面保存的是段选择子,可以用于找到调用门描述符,要求 RPL <= DPL
// 构建索引为9的段选择子
// - 0x004B -> 01001(INDEX) 0(TI) 11(RPL)
BYTE dest[] = { 0x00, 0x00, 0x00, 0x00, 0x4B, 0x00 };
// call far 是6个字节 所以上面需要构建前4个字节 后面选择子的数据
// 3. 使用 call far 语句,跳转到指定的调用门中,在进行R3到R0 的转化的
// 时候 CPU 会默认的将用户 SS ESP CS IP 保存到栈中
__asm push fs
__asm call fword ptr dest;
__asm pop fs
// 4. 输出 R0 下的 esp 和 ss,如果R0代码设置断点,那么 fs 会被改变,调
// 用函数会崩溃,需要在进入门之前先进行保存
printf("%04X: %08X\n", r0_ss, r0_esp);
system("pause");
return 0;
}