5.3 实现漏洞分析的具体过程
5.3.2 实现漏洞分析的单位和起点
理论上,项目中所有的代码都应该进行代码漏洞分析并逐一标注。但实际上只有实际会被执行的代码才需要进行分析。有些项目中的有些开发人员会引入巨大的开源库或者自己积累的库,而实际库中只有少量函数被调用,那么就没有必要浪费时间去给永远不会跑到的代码做漏洞分析。在这里你会注意到进行漏洞分析非常合适的单位是函数。如果是C语言这类结构化编程语言的项目,那么逐个函数分析是很好的选择。如果是C++或者Java这类面向对象的编程语言,可以以类或者是类的每个成员函数为单位进行分析。接下来需要确认的是分析的顺序。原则依然是“不会被执行的代码就不用分析”,因此分析的起点应该是毫无疑问会被执行的函数。作为一个Windows内核驱动,自己并不会主动执行任何代码,所有的代码都是由Windows内核调用而执行的。如下的函数会直接被外界调用: (2)向系统注册的各类回调函数(如进程线程回调、对象回调、文件过滤驱动的各类回调、网络过滤驱动的各类回调等)。 (4)设备对象的分发函数(Dispatch函数)。 (5)注册的DPC、APC等各类处理函数、系统工作线程任务(WorkItem)等。应该首先将这些可能的“入口”函数列出,然后对它们进行依次的分析。在分析过程中,应该局限于该函数内部的代码,而不包括这个函数所调用的其他函数的代码。对于被调用的函数可暂时作为黑盒,根据黑盒可能存在的问题而标注潜在风险,并标注该风险和黑盒内部风险之间的关联。将当前分析的函数完全分析完毕之后,再顺次展开该函数内部调用的函数,对被调用的函数进行逐个分析,如此递归直到所有的入口函数以及入口函数中直接或者间接调用的所有函数分析完毕,任务即告完成。当然这是说事后分析的方式。实际上更好的顺序是在开发的过程中,写代码的同时就标注好潜在风险的。另外,以函数或者类为单位进行分析的时候,要特别注意一些“隐式”的代码。比如全局变量的默认赋值。C语言中的全局变量初始化的代码并不写在任何函数中,然而这个赋值过程确实会执行。如果是使用的面向对象的语言,还会执行构造函数之类更复杂的代码。如果在分析中忽视,就可能导致潜在漏洞被漏过了。5.3.3 代码风险标注
我这里在代码注释中用[]把风险标注和正常的注释隔离开,一个具体的例子如下代码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所示。从微软文档来看,微过滤驱动中的操作前回调的中断级要么是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)”,就变成了和标注不符的代码,在代码提交评审的时候就会被发现。若没有标注,没有人会意识到这个问题。如果没有风险标注的要求,这些简单的跳转代码会淹没在无数和它们看起来一样的代码中,无论它们是否被修改,其中潜藏的风险永远也不会有人去调查,直到它们被恶意攻击者或者渗透测试人员利用。它们被利用之后自然会被修复,但没有人会知道无数缺乏风险标注的代码中知道究竟还有多少这样的漏洞没有被修复。 (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所示。 // 获取路径
// [风险: 绕过
// 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长度的文件路径来绕过检查。此类漏洞的处理有很多方式,其中之一是将异常长的路径一律阻止。但这里依然没有处理。我们假定是因为函数DubiousGetFilePathAndUpcase本身只返回失败与否,并不返回失败的原因是否因为路径过长。如果要处理这种情况就必须修改函数的原型,这可能增加很多工作量。因此项目经理与开发人员商议后认为有更多其他四星漏洞要修复,而这个三星风险暂不处理。该函数的风险标注中仅含有失败风险的标注。这是因为它的调用者AcquireSectionIrpProcess仅仅和这个种类的风险关联。如果其他函数也调用了这个函数并且也只和失败风险关联,就无需再多做分析了。但如果和其他的风险关联,则还要做更多的分析。但无论如何,这样就不会有任何重复工作了。必须注意到,用风险标注来做漏洞分析的前提是代码的逻辑本身清晰,绝大部分使用do-while-break来方式来进行处理。如果逻辑层次非常复杂,有多层的if甚至是goto语句进行跳转,就会给分析带来巨大的麻烦。因此,在编写代码的同时就要尝试去进行风险标注,这样还顺带可以促进代码的逻辑简化。设计漏洞分析、技术漏洞分析和实现漏洞分析,都应该以风险标注的方式统一标注在代码(或配置)注释中,并在每次编译代码以及修改配置之后用文本工具自动提取生成。对高优先级的、已经存在的漏洞应该及时弥补并关闭。中低优先级的潜在风险虽然暂时不用去做什么,但是必须了解和关注,并根据实际的对抗情况来更新优先度、排期进行处理。看雪ID:星星人
https://bbs.kanxue.com/user-home-143652.htm
*本文为看雪论坛优秀文章,由 星星人 原创,转载请注明来自看雪社区
文章来源: https://mp.weixin.qq.com/s?__biz=MjM5NTc2MDYxMw==&mid=2458571594&idx=2&sn=3138702200cf93e00da9ab0229f0223f&chksm=b18de3c086fa6ad62c93345d52ea8c7aeb83d299e220ed293cc41d31cd4f37bb08fe590a7d0b&scene=58&subscene=0#rd
如有侵权请联系:admin#unsafe.sh