本系列文章为看雪星星人为看雪安全爱好者创作的原创免费作品。
欢迎评论、交流、转载,转载请保留看雪星星人署名并勿用于商业盈利。
本人水平有限,错漏在所难免,欢迎批评指正。
本节将介绍写操作的完整的处理过程。请随时关注2.3节中设计。基于这些设计我们写操作过滤的主要目的为:
(1)如果被写入的是PE文件而且被写入之后还会是PE文件,那么这个文件的路径要加入可疑库中。
(2)如果被写入的文件本身不是PE文件,但被写入之后变成了PE文件,那么它的路径也要被加入可疑库中。
其中(1)涉及到在写操作中对文件进行读取。3.1.4节已经解决了权限问题,因此本节的代码默认并不存在权限问题。(2)则涉及到写入缓冲的读取。这些细节将在本节的代码中一一呈现。你会注意到涉及内核的代码的超乎寻常的复杂性。
3.1.4节中已经介绍在微过滤驱动中操作文件,一般直接操作拦截到的被操作的文件对象,而避免自己再去打开文件。很显然,为了实现设计目的,在写操作的前回调函数中,我们必须首先拿到这次写操作所操作的文件对象。
WriteIrpProcess的文件对象获取和前置条件检查实现如代码3-5所示。注意这不是WriteIrpProcess的完整实现,只是其中开头的一部分。其中获取文件对象的代码在(3)处。
代码3-5 WriteIrpProcess的文件对象获取和前置条件检查实现
(3)处展示的代码牵涉到Windows传入前回调函数的两个参数,即代码3-4中(1)处data和(2)处的flt_obj。在这里,flt_obj->FileObject和data->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的实现
代码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_more为TRUE表示需要后回调继续处理。将*compl_context设置为常数1是为了给后回调一个消息,告诉它需要做更多事。
这么做的原因是前回调因中断级别过高无法确认被写入的文件为PE文件,所以无法进行任何处理。后回调往往中断级别更高,但后回调可以插入低中断的“安全函数”,所以这类情况可以留到后处理中再进行。
你可能会问,既然后回调中可以处理,为什么不干脆一律在后回调中处理,代码更清晰简单呢?
这又是涉及到内核编程中为追求最好的性能和对系统最小的影响的原则:回调能少调就少调。任何工作能在前回调中处理完毕,就没有必要使用后回调。后回调中插入“安全函数”实际上要使用独立的线程或者在内核工作线程中插入任务,所耗资源更多。
代码3-5中的(7)处对文件是否为PE文件做了判断,其实现会在3.2.2节中介绍。
判断一个文件是否为PE文件,本质是判断其是否遵守PE文件的格式规范。PE的格式规范很复杂,但本书在这里并不深入研究,只是简单地将开头两个字节为‘MZ’的文件确认为PE文件。
这显然存在一种可能:其他类型的文件因为开头两个字节刚好为‘MZ’而被误判为PE文件。但其后果并不严重。因为误判为PE文件也只是被加入到可疑列表中,在被执行时可能被禁止执行。正常情况下非PE文件本来就不应该被加载执行。
误判对系统的主要影响是如果误判的文件很多,大量的非PE文件被加入到可疑库中,显然会拖慢系统的性能并耗费很多内存。但考虑到这两个字节如果完全随机内容重合的概率只有三万多分之一,我接受这个风险。
如果设计者不愿意接受这个风险,可以对文件格式做更多的检查,很容易让误判率进一步下降到可以忽略不计的地步。
代码3-7 判断文件是否为PE文件的函数IsPeFileByRead的实现
从代码中可以看到该函数的关键参数是(1)处的文件对象指针file_object。微过滤驱动常会利用它代替用户态常见的文件句柄来读取文件。但除此之外,(2)处可见另一个参数instance,这是微过滤实例的指针。它没有别的用处,只是在使用函数FltReadFile(见(4)处)的时候必须用到。FltReadFile是在微过滤驱动中专用的读取文件内容的函数。它的好处是,请求是直接发往下层的,所以不会被微过滤驱动再度捕获造成重入。
上述示例代码展示了其参数的用法。除了instance和file_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.2节中,对文件是否是PE文件的判断之后,无论结果如何,我们都必须结合写入操作的写入内容,才能判定写入之后该文件是否还是PE文件。因此我们必须先获取写入缓冲区。Windows内核中请求的写入和读取缓冲区是一样的。从请求中获取缓冲区的实现如代码3-8所示。
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课
最后于 2024-5-10 09:31
被星星人编辑
,原因: