有时在开发中,会遇到这样一种情况,当非常需要对某些内核函数进行挂钩时,而常规基于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<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,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的代码就会执行了。
现在,让我们来看一下挂钩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呢,以对代码作出调整(所做的只是计算出将要跳转到的指令位置,并在此进行挂钩),换句话来说,你可以按自己的想法对示例代码进行调整,以满足现实工作中工程的特定需要。
版权声明:本文为博主原创文章,未经博主允许不得转载。