补丁更新服务问题
【编者按:这是国外高手所写的一篇认为卡巴斯基在其杀毒软件里使用了许多不安全技术的文章,我们将分期将其一一展现给大家。(原文发表于2006年6月)】
卡巴斯基发布了称为卡巴斯基互联网安全套件5.0的个人安全软件包。该软件包里含有很多个人安全软件程序,包括防火墙和杀毒软件。
我们这篇文章的核心就是要谈谈卡巴斯基的杀毒软件。跟很多其他的杀毒软件一样,卡巴斯基的杀毒软件也是既能手动操作扫描病毒也可以实时扫描病毒。
卡巴斯基的杀毒软件系统(KAV)在其内核层组件中使用了很多不安全的技术。这些技术中的一部分甚至有可能会导致系统安全遭到威胁。
问题所在
实时的补丁更新服务
尽管KAV看起来好像是使用文件过滤系统——这是一种标准的拦截文件存取操作的Windows机制(尤其像是为杀毒软件而设的),KAV还使用了一系列API层的函数钩子来拦截对文件的存取。如果某个函数在用户层下可以调用的话,那么执行那些内核层的钩子函数会非常危险,人们必须十分谨慎地验证所有的参数的可靠性(否则系统可能会受到未授权的程序的威胁)。另外,通常删除跟内核层相关联的代码也将面临很大危险,因为你很难保证没有线程在某个特定代码区域运行,也很难做到不对系统造成任何威胁就能解开钩子。KAV还跟很多其他的系统服务相关联,这是它所谓的保护进程不被反编译或者终止而进行的措施。
不幸的是,KAV程序员们并没有正确的验证那些与系统调用相关联的参数,因此导致在此处出现了很多的漏洞,最终这些漏洞使非授权的用户层程序能够造成系统的崩溃。这些漏洞中的一部分甚至允许人们扩大本地权限(尽管笔者并没有花时间来验证这点是否可行)。
KAV与下列系统服务相关联(在WinDbg中,把一个运行着KAV的系统里的 nt!KeServiceDescriptorTableShadow 跟一个干净系统里的相比较,你很容易就能发现这些关联)。
|
另外,KAV还试图建立很多全新的系统服务,它通过更新服务描述表的方法,把这些服务变成调用系统内核模块的捷径。这显然不是允许用户模块程序与驱动程序交互的最佳机制;程序人员应当使用传统的IOCTL接口,该接口不用实时地更新内核结构,并且可以避免很多不便,比如从一个系统升级成另一个系统的正常的系统服务更新。
用户层指针验证不足
用户层指针验证不足
KAV安装的很多关联(甚至还包括很多定制的系统服务)都不断出现对系统来说很致命的漏洞。例如,KAV修改过的NTOpenProcess试图通过与固定值0x7fff0000相对照来判定用户地址是否合法。在大多数x86系统上,这个地址是低于最高用户地址的(即0x7FFEFFFF)。但是,把用户地址空间固定这种做法并不明智。例如,有个可以在boot.ini文件中进行设置的boot参数"/3GB",能改变默认的地址空间分配方式,即将2GB内核、2GB用户空间修改成1GB内核、3GB用户空间。如果运行KAV的系统被设置了/3GB,那么只要参数地址是位于用户地址空间的前2GB里,任何对NtOpenProcess的调用(比如Win32的OpenProcess)都会随机失败。
|
比较合适的、进行此项验证的方法应该是:使用带有SEH框架的ProbeForRead存档函数。这种功能在地址是非合法用户的地址的时候,会自动阻止其访问。
另外,很多KAV的定制系统服务都没有好好地验证用户层的指针自变量,而黑客们正可以利用这些自变量来使系统崩溃。
|
隐藏用户的线程
隐藏用户层的线程
然而,KAV的关联错误还不仅仅止于NtOpenProcess。KAV关联的另一个系统服务是NtQuerySystemInformation。这个例行程序被修改成当SystemProcessesAndThreads信息类被调用的时候就从某些进程中截断线程列表。这是一种潜在的机制,用户层可以接受进程以及所有运行在系统中的程序的线程列表,这就提供给KAV一种隐藏用户层线程的手段。KAV中存在这样一段代码——这件事本身就是不可思议的;在用户层隐藏正在运行着的代码这种行为是跟rootkits相联系的,这不是杀毒软件应该有的功能。
撇去隐藏运行代码这个潜在的威胁不谈,它还存在很多安全漏洞:
它使用用户层的NtQuerySystemInformation输出缓存——实际上内核层已经占用了这部分,但它不能防御恶意用户层的程序对该缓存的修改或释放。该项函数没有SEH框架,因此用户层的应用有可能导致KAV对空的内存进行操作。
这其中没有对返回的输出缓存的偏移地址进行验证,也就不能确定偏移地址是不是指向输出缓存之外的内存。显然这会造成问题,因为返回的数据结构实际上是子结构的一个列表,加一个偏移地址相当于给那个结构的地址加上了一个特殊构架,以便访问下一个结构。用户层可以修改这个偏移地址,让它实际上指向内核内存。由于钩子函数有时会把数据写入到它认为的用户输出缓存,那么黑客就有了一个有趣的、可以用来从非授权的用户层获得内核权限的方法。
|
内核对象类型不恰当验证
Windows一系列的“内核对象”暴露了内核的很多特性。这些对象有可能是由用户层通过句柄用户来执行的。句柄是整数值,内核会判断作为调用者进行交互的是哪个(通常是系统服务),然后将句柄解析成某个特定对象的指针。所有的对象都共享相同的句柄命名空间。
由于这些不同类型的对象共享着相同的命名空间,系统服务检查句柄的一项任务就是验证这些对象指向的是期望类型。这项工作是由对象管理例行程序bReferenceObjectByHandle完成的,这个例行程序会将句柄解析成对象指针并进行可选的嵌入类型检查,它是通过把标准对象页头中的类型域与需要验证的进行对照来完成检查的。
由于KAV关联着系统服务,它不可避免地需要处理内核句柄。不幸的是,它并没有正确地进行此类操作。在一些情况下,在使用对象指针前,KAV并不能确定指向某类型对象的句柄。如果错误类型的句柄被传递给了系统服务,那么系统就有可能崩溃。
典型的例子就是KAV的NtResumeThread关联,该关联试图跟踪系统中正在运行的线程的状态。在这个特例中,看起来用户层好像无法通过把错误类型的对象作为返回对象指针而导致系统崩溃。这主要是因为它仅仅是被用作查找表的钥匙,该列表由线程对象指针预先占用了。KAV跟NtSuspendThread相关联也是基于同样的目的,并且这项关联也在验证对象的句柄类型上存在问题。
|
但是,并不是所有的KAV关联都这么幸运。KAV安装的NtTerminateProcess钩子会查看函数的进程句柄参数所指向的对象实体,这样就能确定要终止的进程的名称。但是KAV没有验证用户层提供的对象句柄是否是真的指向一个进程对象。
由于种种原因,这样做很不安全,如果读者曾经进行过内核编程你就会深刻体会为什么这样不安全。
内核进程结构定义(EPROCESS)随着OS更换甚至是Servicepack的更换而频繁地改变。这样造成的结果就是,直接访问这样的结构通常都很不安全。
由于KAV不适当地执行类型核实操作,因此很有可能将一个对象句柄传递给不同的内核对象——比如,mutex——于是因为mutex的内部结构(或者任何其它的内核对象)与进程对象的内部结构不兼容,KAV将导致系统崩溃。
对于上一个问题,KAV想用如下方法应对:它试图实时地找出包含进程名称的EPROCESS结构成员的偏移地址。它所使用的算法是,每次从进程对象指针的开始提前扫描一字节,直到它发现用来识别初始系统进程名字的那个字节串。(这个例行程序在初始系统进程环境中被调用)。与杀毒软件、还有其他利用与进程关联的像文件名称的那些低端产品比起来,这种例行程序似乎很普通。
|
有了类型错误的那些对象的句柄后,KAV就可以通过读取返回的对象body指针,得到被破坏的进程名字。如果一个对象不是进程对象的话,这种方法是无法应付到对象结构末尾的(与一些对象比起来,进程对象非常巨大,比如Mutex对象,并且结构中的对象名称的偏移地址通常是几百字节或者更多)。可以预见,如果错误的句柄被传递给NtTerminateProcess的话,它将会造成系统的崩溃。
|
该系统服务钩子的目的也是“不可靠的”。该钩子函数的功能是保证某些KAV进程不会被终止,即使是合法的系统管理员也无法终止KAV进程——这种功能更像是恶意软件,比如Rootkits,而不是商用软件应该有的功能。对此的合理解释可能是,它想要阻止病毒删除扫描病毒的进程,尽管如此,我们还是想知道如果KAV实时扫描机制真的如广告所说的那么有用话,那么这种情况发生的可能性究竟有多高?
另外,KAV似乎只在进程与系统服务的关联终止前才跟踪其状态。而恰当的方法应当是通过PsSetCreateProcessNotifyRoutine这个有记录的内核功能来进行,该功能允许驱动程序注册那些进程创建和进程退出时被调用的callback函数。
为非输出的、非系统服务的内核函数打补丁
为非输出的、非系统服务的内核函数打补丁
然而,KAV的内核补丁程序不仅限于系统服务。KAV安装的最危险的钩子函数就是nt!SwapContext功能中的一个,该钩子不是输出的也不是系统服务(因此没有可靠的驱动代码检测机制,更不用说验证代码指纹了)。为了执行一些内部记录任务,内核在每个相关的变更时调用nt!SwapContext。
因为如此关键,而又是非输出、差不多没有代码指纹的、不可靠机制的内核功能打补丁,就我看来,这并不是什么好主意。更糟的是,KAV实际上还修改nt!SwapContext中的代码,而不是从函数的开始处打补丁,并且对使用该内核函数的内部注册机以及堆栈也好像是这么操作的。
|
这是非常危险的一种打包操作,原因有如下几点:
nt!SwapContext是非常热门的代码路径,每次环境变更时都会调用它。这样一来,很难做到实时给它打补丁,并且不冒使系统崩溃的风险。KAV在单核系统上解决此类与给此函数打补丁相关的同步问题的方法是:完全关闭中断,但是,在多核系统上,这个方法并不总是有效。KAV在多核系统上并没有去努力解决这个问题,它使这些系统面临在KAV打补丁的时候会随机地出现启动失败的风险。
准确地找到该函数的位置,并找到所有已发布的和将来出现的系统版本的注册机和堆栈用法(以及指令布局),这些都是不可能操作完成的,而KAV却恰恰试图做这样的事情。这使得KAV用户不得不担心新的系统更新,由于KAV的钩子代码所做的假设与环境变更的进程不相容,那些更新就有可能使他们的系统启动失败。
另外,为了在内核上执行代码补丁,KAV调整了内核代码的页保护设置,使其可以通过直接修改PTE属性而不是使用文档函数(该函数可以锁住访问内部内存管理结构的语义逻辑)变得可以写入。
|
允许用户层代码访问内核内存
允许用户层代码访问内核内存
当前操作系统所使用的划分内核/用户的主要原则是:不允许用户层直接访问内核层的内存。这对维持系统稳定十分重要,比如它可以阻止有bug的用户层程序造成内核崩溃,甚至使整个系统崩溃。不幸的是,KAV的程序员们似乎认为这个原则根本不重要。
KAV所执行的不安全操作里最奇怪的地方就在于:它允许用户层直接调用他们内核驱动的一部分(在内核地址空间内!),而不是只加载用户层DLL(或者在目标进程中加载用户层代码)。
该机制似乎是用来在加载DLL时,调查这些DLL的——这种工作本来最好应当由PsSetLoadImageNotifyRoutine来完成。
创建新进程后,KAV给Kernel32.dll打上补丁,这样输出表就会指向所有的DLL——往调用内核层kav的驱动部分的thunk中加载例行程序(例如LoadLibraryA)。另外,KAV还修改它的代码的部分保护以及数据段,以便允许用户层进行读操作。
KAV设置了PsLoadImageNotifyRoutine关联来检测kernel32.dll的加载,以便确定什么时候给kernel32的输出表打补丁。笔者想知道为什么KAV不只在PsLoadImageNotifyRoutine中直接工作,而是费尽千辛万苦地通过允许用户层来调用内核层来进行LoadLibrary关联。
在新进程加载图片的时候,KAV会调用CheckInjectCodeForNewProcess函数,并且核查 kernel32是否被加载。如果Kernel32已经加载,那么它将安排APC队列给要执行补丁程序的进程。
|
APC例行程序本身给kernel32的输出表打了补丁(并且生成thunk来调用内核层)并调整KAV的驱动镜像的PTE属性,以便允许用户层访问。
|
真正的镜像补丁会保护 kernel32的输出表,它将输出地址表的入口改成LoadLibrary* 函数族,指向thunk,该thunk被写进Kernel32 镜像的空闲空间,并将真正的thunk代码写出:
|
KAV的输出表保护代码做了这样一个假设:用户层的PE页头是形式正确的并且不包含指向内核层地址的偏移地址:
|
KAV用来保护用户层代码的机制也是一种黑客机制。KAV动态地判定系统调用NtProtectVirtualMemory系统服务的序数,然后用它自己的int 2e thunk来调用该服务。
|
KAV的输出查询代码没有正确地验证就使用从PE页头储存的那些偏移地址:
|
未经过 ring 0 转换就在用户模式下直接调用 KAV 核心代码:
|
当在用户调用模式下逐步转换它的核心模式代码时KAV便在开始破坏系统了 (毕竟很明显这是不可靠的!):
|
解决方案
解决方案
KAV的杀毒软件依靠诸多不安全内核层的黑客程序,从而将系统稳定性置于危险中。想要解决这个问题,首先KAV需要去掉不安全的内核层黑客程序,比如给非输出函数打补丁或者不加验证地关联系统服务等等。
KAV使用钩子函数或者其他不安全措施的那些操作也可以通过有记录的并且安全的API以及例行程序来实现,这些都是在Windows 设备驱动工具组(DDK)和可安装文件系统工具组(IFS kit)中详细说明过的。KAV的程序员有必要花时间理解关于如何使用有记录的方法在系统内核进行操作,而不是用字面上的hack-and-slash的方法,致使系统有崩溃或者甚至扩大特权范围的危险。
KAV所倚仗的很多不安全操作都被x64的补丁防御功能拦截了,这使得KAV更难发布针对64位系统的杀毒软件了(由于计算机开始支持x64,有的默认的就是x64的操作系统,于是X64版的杀毒软件也变得尤为重要)。32位的内核驱动无法在64位的系统中使用,因此KAV需要将它的驱动移植到x64上并且解决补丁防御功能的问题。另外,终端用户使用的是单核计算机的假设很快就会不成立了,现在很多系统都支持超线程或者多核。
【参考资料】
网友评论