在需要时把它们转换成它们实际的类型)。在这一点上,我们就能做任何想做的事了——我们可以检查或修改函数参数、修改返回地址,也就是那些通常在挂钩函数之后可以做的事情。但对我们目前的任务来说,我们只对第一个参数感兴趣,也就是传递给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<IdtsHooked;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,idtrmov 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的代码就会执行了。 现在,让我们来看一下挂钩INT 1与INT 3的代码。 //此函数保证我们的挂钩工作正常 ULONG __stdcall INT1check(INTTERUPT_STACK * savedstack) { ULONG offset=0,stepping=savedstack->SavedFlags&0x100; //如果INT 1是因为单步之外的其他原因被抛出,返回0。 //因为执行流程最终仍会到达真正的INT 1处理程序。 if(!stepping)return 0; //检查单步是否与我们的挂钩有关,否则,返回0。 if(savedstack->InterruptReturnAddress<= HookedFunctionDescriptor.ProxyCode) return 0; if(savedstack->InterruptReturnAddress>= HookedFunctionDescriptor.ProxyCode+8) return 0; //在堆栈上修改返回地址,清除TF标志。 offset=savedstack->InterruptReturnAddress- HookedFunctionDescriptor.ProxyCode; savedstack->InterruptReturnAddress= HookedFunctionDescriptor.RealCode+offset; savedstack->SavedFlags &=0xfffffeff; //清除DR6 _asm { mov eax,0 mov dr6,eax } return 1; } ULONG __stdcall BPXcheck(INTTERUPT_STACK * savedstack) { PDRIVER_OBJECT driver;char buff[1024]; HANDLE handle=0; PUNICODE_STRING unistr=(PUNICODE_STRING)&buff[0];ULONG a=0; //如果断点与我们的挂钩无关,返回0。 if(savedstack->InterruptReturnAddress!= HookedFunctionDescriptor.RealCode+1) return 0; //使INT 1返回到我们复制的代码,并设置TF标志。 savedstack->SavedFlags|=0x100;savedstack->InterruptReturnAddress=
HookedFunctionDescriptor.ProxyCode; //所有x86相关的工作都已完成, //现在来进行实际的工作。 driver=(PDRIVER_OBJECT)savedstack->Arg; if(ObOpenObjectByPointer(driver,0, NULL, 0, 0,KernelMode,&handle))return 1; ZwQueryObject(handle,1,buff,256,&a); if(!unistr->Buffer){ZwClose(handle);return 1;} if(_wcsicmp(unistr->Buffer,L"\\Driver\\USBSTOR")) {ZwClose(handle);return 1;} ZwClose(handle); a=(ULONG)driver->MajorFunction[IRP_MJ_DEVICE_CONTROL]; if(a==(ULONG)Dispatch)return 1; realdispatcher=(ProxyDispatch)a; driver->MajorFunction[IRP_MJ_DEVICE_CONTROL]=Dispatch; return 1; } _declspec(naked) INT1Proxy() { _asm { pushfd pushad mov ebx,esp add ebx,36 push ebx call INT1check cmp eax,0 je fin popad popfd iretd fin: popad popfd jmp Int1RealHandler } } _declspec(naked) BPXProxy() { _asm { pushfd pushad mov ebx,esp add ebx,36 push ebx call BPXcheck cmp eax,0 je fin popad popfd iretd fin: popad popfd jmp BPXRealHandler } } 当有一个对IoCreateDevice()的调用发生时,会触发BPXProxy()函数。函数BPXProxy()保存了寄存器与标志值,并在开始执行时把ESP值压入栈,接着调用BpxCheck(),因此,BpxCheck()收到一个指向我们前面所提过的INTTERUPT_STACK结构的指针作为参数。首先,通过把结构的InterruptReturnAddress与目标函数的地址进行对比,BpxCheck()将会检查INT 3的调用,是否与我们的挂钩有关;如果不是,它返回0;否则,它把InterruptReturnAddress修改为我们复制过去的带有指令的数组,并设置SavedFlags字段中的TF标志。至此,我们就可以做与挂钩相关的工作了,在我们的例子中,将检查传递给IoCreateDevice()的PDEVICE_OBJECT是否为\\Driver\\USBSTOR(其意味着USBSTOR.SYS已经加载)的其中一个,并把IRP_MJ_DEVICE_CONTROL处理程序替换为我们函数的地址——当然,是在它还未被替换时。现在,我们已可以监视由系统发送给USBSTOR的所有IRP_MJ_DEVICE_CONTROL请求了,也即完成了我们的最初目标。在BpxCheck()返回之后,中断的处理方式依赖于它的返回值,如果它回返0,我们把控制传给INT 3真正的处理程序,否则,我们仅仅带着IRETD指令返回,因此,执行流程将会从带有指令数组的开始处恢复执行。一旦我们修改了TF标志,它将会以单步模式恢复执行,也就是说,INT1Proxy()得到了调用。 有关INT1Proxy()的实现,几乎与BPXProxy()一样,唯一的不同之处,是它调用了INT1Check(),而不是BpxCheck()。首先,INT1Check()检查保存在堆栈上的EFLAGS寄存器中的TF标志,如果它发现INT 1是因为单步之外的其他原因被抛出的,它将返回0(因为前面也提到,INT 1可由多种原因抛出);否则,它将检查返回地址是否位于我们复制的指令数组中某处,如果也不是,还是返回0——毕竟,其他程序在调试时,也会打开TF标志;如果是,从堆栈上的返回地址中减去数组中地址,就得到了目标函数的第一条指令大小(也就是刚执行过的那条指令),紧接着修改堆栈上的返回地址为目标函数起始地址(+)它的第一条指令大小,清除保存在堆栈上的DR6寄存器和EFLAGS中的TF标志,并返回1。这样一来,如果INT 1是因为其他原因抛出的,那么与我们的挂钩无关,INT1Proxy()会将控制传到INT 1真正的处理程序中,否则,它带着IRETD指令返回,所以,目标函数(IoCreateDevice())将会继续执行,好像什么事也没发生过一样。 要运行示例的代码,你必须创建一个按需启动的服务,并在命令行中手工启动它。当你在这个服务运行期间插入一个USB存储设备时,你将看到一个基本磁盘标志,而不是一个可移动磁盘,因此,如果打开控制面板中的磁盘管理,将可以在其上创建多个分区了。 注意:此示例程序使用了Windows 2000 DDK来构建,因此,它会把KeNumberProcessors导出符号当作一个指针;如果你在使用XP DDK,KeNumberProcessors会被当作一个变量,这样,示例程序就通不过编译了。然而,这些问题只存在于编译期间,示例程序在Windows 2000与Windows XP上都工作正常,而不管你用的是什么DDK版本。 结论 尽管在我们的例子中,只挂钩了一个函数,但可扩展这种方法以用于处理多个函数,此外,我们也作了一个大胆的假设——目标函数的首指令不为JMP。但在实际应用中,还是觉得有必要检查一下——如果目标函数首指令刚好为JMP呢,以对代码作出调整(所做的只是计算出将要跳转到的指令位置,并在此进行挂钩),换句话来说,你可以按自己的想法对示例代码进行调整,以满足现实工作中工程的特定需要。版权声明:本文为博主原创文章,未经博主允许不得转载。