1319|0

6593

帖子

0

TA的资源

五彩晶圆(高级)

楼主
 

对内核的直接挂钩 [复制链接]

有时在开发中,会遇到这样一种情况,当非常需要对某些内核函数进行挂钩时,而常规基于PE的挂钩,往往达不到目的。在本文中将要探讨的,是怎样直接挂钩内核函数,另外,在示例中,还要演示在系统中显示为一个基本磁盘的可移动USB存储设备,并在其上创建及管理多个分区(因为这样或那样的原因,Windows既不允许,也不能识别可移动存储设备上的多个分区,所以我们要“欺骗”一下系统)。因为本文中的示例只用作演示目的,所以只对一个函数进行了挂钩,但可对文中阐述的方法进行扩展,以处理多个函数(例如,工程中可能需要直接挂钩好几个NDIS库中的函数)。再者,你应该清楚地认识到,本文是在讲述直接挂钩,而不是研究USB存储,所以,用作示例的问题当然还可有其他的方法来解决。


         我们的问题
         USB设备在系统中表示的方式,定义在STORAGE_DEVICE_DESCRIPTOR结构的RemovableMedia字段中,此结构通常会在USBSTOR.SYS响应IOCTL_STORAGE_QUERY_PROPERTY请求时返回。如果设备生产商想让此设备显示为一个基本磁盘,会在驱动程序中设置STORAGE_DEVICE_DESCRIPTOR 结构中RemovableMedia字段,并在响应IOCTL_STORAGE_QUERY_PROPERTY请求时返回FALSE。由此,设备在系统中就显示为一个基本磁盘,而DISK.SYS也不知道它实际上是在与硬盘,还是在与一个USB设备打交道。
         因此,如果我们挂钩USBSTOR.SYS中的IRP_MJ_DEVICE_CONTROL子程序,只需简单地修改IOCTL_STORAGE_QUERY_PROPERTY请求的返回值,就能在系统中把可移动磁盘显示为一个基本磁盘,这可通过以下的代码来完成:

typedef NTSTATUS (__stdcall*ProxyDispatch) (IN PDEVICE_OBJECT device,IN PIRP Irp);
ProxyDispatch realdispatcher;

//代理函数
NTSTATUS Dispatch(IN PDEVICE_OBJECT device,IN PIRP Irp)
{
    NTSTATUS status=0; ULONG a=0;PSTORAGE_PROPERTY_QUERY query;
    PSTORAGE_DEVICE_DESCRIPTOR descriptor;

    PIO_STACK_LOCATION loc= IoGetCurrentIrpStackLocation(Irp);

    if(loc->Parameters.DeviceIoControl.IoControlCode
                         ==IOCTL_STORAGE_QUERY_PROPERTY)
    {
        query=(PSTORAGE_PROPERTY_QUERY) Irp->AssociatedIrp.SystemBuffer;
        if(query->PropertyId==StorageDeviceProperty)
        {
            descriptor=(PSTORAGE_DEVICE_DESCRIPTOR) Irp->AssociatedIrp.SystemBuffer;
            status=realdispatcher(device,Irp);
            descriptor->RemovableMedia=FALSE;
            return status;
        }
    }
    return realdispatcher(device,Irp);
}

//代码中的其他地方……
realdispatcher=(ProxyDispatch) driver->MajorFunction[IRP_MJ_DEVICE_CONTROL];
driver->MajorFunction[IRP_MJ_DEVICE_CONTROL]=Dispatch;

         正如你所看到的,一个可移动USB设备能非常简单地在系统中显示为一个基本磁盘,然而,还有一点小小的“并发症”——只有当你在USB接口中插入一个设备时,系统才会加载USBSTOR.SYS,直到拔出设备后,才会卸载它,因此,我们不能预先对USBSTOR.SYS进行挂钩——必须先插入一个设备。如果我们在USBSTOR.SYS已经处理了IOCTL_STORAGE_QUERY_PROPERTY请求之后,才对它进行挂钩,那么为时已晚了。我们也不能插入一个设备,挂钩USBSTOR.SYS,拔掉它,接着再插入;当你拔出设备时,USBSTOR.SYS也卸载了,挂钩只会白费力气。所以,要对USBSTOR.SYS进行挂钩,最适当的时机是在当它准备创建设备对象时,一方面,我们知道USBSTOR.SYS已经加载了,另一方面,此时IOCTL_STORAGE_QUERY_PROPERTY请求还并未被处理。如果我们能设法捕捉到USBSTOR.SYS对IoCreateDevice()的调用,那么接下来的事情就简单多了——IoCreateDevice()接受一个指向新创建设备的DRIVER_OBJECT的指针作为参数,因此,我们就可在驱动程序的MajorFunction[IRP_MJ_DEVICE_CONTROL]中替换掉一个指针。

         为了达到上述目的,我们准备在IoCreateDevice()的可执行代码中插入一些指令,以便直接挂钩,也就是所谓的“通过覆盖的挂钩”。事实上,只有通过挂钩ntoskrnl.exe的导出索引,才能完成此项任务,但是,本文要讲述的是有关直接挂钩,所以,我们准备对IoCreateDevice()进行直接挂钩。然而,知己知彼,百战百胜,先了解一下相关的事情,总是有好处的,那就先来了解一下中断挂钩吧。


         处理中断与异常
         为响应硬件中断或异常,CPU保存了当前运行线程的执行上下文,并把执行流程转到一个特殊的内核模式程序中——称为“处理程序”。执行上下文保存的方式,依赖于中断模式的特权级;如果中断代码是非特权级的,处理器必须切换到特权堆栈和代码段,以便可以执行一个内核模式的处理程序,因此,CPU在转换执行流程到相应的处理程序之前,会把用户模式的SS、ESP、EFLAGS、CS寄存器值(所有入栈均按上述顺序),加上返回地址,压入到内核堆栈上;另外,如果是发生异常,CPU也可以在栈顶的返回地址上,再压入一个错误代码。如果中断代码是特权级的,堆栈切换就没有必要了,因此,在这种情况下,只有EFLAGS、CS和返回地址,也许可能还有错误代码被压入到堆栈中;此时,SS和ESP寄存器不会保存在堆栈上。
         每一个中断及异常都有着与之关联的号码,称为向量,共有256个中断向量。所有中断与异常处理程序的地址,都存储在一个称为“中断描述符表”(IDT)的内核模式的数据结构中。通常,在一台对称多处理(SMP)计算机上,每个处理器都有其自己的IDT,但在整个系统中,所有中断与异常处理程序的地址,对所有CPU而言,都是一样的。每个IDT入口点关联到它对应的向量,且在每个IDT中,都可以保存中断门描述符、陷阱门描述符、任务门描述符。中断与陷阱门描述符的二进制形式,可用如下的结构来表示:

struct GATE
{
WORD    OffsetLow;      
WORD    Selector;      
WORD    Unused:8;
WORD    Type:5;
WORD    DPL:2;
WORD    Present:1;
WORD    OffsetHigh;
} ;

         如上所示,中断与陷阱门描述符的二进制表示形式,与调用门描述符非常相似。而中断与陷阱门的不同之处,在于当中断或异常处理程序开始处理时,EFLAGS寄存器中IF标志的状态。如果中断或异常是通过一个中断门引发的,IF标志会被处理器自动清除;如果中断或异常是通过一个陷阱门引发的,则IF标志不会受到影响。在其他方面,中断与陷阱门是一样的——这也不足为奇,因为它们都是用同样的结构来描述的,但任务门描述符的二进制形式就不相同了。另外,因为性能的原因,在Windows NT中,所有的用户过程都运行于一个单任务的上下文中,所以在IDT中,还有一些任务门描述符,它们主要保留用于“异常的情况”,如系统崩溃;它们的任务是保证系统可以有足够长时间,在CPU重设自身之前,抛出一个蓝屏错误。

         现在,要来说一下异常了,IDT的头32个入口点负责与异常处理程序打交道(它们对特定向量的映射,已被Intel预先定义好了),异常在此可归类为陷阱(Trap)、错误(Fault)与异常终止(Abort)。异常终止类的异常不允许失败的任务继续执行下去,有关的一个典型例子就是机器检查异常(INT 0x12);而陷阱与错误则允许失败的任务在异常被处理之后,继续执行下去。陷阱与错误的不同之处,在于保存在堆栈上的返回地址不同;在错误类的异常情况下,这个地址指向导致异常的指令,也就是说,在异常处理程序返回控制之后,会试图执行前面失败的指令,有关的一个典型例子就是页面错误异常(INT 0xE);而在陷阱类的异常情况下,返回地址将指向紧跟在导致异常指令后的下一条指令,有关的典型例子如调试断点异常(INT 3)。

         一个调试异常(INT 1)就本身而言,是个非常有意思的异常——依据不同的异常原因,它可以被陷阱或错误异常抛出。通常,一个调试异常可被以下任一原因抛出:

Ø 执行时的断点
Ø 内存访问的断点
Ø IO端口访问的断点
Ø 一般侦测情况(会设置EFLAGS寄存器的TF标志,甚至于每条指令的执行,都可以抛出一个调试异常)
Ø 任务切换(此处与Windows的任务切换无关)
Ø INT 1指令

在1至4的情况中,INT 1是作为一个错误被抛出,而在其他情况中,它是作为一个陷阱被抛出,而一般可通过来自DR6寄存器的INT 1处理程序,来找出抛出异常的原因。一个调试异常能由多个原因产生,例如,设置了TF标志的执行断点,在这种情况下,执行断点比TF标志具有更高的优先级,因此,INT 1是作为一个错误抛出,而不是作为一个陷阱。

         那么,有了挂钩函数之后,上面这些东西都能做些什么呢?我们将要把目标函数开始处的头几个字节(8个字节就足够了),复制到从非分页池里分配的数组中,再挂钩INT 1与INT 3的处理程序,并写入一个0xCC操作码(其代表INT 3指令)至目标函数的开始处。这样,当目标函数准备执行它的第一条指令时,就会触发我们被代理过的INT 3处理程序,而我们INT 3处理程序开始执行时的堆栈布局,可用下面的结构来描述:

struct INTTERUPT_STACK
{
    ULONG InterruptReturnAddress;
    ULONG SavedCS;
    ULONG SavedFlags;
    ULONG FunctionReturnAddress;
    ULONG Argument;
};

         在堆栈顶部,CPU设置了一个帧,以用于响应一个INT 3指令,也就是一个INT 3处理程序应该返回控制,加上CS及EFLAGS寄存器标志的地址值;而目标函数应该返回控制的地址紧接其后;另外,函数参数的数组在堆栈上,正位于返回地址之下(所以从实践经验来说,把所有的参数当作ULONG,还是有道理的,这样我们就能
在需要时把它们转换成它们实际的类型)。在这一点上,我们就能做任何想做的事了——我们可以检查或修改函数参数、修改返回地址,也就是那些通常在挂钩函数之后可以做的事情。但对我们目前的任务来说,我们只对第一个参数感兴趣,也就是传递给IoCreateDevice()的PDRIVER_OBJECT。
         在被我们代理的INT 3处理程序返回之前,它将会把栈顶结构中的InterruptReturnAddress字段,修改为我们复制的带有指令的数组,并设置SaveFlags字段中的TF标志。我们的INT 3处理程序返回之后,保存在堆栈上的InterruptReturnAddress和SavedFlags字段,将会分别弹出至EIP与EFLAGS寄存器中。由此,执行流程将会从我们复制的指令数组处继续执行,而且,我们一旦修改了TF标志,它将会以单步模式继续下去,也就是说,在每条指令执行时,都会抛出INT 1。
         如果INT 1的抛出,是因为设置了TF标志,那它将会被当作一个陷阱来处理。因此,在数组中第一条指令执行之后,就会触发我们代理过的INT 1处理程序,而保存在堆栈上的EIP将会指向数组中的第二条指令。这样,从保存在栈顶的返回地址中,减去我们数组的地址,就可以得到执行过的指令大小,因此,在我们的INT 1处理程序返回前,它将会修改返回地址为目标函数起始地址(+)执行过的指令大小,并清除保存在堆栈上的EFLAGS中的TF标志。由此,执行流程将会从目标函数的第二条指令处开始继续,而我们的INT 1处理程序返回之后,TF标志也被清除了。换句话来说,目标函数将会继续执行下去,好像什么事也没有发生过一样。

         明显地,我们的方法似乎有点复杂了,让人难以理解,但实际上,我们只不过换了种方式来做而已。例如,我们可以复制目标函数起始处的一些指令到我们的数组中,并通过一个JMP指令覆盖掉目标函数的起始地址,这样,执行程序就能跳到我们的挂钩代码中来了。如果这样做的话,我们还要计算出目标函数内的偏移量,以确定我们的挂钩代码执行完后,从目标函数哪条指令开始恢复执行,所以,就还要算出指令大小。可是,说起来容易,做起来难啊,要像上述这样来做,将必须写一个完整的反汇编程序,而且,复杂的事还在后面,指令还可能涉及到与特定指令位置相关的内存,这种情况下,我们必须在重定位之后,调整指令的操作数。换句话来说,如果我们选择把函数开始处覆写为一个JMP,而不是INT 3指令,我们的程序将会非常大,95%的代码都要用于处理反汇编,而不是挂钩本身。因此,对INT 1与INT 3进行挂钩,是更加合情合理的事情,只要利用好INT 1与INT 3,想要CPU做什么,都不是问题了。

         现在,来看一下实际的工作。


         解决我们的问题
         针对我们特定的工程,可在DriverEntry()中进行所有与挂钩相关的工作,下面来看一下代码:

//这个子程序挂钩并恢复IDT,
//必须保证这个函数只运行在一个CPU上,
//因此我们在整个执行过程中屏蔽了中断以避免上下文切换。

void HookIDT()
{
    ULONG handler1,handler2,idtbase,tempidt,a;
    UCHAR idtr[8];

    //取得地址以便写入到IDT
    handler1=(ULONG)&replacementbuff[0];
    handler2=(ULONG)&replacementbuff[32];

    //分配临时的内存,这应该为我们的第一步,从此时开始,我们屏蔽了中断直到返回,
    //我们不想冒险调用任何不是我们自己编写的代码。
//(理论上来说,这个代码可能会在我们未知的情况下重新打开中断,那可就……)

    tempidt=(ULONG)ExAllocatePool(NonPagedPool,2048);

    _asm
    {
        cli

        sidt idtr
        lea ebx,idtr
        mov eax,dword ptr[ebx+2]
        mov idtbase,eax
    }

    //检查是否已挂钩IDT,
    //如果是,重新打开中断并返回。
    for(a=0;a     {
        if(idtbases[a]==idtbase)
        {
            _asm sti
            ExFreePool((void*)tempidt);
            KeSetEvent(&event,0,0);
            PsTerminateSystemThread(0);
        }
    }



    _asm
    {
        //现在,将要加载IDT的副本到IDTR寄存器。
        //以个人的经验来看,修改内存,再由IDTR寄存器进行指向,是不安全的。
        mov edi,tempidt
        mov esi,idtbase
        mov ecx,2048
        rep movs

        lea ebx,idtr
        mov eax,tempidt
        mov dword ptr[ebx+2],eax
        lidt idtr

        //现在,我们能安全地修改IDT了,准备好。
        mov ecx,idtbase

        //挂钩INT 1
        add ecx,8
        mov ebx,handler1

        mov word ptr[ecx],bx
        shr ebx,16
        mov word ptr[ecx+6],bx

        //挂钩INT 3
        add ecx,16
        mov ebx,handler2

        mov word ptr[ecx],bx
        shr ebx,16
        mov word ptr[ecx+6],bx

        //重新加载原始IDT
        lea ebx,idtr
        mov eax,idtbase
        mov dword ptr[ebx+2],eax
        lidt idtr
        sti
    }

    //添加我们刚才挂钩的IDT地址至已挂钩的IDT列表
    idtbases[IdtsHooked]=idtbase;
    IdtsHooked++;
    ExFreePool((void*)tempidt);
    KeSetEvent(&event,0,0);
    PsTerminateSystemThread(0);
}

NTSTATUS DriverEntry(IN PDRIVER_OBJECT driver,IN PUNICODE_STRING path)
{
    ULONG a;PUCHAR pool=0;
    UCHAR idtr[8];HANDLE threadhandle=0;

    //以机器码填充数组
    replacementbuff[0]=255;replacementbuff[1]=37;
    a=(long)&replacementbuff[6];
    memmove(&replacementbuff[2],&a,4);
    a=(long)&INT1Proxy;
    memmove(&replacementbuff[6],&a,4);

    replacementbuff[32]=255;replacementbuff[33]=37;
    a=(long)&replacementbuff[38];
    memmove(&replacementbuff[34],&a,4);
    a=(long)&BPXProxy;
    memmove(&replacementbuff[38],&a,4);

    //保存INT 1与INT 3处理程序的原始地址
    _asm
    {
        sidt idtr
        lea ebx,idtr

mov ecx,dword ptr[ebx+2]

        //保存INT1
       add ecx,8
        mov ebx,0
        mov bx,word ptr[ecx+6]
        shl ebx,16
        mov bx,word ptr[ecx]
        mov Int1RealHandler,ebx

        //保存INT3
        add ecx,16
        mov ebx,0
        mov bx,word ptr[ecx+6]
        shl ebx,16
        mov bx,word ptr[ecx]
        mov BPXRealHandler,ebx
    }

    //挂钩INT 1与INT 3的处理程序,必须在覆写NDIS之前完成。
    //把HookUnhookIDT()作为一个单独的线程运行,直到所有的IDT都进行了挂钩。
    KeInitializeEvent(&event,SynchronizationEvent,0);

    RtlZeroMemory(&idtbases[0],64);
    a=KeNumberProcessors[0];
    while(1)
    {
        PsCreateSystemThread(&threadhandle,
                (ACCESS_MASK) 0L,0,0,0,
                (PKSTART_ROUTINE)HookIDT,0);
        KeWaitForSingleObject(&event,
           Executive,KernelMode,0,0);
        if(IdtsHooked==a)
            break;
    }

    KeSetEvent(&event,0,0);

    //填充结构
    a=(ULONG)&IoCreateDevice;
    HookedFunctionDescriptor.RealCode=a;
    pool=ExAllocatePool(NonPagedPool,8);
    memmove(pool,a,8);
    HookedFunctionDescriptor.ProxyCode=(ULONG)pool;

    //现在进行覆写内存
    _asm
    {
        //在覆写之前去掉保护
        mov eax,cr0
        push eax
        and eax,0xfffeffff
        mov cr0,eax

        //插入断点(0xCC操作码)
        mov ebx,a
        mov al,0xcc
        mov byte ptr[ebx],al

        //恢复保护
        pop eax
        mov cr0,eax
    }

    return 0;
}

         让我们先来解释一下上述动作,一开始,我们用非直接跳转指令,填充了两个内存块——在挂钩IDT之后将会用到。但有些东西似乎从逻辑上解释不了,当试图写入函数地址本身到IDT中时,总会产生蓝屏,然而,如果写入带有非直接跳转指令的数组地址到IDT中时,也就是说,使执行流程跳到我们的函数中,就一切正常,真是让人不解啊。接下来,把INT 1与INT 3实际处理程序的地址保存在全局变量中,再对IDT进行挂钩,此处需格外小心。

         正如前面所说过的,在一部SMP电脑上,每个处理器都有其自己的IDT,但随着Intel超线程技术的出现,一个支持超线程技术的CPU,会被系统当作两个独立的CPU,因此,不得不对系统中的所有IDT进行挂钩,所以要创建运行HookIDT()的线程,直到系统中所有IDT都被挂钩了。
         一开始,HookIDT()分配了内存,以便复制IDT的内容——但就个人经验来看,写入内存,再由IDTR寄存器进行指向,是不安全的,即使中断已被屏蔽。因此,我们复制IDT到分配的内存中,并使用LIDT指令,加载一个指向此内存的指针到IDTR寄存器中,这样,我们就能安全地修改原始IDT;完成之后,会用原始IDT地址来重新加载IDTR。从HookIDT()发现IDT还未被挂钩,到修改并重新加载IDT,它都运行在同一个CPU上,所以我们就可以屏蔽中断,以避免上下文切换。然而,所有的工作,都只应在为临时IDT分配内存之后进行,为什么呢?因为,在我们这个例子中,调用任何不是我们自己编写的代码,都是不明智的行为——如果这些代码重新打开中断,很可能会把我们搅得一团糟。因此,我们要避免调用任何不是我们自己编写的代码——正如大家所看到的,甚至我们在分配用于复制原始IDT内容的内存时,都用的是REP MOVS指令,而不是常用的memcpy()。

         在对IDT中的INT 1与INT 3处理程序进行挂钩之后,我们把目标函数(即IoCreateDevice())的头八个字节,复制到我们从非分页池中分配的内存中,并在目标函数的起始处插入0xCC操作码。在此目标函数的可执行代码存放于只读内存中,因此,在我们可覆写函数之前,要么在页表中修改页面保护,要么清除CR0寄存器中的WP标志(此处为简单起见,我们选择清除WP标志)。以上操作完成之后,当每次有对IoCreateDevice()的调用发生时,我们挂钩于INT 3的代码就会执行了。

 
点赞 关注

回复
举报
您需要登录后才可以回帖 登录 | 注册

开源项目 更多>>
    随便看看
    查找数据手册?

    EEWorld Datasheet 技术支持

    相关文章 更多>>
    关闭
    站长推荐上一条 1/10 下一条

     
    EEWorld订阅号

     
    EEWorld服务号

     
    汽车开发圈

    About Us 关于我们 客户服务 联系方式 器件索引 网站地图 最新更新 手机版

    站点相关: 国产芯 安防电子 汽车电子 手机便携 工业控制 家用电子 医疗电子 测试测量 网络通信 物联网

    北京市海淀区中关村大街18号B座15层1530室 电话:(010)82350740 邮编:100190

    电子工程世界版权所有 京B2-20211791 京ICP备10001474号-1 电信业务审批[2006]字第258号函 京公网安备 11010802033920号 Copyright © 2005-2024 EEWORLD.com.cn, Inc. All rights reserved
    快速回复 返回顶部 返回列表