<寒江独钓>Windows内核安全编程__具有还原功能的磁盘卷过滤驱动

来源:互联网 发布:微软软件合规部 编辑:程序博客网 时间:2024/05/24 02:09
 


磁盘过滤驱动的概念
1.设备过滤和类过滤
在之前的文章里,我们已经介绍过滤的概念,所谓过滤技术就是在本来已有的设备栈中加入自己的一个设备。由于Windows向任何一个设备发送IRP请求都会首先发送给这个设备所在设备栈的最上层设备,然后再依次传递下去,这就使得加入的设备在目标设备之前获取Irp请求称为可能,这时候就可以加入自己的处理流程。在这里把插入设备栈的用户设备叫做过滤设备,建立这个设备并使其具有特殊功能的驱动叫做过滤驱动。
在前面已经展示了如何去建立一个过滤设备并将其绑定在一个有名字的设备上,这叫做设备过滤,这是对某个特定设备加以过滤的方法。但是在实际应用中,这种方法还存在一些问题,例如,Windows中有很多即插即用设备,如何在这些设备加入系统的时候就自动对他们进行绑定呢?实际上,在Windows的过滤驱动框架中,还有一种叫做类过滤驱动的驱动程序,能够在某一类特定的设备建立时有Pnp Manager调用指定的过滤驱动代码,并且允许用户对此时这一类设备进行绑定。根据用户设备在整个设备栈中的位置可以分为上层过滤和下层过滤。
2.磁盘设备和磁盘卷设备过滤驱动
在Windows的存储系统中,最底层的是磁盘,而在磁盘上面又有卷,卷虽然只是逻辑上的一个概念,但是Windows仍然为其建立了设备,所以在Windows的存储系统里有磁盘设备和磁盘卷设备两种类型的设备。
如果一个磁盘卷位于某个磁盘上,那么对于磁盘卷的访问最终也会体现在相应的磁盘上。但是这并不意味着他们在一个设备栈上,irp不会原封不动从磁盘卷设备栈上一直传到磁盘设备栈上,更何况Windows中还存在着跨磁盘的卷,软RAID卷等不能对应到唯一磁盘上的卷。
从驱动的角度上来讲,这两种设备受到读/写请求都是针对与磁盘大小或者卷大小范围之内的请求,都是以扇区大小对齐,处理起来也没有什么太大的区别。在此我们选用磁盘卷设备的上层类过滤驱动。
3.注册表和磁盘卷设备过滤驱动
在实际的系统运行过程中,一个普通的驱动程序是如何告诉Windows操作系统它是一个类过滤驱动,并且何如和相应的设备联系起来的呢?这就需要注册表的帮忙了。
读者应该很熟悉一个驱动程序作为一个服务是如何在注册表中存在的,在\HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Service下服务键的名字也就是这个服务的名字了。同时在\HKEY_LOCAL_MACHINE\SYSTEM\Control\Class下,也有许多类的名字,这些类都是一长串数字。这些数字实际上是一个ClassGUID,随意选择一个键下面都会有一个Class的值,读者可以找到一个Class值为“Volume”的键,这就是磁盘卷类。其中有一个UpperFilters的值,这个值说明了磁盘卷有哪些上层过滤驱动。

具有还原功能的磁盘卷过滤驱动
1.简介
这里首先介绍一下什么是还原。在网吧,机房我们所看到的计算机,在用过之后,只要重新启动,所有的操作都没有了,系统又回到了设置还原点的那个状态,这就是还原。
由于这个过滤驱动只是为了讲解而写的,故这里对它的使用条件比限制较多。这个驱动工作时需要只有一个硬盘,需要使用Windows XP系统,并且硬盘被分为C盘为主分区,D盘和E盘都为扩展分区的分区形式,而且所有分区都必须是NTFS文件系统。本驱动只保护D盘并且会在E盘建立临时文件,而且操作系统要安装在C盘。
2.基本思想
为了实现还原,一种简单的思想如下:
*在开启还原之后,所有对还原卷的写操作将被写到另一个地方,而不会真正写在还原卷上。这里所说的另一个地方可以称为转存处。
*在开启还原之后,所有对还原卷的读操作将分为两种情况处理:一种情况是读了开启还原之前就存在的内容,这种情况就按照正常的读取方式从还原卷上读取。另一种情况是读了开启还原之后写到还原卷上的内容,这种情况将会从转存处把之前写过来的内容读取出来。
*上述的读/写必须建立在互斥的基础上,不能出现写了一半就开始读的情况。
*重启之后转存处的数据清零,所有在还原开始后被写过的数据也就不复存在了。

驱动分析
1.DriverEntry函数
DriverEntry函数作为过滤驱动的入口函数,主要负责初始化本驱动的各个分发函数。它首先会将所有的分发函数都设置成一个统一的处理函数,这个函数是对大部分irp请求的处理方式;其次,它会将本驱动关心的分发函数指定为驱动专门实现的函数。另外,它还指定了这个驱动的AddDevice函数和驱动的Unload函数。由于这个驱动被注册成了磁盘卷设备的上层过滤驱动,PnP manager将会在一个新的磁盘卷设备建立之后,首先调用本过滤驱动的AddDevice函数,然后在调用磁盘卷设备驱动中的AddDevice函数。这就让过滤驱动有了在系统加入磁盘卷设备起作用之前做一些工作的机会,而Unload函数会在过滤驱动结束的时候被调用,用来做一些清理工作。不过本驱动将会一直工作到系统关机,所以Unlaod函数将不会做任何清理工作。
在DriverEntry函数的最后,还注册了一个boot类型驱动的完成回调函数。首先需要说明一点的是,本过滤驱动是作为一个boot类型驱动存在的。boot类型驱动程序是启动最早的驱动程序,在系统引导时就必须加载完毕;而对于注册为boot类型驱动的完成回调函数的函数,将会在所有的boot类型驱动执行完毕之后被调用一次,需要注意的是,这时候仍然是系统启动过程中比较早的时候。在这里需要注册这个回调函数,是因为驱动中有些工作需要等到这个时间才能做。
NTSTATUS
DriverEntry(
    IN PDRIVER_OBJECT DriverObject,
    IN PUNICODE_STRING RegistryPath
    )
{
 int i;

 //KdBreakPoint();

 for (i = 0; i <= IRP_MJ_MAXIMUM_FUNCTION; i++)
 {
  //初始化这个驱动所有的分发函数,默认值是初始化为DPDispatchAny
  DriverObject->MajorFunction[i] = DPDispatchAny;
 }
   
 //下面将我们特殊关注的分发函数重新赋值为我们自己的处理函数
    DriverObject->MajorFunction[IRP_MJ_POWER] = DPDispatchPower;
    DriverObject->MajorFunction[IRP_MJ_PNP] = DPDispatchPnp;
    DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DPDispatchDeviceControl;
    DriverObject->MajorFunction[IRP_MJ_READ] = DPDispatchReadWrite;
    DriverObject->MajorFunction[IRP_MJ_WRITE] = DPDispatchReadWrite;

 //将这个驱动的AddDevice函数初始化为DpAddDevice函数
    DriverObject->DriverExtension->AddDevice = DPAddDevice;
 //将这个驱动的unload函数初始化为DpUnload函数
    DriverObject->DriverUnload = DPUnload;
   
 //注册一个boot驱动结束回调,这个回调函数会在所有的boot型驱动都运行完毕之后再去执行
 IoRegisterBootDriverReinitialization(
  DriverObject,
  DPReinitializationRoutine,
  NULL
  );

 //作为一个过滤驱动,无论如何都要返回成功
    return STATUS_SUCCESS;
}
2.AddDevice函数
由于过滤驱动的DriverEntry函数中将驱动对象的AddDevice函数赋值成自己实现的DPAddDevice函数,这样在任何磁盘设备建立的时候,DPAddDevice被调用的时候,实际上磁盘卷设备已经建立了起来,只是还不能被使用,也就是说,这个设备对象有了,但是不能响应大部分的irp请求。
在DPAddDevice中将建立一个过滤设备,这个设备将绑定在真正的磁盘卷设备上。并且由于这是一个上层过滤驱动,这个过滤驱动设备会将位于磁盘卷设备顶端方向上,也就是先于磁盘卷设备收到irp请求。在建立并绑定了这个过滤驱动之后,需要对这个过滤驱动做一些初始化,而过滤设备的所有基本信息都会在DP_FILTER_DEV_EXTENSION结构的类型存储在设备扩展中。在这里先介绍DP_FILTER_DEV_EXTENSION数据结构中的成员变量。
//用来存储一个卷所有的相关信息的数据结构,放在过滤设备的设备扩展中
typedef struct _DP_FILTER_DEV_EXTENSION_
{
 //卷的名字,例如"C:,D:"等中的字母部分
 WCHAR     VolumeLetter;
 //这个卷是否在保护状态
 BOOL     Protect;
 //这个卷的总大小,以byte为单位
 LARGE_INTEGER   TotalSizeInByte;
 //这个卷上文件系统的每簇大小,以byte为单位
 DWORD     ClusterSizeInByte;
 //这个卷的每个扇区大小,以byte为单位
 DWORD     SectorSizeInByte;
 //这个卷设备对应的过滤设备的设备对象
 PDEVICE_OBJECT   FltDevObj;
 //这个卷设备对应的过滤设备的下层设备对象
 PDEVICE_OBJECT   LowerDevObj;
 //这个卷设备对应的物理设备的设备对象
 PDEVICE_OBJECT   PhyDevObj;
 //这个数据结构是否已经被初始化完毕了
 BOOL     InitializeCompleted;
 //这个卷上的保护系统使用的位图的句柄
 PDP_BITMAP  Bitmap; 
 //用来转储的文件句柄
 HANDLE     TempFile;
 //这个卷上的保护系统使用的请求队列
 LIST_ENTRY    ReqList;
 //这个卷上的保护系统使用的请求队列的锁
 KSPIN_LOCK    ReqLock;
 //这个卷上的保护系统使用的请求队列的同步事件
 KEVENT     ReqEvent;
 //这个卷上的保护系统使用的请求队列的处理线程之线程句柄
 PVOID     ThreadHandle;
 //这个卷上的保护系统使用的请求队列的处理线程之结束标志
 BOOLEAN     ThreadTermFlag;
 //这个卷上的保护系统的关机分页电源请求的计数事件
 KEVENT     PagingPathCountEvent;
 //这个卷上的保护系统的关机分页电源请求的计数
 LONG     PagingPathCount;
} DP_FILTER_DEV_EXTENSION, *PDP_FILTER_DEV_EXTENSION;
在上面的数据结构中可以看到有3个设备对象:过滤设备,物理设备和下层设备,其中过滤设备是本过滤驱动自己建立的;物理设备是通过AddDevice函数的参数传递进来的设备,是真正的磁盘卷设备;而下层设备是在将过滤设备绑定到物理设备之上后,返回的绑定之前的物理设备栈上最顶部的设备。
在DP_FILTER_DEV_EXTENSION数据结构中可以看到,针对每个过滤设备都会建立一个处理线程和相应的请求队列,这时因为在这个驱动中同样采用了将所有请求依次排队,然后使用一个单独的线程依次处理的方式。这么做的好处在于将所有读/写请求串行化,程序易于编写而且不会出现读/写请求之间的同步问题。
在DPAddDevice函数中读者还会发现初始化了PagingPathCountEvent和PagingPathCount这两个与分页路径相关的变量,他们将会在Pnpirp请求的处理中被用到。
NTSTATUS
DPAddDevice(
    IN PDRIVER_OBJECT DriverObject,
    IN PDEVICE_OBJECT PhysicalDeviceObject
    )
{
 //NTSTATUS类型的函数返回值
 NTSTATUS     ntStatus = STATUS_SUCCESS;
    //用来指向过滤设备的设备扩展的指针
 PDP_FILTER_DEV_EXTENSION DevExt = NULL;
 //过滤设备的下层设备的指针对象
 PDEVICE_OBJECT    LowerDevObj = NULL;
 //过滤设备的设备指针的指针对象
 PDEVICE_OBJECT    FltDevObj = NULL;
 //过滤设备的处理线程的线程句柄
 HANDLE      ThreadHandle = NULL;

 //建立一个过滤设备,这个设备是FILE_DEVICE_DISK类型的设备并且具有DP_FILTER_DEV_EXTENSION类型的设备扩展
 ntStatus = IoCreateDevice(
  DriverObject,
  sizeof(DP_FILTER_DEV_EXTENSION),
  NULL,
  FILE_DEVICE_DISK,
  FILE_DEVICE_SECURE_OPEN,
  FALSE,
  &FltDevObj);
 if (!NT_SUCCESS(ntStatus))
  goto ERROUT;
 //将DevExt指向过滤设备的设备扩展指针
 DevExt = FltDevObj->DeviceExtension;
 //清空过滤设备的设备扩展
 RtlZeroMemory(DevExt,sizeof(DP_FILTER_DEV_EXTENSION));

 //将刚刚建立的过滤设备附加到这个卷设备的物理设备上
 LowerDevObj = IoAttachDeviceToDeviceStack(
  FltDevObj,
  PhysicalDeviceObject);
 if (NULL == LowerDevObj)
 {
  ntStatus = STATUS_NO_SUCH_DEVICE;
  goto ERROUT;
 }

 //初始化这个卷设备的分页路径计数的计数事件
 KeInitializeEvent(
  &DevExt->PagingPathCountEvent,
  NotificationEvent,
  TRUE);

 //对过滤设备的设备属性进行初始化,过滤设备的设备属性应该和它的下层设备相同
 FltDevObj->Flags = LowerDevObj->Flags;
 //给过滤设备的设备属性加上电源可分页的属性
 FltDevObj->Flags |= DO_POWER_PAGABLE;
 //对过滤设备进行设备初始化
 FltDevObj->Flags &= ~DO_DEVICE_INITIALIZING;

 //将过滤设备对应的设备扩展中的相应变量进行初始化
 //卷设备的过滤设备对象
 DevExt->FltDevObj = FltDevObj;
 //卷设备的物理设备对象
 DevExt->PhyDevObj = PhysicalDeviceObject;
 //卷设备的下层设备对象
 DevExt->LowerDevObj = LowerDevObj;

 //初始化这个卷的请求处理队列
 InitializeListHead(&DevExt->ReqList);
 //初始化请求处理队列的锁
 KeInitializeSpinLock(&DevExt->ReqLock);
 //初始化请求处理队列的同步事件
 KeInitializeEvent(
  &DevExt->ReqEvent,
  SynchronizationEvent,
  FALSE
  );

 //初始化终止处理线程标志
 DevExt->ThreadTermFlag = FALSE;
 //建立用来处理这个卷的请求的处理线程,线程函数的参数则是设备扩展
 ntStatus = PsCreateSystemThread(
  &ThreadHandle,
  (ACCESS_MASK)0L,
  NULL,
  NULL,
  NULL,
  DPReadWriteThread,
  DevExt
  );
 if (!NT_SUCCESS(ntStatus))
  goto ERROUT;

 //获取处理线程的对象
 ntStatus = ObReferenceObjectByHandle(
  ThreadHandle,
  THREAD_ALL_ACCESS,
  NULL,
  KernelMode,
  &DevExt->ThreadHandle,
  NULL
  );
 if (!NT_SUCCESS(ntStatus))
 {
  DevExt->ThreadTermFlag = TRUE;
  KeSetEvent(
   &DevExt->ReqEvent,
   (KPRIORITY)0,
   FALSE
   );
  goto ERROUT;
 }

ERROUT:
 if (!NT_SUCCESS(ntStatus))
 { 
  //如果上面有不成功的地方,首先需要解除可能存在的附加
  if (NULL != LowerDevObj)
  {
   IoDetachDevice(LowerDevObj);
   DevExt->LowerDevObj = NULL;
  }
  //然后删除可能建立的过滤设备
  if (NULL != FltDevObj)
  {
   IoDeleteDevice(FltDevObj);
   DevExt->FltDevObj = NULL;
  }
 }
 //关闭线程句柄,我们今后不会用到它,所有对线程的引用都通过线程对象来进行了
 if (NULL != ThreadHandle)
  ZwClose(ThreadHandle);
 //返回状态值
    return ntStatus;
}
3.PnP请求的处理
作为一个卷过滤驱动PnP请求是非常重要的,这是因为Windows操作系统在某些时候回向存储设备发出专门的请求,如果没有进行正确的处理将会造成系统无法正常关机等一系列的问题。
在收到PnP请求之后,由于DriverEntry中对PnP请求的处理函数特别设置成了DPDispatchPnp函数,所以DPDispatchPnp函数将会被调用。它具有两个参数:DeviceObject和irp,分别说明了这个请求发往的设备和这个请求的具体细节。由于这是过滤驱动的PnP分发函数,所以也只有在过滤驱动所建立的设备收到PnP请求时会调用这个函数。在AddDevice函数中,每个卷的过滤设备都会被建立相应的设备扩展,里面存储有很多过滤设备的属性信息,所以这个函数的一开始就需要将这些信息拿出来,同时需要通过irp参数中的irp stack成员来进一步确定irp请求的具体目的。
在获取到了这些参数之后可以直接通过判断irp stack中的MinorFunction来判断这个irp请求的具体目的是什么。在irp stack中,通常会存在MajorFunction和MinorFunction两个请求号,其中MajorFunction是大请求号,一般类似于Read,Write,PnP,DeviceIoControl等大分类的请求;而MinorFunction是小的请求号,一般在某一个大分类的子请求号。
第一个需要处理的PnP子请求是设备移除请求,这个请求会在Windows进行设备热插拔,均衡或关机的时候被发送到磁盘卷设备。当然过滤驱动会先于磁盘卷驱动收到这个请求,在这个请求发送时,所有的磁盘卷设备的读/写请求都应该已经完成,所以在过滤驱动收到这个请求的时候只需要简单的将曾经建立过的所有设备和初始化过的所有内部数据结构全部销毁即可。建立过的设备主要是AddDevice函数中建立的过滤设备和由绑定而生成的下层设备,内部数据结构主要包括了在下面介绍的Bitmap数据结构,此外在AddDevice函数中为卷设备建立的请求处理线程也需要停止。
switch(irpsp->MinorFunction)
 {
 case IRP_MN_REMOVE_DEVICE:
  //如果是PnP manager发过来的移除设备的irp,将进入这里
  {
   //这里主要做一些清理工作
   if (DevExt->ThreadTermFlag != TRUE && NULL != DevExt->ThreadHandle)
   {
    //如果线程还在运行的话需要停止它,这里通过设置线程停止运行的标志并且发送事件信息,让线程自己终止运行
    DevExt->ThreadTermFlag = TRUE;
    KeSetEvent(
     &DevExt->ReqEvent,
     (KPRIORITY) 0,
     FALSE
     );
    //等待线程结束
    KeWaitForSingleObject(
     DevExt->ThreadHandle,
     Executive,
     KernelMode,
     FALSE,
     NULL
     );
    //解除引用线程对象
    ObDereferenceObject(DevExt->ThreadHandle);
   }
    if (NULL != DevExt->Bitmap)
    {
    //如果还有位图,就释放
     DPBitmapFree(DevExt->Bitmap);
    }
   if (NULL != DevExt->LowerDevObj)
   {
    //如果存在着下层设备,就先去掉挂接
    IoDetachDevice(DevExt->LowerDevObj);
   }
    if (NULL != DevExt->FltDevObj)
    {
    //如果存在过滤设备,就要删除它
     IoDeleteDevice(DevExt->FltDevObj);
    }
   break;
  }
第二个需要处理的请求是设备通告请求,Windows操作系统会在建立或者删除特殊文件的时候想存储设备发出这个irp请求,作为存储设备卷过滤设备自然也会收到这个请求。这里说的特殊文件包括页面文件,休眠文件,dump文件。Windows会通过irp stack中的Parameters.UsageNotification.Type域来说明请求的是哪种文件,并且会使用Parameters.UsageNotification.InPath域来说明这个请求是在询问设备是否可以建立这个文件,还是在删除这个文件之后对这个设备的通知。在处理这个请求的时候,过滤驱动比较关心的是对页面文件的处理,因为这牵扯到过滤设备标志位中的DO_POWER_PAGEBLE;反之,就应该加上DO_POWER_PAGEBLE。
这个请求的根本目的是,Windows操作系统用来查询设备是否可以在其上建立特殊文件,作为过滤驱动程序是不应该对这种询问加以回答的,正确的处理方法是将这个请求发送给下层设备,由下层设备来回答这个问题。但是同时过滤驱动需要监视下层设备的问答,如果下层设备不支持这个请求,自然是最简单不过的事情,过滤设备什么都不做即可。反之,如果下层设备支持这个请求,那么过滤设备就需要进行处理,在下层设备对第一个页面文件建立请求回答之后,过滤设备需要对DO_POWER_PAGEBLE位进行相应的设置,并且做一个计数。这个计数会随着页面文件建立的请求而增加,随着页面文件删除的通知而减少,当减少到最后一个计数的时候,过滤设备又需要对DO_POWER_PAGEBLE位进行相应的设置。
//这个是PnP 管理器用来询问设备能否支持特殊文件的irp,作为卷的过滤驱动,我们必须处理
 case IRP_MN_DEVICE_USAGE_NOTIFICATION:
  {
   BOOLEAN setPagable;
   //如果是询问是否支持休眠文件和dump文件,则直接下发给下层设备去处理
   if (irpsp->Parameters.UsageNotification.Type != DeviceUsageTypePaging)
   {
    ntStatus = DPSendToNextDriver(
     DevExt->LowerDevObj,
     Irp);
    return ntStatus;
   }
   //这里等一下分页计数事件
   ntStatus = KeWaitForSingleObject(
    &DevExt->PagingPathCountEvent,
    Executive,
    KernelMode,
    FALSE,
    NULL);

   //setPagable初始化为假,是没有设置过DO_POWER_PAGABLE的意思
   setPagable = FALSE;
   if (!irpsp->Parameters.UsageNotification.InPath &&
    DevExt->PagingPathCount == 1 )
   {
    //如果是PnP manager通知我们将要删去分页文件,且我们目前只剩下最后一个分页文件的时候会进入这里
    if (DeviceObject->Flags & DO_POWER_INRUSH)
    {}
    else
    {
     //到这里说明没有分页文件在这个设备上了,需要设置DO_POWER_PAGABLE这一位了
     DeviceObject->Flags |= DO_POWER_PAGABLE;
     setPagable = TRUE;
    }
   }
   //到这里肯定是关于分页文件的是否可建立查询,或者是删除的通知,我们交给下层设备去做。这里需要用同步的方式给下层设备,也就是说要等待下层设备的返回
   ntStatus = DPForwardIrpSync(DevExt->LowerDevObj,Irp);

   if (NT_SUCCESS(ntStatus))
   {
    //如果发给下层设备的请求成功了,说明下层设备支持这个操作,会执行到这里
    //在成功的条件下我们来改变我们自己的计数值,这样就能记录我们现在这个设备上到底有多少个分页文件
    IoAdjustPagingPathCount(
     &DevExt->PagingPathCount,
     irpsp->Parameters.UsageNotification.InPath);
    if (irpsp->Parameters.UsageNotification.InPath)
    {
     if (DevExt->PagingPathCount == 1)
     {
      //如果这个请求是一个建立分页文件的查询请求,并且下层设备支持这个请求,而且这是第一个在这个设备上的分页文件,那么我们需要清除DO_POWER_PAGABLE位
      DeviceObject->Flags &= ~DO_POWER_PAGABLE;
     }
    }
   }
   else
   {
    //到这里说明给下层设备发请求失败了,下层设备不支持这个请求,这时候我们需要把之前做过的操作还原
    if (setPagable == TRUE)
    {
     //根据setPagable变量的值来判断我们之前是否做过对DO_POWER_PAGABLE的设置,如果有的话就清楚这个设置
     DeviceObject->Flags &= ~DO_POWER_PAGABLE;
     setPagable = FALSE;
    }
   }
   //设置分页计数事件
   KeSetEvent(
    &DevExt->PagingPathCountEvent,
    IO_NO_INCREMENT,
    FALSE
    );
   //到这里我们就可以完成这个irp请求了
   IoCompleteRequest(Irp, IO_NO_INCREMENT);
   return ntStatus;
  }  
4.Power请求的处理
Power请求的处理应和大部分irp请求一样,直接交给下层设备处理即可。只是在Windows Vista以前的操作系统中,下发所使用的函数是比较特殊的PoCallDriver,而且在这之前还需要使用PoStartNextPowerIrp来处理一下irp请求。这一情况在Windows Vista中得以改变,开发人员只需要使用一般的方法下发这个irp请求即可。本驱动中用了一个编译宏来判断当前操作系统的版本。
NTSTATUS
DPDispatchPower(
    IN PDEVICE_OBJECT DeviceObject,
    IN PIRP   Irp
    )
{
 //用来指向过滤设备的设备扩展的指针
 PDP_FILTER_DEV_EXTENSION DevExt = DeviceObject->DeviceExtension;
#if (NTDDI_VERSION < NTDDI_VISTA)
 //如果是vista以前的版本的windows,需要使用特殊的向下层设备转发的函数
 PoStartNextPowerIrp(Irp);
 IoSkipCurrentIrpStackLocation(Irp);
 return PoCallDriver(DevExt->LowerDevObj, Irp);
#else
 //如果是vista系统,可以使用和一般下发irp一样的方法来下发
 return DPSendToNextDriver(
  DevExt->LowerDevObj,
  Irp);
#endif 
}
5.DeviceIoControl请求的处理
DeviceIoControl请求的处理函数是DPDispatchDeviceContorl,作为一个磁盘卷设备的过滤驱动,理论上是不需要对DeviceIoControl做任何处理的,只需要如实地转换发给下层设备即可。但是在这里本驱动需要截获一个特殊的DeviceIoControl请求----IOCTL_VOLUME_ONLINE。这个请求是Windows操作系统发出的,它本身的作用是把目标卷设备设置为在线状态,在这个状态设置完毕后,才会有对这个卷的读/写操作发生。
对于这个以还原为目的的驱动来说,最好是在尽量早的时候就对读/写操作进行处理。基于这个理由IOCTL_VOLUME_ONLINE是一个很好的机会,所以在本驱动中,大部分的数据结构等初始化工作都将被放到这个DeviceIoControl的时候完成。
读者这时候可能会认为在收到IOCTL_VOLUME_ONLINE这个DeviceIoControl请求的时候直接做初始化工作即可,然后再将这个请求发往下层设备。这里有一问题在于初始化工作需要目标卷的一些信息,例如需要知道这个卷设备的卷标,因为这个驱动只保护“D”盘;需要知道这个卷的一些信息,例如卷的大小,因为初始化bitmap需要这个信息作为参数,但是这一切都必须要等到过滤驱动的下层设备也就是真正的卷设备开始运行之后才能提供,而卷设备开始运行却需要这个IOCTL_VOLUME_ONLINE的DeviceIoControl请求发下去。但实际上有一个很简单的办法可以解决这个问题,就是让请求先发送下去,等下层设备处理完毕之后在进行初始化工作,同时由于下发请求的时候采用了同步的方式,因此在完成请求之前是不会有其他请求发生的。
WDM驱动框架为实现上文所属的操作提供了相当方便的操作方式,只需要赋值一份irp stack,设置好完成函数和一个等待事件,在调用下层设备之后就开始等待这个事件,当下层设备处理完成之后之前设置的完成函数会被调用,在完成函数中会唤醒刚才所说的等待事件,于是一切都会顺理成章地走下去,当然在完成函数里上文所述的初始化工作就可以进行了。下面是如何设置完成函数和等待事件的代码,也就是在DeviceIoControl的分发函数中所做的事情。
NTSTATUS
DPDispatchDeviceControl(
 IN PDEVICE_OBJECT DeviceObject,
 IN PIRP   Irp
 )
{
 //用来指向过滤设备的设备扩展的指针
 PDP_FILTER_DEV_EXTENSION DevExt = DeviceObject->DeviceExtension;
 //返回值
 NTSTATUS ntStatus = STATUS_SUCCESS;
 //用来指向irp stack的指针
 PIO_STACK_LOCATION  irpsp = IoGetCurrentIrpStackLocation(Irp);
 //用来同步IOCTL_VOLUME_ONLINE处理的事件
 KEVENT     Event;
 //用来传给IOCTL_VOLUME_ONLINE的完成函数的上下文
 VOLUME_ONLINE_CONTEXT context;

 switch (irpsp->Parameters.DeviceIoControl.IoControlCode)
 {
 case IOCTL_VOLUME_ONLINE:
  {
   //如果是卷设备的IOCTL_VOLUME_ONLINE,会进入到这里
   //我们打算自己处理这个irp请求,这里先初始化一个事件用来在这个请求的完成函数里面做同步信号
   KeInitializeEvent(&Event, NotificationEvent, FALSE);
   //给这个请求的完成函数初始化参数
   context.DevExt = DevExt;
   context.Event = &Event;
   //这里copy一份irp stack
   IoCopyCurrentIrpStackLocationToNext(Irp);
   //设置完成函数
   IoSetCompletionRoutine(
    Irp,
    DPVolumeOnLineCompleteRoutine,
    &context,
    TRUE,
    TRUE,
    TRUE);
   //调用下层设备来处理这个irp
   ntStatus = IoCallDriver(DevExt->LowerDevObj, Irp);
   //等待下层设备处理结束这个irp
   KeWaitForSingleObject(
    &Event,
    Executive,
    KernelMode,
    FALSE,
    NULL);
   //返回
   return ntStatus;
  }
 default:
  //对于其它DeviceIoControl,我们一律调用下层设备去处理
  break;
 }
 return DPSendToNextDriver(DevExt->LowerDevObj,Irp);  
}
从上面的代码中可以看到,如何在获取到IOCTL_VOLUME_ONLINE请求的时候设置了名叫DPVolumeOnLineCompleteRoutine的完成函数,这个函数将在下层设备处理完成这个irp的时候被调用,下面看一下这个完成函数里都做了什么,这里需要注意的是,在这个完成函数里,下层设备所对应的磁盘卷设备已经可以工作了。
在完成函数里首先获取了卷的名称,即常见的C,D,E等盘符,这是通过系统调用获取到的,如果读者有兴趣,会发现这个系统调用是无法在IOCTL_VOLUME_ONLINE被下发之前使用的。在获取了这些盘符之后,根据驱动设计,这里只对"D"盘感兴趣,在发现盘符“D”的卷设备之后,首先获取这个卷的第一个扇区并分析其内容来取得所需信息。如果读者对之前介绍的DBR还有印象的话,应该会比较容易地搞清这些信息是如何获取的,这里就不再对代码进行分析了,在获取了卷的信息之后,需要初始化一个bitmap,这个bitmap是还原功能的核心数据结构,具体的作用和实现在下面介绍,这里读者只需要知道初始化bitmap的时候需要卷的总大小作为参数即可。在这些工作都完成之后,将用来表示还原卷的全局变量赋值,在今后运行的读/写分发函数和boot驱动回调函数等众多函数中,都会引用这个全局变量,并根据它的内容来确定哪个是需要保护的卷。下面是完成函数的具体实现过程,在代码中读者可以发现,作为参数被传入的等待事件在最后被唤醒,这使得上面的DeviceIoControl处理代码中的等待得以返回,系统调用得以继续运行下去。
NTSTATUS
DPVolumeOnLineCompleteRoutine(
 IN PDEVICE_OBJECT  DeviceObject,
 IN PIRP  Irp,
 IN PVOLUME_ONLINE_CONTEXT  Context
 )
{
 //返回值
 NTSTATUS ntStatus = STATUS_SUCCESS;
 //这个卷设备的dos名字,也就是C,D等
 UNICODE_STRING  DosName = { 0 };

 //在这里Context是不可能为空的,为空就是出错了
 ASSERT(Context!=NULL);
 //下面调用我们自己的VolumeOnline处理
 //获取这个卷的dos名字
 ntStatus = IoVolumeDeviceToDosName(Context->DevExt->PhyDevObj, &DosName);
 if (!NT_SUCCESS(ntStatus))
  goto ERROUT;
 //将dos名字变成大写形式
 Context->DevExt->VolumeLetter = DosName.Buffer[0];
 if (Context->DevExt->VolumeLetter > L'Z')
  Context->DevExt->VolumeLetter -= (L'a' - L'A');
 //我们只保护“D”盘
 if (Context->DevExt->VolumeLetter == L'D')
 {
  //获取这个卷的基本信息
  ntStatus = DPQueryVolumeInformation(
   Context->DevExt->PhyDevObj,
   &(Context->DevExt->TotalSizeInByte),
   &(Context->DevExt->ClusterSizeInByte),
   &(Context->DevExt->SectorSizeInByte));
  if (!NT_SUCCESS(ntStatus))
  {
   goto ERROUT;
  }
  //建立这个卷对应的位图
  ntStatus = DPBitmapInit(
   &Context->DevExt->Bitmap,
   Context->DevExt->SectorSizeInByte,
   8,
   25600,
   (DWORD)(Context->DevExt->TotalSizeInByte.QuadPart /
   (LONGLONG)(25600 * 8 * Context->DevExt->SectorSizeInByte)) + 1);
  if (!NT_SUCCESS(ntStatus))
   goto ERROUT;
  //对全局量赋值,说明我们找到需要保护的那个设备了
  gProtectDevExt = Context->DevExt;
 }
 
ERROUT:
 if (!NT_SUCCESS(ntStatus))
 {
  if (NULL != Context->DevExt->Bitmap)
  {
   DPBitmapFree(Context->DevExt->Bitmap);
  }
  if (NULL != Context->DevExt->TempFile)
  {
   ZwClose(Context->DevExt->TempFile);
  }
 }
 if (NULL != DosName.Buffer)
 {
  ExFreePool(DosName.Buffer);
 }
 //设置等待同步事件,这样可以让我们等待的DeviceIoControl处理过程继续运行
 KeSetEvent(
  Context->Event,
  0,
  FALSE);
 return STATUS_SUCCESS;
}

6.bitmap的作用和分析
在上面的分析中读者已经多次看到了bitmap,但却一直不知道它具体是什么,它的作用是什么,为什么要用它,他是如何实现的?下面将会解答这些问题。在做进一步说明之前需要提到的是,具体分析bitmap的实现比较复杂,如果读者对算法没有特殊兴趣的话,则可以只看bitmap的接口说明而不去管它的具体实现过程,这并不会影响对之后内容的理解。
顾名思义,bitmap就是一个位图。它实际上是一些内存块,这些内存块的每一个位用来标识一个磁盘上的最小访问单位,一般情况下是一个扇区。每一个位可以被置位或者被清除,用来表示这个扇区对应的两种状态。
如果读者对开始时所描述的还原理论还有印象的话,应该知道作为一个还原驱动,核心的问题在于如何将写入的数据存储在其他地方,而在读取时又能够准确的从其他地方找到,为了达到这个目的就必须使用bitmap。bitmap中的每一位对应的是磁盘上的一个扇区,有多少个扇区就有多少位。这个位为0所代表的意义是,是这个为所对应的扇区的数据没有被存储到其他地方,而反之则代表这个扇区的数据被存储到了其他地方。在写数据的时候,根据操作范围可以讲bitmap中对应的区域置为1,在读操作的时候则又会根据bitmap的内容把置为1的扇区从转存的地方读回来;而对bitmap为0的地方还是从原有设备上读取数据,这样bitmap就成了在这次系统启动生命周期中所有写操作的标志知道重启系统,在重启过后bitmap又将恢复为全0状态,这时无论是什么读操作都不会从转存处拿数据,也就达到了还原的功能。
之所以说bitmap是一些内存块而不是一个连续的内存,是因为在设计bitmap的时候考虑到它所表示的位图可能对应着很大的一块磁盘区域,即使是用一位来表示512B的数据也有可能会是很大的一片内存空间。所以在设计bitmap的时候要求它能够按需分配内存,在用到的时候才去分配对应的内存,这样可以节约大量的内存空间。要知道这里所说的内存空间都是指非分页内存,这一部分内存即使是在内核中也是非常宝贵的。
首先来看一下bitmap的内部数据结构
typedef struct _DP_BITMAP_
{
 //这个卷中的每个扇区有多少字节,这同样也说明了bitmap中一个位所对应的字节数
    unsigned long sectorSize;
 //每个byte里面有几个bit,一般情况下是
    unsigned long byteSize;
 //每个块是多大byte,
    unsigned long regionSize;
 //这个bitmap总共有多少个块
    unsigned long regionNumber;
 //这个块对应了多少个实际的byte,这个数字应该是sectorSize*byteSize*regionSize
    unsigned long regionReferSize;
 //这个bitmap对应了多少个实际的byte,这个数字应该是sectorSize*byteSize*regionSize*regionNumber
    __int64 bitmapReferSize;
 //指向bitmap存储空间的指针
    tBitmap** Bitmap;
 //用于存取bitmap的锁
    void* lockBitmap;
} DP_BITMAP, * PDP_BITMAP;
可以看到bitmap的最上层是1字节类型的指针的指针bitmap,在这里希望读者把这个指针理解成一个指针数组,数组有regionSize个元素,每个元素就是一个指向所谓的内存块的指针。在一开始的时候这些指向内存块的指针都是空指针,这时它们代表了一个内容为0的内存块,只是实际的内存没有被分配出来。当需要将其中任何一位设置为1的时候,这个内存块会首先被分配,在清零之后再对其中需要设置为1的位进行设置,这就是所说的按需分配,也是节约空间的关键所在。下面是初始化这个数据结构的代码,用户通过指定bitmap的参数来初始化一个bitmap,在这里用户需要知道这个bitmap一共代表了多大的区域;同时需要给定一个块的大小,这个大小设得太大可能造成分配空间的浪费,设得太小又会使得块的数目太多,所以一般需要设一个合适的中间值。下面初始化一个bitmap的代码
NTSTATUS DPBitmapInit(
 DP_BITMAP **     bitmap,
 unsigned long       sectorSize,
 unsigned long       byteSize,
 unsigned long       regionSize,
 unsigned long       regionNumber
 )
{
 int i = 0;
 DP_BITMAP * myBitmap = NULL;
 NTSTATUS status = STATUS_SUCCESS;

 //检查参数,以免使用了错误的参数导致发生处零错等错误
 if (NULL == bitmap || 0 == sectorSize ||
  0 == byteSize || 0 == regionSize  || 0 == regionNumber)
 {
  return STATUS_UNSUCCESSFUL;
 }
 __try
 {
  //分配一个bitmap结构,这是无论如何都要分配的,这个结构相当于一个bitmap的handle 
  if (NULL == (myBitmap = (DP_BITMAP*)DPBitmapAlloc(0, sizeof(DP_BITMAP))))
  {
   status = STATUS_INSUFFICIENT_RESOURCES;
   __leave;
  }
  //清空结构
  memset(myBitmap, 0, sizeof(DP_BITMAP));
  //根据参数对结构中的成员进行赋值
  myBitmap->sectorSize = sectorSize;
  myBitmap->byteSize = byteSize;
  myBitmap->regionSize = regionSize;
  myBitmap->regionNumber = regionNumber;
  myBitmap->regionReferSize = sectorSize * byteSize * regionSize;
  myBitmap->bitmapReferSize = (__int64)sectorSize * (__int64)byteSize * (__int64)regionSize * (__int64)regionNumber;
  //分配出regionNumber那么多个指向region的指针,这是一个指针数组
  if (NULL == (myBitmap->Bitmap = (tBitmap **)DPBitmapAlloc(0, sizeof(tBitmap*) * regionNumber)))
  {
   status = STATUS_INSUFFICIENT_RESOURCES;
   __leave;
  }
  //清空指针数组
  memset(myBitmap->Bitmap, 0, sizeof(tBitmap*) * regionNumber);
  * bitmap = myBitmap;
  status = STATUS_SUCCESS;
 }
 __except(EXCEPTION_EXECUTE_HANDLER)
 {
  status = STATUS_UNSUCCESSFUL;
 }
 if (!NT_SUCCESS(status))
 {
  if (NULL != myBitmap)
  {
   DPBitmapFree(myBitmap);
  }
  * bitmap = NULL;
 }
 return status;
}
在上面的代码中可以看出,初始化bitmap的过程中仅仅分配了很少一部分内存。而这时这个bitmap却是完全可用的,只有在对其进行位设置的时候才会有新的内存被分配出来。
bitmap提供了一个接口用来将其中的某一区域置位,因为在bitmap的初始化过程中所有的位都认为是0,而在今后使用的过程中也看不出来需要将1变成0的可能,这就使得这里只需要提供置位的接口即可,而不需要清除位的接口。这个接口函数需要考虑的第一个问题是,在所需要的目标bitmap内存区域没有被分配的时候需要先分配才能置位。需要考虑的第二个问题是,如何能够尽快的完成一个对一长段连续的bitmap做位置的请求。下面请看这两个问题的具体处理方式
NTSTATUS DPBitmapSet(
 DP_BITMAP *      bitmap,
 LARGE_INTEGER       offset,
 unsigned long       length
 )
{
 __int64 i = 0;
 unsigned long myRegion = 0, myRegionEnd = 0;
 unsigned long myRegionOffset = 0, myRegionOffsetEnd = 0;
 unsigned long myByteOffset = 0, myByteOffsetEnd = 0;
 unsigned long myBitPos = 0;
 NTSTATUS status = STATUS_SUCCESS;
 LARGE_INTEGER setBegin = { 0 }, setEnd = { 0 };

 __try
 {
  //检查变量
  if (NULL == bitmap || offset.QuadPart < 0)
  {
   status = STATUS_INVALID_PARAMETER;
   __leave;
  }
  if (0 != offset.QuadPart % bitmap->sectorSize || 0 != length % bitmap
   ->sectorSize)
  {
   status = STATUS_INVALID_PARAMETER;
   __leave;
  }

  //根据要设置的偏移量和长度来计算需要使用到哪些region,如果需要的话,就分配他们指向的内存空间
  myRegion = (unsigned long)(offset.QuadPart / (__int64)bitmap->regionReferSize);
  myRegionEnd = (unsigned long)((offset.QuadPart + (__int64)length) / (__int64)bitmap->regionReferSize);
  for (i = myRegion; i <= myRegionEnd; ++i)
  {
   if (NULL == *(bitmap->Bitmap + i))
   {
    if (NULL == (*(bitmap->Bitmap + i) = (tBitmap*)DPBitmapAlloc(0, sizeof(tBitmap) * bitmap->regionSize)))
    {
     status = STATUS_INSUFFICIENT_RESOURCES;
     __leave;
    }
    else
    {
     memset(*(bitmap->Bitmap + i), 0, sizeof(tBitmap) * bitmap->regionSize);
    }
   }
  }

  //开始设置bitmap,首先我们需要将要设置的区域按照byte对齐,这样可以按byte设置而不需要按bit设置,加快设置速度
  //对于没有byte对齐的区域先手工设置掉他们
  for (i = offset.QuadPart; i < offset.QuadPart + (__int64)length; i += bitmap->sectorSize)
  {
   myRegion = (unsigned long)(i / (__int64)bitmap->regionReferSize);
   myRegionOffset = (unsigned long)(i % (__int64)bitmap->regionReferSize);
   myByteOffset = myRegionOffset / bitmap->byteSize / bitmap->sectorSize;
   myBitPos = (myRegionOffset / bitmap->sectorSize) % bitmap->byteSize;
   if (0 == myBitPos)
   {
    setBegin.QuadPart = i;
    break;
   }
   *(*(bitmap->Bitmap + myRegion) + myByteOffset) |= bitmapMask[myBitPos];
  }
  if (i >= offset.QuadPart + (__int64)length)
  {
   status = STATUS_SUCCESS;
   __leave;
  }

  for (i = offset.QuadPart + (__int64)length - bitmap->sectorSize; i >= offset.QuadPart; i -= bitmap->sectorSize)
  {
   myRegion = (unsigned long)(i / (__int64)bitmap->regionReferSize);
   myRegionOffset = (unsigned long)(i % (__int64)bitmap->regionReferSize);
   myByteOffset = myRegionOffset / bitmap->byteSize / bitmap->sectorSize;
   myBitPos = (myRegionOffset / bitmap->sectorSize) % bitmap->byteSize;
   if (7 == myBitPos)
   {
    setEnd.QuadPart = i;
    break;
   }
   *(*(bitmap->Bitmap + myRegion) + myByteOffset) |= bitmapMask[myBitPos];
  }

  if (i < offset.QuadPart || setEnd.QuadPart == setBegin.QuadPart)
  {
   status = STATUS_SUCCESS;
   __leave;
  }

  myRegionEnd = (unsigned long)(setEnd.QuadPart / (__int64)bitmap->regionReferSize);

  for (i = setBegin.QuadPart; i <= setEnd.QuadPart;)
  {
   myRegion = (unsigned long)(i / (__int64)bitmap->regionReferSize);
   myRegionOffset = (unsigned long)(i % (__int64)bitmap->regionReferSize);
   myByteOffset = myRegionOffset / bitmap->byteSize / bitmap->sectorSize;
   //如果我们设置的区域没有跨两个region,只需要使用memset去做按byte的设置然后跳出即可
   if (myRegion == myRegionEnd)
   {
    myRegionOffsetEnd = (unsigned long)(setEnd.QuadPart % (__int64)bitmap->regionReferSize);
    myByteOffsetEnd = myRegionOffsetEnd / bitmap->byteSize / bitmap->sectorSize;
    memset(*(bitmap->Bitmap + myRegion) + myByteOffset, 0xff, myByteOffsetEnd - myByteOffset + 1);
    break;
   }
   //如果我们设置的区域跨了两个region,需要设置完后递增
   else
   {
    myRegionOffsetEnd = bitmap->regionReferSize;
    myByteOffsetEnd = myRegionOffsetEnd / bitmap->byteSize / bitmap->sectorSize;
    memset(*(bitmap->Bitmap + myRegion) + myByteOffset, 0xff, myByteOffsetEnd - myByteOffset);
    i += (myByteOffsetEnd - myByteOffset) * bitmap->byteSize * bitmap->sectorSize;
   }
  }
  status = STATUS_SUCCESS;
 }
 __except(EXCEPTION_EXECUTE_HANDLER)
 {
  status = STATUS_UNSUCCESSFUL;
 }

 if (!NT_SUCCESS(status))
 {
  
 }
 return status;
}
在上面的代码笔者略去对具体的位设置过程的讲解,这只是普通的四则混合运算,请读者根据驱动加以理解。读者可以在上面的代码中看到,设置位的函数是如何先通过计算确定需要使用哪些块,并且在需要的时候分配它们的,然后是如何尽可能地按照一个字而不是按照一个位来对所需要的位进行设置。
除了位置之外,bitmap也需要提供一个能够测试指定位图区域是全部为1还是全部为0,异或兼而有之的接口。这个接口在于,用户可以通过测试的结果决定如何进行下一步的操作。这个测试函数的代码比较简单,只是根据内存的数据来进行判断,这里就不列举代码了。
最后,bitmap在完成了设置和测试的功能之后,还需要提供一个获取指定区域位图的接口,在后面的分析中读者可以看到,这个获取指定区域的位图操作一定是伴随着磁盘读操作而来的。之前反复强调如果是读操作的话,对于bitmap设置为1取了指定区域的位图之后,需要根据这个位图中的0和1来决定最终生成的数据哪一部分是从原始数据中来,哪一部分是从转存数据中来。由于使用环境的特殊性,这个接口被演变成为将两个内存缓冲区的内容根据指定的bitmap来进行合并操作,读者应该很容易想到这两个缓冲区一个是读取自转存的数据,一个是读取自原始的数据。这个函数的代码如下:
NTSTATUS DPBitmapGet(
 DP_BITMAP *    bitmap,
 LARGE_INTEGER     offset,
 unsigned long     length,
 void *            bufInOut,
 void *            bufIn
 )
{
 unsigned long i = 0;
 unsigned long myRegion = 0;
 unsigned long myRegionOffset = 0;
 unsigned long myByteOffset = 0;
 unsigned long myBitPos = 0;
 NTSTATUS status = STATUS_SUCCESS;

 __try
 {
  //检查参数
  if (NULL == bitmap || offset.QuadPart < 0 || NULL == bufInOut || NULL == bufIn)
  {
   status = STATUS_INVALID_PARAMETER;
   __leave;
  }
  if (0 != offset.QuadPart % bitmap->sectorSize || 0 != length % bitmap->sectorSize)
  {
   status = STATUS_INVALID_PARAMETER;
   __leave;
  }

  //遍历需要获取的位图范围,如果出现了位被设置为,就需要用bufIn参数中指向的相应位置的数据拷贝到bufInOut中
  for (i = 0; i < length; i += bitmap->sectorSize)
  {
   myRegion = (unsigned long)((offset.QuadPart + (__int64)i) / (__int64)bitmap->regionReferSize);

   myRegionOffset = (unsigned long)((offset.QuadPart + (__int64)i) % (__int64)bitmap->regionReferSize);

   myByteOffset = myRegionOffset / bitmap->byteSize / bitmap->sectorSize;

   myBitPos = (myRegionOffset / bitmap->sectorSize) % bitmap->byteSize;

   if (NULL != *(bitmap->Bitmap + myRegion) && (*(*(bitmap->Bitmap + myRegion) + myByteOffset) &bitmapMask[myBitPos]))
   {
    memcpy((tBitmap*)bufInOut + i, (tBitmap*)bufIn + i, bitmap->sectorSize);
   }
  }

  status = STATUS_SUCCESS;
 }
 __except(EXCEPTION_EXECUTE_HANDLER)
 {
  status = STATUS_UNSUCCESSFUL;
 }

 return status;
}
7.boot驱动完成回调函数和稀疏文件
到这里为止,离最终的读/写转向处理只有最后的一点准备工作了。而这个准备工作放在boot驱动完成回/调函数中,至于为什么要放在这里,则是由于本驱动采用的转存缓冲区的机制决定的。
前面已经反复强调这个驱动会将写入保护磁盘的数据转存到另一个地方,那么这个地方在哪?在此本驱动使用了一个最为简单的方法----把数据转存到另一个卷的稀疏文件中。稀疏文件是NTFS文件系统的一个特有的概念,他就好像上一节课所说的bitmap一样,建立时可以表示很大的空间,但是却完全不占用实际的存储空间,只有在向其写入数据的时候才会使用到真正的存储空间。这就是说可以在一个容量只有1GB的磁盘卷上建立一个大小为10GB的稀疏文件,程序可以对这个10GB空间中的人很一个位置进行读/写操作,但是写入的总数据量不能超过1GB。至于为什么将这个稀疏文件放在了另一个磁盘上,主要是因为如果放在同一个磁盘卷上,在写入这个文件的时候势必会被过滤驱动捕获,这就形成了一个典型的重入。当然这种重入是很容易避免的,但是为了不引起不必要的麻烦,这个用于教学目的的驱动就使用了另一个卷作为转储的空间,这样就从根本上避免了重入问题。
那么在开始所说的准备工作又是什么呢?这个工作实际上就是准备好这个稀疏文件,建立它,设置它的大小并且打开它。那么为什么需要在boot驱动完成函数中做这些事情呢?这是因为稀疏文件的操作是依赖于文件系统的,作为文件系统的驱动程序,NTFS驱动是一个boot型驱动,但是它只有在卷设备开始工作了之后才会将自己的处理设备附加到这个卷上,从而响应对这个卷的所有文件请求。这就说明在之前无论是AddDevice函数还是VOLUME_ONLINE的DeviceIoControl中,NTFS文件都是不能读/写的。而在boot驱动的完成回调函数中,所有的boot驱动都已经加载完毕,NTFS自然也不例外,这时对于NTFS文件的读./写就轻而易举了。下面看一下最后一步准备工作的代码。
VOID
DPReinitializationRoutine(
 IN PDRIVER_OBJECT DriverObject,
 IN PVOID   Context,
 IN ULONG   Count
 )
{
 //返回值
 NTSTATUS ntStatus;
 //D盘的缓冲文件名
 WCHAR    SparseFilename[] = L"\\??\\E:\\temp.dat";
 UNICODE_STRING  SparseFilenameUni;
 //建立文件时的io操作状态值
 IO_STATUS_BLOCK     ios = { 0 };
 //建立文件时的对象属性变量
 OBJECT_ATTRIBUTES    ObjAttr = { 0 };
 //设置文件大小的时候使用的文件结尾描述符
 FILE_END_OF_FILE_INFORMATION    FileEndInfo = { 0 };

 //打开我们将要用来做转储的文件
 //初始化要打开的文件名
 RtlInitUnicodeString(&SparseFilenameUni,SparseFilename);
 //初始化文件名对应的对象名,这里需要将其初始化为内核对象,并且大小写不敏感
 InitializeObjectAttributes(
  &ObjAttr,
  &SparseFilenameUni,
  OBJ_KERNEL_HANDLE|OBJ_CASE_INSENSITIVE,
  NULL,
  NULL);
 //建立文件,这里需要注意的是,要加入FILE_NO_INTERMEDIATE_BUFFERING选项,避免文件系统再缓存这个文件
 ntStatus = ZwCreateFile(
  &gProtectDevExt->TempFile,
  GENERIC_READ | GENERIC_WRITE,
  &ObjAttr,
  &ios,
  NULL,
  FILE_ATTRIBUTE_NORMAL,
  0,
  FILE_OVERWRITE_IF,
  FILE_NON_DIRECTORY_FILE |
  FILE_RANDOM_ACCESS |
  FILE_SYNCHRONOUS_IO_NONALERT |
  FILE_NO_INTERMEDIATE_BUFFERING,
  NULL,
  0);
 if(!NT_SUCCESS(ntStatus))
 {
  goto ERROUT;
 }
 //设置这个文件为稀疏文件
 ntStatus = ZwFsControlFile(
  gProtectDevExt->TempFile,
  NULL,
  NULL,
  NULL,
  &ios,
  FSCTL_SET_SPARSE,
  NULL,
  0,
  NULL,
  0);
 if(!NT_SUCCESS(ntStatus))
 {
  goto ERROUT;
 }
 //设置这个文件的大小为"D"盘的大小并且留出m的保护空间
 FileEndInfo.EndOfFile.QuadPart = gProtectDevExt->TotalSizeInByte.QuadPart + 10*1024*1024;
 ntStatus = ZwSetInformationFile(
  gProtectDevExt->TempFile,
  &ios,
  &FileEndInfo,
  sizeof(FILE_END_OF_FILE_INFORMATION),
  FileEndOfFileInformation
  );
 if (!NT_SUCCESS(ntStatus))
 {
  goto ERROUT;
 }
 //如果成功初始化就将这个卷的保护标志设置为在保护状态
 gProtectDevExt->Protect = TRUE;
 return;
ERROUT:
 KdPrint(("error create temp file!\n"));
 return;
}
可以看到,在准备工作中首先建立了预先制定好文件名的文件,并将其属性设置为稀疏文件,之后通过设这文件结尾的方法将这个文件的大小变为之前获取到的“D”盘的大小。这时所有准备工作都已经齐备了,将保护标志设置为真,本驱动中最为核心的数据转储过程即将开始。
8.读/写请求的处理
在本驱动中,最为核心的部分就是读/写请求的处理部分。所有的读/写请求必须按照顺序以同步的方式处理,只有上一个操作被处理完成之后,下一个操作才可以开始被处理。这是因为过滤驱动内部的bitmap设置,读取,转存文件的读/写等操作是无法做到并行处理的,如果不进行读/写请求的顺序化,则有可能带来读/写不同步的问题,即一个写操作还没有完成,另一个读取操作又将来到,这会造成后来的读取数据不正确。为了达到这个目的,对所有流经过滤设备的磁盘卷设备读/写请求,除了不需要保护的卷之外,其他的必须全部顺序放入一个处理队列中,由一个处理线程对这个队列中的请求进行顺序处理。下面看一下这段代码:
NTSTATUS
DPDispatchReadWrite(
    IN PDEVICE_OBJECT DeviceObject,
    IN PIRP   Irp
    )

 //用来指向过滤设备的设备扩展的指针
 PDP_FILTER_DEV_EXTENSION DevExt = DeviceObject->DeviceExtension;
 //返回值
 NTSTATUS ntStatus = STATUS_SUCCESS;

 if (DevExt->Protect)
 {
  //这个卷在保护状态,
  //我们首先把这个irp设为pending状态
  IoMarkIrpPending(Irp);
  //然后将这个irp放进相应的请求队列里
  ExInterlockedInsertTailList(
   &DevExt->ReqList,
   &Irp->Tail.Overlay.ListEntry,
   &DevExt->ReqLock
   );
  //设置队列的等待事件,通知队列对这个irp进行处理
  KeSetEvent(
   &DevExt->ReqEvent,
   (KPRIORITY)0,
   FALSE);
  //返回pending状态,这个irp就算处理完了
  return STATUS_PENDING;
 }
 else
 {
  //这个卷不在保护状态,直接交给下层设备进行处理
  return DPSendToNextDriver(
   DevExt->LowerDevObj,
   Irp);
 }
}
在上面的代码中可以看出,首先对作为参数传入的设备对象扩展中的保护位进行判断,这一位是在boot驱动结束回调函数中进行设置的,并且仅仅对“D”磁盘卷的设备扩展进行设置。如果这一位为非保护状态,过滤驱动将会把这个读/写请求直接发向下层设备去处理;反之,如果这一位是保护状态,过滤驱动将会把这个请求设置为等待处理状态,然后将其插入到为了这个设备所准备的队列中,并且通过设置队列同步事件来通知处理线程对这个请求进行处理。
至此,处理队列中已经塞满了等待处理的读/写请求,而处理线程会忙于将这些请求分门别类地处理好。下面会讲解处理线程中的代码。由于这一段代码是如此重要,这里需要将其分为好几段来讲解。
首先是处理线程函数中只运行一遍的部分,包括变量的声明和对这个线程优先级的设置,由于这里不需要这个线程以非常高的优先级运行,所以将线程的优先级设置为低。
 //NTSTATUS类型的函数返回值
 NTSTATUS     ntStatus = STATUS_SUCCESS;
 //用来指向过滤设备的设备扩展的指针
 PDP_FILTER_DEV_EXTENSION DevExt = (PDP_FILTER_DEV_EXTENSION)Context;
 //请求队列的入口
 PLIST_ENTRY   ReqEntry = NULL;
 //irp指针
 PIRP    Irp = NULL;
 //irp stack指针
 PIO_STACK_LOCATION Irpsp = NULL;
 //irp中包括的数据地址
 PBYTE    sysBuf = NULL;
 //irp中的数据长度
 ULONG    length = 0;
 //irp要处理的偏移量
 LARGE_INTEGER  offset = { 0 };
 //文件缓冲指针
 PBYTE    fileBuf = NULL;
 //设备缓冲指针
 PBYTE    devBuf = NULL;
 //io操作状态
 IO_STATUS_BLOCK  ios;

 //设置这个线程的优先级
 KeSetPriorityThread(KeGetCurrentThread(), LOW_REALTIME_PRIORITY);
接下来就是线程中的无限循环部分。读者应该知道对于一个线程来说,其中必须有一个不会退出的循环体作为线程的工作主体部分,如果这个县城需要结束的话,一般会通过退出这个循环体来结束线程。由于在线程外无法通过api调用的方式结束线程,所以在每个线程循环体里一般会通过一个全局变量进行线程是否需要退出的判断,如果在线程外的任何地方将这个全局变量设置为退出,那么在线程循环下一次运行到这个位置时候就会自己跳出循环,结束自己。
//下面是线程的实现部分,这个循环永不退出
 for (;;)
 { 
  //先等待请求队列同步事件,如果队列中没有irp需要处理,我们的线程就等待在这里,让出cpu时间给其它线程
  KeWaitForSingleObject(
   &DevExt->ReqEvent,
   Executive,
   KernelMode,
   FALSE,
   NULL
   );
  //如果有了线程结束标志,那么就在线程内部自己结束自己
  if (DevExt->ThreadTermFlag)
  {
   //这是线程的唯一退出地点
   PsTerminateSystemThread(STATUS_SUCCESS);
   return;
  }
下面就轮到真正的请求处理逻辑了。首先需要从处理请求队列中取出一个请求来,这里通过带有锁机制的操作将处理请求队列头上的请求取出。由于在插入队列的时候是从队列的尾部插入,这样就保证了是按照插入的顺序来进行请求处理的。在获取到了请求之后,可以根据请求中的参数对一些局部变量进行赋值。
  //从请求队列的首部拿出一个请求来准备处理,这里使用了自旋锁机制,所以不会有冲突
  while (ReqEntry = ExInterlockedRemoveHeadList(
   &DevExt->ReqList,
   &DevExt->ReqLock
   ))
  {
   //从队列的入口里找到实际的irp的地址
   Irp = CONTAINING_RECORD(ReqEntry, IRP, Tail.Overlay.ListEntry);
   //取得irp stack
   Irpsp = IoGetCurrentIrpStackLocation(Irp);
   //获取这个irp其中包含的缓存地址,这个地址可能来自mdl,也可能就是直接的缓冲,这取决于我们当前设备的io方式是buffer还是direct方式
   if (NULL == Irp->MdlAddress)
    sysBuf = (PBYTE)Irp->UserBuffer;
   else
    sysBuf = (PBYTE)MmGetSystemAddressForMdlSafe(Irp->MdlAddress, NormalPagePriority);
下面轮到了对读请求的处理,通过前面几节的反复说明,读者应该对如何处理读请求做到心中有数。这里首先根据需要读取的范围对bitmap中响应的范围进行测试,如果测试的结果是这些数据全部在原始盘上,那么这个请求就被直接发给下层设备去处理。如果发现这些数据全部在转存文件中,就通过对转存文件的读取来获得数据,并完成这个irp请求。这里需要说明的是,如果出现这种情况,那么一定是之前有写请求将这一范围内的数据写入了转存文件中。如果发现需要读取的目标范围中的一部分在转存文件中,另一部分在实际磁盘上,就首先需要通过下层设备发送请求来获取真实磁盘上的数据,然后通过读取转存文件来获取转储的数据,最后通过bitmap的相应接口函数将两个读取的数据按照bitmap的指示进行合并,在完成这个读irp请求。
   if (IRP_MJ_READ == Irpsp->MajorFunction)
   {
    //如果是读的irp请求,我们在irp stack中取得相应的参数作为offset和length
    offset = Irpsp->Parameters.Read.ByteOffset;
    length = Irpsp->Parameters.Read.Length;
   }
   else if (IRP_MJ_WRITE == Irpsp->MajorFunction)
   {
    //如果是写的irp请求,我们在irp stack中取得相应的参数作为offset和length
    offset = Irpsp->Parameters.Write.ByteOffset;
    length = Irpsp->Parameters.Write.Length;
   }
   else
   {
    //除此之外,offset和length都是
    offset.QuadPart = 0;
    length = 0;
   }
   if (NULL == sysBuf || 0 == length)
   {
    //如果传下来的irp没有系统缓冲或者缓冲的长度是,那么我们就没有必要处理这个irp,直接下发给下层设备就行了
    goto ERRNEXT;
   }
   //下面是转储的过程了
   if (IRP_MJ_READ == Irpsp->MajorFunction)
   {
    //这里是读的处理
    //首先根据bitmap来判断这次读操作读取的范围是全部为转储空间,还是全部为未转储空间,或者兼而有之
    long tstResult = DPBitmapTest(DevExt->Bitmap, offset, length);
    switch (tstResult)
    {
    case BITMAP_RANGE_CLEAR:
     //这说明这次读取的操作全部是读取未转储的空间,也就是真正的磁盘上的内容,我们直接发给下层设备去处理
     goto ERRNEXT;
    case BITMAP_RANGE_SET:
     //这说明这次读取的操作全部是读取已经转储的空间,也就是缓冲文件上的内容,我们从文件中读取出来,然后直接完成这个irp
     //分配一个缓冲区用来从缓冲文件中读取
     if (NULL == (fileBuf = (PBYTE)ExAllocatePoolWithTag(NonPagedPool, length, 'xypD')))
     {
      ntStatus = STATUS_INSUFFICIENT_RESOURCES;
      Irp->IoStatus.Information = 0;
      goto ERRERR;
     }
     RtlZeroMemory(fileBuf,length);
     ntStatus = ZwReadFile(
      DevExt->TempFile,
      NULL,
      NULL,
      NULL,
      &ios,
      fileBuf,
      length,
      &offset,
      NULL);
     if (NT_SUCCESS(ntStatus))
     {
      Irp->IoStatus.Information = length;
      RtlCopyMemory(sysBuf,fileBuf,Irp->IoStatus.Information);
      goto ERRCMPLT;
     }
     else
     {
      ntStatus = STATUS_INSUFFICIENT_RESOURCES;
      Irp->IoStatus.Information = 0;
      goto ERRERR;
     }
     break;

    case BITMAP_RANGE_BLEND:
     //这说明这次读取的操作是混合的,我们也需要从下层设备中读出,同时从文件中读出,然后混合并返回
     //分配一个缓冲区用来从缓冲文件中读取
     if (NULL == (fileBuf = (PBYTE)ExAllocatePoolWithTag(NonPagedPool, length, 'xypD')))
     {
      ntStatus = STATUS_INSUFFICIENT_RESOURCES;
      Irp->IoStatus.Information = 0;
      goto ERRERR;
     }
     RtlZeroMemory(fileBuf,length);
     //分配一个缓冲区用来从下层设备中读取
     if (NULL == (devBuf = (PBYTE)ExAllocatePoolWithTag(NonPagedPool, length, 'xypD')))
     {
      ntStatus = STATUS_INSUFFICIENT_RESOURCES;
      Irp->IoStatus.Information = 0;
      goto ERRERR;
     }
     RtlZeroMemory(devBuf,length);
     ntStatus = ZwReadFile(
      DevExt->TempFile,
      NULL,
      NULL,
      NULL,
      &ios,
      fileBuf,
      length,
      &offset,
      NULL);
     if (!NT_SUCCESS(ntStatus))
     {
      ntStatus = STATUS_INSUFFICIENT_RESOURCES;
      Irp->IoStatus.Information = 0;
      goto ERRERR;
     }
     //把这个irp发给下层设备去获取需要从设备上读取的信息
     ntStatus = DPForwardIrpSync(DevExt->LowerDevObj,Irp);
     if (!NT_SUCCESS(ntStatus))
     {
      ntStatus = STATUS_INSUFFICIENT_RESOURCES;
      Irp->IoStatus.Information = 0;
      goto ERRERR;
     }
     //将从下层设备获取到的数据存储到devBuf中
     memcpy(devBuf, sysBuf, Irp->IoStatus.Information);
     //把从文件获取到的数据和从设备获取到的数据根据相应的bitmap值来进行合并,合并的结果放在devBuf中
     ntStatus = DPBitmapGet(
      DevExt->Bitmap,
      offset,
      length,
      devBuf,
      fileBuf
      );
     if (!NT_SUCCESS(ntStatus))
     {
      ntStatus = STATUS_INSUFFICIENT_RESOURCES;
      Irp->IoStatus.Information = 0;
      goto ERRERR;
     }
     //把合并完的数据存入系统缓冲并完成irp
     memcpy(sysBuf, devBuf, Irp->IoStatus.Information);
     goto ERRCMPLT;
    default:
     ntStatus = STATUS_INSUFFICIENT_RESOURCES;
     goto ERRERR;
    }
   }
对于写的操作处理起来很简单,因为只要发到这里的请求必定需要写到转存文件中的。由于使用了稀疏文件,所以这个文件的可寻址范围和被保存磁盘的大小是相同的。转储操作就成了只需要直接写入文件即可。这里需要注意的是,要先写入转存文件,直到写入成功之后,才可以设置bitmap中相应的区域;如果反过来的话,很可能出现写入不成功但是bitmap修改成功的情况。
   else
   {
    //这里是写的过程
    //对于写,我们直接写缓冲文件,而不会写磁盘数据,这就是所谓的转储,但是转储之后需要在bitmap中做相应的标记
    ntStatus = ZwWriteFile(
     DevExt->TempFile,
     NULL,
     NULL,
     NULL,
     &ios,
     sysBuf,
     length,
     &offset,
     NULL);
    if(!NT_SUCCESS(ntStatus))
    {
     ntStatus = STATUS_INSUFFICIENT_RESOURCES;
     goto ERRERR;
    }
    else
    {
     if (NT_SUCCESS(ntStatus = DPBitmapSet(DevExt->Bitmap, offset, length)))
     {
      goto ERRCMPLT;
     }
     else
     {
      ntStatus = STATUS_INSUFFICIENT_RESOURCES;
      goto ERRERR;
     }
    }
   }
ERRERR:
   if (NULL != fileBuf)
   {
    ExFreePool(fileBuf);
    fileBuf = NULL;
   }
   if (NULL != devBuf)
   {
    ExFreePool(devBuf);
    devBuf = NULL;
   }
   DPCompleteRequest(
    Irp,
    ntStatus,
    IO_NO_INCREMENT
    );
   continue;
ERRNEXT:
   if (NULL != fileBuf)
   {
    ExFreePool(fileBuf);
    fileBuf = NULL;
   }
   if (NULL != devBuf)
   {
    ExFreePool(devBuf);
    devBuf = NULL;
   } 
   DPSendToNextDriver(
    DevExt->LowerDevObj,
    Irp);
   continue;
ERRCMPLT:
   if (NULL != fileBuf)
   {
    ExFreePool(fileBuf);
    fileBuf = NULL;
   }
   if (NULL != devBuf)
   {
    ExFreePool(devBuf);
    devBuf = NULL;
   }
   DPCompleteRequest(
    Irp,
    STATUS_SUCCESS,
    IO_DISK_INCREMENT
    );
   continue;
   
  }
 }

}
至此,读/写操作的转储处理已经介绍完毕。