前言

大抵了解了一下,最近驱动开发的招聘岗位依旧是对于文件系统过滤驱动和网络过滤驱动的需求。
一个主要是做文件透明加密,一个主要是做流量劫持。
关于这个驱动,文件过滤驱动稍微较网络过滤复杂一丢丢。
文件系统上面存在热插拔的U盘等介质,实际写法上和网络过滤驱动相接近。
本文主要参考解读《Windows 文件系统过滤驱动开发教程(第二版)》一文,
是对过滤驱动开发方面的总结性内容,也可以用作入门参考。

基础知识

驱动程序的基础开始

DriverEntry 相当于 dllmain,是一个驱动的入口,在驱动加载完成后由系统调用。
但是略有不同的是驱动程序除了入口之外还有一个独立的出口,在驱动被卸载的时候被调用,其位于驱动对象结构中,是一个函数指针。
至于为何要分开,我猜测是由于有些程序并不需要卸载处理,那么在 DriverEntry 在被调用完后,这个函数可以从内存中移除,节约一点内存。

驱动在加载完成后,会由系统构造一个驱动对象传入 DriverEntry,它与驱动是一一对应关系。

DriverObject 重要之处,在于它拥有一组函数指针,称为 dispatch functions.

通常在编写过滤设备驱动时,需要做两件事。

  • 往目标设备的设备栈附加自己的设备对象,让自己的设备对象位于目标设备上层,用于接收处理IRP。
  • 当设备收到IRP后,就会交由其对应的驱动对象,而后调用驱动对象关联的派遣函数(dispatch functions)进行处理,所以需要编写对应的派遣函数。

一个驱动对象可以包含多个设备对象,派遣函数的第一个参数会指明是来自于哪个设备对象,在此做相应的处理即可。

创建设备对象通常使用 IoCreateDevice 函数,而附加设备对象到目标设备对象的设备栈通常使用 IoAttachDevice 函数。
在创建设备对象时,一般会将相关的信息存放于设备对象的扩展域即DEVICE_EXTENSION中,在附加完成后或者说准备工作完成后,需要通过 newDeviceObject->Flags &= ~DO_DEVICE_INITIALIZING 清理掉初始化标志,以表示设备可以正常工作。

在文件系统中,通常需要关注的是卷设备对象的过滤,其涉及到对于文件的相关操作。

UNREFERENCED_PARAMETER 用于消除未使用的参数警告。

派遣函数的注册

通常注册派遣函数的写法如下:

// 首先全部初始化
for (i = 0; i <= IRP_MJ_MAXIMUM_FUNCTION; i++) 
  DriverObject->MajorFunction[i] = SfPassThrough;

// 然后特化处理
DriverObject->MajorFunction[IRP_MJ_CREATE] = SfCreate;

其中 PassThrough 也有较为通用的写法:

NTSTATUS SfPassThrough (IN PDEVICE_OBJECT DeviceObject, IN PIRP Irp) {

  // 跳过当前设备栈,并移交给下层设备。
  // 如果是非过滤驱动,应该完成掉这个IRP请求,并指定错误码及相关信息
  IoSkipCurrentIrpStackLocation( Irp );
  return IoCallDriver( ((PSFILTER_DEVICE_EXTENSION)DeviceObject->DeviceExtension)->AttachedToDeviceObject, Irp );
}

FastIo

FastIo 是 Cache Manager 调用所引发的一种没有 irp 的请求。

FastIo的结构指针位于driver->FastIoDispatch,需要自行分配一个 FAST_IO_DISPATCH 并填充。

在文件系统过滤驱动中,FastIo的过滤与 IRP 过滤同等重要,二者均可能被调用。

在开发的初期学习阶段,你可以简单的设置所有的 fastio 例程返回 FALSE 并不做任何事。这样这些请求都会通过 IRP 重新发送被你的普通分发函数捕获。有一定的效率损失,但是并不是很大。

3.5 附:陆麟关于 fastio 的简述
NT 下 FASTIO 是一套 IO MANAGER 与 DEVICE DRIVER 沟通的另外一套 API. 在进行基于 IRP 为基础的接 口调用前, IO MANAGER 会尝试使用 FAST IO 接口来加速各种 IO 操作. FASTIO 本身的文档并不多见, 本篇就是要 介绍一下 FASTIO 接口.

  • FastIoCheckIfPossible, 此调用并不是 IO MANAGER 直接调用. 而是被 FsRtlXXX 系列函数调用. 用于确认读写 操作是否可以用 FASTIO 接口进行.
  • FastIoRead/FastIoWrite, 很明显, 是读写处理的调用.
  • FastIoQueryBasicInfo/FastIoQueryStandardInfo, 用于获取各种文件信息. 例如创建,修改日期等.
  • FastIoLock/FastIoUnlockSingle/FastIoUnlockAll/FastIoUnlockAllByKey,用于对文件的锁定操作. 在 NT 中.有 2中锁定需要存在.1.排他性锁. 2.共享锁. 排他性锁在写操作前获取,不准其他进程获得写操作权限, 而共享锁 则代表需要读文件某区间. 禁止有写动作出现. 在同一地址上, 如果有多个共享锁请求, 那是被允许的.
  • FastIoDeviceControl 用于提供 NtDeviceIoControlFile 的支持.
  • AcquireFileForNtCreateSection/ReleaseFileForNtCreateSection 是 NTFS 在映射文件内容到内存页面 前进行的操作.
  • FastIoDetachDevice, 当 REMOVABLE 介质被拿走后, FILE SYSTEM 的 DEVICE 对象会在任意的时刻被销 毁. 只有正确处理这个调用才能把上层 DEVICE 和将要销毁的 DEVICE 脱钩. 如果不解决这个函数, 系统会当.
  • FastIoQueryNetworkOpenInfo, 当 CIFS 也就是网上邻居,更准确的说是网络重定向驱动尝试获取文件信息, 会使用这个调用. 该调用是因为各种历史原因而产生. 当时设计 CIFS 时为避免多次在网上传输文件信息请求, 在 NT4 时 传输协议增加了一个 FileNetworkOpenInformation 的网络文件请求. 而 FSD 则增加了这个接口. 用于在一次操作 中获得所有的文件信息. 客户段发送 FileNetworkOpenInformation, 服务器端的 FSD 用本接口完成信息填写.
  • FastIoAcquireForModWrite, Modified Page Writer 会调用这个接口来获取文件锁. 如果实现这个接口. 则 能使得文件锁定范围减小到调用指定的范围. 不实现此接口, 整个文件被锁.
  • FastIoPrepareMdlWrite, FSD 提供 MDL. 以后向此 MDL 写入数据就代表向文件写入数据. 调用参数中有 FILE_BOJECT 描述要写的目标文件.
  • FastIoMdlWriteComplete, 写操作完成. FSD 回收 MDL.
  • FastIoReadCompressed, 当此调用被调用时, 读到的数据是压缩后的.应该兼容于标准的 NT 提供的压缩库. 因 为调用者负责解压缩.
  • FastIoWriteCompressed,当此调用被调用时, 可以将数据是压缩后存储.
  • FastIoMdlReadCompressed/FastIoMdlReadCompleteCompressed, MDL 版本的压缩读. 当后一个接 口被调用时,MDL 必须被释放.
  • FastIoMdlWriteCompressed/FastIoMdlWriteCompleteCompressed, MDL 版本的压缩写.当后一个接 口被调用时,MDL 必须被释放.
  • FastIoQueryOpen, 这不是打开文件的操作. 但是却提供了一个 IRP_MJ_CREATE 的 IRP. 我在以前版本的 SECUSTAR 的软件中错误地实现了功能. 这个操作是打开文件/获取文件基本信息/关闭文件的一个操作.
  • FastIoReleaseForModWrite,释放 FastIoAcquireForModWrite 调用所占有的 LOCK.
  • FastIoAcquireForCcFlush/FastIoReleaseForCcFlush FsRtl 会调用此接口,在 LAZY WRITE 线程将要把修改后的文件数据写入前调用.获取文件锁.

由于上面这些属于比较重要的内容,所以直接摘录过来了。

卷的动态挂载

任何来自应用的请求,终被 windowsIO 管理器翻译成 irp 的,总是发送给设备栈的顶端那个设备。

关于这个内容在前面已经说过了。

当一个U盘被插入时,系统会动态的生成(挂载)一个卷(Volume),
对于文件系统过滤驱动来说,当然希望能动态的识别并往这个新建的设备对象上层加入过滤设备对象。
这个时候就需要用到名为 IoRegisterFsRegistrationChange 的函数。

IoRegisterFsRegistrationChange本身并不能识别卷的创建(挂载),它的功能是动态识别文件系统是否被激活,通过它就可以往激活的文件系统的控制设备对象附加一个过滤设备对象,进而得到发往这个文件系统的控制设备对象的IRP。

一个卷在被挂载时,会产生一个发往文件系统的控制设备对象的IRP,其 Major Function Code 为 IRP_MJ_FILE_SYSTEM_CONTROL,Minor Function Code 为 IRP_MN_MOUNT。

简单的说,就是两个步骤。

  1. IoRegisterFsRegistrationChange 注册一个回调,监视文件对象的激活和卸载,回调内完成 附加/取消附加 文件系统控制设备对象
  2. 为 MajorFunction[IRP_MJ_FILE_SYSTEM_CONTROL] 指定派遣函数,内部对 MinorFunction 为 IRP_MN_MOUNT 的进行 附加处理。

2000以后的系统中,IoRegisterFsRegistrationChange 会重新枚举已经激活的文件系统,类似于调试子系统会重新将已经执行过的内容发往调试器,但是其不会重新发送卷挂载的 IRP,故需要自行枚举该文件系统下的卷设备并附加。

按照 sfilter 的逻辑是不会重发卷挂载的 IRP,具体会不会发我不知道,只是多附加一次也不会出错,这是因为后面附加时可以统一枚举设备栈判断设备对象的扩展域是否为NULL来识别该设备栈是否已经附加。

另外文件系统在激活时,并不是绑定所有设备,只需要绑定感兴趣的设备对象,可以通过 DeviceObject->DeviceType 进行过滤,以及通过驱动对象名过滤掉文件系统识别器(一个占坑的驱动),一般情况下是在\FileSystem\Fs_Rec下面。

由于 DISMOUNT 很难被捕获,在 sfilter 中并未进行相关的处理,会出现影响不大的泄露。

IRP_MN_LOAD_FILE_SYSTEM 是在文件系统识别器决定加载真正的文件系统时产生的请求,其中需要处理被误绑定的 文件系统识别器。由于 识别器路径不是必须为 \FileSystem\Fs_Rec 的缘故,所以可能会发生误绑定现象。

卷设备的附加

前面已经提及,文件系统过滤驱动中,真正需要处理是卷设备的行为过滤。
IRP_MN_MOUNT_VOLUME 可以感知到将要被创建的卷设备,当挂载请求到来时,能得到一个 VPB 结构,它将磁盘设备对象和卷设备对象关联起来,但是卷设备实际是在请求完成之后才会被真正创建,而请求完成后下层的文件系统驱动可能已经修改了 VPB 的值,所以需要提前将其保存起来。在完成例程中去获取真正的卷设备。

VPB->RealDevice是 磁盘驱动创建的物理设备对象
VPB->RealDevice 中又存在指向 VPB的指针,但是并不清楚 VPB 是否等于 VPB->RealDevice->VPB
VPB->DeviceObject是 文件系统创建的卷设备对象

相当于先保存 VPB->RealDevice 磁盘设备对象,在请求完成后再通过 RealDevice->VPB->DeviceObject 获取卷设备对象。

一种更通用的做法是将一个事件作为完成函数的上下文进行投递,再在其完成函数中激活,这样就能在其他地方感知到完成事件了,另外使用该方法也能绕开 IRQL级别的限制,另外在 2000上有个讨厌的死锁问题,可以通过 DelayedWorkQueue 委托,这里不细看了,基本上不会再为 2000 写驱动了。

值得注意的是:根据官方文档

IoCompletion routine is called at IRQL <= DISPATCH_LEVEL

完成例程可能运行于 DISPATCH_LEVEL,故 上下文(事件) 必须分配于 nonpaged memory,sfilter是直接分配于栈上,就我个人理解,内核栈是不会被交换的(否则运行这么多年早就错误百出了)。

另外在附加前需要检查目标设备栈是否已经附加,sfilter中是遍历了整个设备栈,当然也可以只判断顶部那个(可能会遗漏),判断其扩展域是否为 NULL就行了,可以利用宏 ARGUMENT_PRESENT。当然在检查附加及附加这段是需要同步的,在取消时同样。

本文略过卷影部分。

卷影拷贝服务(Volume Shadow Copy Service,VSS)是一种备份和恢复的技术。它是一种基于时间点来备份文件拷贝的技术,可以过滤也可以不过滤。

读写过滤

处理 IRP_MJ_READ 和 IRP_MJ_WRITE,能捕获文件的读写操作.

在 sfilter 中并未对这些进行处理而集中 FastIO.

LARGE_INTEGER offset; Offset.QuadPart = irpsp->Parameters.Read.ByteOffset.QuadPart;

而读取文件的长度则是:
ULONG length; length = irpsp->Parameters.Read.Length;
写的偏移量和长度则为:
Offset.QuadPart = irpsp->Parameters.Write.ByteOffset.QuadPart;
length = irpsp->Parameters.Write.Length;

由于捕获这个请求的时候,请求还未完成,故无法获取到即将被读取的数据。

完成 Irp 的时候忽略还是拷贝当前 IO_STACK_LOCATION,返回什么 STATUS,以及完成函数中如何结束 Irp,是 不那么容易搞清楚的一件事情.我想做个总结如下:

1.如果对 irp 完成之后的事情无兴趣,直接忽略当前 IO_STACK_LOCATION,(对我们的程序来说,调用 IoSkipCurrentIrpStackLocation),然后向下传递请求,返回 IoCallDriver 所返回的状态.

2.不但对 irp 完成之后的事情无兴趣,而且我不打算继续传递,打算立刻返回成功或失败.那么我不用忽略或者拷贝当前 IO_STACK_LOCATION,填写参数后调用 IoCompleteRequest,并返回我想返回的结果.

3.如果对 irp 完成之后的事情有兴趣, 并打算在完成函数中处理, 应该首先拷贝当前 IO_STACK_LOCATION(IoCopyCurrentIrpStackLocationToNext), 然后指定完成函数, 并返回 IoCallDriver()所返回的 status.完成函数中,不需要调用 IoCompleteRequest!直接返回 Irp 的当前状态即可.

4.同 3 的情况,有时候,会把任务塞入系统工作者线程或者希望在另外的线程中去完成 Irp,那么完成函数中应该返回 STATUS_MORE_PROCESSING_REQUIRED,此时完成 Irp 的时候应该调用 IoCompleteRequest.另一种类似 的情况是在 dispatch 函数中等待完成函数中设置事件, 那么完成函数返回 STATUS_MORE_PROCESSING_REQUIRED,dispatch 函数在等待结束后调用 IoCompleteRequest.

关于IO的几种方式在先前的复习中已经总结了。
缓冲方式 直接方式 其他方式。

其他方式需要在同一进程上下文环境才能处理,其他两种则没有这种限制。
根据设备对象的IO标志可以判断其IO方式。

直接IO方式可通过判断 Irp->MdlAddress 是否NULL,并通过 MmGetSystemAddressForMdl(Irp->MdlAddress); 获取缓冲区地址。

剩下两种只要保证进程上下文不变,可以直接从Irp->UserBuffer中获取缓冲区地址。

读请求的buffer需要在请求完成时才能提取,而写请求则不需要。

读请求的完成

略。

文件和目录的创建,打开,关闭,删除

实际上重命名操作也可以归纳到一起。

目录相关的处理老实说比较复杂,我就简单的说一下如何 过滤删除和重命名。

FileDispositionInformation == Iopb->Parameters.SetFile.FileInformationClass
|| FileRenameInformation == Iopb->Parameters.SetFile.FileInformationClass
通过上面两条就能过滤出 删除和重命名操作了。
接着通过
IoGetCurrentIrpStackLocation(Irp)->ileObject->FileName
就能获得文件名,其类型为 UNICODE_STRING。
至于如何处理,就依需求了。

派遣函数重入问题

通常要避免重入造成的死循环。

驱动开发中 由于内核栈的短缺,本身也应该避免递归造成的重入。
另一种是函数内部调用的某个函数会触发对应的IRP请求,又会回到该函数,而导致死循环。

这实际上对程序逻辑理解上的疏忽造成的,通常并不会出现,如果有必要出现时,需要进行过滤。
同样也可以通过创建任务线程来处理,具体可以看本文参考文章的相关部分。

总结

本文主要结合《Windows 文件系统过滤驱动开发教程(第二版)》一文,介绍了sfilter 中的核心部分,并加入了一些个人见解。

参考