针对进程行为的监控需求,以往很多安全软件都是采用的Hook技术拦截关键的系统调用,来实现对恶意软件进程创建的拦截。但在x64架构下,系统内核做了很多安全检测措施,特别是类似于KDP这样的技术,使得Hook方法不再有效。为此OS推出了基于回调实现的行为监控方案。本文借助IDA逆向分析该技术的实现原理并给出了关键数据结构及调用链,通过双机内核调试验证了该数据结构以及调用链的正确性。
涉及到的内容如下:
1、内核对象及内核对象管理;2、进程回调;3、内核调试;4、Windbg双击调试;
0 引言近年来,各种恶意软件新变种层出不穷,攻击方法、手段多种多样,造成了巨大的经济损失。作为防守的第一个环节就是能够识别出恶意进程创建的动作,而进程创建监控技术是为了能够让安全软件有机会拦截到此动作的技术。安全软件根据匹配算法判断是否准许该进程创建,以此达到保护用户数据安全的目的。x86架构下的实现方案多为Hook技术,通过拦截内核中进程创建的关键API如nt!NtCreateProcess或nt!NtCreateProcessEx,通过堆栈来回溯到关键参数,如待创建进程的exe全路径、父进程信息,然后根据获取到的全路径检测exe磁盘文件,同时也可以分析进程链最终确定是否放行该动作。但这种技术方案存在一些缺陷,一方面其破坏了内核的完整性,导致系统的稳定性下降;另一方面,这些API很多都是未公开的,也就意味着需要通过逆向工程等技术手段来分析OS内核镜像文件,定位到关键的API。但如果系统升级了,该API可能就不存在了,这也导致安全软件的兼容性特别差;最重要的是各个安全厂家的实现方案不一样,挂钩的点也不同,很容易出现相互竞争的情况,极有可能会导致BSoD(Blue Screen of Death)。另一种传统的基于特征码的拦截方式,也同样存在类似的问题。需要为每个子版本的系统关键API做逆向分析,取出特征码,当系统更新或者打补丁,则需要再次逆向分析取出特征码。工作量巨大,效率低下,适配性很低,如果没有及时更新特征码,很可能会使得监控失效,情况糟糕的时候会直接导致BSoD。为此,在x64架构下,内核一方面为了保护关键数据的完整性,另一方面也为了提高内核程序自身的稳定性,推出了诸如KDP(Kernel Data Protection)、PG等安全措施,使得传统的 Hook技术失效;同时OS为了规范化安全相关信息的获取,使得安全软件能够在内核可控的情况下提供安全服务,Windows系统层面提供了一种基于回调的方式来通知安全软件注册的内核回调例程。这种方式优点是方便高效,可移植性好,稳定性高,且各个安全厂商之间也不会出现竞争的关系。
本文基于逆向工程及内核调试技术,分析了该技术的具体实现及系统额外增加的数据检测机制。借助逆向工具IDA静态逆向分析了系统关键API的内部动作及具体的实现,相关的数据结构,得到该技术实际触发的调用源以及整个调用链。借助VMWare搭建双机调试环境,利用Windbg动态调试系统内核,查看系统中所涉及到的关键数据,并与PCHunter给出的数据做对比分析,验证了分析结论的正确性。此外还通过对调用链中的关键函数下断点,通过栈回溯技术,动态观察了整个调用链及触发时间。分析得到的关键数据结构和系统对数据做的检测校验算法可用于检测病毒木马等软件恶意构造的表项,且还可以应用到安全厂商对抗恶意代码时,自动构造表项来检测系统行为,完全脱离系统提供的注册卸载API。
1 进程回调原理分析1.1 安装与卸载逆向分析根据微软官方技术文档MSDN上的说明,通过PsSetCreateProcessNotifyRoutine、PsSetCreateProcessNotifyRoutineEx和PsSetCreateProcessNotifyRoutineEx2这三API来安装一个进程创建、退出通知回调例程,当有进程创建或者退出时,系统会回调参数中指定的函数。以PsSetCreateProcessNotifyRoutine为例子,基于IDA逆向分析该API的具体实现。如图1所示,由图可知,该API内部仅仅是简单的调用另一个函数,其自身仅仅是一个stub,具体的实现在PspSetCreateProcessNotifyRoutine中,此函数的安装回调例程的关键实现如图所示。
调用ExAllocateCallBack,创建出了一个回调对象,并将pNotifyRoutine和bRemovel作为参数传入,以初始化该回调对象,代码如图所示;其中pNotifyRoutine即是需要被回调的函数例程,此处的bRemovel为false,表示当前是安装回调例程。
紧接着调用ExCompareExchangeCallBack将初始化好的CallBack对象添加到PspCreateProcessNotifyRoutine所维护的全局数组中。值得注意的是,ExCompareExchangeCallBack中在安装回调例程时,对回调例程有一个特殊的操作如图所示。
与0x0F做了或操作,等价于将低4位全部置1;若ExCompareExchangeCallBack执行失败,则接着下一轮循环继续执行。由图2中第66行代码可知,循环的最大次数是0x40次。如果一直失败,可调用ExFreePoolWithTag释放掉pCallBack所占用的内存,且返回0xC000000D错误码。
然后根据v3的值判断是通过上述三个API中的哪个安装的回调,来更新相应的全局变量。其中PspCreateProcessNotifyRoutineExCount和PspCreateProcessNotifyRoutineCount分别记录当前通过PsSetCreateProcessNotifyRoutineEx和PsSetCreateProcessNotifyRoutine安装回调例程的个数。PspNotifyEnableMask用以表征当前数组中是否安装了回调例程,该值在系统遍历回调数组执行回调例程时,用以判断数组是否为空,加快程序的执行效率。
除了能够安装回调例程,这三个API也能卸载指定的回调例程。以PsSetCreateProcessNotifyRoutine为例,分析其实现的关键部分,如图所示。
通过一个while循环遍历PspCreateProcessNotifyRoutine数组,调用ExReferenceCallBackBlock取出数组中的每一项,该API内部会做一些检验动作且对返回的数据也做了特殊处理,如图所示。图6中*pCallBackObj即是取出回调对象中的回调例程的函数地址,通过判断其低4位是否为1来做一些数据的校验,如17行所示。系统做这个处理也是起到保护作用,防止恶意构造数据填入表中,劫持正常的系统调用流程。此外,图中第33行处的代码,在将回调例程返回给父调用时,也将回调例程的低4位全部清零,否则返回的地址是错误的,调用立马触发CPU异常。
ExReferenceCallBackBlock成功返回后,调用ExGetCallBackBlockRoutine从返回的回调对象中取出回调例程,并判断取出的是否为当前指定需要卸载的项,如果是则调用ExDereferenceCallBackBlock递减引用计数,接着调用ExFreePoolWithTag释放掉Callback所占用的内存。期间也会更新PspCreateProcessNotifyRoutineExCount或PspCreateProcessNotifyRoutineCount的值。根据源码还可以得知,该数组总计64项,也即只能安装64个回调例程。如果遍历完数组的64项依旧没有找到,则返回0xC000007A错误码。
1.2 OS执行回调例程分析回调例程安装完之后,如果有新的进程创建或退出,内核则便会遍历该数组来执行其中安装的每一项回调例程。通过IDA的交叉引用功能,可分析出内核其他地方对PspCreateProcessNotifyRoutine的交叉引用,如图所示,
共计5个地方涉及到此变量。其中PspCallProcessNotifyRoutines是直接调用回调例程的函数,该函数的关键部分如图所示。
通过while循环,遍历PspCreateProcessNotifyRoutine数组中安装的所有回调例程,依次执行。PspNotifyEnableMask & 2的操作即为判断当前数组中是否安装有回调例程,加快程序的执行效率,这个变量的值在PsSetCreateProcessNotifyRoutine中安装回调例程时设置。bRemove & 2这个if分支,是用来判断当前的回调例程是通过PsSetCreateProcessNotifyRoutine还是PsSetCreateProcessNotifyRoutineEx安装,因为这两个API安装的回调例程的原型不同,在实际调用时传入的参数也不同。两者的回调例程原分别为:void PcreateProcessNotifyRoutine(HANDLE ParentId,HANDLE ProcessId,BOOLEAN Create)和void PcreateProcessNotifyRoutineEx(PEPROCESS Process,HANDLE ProcessId,PPS_CREATE_NOTIFY_INFO CreateInfo)。此外,图8中IDA给出的伪C代码RoutineFun((unsigned __int64)RoutineFun)明显不对,因为回调例程的参数个数是3个,而IDA分析出的参数只有1个,显然有问题。直接看下反汇编代码即可得知,如图所示,
根据x64下的调用约定可知,函数的前4个参数是通过rcx、rdx、r8和r9这四个寄存器传递,图给出的正是回调例程的前三个参数,_guard_dispatch_icall内部会直接取rax的值调用过去,而rax的值正是ExGetCallBackBlockRoutine调用返回的回调例程函数地址。
上图中的第二个涉及到PspCreateProcessNotifyRoutine数组的是PspEnumerateCallback函数,该函数是系统内部函数,没有导出,其具体实现如图所示。
该函数根据dwEnumType来判断想要枚举的是哪个数组,由代码分析可知,系统内核维护了三个回调相关的数组,分别为镜像加载回调数组,进程创建退出回调数组,线程创建退出数组。类似之前的函数校验,这里也检测了索引是否超过0x40,超过了则返回0,以示失败。
1.3 触发调用的调用链分析上节分析了回调例程的直接调用上级函数,本节分析整个调用链,主要分析调用源及调用过程中涉及到的关键函数。根据IDA给出的交叉引用图如图所示。
涉及到的函数调用非常多,很多不相关的也被包含进来,不便于分析。经手动分析整理后的调用链,其链路中的关键API如图所示。
虚线以上部分为用户态程序,虚线以下为内核态程序,红色标注的都是标准导出的API。根据图12可知,当用户态进程调用RtlCreateUserProcess、RtlCreateUserProcesersEx或RtlExitUserProcess时,内核都会去遍历PspCreateProcessNotifyRoutine数组,依次执行回调例程,通知给驱动程序做相应的处理。驱动接管之后,可以做安全校验处理,分析进程的父进程或者进一步分析进程链,此外还可以对即将被拉起的子进程做特征码匹配,PE指纹识别,导入表检测等防御手段。这种方式不需要去Hook任何API,也无需做特征码定位等重复繁琐的工作,完全基于系统提供的回调机制,且在Windows系统中都可以无缝衔接。且各个安全厂家之间也不存在相互竞争,大大缩小了系统蓝屏的风险。图12中NtCreateUserProcess调用PspInsertThread的原因是创建进程的API内部会创建该进程的主线程。将遍历回调例程数组的工作统一到PspInsertThread中,由其去调用下层的PspCallProcessNotifyRoutines更为合理。
2 实验2.1 观察系统中已安装的回调例程实验环境如表1所示,借助于VMWare进行双机调试。
Guest OS Build 10.0.16299.125Host OS Build 10.0.17134.885Windbg版本 10.0.17134.1VMWare 14.1.1 build-7528167PCHunter V1.56
在Windbg中观察PspCreateProcessNotifyRoutine数组,共计14项有效数据,如下所示;