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, &reg, &gFilterHandle);
    if (!NT_SUCCESS(status)) {
        return status;
    }

    // 4. 启动过滤
    status = FltStartFiltering(gFilterHandle);
    if (!NT_SUCCESS(status)) {
        FltUnregisterFilter(gFilterHandle);
        return status;
    }

    return STATUS_SUCCESS;
}

这里有个坑,我当年踩过。 FltRegisterFilterFltStartFiltering 之间,千万别做耗时操作。为什么?因为注册成功后系统就开始往你的回调里送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:继续处理,但别叫我Post
  • FLT_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需要 ProbeForReadProbeForWrite,否则容易触发异常。

经验之谈: 如果你要在Post回调里修改读出的数据(比如解密),记得分配自己的缓冲区,把解密后的内容拷贝回去。千万别直接修改Mdl指向的原始缓冲区——那可能是文件系统的缓存页,改了会出大问题。

12.5 目录监控:捕获文件增删改

目录监控是很多安全软件的核心功能。你想知道谁在C盘创建了exe、谁删除了D盘的文档,就得靠IRP_MJ_DIRECTORY_CONTROL。

这个IRP比较特殊,它处理的是目录查询操作。当用户调用 FindFirstFileFindNextFile 时,系统会发这个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进入驱动到最终返回结果:

MiniFilter 文件系统过滤核心流程 IRP 请求到达 Pre 回调处理(拦截/放行) 拦截返回 底层文件系统处理 Post 回调处理(结果检查) 结果返回用户态 关键IRP主功能号 IRP_MJ_CREATE - 文件打开 IRP_MJ_READ - 文件读取 IRP_MJ_WRITE - 文件写入 IRP_MJ_DIRECTORY_CONTROL - 目录查询/监控 IRP_MJ_SET_INFORMATION - 文件重命名/删除 IRP_MJ_CLOSE - 文件关闭 ⚠ 常见陷阱 • 文件名获取有性能开销 • 注意Mdl与UserBuffer区别 • 目录信息格式因FS而异

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