今天看到這個問題如何評價王垠的 《討厭的 C# IDisposable 接口》? - 王垠(人物),答案被歪到windows 內核對象和句柄,答案中談的太淺顯而且有誤。翻出陳年老文章(此文成於2012年,只在公司內部分享過),大部分內容來自Windows內核原理
1句柄和句柄泄露
在Windows編程過程中,很多時候我們都要和句柄打交道,比如窗體句柄,內核對象句柄,GDI句柄,Windows Multimedia庫中的多種句柄等等,以及其他更多未曾使用過的句柄類型。句柄(Handle)是Windows系統下特有的一種數據類型,其本質定義是基本數據類型PVOID,為什么定義為PVOID呢?因為他的數據長度跟處理器的位數有關,在32位CPU下句柄可以用一個32位無符號整數來表示,同理在64位CPU下就用64位的無符號整數來表示。句柄值所代表的意義是句柄表中的一個項的索引,並且這個索引並不都是通常的按照1來遞增的索引,而是4。
句柄的存在意義離不開對象(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的內核對象類型,然后針對每一種類型的內核對象盡量找到所有的操作相關的函數。
