13、文件操作:内核文件读写、ZwCreateFile、文件系统过滤基础
说实话,驱动开发里文件操作这块,我当年刚入门时觉得挺简单的——不就是打开文件、读写数据嘛,用户态玩得溜得很。结果第一次在内核里写文件,蓝屏直接教我做人了。嗯,内核态的文件操作跟用户态完全是两码事,你得用 ZwCreateFile、ZwReadFile 这一套,还得小心 IRP 和缓冲区的坑。
今天咱们就把内核文件读写彻底讲透,顺便聊聊文件系统过滤的基础。这些东西在实际项目中太常用了——日志记录、配置读取、文件保护,都离不开它们。
13.1 内核文件操作的核心API
内核里操作文件,主要用 ZwCreateFile、ZwReadFile、ZwWriteFile 和 ZwClose。你可能会问:为什么不用 CreateFile?因为那是用户态的 API,内核里跑的是 Zw 系列,它们直接走系统服务分发,绕过了 Win32 子系统。
我个人习惯把内核文件操作分成三步走:
- 打开/创建文件 —— ZwCreateFile
- 读写数据 —— ZwReadFile / ZwWriteFile
- 关闭句柄 —— 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。
从图上可以看得很清楚:过滤驱动就像一个安检员,所有进出文件系统的请求都得经过它。你可以选择放行、修改、或者直接拒绝。
13.2.1 过滤驱动的注册
注册一个文件系统过滤驱动,核心是调用 IoRegisterFsRegistrationChange 和 IoAttachDeviceToDeviceStack。我简化一下关键步骤:
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;
}
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里指定InvokeOnSuccess、InvokeOnError、InvokeOnCancel的组合,否则某些情况下完成例程不会被调用。 - 分页池 vs 非分页池:文件操作中分配缓冲区,如果是在 DPC 级别或者中断上下文,必须用非分页池(NonPagedPool)。用分页池会导致页面错误,直接蓝屏。
核心总结:
- 内核文件操作用 ZwCreateFile/ZwReadFile/ZwWriteFile/ZwClose,路径用 NT 格式
- 句柄创建时加 OBJ_KERNEL_HANDLE 标志,确保跨进程可用
- 文件系统过滤驱动挂载在 I/O 管理器和文件系统之间,通过拦截 IRP 实现功能
- 过滤驱动里处理 IRP 后必须传给下层驱动,否则系统会卡死
文件操作这块,说白了就是熟能生巧。刚开始可能会被各种参数和细节搞晕,但只要动手写几个完整的例子,把打开、读写、关闭的流程跑通,后面就顺了。过滤驱动稍微复杂点,但核心思想就是「拦截-处理-转发」三步走,理解了 IRP 的流转,剩下的就是业务逻辑了。