《Undocumented Windows 2000 Secrets》翻譯 --- 3
第一章 Windows 2000對調試技術的支持
翻譯:Kendiv
更新:Monday, January 17, 2005
枚舉系統模塊和驅動(Drivers)
psapi.dll可以返回當前內存中的內核模塊。這本是非常簡單的工作。psapi.dll的EnumDeviceDrivers()函數接受一個PVOID類型的數組,它將用當前活動的內核驅動模塊(active kernel-mode driver)的映像基址(image base address)來填充這個數組,這包括基本的內核模塊ntdll.dll、ntoskrnl.exe、Win32K.sys、hal.dll和bootvid.dll。返回值是這些可執行文件映射到的虛擬內存地址(譯注,也稱作線性地址)。如果你使用內核調試器或其他調試工具檢查這些地址的最初幾個字節,你將清楚地認出那個有名的Dos stub程序,它以著名的Mark Zbikowski的首字母大寫“MZ”開始,內含一個文本消息--“This program cannot be run in DOS mode”或類似的東西。列表1-3展示了一個使用EnumDeviceDrivers()的簡單函數,以及EnumDeviceDrivers函數的原型。
BOOL WINAPI EnumDeviceDrivers ( PVOID* lpImageBase,
DWord; cb,
PDWORD lpcbNeeded);
PPVOID WINAPI dbgDriverAddresses( PDWORD pdCount )
{
DWORD dSize;
DWORD dCount = 0;
PPVOID ppList = NULL;
dSize = SIZE_MINIMUM * sizeof( PVOID );
while ( (ppList = dbgMemoryCreate(dSize)) != NULL )
{
if ( EnumDeviceDrivers( ppList, dSize, &dCount) && (dCount < dSize) )
{
dCount /= sizeof( PVOID );
break;
}
dCount = 0;
ppList = dbgMemoryDestroy( ppList );
if ( (dSize <<= 1) > (SIZE_MAXIMUM * sizeof( PVOID )))
{
break;
}
}
if ( pdCount != NULL )
{
*pdCount = dCount;
}
return ppList;
}
列表1-3枚舉系統模塊地址
EnumDeviceDrivers()期望三個參數:一個數組指針,一個表示輸入大小的值以及一個用于輸出的類型為DWORD的變量。第二個參數指定了傳入的數組的字節數,第三個參數表示復制到該數組中的字節數。因此,你必須將返回值除以sizeof(PVOID)來確定有多少個地址數據復制到了數組中。不幸的是,該函數不能幫助你確定該提供多大的數組,盡管它實際上知道有多少個Driver在運行。但它僅僅告訴你返回了多少字節,而且,如果數組太小,它會隱藏多出的字節。因此,你必須使用無聊的trial-and-error循環來確定適當的數組大小,就如同列表1-3所示的那樣,只要返回值與數組大小相同就假定還有數據未復制到數組中。在剛開始時,代碼中使用了一個合理的最小值--256(由SIZE_MINIMUM表示),這通常都足夠大了,但是如果不夠的話,在開始新的循環時,數組大小會增加為原來的2倍,直到獲取了所有的指針或者數組大小超過了65,536。數組使用的內存緩沖區由兩個幫助函數dbgMemoryCreate()和dbgMemoryDestroy()提供,這兩個函數只是Win32函數LocalAlloc和LocalFree的外包而已,這兒就不列出了。
BOOL WINAPI EnumDeviceDrivers( PVOID* lpImageBase,
DWORD; cb,
DWORD* lpcbNeeded)
{
SYSTEM_MODULE_INFORMATION; smi;
PSYSTEM_MODULE_INFORMATION psmi;
DWORD;;;dSize, i;
NTSTATUSns;
BOOL;;;;fOk = FALSE;
ns = NtQuerySystemInformation( SystemModuleInformation,
&smi, sizeof(smi),NULL);
if ( (STATUS_SUCCESS == ns) | (STATUS_INFO_LENGTH_MISMATCH == ns) )
{
dSize = sizeof(SYSTEM_MODULE_INFORMATION)
+ ;(smi.dCount*sizeof(SYSTEM_MODULE));
if ( (psmi = LocalAlloc(LEME_FIXED,dSize)) != NULL )
{
ns = NtQuerySystemInformation( SystemModuleInformation,psmi,dSize,NULL );
if ( ns == STATUS_SUCCESS )
{
for( i = 0; (i < psmi->dCount) && (i < cb/sizeof(DWORD))i++)
lpImageBase[i] = psmi->aModules[i].pImageBase;
*lpcbNeeded = i*sizeof(DWORD);
fOk = TRUE;
}
LocalFree(psmi);
if ( !fOk ); SetLastError( RtlNtStatusToDosError(ns) );
}
}
else
SetLastError( RtlNtStatusToDosError(ns) );
return fOk;
}
列表1-4; EnumDeviceDrivers函數的示列
列表1-4列出了EnumDeviceDrivers()一種可能的實現方式。注意這并不是來自psapi.dll的原始代碼。但通過C編譯器它可以變成等效的二進制代碼。為了保持簡單干凈,我省略了源代碼中易分散注意力的細節,比如結構化異常等。在列表1-4的中間,你會看到NtQuerySystemInformation()函數作了很多工作。這是我非常喜歡的Windows 2000函數之一,因為該函數可以訪問多種重要的數據結構,如驅動、進程、線程、句柄(handle)和LPC端口列表等等。我的文章“Inside Windows NT Sytem Data”(出版于1999年11月的Dr.Dobb’s Journal)在第一時間提供了有關該函數的內部信息及其搭檔函數NtSetSystemInformation()的文檔化資料。另外的全面講述這兩個函數的文檔可以在Gary Nebbett的《Indispendsable Windows NT/2000 Native API Reference》中找到。
不要過于擔心列表1-4列出的EnumDeviceDrivers()函數的實現細節。我增加這些代碼片斷只是為了例舉該函數有趣的一面,這像一根紅線貫穿于psapi.dll。在使用SystemModuleInformation標志第二次調用NtQuerySystemInformation()獲取了完整的驅動列表后,代碼遍歷驅動模塊數組并將其pImageBase成員復制到調用者提供的指針數組(名為lpImageBase[])中。這似乎很正確,但除非你不知道NtQuerySystemInformation提供的模塊數組所包含的其他信息。這些數據結構都是沒有文檔化的,但是我現在可以告訴你,這些信息同樣是有關模塊在內存中的大小、它們的路徑和名稱、引用計數(load counts)和其他一些標志信息的。甚至文件名在路徑中的偏移量也是很容易就能得到的!,EnumDeviceDrivers()殘忍的丟掉了所有這些有用的信息,僅僅保留了映像基址(Image Base address)。
所以如果你試圖通過返回的指針來獲取有關模塊的更多信息,則肯定會失敗。當你調用GetDeviceDriverFileName()來獲取指定映像基址對應的文件路徑時,猜猜psapi.dll會怎樣做?它會運行與列表1-4類似的代碼來獲取完整的驅動列表,并遍歷該列表來尋找指定的映像基址。如果它找到一個匹配項,就將其路徑復制到調用者的緩沖區中。這難道很高效嗎?為什么EnumDeviceDrivers不在它首次遍歷驅動列表時就復制路徑呢?按這樣的方式實現此函數并沒有多么困難。除去性能問題,這種設計還有另一個潛在的問題:如果在GetDeviceDriverFileName()執行之前指定的模塊就已經被卸載了會怎么樣呢?該模塊的地址將不會出現在第二次獲取的驅動列表中,GetDeviceDriverFileName()將會失敗。我真不明白微軟為什么會發布這樣的DLL。
枚舉活動進程
psapi.dll的另一個典型工作就是枚舉當前系統中運行的進程。為此目的,該DLL提供了EnumProcesses()函數。該函數的工作與EnumDeviceDrivers()十分類似,不過返回的是進程ID而不是虛擬地址了。再次提示,該函數并不會提示緩沖區大小不足,因此我們還需再次使用trial-and-error循環,如列表1-5所示,這些代碼和列表1-3很相似,除了有些不同的符號和類型名稱。
一個進程ID是一個全局數字標簽可在整個系統中唯一標識一個進程。進程和線程ID都取自同一個數字池(pool of numbers),從以0開始的Idle進程,在同一時間,所有運行的進程和線程都不會有相同的ID。但是,當一個進程結束后,另一個進程可能會再次使用該結束進程或線程的ID。因此,在X時間獲取的一個進程ID在Y時間可能會代表另一個完全不同的進程。也有可能在其使用的那一刻還沒有定義或者指定給了某個線程。所以,EnumProcesses()返回一個簡單的進程ID列表并不能可靠的代表當前系統活動進程的快照。如果考慮該函數的實現方式,這個設計缺陷真是無法原諒。列表1-6是psapi.dll另一個函數的克隆,大致勾勒出了EnumProcessees()的基本動作。和EnumDeviceDrivers()類似,它也依賴NtQuerySystemInformation()函數,不過在調用時,用SystemProcessInformation代替了SystemModuleInformation。注意列表1-6中間的循環,在哪兒lpidProcess[]數組被來自SYSTEM_PROCESS_INFORMATION結構中的數據填充。沒什么好驚奇的,該結構也沒有文檔化。
BOOL WINAPI EnumProcesses( DWORD* lpidProcess,
DWORD; cb,
DWORD* lpcbNeeded);
PDWORD WINAPI dbgProcessIds( PDWORD pdCount )
{
DWORD dSize;
DWORD dCount = 0;
PDWORD pdList = NULL;
dSize = SIZE_MINIMUM * sizeof( DWORD );
while ( (pdList = dbgMemoryCreate(dSize)) != NULL )
{
if ( EnumProcesses( pdList, dSize, &dCount) && (dCount < dSize) )
{
dCount /= sizeof( DWORD );
break;
}
dCount = 0;
pdList = dbgMemoryDestroy(pdList);
if ( (dSize <<= 1) > (SIZE_MXAIMUM*sizeof(DWORD)) ); break;
}
if ( pdCount != NULL ) *pdCount = dCount;
return pdList;
}
列表1-5; 枚舉進程ID
在看過EnumDeviceDrivers()是如何浪費從NtQuerySystemInformation()返回的數據后,不幸的是,EnumProcesses也是和其類似的函數,但,事實上,這個函數更糟糕!因為可用的進程信息要遠多于驅動模塊的信息,因為進程數據之后還包含很多有關系統中每個線程的詳細信息。在我寫下這段文字時,我的系統正運行著37個進程,調用NtQuerySystemInformation()產了一個24,488字節的數據塊!而當EnumProcesses()處理完這些數據后,僅剩下了148字節,這些剛好夠存放37個進程ID。
盡管EnumDeviceDirvers()讓我有些難過,但EnumProcesses()卻真正傷害了我的心。如果你需要使用未文檔化API函數的理由,那這兩個函數就是最好的證據。如果實際的工作只需一步既可完成,那為什么還要使用如此低效的函數呢?為什么不自己調用NtQuerySystemInformation()函數自由的獲取感興趣的系統信息?微軟提供的許多系統管理工具都依賴于NtQuerySystemInformation()而不是psapi.dll,so why settle for less?
BOOL WINAPI EnumProcesses( PDWORD lpidProcess,
DWORD; cb,
;PDWORD lpcbNeeded)
{
PSYSTEM_PROCESS_INFORMATION pspi, pSpiNext;
DWORD;;;;dSize, i;
NTSTATUS;ns;
BOOL;;;;;fOk = FALSE;
// 0x8000 = 32KB
for (dSize=0x8000; ((pspi = LocalAlloc(LMEM_FIXED,dSize)) != NULL);
;;dSize += 0x8000)
{
ns = NtQuerySystemInformation( SystemProcessInformation,pspi,
dSize, NULL);
if ( STATUS_SUCCESS == ns )
{
pSpiNext = pspi;
for ( i=0; i < cb/sizeof(DWORD); i++ )
{
lpidProcess[i] = pspiNext->dUniqueProcessId;
pSpiNext = (PSYSTEM_PROCESS_INFORMATION)
;;;;((BYTE)pSpiNext+pSpiNext->dNext);
}
*lpcbNeeded = i * sizeof(DWORD);
fOk = TRUE;
}
LocalFree(pspi);
if ( fOk || (ns != STATUS_INFO_LENGTH_MISMATCH) )
{
if ( !fOk) SetLastError(RtlNtStatusToDosError(ns));
break;
}
return fOk;
}
列表1-6; EnumProcesses()函數的示例實現
枚舉進程模塊
一但你從EnumProcess()返回的進程列表中發現了你感興趣的進程ID,你可能會想知道在此進程的虛擬地址空間中加載了哪些模塊。psapi.dll提供了另一個API函數來完成此功能,叫做EnumProcessModules()。與EnumDeviceDrivers()和EnumProcesses()不同,這個函數需要四個參數(參見列表1-7)。不同于前兩個返回系統全局列表的函數,EnumProcessModules()只取回指定進程的列表,因此,增加的那個參數唯一表示一個進程。然而,該函數需要一個進程句柄(HANDLE)來代替進程ID。為了通過進程ID獲取其句柄(HANDLE),必須調用OpenProcess()函數。
BOOL WINAPI EnumProcessModule( HNADLEhProcess,
HMODULE* lphModule,
DWORD;cb,
DWORD*lpcbNeeded);
PHMODULE WINAPI dbgProcessModules( HANDLE hProcess, PDWORD pdCount)
{
DWORD;dSize;
DWORD;dCount = 0;
PHMODULE phList = NULL;
if ( hProcess != NULL )
{
dSize = SIZE_MINIMUM * sizeof( HMODULE );
while ( (phList = dbgMemoryCreate(dSize)) != NULL )
{
if ( EnumProcessModules(hProcess,phList,dSize,&dCount))
{
if (dCount <= dSize)
{
dCount /= sizeof( HMODULE );
break;
}
}
else
{
dCount = 0;
}
phList = dbgMemoryDestroy(phList);
if ( !(dSize = dCount) ) break;
}
}
if ( pdCount != NULL) *pdCount = dCount;
return phList;
}
列表1-7; 枚舉進程模塊
EnumProcessModules()返回指定進程所有模塊的句柄的引用。在Windows 2000中,一個HMODULE只是簡單的模塊映像基址。在SDK頭文件windef.h中,HMODULE被定義為HINSTANCE的別名,二者都是HANDLE類型。嚴格的來講HMODULE并不是一個句柄。通常,句柄是系統管理的一個表的索引,可通過此表來查找對象屬性。系統返回的所有句柄都有一個與特定對象相關的計數器,在一個對象的所有句柄沒有返回系統時,該對象不能從內存中被移除。Win32 API提供了CloseHandle()函數用于關閉句柄。該函數與Native API NtClose()等價。有關HMODULEs最重要的事情是,這些“handles”不需要關閉。
另一件讓人困惑的事是,事實上,模塊句柄通常并不被保證是一直有效的。SDK的GetModuleHandle()函數文檔提示到,在多線程程序中必須更加注意模塊句柄,因為一個線程可以通過卸載HMODULE引用的模塊而讓另一個線程擁有的HMODULE無效。在多任務環境下,一個程序(如調試器)使用另一程序的模塊句柄時也許注意這一點。這似乎使HMODULEs沒有多大用處了,但是,在下面兩種情況中,HMODULE的有效性會保持足夠長的時間:
1.由LoadLibrary()或LoadLibraryEx()返回的HMODULE在進程調用FreeLibrary()之前都會一直有效,由于這些函數包含了模塊引用計數,所以即使在多線程程序中,這也會阻止模塊被意外卸載。
2.如果HMODULE指向的模塊會永久的存在,那么它也會一直有效。例如,所有Windows 2000內核組件(不包括內核模式的驅動程序)總是被映射到每個進程的相同固定地址上,并且在進程生命期里一直在那里。
不幸地是,這些情況并不適用于EnumProcessModules()函數返回的模塊句柄,至少通常不行。復制到調用者提供的緩沖區中的HMODULE,在獲取進程快照那一刻其所表示映像基址是有效的。稍后,進程可能調用FreeLibrary()來釋放一個或多個模塊,并將其從內存中移除,此時它們的句柄將無效,隨后進程很有可能立即調用LoadLibrary()加載了另一個DLL,而此新模塊恰好映射到了前面釋放的地址上。這看上去是不是很熟悉?是的,同樣的問題也存在于EnumDeviceDrivers()的指針數組和EnumProcesses()函數的ID數組。不過,這些問題是可以避免的。psapid.dll通過調用未文檔化的API函數來完成數據收集工作后,考慮這些數據的完整性,可返回一個完整的請求對象的快照,其中應包括所有感興趣的屬性信息。這樣就沒有必要在稍后調用另一個函數來獲取附加的信息了。我的觀點是,psapi.dll的設計過于簡單,因為它忽略了數據的完整性,這也是我不會將此DLL作為一個專業調試工具的基礎的原因。
與EnumDeviceDrivers()和EnunProcesses()函數相比EnumProcessModules()函數算是個好公民了,因為如果調用者提供的緩沖區不能放下全部的輸出數據,它會準確地提示有多少字節沒有復制。注意列表1-7沒有包括一個循環,在那里緩沖區會不斷增大直到足夠的大。然而,仍然需要trial-and-error循環,因為在下一次調用時,EnumProcessModules報告的所需大小可能已經無效了(如果指定進程在兩次調用之間又加載了新的模塊)。因此,列表1-7中的代碼將不斷枚舉模塊直到EnumProcessModules()報告需要的緩沖區等于或小于實際可用大小,或者出現了錯誤。
我不想描述EnumProcessModules()的等價函數,因為該函數要比EnumDeviceDrivers和EnumProcesses稍微復雜些,它涉及幾個未文檔化的數據結構。基本上,它還是通過調用NtQuerySystemInformation()函數(當然,該函數也沒有文檔化)來獲取目標進程環境塊(PEB)的地址,通過該地址可獲取一個模塊信息鏈表。因為不管是PEB還是這個鏈表在調用進程的地址空間都是無法直接使用的,EnumProcessModules調用Win32 API ReadProcessMemory()(該函數有文檔記載)來遍歷目標進程的地址空間。順便說一下,PEB結構的布局將在第7章討論,在附錄C中,可以找到該結構的定義。
調整進程特權
回憶一下稍早討論過的有關EnumProcessModules所需的進程句柄。通常,你首先得到的是進程ID---可能是EnumProcesses返回的進程ID集中的一個。Win32 API OpenProcess()可通過進程ID來獲取其句柄。這個函數期望一個訪問標志符作為其第一個參數。假定進程ID存放在一個DWORD類型的變量dId中,你以最大訪問權限來調用OpenProcess,如下:
OpenProcess(PROCESS_ALL_ACCESS,FALSE,dId)
以獲取該進程的句柄,此時你會收到一個針對幾個低ID進程的錯誤代碼。這并不是bug---這是安全特性!這些進程都是保持系統活動的系統服務。一個普通用戶進程不允許執行針對系統服務的所有操作。例如,允許所有進程都可以殺死系統中其余進程并不是個好主意。如果一個程序意外終止了一個系統服務,那么整個系統都將崩潰。因此,一個進程只有擁有確切的訪問權限才會有適當的特權。
由于多種原因,調試器必須擁有大量的權限來完成他的工作。改變進程的特權可通過以下三個簡單的基本步驟:
1.首先,必須打開進程的訪問令牌(access token),使用advapi32.dll中的函數OpenProcessToken()。
2.如果上一步正確完成,接下來就是準備TOKEN_PRIVILEGES結構,該結構包含有關要請求的特權的信息。這個工作需要advapi32.dll中的另一個函數LookupPrivilegeValue()的幫助。特權通過名稱來指定。SDK文檔winnt.h定義了27中特權名稱和其對應的符號名稱。例如,調試權限的符號名稱為:SE_DEBUG_NAME,該名稱和字符串“SeDebugPrivilege”等效。
3.如果上一步正確完成,就可以使用進程的令牌句柄(Token Handle)來調用AdjustTokenPrivileges()函數以初始化TOKEN_PRIVILEGES結構。該函數也是advapi32.dll導出的。
如果OpenProcessToken()調用成功,要記得關閉其返回的令牌句柄(Token Handle)。w2k_dbg.dll包含一個dbgPrivilegeSet()函數,該函數合并了這幾個步驟,下面的列表1-8列出了該函數和w2k_dbg.dll中的另一個函數:dbgPrivilegeDebug()。此函數是dbgPrivilegeSet()的一個外包函數,為了便于設定調試特權。順便說一下,Windows NT Server資源工具集中的kill.exe也使用了同樣的技巧。Kill.exe需要調試特權來剔除內存中餓死的服務(starved services)。這是NT Server管理員不可缺少的一個工具,這對于重起一個掛掉的系統服務十分有用,而且可以避免不必要的系統重啟。對于使用IIS(Internet Information Server)的人,在他們的緊急工具箱中可能都有這個工具,以便重起偶爾掛掉的inerinfo.exe。
BOOL WINAPI dbgPrivilegeSet(PWORD pwName)
{
HANDLEhToken;
TOKEN_PRIVILEGES tp;
BOOL;;fOk = FALSE;
if ( (pwName != NULL) &&
OpenProcessToken(GetCurrentProcess(),
TOKEN_ADJUST_PRIVILEGES,
&hToken) )
{
if ( LookupPrivilegeValue(NULL,pwName,&tp,Privileges->Luid) )
{
;;;;tp.Privileges->Attributes = SE_PRIVILEGE_ENABLED;
tp.PrivilegeCount = 1;
fOk = AdjustTokenPrivileges(hToken,FALSE,&tp,0,NULL,NULL)
; && (GetLastError() == ERROR_SUCCESS);
}
CloseHandle(hToken);
}
return fOk;
}
//------------------------------------------------------------------------------------
BOOL WINAPI dbgPrivilegeDebug(void)
{
return dbgPrivilegeSet(SE_DEBGU_NAME);
}
列表1-8; Requesting a Privilege for a Process