目录
MiniFilter框架(1):框架原理与通讯
目录
MiniFilter、Legacy驱动区别
- 加载顺序可控,假设系统中安装了2个过滤驱动,都是
Attach到设备栈上,那么谁在上、谁在下呢?这其实是不可控的 MiniFilter引入了Altitude海拔高度的概念,这样一个驱动发布后,其所在位置就是固定的MiniFilter支持安全卸载,过滤驱动跟Hook驱动其实某些极限场景非常相似,派遣回调、Hook回调正在使用中,驱动被卸载了,代码页面释放了,这时候当然蓝屏MiniFilter跟WDF驱动框架一样,都属于回调注册式的驱动框架,内核开发者只需要向系统注册一个回调即可处理自己关心、跟业务有关的功能了MiniFilter中可以非常方便地获取文件全路径信息,这一点在下文有印证- 注册成为
MiniFilter驱动后,DriverObject->DriverUnload卸载例程就不用注册了,可以参考微软官方示例工程NullFilter中的写法,此时驱动卸载工作由FltUnload回调负责
MiniFilter总体框架
- 由下面的框架图可以合理推测,
MiniFilter框架内部其实也是基于驱动过滤的,内部自己创建了一个过滤设备对象Filter DO附加到设备栈上 - 该框架内部做了大量处理,
MiniFilter自己维护了一个Filter栈,谁注册了Filter,就把谁加入到内部的栈中 MiniFilter驱动、Legacy过滤驱动是可以共存的,从框架图中也可以看出- 从框架图中也可以得知,不同行为的驱动会被安排在不同的
Altitude海拔高度处

海拔高度(Altitude)
- 每一个
MiniFilter驱动必须有一个叫做Altitude的唯一标识符,它定义了该驱动加载时在I/O栈中相对其他MiniFilter驱动的位置,值越小,栈中位置就越低 - 关于
Altitude海拔高度,这个属于申请制,跟微软申请,微软内部会维护一个数据库,然后颁发一个独立的Altitude给你,申请到哪个开发者是决定不了的 Altitude高度,同一个分组内(假设都是反病毒),海拔高低没优劣之分
海拔高度,Pre、Post执行顺序关系
- 其实非常好理解,遵循一个入栈、出栈的顺序,如下所示:
Pre Post
高 Altitude A ───────────────→ A
↓ ↑
B ───────────────→ B
↓ ↑
低 Altitude C ───────────────→ C
↓ ↑
File System注册MiniFilter
- 要注册
MiniFilter其实非常简单,就是构造一个结构体,将该结构体作为一个参数向系统注册即可,如下所示:
const FLT_REGISTRATION fileMonitorRegistration =
{
sizeof( FLT_REGISTRATION ), // Size
FLT_REGISTRATION_VERSION, // Version
0, // Flags
ContextRegistration, // ContextRegistration,上下文注册表
fileMonitorCallbacks, // Operation callbacks,操作回调数组
fileMonUnload, // FilterUnload
fileMonInstanceSetup, // InstanceSetup
NULL, // InstanceQueryTeardown
fileMonInstanceTeardownStart, // InstanceTeardownStart
NULL, // InstanceTeardownComplete
NULL, // GenerateFileName
NULL, // GenerateDestinationFileName
NULL // NormalizeNameComponent
};- 内核开发者可以自由地设置回调,你要处理哪些就设置哪些,如下代码所示:
const FLT_OPERATION_REGISTRATION fileMonitorCallbacks[] =
{
{ // 接管文件创建请求,Flags设置为跳过PAGING_IO,即分页IO请求不通知
IRP_MJ_CREATE,
FLTFL_OPERATION_REGISTRATION_SKIP_PAGING_IO,
HOOK_PreNtCreateFile,
HOOK_PostNtCreateFile
},
{
IRP_MJ_CLEANUP,
0,
HOOK_PreNtCleanup,
NULL
},
{
IRP_MJ_WRITE,
0,
HOOK_PreNtWriteFile,
HOOK_PostNtWriteFile
},
{
IRP_MJ_SET_INFORMATION,
0,
HOOK_PreNtSetInformationFile,
HOOK_PostNtSetInformationFile
},
{ IRP_MJ_OPERATION_END } // 数组结束标志,类似"终止符"
};MiniFilter回调返回值
Pre前回调的返回值,主要是以下三种(最常用):
// 需要执行后回调
FLT_PREOP_SUCCESS_WITH_CALLBACK
// 不执行后回调
FLT_PREOP_SUCCESS_NO_CALLBACK
// 本次文件请求结束,不继续下发
FLT_PREOP_COMPLETE
// 禁用FastIO
FLT_PREOP_DISALLOW_FASTIOMiniFilter中获取IRP参数
MiniFilter本质上也是基于IO Manager工作的,也会接收到IRP,那么注册进其中的Filter,该如何获取原始IRP参数信息呢?方法如下
PFLT_CALLBACK_DATA Data;
// 发起请求的进程、PID
PEPROCESS processObject =
Data->Thread ? IoThreadToProcess(Data->Thread) : PsGetCurrentProcess();
HandleToUlong(PsGetProcessId(processObject)); // PID
// 设置本次请求的返回结果(相当于Legacy中的pIrp->IoStatus)
Data->IoStatus.Status = ntStatus;
Data->IoStatus.Information = 0;
// 关联对象
FltObjects->Volume,
FltObjects->Instance,
FltObjects->FileObject,
FltObjects->FileObject->DeviceObject
// 读取类请求的参数
PMDL pReadMdl = Data->Iopb->Parameters.Read.MdlAddress;
PVOID pReadBuffer = Data->Iopb->Parameters.Read.ReadBuffer;
ULONG uReadLength = Data->Iopb->Parameters.Read.Length;
// 创建类请求的参数
Data->Iopb->Parameters.Create.SecurityContext->DesiredAccess;
// 目录查询类请求的参数
PVOID pQueryBuffer = Data->Iopb->Parameters.DirectoryControl.QueryDirectory.DirectoryBuffer;
ULONG uQueryBufferSize = Data->Iopb->Parameters.DirectoryControl.QueryDirectory.Length;
// 拦截本次请求:设置错误码 + 不再下发
Data->IoStatus.Status = STATUS_ACCESS_DENIED;
Data->IoStatus.Information = 0;
return FLT_PREOP_COMPLETE;MiniFilter中获取文件全路径
- 这里有一个小细节,获取文件全路径,一般是在
后回调中获取(在SFilter中也是在CompleteRoutine中获取全路径),因为只有文件打开成功了,此时获取文件全路径才有意义
// 在PostCreate里获得
PFLT_FILE_NAME_INFORMATION pNameInfo = NULL;
ntStatus = FltGetFileNameInformation(Data,
FLT_FILE_NAME_NORMALIZED |
FLT_FILE_NAME_QUERY_DEFAULT,
&pNameInfo);
FltParseFileNameInformation(pNameInfo);
pNameInfo->Name; // 规范化的文件全路径
pNameInfo->Volume; // 卷信息
FltReleaseFileNameInformation(pNameInfo); // 使用完毕必须释放
// 重命名目标路径的获得:
PFILE_RENAME_INFORMATION pFileRenameInformation =
(PFILE_RENAME_INFORMATION)Data->Iopb->Parameters.SetFileInformation.InfoBuffer;
// 或者直接调用专门的函数获取重命名目标:
FltGetDestinationFileNameInformation();MiniFilter回调运行时的IRQL
Pre前回调可以运行在PASSIVE_LEVEL、APC_LEVEL中,大多数情况是PASSIVE_LEVELPost后回调大多数情况下是PASSIVE_LEVEL,但是少数情况是有可能运行在DPC_LEVEL的,需要开发者额外注意- 微软文档中关于回调IRQL的几点补充:
Pre前回调可以在IRQL = PASSIVE_LEVEL或IRQL = APC_LEVEL被调用,典型情况是PASSIVE_LEVEL- 如果
Pre前回调对一个基于IRP的I/O操作返回了FLT_PREOP_SYNCHRONIZE,那么对应的Post后回调会在IRQL <= APC_LEVEL、与Pre前回调相同的线程上下文中被调用 - FastIO操作的
Post后回调在IRQL = PASSIVE_LEVEL、与Pre前回调相同的线程上下文中被调用 - Post-Create回调在IRQL = PASSIVE_LEVEL、发起
IRP_MJ_CREATE操作的线程上下文中被调用
MiniFilter回调内的文件操作
- 由于我们的
MiniFilter回调中,已经处于IO Manager派发的流程中了,此时还调用常规的Zw/NtCreateFile必然会发起新的文件IRP请求,导致重入问题 MiniFilter提供了一组Flt*函数,供开发者安全地操作文件:FltCreateFile、FltReadFile、FltWriteFile、FltClose等
ntStatus = FltCreateFile(pFilter,
pDstInstance,
&hDstFile,
GENERIC_WRITE | SYNCHRONIZE,
&objDstAttrib,
&ioStatus,
0,
FILE_ATTRIBUTE_NORMAL,
FILE_SHARE_READ |
FILE_SHARE_WRITE |
FILE_SHARE_DELETE,
FILE_CREATE,
CreateOptions,
NULL,0, 0);MiniFilter对象上下文
- 这里有点类似
WDF驱动中的对象上下文 - 这里有一个形象的例子,对象比做人,那么上下文就是衣服上的口袋,可以快速存取自定义的数据
- 比较常用的上下文如下:
- 流句柄上下文(Stream Handle Ctx):就是文件句柄(File Object)上下文,比如用来保存本次是写打开、还是读打开
- 实例上下文(Instance):
MiniFilter中,卷数量、实例数量是一一对应的,分配实例上下文,一般是保存卷名、卷文件系统、卷设备类型等等数据
一个卷设备就有着一个独立的设备栈,那么
fltmgr.sys在工作时,就会给每一个卷都创建一个FLT实例(合理推测内部工作原理跟SFilter一样,创建Filter DO附加上去),使每个卷都可以正常过滤文件消息
- 流上下文(Stream Ctx):透明加解密会用到
强化MiniFilter上下文理解
- 这里需要明确一个前置概念,
MiniFilter框架不会自动分配上下文内存、并且采用引用计数实现生命周期管理 - 需要自己手动
FltAllocateCtx、FltSetxxxCtx之后,就可以调用FltGetxxxCtx使用了 - 关于生命周期管理,可以参考如下(Instance上下文为例):
初始化分配Ctx---------------------------------------------------------
FltAllocateCtx 引用计数 = 1
FltSetxxxCtx 引用计数 + 1 = 2 <--- 将这个Ctx关联到xxx对象
FltReleaseCtx 引用计数 - 1 = 1 <--- 这里的Release本不是释放内存的意思,而是交出自己的占用
让FltMgr自己管理Ctx生命周期
后续使用这个Ctx---------------------------------------------------------
FltGetxxxCtx 引用计数 + 1
FltReleaseCtx 引用计数 - 1 <--- Ctx使用结束,释放占用
关联的对象被销毁---------------------------------------------------------
FltMgr自动将引用计数 - 1
Ctx引用计数归零,先调用我们注册的Cleanup回调、释放内存MiniFilter通讯端口
- 使用
MiniFilter的通讯端口能力,可以轻松实现R3、R0互相通讯,通讯模型如下所示:
【R3主动】
R3:FilterSendMessage(R3 Port)
│ 通过 FilterConnectCommunicationPort 连接到R0端口
↓
R0:fnMessageFromClient(R0 Port)
【R0主动】
R0:FltSendMessage(R0 Port)
↓
R3:FilterGetMessage / FilterReplyMessage(R3 Port)- R0端通过
FltCreateCommunicationPort创建通讯端口,如下所示:
FltCreateCommunicationPort(
g_pFilter,
&g_pServerPort,
&oa, // 设置PORT的名字
NULL, // ServerPortCookie
fnConnectFromClient, // R3连接端口时回调
fnDisconnectFromClient, // R3断开端口时回调
fnMessageFromClient, // 处理从R3 FilterSendMessage的请求
1);- 根据如下对R3缓冲区的处理方式,可以合理推测这里使用的是
Neither,既不是复制缓冲区、也不是MDL锁住访问,居然是直接操作R3缓冲区 - 所以安全用法是内核SEH+ProbeForxxx探测
// 处理从R3 FilterSendMessage的请求
NTSTATUS
fnMessageFromClient(
IN PVOID PortCookie,
IN PVOID InputBuffer OPTIONAL,
IN ULONG InputBufferLength,
OUT PVOID OutputBuffer OPTIONAL,
IN ULONG OutputBufferLength,
OUT PULONG ReturnOutputBufferLength
)
{
__try
{
ProbeForRead(InputBuffer, InputBufferLength, sizeof(ULONG));
// GET InputBuffer
// Do something
ProbeForWrite(OutputBuffer, OutputBufferLength, sizeof(ULONG));
// Copy Result to OutputBuffer
}
__except(EXCEPTION_EXECUTE_HANDLER)
{
return STATUS_NOT_IMPLEMENTED;
}
return STATUS_SUCCESS;
}知识拓展,当某些特殊场景下,无法使用内核SEH时,可以使用MmCopyVirtualMemory安全地访问R3内存,因为该函数内部有SEH保护、且处于ntoskrnl中,可以正常走异常派发流程修复异常
MiniFilter R0发送通知到R3(双向通讯)
- 要实现R3、R0双向通讯,那么首先要做的肯定是约定通讯协议,我们简单设置如下:
struct _COMM_PACK
{
ULONG CtlCode;
PVOID pBuffer;
ULONG BufLen;
BOOLEAN NeedReplay; // 本次消息是否需要回复
};- 处于内核态的驱动,调用
FltSendMessage发送消息到R3:
FltSendMessage(
g_hFilter,
&g_ClientPort,
Message,
MessageLength,
NULL, // 单向通知
0, // 单向通知
&Timeout);- 处于R3的应用程序,调用
FilterGetMessage接收到的就不是_COMM_PACK对象了,被增加了一个协议头,结构如下所示:
typedef struct _FILTER_MESSAGE_HEADER
{
// R0接收回复数据,缓冲区的大小
ULONG ReplyLength;
// 本次信息ID,通讯标识符
ULONGLONG MessageId;
} FILTER_MESSAGE_HEADER, *PFILTER_MESSAGE_HEADER;
struct _FLT_MSG_PACK
{
// 相当于Flt通讯的协议头
_FILTER_MESSAGE_HEADER Header;
// 存放实际数据内容
_COMM_PACK Payload;
};typedef struct _FILTER_REPLY_HEADER
{
// 告诉R0结果
NTSTATUS Status;
// 本次信息ID,通讯标识符
ULONGLONG MessageId;
} FILTER_REPLY_HEADER, *PFILTER_REPLY_HEADER;
struct _FLT_MSG_PACK_REPLAY
{
// 相当于Flt通讯的协议头
_FILTER_REPLY_HEADER Header;
// 存放实际数据内容
_COMM_PACK Payload;
};- 示例R3代码如下所示:
void NotifyThread()
{
while(1)
{
// 阻塞、死等
FLT_MSG_PACK RecvMsg;
FilterGetMessage(..., &RecvMsg.Header, NULL);
// 等到Msg了,解析、执行动作...
// 如果需要回复消息、且R0缓冲区足够大
if (RecvMsg.Payload.NeedReplay &&
RecvMsg.Header.ReplyLength > sizeof(FLT_MSG_PACK_REPLAY))
{
FLT_MSG_PACK_REPLAY ReplayMsg;
ReplayMsg.Header.Status = 0;
ReplayMsg.Header.MessageId = RecvMsg.Header.MessageId;
ReplayMsg.Payload = /**/;
FilterReplyMessage(..., &ReplayMsg.Header, sizeof(ReplayMsg));
}
}
}MiniFilter中的Create回调
- 不管
SFilter、还是现代化的MiniFilter,都不应该在Pre前回调中做业务逻辑,因为在Pre回调中获取的文件路径不保真 - 正经操作一个是在
Post后回调中可以根据打开的结果,安全地获取文件路径、扫描等等操作,如果扫描到敏感特征,后回调中是可以取消本次文件打开的
// 在Post回调,也是有机会关闭已打开的文件
VOID FLTAPI FltCancelFileOpen(
[in] PFLT_INSTANCE Instance,
[in] PFILE_OBJECT FileObject
);Create回调中的文件扫描策略
- 在
Scanner示例工程中,在Post后回调中,将文件内容读出来,再将内容发回R3进行处理 - 这里其实是规范的操作:
- 我们的
Hips主防驱动,核心在于实现文件请求监控 - 内核中有着IRQL规范要求,对调用API有着限制,而且内核中不会、也不应该承载沉重、复杂的扫描逻辑
- 将文件内容发回R3处理,主防引擎可以自由地调用各种库、各种算法实现扫描,必要时主防引擎还可以动态热更新
- 扫描的动作放置在R3,哪怕出现异常崩溃了,也不会导致用户系统BSOD
- 我们的
用户打开超大文件时Hips策略
- 当用户打开一个非常大的文件(假设2g),那么此时Hips就不能沿用常规的全量扫描策略了
- 应该扫描文件关键区域,例如扫描PE头,定位到导入表,有没有调用敏感的跨进程操作、内存操作相关的API
- 校验文件签名
- 计算文件熵值,如果熵值过高,极有可能是一个压缩文件,此时可以降低扫描优先级、或者跳过
信息论、文件压缩的知识,压缩的本质就是去除冗余的信息,那么压缩后的文件就代表"信息熵"很大,即不可预测性,此时就能说这个文件熵值很高
MiniFilter中的Write、Cleanup回调
Write前回调没啥好说的,参数中的缓冲区内,就保存有待写入的字节集,此时直接Copy、发回R3扫描即可Cleanup回调,就是计算文件MD5发回服务器搜索,再判断是删除、隔离、还是放行