本系列文章为看雪星星人为看雪安全爱好者创作的原创免费作品。
欢迎评论、交流、转载,转载请保留看雪星星人署名并勿用于商业盈利。
本人水平有限,错漏在所难免,欢迎批评指正。
第3章 微过滤驱动与模块执行防御(下)
3.3 利用微过滤捕获文件改名
除了写操作之外,文件的改名(对文件系统来说,改名和移动是等价的,3.2.5节中详细介绍)同样需要捕获。原因是可疑库中保存的是文件路径。如果一个可疑库中的文件进行了改名,那么显然必须同步更新可疑库中保存的路径,否则就会发生“可疑逃逸”而成为不可疑的文件。
对文件改名操作的捕获和对写操作的捕获类似,都是在请求前回调和后回调中做相应处理即可。但值得注意的是,微软的文件系统中并无专门的“改名”请求。改名操作是通过设置请求(3.2.5节中详细介绍)来实现的。
3.3.1 设置请求的前回调中发现文件改名
本书中的设置请求全称应该是“信息设置(Set Information)请求”,其主功能号为IRP_MJ_SET_INFORMATION。与设置请求相对的还有一个查询请求(Query Information)。设置和查询操作的对象是文件的属性,这些属性由信息类(Information Class)进行区分,有着非常复杂庞大的体系,是文件系统中极为重要的操作。
文件的名字(也包括路径),被认为是文件的属性之一。因此文件的改名也是通过设置请求来实现的。后文将这种特别的设置请求称为改名请求。这里要注意的是文件系统并不认为文件名的修改和文件路径的修改有什么不同。很显然,文件路径的修改会导致文件的移动。因此,文件的移动和文件的改名对文件系统来说是同一件事。
这里常令人疑惑的是实际操作和文件系统请求的对应关系。比如在Windows的资源管理器中将一个文件从一个文件夹拖动到另一个文件夹中的行为,这明显对应着文件的改名请求。但你会发现当你将一个文件从C盘拖动到D盘,文件系统种并不会发生任何改名请求。
原因是文件的改名和移动只能局限在一个卷(Volume)内。C盘、D盘在文件系统看来都是卷。因此类似C盘拖动文件到D盘这种操作必须分解:首先将文件从C盘复制到D盘,然后将C盘上的文件删除。
这种情况虽然无法被我们的改名请求过滤捕获,但D盘上新建文件的行为显然会被写请求过滤捕获并加入可疑路径,因此不会造成漏洞。
与之相关的另一种操作是文件的删除。在本书的第4章中我还会更详尽地介绍删除操作。这里我们要知道的是,有时[1]删除也是一种设置请求,但设置时指定的信息类不同。但一种常见的操作,即将文件删除到回收站里,这并不是删除,而是一种改名(移动到回收站),我们能捕获到改名请求。
下面首先要将设置请求加入到操作数组中。参考代码3-1,在操作数组的初始化中加入IRP_MJ_SET_INFORMATION的前后操作回调如代码3-12所示。
代码3-12操作数组的初始化中加入IRP_MJ_SET_INFORMATION的前后操作回调
// 文件过滤驱动需要过滤的回调
CONST FLT_OPERATION_REGISTRATION callbacks[] = {
…
{
IRP_MJ_SET_INFORMATION,
FLTFL_OPERATION_REGISTRATION_SKIP_PAGING_IO,
SetInformationIrpProcess,
SetInformationIrpPost
},
…
{
IRP_MJ_OPERATION_END
}
};
以上代码和写请求过滤时操作数组的初始化如出一辙。接下来是函数SetInformationIrpProcess的代码的实现。这些代码和WriteIrpProcess的实现也是极为类似的。SetInformationIrpProcess具体的实现如代码3-13所示。
代码3-13 SetInformationIrpProcess具体的实现
FLT_PREOP_CALLBACK_STATUS
SetInformationIrpProcess(
PFLT_CALLBACK_DATA data,
PCFLT_RELATED_OBJECTS flt_obj,
PVOID* compl_context)
{
// 在这里,我只关心文件的重命名操作。对其他操作不需要后回调。
FLT_PREOP_CALLBACK_STATUS flt_status =
FLT_PREOP_SUCCESS_NO_CALLBACK;
PFILE_OBJECT file_obj = data->Iopb->TargetFileObject;
NTSTATUS status = STATUS_SUCCESS;
DUBIOUS_PATH *path = NULL;
FILE_INFORMATION_CLASS file_infor_class =
data->Iopb->Parameters.SetFileInformation.FileInformationClass; (1)
do {
// 判断是否重命名请求。
BreakIf(data->Iopb->MajorFunction != IRP_MJ_SET_INFORMATION ||
file_infor_class != FileRenameInformation);
// 当请求irql完全不符合标准的时候,这个操作无法拦截。因为我不知道有任何方法
// 可以去获取当前文件路径。但这似乎不应该发生。所以我加了ASSERT。如果一旦发
// 生,那么就造成一个漏洞(当然漏洞不一定可以利用)。
ASSERT(KeGetCurrentIrql() <= APC_LEVEL);
BreakIf(KeGetCurrentIrql() > APC_LEVEL);
if (file_infor_class == FileRenameInformation) (2)
{
path = DubiousGetFilePathAndUpcase(data); (3)
// 如果获取失败了,这没有办法,只能放弃。这也有形成漏洞的可能性。为了封
// 闭这一漏洞,我们直接让请求失败
BreakDoIf(path == NULL, status = STATUS_NO_MEMORY);
// 判断这个列表是否在我们的可疑列表中。如果不在的,无需处理。
BreakIf(!IsDubious(path)); (4)
// 到了这里,确认是要处理的,把已经获得的Path记录下来。
*compl_context = (PVOID)path; (5)
flt_status = FLT_PREOP_SUCCESS_WITH_CALLBACK; (6)
}
…
} while (0);
// 如果中途有解析失败等情况,直接让请求失败。
if (status != STATUS_SUCCESS)
{
data->IoStatus.Status = status;
flt_status = FLT_PREOP_COMPLETE;
}
// 清理path。这种情况下path无需继续传递到后回调。
DoIf(flt_status != FLT_PREOP_SUCCESS_WITH_CALLBACK && path != NULL,
ExFreePool(path));
return flt_status;
}
该函数的参数、以及函数进入之后的各种前置条件检查都和3.2节中介绍过的函数WriteIrpProcess类似,这里不再赘述。值得关注的是(1)处。在设置请求中,最重要的是要设置的信息类。该参数为一个枚举类型,可以从data->Iopb->Parameters.SetFileInformation.FileInformationClass中获取。
获得此值之后,如果为FileRenameInformation则说明这是一个文件改名请求。(2)处有相关的判断。如果确认为改名请求,那么我首先要获得改名之前的文件的全路径并全部改为大写,这在3处完成。
接下来判断这个路径是否在可疑库中(也就是说是不是可疑路径)。如果一个路径本身不是可疑路径,那么改名并不会产生新的可疑模块,对系统并没有什么威胁,所以不用处理。但如果一个可疑模块改名,就等于一个可疑模块消失,并新出现了另一个不同路径的可疑模块。所以在(4)处做了判断。
你可以看到我们的判断始终以“BreakIf”为主,将不处理的情况不断跳出,而留下必须处理的情况。这样可以确保逻辑简单,并且做漏洞分析更容易,因为漏洞大概率会在“Break”的节点产生。
如果已经确认这是一个可以路径,那么获得的全部大写化的路径的指针会保存在上下文指针中,以便传递到后回调中处理。和WriteIrpProcess类似,这也是需要后回调处理才能确认请求是否成功,才能进行实质性的操作。
如果在前回调中直接将可疑库中的路径修改掉,就会留下一个经典的漏洞:攻击者只需要发一个一定会失败的重命名请求,比如名字中含有非法字符(一些符号不允许在路径中出现)到内核,这里就会把可疑库中的路径修改掉。接着请求失败,但可疑库中原有的可疑路径已经被修改了。这等于将一个可疑模块设定为白模块,以后可以随意执行了。
因此,为了向内核表明后回调必须被调用,(6)处将本函数返回值设定为FLT_PREOP_SUCCESS_WITH_CALLBACK。至此,本函数的逻辑基本介绍完毕,接下来是后回调中的处理。
3.3.2 文件改名的后回调中调用安全函数
本节讲解文件改名的后回调。这里需要注意的是后回调参数中的上下文指针。根据3.2.5节中的内容,上下文指针已经在前回调中设置为可疑文件的全路径(已全部转换为大写)。这里有一个疑问:既然一定要在后回调中处理,那何不在后回调中再获取文件路径,而要通过上下文指针来传递这么麻烦呢?
需要通过上下文指针传递文件的原始路径的原因是这是一个改名操作。到了后回调中,文件大概率已经被改名成功。此时要获得改名之前的路径可就麻烦了。所以在前回调中获得一次之后,不要释放,而要通过上下文指针传递到后回调中。
和写请求的后回调函数一样,因为很难确认后回调的中断级,真正的处理依然在安全函数中进行。所以后回调主要的工作是设置安全函数并把参数传递给安全回调。后回调函数SetInformationIrpPost的实现如代码3-14所示。
代码3-14 后回调函数SetInformationIrpPost的实现
FLT_POSTOP_CALLBACK_STATUS SetInformationIrpPost(
_Inout_ PFLT_CALLBACK_DATA data,
_In_ PCFLT_RELATED_OBJECTS flt_obj,
_In_opt_ PVOID compl_ctx,
_In_ FLT_POST_OPERATION_FLAGS Flags
)
{
PFLT_FILE_NAME_INFORMATION name_info = { 0 };
PFILE_OBJECT file_obj = data->Iopb->TargetFileObject;
FLT_POSTOP_CALLBACK_STATUS ret = FLT_POSTOP_FINISHED_PROCESSING;
FLT_POSTOP_CALLBACK_STATUS ret_status;
BOOLEAN ret_bool = FALSE;
do {
// 正常情况下rename都是irp操作
ASSERT(FLT_IS_IRP_OPERATION(data));
BreakIf(!FLT_IS_IRP_OPERATION(data));
ret_bool = FltDoCompletionProcessingWhenSafe(
data, flt_obj, compl_ctx, 0,
SetInformationSafePostCallback, &ret_status); (1)
// 请求无法列队,也就无法完成,放弃
BreakIf(!ret_bool);
ret = ret_status;
} while (0);
if (!ret_bool && compl_ctx != NULL)
{
// 如果RenameSafePostCallback不能得到执行,那么本函数就要负责
// 释放上下文。但遗憾的是这里也有中断级别要求。如果不符合,只能造
// 成泄漏。但理论上不会这么悲剧,所以加入ASSERT
ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);
if (KeGetCurrentIrql() <= DISPATCH_LEVEL) (2)
{
ExFreePool(compl_ctx); (3)
}
}
return ret;
}
这些代码和3.2.5节中写请求后回调处理中调用安全函数的代码类似。唯一需要注意的是调用FltDoCompletionProcessingWhenSafe失败的情况。从微软的文档来看,这个函数是可能失败的。我们通过它的返回值可以判断其是否失败。
相关处理见上述代码的(1)处。该函数返回值不是通常的NTSTATUS,而是TRUE和FALSE。FALSE表示失败,既未能正确执行安全函数,也未能将安全函数列入工作线程队列中。
这时候要考虑到安全函数不会再被调用,可疑模块路径自然也不会再被处理,我们只能放弃。但参数compl_ctx中保存的实际上是前回调中获取的文件全路径,其内存是在前回调中分配的。如果后回调直接放弃处理,会造成内核内存泄漏。因此,在(3)处调用的ExFreePool释放了这些内存。
但要注意的是,即便是调用ExFreePool,也是需要确认中断级的!好在微软的文档明确表面所有的后回调中断级都在<=DISPATCH_LEVEL的水平,所以这里调用ExFreePool理论上不会有任何问题。
即便如此,我依然在(2)处增加了中断级别的检查。这是因为文档仅仅描述所有模块都正常的前提下的情况。而我并不希望当内核模块出现缺陷(比如上层微过滤驱动提升了中断级却忘记了恢复)时,蓝屏却直接出现在我的模块中,导致我必须投入精力去寻找并不存在的缺陷。所以通过检查中断级、放弃可能导致蓝屏的内存释放,让系统更下层蓝屏对我而言是最好的。
从这些代码我们可以看到,涉及内核的代码是极为复杂的,并非所有的情况都可以预计,我们只能在投入成本合理的情况下去争取最好的结果。
3.3.3 文件改名后回调安全函数的处理
文件改名的后回调安全函数主要完成可疑库中的路径更新。实际的后回调的安全函数SetInformationSafePostCallback的实现如代码3-15所示。
代码3-15 后回调的安全函数SetInformationSafePostCallback的实现
FLT_POSTOP_CALLBACK_STATUS
SetInformationSafePostCallback(
_Inout_ PFLT_CALLBACK_DATA data,
_In_ PCFLT_RELATED_OBJECTS flt_obj,
_In_opt_ PVOID compl_ctx,
_In_ FLT_POST_OPERATION_FLAGS flags)
{
NTSTATUS status = STATUS_SUCCESS;
FLT_POSTOP_CALLBACK_STATUS ret = FLT_POSTOP_FINISHED_PROCESSING;
DUBIOUS_PATH *src_path = NULL;
DUBIOUS_PATH *dst_path = NULL;
FILE_INFORMATION_CLASS file_infor_class =
data->Iopb->Parameters.SetFileInformation.FileInformationClass;
do {
BreakIf(!NT_SUCCESS(data->IoStatus.Status) || context == NULL);
// 处理文件的重命名和移动
if (file_infor_class == FileRenameInformation)
{
src_path = (DUBIOUS_PATH*)compl_ctx;
ASSERT(src_path != NULL);
BreakIf(src_path == NULL); (1)
// 我重新获得这个文件全路径。现在应该已经是重命名之后的新名字了
dst_path = DubiousGetFilePathAndUpcase(data); (2)
BreakIf(dst_path == NULL); (3)
// 记录可疑模块的变更。
DubiousMove(src_path, dst_path); (4)
}
} while (0);
// 释放掉src_path。这个已经不再使用了。但是dst_path会由DubiousMove
// 负责处理
DoIf(src_path != NULL, ExFreePool(src_path)); (5)
return ret;
}
上述代码的(1)处,即便明确知道src_path是通过上下文指针传递过来的文件原始的全路径,我依然在这里检查src_path是否为空,以避免上下文指针未能正确赋值的异常情况导致蓝屏。
接下来在(2)处用函数DubiousGetFilePathAndUpcase获得文件现在的路径,也就是改名之后的路径。同时(3)处的代码处理了获取路径失败的情况,一旦失败只能放弃处理。
同时在(3)处我们可以发现,这处放弃其实上是一处潜在的极为难以弥补的漏洞。DubiousGetFilePathAndUpcase获取文件路径是需要分配非分页内存的。如果黑客有某种手段,用某个能从用户态利用的手段将Windows内核的非分页内存消耗光(考虑到数不清的第三方驱动都在分配内核非分页内存,这是有可能发生的),导致DubiousGetFilePathAndUpcase无法分配到内存,那么它将文件改名的时候就会在(3)这里被跳过,从而从可疑库中消失。
在这里设法让请求失败也是很难的。因为请求已经完成。一个可能的弥补办法是把文件再重命名回去,但已经无非分页内存可用时大概率失败。
好在同样,这样的漏洞想要利用也是非常难的。即便假定从用户态耗光内核态非分页内存可行,如何精确地让DubiousGetFilePathAndUpcase失败而其他的内核操作(比如改名请求)成功?这并非不可能,但这不值得投入太多成本去研究。在第4章我们会看到,还有大把更多成本更低的漏洞可以利用,目前这类潜在漏洞根本不是关键的安全瓶颈。
如果一切顺利,在(4)处的DubiousMove函数完成了可疑库中该模块路径的更新,从src_path移动到了dst_path。这样本函数的主要处理就完成了。接下来是资源释放的部分。注意dst_path将被保存在可疑库中,所以这里不用释放。而src_path后面不会在使用,在处调用的ExFreePool释放了它。
3.3.4 关于文件的删除
除了文件改名之外,捕获文件的删除对这个体系也是必不可少的。因为任何新生成的PE文件的路径都应被加入到可疑库中,那么相应的,任何PE文件被删除,都应该同时删除可疑库中相应的路径。倘若不这么做,可疑库就是只增不减的。那么一个恶意的攻击者可能仅仅通过不断创建新的PE文件并删除,就能最终把可疑库的内存消耗光,导致系统崩溃。
和文件写入、改名类似,文件的删除也是通过在文件过滤驱动中注册操作回调函数来处理的。关于如何捕获文件的删除,可以参考微软在GitHub上的Windows-driver-samples目录下的例子delete(本书创作时,其路径在microsoft/windows-driver-samples/tree/main/filesys/miniFilter/delete,仅供参考)。
但你会发现delete这个例子极为复杂,其复杂程度远远超过了本书前面关于写、改名等操作的处理。其原因是考虑到文件系统中的事务的概念。文件系统的事务操作会给我们的安全系统带来一个漏洞,存在绕过拦截的可能。改进这些代码完全兼容事务需要超大的篇幅和工作量。我将在第6章中介绍漏洞的利用和修补的过程中,并介绍考虑事务的、对文件操作的正确处理方式。
3.4 小结
本章的代码解决了捕获Windows系统中的可执行文件的写入和改名操作,以同步修改可疑库的问题。但可疑库是如何构建的、以及Windows中的可执行文件作为模块执行或者被进程加载执行时,如何进行拦截和判断,在本章中尚未涉及。这些问题将在第4章中解决。
[1] 不用设置请求也可以完成删除,第4章中将有介绍。
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课