一次性进群,长期免费索取教程,没有付费教程。
ID:Computer-network
毫不夸张地说,Rootkit技术其实就是免杀技术,因为Rootkit的核心目的就是隐藏自己,并能长期地潜伏于目标系统中。早在2009年以前,绝大多数Rootkit经过简单的处理即可躲过大多数反病毒软件的查杀,但是随着Rootkit的逐渐肆虐,反病毒公司也逐渐将一些Rootkit技术应用到了自己的商业产品中,从而使得反病毒软件可以与Rootkit在一个更加有优势的层面上对抗(以前的反病毒产品大多运行在用户层),这使得Rootkit泛滥的情况有所收敛,并将这场查杀与免杀的“战争”推向了一个更高的层次。
但是即便如此,也不能就武断地认为Rootkit已经成为过时技术,这基于以下几点事实:
Rootkit技术仍然是网络安全领域中举足轻重的技术,在反病毒、游戏安全、防泄密等诸多领域占有一席之地。
仅就黑客免杀技术来讲,Rootkit技术仍然属于高端主流技术,其实绝大多数基于源码的行为免杀本质上都属于用户模式的Rootkit。
并非所有用户都安装了足够强大的反病毒软件,并非所有用户对反病毒软件的提示信息都能做出正确的判断,并非所有正常的应用程序或驱动程序都在反病毒软件的白名单内,这也是目前病毒木马仍然广泛流行的原因之一。
因此,Rootkit技术是研究黑客免杀技术的必备基础知识之一。
一、用户模式Rootkit
现在用户模式的Rootkit正在被赋予越来越多的定义,由于反病毒软件对内核的防护越来越严密,直接从用户层躲过反病毒软件的检查并隐藏自己,从而避免碰触内核这根红线,已经在攻击者中取得越来越多的共识。在这里,我们学习用户模式的Rootkit技术更多的是为学习内核模式的Rootkit打基础,因此我们将主要探讨经典的DLL远程注入技巧,以及内联Hook与IAT Hook的原理。
1、DLL远程注入技巧
如果想让自己的代码在目标进程中运行,就必须将其注入目标进程中,而将自己的DLL注入目标进程中是攻击者们常用的代码注入技巧。
当目标进程中包含DLL时,就可以Hook目标进程中的关键API了。
一般情况下,将DLL注入到远程进程有3种方法,下面逐一介绍。
(1)使用注册表注入DLL
在Windows 3.x及以后版本的操作系统中,通过向注册表HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersion\Windows中添加名为AppInit_DLLs的键,并将键值设置为攻击者的DLL名称,即可完成对所有进程的DLL注入。
当设置了这个键值后,系统在启动一个加载了User32.dll的新进程时,User32.dll同时会使用LoadLibrary()函数将此键值下的DLL文件也加载到用户的进程空间中,并传入一个DLL_PROCESS_ATTACH消息执行dllmain()函数。
(2)使用Windows钩子注入DLL
可以使用微软提供的API函数SetWindowsHookEx()来完成DLL注入,在MSDN中其声明方式如下:
如果想将自己的DLL注入目标进程中,首先要加载需注入的DLL,并获得DLL加载后的句柄,使用如下代码即可将c:\test\rootkit.dll注入其他进程的空间内。
需要注意的是,在本例中这个DLL必须导出一个名为HookProc的函数,用于执行消息Hook操作,如果我们的目的仅仅是单纯地注入DLL,则此函数内可不写任何逻辑代码。
(3)使用远程线程注入DLL
这种注入技巧相对于以上两种来说相对隐蔽一些,它的原理是利用微软提供的API函数CreateRemoteThread()执行远程进程空间中的LoadLibrary()函数加载DLL。这项技术的实现是基于每个进程都必须加载Kernel32.dll模块,且Kernel32.dll模块中导出LoadLibrary()函数这一事实的。
以下就是使用此技巧进行DLL注入的关键代码。
这段代码可以将位于C:\test\rootkit.dll下的这个DLL文件注入指定进程中。但是由于此段代码仅作示例使用,因此并未进行任何返回值检查,这点需要大家注意。
2、内联钩子
内联钩子(Inline Hook)是一种非常强大的钩子技术,它的大致原理是替换目标函数的部分代码,以达到控制其执行流程的目的。内联钩子的根本原理同通过反汇编手段为应用程序随意添加功能一样,只不过这里需要做的是为一个系统API添加我们自己的功能函数。
在用户控件中实现普通的内联钩子是相对比较简单的,因为微软为了便于进行热补丁操作(在不重启计算机的情况下更新并修补函数功能)而刻意将用户层API的前5个字节都设计成同样的起始格式,这被称为前同步码(Preamble)。以下是x86平台上Windows系统API函数的前同步码。
这5个字节足够攻击者在这个位置上放置一个指向他们自己钩子函数的JMP指令,由于每个系统API函数的前同步码都是一样的,因此在执行完钩子函数时执行一下这些指令,并跳转到原函数头5字节后的代码中即可。
但是如果想在函数内部执行内联钩子操作的话则会面临很多问题,其中最重要的就是指令截断问题。我们都知道80×86的汇编指令属于变长指令,也就是说不同的80×86汇编指令的长度是不同的,甚至相同的80×86汇编指令如果执行不同的操作,其指令长度也是不同的。这就需要我们在执行内联钩子前先选好位置,并记下需要替换的指令,然后还需要有一个最起码的校验机制以防止改错地方。当然,最好的方式是内置一个小巧的反汇编引擎,这样就可以较为灵活地执行内联钩子操作了。
4、一个保护文件不被删除的例子
为了更好地帮助大家理解用户层钩子的编写过程,我们给出了一个使用内联钩子实现文件保护的例子。这个例子将通过钩子的方式阻止目标程序打开文件名含有A1Pass这个特征字串的所有文件。
我们的主要工作都在一个名为R3_Rootkit.dll的文件中完成。外部程序通过我们前面讲的DLL远程注入技巧将R3_Rootkit.dll注入目标进程中,从而以内联钩子的方式勾住其CreateFileW函数,并对此函数的参数进行判断。当发现其要打开的文件的名称中含有特征字串A1Pass时,则拒绝执行打开操作。
首先,需要一个安装内联钩子的函数,其关键代码如下:
然后,我们要准备一个跳板函数,用以接管钩子。
最后,我们需要准备一个判断CreateFileW参数的文件名的函数。
通过这3步,我们就完成了一次针对CreateFileW函数的内联钩子的安装操作。
二、内核编程基础
编写内核级Rootkit的基础就是内核编程,Windows内核编程是Windows下所有编程技术中权限最高、价值最大、可发挥空间最大,当然同样也是难度最大的编程技术。也正因如此,内核级的Rootkit通常都具有不菲的价值,且一旦优秀的Rootkit植入被攻击系统,便极难通过普通方式将其检测出来。
1、内核编程环境与用户层编程环境的异同
我们都知道用户层编写的程序大多都以一个进程的方式运行,每个进程拥有相对完全独立的4GB空间,从而保证了每个进程所面对的环境是高度统一且高度可靠的。
但是在每个进程4GB的空间中,可完全自由使用的只有低2GB的内存空间(0x00000000~0x7FFFFFF),这部分空间称为用户空间,而与之相对的高2GB内存空间(0x80000000~0xFFFFFFF)则称为系统空间。其中,每个进程的用户空间所保存的内容是各不相同的,但是每个进程的系统空间绝大多数情况下是完全一致的,这是因为系统内核中没有进程的概念,只有线程的概念。我们可以将整个系统内核想象为一个进程,微软为它的内核提供了一个具有特定格式的接口,而我们则可以按照这个格式编写驱动程序,并通过这个接口加载到系统中,而系统内核则会创建一个线程调用我们加载进去的驱动程序的DriverEntry函数。因此,驱动程序本质上就是系统内核的一个扩展模块,这类似一个已经运行起来的程序动态加载DLL。当然,这个解释虽不准确,但却可以让有用户层开发经验的朋友更容易理解系统内核加载驱动的这一行为。
正因如此,由此带来的问题就会与用户层编程有较大的差异。
首先,在内核中编程时所面对的空间是共享的,这就意味着我们编写的驱动被加载到内核中运行起来之后,任何一个未经处理的错误都会导致系统出现BSoD,也就是我们常说的蓝屏死机。
其次,在内核中编程时我们不能直接使用C 。如果我们需要利用C 的某些机制则必须要对我们的代码做一定的兼容处理。除此之外,内核编程所使用的API函数与用户层编程也是完全不同的,我们必须要适应很多新的函数,但好在它们与用户层的API差别并不太大。
最后,在内核中编程时我们所面对的各种约束规则与用户层编程有很大的差异,我们需要对CPU平台与Windows操作系统有更进一步的了解才能做到游刃有余。
2、如何选择Windows驱动开发模型
不同的Windows版本上所支持的驱动开发模型也不尽相同,Windows驱动开发模型从开始的VXD与KDM发展到今天的WDM与WDF,这其中走过了很长的一段路。由于这些内容与我们编写Rootkit的关系并不大,因此在这里只做简要说明。
首先,无论是VXD、KDM、WDM还是WDF,它们都可以用来开发内核模式Rootkit,具体选择哪种模式取决于你想要做什么。
其次,VXD在Windows 2000中就已经被淘汰了,WDM与WDF驱动开发模型提供了很多新的特性,因此,如果您刚开始学习内核编程,建议至少从WDM开始学习。
最后,WDF驱动开发模型是Windows Vista及后续版本的开发模型,此模型虽然比WDM更先进且更合理,但是其更多的改进都是针对驱动编程而言的,因此如果用不到这些最新的特性,对于属于内核编程的Rootkit开发来说使用WDF模型开发并没有太多的意义。
3、驱动设备与请求处理
如果我们编写的驱动程序需要与系统中的其他组件进行交互,那么我们的驱动程序就必须生成一个驱动设备进行这种收发请求交互;如果我们编写的驱动程序需要与应用层进行交互通信,那么我们的驱动程序就必须生成一个符号链接。因此,驱动设备与请求处理是驱动程序与其他组件或应用程序进行交互的基本机制。
Windows为我们提供了多种方式与内核级驱动通信,其中最常见的就是IOCTL(I/O Control)。IOCTL的使用非常简便,下面我们就一起看看用户层应用程序是怎样与驱动程序进行交互的,如代码清单1所示。
代码清单1 用户层应用程序与驱动交互的例子
由以上代码可知,我们在用户层可以使用CreateFile函数通过符号链接名打开某个驱动设备,并使用DeviceIoControl函数通过自定义功能号执行驱动中与之对应的功能。在完成上述操作后,便可使用函数CloseHandle关闭设备。
既然用户层控制驱动程序如此简便,那么驱动程序中与之对应的功能是不是也非常简单呢?其实不尽然,当然,驱动程序与外部通信的交互部分相对是比较简单的,但是由于我们现在对驱动程序还知之甚少,因此需要掌握一些基础知识后才能对其有一个比较透彻的了解,因此这对我们来说也并不简单。
总的来说,驱动程序与外部通信总共由3个部分组成,它们是设备对象、符号链接与请求处理。下面我们将分别详细讲解这3个部分。
(1)设备对象
如果我们想要自己的驱动程序能与系统交互,就必须创建一个设备对象,否则此驱动将不能按照规范方式与系统交互,当然也不会收到任何I/O请求包。
一般情况下,我们在进行驱动编程时可以使用IoCreateDevice函数创建一个设备对象,其关键代码如代码清单2所示。
代码清单2 创建设备对象的一个简单例子
这是一种较常用且较安全的设备对象创建方式,使用此种方法创建的设备对象必须要有系统权限的用户才能将其打开。但如果使用者编写了一个普通的用户层应用程序,且此应用程序不是以管理员权限运行的,那么这个程序将无法打开这个设备进行交互。
因此,如果我们希望自己生成的设备是可以被任何权限的应用程序所访问的话,那么就需要使用IoCreateDeviceSecure函数创建一个带安全描述符的设备对象,其关键代码如下:
(2)符号链接
驱动通过创建一个符号链接,从而使得用户层应用程序可以使用CreateFile函数打开此设备。一般情况下,简单的符号链接总是命名在\DosDevices\下。下面就是一个简单的使用IoCreateSymbolicLink函数创建符号链接\DosDevices\MyRootkitSymLink的部分关键代码。
这种创建符号链接的方式虽然简单,但是会碰到一些问题,因为在Windows 2000及更高版本的操作系统中,链接符号会带有用户相关性,所以普通用户创建的链接符号只能供自己使用,而对于其他用户来说都是不可见的。
如果我们需要自己创建的链接符号可以被所有用户使用的话,那么创建一个全局的符号链接便可以解决这个问题。例如将\DosDevices\MyRootkitSymLink改为全局符号链接格式后为\DosDevices\Global\MyRootkitSymLink,非常简单。但是由于只有Windows 2000及更高版本的系统才支持这种格式的符号链接,因此我们在创建全局链接符号时必须先使用IoIsWdmVersionAvailable函数判定一下操作系统版本,如果操作系统是Windows 2000及更高版本就可用全局的链接符号,否则使用普通的链接符号即可。以下是关键代码:
(3)请求处理
驱动中的请求处理都是通过I/O请求包与派遣函数完成的。IRP的处理机制类似于Windows应用程序中的消息处理机制。
IRP有两个基本属性,分别是MajorFunction与MinorFunction,它们分别记录了IRP的主类型与子类型。操作系统会根据MajorFunction将IRP传送到不同的派遣函数中,然后派遣函数内部便可以进一步判断这个IRP的MinorFunction。
在Windows编程中,包含有界面的程序都是由消息驱动的,不同类型的消息会被发送到不同的消息处理函数中。如果编程人员没提供与某一消息对应的处理函数,那么它会进入系统默认的消息处理函数中。
而IRP的处理也采用类似的方式,例如,当我们在用户层调用CreateFile、ReadFile、CloseHandle等系统API时,操作系统则会产生出与之对应的IRP_MJ_CREATE、IRP_MJ_READ、IRP_MJ_CLOSE等IRP,并将这些IRP传送到驱动程序相应的派遣函数中。更多IRP类型如下表所示。
一般情况下,我们需要在驱动程序的DriverEntry函数中为这些IRP类型设置相对应的派遣函数,并对其做一些简单的处理,其关键代码如下:
有了这些基础,就可以开始写我们自己驱动部分的IOCTL请求处理派遣函数了。首先要在DriverEntry函数中为IRP_MJ_DEVICE_CONTROL类型的IRP设置派遣函数,代码如下:
然后我们便可以编写响应自定义控制码的DispatchDeviceControl函数了,如代码清单3所示。
代码清单3 响应自定义控制码的DispatchDeviceControl函数
至此,我们便完成了驱动程序,也就可以处理来自用户层应用程序的IOCTL请求了。
这里所讲的是缓冲内存模式IOCTL,除此之外直接内存模式IOCTL、缓冲区式读写操作与直接式读写操作都可以实现应用程序与驱动程序的通信及交互。
4、内核编程中的数据类型
在特定环境下编程就要使用相应环境下的数据类型表述方式,就要尽可能地遵守相应的编码规范,虽然不这么做也可以写出程序,但是在遇到一些极端情况或某些细微之处时就会暴露出相应的问题。
举例来说,控制台下的无符号整型一般都为unsigned int,而Windows编程则更习惯于用DWORD,与之对应的,内核编程则更倾向于使用UINT。
使用这些被推荐的重定义数据类型可以避免很多歧义性问题。因此,在我们进行内核编程时要习惯使用如下表所示的数据类型。
除了类型发生了变化外,在进行内核编程时,函数的返回值也都有了比较明确的规定,绝大多数的函数的返回值都是返回一个状态,我们可以使用宏NT_SUCCESS()判断一个返回值是否成功,具体例子可参考代码清单2。
我们在编写自己的函数时最好也遵守这些非强制性的规定。下表为总结的一些常用的函数返回值。这些返回值的含义虽然并没有被明确定义,但是这并不妨碍它们各自都拥有独特的、约定俗成的含义。
最后值得一提的就是内核编程中,使用字符串的方法与我们平时不太一样。在内核编程过程中字符串大多数情况下是用一个结构体来表示的,这个结构体被定义在公共头文件中,具体结构如下:
之所以将保存字符串的结构设计得如此之复杂,就是出于安全方面的考虑,以尽可能地避免出现溢出漏洞。
不过我们在使用字符串的时候还是非常简便的,只需要使用宏RTL_CONSTANT_STRING就可以帮助我们完成对字符串的初始化操作,如下所示:
5、函数调用
我们可以通过查看WDK帮助文档来了解我们需要使用哪些内核编程接口(可以理解为内核API),以及怎样使用这些内核编程接口。如果我们在内核中用错了这些内核编程接口则极有可能造成BSoD情况的发生。
大部分内核编程接口都拥有一个前缀,例如负责读取文件的ZwReadFile的前缀就是Zw。在内核编程中不同前缀的内核编程接口就代表它们的功能类别不同,我们常见的一些前缀包括Io、Ex、Rtl、Ke、Zw、Nt、Ps与Wdf等。下面我们将分别介绍一些比较有代表性的内核编程接口。
以Io为前缀的内核编程接口是比较常用且比较底层的,它们会涉及I/O管理器,我们在在处理请求时会经常用到这类接口。常用的几个函数如下表所示。
以Ex为前缀的内核编程接口主要提供一些管理层功能,例如内存申请、互斥体的管理及异常处理等。常用的几个函数如下表所示。
以Rtl为前缀的内核编程接口只要提供一些字符串、内存的操作,例如复制或拼接字符串、复制内存等运行时程序库功能。常用的几个函数接口如下表所示。
以Zw为前缀的内核编程接口和同名的以Nt为前缀的函数具有同样的功能,这些函数基本都有用户层API函数与之对应。与用户层同名的函数可以用于编写Native程序,因此Nt系列函数无论是在用户层还是在内核层都被称为Native API,但是Nt系列函数在WDK的帮助文档中是找不到的,不过这些函数确实存在,只需要自己声明后即可使用。
6、Windows内核编程的特点
我们在进行Windows内核编程时需要额外注意两个问题:函数的多线程安全性与代码中断级。提出这两个问题的本质意愿是希望借此来保证多线程并发运行的安全性。
Windows内核的整个结构是颇为复杂的,因此其不可避免地需要设计一些具有全局性质的数据结构及变量。我们都知道整个系统内核就相当于一个进程,里面运行的全部是一个个的线程,如果这些线程同时访问或修改某个位于内核的全局变量,势必会引发不可预料的问题。
因此这就要求我们去保证自己的函数是多线程安全的。所谓的多线程安全,就是指函数中不存在使用内核全局变量与全局性资源的情况,或者是使用了全局变量与全局性资源,但是同时使用了自旋锁等强制访问同步手段进行了处理。
不过这还有一个问题,我们肯定不能将自己写的每一个函数都写成多线程安全的,这样不但费时费力,而且也不能确定自己写的函数就一定运行在多线程的环境下。因此,我们在设计自己的函数时就需要对这个函数的使用情况做一个基本的判定,对于那些运行在单线程环境下的函数完全没必要将其设计成是多线程安全的。具体判断方法如下:
如果此函数是被DriverEntry与DriverUnload调用的,且没有两个及以上线程对其进行调用,那么这个函数就是运行在单线程环境中的。
如果此函数是被各种分发函数、完成函数与各种NDIS回调函数调用的,或者有两个及以上线程对其进行调用,那么这个函数就是运行在多线程环境中的。
为了巩固这种多线程环境下的安全性,Windows操作系统中引入了中断级。在软件层面系统共有3个中断级,它们由低到高分别是Passive级、APC级与Dispatch级,其中需要我们着重了解的是Passive级与Dispatch级。
一般情况下,代码都运行在Passive级中,只有比较简单的,且有多线程同步需求的代码才会运行在Dispatch级。为了确保多线程安全,所有运行在Dispatch级的代码都会进行原子操作,也就是说操作系统中在一定时间内只能运行一段Dispatch级的代码,且必须将其完全执行完毕后才会发生线程切换,这就有效地杜绝了多线程同时操作同一块数据的问题。因此,一些较为复杂的函数,或需要系统内核中其他部分协同完成某项工作的函数是不能工作在Dispatch级的,比如IoCreateFile。所以,我们在使用任何一个内核编程接口时都要通过查看WDK帮助文档来确定其对中断级的要求。
在实际编程时,我们可以通过获取自旋锁来提高中断级,通过释放自旋锁来降低中断级。但需要注意的是,Windows的所有代码都运行在规范的中断级上,我们不能因为自身的编程需要而随意强制降低其中断级,否则会产生不可预料的后果。如果我们的代码确实运行在Dispatch级上,但却要使用一些只能在Passive级中运行的内核编程接口时,可以通过创建一个新线程的方法在指定中断级上使用那些对中断级有特殊要求的函数。
在内核编程时需要注意,被调用者具有与调用者相一致的中断级,因此我们在执行某些操作时就必须判断函数本身处于哪个中断级。具体可以通过以下方式简单判断。
如果其上层函数没有对中断级进行操作,且此函数的顶层调用函数是DriverEntry、DriverUnload与各种分发函数,那么这个函数就是运行在Passive级上的。
如果其上层函数有获取自旋锁的操作,或者其顶层调用函数为完成函数或各种NDIS回调函数,那么这个函数就是运行在Dispatch级上的。
通过对以上判定方法及技巧的学习,我们可以在以后的内核编程之路上披荆斩棘,最大限度地减少错误的发生。
三、内核模式Rootkit
在病毒与反病毒的战争中,内核向来都是“兵家必争之地”,而内核模式的Rootkit作为占据内核最有效的手段,一直都是网络安全界最热门的技术话题之一。
就目前来看,内核模式的Rootkit是仅次于硬件后门的一种最接近底层,也是最有效且最彻底的技术手段,它既可以被恶意软件用于免杀操作从而彻底躲过查杀,也可以作为反病毒软件的底层检查手段。
通过用户模式的Rootkit我们可以知道,Rootkit之所以能够掩藏软件行踪并控制一些系统行为,最关键的思路就是通过勾住特定的系统函数,从而实现改变系统函数执行路径的目的,以使其在做关键操作时被我们提供的函数拦截并过滤。因此,Rootkit技术的本质就是各种钩子的应用,黑客们可以使用它做出免杀效果非常理想的木马,反病毒工程师也可以利用它建立坚固的安全阵地,以使得所有病毒木马无所遁形。
在内核中,可以使用的钩子类型有十余种之多,由其衍生出来的各种应用方案更是多达近百种。下面将尽可能全面地介绍各种钩子的原理及使用方法。
1、SYSENTER钩子
SYSENTER指令是在Intel Pentium(R)Ⅱ处理器上作为“快速系统调用”功能的一部分被首次引入的,SYSENTER指令进行过专门的优化,能够以最佳性能由Ring3层切换到Ring0层。
微软首次引入SYSENTER指令是在Windows 2000的系统上,在此之前微软的系统是通过自陷指令int 0x2E进入Ring0层的系统空间的。在Windows 2000及其以后的系统中,如果想要从Ring3层进入Ring0层,系统首先会将要调用的系统调用号(SSDT调用号)放入EAX中,然后将当前栈指针ESP的内容放入EDX中后即执行SYSENTER指令。SYSENTER被执行后会将控制权传递给特殊模块寄存器IA32_SYSENTER_EIP所指向的函数,以完成后续操作。
特殊模块寄存器组(Model-specific registers,MSRs)是CPU中的一组寄存器,负责实现一些特定的、与计算逻辑相关性不大的、且操作较复杂的功能,可通过使用rdmsr/wrmsr指令读写其位于MSRs指定偏移处的某个寄存器的信息。MSRs共包含上百种具有不同功能的寄存器,每个寄存器所在的偏移与所占用的空间都不相同,目前MSRs的可用偏移范围是0x00000000~0xC0000103。
与SYSENTER相配合的MSRs共有3个,其详细信息如下表所示。
SYSENTER被执行时,这3个寄存器会执行以下操作配合SYSENTER完成切换操作。
装载SYSENTER_CS_MSR到CS寄存器,设置目标代码段;
装载SYSENTER_EIP_MSR到EIP寄存器,设置目标指令;
将SYSENTER_CS_MSR 8装载到SS寄存器,设置栈段;
装载SYSENTER_ESP_MSR到ESP寄存器,设置栈帧;
切换Ring0;
清除EFLAG的V标志;
执行位于EIP处的Ring0例程。
但是这里需要注意的是,Ring3层的栈帧信息会随同EDX传入,因此如果我们需要获取Ring3层栈信息的话直接执行MOV ESP,EDX就可以得到。
由以上信息可知,如果我们能通过修改SYSENTER_EIP_MSR寄存器使其指向我们自己的函数,那么我们就可以在自己的函数中对所有来自Ring3层的调用进行第一手过滤。我们可以通过判断EAX中的值从而判断本次将要调用的内核函数,可以通过读取EDX中的内容获取此函数的参数,这些信息已经足以满足安装钩子所需要的一切基础信息了。
要完成一个SYSENTER钩子其实是非常简单的,我们只需要完成以下几步:
构建一个我们自己的MyKiFastCallEntry函数用以过滤调用信息;
下面我们将以进程保护为例对这些步骤进行逐一讲解。
(1)读取SYSENTER_EIP_MSR寄存器的信息并备份
读取MSRs寄存器内的某一寄存器信息需要使用rdmsr指令,这个指令将根据ECX中的值在MSRs中的指定偏移处取出对应寄存器的内容,并将其按照高低位保存在EDX:EAX中,如果目标寄存器的值是32位的,则只有EAX起作用。
(2)构建自己的过滤函数
由于我们需要执行的操作是保护进程,最简单的方法就是过滤ZwOpenProcess函数,当发现其参数包含结束进程权限时,就将这个权限去掉,然后再将其返回给系统处理。
(3)修改SYSENTER_EIP_MSR寄存器指向
想要修改IA32_SYSENTER_EIP寄存器的值需要我们使用wrmsr指令,这个指令会将EDX:EAX中的值写入ECX所指向MSRs的指定寄存器中。
(4)摘除钩子
摘除SYSENTER钩子非常简单,我们只需要将IA32_SYSENTER_EIP中的值恢复为系统设置的初始值即可。
至此我们就已经完成了第一个内核钩子。为了便于大家理解,这里所附带的源码都将使用统一的简单驱动框架进行编写。一般情况下控制码0执行打印调试信息,控制码1安装钩子,控制码2摘除钩子。为了避免代码逻辑过于复杂,例子中的安全判断都非常有限,因此在测试完钩子效果后一定要记得摘除钩子后再卸载驱动,否则会导致BSoD。
以本例为例,我们需要先运行一个程序,这里选择的是记事本程序,运行完毕后记录下其PID,如图1所示。
图1 记录目标程序的PID
运行A1SysTest加载驱动,并设置好符号链接名,然后将记录下的PID输入到A1SysTest输入缓冲区中,如图2所示。
图2 设置符号链接名并在输入缓存中输入目标程序的PID
将控制码设置为1,并单击带有闪电图标的按钮执行I/O操作,控制驱动执行安装钩子并保护相应PID进程的功能。
在试图使用任务管理器结束PID为2880的进程时,会看到如图3所示的结果。
图3 进程保护效果
实验完毕后,不要忘记执行控制码为2的I/O操作,控制驱动摘除刚刚安装的钩子,然后即可停止并卸载驱动。
2、SSDT钩子
一般情况下,为系统安装一个SSDT钩子要经过以下几步:
导入需要勾住的内核函数;
使SSDT所在的内存页可写(可以通过CR0方法或创建MDL的方法达到此目的);
替换SSDT中指定内核函数为我们自定义的函数;
如果需要摘除钩子,将被勾住的函数还原后将SSDT所在的内存页设为初始状态。
下面我们将以进程保护为例对这些步骤进行逐一讲解。这里首先给出我们自己实现的过滤函数。
这里直接给出关键代码。
(2)导入需要勾住的内核函数
由于我们要写一个进程保护的例子,因此我们只需要勾住ZwOpenProcess这个函数就可以了,所以这里只需要导入这个函数即可,代码如下:
(3)使SSDT所在的内存页可写
我们已经介绍过了通过使用CR0来修改内存保护的例子,这里我们使用内存描述符表(Memory Descriptor List,MDL)来完成这个目标—使SSDT所在的内存页可写。
在本例中我们使用MDL改变SSDT所在内存页的权限描述,从而使得我们可以随意修改SSDT,关键代码如下:
(4)替换指定内核函数为我们自定义的函数
有了这个基础,我们就可以更方便地执行勾住某个位于SSDT系统函数的操作了,其代码如下:
有了这两个宏,在进行SSDT的钩子安装工作时将非常方便。在本例中的应用大致如下:
(5)摘除钩子
有了以上的积累做基础,执行摘除钩子的操作就非常简单了,我们只需要先将目标函数的钩子摘除,然后再释放MDL即可,关键代码如下:
通过以上5个步骤的操作,我们已经基本构建起了一个SSDT钩子的模板,大家可以由此自由扩展。
(6)Shadow SSDT介绍
除此之外,系统还存在一个名为Shadow SSDT的表,这个表有时也被称为SSSDT,其在系统中的符号名是KeServiceDescriptorTableShadow。
那么我们要怎样才能获得ServiceTable字段的信息呢?下面我们就用WinDBG来找找看。
首先,我们需要找一个带有界面的程序,并得到它的EPROCESS获得KPROCESS。
最后,我们再用系统符号直接查看一下KeServiceDescriptorTableShadow中的信息,看看我们找的结果是否准确。
3、内联钩子
内核中的内联钩子与用户层的内联钩子并没有太大不同,只不过内核中修改内存属性及相应的函数都有些变化而已,除此之外注意一下安装钩子与摘除钩子时代码所处的IRQL即可。
为了便于大家理解,这里我们以前面“一个保护文件不被删除的例子”为蓝本编写一个内核版的例子,保护一个路径中包含有A1Pass字符的文件。
在内核中安装一个内联钩子要经过以下几步:
编写跳板函数;
修改内存属性后安装钩子,并将函数执行流程转移到跳板函数;
如果有摘除钩子的需要,则摘除完毕后还原内存属性。
(1)编写跳板函数
在编写跳板函数时需要注意,我们在内核中勾住的是NtCreateFile函数,此函数将打开文件的文本路径保存在第3个结构体参数的OBJECT_ATTRIBUTES.ObjectName.Buffer中,因此我们需要做些许修改,具体代码如下:
(3)安装钩子
在内核中安装内联钩子时要注意两点:首先要设置需勾住函数头的内存属性,使其变为可写的;其次需要在执行安装钩子代码前关闭中断,以使得安装钩子的操作可以一次性完成,不会因时钟中断切换线程引发BSoD。以下为部分关键代码:
(4)摘除钩子
摘除钩子与安装钩子所做的事情基本一致,不过需要注意的是不要忘记恢复映射的MDL内存属性。以下是部分关键代码:
4、IRP钩子
I/O请求包IRP是驱动程序与驱动程序之间、应用层与驱动程序之间进行通信的一种方式,有点类似于Windows用户层程序中的消息机制。在分层的驱动程序中这个过程会比较复杂,一个IRP常常要穿越几层驱动程序,IRP功能的复杂性也决定了IRP结构的复杂性。正确理解IRP的结构是理解驱动程序开发的基础。
IRP的创建是由I/O管理器在非分页内存进行的。一个IRP由头部区域和I/O堆栈位置这两部分组成。IRP的头部区域是一个以IRP结构起始的固定部分,在这个头部的后面是一个I/O栈(I/O stack locations),这是一个IO_STACK_LOCATIONS的结构体数组。这个数组中元素的个数由IoAllocateIrp()的参数StackSize决定,而StackSize又通常是由IRP发往的目标DEVICE_OBJECT的StackSize决定的。
在一个驱动程序中,不同类型的IRP对应不同的派遣函数,这些派遣函数用来处理其对应的IRP请求,驱动对象(DRIVER_OBJECT)在其MajorFunction[]数据成员中保存着负责响应各种IRP的派遣函数指针。每个IRP请求下都有一个I/O栈,堆栈中的每一层都对应驱动设备栈中的每一层设备,每一层I/O栈中都有一个完成例程函数,这个完成例程函数会在IRP执行完毕后,返回到此层设备中时被调用,具体对应关系如图4所示。
图4 设备栈与I/O栈的对应关系
需要注意的是,设备栈上的设备都是某个驱动创建的,一个驱动可以创建一个设备,也可以创建多个设备。
由图4可知,对于IRP钩子我们需要做好两件事:一个是替换指定驱动对象中的IRP派遣函数,这样就可以对此驱动下的所有设备中的某个类型的IRP进行完全控制;另一个就是替换IRP中的完成例程,最终在自定义的完成例程中取出感兴趣的数据。
下面我们以一个简易的键盘嗅探工具为例,演示IRP钩子的使用过程。
一般情况下,完成一个具有键盘嗅探功能的IRP钩子需要以下几步:
替换指定驱动对象下的特定IRP派遣函数为我们自定义的派遣函数;
在自定义的派遣函数中替换此类型IRP的每个完成例程为我们自定义的完成例程;
编写自定义的IRP完成例程函数,实现简单的打印按键扫描码的功能;
如果需要摘除钩子,需要将已替换的派遣函数恢复为系统默认的,并注意IRPPending问题。
(1)替换驱动对象中的IRP派遣函数
键盘类驱动\Driver\Kbdclass位于整个键盘类设备驱动的最上层,也就是说无论我们使用的是USB键盘还是PS/2键盘,其按键信息都会经过\Driver\Kbdclass。因此,如果我们能勾住\Driver\Kbdclass的IRP_MJ_READ类型IRP,就可以获取此系统中的所有按键信息了。
首先需要打开这个名为\Driver\Kbdclass的驱动对象,具体代码如下:
此时,我们已经获取到了键盘类驱动\Driver\Kbdclass的驱动对象,接下来就要替换此驱动对象下IRP_MJ_READ所对应的派遣函数了,具体代码如下:
至此,我们已经成功地将IRP_MJ_READ所对应的派遣函数替换为我们自定义的MyReadDispatch()函数了。通过对派遣函数进行替换操作,所有IRP的IRP_MJ_READ类请求都会被派遣给函数MyReadDispatch()处理。但是由以上代码可知,在MyReadDispatch()中我们未对被派遣过来的IRP做任何处理,而是直接转交给了系统的原派遣函数。
(2)替换IRP中的完成例程
由于我们想要获取的按键信息被保存在IRP_MJ_READ的返回信息中,因此我们需要替换所有IRP_MJ_READ类型IRP中的完成例程,如此在所有IRP_MJ_READ类型IRP被完成后都会调用我们自定义的完成例程,进而使得读取用户按键信息成为可能。下面我们就来完善MyReadDispatch()函数,具体如下:
通过以上代码的操作,我们已经成功将IRP_MJ_READ的完成例程替换为我们自定义的OnReadCompletion(),此时所有的IRP_MJ_READ在完成后都会调用OnReadCompletion()对其进行处理。但是同样,此时我们的OnReadCompletion()还没有具体的功能,只是简单地将执行流程转交给了IRP保存在Context中的原完成例程。
(3)编写自定义的IRP完成例程
当\Driver\Kbdclass驱动对象的IRP_MJ_READ类IRP完成时,其中必然会携带用户的按键信息,因此只需要将其按键的具体扫描码打印出来即可,其关键代码如下:
(4)摘除钩子
摘除基于IRP的键盘嗅探钩子会碰到一个问题,即当我们卸载驱动后,某个已经发出的IRP_MJ_READ类IRP还未完成。大家应该还记得,我们在其分发之前已经对其做了手脚,将其完成例程修改为我们自定义的OnReadCompletion()了。但此时的问题是,我们的驱动已经卸载了,因此很显然我们的OnReadCompletion()函数也就不复存在了。这时如果这个IRP完成后试图调用我们的自定义完成例程函数OnReadCompletion(),肯定会导致BSoD。那么我们应该怎么办呢?
相信细心的朋友在以上代码中应该注意到以下全局变量了:
我们通过使用全局变量g_uPendingIrpsCount来记录当前尚未返回的IRP数目,并使用全局变量g_pPendingIrp记录最后一个未返回的IRP,以便我们在卸载驱动后将其取消掉。
5、LADDR钩子
分层设备驱动程序(Layered Device Driver,LADDR)钩子是一种在内核中实现过滤的正规技术手段,LADDR存在的目的就是实现类似于钩子的功能,因此“分层设备驱动程序钩子”这种描述其实并不严谨,但是它却能使得初学者可以“见题知意”。
将驱动设备进行分层的思想是Windows内核中一切逻辑的根基,这种思想使得驱动开发人员能更加方便地“站在巨人的肩膀上做事”。比如我们想对键盘的按键行为做一定的监控,或对磁盘的文件读写做一定的过滤,这在分层驱动思想的基础上是比较容易的事情,因为你可以不必关心一切底层驱动的逻辑,也不必关心它们与硬件是怎么交互的,你只需要在现有的驱动上再叠加一份你自己的驱动就可以了。这样你自己的驱动程序不仅可以截获下层驱动已经初步处理完毕的数据,还可以在数据传输之前对其进行修改。尽管这听起来已经不像是Ring0层下的开发工作了,不过事实就是这么方便。
学习分层设备驱动程序的最好方法就是通过例子讲解,下面我们以一个键盘嗅探器为例讲解分层设备驱动程序的开发技巧。
一般情况下,LADDR钩子的开发工作要分为以下几步完成:
设计设备扩展中保存的数据结构,以便于我们后期附加或摘除过滤设备时使用;
设置过滤驱动的IRP派遣函数,以便获得我们感兴趣的IRP的控制权;
设计自定义的派遣函数与完成例程;
使用我们的过滤驱动生成过滤设备,并分别附加到指定驱动对象下的所有设备上;
编写摘除过滤设备的函数。
(1)设计设备扩展中的数据结构
在使用IoCreateDevice()函数生成设备时,第二个参数可以指定设备扩展的大小。设备扩展是用来保存操作此设备所需的一些必要数据的,它有点像跟随设备的一片指定大小的内存区域,设备可以随意在此区域中保存需要的数据。我们可以通过DEVICE_OBJECT下的DeviceExtension访问到属于此设备的扩展空间。
一般情况下,设备扩展中主要保存有一些同步对象、保护锁对象以及上层设备的对象等,总而言之就是为了在编写驱动时,其他驱动的代码能够更加轻松容易地控制此驱动生成的设备。
在本例中,我们的设备扩展空间中只需要保存扩展设备的结构体积、过滤设备对象、绑定设备对象与绑定设备的上一层设备对象即可,具体代码如以下:
(2)设置派遣函数
在驱动中设置派遣函数的作用有两个:
便于过滤。由于以后用于附加的所有过滤设备都要由此驱动创建,因此我们需要将此驱动感兴趣的IRP(例如本例中的IRP_MJ_READ)派遣函数设置为我们自定义的,这样所有由此驱动创建的设备的此类IRP派遣函数都将是我们自定义的。
保证兼容。很显然我们无法逐个处理绑定设备对象的所有类型的IRP请求,因此对于我们不感兴趣的请求都应该由一个通用的例程直接派发给底层设备处理。
鉴于以上两点,我们需要在过滤驱动的DriverEntry()函数中设置这些派遣函数,关键代码如下:
(3)设计自定义的派遣函数与完成例程
首先,我们需要设计一个常规派遣函数,用于将我们不关心的IRP请求直接转交给上层的系统真实设备处理。但是由于我们在实际的应用中往往有两个设备,它们分别是用于与用户层通信并接受用户指令的通信设备,和用于实际过滤功能的过滤设备。由于这两种类型的设备都是由我们的过滤驱动创建的,因此常规派遣函数必须要能同时处理这两种不同类型设备发送过来的IRP请求才可以,具体代码如下:
然后,我们还需要一个IRP_MJ_READ的自定义派遣函数,并用此函数替换所有此类型IRP的完成例程,以方便我们展开下一步工作,具体代码如下:
(4)生成过滤设备并附加到指定设备上
此时我们首先需要获取键盘驱动Kbdclass的对象,关键代码如下:
然后我们需要遍历键盘驱动Kbdclass下的所有设备,并创建对应的过滤设备附加上去,以达到过滤的目的,关键代码如下:
(5)编写摘除过滤设备的函数
摘除键盘过滤设备与摘除键盘IRP钩子的唯一不同之处就是需要执行一个逐一解除绑定的操作,大致代码如下:
至此我们就已经完成了一个基本的LADDR键盘嗅探器。
6、IDT钩子
IDT钩子是在各种钩子中比较靠近底层的一种,因此使用好IDT钩子,攻击者可以在相对较靠近硬件的层次执行一些过滤或截获操作,从而躲过其他钩子技术的检测。
不过需要注意的是,IDT属于硬件关键结构,每个CPU核心都拥有一个IDT,因此我们如果要在一个多核环境下勾住IDT的话,需要对所有核心都做相应的处理,否则会引起钩子失效甚至BSoD。
一般情况下,勾住IDT需要分如下几步完成:
获取IDT入口点及需要处理IDT的ISR,并保存ISR留待恢复时使用;
准备一个我们自己的ISR函数用以处理此IDT的中断请求;
如果需要摘除钩子,则将保存的原ISR写回即可。
这里我们就以一个工作在单核环境下的键盘嗅探器为例,讲解如何勾住IDT。
(2)准备一个我们自己的ISR中断请求处理函数
由于ISR不同于普通的函数,因此需要使用一个裸函数来接管它。
通过以上代码可知,当系统发生键盘中断触发此函数后,此函数会首先调用一个我们为其准备的功能函数PrintScanCode(),然后跳回系统的ISR中,以执行其他后续操作。
我们的PrintScanCode()函数只负责在键盘端口中读取出相应的按键扫描码,然后使用DbgPrint函数将其打印出来,其具体代码如下:
(3)替换需要处理IDT的ISR
(4)摘除钩子
摘除钩子与第三步操作基本一致,只不过是将我们的ISR处理函数MyInterruptHook替换为在第一步保存的此IDT的原ISR即可,关键代码如下:
7、IOAPIC 钩子
I/O高级可编程中断控制器(I/O Advanced Programmable Interrupt Controller,IOAPIC)是一个可以用于控制多个CPU核心中断操作的新型中断控制器,如图5所示。
图5 Intel 82093AA控制器(IOAPIC)
IOAPIC负责控制将IRQ发送给哪个CPU,以何种方式发送等。举例来说,通过对IOAPIC的编程可以决定PS/2的键盘中断发送给哪个CPU执行,以及此CPU的哪个IDT来响应具体的中断请求。
因此,如果我们使用好IOAPIC,可以用比较底层的方式完美地实现一个支持多核CPU的键盘嗅探器。我们需要讨论的是怎样通过对IOAPIC的编程控制某个IDT执行的功能。IOAPIC中的I/O重定向表寄存器里有24个可编程的中断请求项(IRQ0~IRQ23),其中每个中断请求都被赋予响应特定中断请求的职能,其中IRQ1就是负责响应PS/2键盘中断请求的,在Windows 7中,IRQ1所记录的中断向量是0x81,因此在Windows 7中响应PS/2键盘中断的是IDT第0x81项。
如果我们能够控制IOAPIC,那么我们就可以将IRQ1的中断向量重新修改为其他任何一个未被占用的IDT(例如IDT第0x20项),从而实现勾住键盘中断请求的目的。
想要重定位中断处理,我们需要分为以下几步完成:
映射IOAPIC的关键寄存器至虚拟内存空间;
修改中断处理向量;
获取并保存原键盘中断的ISR;
复制原键盘IDT到我们找到的未被占用的IDT中,并将其ISR修改为我们自己的处理函数;
如果需要摘除钩子,恢复IRQ1后,将我们找到的空中断向量重新置为空即可。
下面我们仍然以一个键盘嗅探器为例,讲解如何使用IOAPIC控制重定位中断处理。
(1)映射IOAPIC的关键寄存器
控制IOAPIC需要我们先了解两个IOAPIC中的关键寄存器,即IOREGSEL与IOWIN。
举例来说,Intel手册定义IOAPIC偏移为0x1的地方保存着其版本号,因此如果我们要想获得此IOAPIC控制器的版本号的话,就需要先将偏移0x1写入IOREGSEL寄存器以选择内容,此时IOWIN所指向的内容就是IOAPIC控制器的版本号了。
(2)修改中断处理向量
中断处理向量保存在每个IRQ的低8位中,我们前面已经讲过IOAPIC控制器使用IRQ1控制PS/2键盘的中断请求,因此我们首先需要得到IRQ1,然后才能修改它的中断向量。
但是IOAPIC控制器并不是一个简单的东西,如果想要使用好它,就必须阅读Intel的相应手册,在这里直接给出操作方法。
在IOAPIC控制器中,我们可以向IOREGSEL寄存器传入偏移量0x12来控制IOWIN指向它的第一个中断请求IRQ1所在的内存位置,然后就可以通过对IOWIN的读写来修改IRQ1的中断向量了,关键代码如下:
(3)获取并保存原键盘中断的ISR
我们都知道Windows 7中响应PS/2键盘中断的是IDT第0x81项,因此我们需要将这一项的ISR保存下来,以便当我们自己的ISR处理完相应的中断信息后,可以跳转到系统中负责处理PS/2键盘中断的ISR上继续操作,从而避免影响其他程序的运行。
(4)制作一个可用的键盘中断IDT
将我们找到的空IDT制作成一个可用的键盘中断IDT非常简单,只需要复制原键盘IDT数据到我们找到的未被占用IDT中,并将此IDT的ISR修改为我们自己的处理函数即可。
(5)摘除钩子
摘除TOAPIC钩子的方法和上述其他钩子类似,直接给出关键代码,具体如下:
四、结语
本文由用户模式的Rootkit开始讲起,一直延伸到内核模式下的7种Rootkit技术,并在中间穿插了内核模式编程基础的相关内容,以使您能以最少的基础知识和最快的时间全面掌握各种Rootkit的基础技巧。
ID:Computer-network
【推荐书籍】