深耕保护模式(三)

 

中断门

windows没有使用调用门,但是使用了中断门:

1.系统调用(老的cpu,从3环到0环。新的cpu直接通过快速调用)
2.调试

IDT

IDT即中断描述符表,同GDT一样,IDT也是由一系列描述符组成的,每个描述符占8个字节。但要注意的是,IDT表中的第一个元素不是NULL。

使用windbg查看IDT表的基地址和长度。

r idtr
r idtl
dq 8003f400

IDT 表包含三种门描述符:

  1. 中断门描述符
  2. 任务门描述符
  3. 陷阱门描述符

中断门描述符

这里与调用门有个显著的区别是不能再传参了,而且高四字节的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)

  1. 将堆栈中储存的SS和ESP的值分别向高地址移动四字节
  2. 将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)

  1. 将[ESP+0x8]写到EFLAG。
  2. 将堆栈中储存的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任务段,具体关系如下图所示。

这里有几点来区分三个概念之间的区别和关系:

  1. TSS段描述符存在于GDT表中。
  2. tr寄存器的值是从TSS段描述符中加载出来的。
  3. 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去访问一个任务段,并能保证正常返回。

大体上思路为:

  1. 准备一个自己写的TSS段描述符,写入到gdt的一个空白的位置。
  2. 准备一个104字节的TSS,并附上正确的值。
  3. 修改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 能跳到一个正确的地方去执行(除非那个地方也被破坏了),此时什么错误都无所谓了,收集信息后,蓝屏。

 

后记

下节进入页的机制。

(完)