Windows内核安全编程:传统键盘过滤程序

来源:互联网 发布:编程好找工作吗 编辑:程序博客网 时间:2024/06/07 16:12

技术原理

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;

}

这里得到了输出缓冲区,按键信息当然就在其中了。但是这些信息是什么格式保存的,又如何从这些信息中打印出按键的情况呢?