技术原理
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;
}
这里得到了输出缓冲区,按键信息当然就在其中了。但是这些信息是什么格式保存的,又如何从这些信息中打印出按键的情况呢?