4.2 应用方面的编程
4.2.1 基本的功能需求
本章前面的内容中介绍了在内核驱动中需要增加的部分。但是到此为止,在本章的例子中都没有说明应用和内核之间要进行怎样的通信。这一点将在这里说明。本节的例子将实现一个极为简单的通信需求:应用程序可以随时发送字符串给内核驱动,而内核驱动将把这些字符串保存在自己的缓冲区中;同时应用程序也可以随时发送请求将这些内核驱动缓冲的字符读取出来。
这看似简单,但是实际上它给进程之间的通信提供了一种通道。因为一个普通的应用,也就是运行在r3级别的一个进程实际上是没有权限主动和其他进程进行通信的(除非通过操作系统提供的接口)。而本节的例子在操作系统内核中提供了这样一个实现:一个进程可以向这个内核驱动中发送一些字符串,而另一个进程可以从这个内核驱动中接收这些字符串(因为在笔者的例子中的控制设备并不禁止任何进程打开它)。这也是操作系统的进程间通信的一种实现方式。
另外,既然应用和内核之间可以互相传递字符串,那么自然也可以传递其他的信息。这就看读者各自的需求了。字符串也可以改成其他的数据结构,用来传递任何可能的信息。
4.2.2 在应用程序中打开与关闭设备
在应用程序中打开设备和打开文件没有什么不同,除了路径有点特殊。笔者用VS建立了一个简单的Windows 控制台工程。大部分代码很简单地写在了_tmain函数中。打开设备使用API函数CreateFile。文件的路径就是符号链接的路径,但是符号链接的路径在应用层看来,是以“\\.\”开头的。注意,这些“\”在C语言中要使用“\\”来转义,所以在C代码中,上节生成的符号链接就变成了这个样子:
接下来是在_tmain中打开设备的代码:
CreateFile中最重要的参数就是第一个:用一个字符串来表示设备的路径。后面的参数读者可以直接拷贝笔者的代码。CreateFile的参数非常复杂,有兴趣的读者请参考MSDN的说明。
注意,如果失败了并不是返回NULL,而是返回INVALID_HANDLE_VALUE,而且INVALID_HANDLE_VALUE并不是NULL。这是一个实际写代码时容易出现隐藏错误的地方。
关闭设备非常简单,调用CloseHandle即可。代码如下:
4.2.3 设备控制请求
设备控制请求可以进行输入,也可以进行输出。无论输出还是输入都可以利用一个简单的自定义结构和长度缓冲区,所以读者可以根据自己的需要来设计非常复杂的通信协议。在这里笔者做一个简单的设计:定义一个叫作“发送字符串”的功能号。每个设备控制请求会有一个功能号,以便区分不同的设备控制请求。
这里的CTL_CODE是一个宏,是SDK里的头文件提供的。读者要做的是直接利用这个宏来生成一个自己的设备控制请求功能号。CTL_CODE有4个参数,其中第一个参数是设备类型。笔者生成的这种控制设备和任何硬件都没有关系,所以直接定义成未知类型(FILE_DEVICE_ UNKNOWN)即可。第二个参数是生成这个功能号的核心数字,这个数字直接用来和其他参数“合成”功能号。0x0~0x7ff已经被微软预留,所以笔者只能使用大于0x7ff的数字。同时,这个数字不能大于0xfff。如果要定义超过一个的功能号,那么不同的功能号就靠这个数字进行区分。第三个参数METHOD_BUFFERED表示用缓冲方式[2]。用缓冲方式的输入/输出缓冲会在用户和内核之间拷贝。这是比较简单和安全的一种方式。最后一个参数是这个操作需要的权限。当笔者需要将数据发送到设备时,相当于往设备上写入数据,所以标志为拥有写数据权限(FILE_WRITE_DATA)。
笔者另外定义了一个从内核接收字符串的功能号如下:
下面就是发送请求的过程。除了之前的打开设备和关闭设备,中间增加了使用DeviceIoControl发送请求的过程。
msg在这里是一个普通的字符串,当作缓冲区使用DeviceIoControl传送。当然,仅仅是为了简单而这样做,事实上这是极不安全的,等于在内核中敞开了一个不限长度的缓冲区用来进行攻击。DeviceIoControl函数会导致内核中的设备对象收到一个设备控制请求。下一节中笔者会修改内核驱动的部分,来处理这个请求。
4.2.4 内核中的对应处理
在目前的情况下,应用中调用DeviceIoControl一定会返回错误,因为内核驱动中还没处理。现在回到内核编程中来修改。在处理打开和关闭IRP时,比较简单,直接返回成功即可。但是在处理设备控制请求时,还有如下的任务要完成。
●获得功能号。
●如果有输入缓冲区,则必须获得输入缓冲区的指针以及长度。
●如果有输出缓冲区,则必须获得输出缓冲区的指针以及长度。
这些任务可以用下面的代码来完成。
注意,缓冲区是irp→AssociatedIrp.SystemBuffer的前提是,这是一个缓冲方式的设备控制请求。其他方式的设备控制请求取得缓冲区地址的方式也不同,在本书中不做介绍。这里只说缓冲区,没有说是输入缓冲区还是输出缓冲区,是因为在设备控制请求中,输入缓冲区和输出缓冲区是共享的,是同一个指针。
下面是笔者写的完整的分发函数。笔者使用了while代替if,是希望在发现错误时,可以用break直接跳到返回的地方。其中关于缓冲区的处理,非常简单地调用了DbgPrint((char *)buffer)。也就是说,只要应用程序发送一个字符串,内核驱动就把它打印出来。
这样的处理方式非常简单,但是有一个缺点,就是没有规定缓冲区的最大长度。换句话说,缓冲区的长度是由用户态的应用程序发起调用时决定的。
一般而言,在处理缓冲区的时候,内核中的内存是有限的,因此编程中很容易出现使用一个有限的空间去处理数据,结果却被攻击者输入了一个更长的缓冲区的情况。结果就是缓冲区溢出。
缓冲区溢出会使内核驱动自身被修改,跑去执行可能由攻击者从用户态写入的代码。这远比应用程序自身的漏洞要严重。这样的内核驱动安装在系统上,等于给攻击者大开方便之门,攻击者可以利用内核驱动的漏洞直接从用户态获得内核态权限。
因此,在应用层和内核层之间进行通信时,必须严格地设计通信接口,避免各种缓冲区溢出的可能。首要的就是限制缓冲区的最大长度。一旦发现缓冲大于这个长度,就可以不加处理,直接返回失败。
4.2.5 结合测试的效果
笔者对以上的示例程序进行了测试,相关的代码在/source/coworker目录下。
笔者在虚拟机里启动了Windows系统,连接上WinDbg,然后安装了coworker_sys.sys并手动加载。
接下来在虚拟机上运行coworker_user.exe,运行结果如图4-2所示,该结果说明一切都显示成功,没有错误提示。
图4-2 coworker_user.exe执行输出的信息
当然,另一个重要的问题是内核中的确收到了coworker_user.exe所发出的信息。这一点可以通过WinDbg看出来,如图4-3所示。
图4-3 WinDbg中coworker.sys所输出的信息
这里看到两条信息是因为笔者执行了两次coworker_user.exe的缘故。从这里看,内核的确收到了从应用层发来的信息。至于如何利用这些信息,就看读者自己的需求了。
[1]注意:对应的英文是Create而不是Open。因为在文件系统中,常常将Create和Open视为同一个请求。Create意味着生成新文件并打开,而Open意味着打开已经存在的文件。在很多情况下请求的意图是,如果文件已存在就打开,如果文件不存在则新建一个并打开。所以用同一个请求Create来表示,只是用参数表示意图的不同(要不要新建、覆盖、打开已存在的文件等)。在本章中打开控制设备的情况下,控制设备是本来就存在的,所以意图是“打开”而不是“生成”。
[2]除了缓冲方式(Buffered),还有直接方式(DirectIO,用MDL锁定用户内存)和原始方式(使用irp->UserBuffer,无标志定义)。