段的机制
段寄存器
段寄存器结构
段寄存器:ES,CS,SS,DS,FS,GS,LDTR,TR共八个
mov eax,ds:[0x00123456]
上面汇编指令的效果为ds.base+0x00123456
常见的寄存器是32位寄存器,比如eax,edx.
但是段寄存器却是96位寄存器:
有16位是可见的,还有80位是不可见的
struct SegKent
{
WORD Selector; //16位Selecter选择子
WORD Attributes;//16位Attribute属性
DWORD Base; //32位Base段基址
DWORD Limit; //32位Limit长度
}
用MOV指令读写段 寄存器(LDTR和TR除外)
mov ax,es //读段寄存器
mov ds,ax //写段寄存器(写96位)
段寄存器读是只能读出来可见的16位的,所以我们只能用一个16位寄存器去接收。但是写寄存器会写96位。
练习:
1).段寄存器只能看见16位,如何证明有96位
mov eax,es:[0x123] 如果es是16位那es+0x123的值明显跟eax值不相等
2).写段寄存器的时候,只给了16位,剩下的80位填什么?
段寄存器属性探测
证明Attrubute,Base,Limit的存在
上图红色的值可能会发生一些变化。
//探测Attribute的存在性,程序执行无错:ss可读,ds可写
int var=0;
__asm
{
mov ax,ss//cs不行,cs是可读可执行但不可写
mov ds,ax
mov dword ptr ds:[var],eax
}
//探测Base的存在性,如果两次var结果相同,则说明fs基址的存在
int var=0;
__asm
{
mov ax,fs//cs不行,cs是可读可执行但不可写
mov gs,ax
mov eax,gs:[0]//一般0地址不能读写,而这里却可以,侧面说明是有基址存在的。
//相当于
//mov edx,dword ptr ds:[0x7FFDF000] //这个地址是可以访问的
mov dword ptr ds:[var],eax
}
//探测limit的存在性,ds寄存器当然可以访问到因为ds的limit长度是0xFFFFFFFF,但是fs就不行了,fs的长度是0X7FFDF000+0xFFF
int var=0;
__asm
{
mov ax,fs//cs不行,cs是可读可执行但不可写
mov gs,ax
mov eax,dword ptr gs:[0x1000]//0x7FFDF000+0x1000访问的地址相当于下面的,但DS的Limit是0xFFFFFFFF
//mov eax,dword ptr ds:[0x7FFDF000+0X1000]
mov dword ptr ds:[var],eax
}
段描述符与段选择子
GDT 全局描述符表
gdtr 寄存器 32位存储GDT表的开始位置gdtr包含另外一个寄存器
gdtl,这个寄存器保存全局描述符表有多大
段描述符
GDT是一张表,这正表其中存储的就是段描述符
每一个都是一个段描述符,并且每一个段描述符的大小都是8个字节
段描述符的结构:
对应关系
高32以后简称高四(高位四字节)
低32以后简称低四(低位四字节)
dq 是8个字节数据一起显示,高位在前低位在后
当 mov ds,ax 时,我们明明只赋值了16位数据,但是ds寄存器96的值却都有值甚至都发生了变化,这是为什么呢?
因为CPU会根据ax的值去在上面的段描述符中查找对应的段描述符,并将段描述符的8个字节值分别赋值给ds对应的位.
那么根据什么查找呢?
段选择子
段选择子只有16位,指向了定义该段的描述符,去掉低位3位才是索引值.根据索引值找到对应的GDT或者LDT
修改段寄存器
以下指令都可以修改段寄存器
LES
LSS
LDS
LFS
LGS
CS段寄存器不能通过上述的指令进行修改,CS为代码段,CS的改变会引起EIP的改变 ,要改CS,必须要保证CS与EIP一起改.
char buffer[6];
__asm
{
les ecx,fword ptr ds:[buffer] //高2个字节给es,低四个字节给ecx
}
注意:RPL<=DPL(在数值上)
实现代码:
int main()
{
char buffer[6]={0x3,0x2,0x1,0x1,0x1b,0x00};
DWORD dwECX;
WORD wES;
__asm
{
les ecx,fword ptr ds:[buffer]
mov dwECX,ecx
mov ax,es
mov wES,ax
}
printf("ecx:%x es:%x",dwECX,wES);
return 0;
}
LDT 局部描述符表
段描述符属性
这个结构体一定要记牢,段描述符的结构,用这64位去填充80位的段寄存器(外加16位已知的段选择子)将段寄存器填满
P位 高四15
P = 1 段描述符有效
P = 0 段描述符无效
G位 高四23
又称段粒度
G = 0 Limit的单位是1(byte)
G = 1 Limit的单位是4K(Page)
S位 高四12
S = 0 段描述符属于系统段
S = 1 段描述符属于代码段或者数据段
D/B位 高四[22]
1.对CS段(代码段)的影响
当D位==1是,段描述符所描述的段被加载的时候,所采用的32位寻址方式
D = 1采用32位寻址方式
D = 0采用16位寻址方式
2.对SS段(栈段,仍属于数据段)的影响
D = 1 隐式堆栈访问指令(如:PUSH POP CALL),使用32位堆栈指针寄存器ESP
D = 0 隐式堆栈访问指令(如:PUSH POP CALL),使用16位堆栈指针寄存器SP
3.对向下拓展的数据段影响
这个属性跟下面的TYPE域有关,建议先看TYPE,TYPE中如果代表的是数据段那么10位(E);
当E=0时,代表向上扩展,也就是FS.Base+Limit是段有效区域
反之 当E=1时,代表向下扩展,这时,下图红色区域变为有效,并且有效大小跟D位的值有关.
TYPE域 高四[8,11]
在说TYPE域之前,先了解一个知识,当我们读取一个段描述符时,先判断P位是否为1(P=1段有效),S位=1时位代码段或者数据段,所以当DPL=00时,高[12,16]也就是高四第五个字节的数值为1001也就是9 如果DPL=11 这时第五个字节数值为F,所以我们只需要判断下面数据中高四第五个字节为9或者F的就可以判断该段表示数据段或者代码段
TYPE[11] = 0 即TYPE的最高位为0代表描述符描述的是数据段
TYPE[11] = 1 即TYPE的最高位为1代表描述符描述的是代码段
得
高四[6] > 8 表示代码段
高四[6] < 8 表示数据段
当表示数据段时
上面的
代表该段是否被访问过
W = 1代表是否有写权限
E = 1代表是向下拓展,E=0代表向上拓展
当表示代码段时
A = 1表示是否被访问过
C = 1表示该段是一致性代码段
R = 1表示可读
当表示系统段时
系统要看TYPE四位16种组合 16种情况
练习:
其中下面第五个段索引所指的段描述符是指向系统段属性是32-BIT TSS(Busy)
段描述符结构字段与段寄存器对应关系
WORD Selector; //16位 段选择子 已经确认
WORD Atrribute; //16位 属性 高四字节中[8,23]位
DWORD Base; //32位 低四[16,32]+高四[0,7]+[25,32]
DWORD Limit; //32位低四[0.15],+高四[16,19]虽然加起来只有20位,但是G位保存着limit的单位,如果单位是4k也就是2^12,这样长度就够了并且4k说的是0x1000 但由于0x1000是从0开始数,所以如果G位是1,就在limit的值后面补上FFF
段权限检查
段在加载之前,会有一系列的检查.
mov es,ax
当调用这样的语句时,并不是ax为任何段选择子时,都会将对应的段描述符内容填充到段寄存器ES中,而是在进行权限检查之后,才会将值复制进来
CPL
Current Privilege Level:当前特权级
CS和SS中存储的段选择子后2位
DPL 高四[13,14]
Descriptor Privilege Level: 描述符特权级别
DPL存储在段描述符中,规定了访问该段所需要的特权级别是什么.
举例:
MOV DS,AX
如果AX指向的段DPL = 0 但当前程序的CPL =3 这行指令是不会执行成功的
RPL
Request Privilege Level: 请求特权描述符
RPL是针对段选择子而言的,每个段的选择子都有自己的RPL,也就是说你随便写个WORD类型的数字 都可以作为段选择子(不论有效与否),而这个段选择子的后两位保存的就是RPL
举例说明:
mov ax,0008 与 mov ax,000B
mov ds,ax mov ds,ax
RPL==0 RPL==1
这两个段选择子的索引都是相同的==1,但是RPL不同
数据段的权限检查
举例证明:
RPL<=DPL 的原因是没法用3环的权限去执行一个需要大于RPL的DPL权限的段
代码的跨段执行
CPU并没有直接提供修改CS段寄存器的指令,因为直接修改代码段寄存器CS的值会导致EIP的改变,从而使得程序变得不可控,所以要同时修改CS和EIP:
jmp FAR
call FAR
RETF
INT
IRETED
这些指令都能同时修改CS和EIP的值。
举例:
JMP 0X20:0X004183D7
CPU 会怎么执行这段代码呢?
<1>拆分段选择子
0x20 拆分 0010 0000
TL= 0
GPL = 0
INDEX = 4
<2>查表得到段描述符
并且系统会根据索引判断对应的段描述符所描述的段属性是不是代码段(JMP到的是一个代码段,修改的也是CS段寄存器的值)如果不是代码段会执行错误。
<3>权限检查
补充知识:
一致代码段与非一致代码段
所处位置:TYPE的C位
一致代码段:通俗的讲,一致代码段就是系统用来共享、提供给低特权级的程序使用调用的代码。
非一致代码段:为了避免被低特权级程序访问而被系统保护起来的代码。
一致代码段限制
特权级高的程序不允许访问特权级低的数据,即核心态程序不能访问用户态数据。
特权级低的程序可以访问特权级高的程序,但是特权级不会因此而改变。
非一致代码段限制
只允许同级之间访问
不允许不同级之间访问,核心态不能访问用户态,用户态也不能访问核心态
所以
如果是非一致代码段,要求:CPL==DPL并且RPL<=DPL
如果是一致代码段,要求CPL>=DPL
权限检查不会对CPL和DPL做任何修改。
<4>加载段描述符
通过上面3步的检验后,CPU将段描述符加载到CS段寄存器中。
<5>代码执行
CPU将CS.Base + Offset的值写入EIP然后执行CS:EIP处的代码,段间跳转结束
代码的跨段执行实现
避免过于麻烦,我们拷贝一份现成的段描述符
到一个未被填充的区域,最好该段DPL=3
CF9B明显可以知道这个被拷贝的是一个代码段或者数据段(9)又根据B得知是是一个非一致代码段 并且DPL==3
跳转向非一致代码段
权限检查条件:CPL==DPL并且RPL<=DPL 并且索引值是19(因为copy到的位置段索引是19)
成功跳转
JMP FAR 0x9B:0x401324
9B拆开 1001011 RPL=3(满足)
修改汇编指令,注意此时EIP和CS的值
F8之后的CS和EIP
这时我们已经跳转到段选择子未0x9B,偏移为401324的位置了
失败的跳转
如果上面拷贝的不是00cffb00-0000ffff(DPL=3)而是00cf9b00-0000ffff(DPL=0)等不满足权限检查的,都会直接跑进异常中
9B处的描述符:
跑进异常
跳转向一致性代码段
权限判断条件:CPL>=DPL
我们知道一致代码段的标志位在Type中,也就是说代码段(TYPE[4]==1)并且一致性(TYPE[3]==1) 也就是说只要段描述符第六位数字大于0xC 就说明是一致性代码段
所以我们将上面执行失败的00cf9b00`0000ffff段描述符中的cf9b替换为Cf9c(DPL==0 并且属于一致性代码段)
为了区分,我们将该段描述符放在了索引值为21的地方
成功跳转
索引值为21
21拆分:10101
RPL=3吧 连在一起就是10101011 = AB
JMP FAR 0xAB:0x401324
成功跳转到AB段选择子处
短调用与长调用
短调用
短调用的Call是指不改变CS寄存器的。
长调用
长调用,就是指跨段的跳转 底下的EIP是被废弃的,程序要跳转到哪里执行并不是EIP说了算的,CPU会根据CS段选择子的值找到一个调用门(必须是),然后进行执行
长调用并提权
假设要从一个ring3的程序跳到ring0的程序,堆栈是会发生变化的
会从之前3环栈切换到0环栈,并在0环栈中存储之前的栈段寄存器的段选择子和ESP以及CS,返回地址等
总结
1)跨段调用时,一旦有权限切换,就会切换堆栈.
2)CS的权限一旦改变,SS的权限也要随着改变,CS与SS的等级必须一样.
3)JMP FAR 只能跳转到同级非一致代码段,但CALL FAR可以通过调用门提权,提升CPL的权限
CS 会通过调用门来赋值,那更改的SS和ESP又是怎么来的呢? 参见以后的TSS段
调用门
Windows并没有使用调用门,但是使用了中断门
调用门的执行流程
指令格式:CALL CS:EIP(EIP是废弃的)
执行步骤:
1)根据CS的值 查GDT表,找到对应的段描述符,这个描述符对象是一个调用门
2)拿到在调用门描述符中存储着另一个代码段的选择子
3)选择子指向的段,段.Base+偏移地址 就是真正要执行的地址
门描述符
之前我们学习过,段描述符,什么段都有,那么门描述符又是什么呢?
根据下面的结构,门描述符首先要是系统段描述符(S==0),其次Type中的(Type位的四个字节是:1100)
门描述符中又存储了一个代码段的选择子低四[16,31]
并且存储了偏移低四[0,15]+高四[16,31]
真正执行的代码位置:段选择子描述的段基址+偏移 = 实际要执行的代码位置
构造一个门描述符(无参,提权)
P=1(有效) DPL=11(三环) S=0(系统段) TYPE == 1100(代表调用门)
所以段描述符第5位=E
第6位=C
0000EC00`001B0000 //1B是不想提权,这是一个3环的代码段(RPL==3)
0000EC00`00080000 //8就是0环的代码段(RPL==0)
将上面的调用门描述符写入GDT表中
注意编写实现代码之前 将随机基址去掉,建议使用release版本,因为debug函数编写过多基址会变
拿到这个需要跳回来执行的函数基址
拿到基址后 将调用门的偏移修改一下:
0040EC00`00081000
实现代码:
注意 输出上边那个函数地址的printf函数最好是要的,因为如果不使用Getnu这个函数401000的函数地址就会优化给main函数了
#include<stdio.h>
#include<windows.h>
WORD w1SS,w2SS;
WORD w1CS,w2CS;
void __declspec(naked) Getnu() //地址0x401000 设置了固定基址
{
__asm
{
mov w2SS,ss
mov w2CS,cs
retf
}
}
int main()
{
char buffer[6];
*(DWORD*)&buffer[0] = 0x12345678;
*(WORD*)&buffer[4] = 0x4B;
__asm
{
mov w1SS,ss
mov w1CS,cs
call fword ptr[buffer]
}
printf("W1S:%x,W2S:%x,W1C:%x,W2C:%x\n",w1SS,w2SS,w1CS,w2CS);
printf("%p\n",Getnu);
getchar();
return 0;
}
运行到int 3中断下来会看到寄存器的内容
运行到第二个int 3时
寄存器内容是:
返回之后,寄存器的值又会会到ring3的状态值
调用门(含参数)
先说一下两个指令
操作gdt表的(这两个指令都不需要内核权限)
BYTE GDT[6] ={0};
sgdt GDT //读取gdt内容到GDT中
lgdt GDT //将GDT内容加载到gdt表中
构造调用门
调用门结构中,高四[0,4]位代表的是参数位,设置为0011即可代表传参12字节数(四字节为单位)
得调用门描述符:
0040ec03`00081000
同样设置到上一个GDT位置处
实现代码:
#include<stdio.h>
#include<windows.h>
DWORD x;
DWORD y;
DWORD z;
void __declspec(naked) Getnu() //地址0x401000 设置了固定基址
{
__asm
{
pushad
pushfd
mov eax,[esp+0x24+0x8+0x8]
mov dword ptr ds:[x],eax
mov eax,[esp+0x24+0x8+0x4]
mov dword ptr ds:[y],eax
mov eax,[esp+0x24+0x8+0x0]
mov dword ptr ds:[z],eax
popfd
popad
retf 0xC
}
}
void PrintRegister()
{
printf("%x %x %x\n",x,y,z);
}
int main()
{
char buffer[6];
*(DWORD*)&buffer[0] = 0x12345678;
*(WORD*)&buffer[4] = 0x4B;
__asm
{
int 3
push 1
push 2
push 3
call fword ptr[buffer]
}
PrintRegister();
printf("%p\n",Getnu);
getchar();
return 0;
}
值得注意的一点是:
刚进Getnu函数时,新栈空间是这样的:
也就是说,当系统发现程序提权到ring 0 进行换栈后,会先保存之前的SS,ESP 然后将参数压栈,最后保存cs,返回地址。
运行结果:
中断门
IDT
IDT 即中断描述符表,同GDT一样,IDT也是由乙烯类描述符组成的,每个描述符占8个字节。但要注意的时,IDT表中的第一个元素不是NULL。
IDT表可以包含3种门描述符:
任务门描述符
中断门描述符
陷阱门描述符
在windbg中查看IDT表的基址和长度:
r idtr
idtr = 8003f400
r idtl
idtl = 000007ff
实验:通过调用中断门代码来实现提权(读取高权限的地址)
构建中断描述符:
0040ee00`00081000 保证dpl == 11 以及type = 1110
并找个iat的位置填入
中断门实验代码:
#include<stdio.h>
#include<windows.h>
DWORD x;
void __declspec(naked) Getnu() //地址0x401000 设置了固定基址
{
__asm
{
int 3
pushad
pushfd
mov eax,[0x8003f00c] //mov eax,8003f00c 如果想要取立即数的值 需要dword pts ds:[立即数]
mov ebx,[eax]
mov x,ebx
popfd
popad
iretd
}
}
void PrintRegister()
{
printf("%x \n",x);
}
int main()
{
char buffer[6];
__asm
{
INT 0x20
}
PrintRegister();
printf("%p\n",Getnu);
getchar();
return 0;
}
实验成功结果:
陷阱门
陷阱门跟中断门几乎一样,结构体
构造一个陷阱门
0040ef00`00081000 保证dpl == 11 以及type = 1111
放在跟上次同样的位置
同样的代码,同样的结果,没什么问题:
中断门和陷阱门的区别
中断门执行时,会将IF位(EFLAG[9])清零,但是陷阱门并不会
8086中
IF可以屏蔽 可屏蔽中断请求INTR
如果外设有可屏蔽中断请求INTR,而此时CPU内IF=0,那么CPU不会响应中断
只有可屏蔽中断请求INTR和IF有关系,
内中断和不可屏蔽中断NMI,都不受IF的影响
ring3 默认打开IF == 1,不允许关闭(自己设置为0)
任务段
任务段 (TSS)
在上面调用门,中断门与陷阱门的学习中,我们知道一旦出现权限切换,程序就会进行堆栈切换,而且,由于CS的CPL发生改变,也会伴随着SS的切换
切换时,会有新的ESP和SS,那么这两个值是怎么来的呢?切换时又怎么知道该跳到哪个ESP和SS?
答案:TSS(Task-state segment),任务状态段
TSS是一块内存
所有的通用,段,标记,CR3寄存器等
TSS的作用
++Intel的设计思想:++
程序在运行的时候,一定会进行任务的切换(这是基于intel来说的),这里的任务对应到操作系统上来说就是指 线程
进行切换任务时,上下文环境都需要切换到一个新的地方。
++intel希望我们使用TSS进行任务切换++
++然而事实是,不仅windows没有这样去做,linux也没有这样去做。++
总结:TSS存在的意义是允许我们一次性替换一堆寄存器
CPU如何找到TSS? TR寄存器
TSS任务段本质也是一个段
TR寄存器 本身也是个段寄存器
段寄存器中的值(base,limit,attribute)都是从段描述符中再加载的.
TSS段描述符首先也是个段描述符
TSS也是存储在GDT中段描述符中的成员
TSS段描述符
属性:
S==0 系统段
TYPE = 9 (未加载的段描述符)
TYPE = B (已加载的段描述符)
TR寄存器读写
- 将TSS段描述符加载到TR寄存器
指令
LTR
说明:
用LTR指令去装载的话,仅仅是改变TR寄存器的值(96位),并没有真正改变TSS
LTR 只能在系统层使用
加载后TSS段描述符状态位会发生变化
- 读TR寄存器
指令:
STR
说明:如果用STR去读的话,只读了TR的16位可见部分 也就是选择子
实验
手动实现切换TSS
准备104字节大小的空间
准备104字节大小的空间并赋上正确的值,构造成TSS段描述符
DWORD iTss[0x68] = { //在我的函数中这块数组的地址固定是0x12fdcc
0x00000000,//link
0x00000000,//esp0
0x00000000,//ss0
0x00000000,//esp1
0x00000000,//ss1
0x00000000,//esp2
0x00000000,//ss2
(DWORD)iCr3,//cr3 //必须有
0x00401000,//eip //必须有
0x00000000,//eflags
0x00000000,//eax
0x00000000,//ecx
0x00000000,//edx
0x00000000,//ebx
(DWORD)stack,//esp
0x00000000,//ebp
0x00000000,//esi
0x00000000,//edi
0x00000023,//es
0x00000008,//cs(ring0) 0x0000001b(ring3)
0x00000010,//ss 0x00000023
0x00000023,//ds
0x00000030,//fs 0x0000003b
0x00000000,//gs
0x00000000,//ldt
0x20ac0000,//io权限位图
};
自制TSS段描述符
自制一份TSS段描述符
0000e912`fdcc0068
其中
base = 0x0012fdcc
limit = 0x00068
Type = 1001(9)未被加载
P=1,DPL=3 S = 0
将该段描述符放置空白区域,并得出段选择子
TR寄存器保存自制的
mov ax ,0x4b
ltr ax
但从上面的内容我们是知道的,只改变TR寄存器,TSS段并没有发生切换,我们需要jmp/call到 对应的TSS段段选择子上
char buffer[6] = {0, 0, 0, 0, 0x48, 0};
__asm {
call fword ptr [buffer]
}
call far 和 jmp far 的区别:
JMP FAR和CALL FAR访问任务段的区别 当使用JMP FAR来实现任务切换时,TSS结构体中的Previous Task Link的值在任务切换完成之后为0,CPU不会为其赋值;如果使用CALL FAR来实现任务切换,Previous Task Link的值在任务切换完成之后会CPU会将其填充为原来的TSS段选择子 当使用JMP FAR来实现任务切换时,EFLAGS寄存器中的NT位不变;当使用CALL FAR来实现任务切换时,EFLAGS寄存器中的NT位就会被置1(NT位会对iret指令产生影响 NT位如果为0,iret的值从堆栈中取(中断返回);如果NT位为1,会找TSS中的Previous Task Link进行返回)
任务门
思考:
为什么已经可以访问任务段了,那为什么还要任务门呢?
任务门:之前我们已经学过了IDT表中的调用门和中断门,任务门就是三个门中剩下的这个门
任务门的执行过程
前提:构造一个任务门描述符存储在IDT和一个TSS段描述符到GDT表中
1.INT N
2.查IDT表,找到中断门描述符
3.通过中断门描述符,查GDT表,找到任务段描述符
4.使用TSS段中的值修改寄存器
5.IRETD返回