第六章 在用户模式下调用内核API函数
翻译:Kendiv( fcczj@263.net )
更新:Saturday, May 14, 2005
声明:转载请注明出处,并保证文章的完整性,本人保留译文的所有权利。
实现内核API 的Thunks
其实,可替代简单内核API函数的基本框架已经存在。我称其为“Thunks”,这是Windows行话中常见的一个术语,它代表一小段代码,这段代码是一个前端函数,为在系统的不同空间中实现的函数提供服务。另一个常见的术语是“代理”,不过它与微软组件对象模型(COM)紧密相关,因此,如果在这里使用它可能会引起混淆。让我们先从两个非常简单的Windows 2000内存管理函数开始,这两个函数是我在开发w2k_call.dll模块时的主要实验对象,它们是:MmGetPhysicalAddress()和MmIsAddressValid()。列表6-24展示了在w2kCall64()和w2kCall08()的帮助下如何实现它们的Thunks。为了避免和原始的目标函数冲突,我在所有的Thunks名称前增加了一个下划线作为前缀。
PHYSICAL_ADDRESS WINAPI
_MmGetPhysicalAddress (PVOID BaseAddress)
{
PHYSICAL_ADDRESS pa;
pa.QuadPart = w2kCall64 (0, "MmGetPhysicalAddress", FALSE,
4, BaseAddress);
return pa;
}
// -----------------------------------------------------------------
BOOLEAN WINAPI
_MmIsAddressValid (PVOID VirtualAddress)
{
return w2kCall08 (FALSE, "MmIsAddressValid", FALSE,
4, VirtualAddress);
}
列表6-24. MmGetPhysicalAddress()和MmIsAddressValid()的Thunks示列
MmGetPhysicalAddress()接受一个32位的线性地址,返回一个64位的PHYSICAL_ADDRESS结构,该结构只是LARGE_INTEGER结构的一个别名。因此,该函数的Thunks需要调用w2kCall64(),这意味着将会向参数堆栈中传入四个字节的参数,而BaseAddress则作为Thunks的参数出现。在发生严重的IOCTL错误时,默认的返回值为0,这也是原始函数返回的出错值。由于MmGetPhysicalAddress()使用__stdcall约定,因此,fFastCall必须设为FALSE。MmIsAddressValid()的Thunks的实现方式与之类似,不同之处仅在于SpyCallEx()返回的第八个最低有效位,该位对应一个BOOLEAN数据类型。默认的返回值为FALSE,这是一种预防性的选择。MmIsAddressValid()的典型应用是在访问内存前调用它,以避免潜在的页错误(page fault)。所以,不能假定函数的实际返回值为TRUE,因为一个IOCTL错误将增大出现蓝屏的风险。
现在,让我们看一下在这一框架下导出变量是如何被访问的。列表6-25给出了两个Thunks:_NtBulidNumber()和_KeServiceDescriptorTable()。NtBulidNumber是ntoskrnl.exe导出的一个16位的WORD类型,因此,对应的w2k_call.dll接口函数为w2kCopy16()。在发生错误是,该Thunks返回0(如果你知道更合适的值,请告诉我J)。_KeServiceDescriptorTable()稍微有些不同,因为ntoskrnl.exe导出的KeServiceDescriptorTable所指向的结构体大于64位。此时,最好的选择就是返回KeServiceDescriptorTable自身的地址。因此,该Thunks将使用w2kCopyEP(),列表6-23给出了该函数。
WORD WINAPI
_NtBuildNumber (VOID)
{
return w2kCopy16 (0, "NtBuildNumber");
}
// -----------------------------------------------------------------
PSERVICE_DESCRIPTOR_TABLE WINAPI
_KeServiceDescriptorTable (VOID)
{
return w2kCopyEP (NULL, "KeServiceDescriptorTable");
}
列表6-25. NtBulidNumber和KeServiceDescriptorTable的Thunks
你可以想象当我发现这些Thunks居然真的可用时我是多么兴奋!当时我想:我要尝试调用一些最低层的函数---这些函数都与低层硬件绑定在一起,用于进行读写I/O端口等操作。很幸运,我先前设计的SpyModuleSymbolEx()函数(参见列表6-11)允许解析任何系统模块的导出符号,这包括内核模式的驱动程序。我的下一个任务就是调用Windows 2000硬件抽象层(Hardware Abstraction Layer,HAL)的导出函数。在检查完hal.dll导出节(export section)中的所有符号后,我决定尝试两个较简单的函数以保证可以和底层硬件直接对话:HalMakeBeep()和HalQueryRealTimeClock()。通过可编程的定时器以及并行I/O(Parallel I/O,PIO,其I/O地址为0x0042、0x0043和0x0061)可以控制PC喇叭的发声,因此HalMakeBeep()是测试与硬件相关的函数的Thunk的理想候选人。
列表6-26给出了_HalMakeBeep() Thunk的实现代码,得益于w2kCall08()辅助函数,实现代码非常简单。HalMakeBeep()可以根据所要求的强度使PC喇叭发出蜂鸣声。如果强度参数为0,喇叭将停止发声。如果传入的强度值有效,则函数返回TRUE。确切的说,0或者大于18的强度值都是有效的。这里要注意的是,在调用w2kCall08()时指定的字符串包含目标模块的名字,这里的目标模块为hal.dll。列表6-24和列表6-25中均未指定模块,因为它们所引用的函数均由ntoskrnl.exe导出,而ntoskrnl.exe是默认的模块。
尽管HalMakeBeep()是一个非常简单的函数,我还是非常非常高兴得看到_HalMakeBeep()终于可以工作了。PC喇叭可以按我的要求发出声音了!而这时在Windows 2000而不是DOS下实现的,这证明了Win32应用程序可以调用HAL函数(这些函数直接访问硬件)。我将我在DOS下编写的Beep Sequencer移植到了Windows 2000中,列表6-27给出了其代码。w2kBeep()可发出一个指定强度和持续时间的单音节。w2kBeepEx()使用一组强度值/持续时间并顺序的演奏它们,直到遇到0强度。这两个函数均有w2k_call.dll导出。可能你会使用它们为Win32程序增加DOS风格的背景音乐。
BOOLEAN WINAPI
_HalMakeBeep (DWORD Pitch)
{
return w2kCall08 (FALSE, "hal.dll!HalMakeBeep", FALSE,
4, Pitch);
}
列表6-26. HalMakeBeep()的Thunks
BOOL WINAPI
w2kBeep (DWORD dDuration,
DWORD dPitch)
{
BOOL fOk = TRUE;
if (!_HalMakeBeep (dPitch)) fOk = FALSE;
Sleep (dDuration);
if (!_HalMakeBeep (0 )) fOk = FALSE;
return fOk;
}
// -----------------------------------------------------------------
BOOL WINAPI
w2kBeepEx (DWORD dData,
...)
{
PDWORD pdData;
BOOL fOk = TRUE;
for (pdData = &dData; pdData [0]; pdData += 2)
{
if (!w2kBeep (pdData [0], pdData [1])) fOk = FALSE;
}
return fOk;
}
列表6-27. 一个简单的Beep音序程序
接下来,我将尝试更有用的函数,如HalQueryRealTimeClock()。我记得在一个DOS程序中访问主板上的真实时钟(real-time clock)曾经是很困难的。这需要读/写硬件上的一对I/O端口。列表6-28给出了HalQueryRealTimeClock()及其兄弟HalSetRealTimeClock()的Thunks,同时还给出了这两个函数所处理的TIME_FIELDS结构。在ntddk.h中定义了TIME_FIELDS结构。
typedef struct _TIME_FIELDS {
CSHORT Year;
CSHORT Month;
CSHORT Day;
CSHORT Hour;
CSHORT Minute;
CSHORT Second;
CSHORT Milliseconds;
CSHORT Weekday;
} TIME_FIELDS, *PTIME_FIELDS;
// -----------------------------------------------------------------
VOID WINAPI
_HalQueryRealTimeClock (PTIME_FIELDS TimeFields)
{
w2kCallV (NULL, "hal.dll!HalQueryRealTimeClock", FALSE,
4, TimeFields);
return;
}
// -----------------------------------------------------------------
VOID WINAPI
_HalSetRealTimeClock (PTIME_FIELDS TimeFields)
{
w2kCallV (NULL, "hal.dll!HalSetRealTimeClock", FALSE,
4, TimeFields);
return;
}
列表6-28. HalQueryRealTimeClock()和HalSetRealTimeClock()的Thunks
列表6-29提供了一个使用_HalQueryRealTimeClock()的典型程序,该程序在控制台窗口中显示当前日期和时间。
VOID WINAPI DisplayTime(void)
{
TIME_FIELDS tf;
_HalQueryRealTimeClock(&tf);
printf(L"\r\nData/Time: %02hd-%02hd-%04hd%02hd:%02hd:%02hd\r\n",
tf.Month, tf.Day, tf.Year,
tf.Hour, tf.Minute, tf.Second);
return;
}
列表6-29. 显示当前日期和时间
尽管内核调用接口可以工作,但仍然有些遗憾。很多年来,我们都被告知Windows NT/2000是一个安全的操作系统,在那里应用程序是不能为所欲为的。大多数Win32开发人员都已远离硬件了。经验更丰富些的NT开发人员至少知道如何通过ntdll.dll调用Native API函数。现在,使用本书提供的DLL,所有的Win32开发人员都可以调用任意的内核函数,就像调用Win32 API函数那样。这是Windows 2000内核的一个安全漏洞吗?不,这并不是。只有让应用程序什么都不能访问的操作系统才是100%安全的,不过这样的操作系统也没有什么实际价值。一旦我们可通过某种途径影响系统,那么系统将变得易于受到攻击。一但操作系统供应商允许第三方开发人员向系统中添加组件,就有可能获取通向内核的桥梁,就像w2k_spy.sys/w2k_call.dll。不存在100%安全的操作系统,除非该系统不与它周围的环境进行交互。
数据访问的支持函数
我向w2k_call.dll中加入了几打内核API函数的Thunk。例如,由Windows 2000运行时库导出的所有字符串管理函数都可通过w2k_call.dll来调用。不过,你在实验这些预定义的Thunks或者你自己增加的thunks,你会发现从用户模式调用内核API函数与调用普通的Win32函数还是有些不同。这里介绍的内核调用接口的简易性掩盖了调用程序仍然运行于用户模式、仅有有限的特权这一事实。例如,程序调用的内核函数可能会一个返回指向UNICODE_STRING结构的指针,而这一指针很有可能指向的是内核内存空间。任何读取该字符串的尝试都将引发一个异常,并导致程序终止,这是由于指令试图读取的地址是被禁止访问的地址。为了解决这一问题,我给w2k_call.dll增加了支持函数,用来更容易的访问内核API调用中大多数常见的数据类型。
列表6-30给出的w2kSpyRead()函数是一个通用函数,它可以将任意的内存数据块复制到调用者提供的缓冲区中。该函数依赖于w2k_spy.sys提供的IOCTL函数-----SPY_IO_MEMORY_BLOCK(在第四章里简要的介绍过该函数)。可以使用w2kSpyRead()读取位于内核空间中的结构体的任意部分。这里要特别注意的是,如果提供的内存块的地址范围无效,则对w2kSpyRead()的调用将失败。这里的无效指的是没有与指定地址相关的物理内存或页面文件。w2kSpyClone()是w2kSpyRead()的增强版本,w2kSpyClone()可以自动分配大小适当的缓冲区,然后将内核数据复制到该缓冲区中。
BOOL WINAPI w2kSpyRead (PVOID pBuffer,
PVOID pAddress,
DWORD dBytes)
{
SPY_MEMORY_BLOCK smb;
BOOL fOk = FALSE;
if ((pBuffer != NULL) && (pAddress != NULL) && dBytes)
{
ZeroMemory (pBuffer, dBytes);
smb.pAddress = pAddress;
smb.dBytes = dBytes;
fOk = w2kSpyControl (SPY_IO_MEMORY_BLOCK,
&smb, SPY_MEMORY_BLOCK_,
pBuffer, dBytes);
}
return fOk;
}
// -----------------------------------------------------------------
PVOID WINAPI w2kSpyClone (PVOID pAddress,
DWORD dBytes)
{
PVOID pBuffer = NULL;
if ((pAddress != NULL) && dBytes &&
((pBuffer = w2kMemoryCreate (dBytes)) != NULL) &&
(!w2kSpyRead (pBuffer, pAddress, dBytes)))
{
pBuffer = w2kMemoryDestroy (pBuffer);
}
return pBuffer;
}
列表6-30. 通用数据访问函数
读取字符串需要稍微复杂些。内核组件使用的常见字符串类型是UNICODE_STRING,该结构由一个字符串缓冲区指针、有关缓冲区大小的信息和当前字符串占用的字节数构成。读取一个UNICODE_STRING结构通常需要两步来完成。第一,必须复制UNICODE_STRING结构以确定字符串缓冲区的大小和基地址。第二,读取实际的字符串数据。为了简化这一常见任务,w2k_call.dll提供了一组函数(见列表6-31)来完成这一工作。
w2kStringAnsi()和w2kStringUnicode()分别用于分配并初始化空的ANSI_STRING和UNICODE_STRING结构,在初始化后的结构中将包含指定大小的字符串缓冲区。为了简单起见,字符串的头部和缓冲区使用一个内存块。这些结构体可用于字符串复制,w2kStringClone()给出的一个范例。w2kStringClone()可在用户内存空间中创建指定UNICODE_STRING结构的一个精确副本。副本的MaximumLength通常与原始结构相同,除非原始字符串结构中的参数不一致。例如,如果MaximumLength小于或等于Length成员,则MaximumLength将被视为无效,实际的最大长度将为Length+2。不过,副本的MaximumLength永远不会小于原始的MaximumLength。
PANSI_STRING WINAPI w2kStringAnsi (DWORD dSize)
{
PANSI_STRING pasData = NULL;
if ((pasData = w2kMemoryCreate (ANSI_STRING_ + dSize))
!= NULL)
{
pasData->Length = 0;
pasData->MaximumLength = (WORD) dSize;
pasData->Buffer = PTR_ADD (pasData, ANSI_STRING_);
if (dSize) pasData->Buffer [0] = 0;
}
return pasData;
}
// -----------------------------------------------------------------
PUNICODE_STRING WINAPI w2kStringUnicode (DWORD dSize)
{
DWORD dSize1 = dSize * WORD_;
PUNICODE_STRING pusData = NULL;
if ((pusData = w2kMemoryCreate (UNICODE_STRING_ + dSize1))
!= NULL)
{
pusData->Length = 0;
pusData->MaximumLength = (WORD) dSize1;
pusData->Buffer = PTR_ADD (pusData, UNICODE_STRING_);
if (dSize) pusData->Buffer [0] = 0;
}
return pusData;
}
// -----------------------------------------------------------------
PUNICODE_STRING WINAPI w2kStringClone (PUNICODE_STRING pusSource)
{
DWORD dSize;
UNICODE_STRING usCopy;
PUNICODE_STRING pusData = NULL;
if (w2kSpyRead (&usCopy, pusSource, UNICODE_STRING_))
{
dSize = max (usCopy.Length + WORD_,
usCopy.MaximumLength) / WORD_;
if (((pusData = w2kStringUnicode (dSize)) != NULL) &&
usCopy.Length && (usCopy.Buffer != NULL))
{
if (w2kSpyRead (pusData->Buffer, usCopy.Buffer,
usCopy.Length))
{
pusData->Length = usCopy.Length;
pusData->Buffer [usCopy.Length / WORD_] = 0;
}
else
{
pusData = w2kMemoryDestroy (pusData);
}
}
}
return pusData;
}
列表6-31. 字符串管理函数
将内核字符串复制到应用程序内存空间的另一方法是使用内核运行时函数。例如,你可以使用w2k_call.dll提供的_RtlInitUnicodeString()和_RtlCopyUnicodeString() Thunks来完成这一任务。不过,调用w2kStringClone()将更容易些,因为该函数可以自动分配复制字符串所需的内存。
…………待续……….