13、文件操作:内核文件读写、ZwCreateFile、文件系统过滤基础

说实话,驱动开发里文件操作这块,我当年刚入门时觉得挺简单的——不就是打开文件、读写数据嘛,用户态玩得溜得很。结果第一次在内核里写文件,蓝屏直接教我做人了。嗯,内核态的文件操作跟用户态完全是两码事,你得用 ZwCreateFileZwReadFile 这一套,还得小心 IRP 和缓冲区的坑。

今天咱们就把内核文件读写彻底讲透,顺便聊聊文件系统过滤的基础。这些东西在实际项目中太常用了——日志记录、配置读取、文件保护,都离不开它们。

13.1 内核文件操作的核心API

内核里操作文件,主要用 ZwCreateFileZwReadFileZwWriteFileZwClose。你可能会问:为什么不用 CreateFile?因为那是用户态的 API,内核里跑的是 Zw 系列,它们直接走系统服务分发,绕过了 Win32 子系统。

我个人习惯把内核文件操作分成三步走:

  1. 打开/创建文件 —— ZwCreateFile
  2. 读写数据 —— ZwReadFile / ZwWriteFile
  3. 关闭句柄 —— ZwClose

看起来跟用户态差不多?但细节上全是坑。咱们一个一个来。

13.1.1 ZwCreateFile 详解

这个函数的原型长这样:

NTSTATUS ZwCreateFile(
    PHANDLE            FileHandle,
    ACCESS_MASK        DesiredAccess,
    POBJECT_ATTRIBUTES ObjectAttributes,
    PIO_STATUS_BLOCK   IoStatusBlock,
    PLARGE_INTEGER     AllocationSize,
    ULONG              FileAttributes,
    ULONG              ShareAccess,
    ULONG              CreateDisposition,
    ULONG              CreateOptions,
    PVOID              EaBuffer,
    ULONG              EaLength
);

参数看着多,其实核心就几个。我重点说两个最容易出问题的地方:

  • ObjectAttributes:这里要初始化 OBJECT_ATTRIBUTES 结构,最关键的是把文件路径转成 UNICODE_STRING,而且路径必须是 NT 路径格式,比如 \??\C:\log.txt 或者 \Device\HarddiskVolume1\test.dat。我刚开始写的时候直接用 C:\log.txt,结果 ZwCreateFile 永远返回 STATUS_OBJECT_PATH_NOT_FOUND,查了半天才发现路径格式不对。
  • CreateDisposition:控制是创建新文件还是打开已有文件。常用的是 FILE_OVERWRITE_IF(存在就覆盖,不存在就创建)和 FILE_OPEN_IF(存在就打开,不存在就创建)。
小技巧: 我习惯在驱动初始化时用 FILE_OVERWRITE_IF 创建日志文件,这样每次驱动加载都会生成一个全新的日志,不会跟上次的混在一起。

13.1.2 完整的文件写入示例

光说不练假把式,咱们直接看代码。这是一个在内核里写日志文件的完整例子:

NTSTATUS WriteLogToFile(PUNICODE_STRING FilePath, PCHAR Buffer, ULONG Length)
{
    HANDLE hFile = NULL;
    OBJECT_ATTRIBUTES objAttr;
    IO_STATUS_BLOCK ioStatus;
    NTSTATUS status;

    // 初始化对象属性
    InitializeObjectAttributes(
        &objAttr,
        FilePath,
        OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE,
        NULL,
        NULL
    );

    // 打开或创建文件
    status = ZwCreateFile(
        &hFile,
        GENERIC_WRITE,
        &objAttr,
        &ioStatus,
        NULL,
        FILE_ATTRIBUTE_NORMAL,
        FILE_SHARE_READ,
        FILE_OPEN_IF,
        FILE_SYNCHRONOUS_IO_NONALERT,
        NULL,
        0
    );

    if (!NT_SUCCESS(status)) {
        DbgPrint("ZwCreateFile failed: 0x%X\n", status);
        return status;
    }

    // 移动到文件末尾(追加写入)
    LARGE_INTEGER byteOffset;
    byteOffset.QuadPart = 0;
    status = ZwSetInformationFile(
        hFile,
        &ioStatus,
        &byteOffset,
        sizeof(LARGE_INTEGER),
        FilePositionInformation
    );

    if (NT_SUCCESS(status)) {
        // 写入数据
        status = ZwWriteFile(
            hFile,
            NULL,
            NULL,
            NULL,
            &ioStatus,
            Buffer,
            Length,
            &byteOffset,
            NULL
        );
    }

    ZwClose(hFile);
    return status;
}

注意看几个关键点:

  • OBJ_KERNEL_HANDLE 标志——这个必须加,否则句柄只能在当前进程上下文用,换个线程就崩了。我踩过这个坑,蓝屏后查 dump 才发现句柄无效。
  • FILE_SYNCHRONOUS_IO_NONALERT——同步模式,写文件时线程会等操作完成。简单场景够用了,高性能场景可以考虑异步。
  • 写入前用 ZwSetInformationFile 把文件指针移到末尾,实现追加写入。如果不移,默认从文件头开始写,会把原有内容覆盖掉。

13.2 文件系统过滤基础

文件操作搞定了,咱们再聊聊文件系统过滤。说白了,就是在文件请求到达真正的文件系统之前,插一脚,看看、改改、甚至拦截掉。

为什么要做过滤?我举几个实际场景:

  • 防病毒软件:拦截恶意文件的读写操作
  • 文件加密:写的时候加密,读的时候解密
  • 文件审计:记录谁在什么时候访问了什么文件
  • 虚拟文件系统:把远程文件映射成本地文件

文件系统过滤的核心机制是 IRP(I/O Request Packet)。每个文件操作(打开、读、写、关闭)都会生成一个 IRP,沿着驱动栈往下传。过滤驱动就挂在这个栈的中间,像中间人一样处理 IRP。

文件系统过滤驱动栈 用户态应用程序 内核态 系统服务分发 (ZwCreateFile等) I/O 管理器 (创建IRP) ★ 文件系统过滤驱动 文件系统驱动 (NTFS/FAT32) 返回结果 过滤驱动位于I/O管理器和文件系统驱动之间,可以拦截、修改或监控IRP

从图上可以看得很清楚:过滤驱动就像一个安检员,所有进出文件系统的请求都得经过它。你可以选择放行、修改、或者直接拒绝。

13.2.1 过滤驱动的注册

注册一个文件系统过滤驱动,核心是调用 IoRegisterFsRegistrationChangeIoAttachDeviceToDeviceStack。我简化一下关键步骤:

NTSTATUS RegisterFilterDriver(PDRIVER_OBJECT DriverObject)
{
    NTSTATUS status;
    PDEVICE_OBJECT filterDevice = NULL;
    PDEVICE_OBJECT targetDevice = NULL;

    // 1. 创建过滤设备对象
    status = IoCreateDevice(
        DriverObject,
        sizeof(FILTER_EXTENSION),
        NULL,  // 不指定名称,匿名设备
        FILE_DEVICE_UNKNOWN,
        0,
        FALSE,
        &filterDevice
    );

    if (!NT_SUCCESS(status)) {
        return status;
    }

    // 2. 找到要挂载的目标设备(比如 C: 盘)
    // 实际项目中需要遍历所有卷设备
    targetDevice = GetTargetVolumeDevice();

    // 3. 挂载到设备栈上
    // IoAttachDeviceToDeviceStack 返回的是栈顶设备
    PDEVICE_OBJECT attachedDevice = IoAttachDeviceToDeviceStack(
        filterDevice,
        targetDevice
    );

    if (attachedDevice == NULL) {
        IoDeleteDevice(filterDevice);
        return STATUS_UNSUCCESSFUL;
    }

    // 4. 保存下层设备指针,用于转发IRP
    FILTER_EXTENSION* ext = (FILTER_EXTENSION*)filterDevice->DeviceExtension;
    ext->LowerDevice = attachedDevice;

    // 5. 设置分发函数
    DriverObject->MajorFunction[IRP_MJ_CREATE] = FilterDispatchCreate;
    DriverObject->MajorFunction[IRP_MJ_READ] = FilterDispatchRead;
    DriverObject->MajorFunction[IRP_MJ_WRITE] = FilterDispatchWrite;
    // ... 其他IRP处理

    return STATUS_SUCCESS;
}
注意: 过滤驱动里处理 IRP 时,一定要记得调用 IoSkipCurrentIrpStackLocation 或者 IoCopyCurrentIrpStackLocationToNext,然后把 IRP 传给下层驱动。如果不传,文件系统就收不到请求,整个系统会卡死。我曾经犯过这个错,结果整个盘都打不开了,重启才恢复。

13.2.2 拦截写操作示例

假设我们要拦截对某个敏感文件的写操作,可以在 IRP_MJ_WRITE 的分发函数里做文章:

NTSTATUS FilterDispatchWrite(
    PDEVICE_OBJECT DeviceObject,
    PIRP Irp
)
{
    PIO_STACK_LOCATION irpStack = IoGetCurrentIrpStackLocation(Irp);
    PFILE_OBJECT fileObject = irpStack->FileObject;

    // 检查文件名是否在保护列表中
    if (IsProtectedFile(fileObject->FileName)) {
        // 拒绝写入,返回权限错误
        Irp->IoStatus.Status = STATUS_ACCESS_DENIED;
        Irp->IoStatus.Information = 0;
        IoCompleteRequest(Irp, IO_NO_INCREMENT);
        return STATUS_ACCESS_DENIED;
    }

    // 放行,传给下层驱动
    IoSkipCurrentIrpStackLocation(Irp);
    return IoCallDriver(GetLowerDevice(DeviceObject), Irp);
}

这段代码的逻辑很简单:检查文件名,如果在保护列表里就直接拒绝,否则放行。实际项目中,你还可以在放行前记录日志、修改缓冲区内容、或者做实时备份。

13.3 避坑指南

做内核文件操作和过滤驱动,有几个坑我印象特别深:

  • 路径格式:内核里一定要用 NT 路径格式,别用 Win32 路径。我刚开始写的时候用 C:\test.txt,ZwCreateFile 死活打不开,后来才发现要写成 \??\C:\test.txt
  • 句柄泄露:每次 ZwCreateFile 成功后,一定要记得 ZwClose。驱动卸载时如果没关句柄,系统会报资源泄露,严重时蓝屏。
  • IRP 完成例程:如果你在过滤驱动里设置了完成例程(CompletionRoutine),一定要在 IoSetCompletionRoutine 里指定 InvokeOnSuccessInvokeOnErrorInvokeOnCancel 的组合,否则某些情况下完成例程不会被调用。
  • 分页池 vs 非分页池:文件操作中分配缓冲区,如果是在 DPC 级别或者中断上下文,必须用非分页池(NonPagedPool)。用分页池会导致页面错误,直接蓝屏。

核心总结:

  • 内核文件操作用 ZwCreateFile/ZwReadFile/ZwWriteFile/ZwClose,路径用 NT 格式
  • 句柄创建时加 OBJ_KERNEL_HANDLE 标志,确保跨进程可用
  • 文件系统过滤驱动挂载在 I/O 管理器和文件系统之间,通过拦截 IRP 实现功能
  • 过滤驱动里处理 IRP 后必须传给下层驱动,否则系统会卡死

文件操作这块,说白了就是熟能生巧。刚开始可能会被各种参数和细节搞晕,但只要动手写几个完整的例子,把打开、读写、关闭的流程跑通,后面就顺了。过滤驱动稍微复杂点,但核心思想就是「拦截-处理-转发」三步走,理解了 IRP 的流转,剩下的就是业务逻辑了。


公众号:蓝海资料掘金营,微信 deep3321