第3章微过滤驱动与模块执行防御(上)
微软在Windows的内核中提供了一套进行文件系统过滤驱动开发的标准接口。利用这套接口开发出来的驱动程序即为微过滤驱动(minifilter driver)。微过滤驱动是内核级的安全模块,能捕获正常的文件写入、文件改名等事件,正符合本篇开发模块执行防御的需求。
本书并不会详细介绍Windows驱动开发的环境准备和基础架构代码。有相关需要的读者建议阅读《Windows内核调试技术》,或是《Windows内核编程》,这两本书中都有配置环境和准备调试环境、微过滤驱动开发和调试的详细说明。
我们需要一个微过滤驱动的范例,在其基础上开发和改造来实现需要的功能。GitHub上有大量的微过滤驱动的范例,我建议选择微软提供的例子,这些例子是最可靠和权威的。
GitHub上微软的目录下有一个名为Windows-driver-samples的目录,在其下路径blob/main/filesys/miniFilter下可以找到许多微过滤驱动的例子。其中的passThrough是一个只过滤不做任何处理的例子,非常便于理解。
下面以写操作的过滤作为说明。但写操作过滤的详尽实现将会在3.2节中呈现。
根据本书2.3.1节中设计的规则2.2,在任何新的可执行文件创建时,我们应捕获事件并将文件路径加入到可执行列表中。但显然,文件的创建操作所携带的信息并不足以完成这个操作。因为文件创建的初期,我们并不知道被创建的文件是否是一个可执行文件。
因此实际需要被捕获的操作并不是文件的创建,而是文件内容的写入。这和2.3.1节中设计的规则2.3刚好可以合并处理:捕获对任何文件的写入事件。任何时候当一个可执行文件被写入之后依然是一个可执行文件,或者一个不可执行文件被写入之后变成可执行文件了,我们则将其路径加入到2.3.1节中所述过的可疑库中。
综上所述,我们需要捕获的是文件被写入的事件。捕获写入事件的相关完整的代码将在本节中介绍。有经验的读者可能会提前意识到本节代码中含有漏洞。事实上不存在漏洞的安全系统是不存在的。我会在2.3节“漏洞分析”中详细解释并予以部分弥补。
1
理解微过滤驱动框架
参考passThrough的代码,微过滤驱动中最重要的基础框架为一个类型为FLT_OPERATION_REGISTRATION的数据结构的数组(本书后面称之为过滤操作数组,上下文明确时简称数组),该数组中内含一组回调函数指针。
开发微过滤驱动的主要工作是编写这些回调函数,并将回调函数指针填入该数组中,然后使用函数FltRegisterFilter向Windows内核注册。其中passThrough中对过滤操作结构的初始化如图3-1所示。
图3-1 passThrough中对过滤操作结构的初始化
图3-1中箭头所示可以看到数组中每个元素的初始化定义中有两个回调函数指针。其中之一以PtPre开头,而另一个以PtPost开头。Pre和Post分别表示前回调与后回调。前回调将被调用于请求完成之前,而后回调则会被调用于请求完成之后。
在回调函数指针之前的IRP_MJ_XXX系列的宏,则是发生的请求的主功能号。这个宏中的“IRP”前缀对应另一个数据结构IRP,全称为I/O Request Packet,即IO操作请求包,是Windows内核中用来处理诸如文件、磁盘读写这类IO操作的常用数据结构。后文我会将IRP简称为“请求”。
请求有多种,由主功能号进行分别。我们要处理的写操作,正是其中的请求之一。过滤操作数组的每个元素的意义为:如果发生了主功能号为IRP_MJ_XXX的请求,则先调用对应元素下的前回调函数指针(如果前回调函数中返回需要后回调,那么完成后还会再调用后回调函数)。
其中IRP_MJ_CREATE代表着文件的创建和打开请求,IRP_MJ_READ和IRP_MJ_WRITE分别为读和写请求,其他的请求依此类推。从上面这个数组初始化过程看所有请求都共用了前回调函数PtPreOperationPassThrough和后回调函数PtPostOperationPassThrough。当然,也可以为每个请求指定不同的回调函数指针。
因此,PassThrough在按微软相关文档提供的说明编译和安装在被测试机上的时候,一旦有文件被写入(出现IRP_MJ_WRITE请求),就会触发PtPreOperationPassThrough这个函数的调用。至于后回调是否被调用则取决于前回调的返回值。
从3.1.2节开始,我们的代码会注册一系列回调函数,其中包括写操作的前回调和后回调,并在其中处理写入内容可能导致文件变成可执行的情况。此事从原理上看颇为简单,但实际上我们很快会看到在操作系统内核中进行各种处理的艰难和复杂。
2
分页写入与非分页写入
如果要捕获系统中所有文件被写入这一事件,理论上只要在图3-1中的PtPreOperationPassThrough函数中专门进行写请求的处理即可。但我对Callbacks相关代码进行了修改,在过滤操作数组中为需要处理的生成和写请求都指定了专门的处理函数,如代码3-1所示。
代码3-1 在过滤操作数组为需要处理的写请求指定专门的处理函数
//文件过滤驱动需要过滤的回调
CONST FLT_OPERATION_REGISTRATION callbacks[] = {
…
{
IRP_MJ_CREATE,
0,
CreateIrpProcess,
CreateIrpPost
},
{
IRP_MJ_WRITE,
FLTFL_OPERATION_REGISTRATION_SKIP_PAGING_IO, (1)
WriteIrpProcess,
WriteIrpPost
},
…
{
IRP_MJ_OPERATION_END
}
};
代码3-1中需要注意的是(1)处,该标志表示不过滤分页(PAGING_IO)操作请求,直接跳过。这一处首次凸显了内核中文件系统处理的复杂性。
在用户态中,文件的“写入”操作非常单纯。只需要调用API[1](如WriteFile)将数据写入文件内容中,即为写操作,而无需关心内核中底层的实现。
但内核为了提升文件访问的性能,避免每一次读写文件都去转动磁盘,实际上将文件的部分内容保存在内存中,称之为文件缓存。只有文件缓存中找不到对应的内容,才会去真实磁盘中读取。
从用户态用API进行文件操作,看到的文件是文件缓存,而不是磁盘中的真实文件。API在内核中首先被转换为非分页请求,操作文件缓存。当文件缓存不能满足需求的时候,再使用分页请求操作真实文件内容。
一次用户态发起的写文件的操作实际完成过程如图3-2所示。
分页请求的过滤相当麻烦。原因在于分页请求发生时的中断级(即IRQL)相当高,也就是说,代码运行到这里时将无法被很多情况打断。
中断级时处理器运行时的一种状态,标志着当前运行的代码在何种情况下能够被打断。微软提供的内核函数都标示有明确的调用级要求,很多我们需要使用的函数调用级别要求很低,这意味着代码必须能够被打断。
举个简单的例子,假定我们调用函数IoCreateFile,该函数内部不可避免地可能调用分页(Paged)内存。分页内存的特性是不常使用时可能被回收,真实数据保存在磁盘上。当被用到时异常会打断当前代码,等待分页交互(即重新分配物理内存,并把数据从磁盘移动到内存中)的过程完成,才可以继续。因此调用IoCreateFile的时候当前中断级必须要能够接受被缺页异常打断,否则就无法调用此函数。
图3-2 一次用户态发起的写文件的操作实际完成过程
这只是一个例子,实际上关于中断级可能还有其他的要求。因此在MSDN[2]中查阅IoCreateFile的文档时,我们能看到MSDN中关于函数的中断级要求,如图3-3所示。
图3-3 MSDN中关于函数的中断级要求
因为有这样的麻烦存在,大多数情况下,我们并不推荐过滤分页请求。所以在代码3-1中,我们使用了特殊标记来跳过对分页请求的过滤,即FLTFL_OPERATION_REGISTRATION_SKIP_PAGING_IO。
从图3-2来看,因为从用户态发起的文件写操作只会被转换为非分页请求(此处实际有漏洞,会在第4章的缺陷分析中详述),而分页请求只是内核内部使用。因此,考虑到本防御系统的本意只是防范初次执行,这样刚好也足够了。
经过代码3-1的处理,我们似乎只需要编写其中的函数WriteIrpProcess即可实现对任何文件的写操作进行拦截。虽然实际上并非如此(在3.4节中会有相关漏洞分析),但我们已经可以开始尝试进行第一步了。
3
请求前回调函数编写的基本模板
微过滤驱动中所有的请求前回调函数原型都是一样的。代码3-1中的WriteIrpProcess的实现将从一个基本模板开始。请求前回调函数编写的基本模板如代码3-2所示。
代码3-2 请求前回调函数编写的基本模板
FLT_PREOP_CALLBACK_STATUS
WriteIrpProcess(
PFLT_CALLBACK_DATA data,
PCFLT_RELATED_OBJECTS flt_obj,
PVOID* compl_context)
{
FLT_PREOP_CALLBACK_STATUS flt_status =
FLT_PREOP_SUCCESS_NO_CALLBACK; (1)
do {
BreakIf(…); (2)
BreakDoIf(…); (3)
…
flt_status = FLT_PREOP_SUCCESS_WITH_CALLBACK; (4)
} while(0);
DoIf(…); (5)
Return flt_status;
}
所有前回调函数的返回值类型都是FLT_PREOP_CALLBACK_STATUS。其值如果是FLT_PREOP_SUCCESS_NO_CALLBACK,则说明我们不再需要后回调,后回调不会再被调用。但反之如果是另一种返回值FLT_PREOP_SUCCESS_WITH_CALLBACK,则后回调会被调用。
一般情况下,为了追求最大性能和最小的影响,各类回调函数都是非必要则不要调用。所以(1)处我们可以看到返回值flt_status被初始化为FLT_PREOP_SUCCESS_NO_CALLBACK。但最终某种情况下我们可能会需要调用后回调函数。
如果此次请求确实有可能导致一个PE文件被修改,或者使得一个非PE文件变成PE文件,那么根据2.3.1节中的规则2.3,改文件的全路径必须被加入到可疑库中。这个操作显然必须是请求能成功完成才能施行的。
前面已介绍过前回调被调用的时候请求还没有完成,因此该请求是否能成功完成此时是无法预知的。这时我们就必须让后回调函数被调用并在后回调函数中处理。
函数的处理主体是一个do ~ while(0)的循环。这样的结构是为了方便从处理逻辑的深处跳出并返回。
一种常见的、不正确的写法是,函数中存在多处返回(return语句),且一些返回出现在复杂的逻辑深处。这对代码的质量是非常不利的。因为内核函数的返回往往需要伴随资源的释放、锁的解除等处理。确保整个函数只有一个返回点,有利于减少死锁、资源泄漏等等问题,也让逻辑变得更容易理解。
在任何情况下,判断或者循环的嵌套越少越好。因此整个函数逻辑的编写原则为:先找到所有否定的条件,逐个跳出循环,这样后续逻辑会越来越简单,而不至于深嵌一大堆“if”。因此上面(2)、(3)处用到两个“Break”宏。同时资源的释放统一在函数最后返回之前的(5)处进行。资源的释放往往也伴随着条件判断,因此使用了“DoIf”宏。
我将上述用到的宏称为“逻辑宏”。本书代码中常用的三个逻辑宏定义如代码3-4所示。它们本质是简单的if~break,或者if~{}语句,在本书中起到减少代码行数、使得排版紧凑的效果。
代码3-3 本书代码中常用的三个逻辑宏定义
//为了方便从do循环中检测错误并跳出而定义的宏
//如果a成立则break
#define BreakIf(a) if (a) break;
//如果a成立,则执行b并break
#define BreakDoIf(a, b) if (a) {b; break;};
//为了清理资源而增加的宏
//如果a成立,则执行b(清理资源)
#define DoIf(a, b) if(a){ b; };
除了以上宏之外,在代码3-2中,另一个值得注意的点是前回调函数的参数。其中的data、flt_obj这两个参数的用途将在实际代码中展现。comp_context是一个可设置的上下文指针,供前回调函数和后回调函数之间通信使用。
也就是说,如果前回调函数需要传递某些信息给这次前回调所对应的后回调,那么可以分配一个自定义的数据结构并填写信息,并将指针赋给*comp_context。那么该指针将会作为参数传递给后回调函数。
当然,不分配任何内存,只是给这个指针位填入任何有意义的内容也是可以的。只要后回调函数知道如何理解这些内容就行。在3.2节的代码中,会出现类似的用法。
4
为读取操作解决重入和权限问题
由于需要判断一个文件是否为可执行文件(PE文件),这个项目显然需要在微过滤驱动中读取文件内容。从微过滤驱动中读写文件的操作并不像在用户态读写文件那么自然。
这是因为微过滤驱动本身就是用来对文件操作进行过滤的。如果微过滤驱动自己发起文件的读写操作,那么就有一个问题必须考虑进去:微过滤驱动会不会捕获自己发起的读写操作呢?如果捕获了,这个操作会不会再度触发递归的文件操作,陷入重入的死循环呢?
因此在微过滤驱动中进行文件读写的时候,一般不会和用户态中一样遵循打开文件-读写文件-关闭文件的顺序。这个涉及的操作太多,性能上也不经济。通常的方法是,直接使用过滤到的操作参数中包含的文件对象,对该文件对象使用微过滤驱动中专用的“向下”读写操作。
这里的所谓“向下”读写操作是指并非从系统中从头发起,而是直接发往该微过滤驱动的“下层”,从而避免再次被自己过滤到引发重入的操作
重入的文件解决后,另一个问题也就是权限问题浮现了。回想一下我们在2.3节中的设计:在文件被写和被改名的时候进行处理。那么文件写入的时候,该文件的内容需要被读取出来以便确定是否为可执行文件。
但Windows内核中每个文件对象被打开的时候都确定了权限。写权限和读权限是分开的,在写操作的参数中出现的文件对象不一定拥有读权限。此时再试图去获取权限已经迟了。因此我在文件对象被打开的时候(创建请求的处理中)给它填上读权限。
这实际上会造成一个安全漏洞:一个本来只允许进行写操作的文件对象,实际上有了读的能力。如果要彻底解决这个文件,应该在打开文件对象的时候创建另一个只能被内核看到的、具有读权限的影子文件对象用来进行读取操作。但这样会使得代码过于冗长。而且考虑到读能力只会造成信息泄漏,并不会导致文件被感染,所以我将接受这个残余风险。
注意代码3-1中,我已经为进行文件打开的操作准备了回调函数CreateIrpProcess。CreateIrpProcess的实现如代码3-4所示。
代码3-4 CreateIrpProcess的实现
FLT_PREOP_CALLBACK_STATUS
CreateIrpProcess(
PFLT_CALLBACK_DATA data, (1)
PCFLT_RELATED_OBJECTS flt_obj,
PVOID* compl_context)
{
NTSTATUS status = STATUS_SUCCESS;
FLT_PREOP_CALLBACK_STATUS flt_status =
FLT_PREOP_SUCCESS_NO_CALLBACK;
do {
//如果说文件打开的之后有写权限,说明文件可能会被改写。那么改写后我就必须
//检查是否是PE文件。如果我要检查,我就需要读权限。所以这里检查如果有
//写权限就加上读权限。这个修改对上层没有功能性的影响。但可能因为权限扩大
//了而导致一个新的漏洞。那就是所有只写权限都变成了读写权限
ACCESS_MASK* access_mask = (2)
&data->Iopb->Parameters.Create.SecurityContext->DesiredAccess;
if ((*access_mask) & FILE_GENERIC_WRITE)
{
(*access_mask) |= FILE_READ_DATA; (3)
}
//为了捕获文件的删除,对有删除企图的,我需要后回调里去加上context
BreakIf(!(data->Iopb->Parameters.Create.Options &
FILE_DELETE_ON_CLOSE));
flt_status = FLT_PREOP_SUCCESS_WITH_CALLBACK;
} while (0);
return flt_status;
}
CreateIrpProcess的函数原型和代码3-2中的WriteIrpProcess的原型是完全一样的。因为它们都是文件操作的前回调函数。只不过前者拦截的文件生成请求而后者是文件写入请求。
请注意代码3-4中的(1)处。在前回调函数中的data参数含有关于这次请求的相关参数,非常重要。这些参数不但可以读取而且可以修改。在一次打开或创建(Create)请求中,打开所请求的权限也含在data指向的结构中。这一点在(2)处的代码行体现。
要注意的是关于请求的具体参数都在data->Iopb->Parameters结构中。Parameters结构是一个很大的共用体类型,其中含有各种不同请求的参数的具体数据结构。再进一步,对应打开或创建文件的请求的相关参数都保存在data->Iopb->Parameters.Create中。
具体这次我们要解决的问题,也就是打开文件对象所请求的权限,则是保存在data->Iopb->Parameters.Create.SecurityContext->DesiredAccess中。微过滤驱动可以修改这个权限,从而让文件对象获得与预期不同的权限。
相关的修改在上述代码的(3)处。该行代码通过或操作给access_mask设置了FILE_READ_DATA,从而增加了读取数据的权限。
CreateIrpProcess的最后还有少量代码和捕获文件的删除有关,本节不会涉及这些内容。
[1]本书中的API(Application Programming Interface,应用程序编程接口)特指Windows提供的用户态应用程序编程接口,包括一系列Windows提供的API函数。
[2]本书中的MSDN指Microsoft Developer Network,即微软的开发者资源网站,其中提供了关于Windows编程的最官方的文档。
看雪ID:星星人
https://bbs.kanxue.com/user-home-143652.htm
# 往期推荐
球分享
球点赞
球在看
点击阅读原文查看更多