6.6 稳定性处理

前面为读者介绍了编写内核代码的技巧与基本原则,本节将就如何妥善处理驱动稳定性问题进行探讨。

一般来说,处理驱动稳定性问题分为三个阶段:事前处理,事中处理以及事后处理。下面针对这三个阶段进行详细介绍。

6.6.1 事前处理

稳定性的事前处理主要是指驱动在发布前所做的一系列稳定性保证,正如本章前面所介绍的技巧,正确使用这些技巧可以避免一些低级错误以及一些隐藏的问题,提高程序的稳定性。但这些技巧还不足以确保驱动的整体稳定,一个成熟的稳定驱动,必须经过多个环节的严格测试。

常见的驱动测试分为逻辑测试、异常情况测试、压力测试以及兼容性测试。

逻辑测试主要是测试驱动的业务逻辑,通过逻辑测试可以发现驱动是否存在一些业务方面的缺陷。测试人员一般会事先设计好测试用例,这些测试用例与业务需求强结合,逻辑测试相对比较简单,只要确保驱动的业务逻辑没有问题即可。

异常情况测试主要是验证驱动在极端环境下或遇到异常数据输入时的表现。所谓极端环境,是指系统资源不足、IO异常繁忙、CPU无法调度、API无故返回错误等一系列情况。异常数据输入是指:驱动与应用层进程进行数据交互时,应用层传递一个错误或畸形的数据,如大小非法、格式不符合预期等。

常见的驱动蓝屏有一部分是由在极端环境下驱动没有妥善处理好逻辑所导致的,所以开发者务必测试驱动在极端环境下的表现。读者也许会问,如何模拟系统的极端环境?方法有两个,一个是自己编写程序,通过内核API挂钩(请参考本书第三篇)来模拟各种异常,另外一种方案是使用微软提供的驱动验证工具——Verifier。

开发者可以在系统中找到这个可执行程序,具体方法是按下键盘“WIN+R”,调用出系统的运行框,在运行框中输入:Verifier,然后按“回车”键,打开界面如图6-1所示。

选择“创建自定义”,点击“下一步”,进入配置界面,如图6-2所示。

开发者可以根据自身需要勾选需要检测的项目,然后点击“下一步”,根据提示选择需要检测的驱动。操作完成后,需要重启电脑,设置才会生效。在Verifier环境下,被检测的驱动会遇到各种资源申请失败、各种IRQL不同的情况。关于Verifier的具体说明,读者可以参考微软在线帮助文档。

图6-1 Verifier界面

图6-2 Verifier配置

下面介绍驱动的异常输入,前面章节介绍了驱动与应用程序的数据交互,请读者注意,驱动与应用层的数据交互是一个广义的概念,其方式与手段众多,除了前面介绍的DeviceIoControl方式,还可以以FltCreateCommunicationPort方式(这个通信方式基于文件MiniFilter过滤框架)实现通信,此外,通过文件、注册表、内存共享等方式也可以实现驱动与应用层之间的数据交互。不管通过何种方式,在通信过程中,驱动层与应用层之间必须约定好一种通信的数据格式,这个格式就是所谓的“通信协议”,正常情况下,这个协议都是符合预期的,但在异常情况下,如应用层数据被篡改、堆数据被覆盖、漏洞发掘者精心构造的攻击数据等,驱动收到这些异常数据后,如果没有对协议内数据进行严格的有效性判断,就很容易导致访问越界或蓝屏。

压力测试主要是指测试驱动在长时间或高强度下运行的情况。如果开发者开发的驱动只针对个人用户电脑,那可以简单认为这个驱动连续工作时间为一周;但如果开发者针对服务器开发驱动程序,由于服务器一般不会关机或重启(服务器一般只在维护的时候才会关机),所以在这种情况下,必须认为驱动可以在无限时间下稳定工作。

如何通过压力测试,在有限的时间内模拟驱动长时间工作?一个比较简单的做法是编写测试工具,测试工具在短时间内模拟相应的业务逻辑,如向驱动短时间发送大量数据,如果是过滤驱动,如文件过滤驱动,测试工具可以在短时间内进行大量的文件操作,这些操作会触发驱动相应的处理逻辑。举个例子,开发者开发了一个基于MiniFilter的文件过滤驱动(即微过滤驱动,可以参考本书第二篇),这个驱动的主要工作是收集程序对文件的操作信息,这些信息最终被用来审计,那么测试工具可以设计成一个多进程模型的架构,多个测试进程随机操作任何文件,如创建、删除、读写、重命名等,此外,文件操作的频率要高,如每个测试进程包含100个线程,每个线程1秒执行1000次文件操作,一共启动100个测试进程等。在压力测试前,测试人员应先记录系统的CPU、内存、句柄数等指标,在压力测试开始后,每隔一段时间,如3个小时,再次记录系统的相关指标,用于与前一次指标比较,判断驱动是否存在导致系统性能下降及内存泄露等一系列问题。

最后介绍兼容性测试,兼容性测试的主要目的是测试本驱动与同类驱动共存时的表现。在一些情况下,单一驱动存在时并没有异常,但同类产品或驱动同时存在时,就会发生兼容性问题。考虑这样一个场景,两家公司都开发了一套基于文件过滤微过滤的驱动程序(参阅本书第二篇文件系统微过滤一章的介绍),两个驱动分别为A和B;假设A驱动的过滤高度大于B驱动,那么每当发生相应的文件操作时,A驱动首先会捕获到文件操作,然后根据自身逻辑放行或拦截当前所捕获的文件操作。当这两个驱动同时存在时,对于B驱动来说,由于A驱动已经过滤(拦截)掉了一部分操作,所以B驱动获取到的文件操作信息是缺失的。如果B驱动的逻辑依赖一些文件行为序列的话,该逻辑也许会受到A驱动的逻辑影响,从而导致工作不正常,笔者建议开发者在开发过滤型驱动时,务必进行兼容性测试。

6.6.2 事中处理

稳定性的事中处理是指蓝屏或者兼容性问题发生时,为了保全大局,临时关闭驱动以恢复系统正常工作以及提取必要的故障信息的过程。

一般来说,一套成熟的商用驱动的稳定性必定经历一个坎坷的过程,这些稳定性主要包括:驱动程序自身的缺陷、驱动程序与第三方同类驱动共存时的冲突、操作系统升级后导致驱动程序某个逻辑不可用等。对于不同开发者来说,这个过程的时间可能不同,如对于经验丰富的驱动开发者来说,这个过程也许是一个月到两个月,而对于新手来说,这个过程也许需要半年。正是基于上面的考虑,一些公司喜欢招聘经验丰富的开发者,以尽快稳定产品。这是一个不错的做法,但是经验丰富的开发者偶尔也会失误导致驱动程序出错,因为人为的出错是不避免的,所以还必须要具备一种机制,在驱动出现错误的时候,紧急关闭驱动出错的逻辑,恢复用户的业务,这个过程被称为“止损”。

下面以C/S架构为例,为读者介绍如何在设计阶段引入驱动的“可止损”功能。在驱动程序设计阶段,设计人员一般会对驱动的逻辑进行模块划分,以常见杀毒软件的主动防御模块为例,在逻辑层面,主动防御模块一般会分为文件监控、注册表监控、进程监控、线程监控、注入检测等等。每一个模块都需要设置一个“开关”,所谓的“开关”,其实是后台服务器的一组标志,这些标志表示客户端驱动的某一个功能或模块的开启情况。为了让读者清楚明白这个“开关”的工作过程,下面举一个简单例子:Safe.sys驱动为主动防御驱动,里面包含了一系列逻辑模块,其中一个是文件监控模块,在后台服务器的数据库中,有一个字段用于表示这个功能的开启情况,0表示关闭,1表示开启。在用户电脑开机后将经历如下过程。

(1)Safe.sys驱动自动加载到操作系统中。

(2)Safe.sys的文件监控模块在默认情况下处于关闭状态。

(3)假设Safe.exe为驱动的用户态控制程序(常见的软件都会分为内核模块以及用户态模块,请参考本书第4章)。

(4)Safe.exe程序连接后台的服务器,并保持定时的数据包心跳。这个心跳可以是TCP,也可以是UDP;心跳作用有两点,一个是服务器可以通过心跳信息统计在线的用户数,其次是心跳数据包中可以附带一些简单的控制信息,如上面提到的“开关”信息。

(5)Safe.exe通过心跳信息获取到文件监控模块的开关信息,假设当前状态为1表示开启。

(6)Safe.exe与Safe.sys驱动通信,通知驱动启动文件监控模块(具体的通信方式,请参考本书第4章)。

上面是一个完整的“开关”工作流程。聪明的读者也许看出了一些问题:如果对于一款安全软件来说,在开关拉取下来之前文件监控模块处于关闭状态,这段时间是否会有安全风险?恶意软件是否会利用这个空隙来入侵系统呢?

答案是肯定的,请读者记住,上述的操作只是一个临时的过程,其目的在于驱动没有完全稳定前,一旦发生问题,可以及时止损。

一旦驱动稳定后,如驱动连续大规模使用三个月后没有问题,上面的步骤(2)可以调整为:Safe.sys的文件监控模块在默认情况下处于开启状态。

关于用户态程序与驱动通信的方法,请读者参阅本书第4章中的内容。

除了上面介绍的“开关”方法,驱动“止损”的方法还有很多,比如驱动开发者可以注册一套“系统关机回调”,注册“系统关机回调”的API如下:

该API参数比较简单,只有一个设备对象指针,IoRegisterShutdownNotification成功注册后,当系统进入关机或者重启逻辑时,系统会向DeviceObject设备对象发送IRP_MJ_SHUTDOWN请求,开发者可以在这个请求中进行一些关机或重启前的逻辑处理。为何通过关机消息,可以在一定程度实现驱动“止损”?原因是:当驱动发生蓝屏时,系统并不会触发IRP_MJ_SHUTDOWN,一旦系统触发了IRP_MJ_SHUTDOWN,开发者可以认为系统本次是正常关机或重启。开发者可以在本次接收到IRP_MJ_SHUTDOWN时,往注册表或文件中写入一个用于表明当前成功关机或重启的信息,在下次系统启动时,Safe.sys驱动加载后,检查相应的注册表或文件是否被记录了“成功关机或重启”信息,如果是,则表明上次系统没有蓝屏,本次驱动可以继续正常加载逻辑,否则表明上次系统异常关机或重启,可能是发生了蓝屏,也可能是系统异常断电,在这种情况下,Safe.sys驱动程序可以暂时不工作,应用程序可以检查系统目录是否有DUMP文件生成来进一步确认系统是否蓝屏,关于DUMP文件的介绍,用户可以阅读本书第3章。

这种方案的缺点是误判率太高,因为任何一个驱动异常都会导致系统蓝屏,因此没有办法确认是否为自身驱动导致的系统蓝屏。但是对于一款过百万级的驱动程序来说,这个方案是有效的,因为可以通过此方法提前对驱动的稳定性进行预警,比如说,在升级一个驱动模块后,短时间内有大量的用户出现了没有“成功关机或重启”的情况,又或者说,升级一个驱动模块后,没有“成功关机或重启”的数量远超过了升级前的数量,在这种情况下,极有可能是升级的驱动出了问题。在企业内部,一般会建立这种预警机制,一旦收到预警信息,可以马上进行人工甄别,一旦确认是自身驱动导致的,即可临时关闭驱动。

此外,参考系统的做法,一些厂商为驱动引入了“安全模式”的概念。所谓“安全模式”,是指通过上面的方法,判断出系统连续出现N次无法“成功关机或重启”时,驱动程序自动不加载任何逻辑,或者自动调整为“手动”方式加载,甚至不加载。这个做法的目的是避免一些随系统启动的驱动在启动过程中不断蓝屏,导致用户无法进入系统的状况。在实际的场景中,这个策略会很复杂,如上面提到的N次无法“成功关机或重启”,这里的N是一个变值,在不同时期可能不同。另外,一旦驱动关闭后,如果应用层程序发现系统依然不断产生DUMP文件,则说明蓝屏与自身驱动无关,则自身驱动会被重新唤醒,整套机制是一个自我判断、自我检测、自我修复的过程。请读者务必记住,策略是灵活的,针对不同的场景、不同的应用,策略也应该不同。

“系统关机回调”是上述方案的主要技术点,下面为读者详细介绍该技术点的细节,请读者注意,每个驱动程序都可以注册一个“系统关机回调”,当系统关机或重启时,操作系统会按顺序为每一个注册“系统关机回调”的设备对象发送IRP_MJ_SHUTDOWN,当一个设备对应的驱动处理完IRP_MJ_SHUTDOWN后,系统才会为下一个设备对象发送IRP_MJ_SHUTDOWN,这个过程是串行的,当所有设备都处理完成后,系统关机或重启流程才会继续执行。如果一个驱动已经注册了“系统关机回调”,当这个驱动需要卸载时,必须调用IoUnregisterShutdownNotification函数来移除“系统关机回调”,IoUnregisterShutdownNotification函数的原型如下:

这个函数的参数只有一个,就是调用IoRegisterShutdownNotification函数时所指定的设备对象指针。

下面通过一个代码例子,为读者展示IoRegisterShutdownNotification以及IoUnregister ShutdownNotification的基本用法:

IoRegisterShutdownNotification函数的用法很简单,这里留一个作业给读者:在DispatchShutdown函数中把成功关机或重启的信息记录到注册表中。关于注册表的操作,请参考本书第3章中的内容。

6.6.3 事后处理

最后为读者介绍稳定性的事后处理,事后处理更多是分析驱动的具体缺陷原因,修改或规避掉相应的问题,重新发布新的驱动。

常见的驱动问题是蓝屏,对于蓝屏来说,一般的做法是收集系统生成的DUMP文件,然后对该文件进行自动化或人工分析。

以笔者的经验来看,蓝屏问题相对容易解决,而驱动与其他软件冲突导致其他软件逻辑不正常的问题往往更难定位。此类问题的重点是复现现场,然后动态调试以确定问题。

分析系统DUMP文件属于另外一个范畴的技术,该技术并不属于本书的介绍重点,有兴趣的读者请自行查阅其他资料进行学习。

本章为读者介绍了编写内核驱动的一些典型的技巧与注意事项,请读者务必掌握。

由于篇幅所限,本章不可能把内核编程的注意事项全部列举出来,读者在实际开发过程中肯定会遇到其他问题与困难,这些问题与困难需要读者逐一去克服和解决,这是内核开发有趣的地方,也是初学者成长的必经之路。