本文假設你熟悉 Win32,DLL
摘要
在 Windows 系統中,動態庫版本衝突實在是一個老大難的問題了,爲了解決這個問題,除了使用大量現有的工具外,你還可以利用豐富的 Windows APIs 函數構造自己的調試工具和實用程序。作爲例子,本文將提供三個這種類型的工具,討論如何利用它們來解決動態庫的衝突問題。這三個工具分別是:
從所周知,動態庫“地獄”(DLL Hell)已經不是什麼新鮮玩意兒了,如果你使用第三方的 Dlls,肯定會碰到不少與它有關的問題,如找不到入口點,或者庫版本不兼容等。.NET 中允許組件的並行執行,減少了產生這種問題的機率,但是如果你還沒有升級到 .NET 環境,那怎麼辦?針對這種情況,可用的方法是用不同的工具跟蹤 DLL 的依賴性。但是用標準工具跟蹤時,你可能最後得不到所要的信息。許多工具都沒有你需要的功能,比如自動寫日誌文件,跟蹤分析,僅在控制檯操作腳本控制等。
本文我們先用一些現有的工具來考察系統中的運行進程,然後系統地研究本文提供的三個工具:DllSpy, ProcessSpy 和 ProcessXP,以便在今後的開發或調試中使用這些工具和技術。
現有的工具
Depends.exe 是 Visual C++ 自帶的一個工具。它可能是我們經常使用的工具中最簡單的一個工具了,其功能是列出某個應用程序或 DLL 需要的 DLLs。這個程序在本站可以下載(更新版本請到下面這個地址下載:http://www.dependencywalker.com)。如果你需要看某個 DLL 或可執行文件的全路經,可以用它的上下文菜單進行設置:如圖一:
圖一 察看全路經
對於靜態加載的情況(即應用程序在鏈接過程中將 dlls 對應的 lib 文件鏈接到程序中),這個工具非常好用,但對於版本較新的系統,大多使用 COM 編程接口,或者說是用 COM 對象編程模型,而 COM 對象的實例化都是運行時加載或者說動態加載某個 DLL 文件,然後通過 LoadLibrary 和 GetProcAddress 調用其中某個特殊的函數來實現的。你不知道這個 DLL 是何時、從哪裏被加載的。
一種確定 DLLs 被動態加載的方法是找出需要被每一個進程加載的 DLL。Sysinternal 公司(http://www.sysinternals.com)提供了一個工具軟件 ListDlls.exe。它是一個控制檯程序,其圖形用戶界面(GUI)版本爲 Process Explorer。如圖二:
圖二 Process Explorer 運行畫面
除了列出被某個進程使用的 DLLs 之外,還可以用這個工具瞭解某個程序用到了哪個 kernel 對象,從版本3.11之後,Process Explorer 還可以讓你在兩個快照之間輕鬆掃描到新的或未使用的對象。
有時候在你用 Process Explorer 掃描到某個進程之前,它可能已經被加載然後又在很短的時間內被卸載了。碰到這種情況時,你需要另外一種類型的工具,我們將在後文中討論。
爲了操縱進程和 DLLs,首先你必須知道每一個被加載的 DLL 被哪些進程使用。本文的例子程序 DllSpy 實現目的即在於此。如圖三所示:
圖三 DllSpy 運行畫面
DllSpy程序上面的窗格列出的是所有已經加載的 DLL,每選中一個DLL,在下面的窗格中就會列出使用該 DLL 的所有進程。
而 ProcessSpy 例子程序的功能正好與 DllSpy 相反,它在上面窗格列出系統中所有的運行進程,每選中一個進程,在下面窗格便顯示出此進程使用的所有 DLLs,如圖四所示:
圖四 ProcessSpy 運行畫面
下面窗格還反映了 DLL 加載的地址是實際地址還是首選地址,以及它們的從屬性是靜態的還是動態的。這些工具的源代碼和可執行程序都可以從本文的下載鏈接中下載,它們也許不完全滿足你的需要,但可以作爲技術參考,對編程工作肯定是有所裨益的。
如何獲取運行進程列表
有三種方法來獲取Win32運行進程的信息,參見表一:
(表一)
方法 | 平臺 | 備註 |
PSAPI | Windows NT,Windows2000,Windows XP | 獲取進程,驅動器,模塊,內存和工作集信息 |
性能計數器 | Windows NT,Windows2000,Windows XP | 提供除進程清單以外的關於進程的更多信息,可在遠程機器上使用。 |
TOOLHELP32 | Windows 9x,Windows2000,Windows XP | 獲取進程,線程,模塊和堆信息 |
本文不打算討論 TOOLHELP32,因爲 MSDN 中提供了很多使用 TOOLHELP32 函數的例子代碼。性能計數器提供的信息更多,不僅僅是進程清單。如果你想獲取遠程機器的信息,那麼性能計數器是再好不過的工具了。如果你總是想得到另外一臺機器的進程列表信息,那麼就用性能計數器吧!
進程狀態 API(PSAPI 全稱是 Process Status API)是微軟 SDK 中一個很有用的工具,在例子程序中有一個類 CProcessList,其實現文件 Process.cpp 對 PSAPI 進行了打包,用這個類可以獲取進程清單。只要 Refresh 一被調用,通過某個進程的ID便可獲得此進程的描述信息,並很容易用 GetFirst 和 GetNext 列舉出其它進程:
//用 CProcessList 列出運行進程 // 一個挨一個獲取進程 CProcess* pProcess = NULL; POSITION Pos = 0; for ( pProcess = ProcessList.GetFirst(Pos); (pProcess != NULL); pProcess = ProcessList.GetNext(Pos) ) { if (pProcess != NULL) { // 對進程信息進行處理 } }
Refresh 的實現用到了 EnumProcesses 函數(在PSAPI中):
//刷新進程列表 void CProcessList::Refresh() { // 不要忘了重置和釋放當前的進程列表 DefaultReset(); // 存儲當前進程列表 DWORD aProcesses[MAX_PROCESS]; DWORD cbNeeded = 0; // 獲取進程快照 if (!g_PSAPI.EnumProcesses(aProcesses, sizeof(aProcesses), &cbNeeded)) return; // 計算返回了多少個進程IDs DWORD cProcesses = cbNeeded / sizeof(DWORD); // 將CProcess 對象捆綁到每一個進程ID DWORD dwProcessID; CProcess* pProcess; for ( DWORD dwCurrentProcess = 0; dwCurrentProcess < cProcesses; dwCurrentProcess++ ) { dwProcessID = aProcesses[dwCurrentProcess]; // 將進程信息添加到映射 pProcess = new CProcess(TRUE); if (pProcess != NULL) { // 填寫當前進程ID的進程信息--捆綁 if (!pProcess->AttachProcess(aProcesses[dwCurrentProcess])) delete pProcess; else // 存到映射表中 m_ProcessMap[(LPVOID)dwProcessID] = pProcess; } } // 第二次循環需要知道此進程的子進程清單 SetChildrenList(); }
如果你至今還在支持 Windows 的 16 位代碼,比如在任務管理器的 ntvdm.exe 項下列出的應用程序就屬於此列,那麼枚舉進程更棘手。有關細節參見本文參考資料中的知識庫文章,以及 Matt Pietrek 的專欄文章:August 1998 和 September 1998。
如何獲取進程信息
有了運行進程的列表,接下來就是根據 EnumProcesses 返回的進程IDs儘可能多的獲取每一個進程的詳細信息,然後根據這些信息建立有用的工具。用PROCESS_QUERY_INFORMATION | PROCESS_VM_READ作爲參數,調用OpenProcess獲取進程句柄,然後用AttachProcess(參見Process.cpp文件中的CProcess類實現)方法創建進程描述。表二中列出的是CProcess用於獲取進程細節信息的函數:
(表二)
方法 | 描述 |
GetName | 以NULL作爲參數,調用 GetModuleBaseName ,最後去掉擴展名 “.EXE” |
GetFileName | 以NULL作爲參數,調用 GetModuleFileNameEx |
GetMainWindowHandle | 參見GetMainWindowHandle |
GetMainWindowTitle | |
GetParentProcessID | 用ProcessBasicInformation作爲參數調用NtQueryInformationProcess |
GetKERNELHandleCount | 用ProcessHandleCount作爲參數調用NtQueryInformationProcess |
GetUSERHandleCount | 用GR_USEROBJECTS作爲參數調用GetGuiResources |
GetGDIHandleCount | 用GR_GDIOBJECTS作爲參數調用GetGuiResources |
GetWorkingSet | 調用GetProcessMemoryInfo |
GetCmdLine | 參見GetProcessCmdLine |
GetOwner | 參見GetProcessOwner的細節 |
GetSessionID | ProcessIdToSessionId (參見對快速用戶轉換的討論部分——Windows XP的一個新特性) |
GetModuleList | CModuleList是一個對EnumProcessModules 和GetModuleFileNameEx的打包類 |
GetChildrenCount 以及子進程清單 | 要獲取某個進程的子進程列表,目前還沒這樣的API(即便有也未公開)可供使用。但是,因爲某個進程的父進程是已知的,所以將某個進程加到其父進程的子進程列表中不難(參見SetChildrenList的實現) |
這裏有幾個關於AttachProcess的細節問題需要解釋一下。首先,爲了避免與PSAPI或 NTDLL這樣的操作系統特有的DLLs進行靜態鏈接,編寫一個類對我們需要的從這些DLLs中輸出的函數進行打包是值的得--有關細節可以參考例子代碼中的Wrappers.h和Wrappers.cpp文件。這樣的話,你只需要定義一個CPSAPIWrapper對象並調用它的GetModuleFileNameEx方法即可,不用鏈接到PSAPI庫。另外,你應該調用IsValid方法來檢查這些DLLs在你運行系統中是可用的。如此一來你的代碼便可以運行在任何Windows平臺而不會產生諸如某某函數未定義之類的鏈接錯誤。注意在使用某個專門的特性之前,你應該檢查一下Windows的版本或IsValid的返回結果(參見DllSpy例子代碼中的DllSpyApp::InitInstance部分)。
注意PSAPI中的GetModuleFileNameEx函數返回的文件名很奇怪:如:"/SystemRoot/ System32/ smss.exe"或者"/??/C:/WINNT/system32/winlogon.exe"等等。誰知道這是什麼意思?在Helper.cpp中有一個函數TranslateFilename專門對此進行轉換,將這些文件名轉換成更容易理解的名字。稍候會我們還會談到這個函數。
接下來我們討論如何尋找某個進程的主窗口,EnumWindows有一個參數是回調函數,此回調函數的作用是接收頂層窗口句柄,在這個問調函數中,我們要調用GetWindowThreadProcessId來獲取創建相應窗口的進程ID,如果找到這個窗口(可見的)便停止枚舉(詳情請參見GetMainWindow實現)。函數GetWindowText可以被用來獲取某個不同進程的窗口標題。
在Windows NT 和Windows 2000裏,爲了獲取與創建某個特定窗口的進程對應的文件名字,不能像以前那樣用GetWindowModuleFileName函數,你會毫無所獲,這個函數總是返回當前運行進程的路徑名。
獲取某個進程主窗口的詳細過程描述可以參考Jeff Prosise在MSJ Aug99上的Wicked Code專欄文章。現在你已經知道了如何通過某個已知的進程ID,調用PSAPI函數來獲取全路徑名。然後利用這個路徑名並調用GetWindowThreadProcessID函數獲取創建某個特定窗口的進程文件名。
在AttachProcess中必須調用OpenProcess來獲取大多數的進程信息,但是有可能出現拒絕訪問的錯誤。如果出現這種情況,我用了一個Keith Brown給出的方法,參見他在MSJ Aug99中的"Security Briefs"專欄文章,其中詳細討論瞭如何用高級別權限獲取進程句柄。細節請參見例子代碼的Helpers.cpp文件,其中有一個函數名叫GetProcessHandleWithEnoughRights,就是出自Keith Brown之手。
當某個進程是作爲另外一個用戶賬號計劃任務而運行的時候,就會出現上述提到的拒絕訪問問題。即便是Windows任務管理器都無法終止這樣的進程,它只顯示一個象下面這樣的對話框。如圖九:
圖九 無法終止的進程
如果你在ProcessSpy例子程序裏的某個進程上雙擊鼠標按鍵,它將終止這個進程。你看一下在例子代碼中的SlayProcess函數就會知道。此輔助函數調用了GetProcessHandleWithEnoughRights來獲取進程句柄,但訪問權限參數是PROCESS_TERMINATE,而不是在AttachProcess裏所用的PROCESS_QUERY_INFORMATION | PROCESS_VM_READ。
最後是用GetProcessOwner獲取進程運行的用戶賬號(格式爲//Domain/User),通過用TokenUser作爲參數調用GetTokenInformation,然後用LookupAccountSid將返回的用戶SID轉換爲人可讀的域名和用戶名。有時OpenProcessToken會因爲遇到象System這樣的進程而調用失敗,甚至是Windows 2000 資源開發包中的PULIST.EXE遇到這種情況都無法顯示出擁有進程的用戶。只有ProcessExplorer(Sysinterals公司開發的一個工具軟件)能成功找到此"安全的"應用的所有者。本文稍後會討論Windows XP中如何用WTS APIs(也就是Windows Terminal Services API--Windows終端服務API)來獲取進程的宿主。
|
列舉加載的模塊
任何時候通過 PSAPI 或 TOOLHELP32 都可以列出某個進程加載的 DLLs 列表。在寫此文前的調研過程中,我研究了 Matt Pietrek 以前在 MSJ Under The Hood 專欄中的一篇文章,其內容是討論如何使用 TOOLHELP32 來實現前述的功能,我發現在 Windows 2000 和 Windows XP 環境中是有問題的,代碼不能正常工作,現將其代碼摘錄如下:
用TOOLHELP32遍歷模塊
// //通過取得ToolHelp32 進程快照,枚舉此進程的模塊列表 // HANDLE hSnapshotModule; hSnapshotModule = pfnCreateToolhelp32Snapshot( TH32CS_SNAPMODULE, procEntry.th32ProcessID ); if ( !hSnapshotModule ) continue; // 迭代快照中每一個模塊 MODULEENTRY32 modEntry = { sizeof(MODULEENTRY32) }; BOOL fModWalkContinue; for (fModWalkContinue = pfnModule32First(hSnapshotModule,&modEntry); fModWalkContinue; fModWalkContinue = pfnModule32Next(hSnapshotModule,&modEntry) ) { // 確定是否爲EXE文件本身,如果是,則不將它加入模塊列表 if ( 0 == stricmp( modEntry.szExePath, procEntry.szExeFile ) ) continue; // 確定是否爲我們已有的DLL PModuleInstance pModInst = modList.Lookup(modEntry.hModule, modEntry.szExePath ); // 如果以前沒有見過,則將它加入列表 if ( !pModInst ) pModInst = modList.Add( modEntry.hModule, modEntry.szExePath ); // 將此進程加入到使用此DLL的進程列表 pModInst->AddProcessReference( procEntry.th32ProcessID ); } CloseHandle( hSnapshotModule ); // 完成模塊列表快照其實並不是程序有什麼瑕疵,主要是時過境遷,導致代碼中一個if語句的使用無效,畢竟 Matt Pietrek 寫那篇文章的時候(其代碼是1998.9 在 MSJ 上發佈的),Windows 2000 還不知道在哪裏呢!
那個無效的 if 語句是這樣的:由於 CreateToolhelp32Snapshot 調用失敗時不會返回 NULL,所以下面的錯誤處理代碼是無效的:
if ( !hSnapshotModule ) continue;實際上,如果失敗,hSnapshotModule的值爲INVALID_HANDLE_VALUE或-1,並且這個if語句是捕獲不到它的,這到沒什麼,關鍵是如何發現這個bug。當我在Windows 2000上測試ProcessSpy時,一切運行正常,只是當列表框即便爲空的時候,程序也沒有返回某些進程的出錯信息。由於錯誤處理代碼本身是錯的,執行跳過了循環,Module32First調用失敗,但沒有任何實質性的錯誤。如果你在Windows 2000環境用Matt Pietrek的這篇文章提供的ModuleList工具,你將得到不正確的結果。
爲了搞清楚代碼運行中發生的事情,用本文實例代碼包含的Helpers.cpp 文件中提供的GetLastErrorMessage輔助函數可以有助於你看得更清楚。他調用GetLastError 和 FormatMessage以純文本形式獲取相應的失敗原因。失敗原因都一樣:Access Denied,也就是拒絕存取。但是使用PSAPI函數時,當獲得相同進程的模塊列表時不存在存取問題。
之所以發生存取問題,是由於缺乏優先級。使用TOOLHELP32 的代碼要正常工作必須得有 SE_DEBUG_NAME 優先級。有關這個問題的詳細信息,請參考 1998.3 MSJ 的 Q&A Win32 專欄以及 1999.8 的 Security Briefs 專欄
關於 DLL 的方方面面
用 PSAPI 和 TOOLHELP32 兩種途徑獲得的某個進程所加載的模塊列表只反映地址,在這個地址處,DLL被映射到地址空間。下一步便是儘可能完整地獲取關於DLL的描述。我的實現並不象在CProcess中所做的那樣提供單獨的 AttachModule 方法。因爲要獲取某些細節信息代價實在太高,因此我選擇將它們分割成不同的函數。最不值錢的信息從 CModule 的構造函數獲得,其它信息的獲取要到相應的存取器方法被調用(通過 Refresh 函數)。實現細節請參考 Module.cpp 文件。其 Refresh 方法模仿了 Matt Pietrek 的 CModuleList 中的Refresh/RefreshTOOLHELP32 方法。表三列出了 CModule 的存取器方法:
存取器 |
說明 |
HMODULE GetModuleHandle |
DLL被映射的地址 |
CString& GetFullPathName |
源自TOOLHELP32::Module32xxx 或PSAPI::GetModuleFilenameEx |
CString& GetPathName |
同GetFullPathName |
CString& GetModuleName |
同GetFullPathName |
我前面提到過,想要獲取模塊的全路徑名需要一點訣竅。由於一些原因,GetModuleFilenameEx 或 TOOLHELP32 模塊函數返回的模塊名很奇怪,它們不遵循 Win32 的命名標準。例如以smss爲例,返回的名字是"/SystemRoot/System32/smss.exe",這裏"/SystemRoot"必須用Windows文件夾的實際名字來替換。又如 wonlogon.程序,返回的名字是"/??/C:/WINNT/system32/winlogon.exe",應該轉換成"C:/WINNT/system32/winlogon.exe"。"/??/"前綴是 Windows NT 名字空間的殘留物,是 kernel 模式中的東西,即便是Win32編程也很少用到它。我寫了一個輔助函數 TranslateFilename 用於將這些文件名轉換成更標準的形式。此函數的細節請參考下載源代碼中的Helpers.cpp 文件。
我用 Refresh 方法採集其餘的模塊描述,具體實現請參考 Module.cpp 文件,下面是對它的一個概述,詳細的存取函數見表四:
存取器 |
說明 |
DWORD GetBaseAddress |
使用PE_EXE::GetImageBase 來獲得首選的加載地址 |
void GetFileTime(FILETIME& ft) |
用KERNEL32.DLL 輸出的API GetFileTime來獲悉何時被創建、修改和最後一次存取 |
CString& GetFileTime |
獲得與上一個函數相同的信息,但這裏是文本格式,使用GetFileDateAsString/GetFileTimeAsString 輔助函數 |
DWORD GetFileSize |
用PE_EXE::GetFileSize 獲取文件的大小,以字節爲單位 |
CString& GetSubSystem |
用PE_EXE::GetSubSystem 獲悉IMAGE_SUBSYSTEM_xxx模塊子系統之一,在winnt.h 文件中定義,在這個文件的最新版本中可以找到IMAGE_SUBSYSTEM_XBOX |
void GetLinkTime(FILETIME& ft) |
用PE_EXE::GetTimeDateStamp 獲取模塊的鏈接時間 |
CString& GetLinkTime |
獲得與上一個函數相同的信息,但這裏是文本格式,使用GetFileDateAsString/GetFileTimeAsString輔助函數 |
WORD GetLinkVersion |
用PE_EXE::GetLinkerVersion 獲取用於構造此模塊的鏈接器版本 |
大部分的描述信息都是從文件本身吸取出來的,同時藉助了 Matt Pietrek 所寫的幾篇文章中有關PE格式的知識。
如果你想了解更多有關 PE 文件的細節,請閱讀 Matt Pietrek 的這些文章,其中重點是 PE_EXE 類和 PEDUMP 實現。其代碼對於諸位具有很高的參考價值。
GetBaseAddress 一個有趣的使用方法是將它的返回值與 GetModuleHandle 的返回值進行比較。後者是實際的地址,正是在這個地址,模塊被加載到進程地址空間裏,而前者的地址是模塊希望被加載的地址。這正好用來發現加載是否衝突。
當一個進程啓動時,Windows 加載程序自動加載靜態DLLs。這些靜態鏈接的東西很容易用PE格式和 MODULE_DEPENDENCY_LIST 類通過編程獲得。沒有哪個API能掃描到這些模塊與那些用 LoadLibrary 或 CoCreateInstance 動態加載的模塊之間的差別。如果一個DLL被某個進程使用,但它又不在靜態鏈接之列,那麼它就應該是動態加載的。
在 ProcessSpy 的輸出畫面中,如圖四,底下的窗格中每一個模塊都有一個前綴圖符,圓形的D表示動態加載的,方形的S表示靜態加載的。它們的顏色也有不同的意思,紅色表示這個模塊的基地址與其加載地址是不同的,反之則爲淺藍色。
除了從文件本身吸取描述信息外,還可以從它的資源版本中獲取其它描述信息。Paul DiLascia 在他的 C++Q&A 專欄(MSJ 1998.4)文章中爲我們提供了一個很帥的打包類 CModuleVersion,用這個類可以方便地獲得資源版本中對模塊的描述信息。對於每一項VS_VERSION_INFO 細節都有存取函數,這些函數返回 CString 引用,都是由 CModuleVersion::GetFileVersion 用相應的串填寫。GetCompanyName 就是一個很好的例子。
爲了滿足我的需要,我對 Paul DiLascia 的代碼進行了修改。GetFileVersionInfo 方法應該得到模塊的名字,而不是真正的文件名。爲了獲取相應的文件名,調用 GetModuleHandle。如果在當前的進程空間中查找模塊失敗(這種情況罕見)。爲了解決這個問題,當給定的模塊名就是實際的執行文件名時(用 GetFileAttributes 可以判斷出來),則直接使用它即可。
Windows 提供的資源信息不僅僅限於公司名這麼簡單,通常還有更多的東西,例如,從中可以很容易知道應用程序是否爲Debug版本,是否是私隱或特別版。你必須看一下 VS_FIXEDFILEINFO 結構中的 dwFileFlags 標誌。MSDN文檔對它的描述是包含一個位碼(bitmask)值,這些位碼值的含義請參考表五:按照版本信息對文件進行分類:
(表五)
標誌 |
描述 |
VS_FF_DEBUG |
包含調試信息或者編譯時是按可調試方式編譯的 |
VS_FF_INFOINFERRED |
動態創建版本結構,因此這個結構中的某些成員可能爲空或不正確。在文件的VS_VERSIONINFO數據中決不能設置此標誌 |
VS_FF_PATCHED |
已經被修改並且與原來同一版本號的文件不相同了 |
VS_FF_PRERELEASE |
開發版本,非商業發佈產品 |
VS_FF_PRIVATEBUILD |
沒有用標準的發佈過程構造,如果設置了此標誌,則StringFileInfo 結構應該包含PrivateBuild 項 |
VS_FF_SPECIALBUILD |
由原公司用標準的發佈過程構造,但是相同版本號的標準文件的變種。如果設置此標誌,則StringFileInfo 結構應該包含SpecialBuild 項 |
在相同版本的結構中,dwFileType域定義了文件類型,參見表六:dwFileType域中的標誌
(表六)
標誌 |
描述 |
VFT_UNKNOWN |
系統未知 |
VFT_APP |
包含一個應用程序 |
VFT_DLL |
包含一個動態鏈接庫(DLL) |
VFT_DRV |
包含一個設備驅動程序,如果dwFileType 是VFT_DRV,則dwFileSubtype 包含進一步的關於此驅動程序的描述 |
VFT_FONT |
包含一種字體,如果dwFileType 是VFT_FONT,則dwFileSubtype 包含進一步的字體文件描述 |
VFT_VXD |
包含一個虛擬設備 |
VFT_STATIC_LIB |
包含一個靜態鏈接庫 |
ProcessSpy 使用這些標誌來表示版本欄(Version),用D表示 Debug,用P表示補丁,參見圖四。
下一回內容預告
本文以後的內容將討論幾種用非常規方式來獲取一些附加的信息源。也就是說如果在沒有可藉助的 API 的情況下,你就可以用這幾種非常規方式。其中包括我至今未曾提到的一個主要信息源,那就是 Windows 的外殼(Shell)。在模塊文件中隱藏一個文件的時候, 關於某個文件的信息,沒有人比 Windows 資源管理器知道的更多。如圖十八所示:
圖十八 用資源管理器查看文件信息
那麼如何從自己的程序中打開或者調用 Windows 資源管理器文件屬性對話框呢?關於這個請參考精華區的一小段代碼。其關鍵是先填寫 SHELLEXECUTEINFO 結構,注意結構中的 fMask 成員一定要用 SEE_MASK_INVOKEIDLIST 賦值,然後調用 ShellExecuteEx API 函數,如:
SHELLEXECUTEINFO sei; ZeroMemory(&sei,sizeof(sei)); sei.cbSize = sizeof(sei); sei.lpFile = szFilename; sei.lpVerb = _T("properties"); sei.fMask = SEE_MASK_INVOKEIDLIST; ShellExecuteEx(&sei);
在 ProcessSpy 程序界面底部窗格中任何一個模塊記錄上雙擊鼠標便可以調出文件的屬性對話框,相應模塊文件的描述信息一目瞭然。注意 Windows XP 中不支持多個 ShellExecuteEx 調用,當你調用第二次時,線程凍結,也不會有任何提示。
正如你所看到的,有許多方法都可以獲得加載 DLLs 以及活動進程的信息。我在本文中提供的幾個工具可以作爲一個很好的學習開端,你完全可以借鑑文本描述的方法以及所提供的 C++ 類來定製滿足自己需要的調試工具。
本文前面的部分討論瞭如何用有着良好文檔描述的 API 函數來獲取運行進程列表以及它們加載的 DLLs 信息。接下來我將用不同的方法,或者說是非正式的方法來獲取系統級信息,首先,我將深入分析 Win32 調試 API 以及 Windows 加載器(Windows Loader)提供的痕跡來揭示給定進程是如何加載 DLL 的。我將藉助我的 CApplicationDebugger 可重用類,用幾種不同的方法來分析 DLL 重定位的原因。
接着,我將生成兩個工具。LoadLibrarySpy 掃描 DLL 重定位。WindowDump 竊取任何窗口的內容和詳細描述信息。最後,在討論進程環境塊(PEB)內部結構之前,我會向你展示如何操縱控制檯程序產生的輸出以便摸索尋找一些未公開的信息。
回到 DLL Hell
前面我們已經看到獲取所有靜態或動態加載的 DLLs 列表是很容易的事情。但是對動態加載的DLL而言,情況比想象的稍微複雜一些。例如,DllSpy 和 ProcessSpy 兩個工具依據某個時間點獲得的快照。因此,有可能出現來不及掃描某個被快速加載和卸載的DLL。Win32 調試 API 提供了對這個問題的解決辦法:在調試程序時, 這些 API 可以對被調試程序加載和卸載的任何DLL瞭如指掌。
要實現我的意圖,並不需要一個功能完整,名副其實的調試器,但我必須偵測到新 DLL 何時被加載到進程地址空間。因此,我將討論 Win32 調試 API 的基本知識以及它們在 Windows NT、Windows 2000 和 Windows XP 操作系統中有用的擴展。
爲了調試一個程序,你首先必須使用用下面這些特殊的標誌之一調用 CreateProcess 來啓動擬調試的程序。DEBUG_PROCESS 表示請求來自被調試程序以及被調試程序啓動的每一個進程的事件。DEBUG_ONLY_THIS_PROCESS 表示只請求來自被調試程序的事件(而不是來自其子進程的事件)。
使用 DEBUG_ONLY_THIS_PROCESS 標誌時,調試器將接收不到來自被調試程序啓動的進程事件。性能監視器(perfmon.exe)就是一個很好的例子,此標誌對這個程序沒有作用。性能監視器是一個簡單的打包程序,其作用 只不過是啓動另外一個程序——微軟管理控制檯(MMC),並傳遞任何所需的參數使它顯示性能計數器。
在被調試程序的生命期內,Windows 通知調試器 Figure 1 所列出的事件。這些事件由 DEBUG_EVENT 結構描述,如 Figure 2 所示。
爲了接收這些事件,調試器必須調用 WaitForDebugEvent。該函數阻塞調試器的運行,直到被調試程序發生 Figure 1 所列的事件之一,或者超時參數中給定的秒數爲止。當調試器處理某個事件時,它調用 ContinueDebugEvent 讓被調試程序繼續其生命之旅。注意:在調試器中,當 WaitForDebugEvent 解除阻塞時,所有被調試者線程被凍結,在調用 ContinueDebugEvent 期間被解凍。參見 Figure 3:
Figure 3 調試事件流
CApplicationDebugger
調用 CreateProcess 的線程必須是進入調試循環的線程。既然調試器阻塞於 WaitForDebugEvent,因此最好讓這部分代碼運行在一個與主UI線程不同的專門線程中。本文將其行爲包裝在 CApplicationDebugger 類中,其聲明參見本文附帶源代碼中的 ApplicationDebugger.h 文件,這個類的一部分靈感還來自 Matt Pietrek 的 LoadProf32(參見 MSJJul95.exe)。
CApplicationDebugger 是一個虛擬類,因爲你得從它派生並實現自己的重寫版本,以便特定的調試事件發生時進行相應的調用。這個類被用於生成 LoadLibrarySpy(參見 Figure 4),這是一個調試程序和監控 DLL 加載和卸載的工具,不論是靜態加載還是動態加載,也不論是不是有加載地址衝突,它都能監控。
Figure 4 LoadLibrarySpy
調用 CreateProcess 是在 CApplicationDebugger::LoadTheProcess 中進行的,爲簡單起見,參數使用 DEBUG_ONLY_THIS_PROCESS。如果需要,你可以將 CApplicationDebugger 擴展成能處理來自多個被調試進程的事件,對於 MMC 管理單元(snap-ins)很有用。
CLoadLibrarySpyDlg 類負責對話框自身的處理,同時也是暗中監視 CApplicationDebugger 派生類的線程宿主。CModuleListCtrl 類負責顯示附屬到每個DLL的詳細信息 CModuleInfo*;針對每個 DLL,這個類存儲的詳細信息見 Figure 5。
當某個 DLL 被加載,對話框便調用 AddModule 方法;反之卸載DLL時,則執行 RemoveModule 方法。這兩個方法都以 UpdateModule 方法告終,從而更新與該 DLL 對應的 CModuleObject 對象的 m_nLoaded 或 m_nRemoved。如果不存在這樣的對象,則會創建一個新的對象,並將它添加到列表框中。
不要爲 m_nLoaded 或 m_nRemoved 而困惑。如果你針對某一行的相同 DLL 多次調用 LoadLibrary,調試器只會收到 LOAD_DLL_DEBUG_EVENT 一次,並且 m_nLoaded 被賦值爲 1。如果調試器接收到某個 DLL 的 UNLOAD_DLL_DEBUG_EVENT,你便可以確定該 DLL 不再被該進程使用。因此,對於靜態 DLLs 而言,你決不會收到此事件,即使可能在進程被啓動後,它們被動態加載並用 LoadLibrary/FreeLibrary 卸載。
處理被調試程序的事件
一旦被調試程序的進程啓動後,調試器便等待某些事件的發生。這就是爲什麼它應該在一個與主 UI 線程不同的單獨線程中的原因,當主窗口是一個模式對話框時尤其如此!
爲了在 CLoadLibrarySpyDlg 中有效地使用 CApplicationDebugger,GoThreadProc 線程過程首先聲明一個 CApplicationDebugger 對象,指定要執行的命令行並說明是否截獲來自被調試程序的 OutputDebugString 或 TRACE 輸出。接着,DebugProcess 阻塞,直到被調試程序終止(接收 EXIT_PROCESS_DEBUG_EVENT 或第二次的未處理異常),或者重寫的方法之一未返回 DBG_CONTINUE。
線程與對話框之間的溝通機制很簡單:當某個被調試事件發生時,調試器線程將 Figure 6 中所列的消息發送到對話框。其中第一個消息是在加載了所有靜態鏈接的 DLLs 時發送;也就是說,當 Windows 觸發第一個(僞)斷點時,便發信號給調試器,然後調試器調用可重寫的 OnProcessRunning 將消息發送給對話框。第二個消息是當被調試程序卸載某個 DLL 時,由可重寫的 OnUnloadDLLDebugEvent 調試事件處理例程發送
第三個消息需要所解釋幾句,爲了創建 CModuleInfo,需要 DLL 的全路徑名。而在本文第一部分中,我們沒有提供任何方法直接從其 hModule 或加載地址獲取 DLL 文件名。即便是當調試器接收到此事件時(因爲它可能瀏覽到了它的 PE 頭),DLL已經被映射到被調試程序的地址空間,這時,Windows 還沒有初始化 PSAPI 所需的數據結構。
事實上,LoadDll.lpImageName 域是一個 LOAD_DLL_DEBUG_INFO 結構成員,LOAD_DLL_DEBUG_INFO 來自 DEBUG_EVENT 結構中的聯合 u(參見 Figure 2),LoadDll.lpImageName 總是指向被調試程序地址空間中一塊具備讀/寫/執行權限的奇怪的內存區域,LOAD_DLL_DEBUG_INFO 結構定義如下:
typedef struct _LOAD_DLL_DEBUG_INFO { HANDLE hFile; LPVOID lpBaseOfDll; DWORD dwDebugInfoFileOffset; DWORD nDebugInfoSize; LPVOID lpImageName; WORD fUnicode; } LOAD_DLL_DEBUG_INFO, *LPLOAD_DLL_DEBUG_INFO;
被加載的DLL的路徑名就包含在此內存塊中。MSDN 在線幫助文檔是這樣描述 IpImageName 的:
“...與 hFile 關聯的文件名指針。該成員可能爲 NULL,也可能包含被調試進程地址空間中的串指針地址。這個地址可能爲 NULL 或者指向實際的文件名。 如果 fUnicode 是一個非零值,則名字串是 Unicode,否則是 ANSI 串。該成員是可選項。調試器必須考慮處理 lpImageName 爲 NULL 或 *lpImageName(在被調試進程的地址空間中)爲 NULL 的情況。很顯然,系統決不會爲某個創建進程事件提供映像名,同時它也不可能爲第一個 DLL 事件傳遞映像名。系統也決不會在源於 DebugActiveProcess 函數調用的調試事件中提供這個信息。” |
OnLoadDLLDebugEvent 可重寫方法將上述解釋翻譯爲在 99% 的情況下可工作的純 C++ 代碼。其餘 1% 不工作的情況是指加載 ntdll.dll:這種情況既是文檔中所說的第一個 DLL 事件。即使延遲到下一個被調試程序事件發生時(參見 CLoadLibraryDebugger 的 OnDebugEvent)才獲取路徑名。在文檔的描述中,可以調用 SearchPath 從模塊名獲得全路徑名,“system32”對於 ntdll.dll 並不感到驚訝。這個 API 函數使用與 LoadLibrary 同樣的算法在文件系統中查找某個 DLL。從理論上講,因爲它是由調試器調用的,有可能返回的文件並不是被調試程序加載的那個文件——例如,在調試器文件夾中存在另外一個版本的 ntdll.dll。在實際應用中,ntdll.dll 得不到打補丁的機會,並且被拷貝到了某個與 system32 不同的目錄。
防止泄漏
文檔中關於 Win32 調試 API 的另一方面的描述是必須釋放不同的 XXX_DEBUG_EVENT 結構返回的句柄。Matt Pietrek 在其 November 1995 MSJ“Under the Hood”專欄文章中指出:在 XXX_DEBUG_EVENT 結構中返回到調試器的句柄應該被關閉。事實上,幾乎每個句柄都必須用 CloseHandle 關閉。只有一個例外,就是存儲在 CREATE_THREAD_DEBUG_EVENT 中的線程句柄,它應該在進程終止時由系統來關閉。其它的句柄如果不關閉,便會造成增長速度非常快的系統資源泄漏,有關的句柄如 Figure 7 所示。這類垃圾的收集由 CApplicationDebugger::HandleDebugEvent 自動處理。
不論你使用哪種清除方法,每次你調試某個進程時,系統不可避免地要泄漏兩個句柄:信號機(semaphore )和端口(port),兩者都沒有命名。爲了讓你確信 CApplicationDebugger 不負責處理這種泄漏,請允許我指出:用 sysinternals 的 ProcessExplorer 或 Windows Resource Kit 中的 DH.EXE 可以觀察到 Visual Studio 6.0 和 Visual Studio .NET 中同樣的泄漏行爲。
現在你已經看到了如何用 Win32 調試 API 來獲取某個進程執行期間在其地址空間中加載和卸載的 DLLs 確切列表。Windows 本身提供了另外一個途徑來獲取有關 DLLs 的其它詳細信息。
Windows Loader 知道一切
除了 Win32 調試 API 之外,Windows 還提供另外一種很好的關於 DLL 加載地址衝突的信息源。那就是在註冊表中設置的一些全局標誌(或 GFlags):
HKEY_LOCAL_MACHINE/SOFTWARE/Microsoft/Windows NT/CurrentVersion/Image File Execution Options
從而改變 Windows 處理應用程序的方式。GFlags.exe (see Figure 8) 是一個微軟調試工具之一,用它可以輕鬆更改上述的註冊表項值。
Figure 8 全局標誌
在 1999 九月的 “Under the Hood”專欄中, Matt Pietrek 解釋瞭如何將 FLG_SHOW_LDR_SNAPS 與上述 GFlags 結合使用讓 Windows Loader 產生一些有用的跟蹤信息。如果你想捕獲這些跟蹤信息,你有兩個選擇:第一個是調試應用程序,然後象所做的 CApplicationDebugger 那樣解釋 OUTPUT_DEBUG_STRING_EVENT。另外一個方法比較容易:使用全局捕獲工具。如果你想要生成自己的跟蹤信息,使用 Sysinternals 或《Inside Windows 2000, Third Edition》CD 中的 DbgView,這個工具還可以顯示內核跟蹤信息。
在 LoadLibrarySpy 工具中,啓動被調試程序之前,與被調試程序對應的 GFlags 值會被 CApplicationDebugger 的 PreLoadingProcess 更新,其以前的值會被保存在 PostMortem 中,也就是說,因爲使用“LDR”作爲前綴,所以調試器從 Windows Loader 得到的專用輸出信息很容易在 OnOutputDebugStringDebugEvent 中過濾。
這種 Loader 日誌的一個主要優點是輸出信息前都有一個 LDR:自動化 DLL 重定位信息。它解釋哪個 DLL 與另外的 DLL 有地址衝突。這既是 CModuleListCtrl 獲取 Reason 欄數據的方法。不幸的是,Windows 2000 Loader 好像抑制這種特定的輸出信息。如果你過去習慣於通過加載某個進程來存取其資源,如 explorer.exe 的動畫或圖標,0x400000 加載地址通常已經被你的程序使用,Loader 會自動進行重定位。在這種特殊情況下,即便是在 Windows NT 4.0,它都不會發出 LDR:爲動態加載進程自動化 DLL 重定向。
另外一個解決方案是枚舉每個加載的 DLL 並與專用的地址空間區域(從 hModule 開始)進行比較,從而找到衝突者(實現細節參見 CLoadLibraryDebugger::OnLoadDLLDebugEvent)。加載器還提供另外一個帶前綴“LDR:Loading (DYNAMIC)”的有趣的信息,同時其後跟隨模塊的全路徑名。當某個 DLL 被顯示通過 LoadLibrary 加載時,似乎就是這種情況。
使用這些來自 Windows Loader 的線索,LoadLibrarySpy 根據加載狀態爲每個 DLL 提供了一個專門的圖標,詳情參見 Figure 9。
帶方形圖標的 DLLs 是在進程初始化期間加載的,稱爲靜態加載。帶圓形圖標的則是在進程初始化之後加載的,因此稱爲動態加載。圖標的顏色預示着是否有加載地址衝突,紅色表示有,藍色表示沒有。
那些帶黑色背景與其它動態 DLLs 之間的區別很微妙:帶黑色圖標的 DLL 已被加載,要麼是用 LoadLibrary 顯式加載,要麼是用其它類似 CoCreateInstance 的 API 函數加載。沒有黑色背景圖標的 DLL 已被加載,因爲另外一個 DLL需要它。例如,在 Figure 4 中,BROWSEUI.dll 有一個黑色圖標,因爲它已被動態加載。而 SHDOCVW.dll 圖標沒有黑色背景,因爲它已被 Windows 自動加載。理由很簡單:BROWSEUI.dll 是靜態鏈接到 SHDOCVW.dll 的,所以爲了加載 BROWSEUI,Windows 也得加載 SHDOCVW。
另一種“盜取”信息的途徑
在結束 Win32 調試 API 的討論之前,我想用一點點時間討論異常處理機制。當被調試程序中有異常發生時,調試器通過 EXCEPTION_DEBUG_EVENT 收到通知,並且 u.Exception.ExceptionRecord.ExceptionCode 域中會包含此異常編碼。異常編碼都分佈在 WINNT.H 和 WINBASE.H 文件中,因此要獲得一個全面而且清晰易讀的異常編碼清單並不是件容易的事。CApplicationDebugger 的 GetExceptionDescription 方法將這些異常編碼轉換成可讀性更強的字符串。
另一個異常編碼清單信息源是 Visual C++ 本身。在調試應用程序時,“Debug”菜單中有一個“Exception”菜單項,它允許你選擇調試器處理異常的方式,如 Figure 10 所示:
Figure 10 Exceptions Dialog
你可能會感到驚訝,在這裏能發現沒有定義過的異常編碼。不用動手拷貝,從這個列表框“盜取”信息不是很好嗎。這便是 WindowDump 的目的。它允許你用鼠標拾取某個窗口(通過其句柄值)並將信息 dump 到一個編輯框中。此外,它還能收集類信息和式樣描述信息,如 Figure 11 所示。
Figure 11 WindowDump 中的異常編碼
WindowDump 的背後並沒有什麼玄機。唯一有趣的地方是 Windows 通常允許 GetWindowText 和 WM_GETTEXT 操作不同的進程。但對於列表視圖和樹型視圖這樣的公用控件除外。Jeffrey Richter 在他的 Q&A Win32(MSJ September 1997)專欄中解釋瞭如何 dump 另外一個進程中列表視圖的內容,附帶一個範例程序 LV2Clip。下面是一些 WindowDump 能盜取其內容的窗口類:Edit、ScrollBar、ListBox、ComboBox、ListView 和 TreeView。根據這些類的窗口內容,你還能得到 Figure 12 所列出的信息。
有關 WindowsDump 實現的最後一個重點是進程 ID。從窗口句柄入手,使用 GetWindowThreadProcessId 不難確定線程以及負責創建該線程的進程。如果你還想知道模塊名,用 GetWindowModuleFileName 可能會碰壁。與在文檔中給出的信息相反,這個 API 函數在 Windows NT、Windows 2000 或 Windows XP 下調用失敗。你得鑽研知識庫的文章 Q228469 查明原因。
在這樣情況下,你應該用 PSAPI 及其 GetModuleFileNameEx 函數。它以進程和 hModule 模塊句柄爲參數,返回對應的路徑名。爲了查出某一個進程的可執行文件路徑名,hModule 應該爲 0。不要使用 0x400000:某些進程被加載到不同的地址,如 winlogon 和 Task Manager 在 0x1000000,ntvdm 在 f000000 以及Microsoft Word 2000 在 0x30000000。