Windows內核安全編程__傳統鍵盤過濾程序

 

技術原理
1.預備知識
何爲符號鏈接?符號鏈接其實就是設備的一個“別名”。在應用程序中想要訪問設備一般要通過符號鏈接來完成,而不是設備名本身。
ZwCreateFile是很重要的函數。同名的函數有兩個:一個在內核中(ntknos.exe),一個在應用層(ntdll.dll)。在應用程序中調用CreateFile就可以引發對這個函數的調用。
它不但可以打開文件,還可以打開設備(返回一個類似於文件句柄的句柄)。這個函數最終調用NtCreateFile。
何爲PDO?PDO是Phsiycal DeviceObject的簡稱,字面上的意義是物理設備,可以暫時這樣理解:
PDO是設備棧最下面的那個設備。
這個理解並不精確,但是很實用。

2.Windows中從擊鍵到內核
在任務管理器中有一個進程叫做csrss.exe。這個進程很關鍵,他有一個線程win32!RawInputThread,這個線程通過一個GUID(GUID_CLASS_KEYBOARD)獲得鍵盤設備棧中PDO的符號鏈接。
win32k!RawInputThread執行到函數win32k!OpenDevice,它的一個參數可以找到鍵盤設備棧的PDO符號連接名。win32k!OpenDevice有一個OBJECT_ATTRIBUTES結構的局部變量,它自己初始化這個局部變量,用傳入參數中的鍵盤設備棧的PDO賦值給OBJECT_ATTRIBUTES中的PUNICODE_STRING ObjectName。
然後調用ZwCreateFile,ZwCreateFile完成打開設備的工作,最後通過傳入參數返回得到句柄。win32k!RawInputThread把得到的句柄保存起來,供後面的ReadFile,DeviceIOControl等使用。
ZwCreateFile通過系統服務,調用內核中的NtCreateFile,NtCreateFile執行到nt!IoParseDevice中,調用nt!IoGetAttachDevice,通過PDO獲得鍵盤設備棧最頂端的設備對象。用這個設備對象中的char StackSize作爲參數來調用函數IoAllocateIrp,創建IRP。調用nt!ObOpenObjectByName中繼續執行,調用nt!ObpCreateHandle在進程(csrss.exe)的句柄表中創建一個新的句柄,這個句柄對應的對象是剛纔創建初始化的那個文件對象,文件對象中的DeviceObject指向鍵盤設備棧的PDO。
win32k!RawInputThread在獲得了句柄之後,會以這個句柄爲參數,調用nt!ZwReadFile,向鍵盤驅動要求讀入數據。nt!ZwReadFile中會創建一個IRP_MJ_READ的IRP發給鍵盤驅動,告訴鍵盤驅動要求讀入數據。鍵盤驅動通常會使這個IRP Pending,即IRP_MJ_READ不會被滿足,它一直被放在那裏,等待來自鍵盤的數據。而發出這個讀請求的線程win32k!RawInputThread也會等待,等待這個讀操作完成。
當鍵盤上有鍵被按下時,將觸發鍵盤的那個中斷,引起中斷服務例程的執行,鍵盤中斷的中斷服務例程由鍵盤驅動提供。鍵盤驅動從端口讀取掃描碼,進過一些列處理之後,把鍵盤得到的數據交給IRP,最後結束這個IRP。
這個IRP的結束,將導致win32k!RawInputThread線程對這個操作的等待結束。win32k!RawInputThread線程將會對得到的數據作出處理,分發給合適的進程。一旦把輸入數據處理完之後,win32k!RawInputThread線程會立刻再調用一個nt!ZwReadFile,想鍵盤驅動要求讀入數據。於是又開始一個等待,等待鍵盤上的鍵被按下。
簡單的說,win32k!RawInputThread線程總是nt!ZwCreateFile要求讀入數據,然後等待鍵盤上的按鍵被按下。當鍵盤上的鍵被按下時,win32k!RawInputThread處理nt!ZwReadFile得到的數據。然後nt!ReadFile要求讀入數據,再等待鍵盤上的鍵被按下。
(記住,這麼一長串描述都是在瞬間完成的,不要被迷惑)

我們一般看到的PS/2鍵盤設備棧,如果自己沒有另外安裝其他鍵盤過濾程序,那麼設備棧的情況是這樣的:
*最頂層的設備是驅動Kbdclass生成的設備對象
*中間層的設備對象是驅動i8042prt生成的設備對象
*最底層設備對象是驅動ACPI生成的對象
現在,我們只需要知道要去綁定的那個設備驅動就是KbdClass的設備對象就可以。

3.鍵盤硬件原理
從鍵盤被敲擊到計算機屏幕上出現一個字符,中間有很多複雜的變換。一個字符顯然並不代表一個鍵,因爲大寫小寫的字母是同一個鍵,只根據Shift鍵來決定是大寫還是小寫。此外還有許多複雜的功能鍵,如Ctrl,Alt。所以鍵不是用字符來代表,而是給每個鍵規定了一個掃描碼。
鍵盤和CPU的交互方式是中斷和讀取端口,這個操作是串行的。一次中斷髮生,就等於鍵盤給了CPU一次通知。這個通知只能通知一個事件:某個鍵被按下了,某個鍵被彈起了。爲此,一個鍵實際需要兩個掃描碼,如果按下的掃描碼爲X,則同一個鍵彈起的掃描碼爲X+0x80;

鍵盤過濾的框架
1.找到所有的鍵盤設備
要過濾一種設備,首先要綁定它。現在需要找到所有代表鍵盤的設備。從前面的原理來看,可以認定的是,如果綁定了驅動KbdClass的所有設備對象,則代表鍵盤的設備一定在其中。如何找到一個驅動下的所有對象。一個DRIVER_OBJECT下有一個域叫做DeviceObject,這個看似是一個設備對象的指針,但是由於DeviceObject之中又有一個域叫做NextDevice,指向同一個驅動中的下一個設備,所以這裏是一個設備鏈。
除了用上面所說的直接讀取驅動對象下面的DeviceObject域之外,另一種獲得驅動下所有設備對象的方法是調用IoEnumerateDeviceObjectList,這個函數也可以枚舉出一個驅動下所有的設備。
現在來看代碼:
// 這個函數是事實存在的,只是文檔中沒有公開。聲明一下
// 就可以直接使用了。
extern "C" NTSTATUS ObReferenceObjectByName(
      PUNICODE_STRING ObjectName,
      ULONG Attributes,
      PACCESS_STATE AccessState,
      ACCESS_MASK DesiredAccess,
      POBJECT_TYPE ObjectType,
      KPROCESSOR_MODE AccessMode,
      PVOID ParseContext,
      PVOID *Object
      );

extern "C" POBJECT_TYPE IoDriverObjectType;
// 這個函數經過改造。能打開驅動對象Kbdclass,然後綁定
// 它下面的所有的設備:
NTSTATUS
c2pAttachDevices(
     IN PDRIVER_OBJECT DriverObject,
     IN PUNICODE_STRING RegistryPath
     )
{
 NTSTATUS status = 0;
 UNICODE_STRING uniNtNameString;
 PC2P_DEV_EXT devExt;
 PDEVICE_OBJECT pFilterDeviceObject = NULL;
 PDEVICE_OBJECT pTargetDeviceObject = NULL;
 PDEVICE_OBJECT pLowerDeviceObject = NULL;

 PDRIVER_OBJECT KbdDriverObject = NULL;

 KdPrint(("MyAttach\n"));

 // 初始化一個字符串,就是Kdbclass驅動的名字。
 RtlInitUnicodeString(&uniNtNameString, KBD_DRIVER_NAME);
 // 請參照前面打開設備對象的例子。只是這裏打開的是驅動對象。
 status = ObReferenceObjectByName (
  &uniNtNameString,
  OBJ_CASE_INSENSITIVE,
  NULL,
  0,
  IoDriverObjectType,
  KernelMode,
  NULL,
  (PVOID*)&KbdDriverObject
  );
 // 如果失敗了就直接返回
 if(!NT_SUCCESS(status))
 {
  KdPrint(("MyAttach: Couldn't get the MyTest Device Object\n"));
  return( status );
 }
 else
 {
  // 這個打開需要解應用。早點解除了免得之後忘記。
  ObDereferenceObject(DriverObject);
 }

 // 這是設備鏈中的第一個設備 
 pTargetDeviceObject = KbdDriverObject->DeviceObject;
 // 現在開始遍歷這個設備鏈
 while (pTargetDeviceObject)
 {
  // 生成一個過濾設備,這是前面讀者學習過的。這裏的IN宏和OUT宏都是
  // 空宏,只有標誌性意義,表明這個參數是一個輸入或者輸出參數。
  status = IoCreateDevice(
   IN DriverObject,
   IN sizeof(C2P_DEV_EXT),
   IN NULL,
   IN pTargetDeviceObject->DeviceType,
   IN pTargetDeviceObject->Characteristics,
   IN FALSE,
   OUT &pFilterDeviceObject
   );

  // 如果失敗了就直接退出。
  if (!NT_SUCCESS(status))
  {
   KdPrint(("MyAttach: Couldn't create the MyFilter Filter Device Object\n"));
   return (status);
  }

  // 綁定。pLowerDeviceObject是綁定之後得到的下一個設備。也就是
  // 前面常常說的所謂真實設備。
  pLowerDeviceObject =
   IoAttachDeviceToDeviceStack(pFilterDeviceObject, pTargetDeviceObject);
  // 如果綁定失敗了,放棄之前的操作,退出。
  if(!pLowerDeviceObject)
  {
   KdPrint(("MyAttach: Couldn't attach to MyTest Device Object\n"));
   IoDeleteDevice(pFilterDeviceObject);
   pFilterDeviceObject = NULL;
   return( status );
  }

  // 設備擴展!下面要詳細講述設備擴展的應用。
  devExt = (PC2P_DEV_EXT)(pFilterDeviceObject->DeviceExtension);
  c2pDevExtInit(
   devExt,
   pFilterDeviceObject,
   pTargetDeviceObject,
   pLowerDeviceObject );

  // 下面的操作和前面過濾串口的操作基本一致。這裏不再解釋了。
  pFilterDeviceObject->DeviceType=pLowerDeviceObject->DeviceType;
  pFilterDeviceObject->Characteristics=pLowerDeviceObject->Characteristics;
  pFilterDeviceObject->StackSize=pLowerDeviceObject->StackSize+1;
  pFilterDeviceObject->Flags |= pLowerDeviceObject->Flags & (DO_BUFFERED_IO | DO_DIRECT_IO | DO_POWER_PAGABLE) ;
  //next device
  pTargetDeviceObject = pTargetDeviceObject->NextDevice;
 }
 return status;
}
2.應用設備擴展
我們之前在寫串口過濾的程序時,實際上用了兩個數組,一個用於保存所有的過濾設備,另一個用於保存所有的真實設備。兩個數組起到了一一映射的作用。
但實際上這樣做是沒有必要的。在生成一個過濾設備時,我們可以給這個設備指定一個任意長度的“設備擴展”,這個設備擴展的內容可以任意填寫,作爲一個自定義的數據結構。
這樣就可以把真實的設備指針保存在設備對象裏了,就沒有必要做兩個數組對應起來。
在這個鍵盤過濾中,我們專門定義了一個結構作爲設備擴展:
typedef struct _C2P_DEV_EXT
{
 // 這個結構的大小
 ULONG NodeSize;
 // 過濾設備對象
 PDEVICE_OBJECT pFilterDeviceObject;
 // 同時調用時的保護鎖
 KSPIN_LOCK IoRequestsSpinLock;
 // 進程間同步處理
 KEVENT IoInProgressEvent;
 // 綁定的設備對象
 PDEVICE_OBJECT TargetDeviceObject;
 // 綁定前底層設備對象
 PDEVICE_OBJECT LowerDeviceObject;
} C2P_DEV_EXT, *PC2P_DEV_EXT;
這裏很容易看到保存了LowerDeviceObject,此外還保存了一些其他信息暫時可以不用考慮。
要生成一個帶有設備擴展信息的設備對象,關鍵是在調用IoCreateDevice時,注意第二個參數填入擴展長度。
status = IoCreateDevice(
   IN DriverObject,
   IN sizeof(C2P_DEV_EXT),
   IN NULL,
   IN pTargetDeviceObject->DeviceType,
   IN pTargetDeviceObject->Characteristics,
   IN FALSE,
   OUT &pFilterDeviceObject
   );
其中第二個參數是sizeof(C2P_DEV_EXT).生成設備後要填寫這個區域。相關代碼如下:
devExt = (PC2P_DEV_EXT)(pFilterDeviceObject->DeviceExtension);
  c2pDevExtInit(
   devExt,
   pFilterDeviceObject,
   pTargetDeviceObject,
   pLowerDeviceObject );
如何填寫放在了c2pDevExtInit函數中:
NTSTATUS
c2pDevExtInit(
     IN PC2P_DEV_EXT devExt,
     IN PDEVICE_OBJECT pFilterDeviceObject,
     IN PDEVICE_OBJECT pTargetDeviceObject,
     IN PDEVICE_OBJECT pLowerDeviceObject )
{
 memset(devExt, 0, sizeof(C2P_DEV_EXT));
 devExt->NodeSize = sizeof(C2P_DEV_EXT);
 devExt->pFilterDeviceObject = pFilterDeviceObject;
 KeInitializeSpinLock(&(devExt->IoRequestsSpinLock));
 KeInitializeEvent(&(devExt->IoInProgressEvent), NotificationEvent, FALSE);
 devExt->TargetDeviceObject = pTargetDeviceObject;
 devExt->LowerDeviceObject = pLowerDeviceObject;
 return( STATUS_SUCCESS );
}

3.鍵盤過濾模塊的DriverEntry
下面是DriverEntry函數的代碼:
NTSTATUS DriverEntry(
    IN OUT PDRIVER_OBJECT   DriverObject,
    IN PUNICODE_STRING      RegistryPath
    )
{
 
 ULONG i;
 NTSTATUS status;
 KdPrint (("c2p.SYS: entering DriverEntry\n"));

 // 填寫所有的分發函數的指針
 for (i = 0; i < IRP_MJ_MAXIMUM_FUNCTION; i++)
 {
  DriverObject->MajorFunction[i] = c2pDispatchGeneral;
 }

 // 單獨的填寫一個Read分發函數。因爲要的過濾就是讀取來的按鍵信息
 // 其他的都不重要。這個分發函數單獨寫。
 DriverObject->MajorFunction[IRP_MJ_READ] = c2pDispatchRead;

 // 單獨的填寫一個IRP_MJ_POWER函數。這是因爲這類請求中間要調用
 // 一個PoCallDriver和一個PoStartNextPowerIrp,比較特殊。
 DriverObject->MajorFunction [IRP_MJ_POWER] = c2pPower;

 // 我們想知道什麼時候一個我們綁定過的設備被卸載了(比如從機器上
 // 被拔掉了?)所以專門寫一個PNP(即插即用)分發函數
 DriverObject->MajorFunction [IRP_MJ_PNP] = c2pPnP;

 // 卸載函數。
 DriverObject->DriverUnload = c2pUnload;
 gDriverObject = DriverObject;
 // 綁定所有鍵盤設備
 status =c2pAttachDevices(DriverObject, RegistryPath);

 return status;
}
在這個入口函數中:
c2pDispatchGeneral派遣函數用來處理一般IRP
c2pDispatchRead派遣函數用來處理IRP_MJ_READ,即讀請求
c2pPower派遣函數用來處理IRP_MJ_POWER,即和電源有關的請求
c2pPnP派遣函數用來處理IRP_MJ_PNP,即“即插即用”方面的請求
c2pUnload派遣函數用來動態卸載
c2pAttachDevices用來綁定鍵盤驅動對象的所有設備

4.鍵盤驅動模塊的動態卸載
鍵盤過濾模塊的動態卸載和前面的串口過濾稍有不同,這是因爲鍵盤總是處於“一個讀請求沒有完成”的狀態。換句話說,就算類似串口驅動一樣等待5秒,這個請求也未必會完成(如果沒有按鍵的話)。這樣如果卸載了驅動,等下一次按鍵,這個請求就會被處理,很可能馬上藍屏崩潰。
VOID
c2pUnload(IN PDRIVER_OBJECT DriverObject)
{
 PDEVICE_OBJECT DeviceObject;
 PDEVICE_OBJECT OldDeviceObject;
 PC2P_DEV_EXT devExt;

 LARGE_INTEGER lDelay;
 PRKTHREAD CurrentThread;
 //delay some time
 lDelay = RtlConvertLongToLargeInteger(100 * DELAY_ONE_MILLISECOND);
 CurrentThread = KeGetCurrentThread();
 // 把當前線程設置爲低實時模式,以便讓它的運行儘量少影響其他程序。
 KeSetPriorityThread(CurrentThread, LOW_REALTIME_PRIORITY);

 UNREFERENCED_PARAMETER(DriverObject);
 KdPrint(("DriverEntry unLoading...\n"));

 // 遍歷所有設備並一律解除綁定
 DeviceObject = DriverObject->DeviceObject;
 while (DeviceObject)
 {
  // 解除綁定並刪除所有的設備
  c2pDetach(DeviceObject);
  DeviceObject = DeviceObject->NextDevice;
 }
 ASSERT(NULL == DriverObject->DeviceObject);

 while (gC2pKeyCount)
 {
  KeDelayExecutionThread(KernelMode, FALSE, &lDelay);
 }
 KdPrint(("DriverEntry unLoad OK!\n"));
 return;
}
這裏的防止未解決請求沒有完成的方法就是使用gC2pKeyCount這個全局變量。每次有一個請求到來時,gC2KeyCount被加1;每次完成時則減1.只有所有請求都被完成後,才結束等待;否則就無休止的等待下去。

3.鍵盤過濾的請求處理
最通常的處理就是直接發送到真實設備,跳過虛擬設備的處理。
NTSTATUS c2pDispatchGeneral(
       IN PDEVICE_OBJECT DeviceObject,
       IN PIRP Irp
       )
{
 // 其他的分發函數,直接skip然後用IoCallDriver把IRP發送到真實設備
 // 的設備對象。
 KdPrint(("Other Diapatch!"));
 IoSkipCurrentIrpStackLocation(Irp);
 return IoCallDriver(((PC2P_DEV_EXT)
  DeviceObject->DeviceExtension)->LowerDeviceObject, Irp);
}
但是需要注意的是,我們不再遍歷一個數組去尋找真實設備的設備對象,而是直接使用設備擴展,從DeviceObject-DeviceExtension就能直接拿到設備擴展的指針。

但是電源相關的IRP處理稍有不同,電源處理IRP和普通IRP的skip處理並沒有太明顯的區別,只有兩點:
(1)在調用IoSkipCurrentStackLocation之前,先調用PoStartNextPowerIrp
(2)用PoCallDriver代替IoCallDriver
NTSTATUS c2pPower(
      IN PDEVICE_OBJECT DeviceObject,
      IN PIRP Irp
      )
{
 PC2P_DEV_EXT devExt;
 devExt =
  (PC2P_DEV_EXT)DeviceObject->DeviceExtension;

 PoStartNextPowerIrp( Irp );
 IoSkipCurrentIrpStackLocation( Irp );
 return PoCallDriver(devExt->LowerDeviceObject, Irp );
}
請注意:c2pPower只處理主功能號爲IRP_MJ_POWER的IRP;而Ctrl2capDispatchGeneral處理我們並不關心的所有IRP。

2.PNP的處理
唯一需要處理的是,當一個設備被拔出時,則解除綁定,並刪除過濾設備。代碼如下:
NTSTATUS c2pPnP(
    IN PDEVICE_OBJECT DeviceObject,
    IN PIRP Irp
    )
{
 PC2P_DEV_EXT devExt;
 PIO_STACK_LOCATION irpStack;
 NTSTATUS status = STATUS_SUCCESS;
 KIRQL oldIrql;
 KEVENT event;

 // 獲得真實設備。
 devExt = (PC2P_DEV_EXT)(DeviceObject->DeviceExtension);
 irpStack = IoGetCurrentIrpStackLocation(Irp);

 switch (irpStack->MinorFunction)
 {
 case IRP_MN_REMOVE_DEVICE:
  KdPrint(("IRP_MN_REMOVE_DEVICE\n"));

  // 首先把請求發下去
  IoSkipCurrentIrpStackLocation(Irp);
  IoCallDriver(devExt->LowerDeviceObject, Irp);
  // 然後解除綁定。
  IoDetachDevice(devExt->LowerDeviceObject);
  // 刪除我們自己生成的虛擬設備。
  IoDeleteDevice(DeviceObject);
  status = STATUS_SUCCESS;
  break;

 default:
  // 對於其他類型的IRP,全部都直接下發即可。
  IoSkipCurrentIrpStackLocation(Irp);
  status = IoCallDriver(devExt->LowerDeviceObject, Irp);
 }
 return status;
}
當PNP請求過來時,是沒有必要擔心還有未完成的IRP的。因爲Windows系統要求卸載設備,此時Windows自己已經處理掉了所有未解決的IRP。

3.讀的處理
之前見過的所有請求,都是處理完畢之後,直接發送到下層驅動之後就不管了。但是在處理鍵盤過濾的時候不能這樣做。
一個讀請求到來時,只是說Windows要從鍵盤驅動中讀取一個掃描碼的值,但是在完成之前顯然不知道這個值到底是多少,要過濾的目的,就是要知道這個值到底是多少。所以不得不換一種處理方法,就是把這個請求下發完成之後,再去看這個值是多少。
要完成請求,可以採用如下方法:
(1)調用IoCopyCurrentIrpStackLoacationToNext把當前IRP棧空間拷貝到下一個棧空間(這和調用IoSkipCurrentIrpStackLocation跳過當前棧空間形成對比)。
(2)給這個IRP一個完成函數,完成函數的含義是:如果這個IRP完成了,系統會回調這個函數。
(3)調用IoCallDriver把請求發送到下一個設備
另外需要解決的問題是我們前所需要的一個計數器。即一個請求到來時,我們把全局變量gC2pKeyCount加1,等完成之後再減1.完整的讀出理請求如下:
NTSTATUS c2pDispatchRead(
       IN PDEVICE_OBJECT DeviceObject,
       IN PIRP Irp )
{
 NTSTATUS status = STATUS_SUCCESS;
 PC2P_DEV_EXT devExt;
 PIO_STACK_LOCATION currentIrpStack;
 KEVENT waitEvent;
 KeInitializeEvent( &waitEvent, NotificationEvent, FALSE );

 if (Irp->CurrentLocation == 1)
 {
  ULONG ReturnedInformation = 0;
  KdPrint(("Dispatch encountered bogus current location\n"));
  status = STATUS_INVALID_DEVICE_REQUEST;
  Irp->IoStatus.Status = status;
  Irp->IoStatus.Information = ReturnedInformation;
  IoCompleteRequest(Irp, IO_NO_INCREMENT);
  return(status);
 }

 // 全局變量鍵計數器加
 gC2pKeyCount++;

 // 得到設備擴展。目的是之後爲了獲得下一個設備的指針。
 devExt =
  (PC2P_DEV_EXT)DeviceObject->DeviceExtension;

 // 設置回調函數並把IRP傳遞下去。之後讀的處理也就結束了。
 // 剩下的任務是要等待讀請求完成。
 currentIrpStack = IoGetCurrentIrpStackLocation(Irp);
 IoCopyCurrentIrpStackLocationToNext(Irp);
 IoSetCompletionRoutine( Irp, c2pReadComplete,
  DeviceObject, TRUE, TRUE, TRUE );
 return  IoCallDriver( devExt->LowerDeviceObject, Irp );  
}
4.讀完成的處理
讀請求完成之後,應該獲得輸出緩衝區,按鍵信息就在輸出緩衝區中。全局變量應該減1.
// 這是一個IRP完成回調函數的原型
NTSTATUS c2pReadComplete(
       IN PDEVICE_OBJECT DeviceObject,
       IN PIRP Irp,
       IN PVOID Context
       )
{
 PIO_STACK_LOCATION IrpSp;
 ULONG buf_len = 0;
 PUCHAR buf = NULL;
 size_t i;

 IrpSp = IoGetCurrentIrpStackLocation( Irp );

 //  如果這個請求是成功的。很顯然,如果請求失敗了,這麼獲取
 //   進一步的信息是沒意義的。
 if( NT_SUCCESS( Irp->IoStatus.Status ) )
 {
  // 獲得讀請求完成後輸出的緩衝區
  buf = (PUCHAR)Irp->AssociatedIrp.SystemBuffer;
  // 獲得這個緩衝區的長度。一般的說返回值有多長都保存在
  // Information中。
  buf_len = Irp->IoStatus.Information;

  //…這裏可以做進一步的處理。我這裏很簡單的打印出所有的掃
  // 描碼。
  for(i=0;i<buf_len;++i)
  {
   DbgPrint("ctrl2cap: %2x\r\n", buf[i]);
  }
 }
 gC2pKeyCount--;

 if( Irp->PendingReturned )
 {
  IoMarkIrpPending( Irp );
 }
 return Irp->IoStatus.Status;
}
效果預覽:

這裏得到了輸出緩衝區,按鍵信息當然就在其中了。但是這些信息是什麼格式保存的,又如何從這些信息中打印出按鍵的情況呢?

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章