第四章 驱动从开始到结束

第四章 驱动从开始到结束

  在这章中,我们将使用我们在之前很多章提到的概念,来实现一个例子,完成驱动和客户端程序,补充其中一些细节。我们将部署这个驱动和使用它的功能 - 来内核中进行用户模式下午无法进程的操作。


  在这章中:

  • 介绍
  • 驱动初始化
  • 客户端代码
  • Create和Close派发函数
  • DeviceIoControl派发函数
  • 安装和测试

介绍

  我们将用一个简单的线程来解决Windows内核线程优先级设置不灵活的问题。在用户模式中,一个线程的优先级取决于它进程的Priority Class与每个线程的偏移量的组合决定的。改变一个进程优先级可以通过SetPriorityClass类来实现,其接受一个进程句柄和六个所支持的优先类中的一个。每一个优先级类对应着一个优先级别,线程的优先级默认是它进程中的这个。一个具体线程优先级可以使用SetThreadPriority函数来实现,其接受一个线程句柄和几个基于优先类的偏移常量。图4-1展示了这个可基于进程优先类的线程优先级别以及线程的优先级偏移量。

  

  SetThreadPriority函数可接收的值是指定的偏移量。五个级别对应着-2到+2的级别:THREAD_PRIORITY_LOWEST(-2),THREAD_PRIORITY_BELOW_NORMAL(-1),THREAD_PRIORITY_NORMAL(0),THREAD_PRIORITY_ABOVE_NORMAL(+1),THREAD_PRIORITY_HIGHEST(+2)。所保留的两个级别,被称为Saturation(饱和)级别,优先级类支持的两个极端的优先级:THREAD_PRIORITY_IDLE(-Sat)和THREAD_PRIORITY_TIME_CRITICAL(+Sat)。

  下面的代码是改变当前线程的有限级别到11:

  

   Real-time优先级类并意味着Windows是一个实时操作系统;Windows并不提供一些实时操作系统提供的实时保证。因为Real-time优先级非常高,可以完成内核中大部分的操作,例如一个进程一定运行在管理员权限;否则,尝试设置优先级类到Real-time类将会设置这个值非常大。

  实时级别和做低级别的类有其他很多不同,将在Windows Internals book 这本书中有更多的讨论。

  图4-1清晰地展示了我们将解决的问题。我们只有一个很小的优先级可被设置。我们将创建一个驱动,来突破这些限制,允许设置线程优先级到任何数,不管它的进程优先级类是多少。

驱动初始化

  我们开始使用在第二章的相同的方式来开始构建驱动。我们将创建一个新的命名为PriorityBooster的“WDM空项目”(或者其他你选择的名字),并且删除wizard所创建的INF文件。给项目添加一个新的被称为PriorityBooster.cpp的源文件,添加一个基本的#include的WDK头和一个空的DriverEntry:

  

   在DriverEntry中大多数驱动软件需要做以下事情:

  • 设置一个卸载例程。
  • 设置驱动支持的分发函数。
  • 创建一个设备对象。
  • 为设备对象创建一个符号链接。

  当这些操作都完成时,这个驱动准备开始处理请求。

  这第一步是为驱动添加一个卸载函数,并且指明其驱动对象。新的DriverEntry卸载函数如下: 

  

   我们将向卸载例程添加我们需要的代码,当我们在DriverEntry中做了一些我们需要卸载的工作时。

  下一步,我们需要设置我们想支持的分发函数。实际上全部的驱动一定支持IRP_MJ_CREATE和IRP_MJ_CLOSE,否则将无法打开和管局这个驱动的任何设备句柄。因此我们将在DriverEntry中添加如下代码:

  

   Create和Close的主函数指向相同的例程,这是因为,我们很快看到,我们实际做的是相同的事情:简单的赞同请求。在更复杂的案例中,这将必须分开另个函数,在Create的情况下,启动可以检查调用者是谁,并且只让允许的调用者来成功打开一个设备。

  所有的Major函数都有相同的函数原型(他们是一个函数指针的一部分),因此我们必须为PriorityBoosterCreateClose来添加一组函数原型。这个函数原型如下:

  

  这个函数返回NTSTATUS,并且接收一个设备对象指针和一个I/O请求包(IRP)的指针,一个IRP是一个请求信息存储的主要对象,我们将在第六章来挖掘更多关于IRP的信息,但是我们将在本章节之后才看到这些基础,因为我们需要它来完成我们的驱动。

向驱动传递信息

  我们设置了这个Create和Close操作,但是还是不够的。我们需要一种方法来告诉驱动,具体的线程和具体设置它的优先级。从一个用户客户端的视角来看,我们可以有三种可以使用的基础的函数:WriteFile、ReadFile和DeviceIoControl。

  从我们驱动的视角来看,我们可以使用WriteFile或IoDeviceControl。读是明显不可以的,WriteFile或者DeviceIoControl呢?这仅仅是一个个人喜好问题,但是如果这是真正的一个写操作(逻辑上的),最好使用Write,对于其他的,使用DeviceIoControl更好,因为它时一个传递数据给驱动的通用机制。  

  因为改变一个线程的优先级并不是一个纯粹的写操作,我们将使用DeviceIoControl,这个函数的原型如下:

  

   这里有三个DeviceIoControl重要的知识点:

  • 一个控制码
  • 一个输入缓冲区
  • 一个输出缓冲区

  这意味着DeviceIoControl是与驱动沟通的灵活的方式。可以支持多个控制码,这些控制码需要不同的语义来和可选缓冲区一起传递。在驱动的另一方面,DeviceIoControl对应着IRP_MJ_DEVICE主函数码,让我们添加我们的初始派发函数。

 

 客户端/驱动 沟通 协议

  我们决定使用DeviceIoControl来实现客户端/驱动通信,我们现在必须定义实际的语义。明显地,我们需要一个控制码和输入缓冲区。这个缓冲区一定包含所需要的两个信息为了驱动能正常工作:驱动id和它设置的线程优先级。

  这条信息一定对驱动和客户端都有用。这个客户端将提供这些数据,驱动将以它为基准运行。这意味着这些定义一定要分别定义在驱动和客户端代码。

  为了实现这些,我们将添加一个命名为PriorityBoosterCommon.h的头文件给驱动项目。这个文件之后也会给用户模式的客户端使用。

  在该文件中,我们需要定义两件事情:驱动所期待的客户端和这个改变线程权限的控制码。让我们开始声明一个驱动从一个客户端所捕捉的信息:

  

   我们需要线程的唯一ID和目标优先级,线程ID是一个无符号的32位整数,因此我们选择ULONG类型(注意我们一般不能使用DWORD - 一个在用户模式的通用类型 - 因为无法定义在内核模式的头文件。ULONG,另一方面,都是都可以定义的)。这优先级应该是1和31之间的一个数字,因此可以使用一个简单的32位整数。

  下一步我们需要定义一个控制码。你可能认为任何32位的数字都可以胜任这份工作,但实际情况并不是这样。这个控制码一定使用CTL_CODE宏来建立,这个宏接收四个参数来生成最终的控制码。CTL_CODE像这样被定义:

  

   各个宏的参数有如下简单的描述:

  • DeviceType - 定义一个设备类型。这一是FILE_DEVICE_xxx常数定义在WDK头文件中,但大多数是基于硬件和驱动的。对于像我们这种驱动来说,这个数字无关紧要。不过,微软的文档中指出这个第三方值应该从0x8000开始
  •  Function - 指定特定操作的一个升序数字。如果没有其他事情,这个数字一定与相同驱动的不同控制码不同的。再次强调一下,官方文档中指出任何第三方值应该从0x800开始
  •  Method - 控制码最重要的一部分。它指出了通过客户端到驱动如何提供输入和输出缓冲区。我们在第六章有处理这些值的细节。对于我们的驱动,我们将使用这个最简单的METHOD_NEITHER。我们将在这章节后面来看到它的效果。
  • Access - 指出这个操作是到驱动的(FILE_WRITE_ACCESS),来自驱动的(FILE_READ_ACCESS)或者两者都是(FILE_ANY_ACCESS)。一般的驱动都是用FILE_ANY_ACCESS并且在IRP_MJ_DEVICE_CONTROL中来处理实际的请求。

  通过以上给出的信息,我们可以定义如下的单个控制码:

  

 创建一个设备对象

  我们还有在DriverEntry更多的初始化操作。当前,我们没用任何设备对象,因此这个没有办法打开和到达一个驱动。一个一般的驱动软件只需要一个驱动对象,还有一个符号链接来指向它,为了用户模式客户端可以获得这个句柄。

  创建设备对象需要调用IoCreateDevice API,其定义如下:

  

   IoCreateDevice的参数被如下定义:

  • DriverObject - 这个设备对象所属的驱动对象,一般来说这应该是通过DriverEntry函数传递过来的驱动对象。
  • DeviceExtensionSize - 为sizeof(DEVICE_OBJECT)之外分配的额外的字节。这是对于一个设备附加一些数据结构是非常有用的。对于创建单独一个设备对象的软件驱动是很少使用的,因为我们所需的设备的状态可以简单地通过全局变量来管理。
  • DeviceName - 这个内部的去东门,一般在 Device Object Manager 目录下创建。
  • DeviceTyle - 关于一些基于驱动的硬件类型。对于软件驱动,应该使用FILE_DEVICE_UNKNOWN这个值。
  • DeviceCharactristics - 一组flags,关于一些特别的驱动。软件驱动可以指定0或者FILE_DEVICE_SECURE_OPEN如果他们支持一组真正的命名空间(软件驱动很少使用,超出了本书的讨论范围)。
  • Exclusive - 是否允许超过一个文件对象打开相同的设备?多数驱动指定为FALSE,但是在很多情况下TURE更准确;它强制只有一个客户端对应一个设备。
  • DeviceObject - 这是一个返回的指针。如果成功,IoCreateDevice分配一个非分页内存池,向传递的参数中存储一个指针结果。

  在调用IoCreateDevice之前,我们一定要创建一个UNICODE_STRING字符串来管理设备的内部名字:

  

  这个设备名字可以是任何一个但是必须在 Device object manager 目录下的。这里有两种方式使用一个字符串常量来完成初始化。这第一个使用RtlInitUnicodeString,这是非常有效的,但是RtlInitUnicodeString必须制定要字符串的属性来正确地初始这个Length和MaximumLength。这种情况下并没有什么大不了的,但是一个更快的方法 - 使用 RTL_CONSTANT_STRING 宏,这可以在编译时静态地计算这个字符串的长度,这意味着只是对常量字符串才有效。

  现在我们调用IoCreateDevice函数

  

   如果所有都执行良好,我们将获取一个指向设备对象的指针,下一步是通过提供一个符号链接,来使设备对象可以访问。接下来的几行创建一个符号链接,来让我们的设备对象完成沟通:

  

   这个IoCreateSymbolicLink接收符号链接和这个link对象。注意如果这个创建失败,我们必须撤回我们到目前为止所做的任何事情 - 在这里包含我们所创建的设备对象 - 通过调用IoDeleteDevice。详细来说,如果DriverEntry返回任何失败状态,这个Unload路径将不会调用。如果我们需要更多的初始化,我们必须记住撤销任何东西在失败的情况下。我们在第五章会看到一种更为优雅的方式。

  一旦我们有了符号链接,这个设备对象就可以启动,DriverEntry可以返回成功,这个驱动现在开始接受请求。

  在我们继续之前我们忘记了卸载例程。假设DriverEntry成功完成,这个卸载例程也要撤销我们在DriverEntry所做的任何事情。在我们的例子中,有两件事 - 设备对象创建和符号链接创建。我们将逆序来撤销他们:

  

客户端代码

  这部分说明了值得写的用户模式客户端代码。我们在客户端中需要的任何事都已经被定义了。

  添加一个新的控制台桌面项目的名为Booster的解决方案(你也可以选择其他名字),这Visutal Studio的助手应该创建了一个单独的源文件(Visual Studio 2019),两个预编译头文件(pch.h,pch.cpp)在Visual Studio 2017.现在你可以安全地忽视掉这些预编译的头文件。

  在Booster.cpp文件中,移除这个默认的"hello world"代码,并且添加如下声明:

  

   注意我们应该包含着驱动相同的头文件,其与客户端共享的代码。

  改变主函数接收的命令行参数,我们使用命令行接收一个线程ID和一个优先级,请求驱动使用所给予的值来改变优先级。

 

   接下来我们需要打开我们设备的句柄,这个CreateFile的“文件名”应该使用"\\.\"作为前缀的符号链接,整个调用应该看起来是下面这样:

  

   这Error函数简单地打印上一个错误发生的文本:

  

   CreateFile函数调用应该到达驱动的IRP_MJ_CREATE派发例程中。如果这个驱动此时没加载 - 意味着这里没有设备对象和符号链接 - 我们将得到一个错误号2(文件没有找到)。现在我们有一个我们驱动的有效参数,是时候开始调用DeviceIoControl。首先,我们应该创建一个ThreadData结构体并且补充一些细节:

  

   现在我们开始调用DeviceIoControl并且之后关闭设备句柄:

  

   DeviceIoControl到达驱动通过调用IRP_MJ_DEVICE_CONTROL 主函数例程。

  此时客户端代码已经完成。留下的是我们生命在驱动另一边的派发函数的实现。

Create和Close派发例程

  现在我们准备完成由驱动定义的三个派发函数。到目前为止最简单的是Create和Close派发函数。我们只需要使用一个成功状态来完成请求。Create/Close派发例程的实现如下:

  

  每一个派发例程接受一个目标设备对象和一个I/O请求包(IRP),我们并不太多关心设备对象,因为我们只有一个,所以我们必须在DriverEntry中创建一个出来。另一方面,这IRP是非常重要的,我们将在第六章来深入挖掘IRPs,但是我们现在需要快速浏览一下。

  一个IRP是一个表示请求的半文档化结构,通过来自执行体的管理器之一:I/O管理器、即插即用管理器或电源管理器。一个简单的软件驱动,最可能来自I/O管理器。不管IRP的创造者是谁,这驱动的目的是处理IRP,这意味着需要看一下请求的细节并且做出必须的东西来完成它。

  每一个驱动的请求总是用IRP包裹着抵达,无论是一个Create、Close、Read或其他IRP。通过看一下IRP的成员,我们可以搞清这个类型和请求的细节(从技术上说,派发函数本身由一个基础的类型所指向,因此在绝大多数情况下它早已知晓这个请求类型)。非常值得一说的是,IRP从来不是单独抵达;它是由一个或多个IO_STACK_LOCATION类型的结构体所伴随的。在像我们这种简单的案例中,只有一个IO_STACK_LOCATION存在。在更复杂的案例中,存在驱动过滤在我们上面或下面时,多个IO_STACK_LOCATION实例存在,每一层对应着设备栈中的每一层(我们将在第六章更详细的讨论)。简单地一提,我们需要的信息在IRP基本结构中,一些在我们的级别的设备栈的IO_STACK_LOCATION中。

  在Create和Close例子中,我们不需要看任何成员。我们只需要设置这个IRP的返回状态在IoStatus成员中(IO_STATCK_BLOCK类型),这有两个成员:

  •  Status - 指明当前完成状态。
  • Information - 一个多态成员,意味着在不同的请求中有着不同的意义,在Create和Close案例中,这个值仅为零。

  为了实际完成这个IRP,我们可以调用IoCompleteRequest。这个函数可以做很多,但是最基本的是它传递一个IRP的返回值给它的创造者(一般来说是I/O管理器),这个管理器通知了客户端这操作已经完成。这第二个参数是一个驱动可以提高给客户端的暂时的优先级提升。在很多情况下,0是最好的值(IO_NO_INCREMENT被定义为零),因为这个请求完成是同步的,因此没有理由给调用者一个权限提升。再说一遍,在第六章中会给出更多的函数信息。

  最后一个需要操作的是返回给放进IRP的相同的状态,这看起来可能是无用的重复,但是它是必要的(这理由将在之后的一章中讨论)。

DeviceIoControl派发例程

  这是一个关键的问题,到目前为止,所有的驱动都指向着这个派遣例程。这是提高线程优先级所做的派发例程。

  我们首先要做的事情就是检查这个控制码,普通驱动可能支持很多控制码,因此如果这个控制码无法被识别,我们想无法给出请求:

  

   得到任何IRP信息的关键是在与当前设备级别关联的IO_STACK_LOCATION中去看。调用IoGetCurrentIrpStackLocation返回了一个指向当前IO_STACK_LOCATION的指针,在我们的例子中,而仅仅只有一个IO_STACK_LOCATION,但是在其他任何调用IoGetCurrentIrpStackLocation的情况下这将会进行正确的调用。

  这个IO_STACK_LOCATION的主要元素是一个匿名的联合体成员,名为Parameters,它是一系列结构体,每一个对应着每一种类型的IRP。在IRP_MJ_DEVICE_CONTROL的例子中,这个结构体看着它的DeviceIoControl。在这个结构中,我们可以找到客户端传递的信息,例如控制码,缓冲区和它们的长度。

  这个Switch语句使用IoControlCode成员来决定我们是否能理解这个控制码。如果不能,我们设置这个成功之外的状态并且中断出Switch结构体。

  通常这段代码的最后一部分我们需要完成这个IRP在Switch语句之后,无论成功与否。否则这个客户端不会收到一个完成回应。

  

   无论什么状态,我们只需要完成这个IRP。如果这个控制码无法被识别,那将是一个失败的状态,否则,它将由我们识别后所完成的工作决定。

  最后这是非常有趣并且重要的:完成这个改变线程优先级的工作。这第一步是检查这个我们接收的缓冲区是否比ThreadData对象足够大。这个指向用户缓冲区的指针再Type3InputBuffer成员,这个输入缓冲区输入缓冲区长度在InputBufferLength中。

  

   你可能非常好奇访问所提供的缓冲区是否合法。因为这个缓冲区在用户空间,我们必须在一个进程客户端的上下文。确实如此,因为调用者是客户端线程本身,它转换成第一章的形式。

  下一步,我们假设这个缓冲区足够大,因此我们把它作为ThreadData:

  

   如果指针为空,我们应该丢弃它:

  

  下一步,让我们看到检查这个请求的优先级,其是否在1到31这合法范围之内,如果不是则丢弃:

  

   我们已经很接近我们的目标了。我们使用这个KeSetPriorityThread API 来设置优先级,其定义如下:

  

   这个KPRIORITY类型只是一个8字节的定义。这线程本身是一个指向KTHREAD对象的指针。KTHREAD是内核管理线程的一部分。这并未完全文档化,但是这里我们有从客户端传递过来的线程ID并做某些事情来获取在内核空间真正的内核对象。我们可以使用名为PsLookupThreadByThreadId来使用线程的ID来获取内核对象,为了得到这个函数的定义,我们需要添加另外一个#include:

  

   注意我们必须添加这个#include在<ntddk.h>之前,否则我们将会编译错误

  现在我们把线程ID来转换为一个指针:

  

   在这个代码片段中,我们需要指出几个比较重要的点:

  • 这个查询函被定义为接收一个句柄而不是线程ID。所以这个是一个句柄还是一个ID呢?这是一个被定义为句柄的ID。这么做的原因与这个进程和线程ID生成的方式有关。这些通常从一个内核的私有全局句柄表生成,因此这个内核句柄值实际上是IDs。这个ULongToHandle宏提供了必要的将编译器开心的转换。(记住在64位操作系统,一个句柄是一个64位的值,但是这个客户端提供的线程ID总是32位的)
  • 这个返回的指针是被定义为PETHREAD,或者是指向ETHREAD的指针。再说一次,ETHREAD是完全为文档画的。尽管如此,我们似乎有一个问题,因为KeSetPriorityThread接收一个PKTHREAD而不是PETHREAD。这证明他们是相似的,因为ETHREAD的第一个成员是一个KTHREAD(这名为Tcb的成员)。我们将使用内核调试器在下一节证明。底线是我们可以安全地将PKTHREAD切换为PETHREAD,反之亦然。
  • PsLookupThreadByThreadId可能由于各种原因失败,例如违法的线程Id或者线程被中断。如果这个调用失败,我们简单的从switch语句退出,无论其返回什么状态。

  现在我们最终准备改变这个优先级,但是稍等一下 - 如果我们在最后一次调用后线程终止了,就在我们设置了新的线程之前,会发生什么呢?请放心,这通常不会发生。从技术上讲,这个线程可以在那时候中断,但是那并不会让我们的指针悬空。这是因为查询函数,如果成功,将增加内核线程索引,它不会死亡直到我们完全减少这个索引值,这儿是一个线程权限的改变:

  

  我们现在剩下要做的是减少这个线程对象的索引;否则,我们会存在一个句柄泄漏,这只有才下次操作系统时才会解决。完成这一壮举的函数是 ObDeReferenceObject:

  

   现在我们全部完成了!作为参考,这里是完成的 IRP_MJ_DEVICE_CONTRL 处理,这里有一些微小的改变。

  

 安装和测试

  在这里,我们可以成功的构建驱动和客户端。我们下一步是安装驱动并且检测它的有效性。我们可以在虚拟机中尝试接下来的,如果我们足够勇敢 - 在我们的实体机上。

  首先,我们安装驱动。用一个高权限的命令行窗口,安装在我们第二节使用的sc.exe工具:

  

   确保binPath包含着SYS文件的全路径。这个例子中驱动的名字(booster)注册表键所创建的名字,所以一定不要相等。这个与sys文件没有任何关系。

  现在我们加载驱动:

  

   如果一切良好,这个驱动现在已经开始正确运行。为了验证,我们打开WinObj并且查看这个符号名和设备连接。图4-1展示了WinObj中的符号链接。

  现在我们最后运行这个可执行的客户端。图4-2展示了我们想要提升为新等级的cmd.exe进程中的一个新的线程。

  使用线程ID以及所想获取的权限来运行这个客户端

  

   瞧!如图4-3所示

猜你喜欢

转载自www.cnblogs.com/onetrainee/p/12918146.html
今日推荐