《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