<寒江独钓>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;
}
效果预览:

<寒江独钓>Windows内核安全编程__传统键盘过滤程序_第1张图片

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

 

你可能感兴趣的:(编程,windows,object,ext,扩展,attributes)