中断门
windows没有使用调用门,但是使用了中断门:
1.系统调用(老的cpu,从3环到0环。新的cpu直接通过快速调用)
2.调试
IDT
IDT即中断描述符表,同GDT一样,IDT也是由一系列描述符组成的,每个描述符占8个字节。但要注意的是,IDT表中的第一个元素不是NULL。
使用windbg查看IDT表的基地址和长度。
r idtr
r idtl
dq 8003f400
IDT 表包含三种门描述符:
- 中断门描述符
- 任务门描述符
- 陷阱门描述符
中断门描述符
这里与调用门有个显著的区别是不能再传参了,而且高四字节的8到12位也与调用门不一样。
中间这个D,表示default,这里是1(32位)。
中断门执行流程
当执行int n时,就去IDT表寻找对应的描述符,这个n是几就找到IDT表对应的第n+1个(从0开始)。
获取到段描述符后检查权限,进行段权限检查(没有RPL,只检查CPL)。
权限检查通过后,获取新的段选择子与之对应的gtd表中的段描述符的base,再加上IDT表中的limit作为EIP去跳转。
实验
构造一个中断门。找一P位为0的位置,这样不会因为我们的修改而导致蓝屏。
这个位置使用int 32会被调用。
测试代码为:
#include "stdafx.h"
#include <windows.h>
DWORD dwValue;
_declspec(naked) void func() //0x401020
{
__asm
{
pushad
pushfd
mov eax,[0x8003F00C]
mov ebx,[eax]
mov dwValue,ebx
popfd
popad
iretd //16位用iret 64位是iretq
}
}
void printValue()
{
printf("%x",dwValue);
}
int main(int argc, char* argv[])
{
//中断门提权
_asm
{
int 32
}
printValue();
getchar();
return 0;
}
这里还是获取裸函数的地址,我这里是0x401020,那么构造的中断门就可以是0x0040EE00`00081020
kd> eq 8003f500 0040EE00`00081020
然后运行程序,能够成功读取地址,说明提权成功。
值得注意的是:返回的汇编代码这里已经变成了iretd,与调用门的retf有什么区别呢?实际上就是多压了一个eflag寄存器。
为了看这里压栈结构稍微修改下代码,把裸函数中的代码改为int 3。
_declspec(naked) void func() //401020
{
__asm
{
int 3
iretd
}
}
断点后可以看到堆栈顺序,依次是返回地址,cs,elf,esp,ss,应证了上面的图,比调用门多压栈eflag一个寄存器。
eflag寄存器的结构如下:
中断门在执行的时候会清空eflag的IF位,这与中断有关,具体在后面与陷阱门经行对比的时候再说。
这里有一个有意思的题目:在使用调用门返回时用iretd,在中断门返回时使用retf,保证正常返回不蓝屏。
思路比较简单,在返回前将堆栈设置成我们想要的结构就行了,这里如果有问题最好看着堆栈图一起看。
调用门用iretd返回(本来是retf)
- 将堆栈中储存的SS和ESP的值分别向高地址移动四字节
- 将EFLAG写到原来ESP的位置,也就是[ESP+0x8]那个位置。(pushfd)
#include "stdafx.h"
#include <windows.h>
BYTE GDT[6] = {0};
DWORD dwH2GValue;
void _declspec(naked) GetGdtRegister()
{
_asm
{
pushad
pushfd
mov eax,0x8003f00C
mov ebx,[eax]
mov dwH2GValue,ebx
sgdt GDT;
//iretd返回调用门
add esp,0x30
mov eax,[esp]
mov [esp+0x4],eax
mov eax,[esp-0x4]
mov [esp],eax
pushfd //sub 4
sub esp,0x2C
popfd
popad
//不要把操作堆栈的汇编代码写道这里,有些寄存器的值没恢复,堆栈会出问题。
iretd
//retf
}
}
void printRegister()
{
DWORD GDT_ADDR = *(PDWORD)(&GDT[2]);
WORD GDT_LIMIT = *(PDWORD)(&GDT[0]);
printf("%x %x %x\n",dwH2GValue,GDT_ADDR,GDT_LIMIT);
}
int main(int argc, char* argv[])
{
//GetGdtRegister();
char Buffer[6];
*(DWORD*)&Buffer[0] = 0x12345678; //eip随便填
*(WORD*)&Buffer[4] = 0x004B;
_asm
{
call fword ptr[Buffer] //call cs:eip
}
printRegister();
getchar();
return 0;
}
中断门用retf返回(本来是iretd)
- 将[ESP+0x8]写到EFLAG。
- 将堆栈中储存的SS和ESP的值分别向低地址移动四字节。
#include "stdafx.h"
#include <windows.h>
DWORD dwValue;
_declspec(naked) void func() //0x401020
{
__asm
{
pushad
pushfd
mov eax,[0x8003F00C]
mov ebx,[eax]
mov dwValue,ebx
add esp,0x2c
popfd
mov eax,[esp]
mov [esp-0x4],eax
mov eax,[esp+0x4]
mov [esp],eax
sub esp,0x30
popfd
popad
retf
//iretd //16位用iret 64位是iretq
}
}
void printValue()
{
printf("%x",dwValue);
}
int main(int argc, char* argv[])
{
//func();
//中断门提权
_asm
{
int 32
}
printValue();
getchar();
return 0;
}
我们写的裸函数其实已经保留了eflag寄存器(pushfd),中间只需要去掉eflag就行了,并不一定去popfd。
调用门和中断门的区别
1.调用门通过CALL FAR指令执行,RETF返回。中断门用INT指令执行,IRET或IRETD返回。
2.调用门查GDT表。中断门查IDT和GDT表。
3.CALL CS:EIP中CS是段选择子,由三部分组成。INT x指令中的x只是索引,中断门不检查RPL,只检查CPL。
4.调用门可以传参数。中断门不能传参数。
陷阱门
陷阱门结构
陷阱门与中断门几乎一样,段描述符中的第八位是不同的. 中断门为0 陷阱门为1。
如果按照16进制来说. 一个是E 一个是F
陷阱门的构造 以及代码调用与中断门一样. 而且参数也不能有。
陷阱门与中断门的不同
陷阱门与中断门唯一的不同就是 EFLAGS 位中的 IF位
中断门 -执行后 IF 设置为0(cli 清零 (不可被屏蔽))
陷阱门 -执行后 – IF不变
中断
CPU 必须支持中断,那么什么是中断呢?比如你挪动鼠标,正常情况下永远都会第一时间相应;敲击键盘,CPU也会第一时间响应,这就是中断带来的效果。假设没有中断,拖动鼠标或者敲一个数字可能要有几秒延迟电脑才反应过来,cpu并不会第一时间搭理你,用户体验会好吗?
中断分为可屏蔽中断和不可屏蔽中断。
中断是基于硬件的,鼠标,键盘是可屏蔽中断,电源属于不可屏蔽中断. 当我们拔掉电源之后,CPU并不是直接熄灭的,而是有电容的,此时不管你eflags的IF位是什么,都会执行 int 2中断,来进行一些收尾的动作。
中断是可以进行软件模拟的. 称为软中断. 也就是通过 int n 来进行模拟。 我们构造的中断门. 并且进行int n 模拟就是模拟了一次软中断。
实验
kd> eq 8003f500 0040ef00`00081030
然后还是执行与中断门一样的代码。
#include "stdafx.h"
#include <windows.h>
DWORD dwValue;
_declspec(naked) void func() //0x401030
{
__asm
{
pushad
pushfd
mov eax,[0x8003F00C]
mov ebx,[eax]
mov dwValue,ebx
popfd
popad
iretd //16位用iret 64位是iretq
}
}
void printValue()
{
printf("%x",dwValue);
}
int main(int argc, char* argv[])
{
//func();
//陷阱门提权
_asm
{
int 32
}
printValue();
getchar();
return 0;
}
成功打印了地址。
对比实验
陷阱门
写入一个陷阱门到IDT表中。
在执行前下一个断点,查看EFL的值。
执行后
可以看到if位没有发生改变。
中断门
写入一个中断门到IDT表中。
kd> eq 8003f500 0040ee00`00081030
执行前
在要去的函数中加一个int 3中断,观察efl寄存器。
if位已经清0。
任务段
在调用门、中断门与陷阱门中,一旦出现权限切换,那么就会有堆栈的切换。而且,由于CS的CPL发生改变,也导致了SS也必须要切换。切换时,会有新的ESP和SS(CS是由中断门或者调用门指定)这2个值从哪里来的呢?
这里就要引入TSS(Task-state segment ),任务状态段。
TSS是什么呢?TSS就是一块内存,大小为104字节,这个大小只能比104字节大,不能小于104字节,其中存的是一堆寄存器的值。结构图如下:
用结构体可以表示为:
typedef struct TSS {
DWORD link; // 保存前一个 TSS 段选择子,使用 call 指令切换寄存器的时候由CPU填写。
// 这 6 个值是固定不变的,用于提权,CPU 切换栈的时候用
DWORD esp0; // 保存 0 环栈指针
DWORD ss0; // 保存 0 环栈段选择子
DWORD esp1; // 保存 1 环栈指针
DWORD ss1; // 保存 1 环栈段选择子
DWORD esp2; // 保存 2 环栈指针
DWORD ss2; // 保存 2 环栈段选择子
// 下面这些都是用来做切换寄存器值用的,切换寄存器的时候由CPU自动填写。
DWORD cr3;
DWORD eip;
DWORD eflags;
DWORD eax;
DWORD ecx;
DWORD edx;
DWORD ebx;
DWORD esp;
DWORD ebp;
DWORD esi;
DWORD edi;
DWORD es;
DWORD cs;
DWORD ss;
DWORD ds;
DWORD fs;
DWORD gs;
DWORD ldt_selector;//ldt段选择子,用于换ldtr寄存器,一个TSS对应一个LDT表,就算你有100个任务,那么ldtr寄存器里面存储的也是当前ldt表,也就是任务切换时,LDT表会切换,但GDT表不会切换
// 这个暂时忽略
DWORD io_map;
} TSS;
图中下面部分ESP0,SS0即是0环的栈顶和SS,还有一环和二环的ESP和SS,虽然windows 没有使用一环和二环,但是我们自己实际上可以让他切换到一环或者二环。
切换CR3等于切换进程。
TSS的作用
intel的设计初衷是:切换任务(站在cpu的角度来说,操作系统中的线程可以称为任务)。cpu考虑到操作系统的线程在执行的时候会不停的切换,所以设计了TSS,让任务可以来回的切换。
当某一任务不执行时,就将该任务的寄存器存储到TSS这个结构中;当任务重新执行时,又将寄存器从TSS中取出,重新赋值给寄存器。
但是操作系统并没有采用该方法切换线程(windows linux都没有这样做)。
对TSS作用的理解应该仅限于存储寄存器,更任务(线程)切换没有关系。TSS的意义就在于可以同时换掉”一堆”寄存器。
如何找到TSS
这与tr(Task Register)寄存器有关。tr是一个段寄存器,96位。段寄存器的值是段描述符加载的,既然是段描述符,那么就离不开GDT表。这里就有三个概念:TSS段描述符,tr寄存器和TSS任务段,具体关系如下图所示。
这里有几点来区分三个概念之间的区别和关系:
- TSS段描述符存在于GDT表中。
- tr寄存器的值是从TSS段描述符中加载出来的。
- TSS任务段的base和limit是从tr寄存器中读取出来的。
整个加载流程是这样的:
在操作系统启动时,会从gdt表中找到TSS段描述符,将该描述符加载到tr寄存器中,确定了tr寄存器也就确定了当前TSS任务段在什么位置以及有多大。
TSS段描述符(TSS Descriptor)
TSS段描述符属于系统段描述符,所以高四字节的第12位为0。
高四字节的第9位是一个判断位,如果此时该TSS段描述符已经被加载到tr寄存器中,那么该位为1,16进制下为B。如果该TSS段描述符没有被加载到tr寄存器中,那么该位为0,16进制下为9。
tr寄存器读写
(1)将TSS段描述符加载到TR寄存器,使用指令:LTR
有几点需要注意:
- 用LTR指令去装载的话 仅仅是改变TR寄存器的值(96位) ,并没有真正改变TSS。
- LTR指令只能在系统层使用。(当前cpu权限必须是0环的)
- 加载后TSS段描述符会状态位会发生改变。(高四字节的第9位发生变化)
(2)读TR寄存器,使用指令:STR
如果用STR去读的话,只读了TR的16位也就是段选择子。这跟读取cs段寄存器一样,读取16位。
注意:上面两个指令只能修改tr寄存器,并不能直接修改TSS任务段。
前提回顾与对比
在调用门或者段间跳转的时候已经了解过call far和 jmp far。
当执行jmp 0x48:0x123456478时,如果0x48对应的段描述符是一个代码段,那么改变的是cs和EIP。
如果0x48对应的段描述符是一个TSS的段描述符(任务段),将会通过0x48对应的TSS段描述符修改tr的值,再用tr.base指向的TSS中的值修改当前寄存器的值。
这里call和jmp的大题实现是一样的,但在细节上是完全不一样的,下面通过两个实验,一个call一个jmp,来抓住具体细节。
实验
通过call和jmp去访问一个任务段,并能保证正常返回。
大体上思路为:
- 准备一个自己写的TSS段描述符,写入到gdt的一个空白的位置。
- 准备一个104字节的TSS,并附上正确的值。
- 修改tr寄存器(call far,jmp far)
那么第一步先准备一个TSS段描述符。这里还是把图搬过来方便看。
这里的G位是0,是以字节为单位的。如果忘记了G位的可以看之前的文章。
那么其他的位数含义也就不多说了,base的值是要更具我们自己要切换的TSS的地址(这里我自己的地址是0x0012fd78),limit这里没有特殊设置就设置成0x68,也就是十进制的104(limit可以更大一些也没关系)。那么构造的TSS段描述符可以是:
0x0000E912`fd780068
这点明确了之后就可以先贴代码
call far
#include "stdafx.h"
#include <Windows.h>
#include <stdlib.h>
char trs[6]={0};
char gdts[6]={0};
void __declspec(naked) test()
{
__asm
{
//int 3
iretd;
}
}
int main(int argc,char * argv[])
{
char stack[100]={0};
DWORD cr3=0;
printf("cr3:");
scanf("%X",&cr3);
DWORD tss[0x68]={
0x0,
0x0,
0x0,
0x0,
0x0,
0x0,
0x0,
cr3,
(DWORD)test,
0,
0,
0,
0,
0,
((DWORD)stack) - 100,
0,
0,
0,
0x23,
0x08,
0x10,
0x23,
0x30,
0,
0,
0x20ac0000
};
printf("%x",tss);
WORD rs=0;
_asm
{
sgdt gdts;
str ax;
mov rs,ax;
}
*(WORD*)&trs[4]=rs;
char buf[6]={0,0,0,0,0x48,0};
system("Pause");
__asm
{
call fword ptr buf;
}
printf("sucessfully\n");
system("Pause");
return 0;
}
cr3这个值是要当前进程跑起来之后才知道的,所以需要通过控制台来获取。
使用kd> !process 0 0
查看进程信息,获取cr3的值。
写入后再修改gdt表中的值,段选择子为0x48再gdt表中的索引是9。
成功返回
这个实验还可以拓展一下
call far拓展
在返回之前执行int 3,看一下切换的寄存器,顺便看下能不能正常返回。(就是上面代码注释的部分)
寄存器已经全部切换了。我们继续往下走,直接蓝屏了。
这是为什么呢?int 3究竟做了什么导致了蓝屏。
实际上这跟任务嵌套有关,也就是efl的第14位,NT位。
当该位为1时,系统会认为是任务嵌套,也就是call上来的,返回的时候使用iretd就回去找上一个任务链(如下图)。如果使用jmp,该位为0,操作系统认为是一个新的任务,不是任务嵌套。
而int 3会把NT位清0
所以当int 3断点后,系统认为这不是一个嵌套任务,无法通过上一次的任务链接找到对应的esp等寄存器,最终导致蓝屏。
那么既然int 3改变了NT位标识,我们可以在int 3之后再重新将NT位复原,这样也是可以正确返回的。
更改后的test代码如下:
void __declspec(naked) test()
{
__asm
{
int 3
push eax //保存原来的eax寄存器
pushfd
pop eax
or eax,0x4000
push eax
popfd
pop eax
iretd;
}
}
再次运行,这次能够成功返回了。
整个过程如下图所示:
jmp far
直接上代码
#include "stdafx.h"
#include <Windows.h>
#include <stdlib.h>
char trs[6]={0};
char gdts[6]={0};
void __declspec(naked) test()
{
__asm
{
//int 3
jmp fword ptr trs;
//iretd;
}
}
int main(int argc,char * argv[])
{
char stack[100]={0};
DWORD cr3=0;
printf("cr3:");
scanf("%X",&cr3);
DWORD tss[0x68]={
0x0,
0x0,
0x0,
0x0,
0x0,
0x0,
0x0,
cr3,
(DWORD)test,
0,
0,
0,
0,
0,
((DWORD)stack) - 100,
0,
0,
0,
0x23,
0x08,
0x10,
0x23,
0x30,
0,
0,
0x20ac0000
};
printf("%x",tss);
WORD rs=0;
_asm
{
sgdt gdts;
str ax;
mov rs,ax;
}
*(WORD*)&trs[4]=rs;
char buf[6]={0,0,0,0,0x48,0};
system("Pause");
__asm
{
jmp fword ptr buf;
}
printf("SUCESSFULLY\n");
system("Pause");
return 0;
}
由于是jmp,是跳到了一个新的任务段,那么上一次的任务段的tr,tr本身是96位,通过str取出最后的16位选择子,将这个选择子存储到一个全局变量中,方便返回的时候重新跳到一开始的TSS。
jmp far拓展
加上int 3,这对jmp是没有影响的。
void __declspec(naked) test()
{
__asm
{
//int 3
jmp fword ptr trs;
//iretd;
}
}
原因就是回跳的时候不依靠NT位,相当于是直接跳过去的,不会依赖上一次的任务链。
任务门
上面讲的call far,jmp far是两种可以访问任务段的方法,而任务门则是第三种方式可以访问任务段。
IDT表可以包含3种门描述符:
- 任务门描述符
- 中断门描述符
- 陷阱门描述符
IDT表,中断门和陷阱门之前已经说过了,任务门的结构如下:
值得注意的是第四字节的16到31位是一个新的段选择子,指向的是gdt表中对应的段描述符。其他保留位添0就行了。
可以构造的任务门描述符:0000E500`00480000
任务门的执行过程:
- 1.INT N指令来去IDT表中执行代码
- 2.查询IDT表找到任务门描述符
- 3.通过任务描述符表.查询GDT表.找到任务段描述符.
- 4.使用TSS段中的值修改寄存器
- 5.IRETD返回
执行过程与中断门陷阱门是有相似之处的,都是要跨两张表。
要完成任务门的实验,首先要更改idt表。这里选择idt表的第32个,那里没有被操作系统使用。
kd> eq 8003f500 0000E500`00480000
然后其他的和call far访问任务段过程差不多。
实验代码如下:
#include "stdafx.h"
#include <Windows.h>
#include <stdlib.h>
char trs[6]={0};
char gdts[6]={0};
void __declspec(naked) test()
{
__asm
{
//int 3
iretd;
}
}
int main(int argc,char * argv[])
{
char stack[100]={0};
DWORD cr3=0;
printf("cr3:");
scanf("%X",&cr3);
DWORD tss[0x68]={
0x0,
0x0,
0x0,
0x0,
0x0,
0x0,
0x0,
cr3,
(DWORD)test,
0,
0,
0,
0,
0,
((DWORD)stack) - 100,
0,
0,
0,
0x23,
0x08,
0x10,
0x23,
0x30,
0,
0,
0x20ac0000
};
printf("%x",tss);
WORD rs=0;
_asm
{
sgdt gdts;
str ax;
mov rs,ax;
}
*(WORD*)&trs[4]=rs;
char buf[6]={0,0,0,0,0x48,0};
system("Pause");
__asm
{
int 32;
}
printf("sucessfully\n");
system("Pause");
return 0;
}
运行后获取cr3的值,并修改gdt表。
kd> eq 8003f048 0000E912`fd780068
能够正常返回。
为什么任务段可以用call far访问还要有任务门
这也是设计思想的问题。
在idt表的第九个,也就是8号中断(int 8)。
这实际上是个任务门,再看对应的gdt表中描述符。
这里的TSS的base就是:0x8054af00。查看eip信息。
kd> dd 8054af00kd> uf 805404ce
那么八号中断是个什么作用呢,还是得看intel白皮书。
当产生一个异常后会被异常处理接管,进入异常处理里面去,但是异常处理函数在执行中还有可能再次产生异常,这时候就是int 8来接管,也就是双异常。
int 8是如何接管处理的呢?一旦进入8号中断,将会替换一堆寄存器,保证CPU 能跳到一个正确的地方去执行(除非那个地方也被破坏了),此时什么错误都无所谓了,收集信息后,蓝屏。
后记
下节进入页的机制。