标题 简介 类型 公开时间
关联规则 关联知识 关联工具 关联文档 关联抓包
参考1(官网)
参考2
参考3
详情
[SAFE-ID: JIWO-2024-2811]   作者: 大猪 发表于: [2021-01-08]

本文共 [650] 位读者顶过

1句柄和句柄泄露

在Windows编程过程中,很多时候我们都要和句柄打交道,比如窗体句柄,内核对象句柄,GDI句柄,Windows Multimedia库中的多种句柄等等,以及其他更多未曾使用过的句柄类型。句柄(Handle)是Windows系统下特有的一种数据类型,其本质定义是基本数据类型PVOID,为什么定义为PVOID呢?因为他的数据长度跟处理器的位数有关,在32位CPU下句柄可以用一个32位无符号整数来表示,同理在64位CPU下就用64位的无符号整数来表示。句柄值所代表的意义是句柄表中的一个项的索引,并且这个索引并不都是通常的按照1来递增的索引,而是4。 [出自:jiwo.org]

句柄的存在意义离不开对象(Object)的概念,是处理对象的一个接口,对于所涉及的对象,可以通过相应的句柄来操作它。句柄的引入主要是操作系统为了避免应用程序直接对某个对象的数据结构进行操作为目的,用操作句柄来代替操作对象。句柄和对对象是多对一的关系,即一个有效句柄一定可以映射到一个有效对象,一个有效对象可以被多个句柄同时映射。

根据上述介绍,可以用一个简单的图1.1来表述一下句柄和对象的关系:

图1.1 句柄值、句柄表和对象之间的的关系

这只是一个最简单的句柄对象关联模型,实际中的句柄表要比这里的复很多,比如Windows内核对象的句柄表就是一张三级表,并且其表项的索引值是按照4进行递增的,但是其实现的原理最终还是离不开上图所表示的基本关联方法。需要注意的是,句柄值仅仅只是一个表中的索引值,并不是一个内存地址,无论这个值是按照什么数量递增的,操作系统都会在处理句柄值的时候,根据句柄值转换成其对应的句柄表中的项的自然数索引,然后根据句柄表首地址和项偏移算出该项的内存地址,从而访问该句柄表中的项,然后进一步访问该项所关联的对象体。

句柄的最基本操作就是打开(创建)和关闭,需要用到某一个对象的时候就去打开或者创建这个对象,然后就可以得到一个与此对象关联的句柄,后续需要处理对象的时候只需要将句柄交给某一功能函数,系统负责查找该句柄映射到的对象,并处理之,在处理完毕之后,如果在一定时间内不需要对这个对象有任何操作,就需要把该句柄关闭。在这里句柄带来的一个问题就浮出水面了:如果打开了某一个对象的句柄之后,在一段时间内程序确实不需要使用这个对象了,但是却由于疏忽而忘记去关闭这个句柄,而当程序下一次进入相同的功能逻辑中时又再次打开同一个对象的新句柄,就引起了句柄泄漏。

2句柄泄漏的影响

在文章开始的地方已经说了几种句柄类型,虽然他们都叫做“句柄”,但是他们的实现原理、管理方法以及句柄到对象的影射方法却是不完全相同的,唯一相同的地方即句柄都是通过“表”来管理。

  • 窗口句柄:句柄表全局共享,句柄表和对象均由内核维护
  • 内核对象句柄:每个进程有一份private的句柄表,句柄表和对象的管理由内核维护,句柄表不全局共享,内核对象全局共享
  • Windows Multimedia库中的设备句柄则是在进程的用户态内存空间(具体到堆)中管理维护
  • GDI对象句柄是在进程的用户态内存空间中管理维护

根据不同种类句柄的实现和管理方法的不同,句柄泄漏带来的副作用也不相同,以上述三种不同的句柄种类来说明:

  • 窗口句柄泄漏问题比较严重而且直接,因为系统全局共享,如果句柄表被打满,新无法创建新窗口
  • 内核对象句柄泄漏不一定会导致进程用户态内存空间的内存泄漏,因为句柄和句柄所关联的对象均在内核态中存在,不会对用户态内存产生增量影响。但是由于句柄表的容量不是无限的,所以当泄漏数量超过了句柄表的容量,该进程就会出现莫名其妙的行为了,比如悄无声息的退出了
  • Windows Multimedia库中的设备句柄泄漏一定会导致内存泄漏,因为其句柄表在用户态内存中,所以每次泄漏一个句柄至少会增长句柄表中一个项的大小的内存。
  • GDI对象的句柄表在用户态内存中,并且关联的对象也存在与用户态内存中,所以GDI句柄泄漏也会导致内存泄漏,并且当GDI句柄数量超过GDI对象句柄表的数量时,进程的界面就会出现绘制混乱等现象

句柄泄漏可能不会对程序的功能造成实时的影响,但是随着泄漏数量的增加,程序的性能在运行过程中会逐渐受到影响,当泄漏最终超过一定的阈值,程序就可能Crash从而影响了程序的正常功能。

3句柄泄漏的检测方法

和内存泄漏一样,句柄泄露也无法做到在编译过程中通过词法语法等分析来提前告警,因为代码是静态的,程序是动态的,即使是同一段代码,由于执行流程的改变就可能是泄漏和不泄露两种情况。正因为如此,句柄泄漏也成为了程序开发过程中一个比较常见的问题,特别是在中等和大型规模的软件开发项目中。在单一模块内,可能一个开发者会因为疏忽而忘记关闭一个打开的句柄,导致句柄泄漏的发生,不过这种情况是少数的。另一种情况是经验丰富的开发者在单一模块内解决了本模块内可能出现的句柄泄漏,但是上层模块在使用该模块的时候没有做充足的释放清理工作,这也就导致了句柄泄漏,同时还可能伴有其他资源的泄漏。对于一个追求高性能和稳定性的项目团队来说,资源泄漏这种问题绝对不应该出现在团队所开发维护的产品中,虽然不能百分之百的从编码阶段规避这些问题,但至少需要在运行期间有相应的检测处理机制能发现和定位泄漏的源头,从而可以快速的修改编码、解决问题。

目前常用的检测句柄泄漏的方法就是使用WinDbg中的提供的一个扩展命令:!htrace。该命令可以检测出程序在运行过程中发生泄漏的句柄,并且可以通过堆栈定位到句柄泄漏的位置,但是WinDbg是一个功能丰富的综合调试工具,对于需求单一的应用场景—比如只需要检测句柄泄漏—WinDbg使用起来就显得杀鸡用牛刀了。其不便之处在于:命令行式的操作和展示,不直观;每次检测都要去启动或者附加到目标进程;加载符号文件的过程耗时较多;不能存储检测到的详细数据等。这种方法没有带来工作效率的提高,所以我们的团队打造了自己的句柄泄漏检测工具HandleSpy。

4HandleSpy检测句柄泄漏的原理

HandleSpy是一个基于统计方法来查找句柄泄漏的工具,其设计的目标是针对可在用户程序内访问和操作的内核对象句柄泄漏的检测。所谓内核对象就是对象体存在于Windows系统内核中,并由系统内核维护的一些对象,这样设计的目的一方面是保证系统的安全和稳定性,让用户应用程序避免直接操作关乎系统底层的重要数据,另一方面兼顾了对象的共享性,在内核中维护的对象可以让系统内的所有进程访问,并且能为各个进程分配其所需的有限的访问权限。常见的内核对象比如:Process,Thread,Event,Semaphore,Mutex,File,FileMapping,Key等,但是内核对象的类型并不是只有这么多,这些常见的只是一小部分,还有更多的内核对象类型存在,并且随着Windows系统的更新换代,内核对象的种类也在不断的增加,可以使用工具WinObj来查看当前系统内的内核对象的种类,如图4.1所示:

图4.1 查看当前操作系统版本的所有内核对象类型

上图是在Windows 7 SP1(NT 6.1.7601)系统中截取,约有50种内核对象,而所有这些内核对象都会涉及到句柄,无论是在内核态还是用户态。

HandleSpy的工作原理是在检测过程中不断对目标进程的句柄数量进行记录并且输出线条图表,这样就可以直观的反映出进程的句柄数量变化情况,便于选择目标时间段进行分析。在记录句柄数量的同时HandleSpy还会把目标进程内感兴趣的句柄类型的操作数据记录下来,并且打上时间戳。在一次检测完成之后,HadleSpy会把句柄数量线条图表和句柄操作数据通过时间这一关联值进行汇总,在用户选定一个时间段区间之后HandleSpy会根据句柄值对句柄操作进行匹配过滤,最后过滤出在这个选定时间段内打开或者创建,但是没有被关闭的句柄操作和相应的句柄值,根据其句柄操作的堆栈信息结合符号文件就可以定位到程序的源代码中的指定行。

在这里给出一个应用实例,用HandleSpy来试试检测Chrome浏览器的句柄资源泄漏情况。打开Chrome之后先打开一任意一个页面,然后我们来检测一下Chrome在进行打开新的标签页然后关闭这样的操作的时候,是否会产生句柄资源的泄漏。详细操作步骤如下:

1. 打开Chrome,然后打开一个页面,在这里使用了12306的网站,然后启动HandleSpy对Chrome进程进行检测,如图4.2所示结果:

图4.2 没有打开新的标签页时Chrome进程的句柄数量

2. 打开一个新的标签页,观察句柄数量的变化,如图4.3所示:

图4.3 打开一个新的标签页时Chrome进程的句柄数量

3. 等待句柄数量稳定之后关闭刚才新打开的标签页,句柄数量变化如图4.4所示:

图4.4 关闭新打开的标签页时Chrome进程的句柄数量

4. 停止检测,选择一个时间区间然分析句柄泄漏情况,如图4.5所示:

图4.5 通过工具进行泄漏检测得出的泄漏结果

这里可以看到未操作之前句柄数是893,进行操作之后句柄数是899,通过数量可以得出句柄泄漏数为6个,但是这里只显示出来了5个,有一个未显示出来,因为它是我们不关心的句柄类型,并不是由应用程序的代码引出的(实际上这是Windows 7系统的一个ALPC Port类型的句柄,系统会负责释放,只不过是延迟释放的)。还有一点要说明的情况是,如果检测时间在稍微长一点,那么上图中2~5号句柄也会被关闭,从而不会显示出来,因为在举例的时候检测时间比较短,而上述四个句柄在检测时间段内没有被关闭,从某种意义上来说也是一种泄漏,因为它们在一段时间内打开但是没有关闭。关于Chrome的句柄泄漏的实例就到这里,其中第一个CreateMutexW操作可以肯定的说是Chrome的一个句柄泄漏,因为经过多次检测发现这个句柄在每次打开新的标签页的时候都会创建,并且确实没有释放,由于没有Chrome的符号文件,所以在这里无法看到具体的源文件,函数和行号。

5HandleSpy的实现方法详解

5.1句柄数量的“实时”检测

对于目标进程我们需要获取检测过程中其所持有的内核对象的句柄数量的变化情况,这样才可以分辨出在一个时间段内是否发生了残留的未关闭句柄。完成这一功能的方法有多种:

l 使用性能计数器收集进程句柄的数:此方法是通过Windows系统提供的性能计数器组件来添加一个代表目标进程的句柄数量的计数器,然后读取计数器的值得到句柄数。这种方法的缺点是只能根据进程名称指定计数器,当存在多个同名进程时,无法区分单个进程,所以并不适用于HandleSpy。

l 使用未公开的API:ZwQueryInformationProcess获取目标进程句柄数:这个函数是Windows系统的未公开API,存在于Ntdll.dll模块中,该函数功能非常多,其中有一项即可获取目标进程的内的句柄数。

l 使用现有的API:GetProcessHandleCount获取目标进程句柄数:这个函数其实是上述ZwQueryInformationProcess函数的一个封装导出并且已经形成文档,在SDK中可以直接使用。唯一的缺点是只在Windows XP SP1之后系统中才可使用,HandleSpy目前采用的就是这种获取句柄数的方法。

有了获取句柄数的方法之后就需要考虑如何持续的检测进程的句柄数变化情况,理想的情况是当进程的句柄数发生变化时进行采集,这样就可以保证句柄数的每次变化都可以被记录下来以达到实时的目的,但是要达到这个目标的话HandleSpy的部分功能就要放在驱动中去实现了。在内核中截获系统对进程的句柄表操作的关键位置,当操作的句柄表属于目标进程时就记录下新的句柄数,对于一个轻量的应用工具来说使用这种技术来完成这个功能成本相对就高了,所以HandleSpy并没有使用这种方法,而是用了定时查询的方法。

定时查询的方法就是设置一个周期性的定时器,在计时到期时记录目标进程的句柄数。比如设置定时器为1秒,每隔1秒得到一个句柄数,然后将所有数据用线条图表绘制出来,就是一个句柄数量变化的波形图。很容易想到,这种方法与“实时”概念差别很大,因为在1秒的时间间隔内并没有对句柄数进行计数,而在这1秒内一个进程的句柄数量可能发生多次各种幅度的变化,那么这会影响到对泄漏句柄的计数么?其实不会,因为在1秒的计数空白时间内如果发生了泄漏那么泄漏的句柄数肯定会在下次获取句柄数量时被记录,而如果没有发生句柄泄漏,那在这1秒内的句柄数量波动情况就可以忽略,所以使用定时查询的方法是较为合适的。

5.2句柄操作的截获

句柄数量只是表象,而句柄操作才是关乎句柄泄漏的重要数据,因为最终筛选泄漏项的时候是根据句柄操作和句柄值来进行过滤的。获取句柄操作的基本思路是,对句柄进行分类,然后把需要检测的句柄类型的所有相关操作都截获,包括创建(打开)和关闭,比如一个目标进程打开或者创建了一个句柄0x0000000C,HandleSpy会记录下这个操作函数的堆栈和句柄值,然后目标进程又对这个句柄值0x0000000C进行了关闭操作,HandleSpy同样记录下了这个操作函数的堆栈和句柄值。最终HandleSpy在处理所有捕获到的句柄操作时,会对所有打开或者创建操作根据句柄值向后匹配一个关闭操作,如果匹配到了句柄值相同的关闭操作,那么这个句柄值就不是泄漏句柄了,而如果没有匹配到,那就说明在这段时间内该句柄是泄漏项了。

要对句柄操作进行截获,毫无疑问的要对系统API进行Inline Hook,一般少数的函数Hook,可以自己实现Hook代码,但是如果需要Hook的函数数量过多,还是应该使用现有的成熟稳定的第三方库,HandleSpy在实现的时候使用了Detours库进行Hook操作。因为句柄操作相关的API数量众多,并且涉及到Unicode和Ansi版本的API的区分,所以这个功能也是HandleSpy在实现过程的核心工作了。此外在进行Hook操作的时候还应该确定一下选取的目标函数的在系统中的层次。

Windows系统句柄相关的API大部分都位于Kernel32.dll模块中,而注册表相关的句柄操作API位于Advapi32.dll模块中,而这些所有的上层模块中的API最终都要通过ntdll.dll模块中的API进入到系统内核。这种层次模型如图5.2.1所示:

图5.2.1 句柄相关API的层次模型

由此可以看出,如果要进行Hook就可以有三个层次可以供选择,内核层由于需要驱动支持,所以不予考虑。而剩下Win32子系统层和Nt Native层这两个层次上面都是可以进行Hook操作的,并且各有各的优缺点。HandleSpy的第一个版本选择了在Win32子系统层进行Hook,后来又实现了在Nt Native层进行Hook的版本。在这里我们选取Event类型的对象句柄相关的操作API对各个层次的实现方法进行介绍。

5.2.1在Win32子系统层进行Hook

与Event相关并且会造成进程句柄数量增加的Windows API有两个:CreateEvent和OpenEvent。再考虑深入一点,区分一下Unicode和Ansi版本的API就得到了四个相关函数CreateEventA,CreateEventW,OpenEventA,OpenEventW。在早期的Windows 操作系统中有这样一个机制,Ansi版本的系统API会调用同名的Unicode版本的系统API,所以只需要对Unicode版本的系统API进行Hook操作就可以同时截获到Ansi版本的系统API调用。

但是在Windows 7之后的版本中,Windows为了实现MinWin框架而引入了ApiSetScheme机制,使系统API的导出和实现分离。关于ApiSetScheme机制的相关内容无法在这里展开介绍,只需要知道这一机制导致调用CreateEventA函数的时候不会再去调用CreateEventW,所以如果要截获所有句柄相关的操作的API就必须把Unicode和Ansi版本的同名API都进行Hook。关于Windows 7和之前的Windows版本在这个问题上的处理方法对比如下图5.2.1.1所示:

图5.2.1.1 ApiSetScheme机制引起的变化

所以说如果选择在Win32子系统层次进行Hook,那么就不得不面对这些问题,对系统进行版本判断,然后采用不同的Hook目标。但是这一选择的优点是所有的API都是文档化的,并且更接近开发者平时所使用的一些API,所以定位出来的泄漏问题是由于开发者自身编码造成的可能性就比较大。

打开和创建句柄的操作都已经处理完了,还需要处理关闭句柄的操作,同样在Win32子系统层关闭句柄的操作是CloseHandle,并且几乎所有在用户态暴露出来的内核对象的句柄都使用这个API来进行关闭操作,所以对于其他类型的内核对象句柄无需再去重复处理他们的关闭函数。

5.2.2在Nt Native层进行Hook

在Win32子系统层的大部分API会调用一个Ntdll.dll中的API,例如CreateEventA->NtCreateEvent,CreateEventW->NtCreateEvent。这样的话我们就可以只Hook住NtCreateEvent函数,而不用区分到底是上层的Unicode版本函数还是Ansi版本函数。而在Nt Native层的句柄关闭操作API是NtClose函数。

这样做的好处是可以减少被Hook函数的数量,但是缺点是由于所有需要Hook的函数都是未文档化的API,其原型还需要去搜集。另外一点问题是由于Hook的层次相对很低,所以在写Hook函数的时候就受到较大限制,不能在Hook函数中使用已经被Hook的任何函数,否则就会引起循环调用耗尽程序栈空间,导致目标进程Crash。

通过上述介绍,可以选取选择任意一个方案来实现HandleSpy的功能,Hook函数中只需要记录函数调用栈和句柄值,并且存储好数据,在检测完毕时通过前面介绍过的过滤方法即可找到句柄泄漏的项。而要实现HandeSpy支持的句柄类型更加全面,就需要仔细推敲Windows的内核对象类型,然后针对每一种类型的内核对象尽量找到所有的操作相关的函数。

GitHub - tishion/HandleSpy: Tool to capture the count and source of the HANDLE resources in windows application.

评论

暂无
发表评论
 返回顶部 
热度(650)
 关注微信