Christophe Nasarre
本文假設您熟悉 Win32 和 C#
下載本文的代碼: GDILeaks.exe (13,279KB)
摘要
在以前的一篇文章中,作者設計了一種簡單的方法來檢測圖形設備接口 (GDI) 對象,這些對象并未由 Windows 9x 平臺上基于 Win32 的應用程序正確地進行發布。因為有些更新版本的 Windows 需要一種不太相同的 GDI 泄漏方法,作者已經更新了針對那些操作系統的方法。他構建并說明了兩種工具,這兩種工具旨在檢測并消除在 Windows XP、Windows 2000 和 Windows NT 上運行的應用程序中的 GDI 泄漏。
在 Windows® 95、Windows 98 和 Windows Me 中,圖形設備接口 (GDI) 句柄是一個 16 位的值,任何應用程序都可以使用它來調用 GDI API 的函數。在 2001 年 3 月一期的 MSDN® Magazine 中,我講述了如何利用這些平臺的 16 位特性來構建 GDIUsage,這是一種所有應用程序都可使用的列出、比較并顯示 GDI 對象的工具(參見“Resource Leaks:Detecting, Locating, and Repairing Your Leaky GDI Code”)。本文將說明如何編寫用于 Windows XP 的同種類型的工具。我這里將要使用的方法同樣很好地適用于 Windows 2000 和 Windows NT® 4.0,但出于本文的目的,我將使用 Windows XP 來表示所有這三種平臺。
圖 1 Windows 2000 中 GDI 的使用
本文說明了 Windows 9x 和 Windows XP 平臺的不同,提出了在工具的實現過程中產生問題的解決方案,您可以在圖 1 中看到該工具。我將解釋如何利用代碼插入機制來確定某個進程的 GDI 資源消耗情況,以及如何修補進程或 DLL,使其在創建 GDI 對象時得到通知。接下來,我將說明如何編寫 Win32® 調試器來驅動某一進程,如何讓該進程和調試器彼此之間進行通信,以及如何實現調用堆棧管理器來提供有關 GDI 對象資源分配的額外信息。
Windows 9x 與 Windows XP
對于 Windows XP,一系列 GDI 對象均與各個進程相關,大部分由 win32k.sys 按內核模式進行托管,設備驅動程序負責 USER 和 GDI 實現。Win32 應用程序通過由 user32.dll 和 gdi32.dll 提供的 API 調用這些系統服務。因為 Windows 基于每個進程保留了 GDI 對象的記錄,所以只有創建了 GDI 對象的應用程序能夠使用該對象對應的 GDI 函數。
Windows 9x 版本的 GDIUsage 使用 GetObjectType API 函數,該函數提供給定句柄值的 GDI 對象的類型,以檢查某個隨機值是否是個有效的 GDI 句柄。然而,與 Windows 9x 不同,Windows XP GDI 對象句柄完全是個 32 位的值。其可能的范圍是從 0 到 0xFFFFFFFF,為了列出所有真實的 GDI 對象,各個可能的句柄值都需提供給 GetObjectType。這就造成了實際的性能問題。下列代碼需要幾分鐘來執行,遺憾的是,很多通過運行測試應用程序檢測到的 GDI 對象都不是真的(其中有 500 多個!):
DWORD dwObjectType;
DWORD hGdi;
for (hGdi = 0; hGdi < 0xFFFFFFFF; hGdi++)
{
dwObjectType = ::GetObjectType((HANDLE)hGdi);
if (dwObjectType != 0)
{
TRACE("0x%08x -> %u\n", hGdi, dwObjectType);
}
}
這意味著還需要有使用 GetObject 的額外測試代碼(可能會很長)來獲得一個可靠的列表。循環周期使得該方法不可用。本文提供了兩種其他的解決方案。第一種方案使用 GDI 管理的句柄表,而第二種方案通過掛鉤來自 GDI API 的函數進行工作。值得一提的是,第一種解決方案在未來的 OS 版本中既得不到支持也不能保證具有相同的行為。找到另一種可獲得真正 GDI 對象列表的方法是要解決的首要問題。
對于 Windows 9x 的 GDIUsage,有可能利用對應于每個 GDI 對象類型(如位圖的 BitBlt 或畫筆的 FillRect)的函數來顯示 GDI 對象。但是由于當利用由另一個進程創建的 GDI 對象來調用這些 API 函數時會失敗,我所開發的工具的主要功能(即能夠“看到”正在泄漏的資源)消失了。另一方面,顯示的代碼在創建 GDI 對象的應用程序中運行正常。解決方案是顯而易見的 — 顯示引擎必須運行于其他進程的上下文中。本文的稍后部分將講述一種基于將 Windows 掛鉤作為進程間通信機制的實現。
最后,用 Win32 調試 API 將這些方法結合起來,因此,就獲得了可以運行于 Windows XP 和其他 32 位 Windows 平臺上的 GDIUsage 版本的實現。
GDI 如何管理句柄
在我 2002 年 8 月 的文章中,WinDBG 用來說明進程環境塊 (PEB) 結構。在那篇文章中,GdiSharedHandleTable 字段應引起您的注意,該字段如圖 2 轉載所示。事實上,這是一個指向表的指針,其中 GDI 存儲了它的句柄,甚至那些由其他進程創建的句柄。在他撰寫的 Windows Graphics Programming:Win32 GDI and DirectDraw (Prentice Hall,2002 年)一書中,Feng Huan 提供了另一種訪問該表的方法,但他也描述了該表中每個 0x4000 項的結構,如下所示:
typedef struct
{
DWORD pKernelInfo;
// 2000/XP layout but these fields are inverted in Windows NT
WORD ProcessID;
WORD _nCount;
WORD nUpper;
WORD nType;
DWORD pUserInfo;
} GDITableEntry;
每一項都存儲了 GDI 句柄的詳細信息,句柄的值很容易計算。它的低 16 位是在表中的索引,其高 16 位保存在 nUpper 字段中。顧名思義,ProcessID 字段包含創建對象的進程 ID。有了這些信息,簡單的循環就可以允許您列出某個特定進程正在使用的對象,而這也正是 GDIndicator 所做的事情,如圖 3 所示。
如果您有興趣了解獲得運行進程列表的不同方法,您可以閱讀我在 2002 年 6 月一期上發表的文章。每個進程都有一個 ID,用來從共享的表中收集該進程使用的 GDI 對象,并利用 ProcessID 字段進行比較。得到的計數值顯示在每個對象類型列下的 GDIndicator 中。
與其他列不同,第三列顯示兩個值。第一個值是調用 GetGuiResources 的結果(這應該返回該進程使用的 GUI 對象句柄的計數值),第二個加括號的值是在解析 GDI 句柄共享表的過程中得到的和。您可以在圖 3 中看到,這兩個值通常是不同的,而 GetGuiResources 總是返回較大的計數值。沒有文獻說明這種不同的原因,與常用對象或未發布對象也沒有什么明顯的關系。有可能是在您背后分配給 GDI 沒有存儲在共享表中的對象,因此是您沒有涉及的對象。
這種隱藏分配的一個例子發生在圖標操作的過程中。當您創建或加載某個圖標時,Windows 需要多個位圖來實現透明效果。通常一個用于掩碼,一個用于可視圖形。與位圖不同,圖標由 USER 系統組件來處理,而不是由 GDI 來處理。這可能就是當調用 GetGuiResources 來了解 GDI 的使用情況時 GetGuiResources 背后的代碼好像沒有跟蹤這些分配的原因。
通過 API 掛鉤來跟蹤對象分配
您已經看到,要了解特定的過程使用哪些 GDI 對象并不容易。怎樣才能知道對象是否已由應用程序代碼或背后的 GDI 自身加以分配呢?如果創建 GDI 對象時 Windows 能夠通知您,那么就很容易存儲它的句柄值并構建由應用程序分配的對象列表。遺憾的是,Win32 API 并沒有為開發人員提供這種通知機制。
如果您想知道何時創建了新對象,必須了解圖 4 中列出的函數調用。
幸運的是,在作者的文章“Learn System-Level Win32 Coding Techniques by Writing an API Spy Program”中(發表于 1994 年 12 月一期的 MSJ),Matt Pietrek 說明了如何編寫 Win32 領域的 API 偵探引擎。給定一個特定模塊(進程或 DLL),該引擎可以用您自己的函數地址替換被調用函數(由 DLL 導出)的地址。一旦執行了這種替換,每次被偵探的模塊調用一個掛鉤函數時,將在其所在位置執行您自己的句柄。
該 API 掛鉤原則已經過多年的改進(參見 1998 年 2 月和 1999 年 6 月期 MSJ 的 John Robbins Bugslayer 專欄。)如果您需要了解不同 Windows 平臺的可能實現,應該閱讀 Jeffrey Richter 的 Programming Applications For Microsoft Windows Fourth Edition“(Microsoft® 出版社,1999 年)一書的第 22 章,以及 John Robbins 的 Debugging Applications(Microsoft 出版社,2000 年)一書。這里我使用了 John Robbins 的方法。
圖 5 調用 GetDC 的內存布局
John 的 HookImportedFunctionsByName helper 函數接受修補函數列表、導出它們的系統 DLL 的加載地址、調用被修補函數的模塊,以及要重新定向到的存根列表。關于退出,它填充了包含所有被修補函數地址的列表。例如,如果 App.exe 正從 USSER32.DLL 調用 GetDC,則您將得到如圖 5 所示的內存布局。如果我用下面的輸入參數調用 HookImportedFunctionsByName,它將產生如圖 6 所示的不同布局。
• |
系統修補函數列表 (GetDC)
|
• |
導出函數的 DLL 的地址 (USER32.dll)
|
• |
模塊調用(App.exe 直接調用 GetDC)
|
• |
修補函數的列表(來自于 Hook.dll 的 GetDC)
|
在該特例中,包含所有已修補函數地址的列表應是 initial@。
圖 6 對 GetDC 修補調用的內存布局
除了圖 4 中列出的每個函數調用外,用同一機制來掛鉤自由的 GDI 對象的函數(如圖 7 所示)。 有了這兩種類型的通知,您就有可能跟蹤運行的活動 GDI 對象。
CGDIReflect 類負責提供靜態的存根方法,這些方法將替代系統函數被調用。該類派生于 CAPIReflect,其主要目標是利用宏將給定模塊的函數調用重新定向到靜態類成員中。這種替換是通過對 DoReflect 的調用來完成的,DoReflect 接受調用方模塊句柄作為參數。派生類的作用就是將您有興趣接收有關信息的每個系統函數映射到適當的存根函數,本文后面的部分將對此進行討論。
遵循消息映射機制,定義了一組宏來幫助您自動定義并聲明存根函數。在 /P 編譯器選項的幫助下,可獲得每個包含所有擴展宏代碼的源文件的 .i file。您需要觀察結果文件的大小,該結果文件可能很大,但這種方法準確地顯示了哪些代碼被執行,您將在本文后面的圖和源代碼中看到。
從 USER 和 GDI 重定向函數需要三步,稍后我將對這三步進行概述。使用同一示例,我將講述如何修補 user.dll 中的 GetDC。
第一步是利用 DECLARE_REFLECT_APIxxx 宏聲明 CGDIReflect 中的靜態變量,其中 xxx 表示該函數的參數數量(GetDC 有 1個,它接受 HWND 作為參數)。該聲明用 BEGIN_REFLECT_LIST 和 END_REFLECT_LIST 框起來,前者定義了一個隱藏的、提供跟蹤服務的 TraceReflectCall helper 方法,后者沒起什么作用:
BEGIN_REFLECT_LIST()
DECLARE_REFLECT_API1(GetDC, HDC, HWND, hWnd)
END_REFLECT_LIST()
但是,DECLARE_REFLECT_API 宏需要提供系統函數名、其返回類型及其參數列表(類型和名稱)。提供這些信息允許將宏擴展到 CGDIReflect(實際上是個存根)的靜態方法中,該方法共享同一原型并執行下列步驟。首先,通過別名調用初始的系統函數(后來由DEFINE_API_REFLECT 實例化)。之后,由前一調用分配的句柄及其類型被存儲到一個 CHandleInfo 結構中(參見圖 8),這是供將來使用的一些額外數據(參見下一部分有關 DoStackAddressDump 的討論)。最后,CGDIReflect 的靜態映射成員被更新,從而用前面講到的結構來與新分配的句柄以及想俘獲的創建對象相關聯。
第二步通過下列宏實現每個靜態成員,作為掛鉤系統函數地址的別名:
DEFINE_API_REFLECT(CGDIReflect, GetDC);
在我的示例中,宏可擴展到:
CGDIReflect::GetDCProc CGDIReflect::__GetDC = 0;
在第三步和最后一步,需要實例化所有這些成員,然后在執行時使用。在這兩種情況下都要調用 FillStubs 方法。首先,將 APIR STATE INIT 作為參數,由 Init調用 FillStubs,并且沒有模塊句柄要修補。這在利用 GetProcAddress 計算系統函數的地址時發生,然后地址存儲在別名成員(對于 __GetDC 為 GetDC)中。
接著,在新的 DLL 需要修補時調用 FillStubs。用 APIR STATE ENABLE 作為參數,DoReflect調用 FillStubs。它將由模塊進行的每個系統調用 (GetDC) 重定向到相應的靜態存根方法(本示例中為 _GetDC),而模塊的加載地址作為參數被傳遞。該方法遵循與 MFC 消息映射相同的模式:
BEGIN_IMPLEMENT_API_REFLECT()
•••
IMPLEMENT_API_REFLECT(hModule, "USER32.DLL", GetDC);
•••
END_IMPLEMENT_API_REFLECT()
為了在執行過程中幫助調試宏,一些 AfxTrace 調用分散在擴展代碼中。根據圖 9 中所示的值,SetTraceLevel 允許您選擇跟蹤哪個操作。
現在有了 CGDIReflect 類,它允許您將由特定模塊發出的任一調用重定向到存根方法,該方法的唯一作用是將新建的 GDI 對象存儲到映射中。但是該實現有一個缺陷 — 它不是線程安全的。如果您需要檢查的應用程序是多線程的,幾個線程都在調用 GDI 函數,產生的行為可能不確定,因為跨線程訪問句柄映射是不同步的。
利用堆棧跟蹤監視分配
每次對重要函數的調用 都終止于一個存根,該存根完成兩項操作。第一項是將分配的句柄及其類型包裝到 GDIReflect.h 中聲明的一個 CHandleInfo 對象中。第二項操作更有趣。在 CHandleInfo 對象中,當前調用堆棧的每個函數地址都存儲在 m_pStack 中 — 分配的 DWORDs 數組 — 而 m_pStack 中保存的地址數保存在 m Depth 中。因此,除了以圖形方式表示 GDI 對象外,還可能顯示導致分配特定 GDI 對象的函數調用堆棧,如圖 10 所示。
圖 10 導致分配對象的單元
當需要瀏覽堆棧時,imagehlp.dll 和 dbghelp.dll 是您最好的朋友。為了便于您的使用,John Robbins 已經將該引擎包裝到了 CSymbolEngine 類中,該類的發展歷程在 1998 年 4 月和 1999 年 2 月的 MSJBugslayer 專欄中有所介紹。在 John Robbins 的 Debugging Applications 一書中詳細地介紹了使用了 DBGHELP 的最后一個版本,該書我在前面提到過。
CSymbolEngine 類是一個在由 DBGHELP 導出的許多函數頂部的低級層。StackManager.cpp(位于本文頂部鏈接處代碼下載中的 /Common 目錄內)中實現的 CstackManager 提供了只有三個有趣方法的更高級功能。第一個是 DoStackAddressDump,它利用 CSymbolEngine 分配并填充一組當前的調用堆棧。該方法由每個存根調用,并存儲導致對象分配的每個函數地址。
十六進制地址對計算機有好處,但對人沒什么好處。為了將由 DoStackAddressDump 返回的地址數組轉換為可讀的格式,如圖 10 所示,必需調用 DumpStackAllocation。該方法接受堆棧轉儲及其深度,然后在一個 CString 中返回轉換后的堆棧。該方法的調用方能夠選擇他希望在每個地址間使用的行分隔符,要么是選擇 \r\n 在編輯框中顯示 CString,要么是選擇 \n,利用 Trace 或 OutputDebugString簡單地將其進行記錄。該方法背后并沒有什么魔法,對于給定數組中的每個地址,它都調用 ConvertStackAddressIntoFunctionName。
魔法在其他地方存在。當堆棧由 DoStackAddressDump 轉儲,而地址存儲于返回的數組中時,該方法還可以利用 CSymbolEngine 中定義的 SymGetModuleInfo、SymGetSymFromAddr 和 SymGetLineFromAddr(有關實現細節,請參見代碼下載中 StackManager.cpp 中的 ConvertAddress)找到對應于地址的符號。為什么現在進行轉換?答案很簡單:在這個特定的時刻,您確信相應的 DLL 被加載,但稍后調用 DumpStackAllocation 時情況可能會不一樣。
如果頻繁創建 GDI 對象,就會產生許多堆棧轉儲,并保存在 m_HandleMap 中。但保存在該映射中的 CHandleInfo 對象保留的是地址數組,而不是轉換后的字符串數組。技巧是利用一個映射成員(如 m_AddressToName)來跟蹤轉換。這就避免了在堆棧轉儲中存儲長字符串來代替每個地址的 DWORD 類型,因此減少了對內存的消耗。另一個好處是,堆棧轉儲運行速度會更快,因為利用 m_AddressToName 來作為緩沖區,從而避免了對符號引擎進行查詢。
即使您知道如何掛鉤一系列 GDI 函數,您仍需要了解在哪些模塊中調用這些 GDI。我們說,在共享 DLL 中使用 MFC 的應用程序正在創建一個 Cpen 對象,從而操作與 Windows 畫筆相關的 API。真正調用 CreatePen(它返回畫筆的句柄)是在 MFC DLL 中(而不是在調用應用程序代碼中)完成的。如果只掛鉤由可執行文件調用的 API 函數,就會丟失來自由應用程序使用的所有 DLL 的調用。
通過調試來確定需要修補的 DLL
在 Windows XP 中,獲得在給定時間由某個進程加載的所有 DLL 的列表非常簡單,這要感謝 PSAPI 函數 EnumProcessModules,正如 Matt Pietrek 在“Under the Hood”一文中所述,該文發表在 1996 年 8 月發行的 MSJ 中。但是,對于動態加載的庫,這個問題需要一點竅門。除了掛鉤系統函數,還必須掛鉤主程序發出(ANSI 與 UNICODE 版本)的 LoadLibrary 調用,從而檢測何時加載了一個新 DLL,并遞歸地對其進行相同的掛鉤處理。
需要回答最后的兩個問題。第一,如何知道在偵探的進程中哪些 DLL 需要修補?第二,如何確保這些代碼在另一個進程中執行?如果有這樣的解決方案,那么還有可能用它來顯示任何 GDI 對象句柄的圖形化表示。在2002 年 8 月發表的有關調試的文章中,一個調試過的進程通過使用 Win32 調試 API 來動態或靜態地檢測加載的 DLL。這種方法的主要缺點是需要啟動和調試應用程序。與 Windows 9x 版本的 GDIUsage 不同,被檢測的 GDI 對象必須是由調試過的應用程序分配的對象,這可以在圖 1 中看到。
Win32 調試 API 允許您編寫代碼來啟動某個應用程序(調試對象),并且當發生事件時(如加載一個新的 DLL)獲得通知。這正是您所需要的。要輕松地編寫調試器,您只需重載在調試事件發生時要調用的虛方法。CGDIDebugger 類派生于 CapplicationDebugger,在我的 2002 年 8 月的文章中介紹過 CapplicationDebugger。圖 11 顯示了 CGDIDebugger 所重載方法的名稱,并解釋了每個方法的作用。稍后我將討論調試器和調試對象之間的通信機制。
圖 12 搜索字符串
除了這些方法,已經重載了 OnOutputDebugStringDebugEvent,從而將調試對象(前綴為 >)留下的蹤跡重定向到專用的列表框中。還有可能將一個選擇復制到剪貼板,或者搜索一個字符串,如圖 12 所示。當利用 TRACE 或 OutputDebugString 添加跟蹤時,它就會出現在該列表框中。這是一種調試代碼的有效機制,并可以標出調試對象(前綴為 >)和調試器的輸出結果之間的差異。
在另一個進程中插入運行代碼
現在還有一個遺留問題需要解決:一定有一種方法可以使一些代碼在另一個應用程序的上下文中運行。幸運的是,Jeffrey Richter 很早以前就在他的文章“Load Your 32-bit DLL into Another Process's Address Space Using INJLIB”(MSJ,1994 年 5 月)中解決了這個問題。
由于通常我們只對使用 GDI API 的應用程序感興趣,因此我們可以假定這樣的應用程序至少使用一個窗口來顯示它的圖形。(否則,它為何需要 GDI?)因此,在不同的解決方案中,基于 Windows 掛鉤的方案好像是最佳的選擇。當調用下面的掛鉤時,任何進程中的任何線程執行 GetMessage 時 Windows 將會調用 GetMessageHookProc 回調函數:
SetWindowsHookEx(WH_GETMESSAGE, GetMessageHookProc, hInstance, 0)
由于這是一個系統范圍的掛鉤(最后的參數為 0),回調函數的代碼必須位于某個 DLL 中,該 DLL 被映射到其線程調用 GetMessage 的各個進程的地址空間。
如果掛鉤進程和驅動應用程序適合,用預先定義的消息在它們之間建立通信信道就非常簡單。這是一種允許調試器為一些插入代碼(運行在被偵探應用程序上下文中)發送請求的不錯方法,確切地說,這就是 GDI 對象顯示對話框所需的!當掛鉤進程攔截了第一條消息時,它首先重定向已經加載的 DLL 調用。然后,它啟動一條新的線程,專門處理來自調試器的請求。這就在調試器和調試對象之間創建一條通信信道(有關詳細信息,請參見 GDITrace.cpp 中的 StartInfiltratedThread)。
由調試器和調試對象從掛鉤進程和滲透線程函數中調用的函數都已經收集到了 GDITrace.dll 中,GDITrace.dll 的行為由 CGDITraceApp 類來實現。該 DLL 與調試器應用程序 GDIUsage 靜態鏈接,但是它動態加載到觸發 Windows 掛鉤的進程中。由調試器調用的函數在 _GdiTrace.h 中聲明,并集合在 GdiTrace.cpp 中,從而幫助您理解調試器使用的是哪一部分,調試對象使用的是哪一部分。但為什么要在同一個 DLL 中混合不同的代碼?需要在這兩種代碼之間共享一些變量,在同一個 DLL 的實例之間共享變量的值很簡單,如圖 13 所示。
這些代碼用讀/寫/共享屬性 (rws) 定義了名稱為 .shared 的 PE 區域,這些屬性包含 5 個前綴為 s_ 的需要共享的變量。根據這些聲明,Windows 將這些變量保存到一個加載 DLL 的進程共享的內存塊中。因此,這些變量在各個進程中的值都相同,特別是調試器和調試對象。我們看一下當調試器啟動一個調試對象時會發生什么情況,以及如何使用這些變量。
當啟動調試對象時,調試器線程接收到一個 CREATE_PROCESS_DEBUG_EVENT,它由 OnCreateProcessDebugEvent 處理,OnCreateProcessDebugEvent 反過來又調用 StartTraceGDI。該函數執行 SetSharedVariables,用調試器線程的 ID 設置 s_dwCallingThreadID 的值。如果當前進程的 ID 與保存在 s_dwProcessID 中的 ID 相同,掛鉤進程就會知道它是在調試對象的上下文中運行,并根據已經加載的 DLL 開始修補 GDI 調用。接著,由掛鉤進程啟動的專用線程在 dwInfiltratedThreadID 中保存了它的 ID。最后,當該掛鉤進程成功運行時,s_bDebuggeeIsStarted 被設置為 TRUE,然后由調試器用它來決定滲透線程是否已經準備好響應請求。
如果需要在調試器和調試對象之間傳遞或檢索 GDI 對象句柄列表,就需要一個正好比一個 DWORD 或一個 BOOL 大的共享緩沖區。除了這 5 個變量,還要使用一個名為 GDITrace SharedBuffer 的內存映射文件,對應的內存由 CGDITraceApp 的成員 m_lpvMem 指定。它在 DLL 啟動期間被初始化(有關詳細的實現,請參見 GDITrace.cpp 中的 CGDITraceApp::InitInstance)。只有當這兩個進程加載 DLL 時該緩沖區才需要創建和初始化:作為調試器的 GDIUsage 及其當前的調試對象。
s_dwProcessID 共享的變量用來識別兩個進程間的區別。如果沒有啟動的調試對象,它的值總是 0;否則,它就包含調試對象進程的 ID。當 DLL 加載到進程中時,它的 InitInstance 檢查 s_dwProcessID 是否等于 0(應當是 GDIUsage)或者等于 GetCurrentProcessId(應當是調試對象),從而創建內存映射文件。
從調試器到調試對象的通信
調試器使用 s_dwInfiltratedThreadID 共享變量來發送一個請求(利用 PostThreadMessage 通過一個簡單的 Windows 消息),該請求將由調試對象中的滲透線程來處理。當調試對象通知調試器這樣的一個請求已經完成時,需要另一個 s_dwCallingThreadID 共享變量。例如,當用戶單擊“Take Snapshot!”按鈕時,GDIUsage 需要從調試對象收集已分配的 GDI 對象。
GDIUsage 發送一條 TM_GET_LIST 消息給調試對象中的滲透線程,調試對象的值保存在 s_dwInfiltratedThreadID 中。它將執行連同參數 UM_SNAPSHOT_READY 一起發送給 OnGetList 函數,該參數將被用作回調消息被 GDIUsage 主對話框接收。為什么不簡單地使用同樣的 TM_GET_LIST?答案與共享代碼有關。“Take Snapshot!”與“Compare”按鈕都需要獲得相同的已分配 GDI 對象列表(雖然使用該列表的方法不同),以更新 GDIUsage 用戶界面的相應部分。
為了總結一下在前面段落中我已經論述的內容,TM_GET_LIST 線程消息觸發對調試對象端 GDI 對象的處理。另外,根據用戶定義的兩條消息,有兩種方法可以更新 GDIUsage 主對話框:UM_SNAPSHOT_READY 與 UM_COMPARE_READY。
滲透線程喚醒并要求 OnGetList 來處理請求。該 CGDITraceApp 方法通過修補的存根枚舉出調試對象中檢測到的 GDI 分配,并將每個對象的說明(句柄值和類型)按照下面的 GDI_LIST 格式復制到由 m_lpvMem z指向的內存映射文件共享的緩沖區中:
typedef struct
{
DWORD dwType;
HGDIOBJ hObject;
} GDI_ITEM;
typedef struct
{
DWORD dwCount; // count of meaningful GDI_ITEM slots in Items
GDI_ITEM Items[];
} GDI_LIST;
為了通知調試器列表已經準備好,一條相同的 TM_GET_LIST 消息被發送回由 s_dwCallingThreadID 識別的線程。該消息由負責調試事件的線程在 GDIUsage 上下文中接收并發送給 CGDIDebugger::OnThreadMessage。該方法通過用一個指向共享內存的指針向主對話框發送正確的用戶消息(本示例為 UM_SNAPSHOT_READY)來通知 UI 線程,共享內存由內存映射文件定義,而調試對象將 GDI 對象保存在該文件中。通過使用 CGdiResources 的 CreateFromList 方法,這個自動封送處理的序列化列表用來實例化 CGdiResources。該類用來包裝一個 GDI 對象列表,同時還提供諸如枚舉與圖形顯示等服務。
死鎖與計時問題
在深入了解遠程 GDI 對象的圖形顯示之前,您應當了解一下可能發生的死鎖問題。以前探討的收集 GDI 對象的技術都是異步的,因為它要依賴調試器和調試對象之間交換的 Windows 消息。如果需要進行強同步通信,可以使用 Win32 事件。例如,滲透線程等待一個有特定名稱的事件,當一條消息發送到它的隊列中或者事件獲得信號通知時,它就調用 MsgWaitForMultipleObjectsEx 來喚醒(有關詳細的源代碼信息,請參見 GDITrace.cpp 中的 CGDITraceApp::InfiltratedThreadProc)。在該實現中,用事件來要求線程結束它的生存期,因此不是真正的同步。
另一種需要真正同步行為的情況是,當調試對象加載一個 DLL 時修補 GDI 調用。為了截獲 GDI 調用,調試器必須盡快通知滲透線程。否則,調試對象可能在安裝截獲存根之前就開始分配 GDI 對象。這里是一個不錯的實現方案:
1.
|
調試器線程獲得通知,通過 WaitForDebugEvent 返回的LOAD_DLL_DEBUG_EVENT 已在調試對象中加載了一個 DLL。
|
2.
|
OnLoadDLLDebugEvent 重寫方法接收 DLL 對應的 hModule,將它保存在一個新的共享變量中,并通過為事件發送信號來請求調試對象為該特定的 DLL 修補 GDI 調用。
|
3.
|
如果調試對象加載另一個 DLL,為了避免重新進入,OnLoadDLLDebugEvent 等待另一個在調試對象完成其修補工作后的信號通知事件。
|
4.
|
MsgWaitForMultipleObjectsEx 喚醒調試對象-滲透線程,因為它等待的一個事件已經由信號通知。
|
5.
|
CGDITraceApp::OnNewDLL 方法為在由共享變量定義的地址處加載的 DLL 重定向 GDI 調用,該共享變量由調試器用 DLL hModule 填充。
|
6.
|
調試器等待的事件由滲透線程發信號通知。
|
7.
|
滲透線程調用 MsgWaitForMultipleObjectsEx 等待完成另一個請求。
|
8.
|
調試器線程繼續進行,因為它等待的事件已經由信號通知。
|
注意,最后兩步的順序號相同,因為它們的代碼在由 Windows 調度的兩個不同線程中運行。不要指望一個會在另一個之前執行。
雖然這種方案看起來很完美,但它會在調試器線程和插入調試對象的線程之間引起死鎖。第 3 步和第 4 步之間有一個隱患。Win32 調試 API 假定調試器正在利用 WaitForDebugEventsumes 等待一個調試對象事件。當該函數返回時,一直到 ContinueDebugEvent 被調用,調試對象中的所有線程(甚至那些沒有生成調試事件的線程)都被掛起。因此,停留在第 3 步和第 4 步的調試器線程將永遠不會由調試對象執行,因為在同步通信結束后 ContinueDebugEvent 才應執行。要切記,從由調試器接收的調試事件來同步調試對象中的行為是不可能的。
在 GDIUsage 的情況下,基于消息的機制提供的通知速度好像已足夠迅速。真正的問題出在其他地方。由于調試對象檢索到第一條消息后就啟動滲透線程,所以所有靜態鏈接的 DLL 都已經初始化。如果其中的一些已經分配了 GDI 對象,這樣的消耗將永遠不會被 GDIUsage 檢測到。在這種情況下,需要另一種方法來執行修補代碼。GDIUsage 幫助您檢測和找到的 GDI 對象創建不是在應用程序的生存期內發布的,而是在其啟動時發布的。
顯示 GDI 對象
圖 1 中所示的 GDIUsage 用戶界面允許用戶獲得在任何給定時間點某進程使用的 GDI 對象的快照,并且稍后可以與同一進程的當前狀態進行對比。每組對象都保存在 CGdiResources 對象中。可以在 TM_GET_LIST 注釋中看到,對象列表一旦通過內存映射文件在調試對象和調試器之間被序列化和封送處理,利用 CreateFromList 就可以初始化一個 CGdiResources 對象。
在 Windows 9x 版本的 GDIUsage 中,負責顯示 GDI 對象的 CgdiResourcesDlg 類接受指向 CGdiResources 對象的指針作為參數。遺憾的是,在 Windows XP 版本的 GDIUsage 中,由于用來以圖形方式顯示 GDI 對象(在調試對象內部創建)的 GDI 函數總是出故障,因此在調試器的上下文中,CGdiResourcesDlg 就變得毫無價值。
解決方案是移去 GDITrace.dll 內部的 CGdiResourceDlg 和 CgdiResources,同先前討論的一樣,GDITrace.dll 通過 Windows 掛鉤被加載到調試對象中。在檢索完已分配對象的列表后,就該顯示這個列表了。使用的通信機制和先前討論的一樣,但在顯示列表并為滲透的線程發送消息之前,該列表必須在調試器的上下文中保存到內存映射文件。調試器將 CGdiResources 列表(或者當前的快照,或者對比的結果,取決于用戶單擊的按鈕)序列化到內存映射文件,并將一條 TM_SHOW_LIST 消息發送給調試對象中滲透的線程,其中利用 GDIUsage 主對話框窗口的句柄作為參數。在這樣的處理中,由 CGDIDebugger::ShowList 完成序列化,而由 ShowRemoteList 完成消息發送。
與 TM_GET_LIST 一樣,由滲透的線程處理 TM_SHOW_LIST 消息,接著發送到 CGDITraceApp::OnShowList。這種方法根據內存映射文件中保存的序列化與封送數據初始化 CgdiResources。現在,有可能讓 CGdiResourcesDlg 來顯示對應的 GDI 對象了。
與 TM_GET_LIST 消息不同,為了將該顯示命令發送給調試對象,調試器不需要任何返回代碼或信息。在任何情況下,用戶都希望 GDIUsage 保持禁用,直到他關閉了該 CGdiResourcesDlg 對話框。這就是將 GDIUsage 主對話框的句柄作為父窗口傳遞給 CGdiResourcesDlg 對象的原因。
除了有兩點改進之外,該類的實現從 Windows 9x 版本以后就沒有更改過。第一點改進是利用構建 GDI 句柄的方法來檢測它是否引用一個常用對象以及是否在列表框的對應行中添加一個 *。這種功能在 GDIUsage 中是不可見的,因為創建常用對象的 API 沒有被修補過,但您可能在編寫自己的代碼時想實現它。
第二點改進是,為了呈現對應的堆棧跟蹤,用戶單擊或者雙擊 GDI 對象列表時需要進行檢測,如圖 10 所示。由于不應當將 CGdiResourcesDlg 代碼鏈接到堆棧跟蹤代碼,因此定義了一個通用的回調接口 INotificationCallBack。CGDITraceApp 類派生于該界面,它實現了 OnDoubleClick 并使用 CGdiResourcesDlg::SetNotificationCallBack 對其自身進行注冊。
當用戶雙擊某個 GDI 對象時,CGdiResourcesDlg 要檢查是否已經注冊了一個回調。如果是,它就調用該回調的 OnDoubleClick 方法,并以被雙擊的 GDI 對象對應的 CGdiObj 說明作為參數。然后,CGDITraceApp 從傳遞的 CGdiObj 提取調用堆棧,并用它實例化 CcallStackDlg,從而為用戶顯示堆棧跟蹤(有關實現的詳細信息,請參見 GDITrace.cpp 中的 CGDITraceApp::OnDoubleClick)。
這種功能也添加到了圖 3 所示的 GDIndicator 工具中。與 GDIUsage 不同,插入機制是基于 CreateRemoteThread 的,并且在我 8 月份的文章中已經進行過論述。一個要注意的有趣事實是,代碼的序列化和顯示與 GDIUsage 完全相同;只是更改了遠程處理機制。由于沒有被記錄的堆棧跟蹤,因此沒有注冊雙擊的處理程序。
還有最后兩個缺陷必須進行處理。由于顯示 GDI 對象的對話框陷入了另一個進程上下文,因此會發生一些怪異的事情。首先,在 Windows 2000 和 Windows XP 中,從另一個進程中設置前臺的窗口絕非易事。您必須在擁有前臺窗口的進程中調用 AllowSetForegroundWindow,從而讓另一個進程設置它的一個窗口,并作為一個新的前臺窗口。在用戶取消之前,對話框代碼會在另一個進程當前線程的上下文中無限循環地運行。因此,在關閉該對話框之前,GDIndicator 一直掛起。為了避免這種討厭的行為,在對話框的生存期間,要隱藏它的主窗口。
第二個負面影響更難解決。對話框控制的線程中創建的窗口將不再接收它們的所有消息,因為該對話框進程對它們進行了篩選。例如,如果您使用這里概述的工具來找出由 Notepad 分配的 GDI 對象,它的重畫功能將是不錯的選擇,而且可以輕松地導航到它的菜單中,但在關閉對話框之前,選擇的命令不會觸發事件。這是一種復雜的使用功能,但在搜索難以琢磨的 GDI 漏洞時確實頗有價值。
小結
Windows 9x 和 Windows XP 之間,GDI 已有所不同。雖然程序中的許多代碼都重新使用了其 Windows 9x 實現中的代碼,例如對 GDI 對象組的管理以及它們的圖形顯示,但獲得由某個進程分配的 GDI 對象列表的內在機制卻是全新的。Windows XP 版本是基于 Win32 調試 API、DLL 插入、Windows 掛鉤和 API 補丁的,并且與對應的 Windows 9x 相比,提供的功能更多。
除了導致每個 GDI 分配的調用列表外,還增加了最后的處理。當遠程進程終止時,插入的 DLL 的 ExitInstance 方法被調用。CGDITraceApp 最后利用該通知枚舉 GDI 對象并轉儲仍然有效的對象。這與在調試版本中使用 DEBUG_NEW 作為內存分配器來檢測內存泄漏時從 MFC 應用程序中獲得的最后轉儲是一樣的。
假如您知道創建所感興趣的資源需要調用的 API 函數,那么這些機制可以用來找出其他類型的漏洞。例如,利用現有的工具,如來自 Platform SDK 的 oh.exe 與 dh.exe 或者來自 http://www.sysinternals.com 的 ProcessExplorer,可以發現內核對象泄漏(例如文件和注冊表項)。雖然它們可以提供任何進程使用的內核對象列表(還提供與 ProcessExplorer 的對比),但您可以利用本文所述技術提供的調用堆棧和最終泄漏轉儲來更加輕松地跟蹤系統漏洞。
相關文章,請參閱:
Windows XP Escape from DLL Hell with Custom Debugging and Instrumentation Tools and Utilities
Windows XP: Escape from DLL Hell with Custom Debugging and Instrumentation Tools and Utilities, Part 2
有關背景信息,請參閱:
Windows Graphics Programming: Win32 GDI and DirectDraw by Feng Huan (Prentice Hall PTR, 2000)
Debugging Applications by John Robbins (Microsoft Press, 2002)
Programming Applications for Microsoft Windows by Jeffrey Richter (Microsoft Press, 1999)
Christophe Nasarre 是法國 Business Objects 的一位開發經理。他已經為 3.0 版本以后的 Windows 編寫了幾個底層工具。您可以通過 cnasarre@montataire.net 與他聯系。
轉到原英文頁面