第二十一章:文件系统过滤:Minifilter框架、文件操作监控、文件加密与保护
文件系统过滤,说白了就是在文件读写路径上插一脚。你想想看,每次应用程序读写文件,都要经过I/O管理器,再往下走就是文件系统驱动。如果我们能在中间加一层,就能看到所有文件操作,甚至能改数据、拦操作。这就是Minifilter干的事。
我最早接触文件过滤驱动,还是用传统的legacy filter。那玩意儿写起来真叫一个痛苦——你得自己处理IRP的挂载、转发、完成,稍不留神就蓝屏。后来微软推出了Minifilter框架,嗯,这简直就是救星。它把那些脏活累活都封装好了,我们只需要写几个回调函数就行。
21.1 Minifilter框架的核心概念
Minifilter是Windows文件系统过滤的新一代框架。它基于Filter Manager (FltMgr.sys) 工作。Filter Manager负责管理所有Minifilter的加载、卸载、通信,以及IRP的派发。
我个人习惯把Minifilter想象成一个"插件系统"。Filter Manager是主程序,我们写的驱动就是插件。插件只需要注册感兴趣的操作,比如只监控IRP_MJ_CREATE,其他操作Filter Manager会自动帮我们处理。
关键点:Minifilter的加载顺序由Altitude决定。Altitude是一个数字,数字越小越靠近底层文件系统。比如加密驱动通常用低Altitude(30xxx),监控驱动用高Altitude(36xxx)。
这里有个我踩过的坑:Altitude不是随便写的。微软给每个功能类别分配了区间,乱写会导致驱动加载失败。我曾经图省事随便写了个数字,结果驱动死活加载不上,查了半天才发现是Altitude冲突。
21.2 注册与回调机制
写一个Minifilter驱动,核心就是注册回调。Filter Manager提供了两种回调:
- Pre-operation回调:在IRP到达文件系统之前触发。你可以在这里拦截、修改、甚至拒绝操作。
- Post-operation回调:在文件系统处理完IRP之后触发。适合做监控、日志记录。
注册回调的代码大概长这样:
// 定义回调函数
FLT_PREOP_CALLBACK_STATUS
MyPreCreateCallback(
_Inout_ PFLT_CALLBACK_DATA Data,
_In_ PCFLT_RELATED_OBJECTS FltObjects,
_Flt_CompletionContext_Outptr_ PVOID *CompletionContext
)
{
UNREFERENCED_PARAMETER(CompletionContext);
// 获取文件名
PFLT_FILE_NAME_INFORMATION nameInfo;
FltGetFileNameInformation(Data, FLT_FILE_NAME_NORMALIZED, &nameInfo);
// 打印文件名
DbgPrint("文件创建操作: %wZ\n", &nameInfo->Name);
FltReleaseFileNameInformation(nameInfo);
return FLT_PREOP_SUCCESS_WITH_CALLBACK;
}
// 注册回调
CONST FLT_OPERATION_REGISTRATION Callbacks[] = {
{ IRP_MJ_CREATE, 0, MyPreCreateCallback, MyPostCreateCallback },
{ IRP_MJ_READ, 0, MyPreReadCallback, NULL },
{ IRP_MJ_WRITE, 0, MyPreWriteCallback, NULL },
{ IRP_MJ_CLEANUP,0, NULL, MyPostCleanupCallback },
{ IRP_MJ_OPERATION_END }
};
小技巧:Pre回调返回FLT_PREOP_SUCCESS_WITH_CALLBACK,表示我还想收到Post回调。如果返回FLT_PREOP_SUCCESS,Post回调就不会被调用。我一般监控操作都用WITH_CALLBACK,这样前后都能处理。
21.3 文件操作监控实战
文件监控是最常见的Minifilter应用场景。你想监控谁在什么时候改了哪个文件,写个Minifilter就搞定了。
我记得有个项目,客户说服务器上的配置文件总被人改,但不知道是谁干的。我写了个Minifilter,监控IRP_MJ_WRITE操作,把进程名、文件名、时间戳全记下来。结果发现是运维脚本有个bug,半夜自动跑的时候写错了路径。
监控的核心逻辑:
FLT_PREOP_CALLBACK_STATUS
MyPreWriteCallback(
_Inout_ PFLT_CALLBACK_DATA Data,
_In_ PCFLT_RELATED_OBJECTS FltObjects,
_Flt_CompletionContext_Outptr_ PVOID *CompletionContext
)
{
// 获取进程ID和进程名
HANDLE pid = PsGetCurrentProcessId();
PEPROCESS process = PsGetCurrentProcess();
// 获取文件名
PFLT_FILE_NAME_INFORMATION nameInfo;
NTSTATUS status = FltGetFileNameInformation(
Data,
FLT_FILE_NAME_NORMALIZED | FLT_FILE_NAME_QUERY_DEFAULT,
&nameInfo
);
if (NT_SUCCESS(status)) {
// 解析文件名,判断是否需要监控
if (IsTargetFile(nameInfo->Name)) {
// 记录日志
LogFileOperation(pid, nameInfo->Name, "WRITE");
}
FltReleaseFileNameInformation(nameInfo);
}
return FLT_PREOP_SUCCESS_WITH_CALLBACK;
}
注意:FltGetFileNameInformation可能会引起递归调用!因为获取文件名本身可能触发文件操作。我建议在Pre回调中设置一个递归检测标志,或者使用FLT_FILE_NAME_QUERY_DEFAULT来避免死循环。
21.4 文件加密与保护实现
文件加密是Minifilter的另一个硬核应用。原理很简单:写文件时加密数据,读文件时解密数据。但实现起来有不少坑。
加密的时机在Pre-Write回调里。文件系统要写数据了,我们先把数据加密,再放行。解密的时机在Post-Read回调里。文件系统读完了数据,我们拿到数据解密,再返回给上层。
代码骨架:
// 写加密
FLT_PREOP_CALLBACK_STATUS
MyPreWriteEncrypt(
_Inout_ PFLT_CALLBACK_DATA Data,
_In_ PCFLT_RELATED_OBJECTS FltObjects,
_Flt_CompletionContext_Outptr_ PVOID *CompletionContext
)
{
PFLT_IO_PARAMETER_BLOCK iopb = Data->Iopb;
// 只加密特定目录的文件
if (!IsProtectedDirectory(FltObjects->FileObject)) {
return FLT_PREOP_SUCCESS_NO_CALLBACK;
}
// 获取写入缓冲区
PMDL mdl = iopb->Parameters.Write.MdlAddress;
PVOID buffer = MmGetSystemAddressForMdlSafe(mdl, NormalPagePriority);
ULONG length = iopb->Parameters.Write.Length;
if (buffer && length > 0) {
// 加密数据(使用AES-256)
EncryptBuffer(buffer, length, GetFileKey(FltObjects->FileObject));
}
return FLT_PREOP_SUCCESS_NO_CALLBACK;
}
// 读解密
FLT_POSTOP_CALLBACK_STATUS
MyPostReadDecrypt(
_Inout_ PFLT_CALLBACK_DATA Data,
_In_ PCFLT_RELATED_OBJECTS FltObjects,
_Flt_CompletionContext_ PVOID CompletionContext,
_In_ FLT_POST_OPERATION_FLAGS Flags
)
{
if (!NT_SUCCESS(Data->IoStatus.Status)) {
return FLT_POSTOP_FINISHED_PROCESSING;
}
PFLT_IO_PARAMETER_BLOCK iopb = Data->Iopb;
PMDL mdl = iopb->Parameters.Read.MdlAddress;
PVOID buffer = MmGetSystemAddressForMdlSafe(mdl, NormalPagePriority);
ULONG length = iopb->Parameters.Read.Length;
if (buffer && length > 0) {
// 解密数据
DecryptBuffer(buffer, length, GetFileKey(FltObjects->FileObject));
}
return FLT_POSTOP_FINISHED_PROCESSING;
}
关键设计决策:加密密钥怎么管理?我见过两种方案:
- 文件级密钥:每个文件一个随机密钥,密钥存在文件流中。安全性高,但实现复杂。
- 目录级密钥:整个受保护目录共用一个密钥。实现简单,适合演示。
我个人推荐生产环境用文件级密钥,配合DPAPI保护主密钥。
21.5 避坑指南
写Minifilter这些年,我踩过的坑能写一本书。这里挑几个最要命的:
- 递归问题:Minifilter里做文件操作,比如写日志文件,会再次触发你的回调。必须用线程本地存储或全局标志来防止递归。
- 分页池与非分页池:Pre回调运行在APC级别,不能使用分页内存。我习惯在DriverEntry里预分配好所有缓冲区。
- 文件重命名:监控IRP_MJ_SET_INFORMATION时,要处理FileRenameInformation情况。很多人只监控了CREATE和WRITE,漏掉了重命名。
- 缓存问题:Windows有缓存管理器。你加密了数据,但缓存里可能是明文。需要配合CcFlushCache使用。
曾经我犯过一个错误:在Post-Read回调里解密数据后,忘了更新Mdl的标记。结果上层拿到的是解密后的数据,但缓存管理器以为还是加密状态。下次读的时候,缓存命中,直接返回了加密数据。应用程序就崩溃了。解决方案是在解密后调用MmBuildMdlForNonPagedPool刷新Mdl。
21.6 性能优化建议
文件过滤驱动对性能极其敏感。你想想看,每次读写都要经过你的回调,稍微慢一点,整个系统都能感觉到。
| 优化点 | 说明 | 建议 |
|---|---|---|
| 回调粒度 | 只注册需要的IRP | 不要注册IRP_MJ_OPERATION_END以外的所有操作 |
| 文件名获取 | FltGetFileNameInformation开销大 | 使用缓存,或只在需要时获取 |
| 加密算法 | AES-256软件实现较慢 | 使用硬件加速(AES-NI),或只加密文件头部 |
| 日志写入 | 每次操作都写日志 | 批量写入,或使用环形缓冲区 |
我有个项目,刚开始没注意性能,结果部署到文件服务器上,用户反馈打开文件慢了好几秒。后来优化了文件名获取逻辑,把加密从全文件加密改成只加密前4KB,性能就上来了。
经验之谈:调试Minifilter时,用WinDbg的!fltkd扩展。它能显示所有加载的Minifilter、Altitude、回调注册情况。我每次调试新驱动,第一件事就是!fltkd.filters看看有没有冲突。
文件系统过滤是个深坑,但掌握了Minifilter框架,你就能做出很多实用的工具。从简单的文件监控,到企业级的透明加密系统,都能用这套技术实现。嗯,今天就先聊到这,代码里的细节,你得多动手试试才能真掌握。
公众号:蓝海资料掘金营,微信deep3321