专栏名称: 看雪学苑
致力于移动与安全研究的开发者社区,看雪学院(kanxue.com)官方微信公众帐号。
目录
相关文章推荐
安天集团  ·  2024年活跃挖矿木马盘点 ·  昨天  
嘶吼专业版  ·  【急聘】蚂蚁安全天穹光年实验室,招人啦~~ ·  昨天  
西藏举报  ·  提醒!看见这种微信红包,别点开!速删除 ·  2 天前  
老铁股道  ·  暴涨!做好准备! ·  2 天前  
老铁股道  ·  暴涨!做好准备! ·  2 天前  
环球网  ·  具有重要战略意义!中国在太空成功验证 ·  4 天前  
环球网  ·  具有重要战略意义!中国在太空成功验证 ·  4 天前  
51好读  ›  专栏  ›  看雪学苑

Windows主机入侵检测与防御内核技术深入解析

看雪学苑  · 公众号  · 互联网安全  · 2024-09-03 17:59

正文

第5章 方案漏洞分析与利用


5.3 实现漏洞分析的具体过程


5.3.2 实现漏洞分析的单位和起点

理论上,项目中所有的代码都应该进行代码漏洞分析并逐一标注。但实际上只有实际会被执行的代码才需要进行分析。有些项目中的有些开发人员会引入巨大的开源库或者自己积累的库,而实际库中只有少量函数被调用,那么就没有必要浪费时间去给永远不会跑到的代码做漏洞分析。
在这里你会注意到进行漏洞分析非常合适的单位是函数。如果是 C 语言这类结构化编程语言的项目,那么逐个函数分析是很好的选择。如果是 C++ 或者 Java 这类面向对象的编程语言,可以以类或者是类的每个成员函数为单位进行分析。
接下来需要确认的是分析的顺序。原则依然是“不会被执行的代码就不用分析”,因此分析的起点应该是毫无疑问会被执行的函数。
作为一个 Windows 内核驱动,自己并不会主动执行任何代码,所有的代码都是由 Windows 内核调用而执行的。如下的函数会直接被外界调用:
1 )入口函数 DriverEntry
2 )向系统注册的各类回调函数(如进程线程回调、对象回调、文件过滤驱动的各类回调、网络过滤驱动的各类回调等)。
3 )创建的线程的执行体函数。
4 )设备对象的分发函数( Dispatch 函数)。
5 )注册的 DPC APC 等各类处理函数、系统工作线程任务( WorkItem )等。
6 )可能被外界调用的导出函数。
应该首先将这些可能的“入口”函数列出,然后对它们进行依次的分析。在分析过程中,应该局限于该函数内部的代码,而不包括这个函数所调用的其他函数的代码。对于被调用的函数可暂时作为黑盒,根据黑盒可能存在的问题而标注潜在风险,并标注该风险和黑盒内部风险之间的关联。
将当前分析的函数完全分析完毕之后,再顺次展开该函数内部调用的函数,对被调用的函数进行逐个分析,如此递归直到所有的入口函数以及入口函数中直接或者间接调用的所有函数分析完毕,任务即告完成。
当然这是说事后分析的方式。实际上更好的顺序是在开发的过程中,写代码的同时就标注好潜在风险的。
另外,以函数或者类为单位进行分析的时候,要特别注意一些“隐式”的代码。比如全局变量的默认赋值。 C 语言中的全局变量初始化的代码并不写在任何函数中,然而这个赋值过程确实会执行。如果是使用的面向对象的语言,还会执行构造函数之类更复杂的代码。如果在分析中忽视,就可能导致潜在漏洞被漏过了。

5.3.3 代码风险标注

我这里在代码注释中用 [] 把风险标注和正常的注释隔离开,一个具体的例子如下代码 5-1 所示。
代码5-1 一个简单的风险标注示例
     …
     do {
         KIRQL irql = KeGetCurrentIrql();
         // 后面调用DubiousGetFilePathAndUpcase等,中断级不能太高。
         // [风险: 绕过                     (1)
         //  中断级高于APC_LEVEL时,可以绕过。]
         // [评估:*                        (2)
         //   这种情况不符合微软文档,无需处理。]
         BreakIf(irql > APC_LEVEL);                (3)
     …
     } while(0);
     …

代码 5-1 中的风险标注实际上分成了两个部分。首先是“风险”(见 (1) 处),然后是“评估”(见 (2) 处)。“风险”是对这个潜在风险的问题的说明。而“评估”则是开发者初步做出的评估结论。结论当然必须是“无需处理”或“暂不处理”或“等待反馈”等。如果评估结论是必须处理,那么就应该已经处理掉了。评估之后冒号后的“ * ”代表这处风险严重程度的评级。如果评估为“ * ”即为 1 星,表示风险暂时可以忽略不计。如果评价为“ ***** ”即五星最高,表示应该尽快处理这个风险。
(1) 处的“风险”的冒号之后标注的“绕过”为风险的名字。在分析中我将风险分为多种,比如“绕过”,是指能绕过安全机制的风险。“崩溃”是指能导致系统崩溃的风险。“外泄”是指能导致机密信息泄漏的风险。“误判”是将正常行为误判为恶意行为的风险。你也可以根据你自身项目的需要而分出多种需要关注的风险并未它们命名。
接下来是一行语言描述“中断级高于 APC_LEVEL , 可以绕过”。这说明了风险存在的原因。真正的问题显然来源于 (3) 处的代码。这里判断了当前中断级(用函数 KeGetCurrentIrql() 获得),如果高于 APC_LEVEL ,就会用 break 语句跳出 do 循环块。实际上也使得这次执行流程得不到后面的安全策略的处理而直接执行了。
那么如果攻击者有办法让当前中断级提升到 APC_LEVEL 以上,即可绕过这个系统的防护。但攻击者有这样的办法吗?从微软的文档上看,这种情况是不存在的。上述代码出自本例的微过滤驱动一个操作前回调中,而操作前回调的中断级,微软文档说明如图 5-1 所示。
5-1 关于操作前回调的中断级的微软文档说明

从微软文档来看,微过滤驱动中的操作前回调的中断级要么是 PASSIVE_LEVEL ,要么是 APC_LEVEL ,这二者都不高于 APC_LEVEL ,因此代码中的跳转条件是不存在的。
从上面的简单的例子可以看出,从用户发起操作,到安全组件截获操作,对操作做出处理是一个长链。在这个链条上任何一个节点如果判断条件存在“跳出”并返回正常执行的过程,那就存在一个潜在的风险点。
彻底解决这些风险点是不可能的。一律阻止这些情况会给系统带来未知的问题。用更多代码去解决它们又不一定有必要,因为很多情况其实并不会真实发生。
如果完全无视它们的存在,那么风险就被彻底隐藏。黑客只要能挖掘到任何一个能成构造条件成为现实的漏洞,就足以攻破整个系统。而我们甚至无法评估在数十万行代码所组成的系统中,究竟有多少这样的潜在漏洞。
因此我们有必要在代码中做出标注,并用工具生成文档来评估现有的所有的潜在的漏洞,来评估整个系统的安全性。如果安全性不足,我们还需要将部分高危潜在漏洞按紧迫度排名来逐个修复。
在做标注时,开发者不得不去调查微软文档,否则无法结束这个风险的处理流程。当文档调查完毕,做出评估:“这种情况不符合微软文档,无需处理”这是合理的。
当然,有人会提问:“既然这种情况是不符合微软文档的,那也就根本不是一个漏洞,或者即便漏洞存在也是一个外部漏洞,为何一定要注明呢?直接不管它不就可以了吗?
是的,理论上这段代码不做任何标注也不会有问题。但这是建立在编码正确的情况下。假定上述 (3) 处的代码并非是“ BreakIf(irql > APC_LEVEL) ”,而是“ BreakIf(irql >= APC_LEVEL) ”,那么漏洞就可能变成了现实。
而面对这个“ BreakIf ”,开发者完全可以自信满满地不留下任何标注。因为在他的意识中,认为所有操作前回调中断级都是 PASSIVE_LEVEL 的(事实上,绝大多数测试都符合 PASSIVE_LEVEL ),因此他也不会去查阅微软的文档。
更危险的是,即便代码曾经是正确的,也可能后续被修改导致错误。假定原版的代码是“ BreakIf(irql > APC_LEVEL) ”,有人为了后面编程的方便(这是很有可能的,因为有些函数必须 PASSIVE_LEVEL 才能调用),改成了“ BreakIf(irql >= APC_LEVEL) ”,就变成了和标注不符的代码,在代码提交评审的时候就会被发现。若没有标注,没有人会意识到这个问题。
如果没有风险标注的要求,这些简单的跳转代码会淹没在无数和它们看起来一样的代码中,无论它们是否被修改,其中潜藏的风险永远也不会有人去调查,直到它们被恶意攻击者或者渗透测试人员利用。
它们被利用之后自然会被修复,但没有人会知道无数缺乏风险标注的代码中知道究竟还有多少这样的漏洞没有被修复。
因此上风险标注的作用有三个:
1 )提示出代码实现中潜在的风险。
2 )迫使开发者去查证这些风险实际发生的可能性。
3 )一旦代码发生更改,这些标注也会提示更改带来的新的威胁。
本节只写出了一个风险标注。 5.3.4 节将展示对一个完整的函数进行风险标注的例子。

5.3.4 函数风险标注

在本书的例子注册了多个微过滤驱动注册的操作回调。根据 5.3.2 节的说明,这些都是入口函数。其中 AcquireSectionIrpProcess 将负责监控任何可执行模块(实际上是 PE 文件)的加载,因此尤其重要。一旦攻击者能够做到让 Windows 用户态加载一个 PE 文件而绕过该函数的监控,则能顺利地完成攻击。
该函数的代码可见第 4 章中的代码 4-13 。第 4 章展示的该份代码是没有进行风险标注的。进行了风险标注的 AcquireSectionIrpProcess 函数如代码 5-2 所示。
代码5-2 进行了风险标注的AcquireSectionIrpProcess函数


FLT_PREOP_CALLBACK_STATUS
     AcquireSectionIrpProcess(
         PFLT_CALLBACK_DATA data,
         PCFLT_RELATED_OBJECTS flt_obj,
         PVOID* compl_context)
{
     // 监控所有PE文件的加载。且可以在这里拒绝加载。
     FLT_PREOP_CALLBACK_STATUS flt_status =
         FLT_PREOP_SUCCESS_NO_CALLBACK;
     DUBIOUS_PATH* path = NULL;
     KIRQL irql = KeGetCurrentIrql();
     do {
         // 后面调用DubiousGetFilePathAndUpcase等,中断级不能太高。
         // [风险: 绕过
         //  中断级高于APC_LEVEL,可以绕过。]
         // [评估: *
         //   微软文档限制irql最高为APC_LEVEL,此处无风险。]
         BreakIf(irql > APC_LEVEL);
 
         // 只对可执行文件的映射感兴趣
         // [风险: 绕过
         //  是否存在PE文件加载时,AcquireSection请求出现
         //  data->Iopb->Parameters.
         //       AcquireForSectionSynchronization.SyncType
         //  不是SyncTypeCreateSection的情况?如果有,能绕过防护。]
         // [评估: *
         //    暂时未知有这种情况,暂不处理。]
         BreakIf(data->Iopb->Parameters.
              AcquireForSectionSynchronization.SyncType 
              != SyncTypeCreateSection);
 
         // [风险: 绕过                     (1)
         //  是否存在PE文件加载时,AcquireSection请求出现
         //  data->Iopb->Parameters.
         //  AcquireForSectionSynchronization.PageProtection
         //   不是PAGE_EXECUTE的情况?
         //  如果有,能绕过防护。]
         // [评估: **
         //   不知是否存在先以非PAGE_EXECUTE的方式生成,然后用其他请
         // 求设置为PAGE_EXECUTE的情况。暂时没有调查,暂不处理。]
         BreakIf(data->Iopb->Parameters.
              AcquireForSectionSynchronization.PageProtection 
              != PAGE_EXECUTE);
 
         // 获取路径
         // [风险: 绕过
         //   DubiousGetFilePathAndUpcase()若返回失败,可绕过防护。
         //   => DubiousGetFilePathAndUpcase()风险:失败。   (2)
         //  ]
         // [评估:
         //   可考虑DubiousGetFilePathAndUpcase返回失败则让
         //   AcquireSectionIrpProcess请求失败,彻底消弭此风险。但
         //   需要广泛测试确认这样没问题。]
         path = DubiousGetFilePathAndUpcase(data);
         // 这里跳出,实际上导致漏洞产生
         BreakIf(path == NULL);
         // 如果路径本身无可疑,直接放过即可
 
         // [风险: 绕过
         //   IsDubious()若返回假假,则可以绕过。        (3)
         //   => IsDubious()风险:假假。
         //  ]
         // [评估:
         //   暂不处理]
         BreakIf(!IsDubious(path));
 
         // 可疑模块加载。
         LOG(("KRPS: AcquireSectionIrpProcess: dubious path = %wZ is loading\r\n", 
              path));
         // 在实际应用中,这里应该根据path获得文件,然后读取文件计算文件
         // 哈希值(比如md5。如果确认md5为白,则从可以列表中删除该路径。
         // 如果确认为黑,则禁止加载。黑白文件md5可以从服务端下载。对
         // 非黑非白的可以提交到服务器要求判断。这个过程可以用
         // FltSendMessage发送到用户态,在用户态用一个服务程序来执行。
         // 本示例省去了用户态代码,所以这里注释
         // if (FltSendMessage(...) == STATUS_SUCCESS && reply == ALLOWED)
         // { 
         //       DubiousRemove(path))
         //       break;
         // }
         // 如果没有通过验证则阻止
         // 注意,阻止的方式有两种。STATUS_ACCESS_DENIED会弹出提示框并
         // 导致进程退出。而设置STATUS_INSUFFICIENT_RESOURCES阻止非必
         // 要的DLL,进程不会退出,也没有任何提示。这种情况对用户的干扰较
         // 小。但必要的DLL不能加载进程还是会启动失败
         // data->IoStatus.Status = STATUS_INSUFFICIENT_RESOURCES;
         data->IoStatus.Status = STATUS_ACCESS_DENIED;
         LOG(("KRPS: AcquireSectionIrpProcess: Denied.\r\n", path));
         flt_status = FLT_PREOP_COMPLETE;
     } while (0);
     DoIf(path != NULL, ExFreePool(path));
     return flt_status;
}

可以看出,在上面的代码任何中途跳出的情况都进行了标注,所有风险都被标注为“绕过”。实际上除了“绕过”之外,“崩溃”、“误判”等也是安全系统中常见的风险。但最重要、最需要标注的是“绕过”。
崩溃的风险一般是发现即修复的。如果未能发现,自然也无法标注。而“误判”表示的是将正常行为错误判定为恶意攻击。这种情况大概率会在测试和后续用户使用的反馈中被发现并被解决,并不会带来安全风险。
而“绕过”的风险几乎弥散在代码的每一行跳转之中。去严格地分析和评估每一处绕过的风险往往是不划算甚至不现实的。但不了解这些风险点则是致命的。因此它们被重点标注出来,并注明当前评估的结果。
很多风险评估需要相关的知识,更深入的调查,会消耗大量的人力。但是很多市面上已有的开源代码或者是闭源的系统已经这么做了。它们可能已经做了相关评估,合理利用别人的成果也许是可行的。比如上面代码中的 (1) 处。
这里依赖请求参数 AcquireForSectionSynchronization.PageProtection PAGE_EXECUTE 才进行后续的处理,否则跳过。跳过的情况下请求会被允许。那么如果存在一个 PE 文件加载的时候,对应的该请求中的该参数不是 PAGE_EXECUTE ,自然可以绕过安全组件的监控。
PAGE_EXECUTE 直接阻止是不可行的。这将导致大量非 PE 文件映射被阻止, Windows 系统将无法执行。如果纳入可疑监控也非常不划算。因为这类情况绝大部分是映射非可执行的文件。那么能否先进行非 PAGE_EXECUTE 的映射,然后通过其他请求来修改页面属性导致产生一个可执行的文件映射,实现绕过安全检查呢?
这有可能是可行的,但也有可能是不可行的。微软没有任何文档说明这样究竟可行还是不可行,确证这件事需要搜索更多的资料、编码测试,有时甚至需要进行内核逆向来寻找答案。这是非常消耗人力的。问题是,此类点非常多。在一个基于内核的安全系统中,可能有成百上千个。全面调查这些点很可能是不必要和得不偿失的。
但是网络上已有别人的参考代码已经这样写了,说明这样也许是可行的。所以评估标注风险为“ ** ”二星级。二星级的好处是,风险依然很低,在项目日程紧张时暂时不用去调查。但是当开发团队有足够的空余时间去分析评估所有的潜在风险的时候,这些二星级的风险应该优先于一星级的风险进行调查处理。

5.3.5 风险点的关联展开

对单个函数做漏洞分析容易碰到的一个问题是:遇到该函数调用其他函数的情况应该怎么办?
首先要注意的是,对单个函数进行漏洞分析时,应对被该函数调用的其他函数视为黑盒。
为了说明这一点,我们可以先假定分析时将被调用函数视为白盒。这种情况对任何一个函数的分析,这等同于将该函数以及该函数所直接调用的、间接调用的所有函数的代码原地展开,变成一个有可能庞大无比的函数进行统一分析。这会导致一个函数的漏洞分析结果过于复杂。
更麻烦的是,被该函数调用的部分函数也可能是被其他入口函数所调用的。那么分析其他函数的漏洞的时候,是否也要再次分析被调用的同一个函数?还是直接借用其结论?如果选择前者等于造成了极大的重复工作量。如果选择后者,则意味着必须把前面进行的大一统的分析结果再次分解。
因此,将被调用的函数视为白盒不是正确的方法。正确的方法是在分析单个函数的时候,将次函数调用的其他函数视为黑盒进行分析。在一般情况下,黑盒的返回如果总是返回预期,则不会有任何问题。但我们可以假定黑盒返回的结果不返回预期,并将潜在风险和这种情况关联起来。
为了在风险标注中表示这种关联,我用了一个“ => ”符号。我之所以用这个符号单纯是因为在 C 语言中正常情况下不会出现这个符号(若我使用 -> 则和指结构成员符混淆),这更便于文本处理工具识别。我从代码 5-2 中复制出来的两处风险关联的标注如代码 5-3 所示。
代码5-3 风险标注中的关联展开
         // 获取路径
         // [风险: 绕过
         //   DubiousGetFilePathAndUpcase()若返回失败,可绕过防护。
         //   => DubiousGetFilePathAndUpcase()风险:失败。 (1)
         //  ]
         // [评估:
         //   可考虑DubiousGetFilePathAndUpcase返回失败则让
         //   AcquireSectionIrpProcess请求失败,彻底消弭此风险。但
         //   需要广泛测试确认这样没问题。]
         path = DubiousGetFilePathAndUpcase(data);      (2)
         // 这里跳出,实际上导致漏洞产生
         BreakIf(path == NULL);                (3)
         // 如果路径本身无可疑,直接放过即可
 
         // [风险: 绕过
         //   IsDubious()若返回假假,则可以绕过。 
         //   => IsDubious()风险:假假。
         //  ]
         // [评估:
         //   暂不处理]
         BreakIf(!IsDubious(path));

这里只详细说明一下 (1) 处的关联。这里的代码存在绕过风险其实仅仅因为 (3) 处存在 break 语句。如果 path NULL ,则会 break 跳出 do 循环块,等于绕过了后续的检查直接返回允许。而 path NULL 的原因只有一种可能那就是前面调用的函数 DubiousGetFilePathAndUpcase 返回了 NULL
此时我并没有急于对 DubiousGetFilePathAndUpcase 进行展开分析,而只是将它视为黑盒。如果它返回 NULL ,将导致安全系统被绕过。因此此处的风险和函数 DubiousGetFilePathAndUpcase 失败(该函数如果失败就会返回 NULL )的风险关联。
这里关联的意思是:此处的分析即是 DubiousGetFilePathAndUpcase 失败的风险。若后者存在则前者存在。若后者不存在则前者也不存在。因此后续展开分析的时候,只需要展开分析 DubiousGetFilePathAndUpcase 失败的风险即可。
本质上,这里还有一种关联的风险。若 DubiousGetFilePathAndUpcase 返回成功但其中的路径是错误的,也同样会造成绕过的风险。但考虑这种情况属于纯粹的缺陷,发现即应修复,所以未在这里作为潜在风险标注。而返回 NULL 的失败则是各种原因无法阻止的(如内存不足)。
在将被调用的函数视为黑盒,完成本函数的漏洞分析之后,接下来可以分析这些已经被视为黑盒的函数。但在分析过程中,这些被调用的仅仅需要分析有关联的风险即可。比如若我们现在紧接着继续分析函数 DubiousGetFilePathAndUpcase ,则只需要分析该函数失败返回 NULL 的潜在风险即可。
函数 DubiousGetFilePathAndUpcase 的风险标注如代码 5-4 所示。
代码5-4 函数DubiousGetFilePathAndUpcase的风险标注
DUBIOUS_PATH* DubiousGetFilePathAndUpcase(PFLT_CALLBACK_DATA data)
{
     DUBIOUS_PATH* ret = NULL;
     NTSTATUS status = STATUS_SUCCESS;
     ULONG length = 0;
     PFLT_FILE_NAME_INFORMATION name_info = NULL;
     do {
         // 后面调用FltGetFileNameInformation,中断级不能太高。
         // [风险: 失败
         //  中断级高于APC_LEVEL,可以绕过。]
         // [评估: *
         //   不应在不正确中断级调用此函数。因此这里可以改为BugCheck。]
         BreakIf(KeGetCurrentIrql() > APC_LEVEL);
         // 获取文件的全路径。这个操作有中断级的要求。
         status = FltGetFileNameInformation(
              data,
              FLT_FILE_NAME_NORMALIZED,
              &name_info);
         // 检查返回参数是否合理。路径如果太长就直接返回失败了。注意这如果不加处理,
         // 其实也是一个漏洞。
 
         // [风险: 失败
         //  FltGetFileNameInformation失败时可以绕过。]
         // [评估: *
         //   根据文档没有失败理由,此处基本无风险。]
         // [风险: 失败
         //  路径长于DUBIOUS_MAX_PATH时可以绕过。]
         // [评估: ***                            (1)
         //   确实可以构造超长路径绕过。可以考虑改为长路径禁止,但必须修改函数返回。]
         BreakIf(status != STATUS_SUCCESS ||
              name_info->Name.Length == 0 ||
              name_info->Name.Length >= DUBIOUS_MAX_PATH);
         // 分配足够的长度。
         length = sizeof(DUBIOUS_PATH) + name_info->Name.Length;
         ret = (DUBIOUS_PATH*)ExAllocatePoolWithTag(
              NonPagedPool, length, MEM_TAG);
         // [风险: 失败
         //  内存枯竭时可以绕过。]
         // [评估: **
         //   确实可以耗尽内存绕过。可以考虑改为内存耗尽时一律返回禁止。]
         BreakIf(ret == NULL);
         // 复制字符串,顺便完成大写化。
         memset(ret, 0, length);
         ret->path.Buffer = (PWCHAR)(ret + 1);
         ret->path.MaximumLength = name_info->Name.Length;
         ret->path.Length = 0;
         RtlUpcaseUnicodeString(&ret->path, &name_info->Name, FALSE);
         DUBIOUS_PATH_ASSERT(ret);
     } while (0);
     if (name_info != NULL)
     {
         FltReleaseFileNameInformation(name_info);
     }
     return ret;
}

注意上述标注中的 (1) 处是一个三星级的潜在风险,意味着它很可能是一个实际可以利用的漏洞。这是因为黑客确实可以在用户态构造超过 DUBIOUS_MAX_PATH






请到「今天看啥」查看全文