让一切输入都难逃法眼——驱动级键盘过滤钩子的实现
作者:中国石油大学胜利学院 小麒麟(王龙)
上一期给大家带来了SSDT的详细说明,基本上挂钩SSDT就可以实现我们想要的很多功能了,但是有时候,挂钩SSDT有局限性,这时我们就可以充分利用Windows驱动的分层特性,充分挖掘系统的特性。上一篇文章可以说写的有些快,这篇我将从头讲起,希望对有兴趣进入驱动编程的人有些帮助。
驱动分层结构是Windows的特性,I/O管理器有两个重要设计:一是Windows中的任何一个驱动程序都可被设计成Client/Server模式。对于客户端驱动,通过IoGetDeviceObjectPointer之类的获取服务端驱动导出的Device对象,通过I/O管理器的IoCallDriver请求服务端的服务。IoCallDriver实际上根据客户端的调用参数(通过IRP)调用服务端的派遣入口(回调函数)接受客户端的请求。二是I/O管理器实现一个分层的数据结构,在DEVICE_OBJECT对象中保存某种关系,自动将请求IRP发给设备栈中的最高的一个设备,由其决定如何处理,或是自身处理,或是向下传递,达到分层的目的。鉴于这种能力,分层驱动模型可以实现很多应用,如文件监控、加密、防病毒等等。由于PnP的引入,这种应用将更加广泛。
设备对象是系统为帮助软件管理硬件而创建的数据结构。一个物理硬件可以有多个这样的数据结构。处于堆栈最底层的设备对象称为物理设备对象(physical device object),或简称为PDO。在设备对象堆栈的中间某处有一个对象称为功能设备对象(functional device object),或简称FDO。在FDO的上面和下面还会有一些过滤器设备对象(filter device object)。位于FDO上面的过滤器设备对象称为上层过滤器,位于FDO下面(但仍在PDO之上)的过滤器设备对象称为下层过滤器。如图1所示。
javascript:dcs.images.doResizes(this,0,null); border=0>
图1
首先讲讲I/O请求报文。IRP是一个具有不完全文档说明的结构,由I/O管理器进行分配,用于在驱动程序之间传递特有的数据。对驱动程序分层时,它会注册到一个链中,如果向链接起来的驱动程序发出I/O请求,就创建一个IRP并将其传递给链中所有的驱动程序。链中最顶端的驱动程序最先接受IRP,链中最后一个驱动程序负责与硬件通信。I/O管理器准确知道链中驱动程序的数目。在分配的IRP中为链中每个驱动程序添加一个叫做IO_STACK_LOCATION的空间。IRP头部储存IO_STACK_LOCATION索引,也储存当前IO_STACK_LOCATION的一个指针,当我们调用IoCallDriver时,就会递减这个索引。具体IRP等的结构说明,请大家查看DDK吧,我就不罗嗦了。如图2所示。
javascript:dcs.images.doResizes(this,0,null); border=0>
图2
本文要讲的是一个键盘过滤驱动,它通过分层机制,捕获键盘的扫描码,转换成按键字符并保存下来。通过DeviceTree工具可以显示键盘驱动的结构,这里说一下,我们的程序要钩住的是KeybboardClass0,通过DeviceTree可以看到它的设备标记,这对以后我们写程序是有很大帮助的。如图3所示。
图3
这里解释一下,我们要动态卸载驱动程序,所以要挂接KeyboardClass0才可以,而不能挂接如图1所示的上层过滤器驱动。
键盘过滤驱动是工作在异步模式下的,这一点很重要。为了得到一个按键操作,首先需要发送一个IRP_MJ_READ到驱动的设备栈,驱动收到这个IRP会做什么样的处理呢?它会一直保持这个IRP为pending未确定状态,直到一个键被真正的按下,驱动此时就会立刻完成这个IRP,并将刚按下的键的相关数据作为该IRP的返回值。在该IRP带着对应的数据返回后,操作系统将这些值传递给对应的事件系统来处理,然后做什么呢?系统紧接着又会立刻发送一个IRP_MJ_READ请求,等待下次的按键操作,重复以上的步骤。也就是说,任何时候,设备栈底都会有一个键盘的IRP_MJ_READ请求处于pending未确定状态。这意味着只有在该IRP完成返回,新的IRP请求还未发送到的时候才会有一个很短暂的时间。由此我们想到,按照一般的方式动态御载键盘过滤驱动的时候,基本都是有IRP_MJ_READ请求处于pending未确定状态,而我们却卸载了驱动,以后按键的时候需要处理这个IRP却找不到对应的驱动,就会导致蓝屏。
栈底有IRP,为什么我们的驱动卸载就会有问题呢?这是由于IRM_MJ_READ是异步的,对于异步的请求,基本上我们会关心这个异步请求的结果。那么如何得到完成后的数据呢?大家一定想到了,设置完成例程。对,就是这样,由于我们给IRP_MJ_READ设置了完成例程,该IRP完成后会调用我们的完成例程,使我们有处理返回数据的机会。在这样的情况下,我们动态卸载了键盘过滤驱动,也就是说完成例程已经被我们卸载掉了,而以后的再次按键在完成这个IRP后会调用这个根本已经不存在了的东东,结果蓝屏就可想而知了。
那是否是不设置完成例程就不会有问题了呢?答案是肯定的。可是没有完成例程我们就没有办法处理到返回的数据,也就在很大程度上失去了键盘过滤驱动的作用了。如何做到既能设置完成例程来处理数据又可以实现动态卸载呢?
我们由有两个办法。当有IRP_MJ_READ到来的时候,不为这个IRP设置完成例程,也不将该IRP向下传递,而是创建一个自己的IRP,并参考前面的IRP_MJ_READ做对应的设置,然后为自己的这个IRP设置完成例程后,将自己的IRP向下传递,并设置原来的IRP_MJ_READ为pending状态。当有按键操作时,自己的IRP返回触发为它设置的完成例程,在这里取得返回的数据填充前面的IRP_MJ_READ后,将该IRP_MJ_READ完成返回。相当于我们使用了一个代理,而这一切都是透明的。到这里我们实现了完成例程,也就是有了处理数据的机会。
假设现在我们收到卸载的请求,看看当前所有的IRP处于何种状态。
(1)一个我们保存的原来的IRP_MJ_READ处于pending,注意它并没向下传递,也未设置完成例程。
(2)一个我们自己构造的IRP处于栈底,并注意我们为自己的这个IRP设置了完成例程。
基本上就这2个IRP。由于我们自己的IRP有完成例程,所以直接卸载会出现和上面一样的情况,导致蓝屏。如何处理呢?因为是我们自己构造的IRP,所以可以将其取消,这样并不会有太大的影响。然后将原来的这个IRP_MJ_READ向下传递,这里千万注意,我们自己的驱动马上要卸载,所以传递原来的IRP_MJ_READ的时候不要给它设置完成例程。向下传递后卸载我们的驱动。当然,这里还有更简单的办法,使用计数器也可以实现,还简单得多,所以我们这里使用计数器来实现。这里就不多罗嗦了,详细内容可看程序的说明。
写驱动程序要有一个入口DriverEntry,有人会问如何配置一个方便的驱动开发环境,我推荐是VC+DDK+DS,这也是它们安装的顺序。我习惯了VC++的开发环境,我想大多数人也是,所以即使用DDK开发也可以装个DS,呵呵。下面看程序,我只介绍几个重要例程,其他的请看随文的源代码。
具体的代码,大家请看随文的程序。这样我们就完成了一个键盘的过滤驱动,由于它处在较低的等级上,直接接受键盘扫描码,所以现行的键盘保护技术对其无效。由于不推荐大家做木马,所以就不介绍如何实现sys和exe文件的捆绑及通讯等技术了,但可以给大家一个思路,可以用一个exe文件或者驱动判断相应的程序是否启动,然后开启键盘记录功能,我想如果你看了我上一篇文章,实现应该也不难,下面是驱动截图的实现,如图4所示。如果本文章能给大家带来一些启示,我的目的就达到了,同时希望黑防越办越好。