首页
社区
课程
招聘
[原创]Windows主机入侵检测与防御内核技术深入解析(4)
2024-4-26 08:35 2085

[原创]Windows主机入侵检测与防御内核技术深入解析(4)

2024-4-26 08:35
2085

本系列文章为看雪星星人为看雪安全爱好者创作的原创免费作品。

欢迎评论、交流、转载,转载请保留看雪星星人署名并勿用于商业盈利。

本人水平有限,错漏在所难免,欢迎批评指正。

3 微过滤驱动与模块执行防御(中)

3.2 写文件操作处理的实现

        本节将介绍写操作的完整的处理过程。请随时关注2.3节中设计。基于这些设计我们写操作过滤的主要目的为:

        1)如果被写入的是PE文件而且被写入之后还会是PE文件,那么这个文件的路径要加入可疑库中。

        2)如果被写入的文件本身不是PE文件,但被写入之后变成了PE文件,那么它的路径也要被加入可疑库中。

        其中(1)涉及到在写操作中对文件进行读取。3.1.4节已经解决了权限问题,因此本节的代码默认并不存在权限问题。(2)则涉及到写入缓冲的读取。这些细节将在本节的代码中一一呈现。你会注意到涉及内核的代码的超乎寻常的复杂性。

3.2.1 前置条件检查和获取文件对象

        3.1.4节中已经介绍在微过滤驱动中操作文件,一般直接操作拦截到的被操作的文件对象,而避免自己再去打开文件。很显然,为了实现设计目的,在写操作的前回调函数中,我们必须首先拿到这次写操作所操作的文件对象。

        WriteIrpProcess的文件对象获取和前置条件检查实现如代码3-5所示。注意这不是WriteIrpProcess的完整实现,只是其中开头的一部分。其中获取文件对象的代码在(3)处。

 

 

        代码3-5 WriteIrpProcess的文件对象获取和前置条件检查实现

FLT_PREOP_CALLBACK_STATUS

     WriteIrpProcess(

         PFLT_CALLBACK_DATA data,                     (1)

         PCFLT_RELATED_OBJECTS flt_obj,               (2)

         PVOID* compl_context)

{

     // 默认情况下我不做后回调。也就是说我不做任何处理。但是碰到PE文件被修改、

     // PE文件被修改成PE文件的时候,我后面会做处理

     FLT_PREOP_CALLBACK_STATUS flt_status =

         FLT_PREOP_SUCCESS_NO_CALLBACK;

     // 判断是否需要在结束回调中检查

     BOOLEAN need_more = FALSE;

     // 注意,flt_obj->FileObjectdata->Iopb->TargetFileObject

     // 一开始是相同的。区别是minifilter中可以修改掉data->Iopb->TargetFileObject

     // 来实现一些操作,而flt_obj->FileObject是不用自己修改的。等到下发到下

     // 一层minifilter,下层收到的flt_obj->FileObject会被系统自动设置成和

     // 上层修改过的data->Iopb->TargetFileObject一样。在我们不修改的情况下,

     // 这两个参数始终可以认为是等同的

     PFILE_OBJECT file = data->Iopb->TargetFileObject;      (3)

 

     do {              

         // 检查是否必须跳过的情况

         BreakDoIf(file == NULL || IsFileMustSkip(file));   (4)

 

         // 找不到缓冲区,这个调用直接返回错误

         buffer_va = (PUCHAR)IrpBufDecode(data, &length);  (5)

         BreakDoIf(buffer_va == NULL, status = STATUS_NO_MEMORY);

 

         // 如果只是中断级别过高,那么就在完成函数中再处理。这种情况下因为没有判断过请求

         // 完成之后是否变PE,所以compl_context设置1,标志着完成请求中必须判断

         BreakDoIf(KeGetCurrentIrql() > PASSIVE_LEVEL,      (6)

              (need_more = TRUE, *compl_context = (PVOID)1));

 

         is_pe = IsPeFileByRead(file, flt_obj->Instance, file_header); (7)

         …

     } while(0);

     …

}

 

        (3)处展示的代码牵涉到Windows传入前回调函数的两个参数,即代码3-4中(1)data和(2)处的flt_obj。在这里,flt_obj->FileObjectdata->Iopb->TargetFileObject都是被操作的文件对象,两个指针的值也是完全一样的。

        不同之处在于,我们需要考虑到在内核中,微过滤驱动并不只有一层。从最上层的用户态文件操作一直到最下层的文件系统,中间可能插入有多层微过滤驱动。

        如果要在本层使用一个“专有”的文件对象,而让更下层的微过滤驱动和文件系统使用另一个,就可以将flt_obj->FileObject保留自己使用,而给data->Iopb->TargetFileObject赋予新创建的文件对象指针。到了下一层微过滤驱动中的前回调函数中,flt_obj->FileObject将会自动变成被上层新赋值的data->Iopb->TargetFileObject。因此那时这两个指针依然是等值的。

        以上需要更深入地了解Windows中的文件系统和文件系统过滤驱动的层次结构。但即便你暂时不能理解,也可以先放过这个问题,只需要理解如何拿到要操作的文件对象即可。

        接下来的代码(4)(5)(6)展示了一个原则:在内核中总是要尽量把各种异常排除在外,因为如果处理不好就会蓝屏。显然,回避异常并让系统正常工作,远比让系统蓝屏要好。但是这其中也可能会带入安全漏洞。第5章将会进行漏洞分析。

        其中(4)处排除的是文件对象指针为空的情况。事实上我并没有找到任何文档确认文件对象指针必定不为空。虽然听起来很不合理,但一旦内核中某次未知的写操作的确不需要文件对象,在这里跳转出去不做任何处理远比蓝屏好得多。

        同样位于(4)处的IsFileMustSkip则排除了NTFS中的名字带有$符号的特殊文件。这些文件本身会因为对普通文件的操作而不断修改,比如NTFS日志文件。因此对它们进行操作过滤会带来更多的重入问题。

        考虑到用户态程序不可能自建一个名字中带有$的文件,我一般直接排除这些文件。当然这也是有风险的。设想一个某个黑客找到了一种在用户态直接创建带$符号的文件名的可执行文件的方法,便可以轻松绕过我的过滤!

        IsFileMustSkip的实现如代码3-6所示。

       

 

        代码3-6 IsFileMustSkip的实现

// 必须跳过的文件。我遇到的第一个蓝屏是,对"\\$LogFile"进行读取发生了蓝屏

static BOOLEAN IsFileMustSkip(PFILE_OBJECT file)

{

     BOOLEAN ret = TRUE;

     do {

         BreakIf(file == NULL);

         BreakIf(file->FileName.Buffer == NULL || file->FileName.Length < 4);

        BreakIf(file->FileName.Buffer[0] == L'\\' &&

               file->FileName.Buffer[1] == L'$');

         ret = FALSE;

     } while (0);

     return ret;

}

 

        代码3-6对文件名长度过小(小于2个字符)和文件名以“\$”开头的文件进行了排除(返回了FALSE,表示后续不过滤)。正常文件路径以“\”开头,所以至少应该有2个字符。file->FileName.Length表示的是字节长度,2个宽字符对应4个字节。file->FileName.Buffer是一个宽字符串指针。

        要注意文件对象中的file->FileName并不一定是文件的真实名字,它是可能被上层文件过滤驱动所修改的。但在打开或创建请求中,这个名字(即便是被修改后的)一定会被下发到文件系统中去执行真正的操作。所以它在这里对我们而言是准确的。

        代码3-5中的(5)处排除的是无法获得这次写操作的缓冲区的情况。所谓写操作的缓冲区是用来保存要写入的数据的内存区域,该区域的长度和操作要写入的长度是一样的。具体如何获取将在3.2.3节中介绍。这里要注意的是一旦获取失败,那么我们将无法判断写入的内容究竟是什么,所以也只能跳过不做任何处理。

        读者一定会注意到这里似乎也存在漏洞的可能性:如果某个黑客发现了Windows的某种特性,能在写入的时候让过滤驱动无法获得写入缓冲区(虽然这听起来也同样不可能),那么就能绕过微过滤驱动中实现的模块执行拦截。实际上这就是安全系统的复杂性,绝对严密的安全根本就是不存在的。

        代码3-5中的(6)处检查中断级。在代码3-1的(1)处,我们已经设置跳过分页写请求。这会让WriteIrpProcess的中断级基本保持在PASSIVE_LEVEL。但是没有任何文档能确保该函数调用时一定是PASSIVE_LEVEL

        考虑到后面需要对文件进行读取,而读取时所用的函数需要的中断级为PASSIVE_LEVEL,所以这里检查如果不是这个中断级就跳出。但这里跳出的时候,程序执行了“need_more = TRUE, *compl_context = (PVOID)1”。这里的need_moreTRUE表示需要后回调继续处理。将*compl_context设置为常数1是为了给后回调一个消息,告诉它需要做更多事。

        这么做的原因是前回调因中断级别过高无法确认被写入的文件为PE文件,所以无法进行任何处理。后回调往往中断级别更高,但后回调可以插入低中断的“安全函数”,所以这类情况可以留到后处理中再进行。

      你可能会问,既然后回调中可以处理,为什么不干脆一律在后回调中处理,代码更清晰简单呢?

        这又是涉及到内核编程中为追求最好的性能和对系统最小的影响的原则:回调能少调就少调。任何工作能在前回调中处理完毕,就没有必要使用后回调。后回调中插入“安全函数”实际上要使用独立的线程或者在内核工作线程中插入任务,所耗资源更多。

        代码3-5中的(7)处对文件是否为PE文件做了判断,其实现会在3.2.2节中介绍。

 

3.2.2 判断文件是否为PE文件

        判断一个文件是否为PE文件,本质是判断其是否遵守PE文件的格式规范。PE的格式规范很复杂,但本书在这里并不深入研究,只是简单地将开头两个字节为‘MZ’的文件确认为PE文件。

        这显然存在一种可能:其他类型的文件因为开头两个字节刚好为‘MZ’而被误判为PE文件。但其后果并不严重。因为误判为PE文件也只是被加入到可疑列表中,在被执行时可能被禁止执行。正常情况下非PE文件本来就不应该被加载执行。

        误判对系统的主要影响是如果误判的文件很多,大量的非PE文件被加入到可疑库中,显然会拖慢系统的性能并耗费很多内存。但考虑到这两个字节如果完全随机内容重合的概率只有三万多分之一,我接受这个风险。

        如果设计者不愿意接受这个风险,可以对文件格式做更多的检查,很容易让误判率进一步下降到可以忽略不计的地步。

 

代码3-7 判断文件是否为PE文件的函数IsPeFileByRead的实现

// 在写或者重命名之后,检查一个文件是否是PE文件。这个函数对不明情况

// 会从严处理。无法确定的文件皆认定为PE文件。这个函数还可以返回文件

// 开头的两个字节,便于后面判断处理

static BOOLEAN IsPeFileByRead(

     PFILE_OBJECT file_object,         (1)

     PFLT_INSTANCE instance,           (2)

     PUCHAR header)                    (3)

{

     BOOLEAN ret = TRUE;

     NTSTATUS read_ret = STATUS_UNSUCCESSFUL;

#define FILE_HEADER_SIZE 2

    UCHAR filter_header[FILE_HEADER_SIZE] = { 0 };

     ULONG bytes_read = 0;

     LARGE_INTEGER offset = { 0 };

     do

     {

         // 在文件头部读2个字节。如果不是MZ才认为这个文件不是PE。其他情况

         // 一律认为是PE,从严处理。可能误判,但对系统无影响

         read_ret = FltReadFile(        (4)

              instance,

              file_object,

              &offset,

              FILE_HEADER_SIZE,

              filter_header,

          FLTFL_IO_OPERATION_DO_NOT_UPDATE_BYTE_OFFSET,

              &bytes_read,

              NULL,

              NULL);

         BreakIf(!NT_SUCCESS(read_ret) || bytes_read < FILE_HEADER_SIZE);

         if (header != NULL)

         {

              header[0] = filter_header[0];   (5)

              header[1] = filter_header[1];

         }

         BreakDoIf(filter_header[0] != 'M' || filter_header[1] != 'Z',

              ret = FALSE);

     } while (false);

     return ret;

}

 

        从代码中可以看到该函数的关键参数是(1)处的文件对象指针file_object。微过滤驱动常会利用它代替用户态常见的文件句柄来读取文件。但除此之外,(2)处可见另一个参数instance,这是微过滤实例的指针。它没有别的用处,只是在使用函数FltReadFile(见(4)处)的时候必须用到。FltReadFile是在微过滤驱动中专用的读取文件内容的函数。它的好处是,请求是直接发往下层的,所以不会被微过滤驱动再度捕获造成重入。

        上述示例代码展示了其参数的用法。除了instancefile_object之外,offset是要读取的文件内容偏移,FILE_HEAD_SIZE则是这次要读取的长度。因为本函数只检查前2个字节,因此FILE_HEAD_SIZE被设定为2。第5个参数filter_header实际上是一个缓冲区指针。FltReadFile读出的内容会被保存到这里。

        6个参数是一个格外值得注意的常数,在这里它被设置成了FLTFL_IO_OPERATION_DO_NOT_UPDATE_BYTE_OFFSET,其意义是“不要更新文件对象中的偏移量”。文件对象会在自身上下文中保存一个“当前偏移”。这样当不含有偏移参数的读写请求发来的时候,它就会以“当前偏移”为开始位置进行读写。

        微过滤驱动中对文件内容进行读取的行为显然是一种“额外插入”的操作。没有人希望这种额外插入的操作对原本的操作造成不良影响。因此这次操作应当假装没有发生过任何事,不应更新文件对象中的当前偏移。

        7个参数bytes_read是一个ULONG指针。其指向的值最初是要求读取的文件内容的长度,而函数返回后其值会变成实际成功读取的长度。第8、第9参数不常用,这里直接使用NULL

        内容读取出来之后,在5处会以写入参数指针的方式返回。这是因为文件的前两个字节即便不是‘MZ’,它也有很重要的参考价值。因为假设一次文件写操作只改写了前2个字节中的其中之一,那就必须结合前2个字节的原始内容才能判断改写后是否是‘MZ’。所以IsPeFileByRead这个函数不但通过读取判断文件是否为PE,同时也在上面的3处以参数形式返回该文件前2个字节的内容。

3.2.3 获取写缓冲内容

        在经历过3.2.2节中,对文件是否是PE文件的判断之后,无论结果如何,我们都必须结合写入操作的写入内容,才能判定写入之后该文件是否还是PE文件。因此我们必须先获取写入缓冲区。Windows内核中请求的写入和读取缓冲区是一样的。从请求中获取缓冲区的实现如代码3-8所示。

 

代码3-8 从请求中获取缓冲区的实现

static PVOID IrpBufDecode(PFLT_CALLBACK_DATA Data, PULONG length)  

{

     PVOID ret = NULL;

     PMDL* mdl = NULL;

     PVOID* buf = NULL;

     PVOID ret_va = NULL;

     PULONG length_pt = NULL;

     do {

          BreakIf(FltDecodeParameters(Data, &mdl, &buf, &length_pt, NULL)

              != STATUS_SUCCESS);

         if (mdl != NULL && MmIsAddressValid(*mdl))           (1)

         {

              ret_va = MmGetSystemAddressForMdlSafe(*mdl,     (2)

                   NormalPagePriority);

         }

         else if (buf != NULL)

         {

              ret_va = *buf;                                  (3)

         }

         BreakIf(ret_va == NULL)

              ret = ret_va;

         *length = *length_pt;

     } while (0);

     return ret;

}

 

        IrpBufDecode其实是关键函数FltDecodeParameters的一个封装。FltDecodeParameters的参数非常复杂和难以理解,出现了诸如MDL的指针的指针然后再取地址(三重指针!)作为参数的情况。其他的几个参数的费解程度也不遑多让。

        一般开发者的角度理解,指针加长度足以表示一个缓冲区,为什么有要出现诸如PMDLPVOID并存且反复取指针的情况呢?这一方面是因为内核请求缓冲的复杂性(请求中的缓冲有多种不同的存在形式,诸如普通的用户态缓冲区、系统缓冲区、用MDL锁定的缓冲区),另一方面则是内核编程接口本身没有经过良好的封装。因此我们必须自己封装。

        经过封装之后,IrpBufDecode仅返回一个PVOID指针,同时通过参数length返回该缓冲区的长度,这就非常便于理解了。观察代码中的(1)(2)(3)处可以获知其原理。如果FltDecodeParameters返回得到了一个非空的MDL指针,说明缓冲区是以MDL的形式存在的,这时在2处用函数(这其实是一个宏)MmGetSystemAddressForMdlSafeMDL中得到一个可用的系统虚拟地址。反之,则在3处直接使用FltDecodeParameters返回的缓冲区地址。

3.2.4 根据写缓冲内容决定处理方式

        WriteIrpProcess的完整代码如代码3-9所示。前面的内容已经被分步骤讲述清楚。尚未介绍的部分主要是从下面1处开始的,对文件的写入缓冲做判断的代码。

       

代码3-9 WriteIrpProcess的完整代码

     FLT_PREOP_CALLBACK_STATUS

     WriteIrpProcess(

         PFLT_CALLBACK_DATA data,

         PCFLT_RELATED_OBJECTS flt_obj,

         PVOID* compl_context)

{

     // 默认情况下我不做后回调。也就是说我不做任何处理。但是碰到PE文件被修改、

     // PE文件被修改成PE文件的时候,我后面会做处理

     FLT_PREOP_CALLBACK_STATUS flt_status =

         FLT_PREOP_SUCCESS_NO_CALLBACK;

     ULONG64 offset = 0;

     // 判断是否需要在结束回调中检查

     BOOLEAN need_more = FALSE;

     // 判断是否PE文件的时候用

     BOOLEAN is_pe = FALSE;

     PFILE_OBJECT file = data->Iopb->TargetFileObject;

     ULONG length = 0;

     PUCHAR buffer_va = NULL;

     NTSTATUS status = STATUS_SUCCESS;

     UCHAR file_header[FILE_HEADER_SIZE] = { 0 };

     UCHAR file_header_2 = 0;

 

     do {              

         // 检查是否必须跳过的情况。是否可能留有漏洞

         BreakDoIf(file == NULL || IsFileMustSkip(file));

 

         // 找不到缓冲区,这个调用直接返回错误。

         buffer_va = (PUCHAR)IrpBufDecode(data, &length);

         BreakDoIf(buffer_va == NULL, status = STATUS_NO_MEMORY);

 

         // 如果只是中断级别过高,那么就在完成函数中再处理。这种情况下因为没有判断过请求

         // 完成之后是否变PE,所以compl_context设置1,标志着完成请求中必须判断

         BreakDoIf(KeGetCurrentIrql() > PASSIVE_LEVEL,

              (need_more = TRUE, *compl_context = (PVOID)1));

 

         // 只要有写入,无论如何都检查这个文件是否是PE文件。但这里要判断offset

         // 如果<=1,那么可能导致PE头的改变

         is_pe = IsPeFileByRead(file, flt_obj->Instance, file_header);

         offset = data->Iopb->Parameters.Write.ByteOffset.QuadPart;   (1)

         // 可能导致pe头改变的写入,需要检测是否打算输入'MZ'

         if (offset <= 1)   (2)

         {

              // 首先保存原始头的第2个字节。用来最后拼凑用

              file_header_2 = file_header[1];          (3)

              // 用新的缓冲区覆盖原始头,组合成新的头

              file_header[offset] = buffer_va[0];      (4)

              if (length >= 2 && offset == 0)

              {

                   file_header[1] = buffer_va[1];

              }

              // 如果新的头是一个PE头就认定PE模块

              if (file_header[0] == 'M' && file_header[1] == 'Z')  (5)

              {

                   // 它已经"可能"成为一个PE文件

                   is_pe = TRUE;

              }

              // 还有一种可能,比如原来是'AZ',这次写入'MX',那么第一个字节成功而第

              // 二个字节失败的情况,就可能变成'MZ'。所以这里做个扩大化

              else if (file_header[0] == 'M' && file_header_2 == 'Z') (6)

              {

                   is_pe = TRUE;

              }

              // 排除了上面两种情况,剩下的如果操作成功,必然不会是PE。如果操作失败,

              // 那么也无需进一步处理。所以处理终止

              else

              {

                   is_pe = FALSE;

              }

         }

         // 对不是PE,写入后也不会变成PE,或者写入成功之后就不会再是PE的情况,

         // 不用处理。

         BreakIf(!is_pe);

         // 其他的情况,需要处理。

         need_more = TRUE;  (7)

     } while (0);

 

     // 如果中途有解析失败等情况,直接让请求失败。

     if (status != STATUS_SUCCESS)

     {

         data->IoStatus.Status = status;

         flt_status = FLT_PREOP_COMPLETE;  (8)

     }

     else if (need_more)

     {

         flt_status = FLT_PREOP_SUCCESS_WITH_CALLBACK;  (9)

     }

     // 其他情况返回不需要callback继续下发。

     return flt_status;

}

 

        上述代码中首先关键的是(2)处的判断。写入偏移只有是0或者1的时候,才会影响到文件的前2个字节。因此写入偏移如果大于等于2时,写入的具体内容无需关心。此时对文件最终是否为PE文件的判断完全由IsPeFileByRead的返回结果来决定。

        当写入偏移为0或者1的时候,文件的前2个字节(也就是本章代码对PE文件的判断标准)可能被修改,并且存在只修改部分的可能。这种情况下,上述代码先是把文件的原始内容保存在file_header中,然后在(4)处开始的几行代码中,用写入缓冲区的内容结合偏移位置对file_header进行覆盖,最后在(5)处判断文件是否为PE文件。

        这样处理之后看似已经很好了。但是开发安全组件,尤其是在内核中,漏洞是防不胜防的,我们总是要殚精竭虑地更深入一层思考。

        考虑这么一种情况:文件开头2字节的原始内容是‘AZ’,而要写入的缓冲区内容是‘MX’,那么无论是写入之前还是写入之后,文件头都不是标准的PE头。但写入操作并不一定总是成功的。再来一种蹊跷的巧合:第一个字节写入成功了,而第二个字节刚好写入失败,那么文件内容就变成了‘MZ’,恰好成了一个PE文件头!

        有没有可能存在这样的黑客手段来绕过防御呢?似乎不太可能。因为这意味着攻击方需要能控制文件写入时某个字节的成功与失败。但在这种投入成本其实不大就可以弥补的地方,与其因为过于自信而大意失荆州,不如多耗一点点心思将代码补充完整。

        因此(3)处将原始内容的第2字节保存了下来,并在(6)处和要写入的第一个字节组合进行判断。如果判断结果可能为PE头,则视同PE头处理。

        对所有可能生成PE头的情况,这个函数中并未直接添加到可疑路径库。因为这是前回调。前回调发生的时候,请求还没有完成,也无从得知请求结果。所以这个函数对文件被写入之后变成PE文件的情况,仅仅是(7)处设置need_more,然后在(9)处返回FLT_PREOP_SUCCESS_WITH_CALLBACK表示需要后回调函数被调用。

        最后一处值得关注的是(8)处返回FLT_PREOP_COMPLETE。这是一种强硬的做法。即在处理过程中任何一步失败,我们都不是放弃处理,而是让请求直接失败,根本不发到下一层。

        这样的处理的安全性高,但是易用性值得怀疑。因为某些情况下请求失败,会导致用户层的软件无法正常运作,引发用户投诉。这就需要大量的调试工作去排除这些文件,将某些情况下的错误加入白名单等等。

        另一种简单的处理方式是任何失败我们都将请求放过不再过滤。这样易用性很好,用户不会投诉并会盛赞软件的稳定性。同时黑客也将欢欣鼓舞。因为他们只要能找到任何一个方法让安全处理中的任何一个步骤失败,即可成功绕过防御。

3.2.5 写操作的后处理

        3.2.4节介绍了写操作的前回调。前回调中的处理只初步判断了文件是否可能会变得可疑,尚未确定最终的结果。而最终的结果判定是在后回调中进行的。因此本节介绍写操作的后回调。

        与简单而正常的想法不同,后回调中实际上几乎无法进行任何处理。因为后回调的中断级很不确定,有可能非常高而导致大部分函数都无法调用。但幸好后回调中可以调用函数FltDoCompletionProcessingWhenSafe。该函数可以在任何中断级下调用,用来插入一个“安全函数”作为后回调的替代。安全函数被执行时中断级低于或者等于APC_LEVEL,这已经满足大部分微过滤驱动可用内核函数的需求。

        实际的写操作请求的后回调处理如代码3-10所示。

       

代码3-10 写操作请求的后回调处理

// 写入操作后回调

FLT_POSTOP_CALLBACK_STATUS WriteIrpPost(

     _Inout_ PFLT_CALLBACK_DATA data,

     _In_ PCFLT_RELATED_OBJECTS flt_obj,

     _In_opt_ PVOID compl_ctx, (1)

     _In_ FLT_POST_OPERATION_FLAGS flags)

{

     FLT_POSTOP_CALLBACK_STATUS ret = FLT_POSTOP_FINISHED_PROCESSING;

     FLT_POSTOP_CALLBACK_STATUS ret_status;

     BOOLEAN ret_bool = FALSE;

     do {

         // 不成功的操作和不是IRP的操作不用处理

         BreakIf(data->IoStatus.Status != STATUS_SUCCESS

              || !FLT_IS_IRP_OPERATION(data)); (2)

         // 一律放到安全处理函数中处理,避免中断级别的问题

         ret_bool = FltDoCompletionProcessingWhenSafe( (3)

             data, flt_obj, compl_ctx, 0,

              WriteSafePostCallback, &ret_status); (4)

         ret = ret_status;

         // 请求无法列队,也就无法完成,放弃

         BreakIf(!ret_bool);

     } while (0);

     return ret;

}

 

        后回调的重要参数dataflt_obj与前回调相同。第三个参数compl_ctx(见(1)处)即上下文指针则是将前回调中设置的指针原样传来。最后一个参数flags本例中没有使用,读者可忽略。

        (2)处做了两个判断。其中之一是检查data->IoStatus.Status,这是该请求的完成结果的状态。显而易见,如果是对可执行文件的写入,那么只有写入成功了才需要进行后续的处理。如果写入状态为失败即可跳过。

        另一个判断是用宏FLT_IS_IRP_OPERATION确定这是一个IRP操作。如果不是IRP操作,将无法调用FltDoCompletionProcessingWhenSafe。正常情况下,这会是IRP操作。

        当然,在任何一处跳出我们都必须反省是否存在漏洞的可能。如果有人找到了实际写入数据成功,但又让系统此处返回的状态为失败,或者用非IRP请求同样实现写入的方法,即可绕过此处防御。

        所以另一种可能的选择是,无论写这里是否跳出,都将文件一律加入可疑库处理。这样虽然有点过度医疗的感觉,但若能设法控制付出的代价,并进行充分的测试,亦不失为一种相对激进但可考虑的策略。本书这里用的策略相对保守,只是简单地放过了这些异常操作。

        接下来就是重点函数FltDoCompletionProcessingWhenSafe(见(3)处)。其核心是另一个回调函数指针,也就是(4)处的WriteSafePostCallback。调用FltDoCompletionProcessingWhenSafe之后,函数WriteSafePostCallback将迟早被执行,等于替代了WriteIrpPost成为后回调函数。并且它是“安全”的。这里说的安全是指函数执行时的中断级较低,可以安全地调用大部分内核函数而不会蓝屏。

        函数WriteSafePostCallback,也就是一个写操作请求的后回调安全函数的实现如代码3-11所示。

 

代码3-11 写操作请求的后回调安全函数的实现

FLT_POSTOP_CALLBACK_STATUS

     WriteSafePostCallback(

         _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;

     PFILE_OBJECT file_obj = data->Iopb->TargetFileObject;

     DUBIOUS_PATH *path = NULL;

     BOOLEAN is_pe = TRUE;

     do {

         BreakIf(file_obj == NULL || IsFileMustSkip(file_obj));

         if (compl_ctx != NULL)    (1)

         {

              // compl_ctx不为NULL,说明这是高中断级的请求,之前没有判断过是否变成PE

              // 所以这里再次判断文件是否是pe文件。

              is_pe = IsPeFileByRead(file_obj, flt_obj->Instance, NULL); (2)

         }

          // 不是PE,无需处理。

         BreakIf(!is_pe); (3)

         path = DubiousGetFilePathAndUpcase(data); (4)

         BreakIf(path == NULL);

         // 如果获取成功了,加入到可疑列表中。注意path != NULL的时候已经无需再

         // 释放。因为DubiousAppend会负责释放,无论追加处理是否成功。

         DubiousAppend(path); (5)

     } while (0);

     return ret;

}

 

        代码3-11中的不少代码行与前面介绍过的代码3-5中的实现大同小异。值得关注的点在(1)处。

        回顾一下代码3-5中的(6)处,如果中断级不合适,那就会执行*compl_context = (PVOID)1,将*compl_context设置成1(非NULL)。其他情况下*compl_context都是NULL。最终该上下文指针会传递到这里。compl_ctx != NULL时说明了这种特殊情况的存在:前回调处理中因为中断级不正确无法处理,所以现在必须在后回调中完成处理。

        后回调中的处理反而更加简单。因为请求已经完成,无需考虑文件原有内容和写入缓冲内容的结合,直接读取原文件内容即可完成任务。所以(2)处调用函数IsPeFileByRead读取文件内容进行了判断,而(3)处则根据结果选择是否跳出不再处理。

        至此,经过重重过滤,可执行文件的修改(或创建)已经被捕获。接着(4)处调用DubiousGetFilePathAndUpcase获得文件全路径并转换为大写。而(5)处则将这个已经大写化的路径加入到可疑库中。

        DubiousGetFilePathAndUpcaseDubiousAppend这两个函数的实现将在3.4节中介绍。





[培训]二进制漏洞攻防(第3期);满10人开班;模糊测试与工具使用二次开发;网络协议漏洞挖掘;Linux内核漏洞挖掘与利用;AOSP漏洞挖掘与利用;代码审计。

最后于 2024-4-26 09:13 被星星人编辑 ,原因:
收藏
点赞4
打赏
分享
最新回复 (1)
雪    币: 19539
活跃值: (29224)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
秋狝 2024-4-26 09:06
2
1
感谢分享
游客
登录 | 注册 方可回帖
返回