技術原理
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;
}
效果預覽:
這裏得到了輸出緩衝區,按鍵信息當然就在其中了。但是這些信息是什麼格式保存的,又如何從這些信息中打印出按鍵的情況呢?