12、文件系统过滤:MiniFilter框架、注册回调、IRP_MJ_CREATE处理、文件内容过滤、目录监控
文件系统过滤,说白了就是给Windows的文件系统装个“监控探头”。你想想看,用户每打开一个文件、每写入一段数据,系统底层都会产生对应的IRP(I/O请求包)。我们做驱动的,就是要在这些IRP到达真正的文件系统之前,把它们截住,看看里面到底装了啥。
我个人习惯把MiniFilter比作“高速公路上的检查站”。车(IRP)要过收费站,检查站可以放行、可以拦截、甚至可以偷偷复制一份数据。嗯,这个比喻虽然糙了点,但核心逻辑就是这么回事。
12.1 MiniFilter框架:从零搭建一个过滤驱动
MiniFilter是微软在Vista之后主推的过滤模型。相比老旧的Legacy Filter,它更轻量、更安全、也更不容易蓝屏。我刚开始从Legacy迁移到MiniFilter时,最大的感受就是:终于不用自己处理IRP栈的传播了。
一个标准的MiniFilter驱动,核心就三件事:
- 注册框架:告诉系统“我来了,我要过滤哪些东西”
- 挂载卷:绑定到具体的磁盘分区上
- 处理回调:在IRP经过时执行你的逻辑
先看注册入口的代码骨架:
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
NTSTATUS status;
FLT_REGISTRATION reg = {0};
// 1. 填充注册结构
reg.Size = sizeof(FLT_REGISTRATION);
reg.FilterUnloadCallback = MyFilterUnload;
reg.InstanceSetupCallback = MyInstanceSetup;
reg.InstanceQueryTeardownCallback = MyInstanceTeardown;
reg.InstanceTeardownStartCallback = MyInstanceTeardownStart;
reg.InstanceTeardownCompleteCallback = MyInstanceTeardownComplete;
// 2. 注册回调函数表(重点!)
reg.OperationRegistration = MyOperations; // 指向回调数组
// 3. 注册到系统
status = FltRegisterFilter(DriverObject, ®, &gFilterHandle);
if (!NT_SUCCESS(status)) {
return status;
}
// 4. 启动过滤
status = FltStartFiltering(gFilterHandle);
if (!NT_SUCCESS(status)) {
FltUnregisterFilter(gFilterHandle);
return status;
}
return STATUS_SUCCESS;
}
这里有个坑,我当年踩过。 FltRegisterFilter 和 FltStartFiltering 之间,千万别做耗时操作。为什么?因为注册成功后系统就开始往你的回调里送IRP了,但此时过滤还没完全启动,容易出竞争条件。
12.2 注册回调:告诉系统你想看什么
回调注册是MiniFilter的灵魂。你需要定义一个 FLT_OPERATION_REGISTRATION 数组,告诉系统你对哪些IRP主功能号感兴趣。
CONST FLT_OPERATION_REGISTRATION MyOperations[] = {
{ IRP_MJ_CREATE, 0, MyPreCreate, MyPostCreate },
{ IRP_MJ_READ, 0, MyPreRead, NULL },
{ IRP_MJ_WRITE, 0, MyPreWrite, MyPostWrite },
{ IRP_MJ_DIRECTORY_CONTROL, 0, MyPreDirCtrl, MyPostDirCtrl },
{ IRP_MJ_OPERATION_END }
};
每个条目对应一个IRP主功能号。你可以注册Pre回调(IRP到达前)、Post回调(IRP完成后),或者两者都注册。我个人习惯:能Pre处理就别拖到Post,因为Pre里你可以直接返回 FLT_PREOP_COMPLETE 终结这个IRP,效率最高。
关键点: 回调函数的返回值决定了IRP的命运。
FLT_PREOP_SUCCESS_WITH_CALLBACK:继续处理,Post时通知我FLT_PREOP_SUCCESS_NO_CALLBACK:继续处理,但别叫我PostFLT_PREOP_COMPLETE:我替系统处理完了,你们别管了FLT_PREOP_SYNCHRONIZE:同步处理,Post回调在同一个线程上下文执行
12.3 IRP_MJ_CREATE处理:拦截文件打开操作
IRP_MJ_CREATE是所有文件操作的入口。用户双击一个文件、调用CreateFile、甚至系统自己打开日志,都会触发这个IRP。拦截它,就等于守住了大门。
Pre回调里,你可以拿到 FLT_CALLBACK_DATA 结构,里面藏着 IO_STACK_LOCATION,再往下就是文件路径。
FLT_PREOP_CALLBACK_STATUS MyPreCreate(
PFLT_CALLBACK_DATA Data,
PCFLT_RELATED_OBJECTS FltObjects,
PVOID *CompletionContext
)
{
PFLT_FILE_NAME_INFORMATION nameInfo;
NTSTATUS status;
// 获取文件名
status = FltGetFileNameInformation(
Data,
FLT_FILE_NAME_NORMALIZED | FLT_FILE_NAME_QUERY_DEFAULT,
&nameInfo
);
if (!NT_SUCCESS(status)) {
return FLT_PREOP_SUCCESS_NO_CALLBACK;
}
// 检查是否是我们关心的文件
if (wcsstr(nameInfo->Name.Buffer, L"secret.txt")) {
// 拒绝访问!
Data->IoStatus.Status = STATUS_ACCESS_DENIED;
Data->IoStatus.Information = 0;
FltReleaseFileNameInformation(nameInfo);
return FLT_PREOP_COMPLETE;
}
FltReleaseFileNameInformation(nameInfo);
return FLT_PREOP_SUCCESS_NO_CALLBACK;
}
注意: 获取文件名是有开销的。每次Create都调用 FltGetFileNameInformation 会拖慢系统。我建议先判断一下进程名,只对目标进程做文件名过滤。比如只监控notepad.exe打开的文件,其他进程直接放行。
12.4 文件内容过滤:在读写时做手脚
光拦截打开还不够,有时候我们需要在文件内容层面做文章。比如:
- 实时扫描病毒特征
- 加密/解密文件内容
- 记录敏感数据的写入
- 替换文件中的特定字符串
内容过滤主要在IRP_MJ_READ和IRP_MJ_WRITE的Pre/Post回调里做。以写操作为例,Pre回调里你可以拿到用户要写入的数据缓冲区:
FLT_PREOP_CALLBACK_STATUS MyPreWrite(
PFLT_CALLBACK_DATA Data,
PCFLT_RELATED_OBJECTS FltObjects,
PVOID *CompletionContext
)
{
PFLT_IO_PARAMETER_BLOCK iopb = Data->Iopb;
PMDL mdl = iopb->Parameters.Write.MdlAddress;
PVOID buffer;
if (mdl) {
buffer = MmGetSystemAddressForMdlSafe(mdl, NormalPagePriority);
if (buffer) {
// 扫描缓冲区内容
ScanBuffer(buffer, iopb->Parameters.Write.Length);
}
}
return FLT_PREOP_SUCCESS_NO_CALLBACK;
}
这里有个细节:数据可能通过Mdl传递,也可能直接通过 Irp->UserBuffer 传递。我建议优先处理Mdl,因为它在内核态更安全。直接访问UserBuffer需要 ProbeForRead 或 ProbeForWrite,否则容易触发异常。
经验之谈: 如果你要在Post回调里修改读出的数据(比如解密),记得分配自己的缓冲区,把解密后的内容拷贝回去。千万别直接修改Mdl指向的原始缓冲区——那可能是文件系统的缓存页,改了会出大问题。
12.5 目录监控:捕获文件增删改
目录监控是很多安全软件的核心功能。你想知道谁在C盘创建了exe、谁删除了D盘的文档,就得靠IRP_MJ_DIRECTORY_CONTROL。
这个IRP比较特殊,它处理的是目录查询操作。当用户调用 FindFirstFile、FindNextFile 时,系统会发这个IRP来获取目录下的文件列表。我们在Post回调里可以拿到查询结果,然后注入或修改条目。
FLT_POSTOP_CALLBACK_STATUS MyPostDirCtrl(
PFLT_CALLBACK_DATA Data,
PCFLT_RELATED_OBJECTS FltObjects,
PVOID CompletionContext,
FLT_POST_OPERATION_FLAGS Flags
)
{
PFLT_IO_PARAMETER_BLOCK iopb = Data->Iopb;
PFILE_DIRECTORY_INFORMATION dirInfo;
PVOID buffer;
ULONG length;
// 获取目录查询结果
if (iopb->Parameters.DirectoryControl.QueryDirectory.Length > 0) {
buffer = Data->Iopb->Parameters.DirectoryControl.QueryDirectory.MdlAddress;
// 遍历目录条目...
}
return FLT_POSTOP_FINISHED_PROCESSING;
}
目录监控的难点在于:不同文件系统返回的目录信息格式不同。NTFS用 FILE_DIRECTORY_INFORMATION,FAT32用 FILE_BOTH_DIR_INFORMATION。你得根据 FileInformationClass 来判断格式。
我曾经在做一个文件审计项目时,就因为这个格式判断写错了,导致监控结果偶尔漏掉几个文件。排查了两天才发现是FAT32的U盘插上来时,信息类变了。
12.6 知识体系总览
下面这张图总结了MiniFilter的核心工作流程,从IRP进入驱动到最终返回结果:
12.7 避坑指南与最佳实践
做MiniFilter开发,有些坑是绕不开的。我把自己踩过的和见过的整理一下:
我曾经犯过的错:
- 死锁问题:在Pre回调里等待某个事件,而这个事件又在等待IRP完成——典型的死锁。记住,MiniFilter回调运行在任意线程上下文中,千万别在里面做同步等待。
- 内存泄漏:
FltGetFileNameInformation拿到的结构一定要调用FltReleaseFileNameInformation释放。我早期有个版本漏了释放,跑了一周后系统内存耗尽。 - 递归调用:你的过滤驱动自己也会产生文件操作(比如写日志),如果不做递归检测,就会无限循环。用
FltSetCallbackDataDirty或者检查当前线程的TLS标志位来避免。
几个实用建议:
- 调试时用WinDbg + 双机调试,别在真机上直接跑。MiniFilter一旦出问题,整个文件系统都可能挂掉。
- 善用
FltGetRequestorProcess获取发起IRP的进程,按进程做过滤策略,比全局过滤灵活得多。 - Post回调里修改数据时,记得调用
FltSetCallbackDataDirty告诉系统数据变了,否则缓存可能不同步。 - 对于频繁的IRP(比如Read/Write),尽量在Pre里快速判断,不关心的直接返回
FLT_PREOP_SUCCESS_NO_CALLBACK,减少开销。
文件系统过滤是个大话题,MiniFilter框架把很多底层细节封装好了,但核心逻辑还是那几个:注册、拦截、处理、放行。你只要把IRP_MJ_CREATE、READ、WRITE、DIRECTORY_CONTROL这几个主功能号吃透,大部分过滤需求都能搞定。
嗯,今天就先聊到这儿。代码里的细节,建议你动手写一个简单的Demo跑一跑,光看是学不会的。
公众号:蓝海资料掘金营,微信deep3321