第四章 探索Windows 2000的内存管理机制
翻译:Kendiv (fcczj@263.net )
更新:Sunday, February 17, 2005
声明:转载请注明出处,并保证文章的完整性,本人保留译文的所有权利。
Memory Spy Device示例
微软对Windows NT和2000说的最多的就是它们是安全的操作系统。它们不但在网络环境中加入了用户验证系统,同时还加强了系统的稳健性(robustness),以进一步降低错误应用程序危及系统完整性的概率,这些错误的程序可能使用了非法的指针或者在其内存数据结构以外的地方进行了写入操作。这些在Windows 3.x上都是十分让人头疼得问题,因为Windows 3.x系统和所有的应用程序共享单一的内存空间。Windows NT为系统和应用程序内存以及并发的进程提供了完全独立的内存空间。每个进程都有其独立的4GB地址空间,如图4-2所示。无论何时发生任务切换,当前的地址空间都会被换出(switch out),同时另一个被映射进来,它们各自使用不同的段寄存器、页表和其他内存管理结构。这种设计避免了应用程序无意中修改另一个程序所使用的内存。由于每个进程必然会要求访问系统资源,所以在4GB空间中总是包含一些系统数据和代码,并采用了一个不同的技巧来保护这些内存区域不被恶意程序代码所覆写(overwritten)。
Windows 2000的内存分段
Windows 2000继承了Windows NT 4.0的基本内存分段模型,默认情况下,该模型将4GB地址空间划分为相等的两块。低一半的地址范围是:0x00000000 ---- 0x7FFFFFFF,其中包含运行于用户模式(用Intel的术语来说是,是特权级3或Ring 3)的应用程序的数据和代码。高一半的地址范围是:0x80000000 --- 0xFFFFFFFF,默认全部保留给系统使用,位于这一范围的代码运行于内核模式(即特权级为0或Ring 0)。特权级决定了代码可以执行什么操作以及可以访问那一个块内存。这意味着对于低特权级的代码来说,会被禁止执行某些CPU指令或访问某些内存区域。例如,如果一个用户模式下的程序触及了任何0x80000000(即4GB地址空间中的高一半)以上的地址,系统会抛出一个异常并同时终止该程序的运行,不会给其任何机会。
图4-5. 用户模式下不能访问0x80000000以上的地址
图4-5展示了程序试图读取0x80000000地址时的情况。这种严格的访问限制对于系统的完整性来说是好事,但对于调试工具就不是什么好消息了,因为调试工具需要访问所有可用内存。幸运的是,存在着一个简单的方法:采用内核驱动程序,和系统本身类似,它也运行于高特权级(即Ring 3),因此它们可以执行所有的CPU指令,可访问所有的内存区域。这其中的诀窍就是将一个Spy驱动程序注入系统,用它来访问需要的内存,并将读到的内容发送到它的搭档程序,该搭档程序会在用户模式下等待。当然,内核驱动程序不能读取虚拟内存地址,而且得不到分页机制的支持。因此,这样的驱动程序必须在访问一个地址之前小心的检查它,以避免出现蓝屏死机(Blue Screen Of Death,BSOD)。相对于应用程序引发的异常(仅会终止出现问题的程序),驱动程序引发的异常会停止整个系统,并强迫进行重启。
设备I/O控制Dispatcher(Device I/O Control Dispatcher)
本书光盘上有一个通用Spy Device的源代码,该Spy Device作为内核驱动程序实现。可以在\src\w2k_spy目录下找到它的源代码。这个设备基于第三章的驱动向导所产生的驱动程序骨架。其用户模式下的接口为w2k_spy.sys,w2k_spy.sys采用Win32的设备I/O控制(IOCTL),在第三章中曾简要的谈过IOCTL。Spy Device定义了一个名为\Device\w2k_spy的设备和一个符号链接\DosDevices\w2k_spy,定义符号链接是为了能在用户模式下访问该设备。非常可笑的是符号链接的名字空间居然是\DosDevice,而在这儿,我们使用的可不是一个DOS设备驱动。这就像历史上有名的root,原本是叫做石头的J。安装好符号链接后,驱动程序就可以被用户模式下的任何模块打开了,方法是:使用Win32 API函数CreateFile(),路径为\.w2k_spy。字符串\.是通用转义符,表示本地设备。例如,\.C:指向本地硬盘上的C:分区。从SDK的文档中可了解CreateFile()的更多细节。
该驱动程序的头文件有一部分已经由列表4-2到列表4-5给出。这个文件有些像DLL的头文件:它包含在编译过程中,模块所需的定义,而且还为客户端程序提供了足够的接口信息。DLL和驱动程序以及客户端程序都包含相同的头文件,但每个模块会取出各自所需的定义以完成正确的操作。不过,头文件的这种两面性给内核驱动程序带来的麻烦要远多于给DLL带来的,这都是因为微软给驱动程序提供的特殊开发环境所致。不幸的是,DDK中的头文件并不能和SDK中的Win32文件兼容。至少在C工程,二者的头文件是不能混合使用的。这样的结果就是陷入了僵局,此种情况下,内核驱动可以访问的常量、宏和数据类型对于客户端程序来说是却是无法使用的。因此,w2k_spy.c定义了一个名为_W2K_SPY_SYS_的标志常量,w2k_spy.h通过#ifdef…..#else…..#endif来检查该常量是否出现,以决定需要补充哪些缺少的定义。这意味着,所有出现在#ifdef _W2K_SPY_SYS_ 之后的定义仅可被驱动代码看到,位于#else之后的则专用于客户端程序。w2k_spy.h中条件语句之外的所有部分被这两个模块同时使用。
在第三章中,在讨论我的驱动向导时,我给出了向导生成的驱动程序骨架,如列表3-3所示。由该驱动向导生成的新的驱动工程均开始于DeviceDispatcher()函数。该函数接受一个设备上下文指针,以及一个指向IRP(I/O请求包)的指针,该IRP随后将会被分派。向导的样板代码已经处理了基本的I/O请求:IRP_MJ_CREATE、IRP_MJ_CLEANUP和IRP_MJ_CLSE,当客户要关闭一个设备时,会给该设备发送这些I/O请求。DeviceDispatcher()针对这些请求只是简单的返回STATUS_SUCCESS,因此设备可以被正确的打开和关闭。对于某些设备,这种动作已经足够,但有些设备还需要初始化和清理代码,这些代码多少都有些复杂。对于其他的请求,第三章中的驱动程序骨架总是返回STATUS_NOT_IMPLEMENTED。扩展该骨架代码的第一步是修改默认的动作,以便处理更多的I/O请求。就像w2k_spy.sys的主要任务之一:通过IOCTL调用将在用户模式下无法访问的数据发送给Win32应用程序,因此首先需要在DeviceDispatcher()中添加处理IRP_MJ_DEVICE_CONTROL的函数。列表4-6给出了更新后的代码。
NTSTATUS DeviceDispatcher (PDEVICE_CONTEXT pDeviceContext,
PIRP pIrp)
{
PIO_STACK_LOCATION pisl;
DWORD dInfo = 0;
NTSTATUS ns = STATUS_NOT_IMPLEMENTED;
pisl = IoGetCurrentIrpStackLocation (pIrp);
switch (pisl->MajorFunction)
{
case IRP_MJ_CREATE:
case IRP_MJ_CLEANUP:
case IRP_MJ_CLOSE:
{
ns = STATUS_SUCCESS;
break;
}
case IRP_MJ_DEVICE_CONTROL:
{
ns = SpyDispatcher (pDeviceContext,
pisl->Parameters.DeviceIoControl.IoControlCode,
pIrp->AssociatedIrp.SystemBuffer,
pisl->Parameters.DeviceIoControl.InputBufferLength,
pIrp->AssociatedIrp.SystemBuffer,
pisl->Parameters.DeviceIoControl.OutputBufferLength,
&dInfo);
break;
}
}
pIrp->IoStatus.Status = ns;
pIrp->IoStatus.Information = dInfo;
IoCompleteRequest (pIrp, IO_NO_INCREMENT);
return ns;
}
列表4-6. 为Dispatcher增加处理的IRP_MJ_DEVICE_CONTROL函数
列表4-6中的IOCTL处理代码非常简单,它仅调用了SpyDispatcher(),并将一个扩展后的IRP结构和当前I/O堆栈位置作为参数传递给SpyDispatcher()。SpyDispatcher()在列表4-7中给出,该函数需要如下的参数:
l pDeviceContext一个驱动程序的设备上下文指针。驱动程序向导提供了的基本Device_Context结构,该结构中包含驱动程序和设备对象指针(参见列表3-4)。不过,Spy驱动程序在该结构中增加了一对私有成员。
l dCode指定了IOCTL编码,以确定Spy设备需要执行的命令。一个IOCTL编码是一个32位整数,它包含4个位域,如图4-6所示。
l pInput指向一个输入缓冲区,用于给IOCTL提供输入数据。
l dInput 输入缓冲区的大小。
l pOutput指向用来接收IOCTL输出数据的输出缓冲区。
l dOutput输出缓冲区的大小
l pdInfo指向一个DWORD变量,该变量保存写入输出缓冲区中的字节数。
图4-6. 设备I/O控制编码的结构
根据所用的IOCTL使用的传输模式,输入/输出缓冲区会以不同的方式从系统传递给驱动程序。Spy设备使用已缓存的I/O(buffered I/O),系统将输入数据复制到一个安全的缓冲区(此缓冲区由系统自动分配)中,在返回时,将指定数目的数据从同样的系统缓冲区中复制到调用者提供的输出缓冲区中。一定要牢记:在这种情况下,输入和输出缓冲区是重叠的,因此IOCTL的处理代码必须在向输出缓冲区中写入任何数据之前,保存所有它稍后可能需要使用的输入数据。系统I/O缓冲区的指针保存在IRP结构中的SystemBuffer成员中(参见ntddk.h)。输入/输出缓冲区的大小保存在一个不同的地方,它们是IRP的参数成员DeviceIoControl的一部分,分别为InputBufferLength和OutputBufferLength。DeviceIoControl子结构还通过其IoControlCode成员提供了IOCTL编码。有关Windows NT/2000的IOCTL的传输模式的信息以及它们如何传入/传出数据,请参考我在Windows Developer’s Journal(Schreiber 1997)发表的文章“A Spy Filter Driver for Windows NT”。
NTSTATUS SpyDispatcher (PDEVICE_CONTEXT pDeviceContext,
DWORD dCode,
PVOID pInput,
DWORD dInput,
PVOID pOutput,
DWORD dOutput,
PDWORD pdInfo)
{
SPY_MEMORY_BLOCK smb;
SPY_PAGE_ENTRY spe;
SPY_CALL_INPUT sci;
PHYSICAL_ADDRESS pa;
DWORD dValue, dCount;
BOOL fReset, fPause, fFilter, fLine;
PVOID pAddress;
PBYTE pbName;
HANDLE hObject;
NTSTATUS ns = STATUS_INVALID_PARAMETER;
MUTEX_WAIT (pDeviceContext->kmDispatch);
*pdInfo = 0;
switch (dCode)
{
case SPY_IO_VERSION_INFO:
{
ns = SpyOutputVersionInfo (pOutput, dOutput, pdInfo);
break;
}
case SPY_IO_OS_INFO:
{
ns = SpyOutputOsInfo (pOutput, dOutput, pdInfo);
break;
}
case SPY_IO_SEGMENT:
{
if ((ns = SpyInputDword (&dValue,
pInput, dInput))
== STATUS_SUCCESS)
{
ns = SpyOutputSegment (dValue,
pOutput, dOutput, pdInfo);
}
break;
}
case SPY_IO_INTERRUPT:
{
if ((ns = SpyInputDword (&dValue,
pInput, dInput))
== STATUS_SUCCESS)
{
ns = SpyOutputInterrupt (dValue,
pOutput, dOutput, pdInfo);
}
break;
}
case SPY_IO_PHYSICAL:
{
if ((ns = SpyInputPointer (&pAddress,
pInput, dInput))
== STATUS_SUCCESS)
{
pa = MmGetPhysicalAddress (pAddress);
ns = SpyOutputBinary (&pa, PHYSICAL_ADDRESS_,
pOutput, dOutput, pdInfo);
}
break;
}
case SPY_IO_CPU_INFO:
{
ns = SpyOutputCpuInfo (pOutput, dOutput, pdInfo);
break;
}
case SPY_IO_PDE_ARRAY:
{
ns = SpyOutputBinary (X86_PDE_ARRAY, SPY_PDE_ARRAY_,
pOutput, dOutput, pdInfo);
break;
}
case SPY_IO_PAGE_ENTRY:
{
if ((ns = SpyInputPointer (&pAddress,
pInput, dInput))
== STATUS_SUCCESS)
{
SpyMemoryPageEntry (pAddress, &spe);
ns = SpyOutputBinary (&spe, SPY_PAGE_ENTRY_,
pOutput, dOutput, pdInfo);
}
break;
}
case SPY_IO_MEMORY_DATA:
{
if ((ns = SpyInputMemory (&smb,
pInput, dInput))
== STATUS_SUCCESS)
{
ns = SpyOutputMemory (&smb,
pOutput, dOutput, pdInfo);
}
break;
}
case SPY_IO_MEMORY_BLOCK:
{
if ((ns = SpyInputMemory (&smb,
pInput, dInput))
== STATUS_SUCCESS)
{
ns = SpyOutputBlock (&smb,
pOutput, dOutput, pdInfo);
}
break;
}
case SPY_IO_HANDLE_INFO:
{
if ((ns = SpyInputHandle (&hObject,
pInput, dInput))
== STATUS_SUCCESS)
{
ns = SpyOutputHandleInfo (hObject,
pOutput, dOutput, pdInfo);
}
break;
}
case SPY_IO_HOOK_INFO:
{
ns = SpyOutputHookInfo (pOutput, dOutput, pdInfo);
break;
}
case SPY_IO_HOOK_INSTALL:
{
if (((ns = SpyInputBool (&fReset,
pInput, dInput))
== STATUS_SUCCESS)
&&
((ns = SpyHookInstall (fReset, &dCount))
== STATUS_SUCCESS))
{
ns = SpyOutputDword (dCount,
pOutput, dOutput, pdInfo);
}
break;
}
case SPY_IO_HOOK_REMOVE:
{
if (((ns = SpyInputBool (&fReset,
pInput, dInput))
== STATUS_SUCCESS)
&&
((ns = SpyHookRemove (fReset, &dCount))
== STATUS_SUCCESS))
{
ns = SpyOutputDword (dCount,
pOutput, dOutput, pdInfo);
}
break;
}
case SPY_IO_HOOK_PAUSE:
{
if ((ns = SpyInputBool (&fPause,
pInput, dInput))
== STATUS_SUCCESS)
{
fPause = SpyHookPause (fPause);
ns = SpyOutputBool (fPause,
pOutput, dOutput, pdInfo);
}
break;
}
case SPY_IO_HOOK_FILTER:
{
if ((ns = SpyInputBool (&fFilter,
pInput, dInput))
== STATUS_SUCCESS)
{
fFilter = SpyHookFilter (fFilter);
ns = SpyOutputBool (fFilter,
pOutput, dOutput, pdInfo);
}
break;
}
case SPY_IO_HOOK_RESET:
{
SpyHookReset ();
ns = STATUS_SUCCESS;
break;
}
case SPY_IO_HOOK_READ:
{
if ((ns = SpyInputBool (&fLine,
pInput, dInput))
== STATUS_SUCCESS)
{
ns = SpyOutputHookRead (fLine,
pOutput, dOutput, pdInfo);
}
break;
}
case SPY_IO_HOOK_WRITE:
{
SpyHookWrite (pInput, dInput);
ns = STATUS_SUCCESS;
break;
}
case SPY_IO_MODULE_INFO:
{
if ((ns = SpyInputPointer (&pbName,
pInput, dInput))
== STATUS_SUCCESS)
{
ns = SpyOutputModuleInfo (pbName,
pOutput, dOutput, pdInfo);
}
break;
}
case SPY_IO_PE_HEADER:
{
if ((ns = SpyInputPointer (&pAddress,
pInput, dInput))
== STATUS_SUCCESS)
{
ns = SpyOutputPeHeader (pAddress,
pOutput, dOutput, pdInfo);
}
break;
}
case SPY_IO_PE_EXPORT:
{
if ((ns = SpyInputPointer (&pAddress,
pInput, dInput))
== STATUS_SUCCESS)
{
ns = SpyOutputPeExport (pAddress,
pOutput, dOutput, pdInfo);
}
break;
}
case SPY_IO_PE_SYMBOL:
{
if ((ns = SpyInputPointer (&pbName,
pInput, dInput))
== STATUS_SUCCESS)
{
ns = SpyOutputPeSymbol (pbName,
pOutput, dOutput, pdInfo);
}
break;
}
case SPY_IO_CALL:
{
if ((ns = SpyInputBinary (&sci, SPY_CALL_INPUT_,
pInput, dInput))
== STATUS_SUCCESS)
{
ns = SpyOutputCall (&sci,
pOutput, dOutput, pdInfo);
}
break;
}
}
MUTEX_RELEASE (pDeviceContext->kmDispatch);
return ns;
}
列表4-7. Spy驱动程序的内部命令Dispatcher
#define CTL_CODE (DeviceType, Function, Method, Access)
(( (DeviceType) << 16 ) | ( Access << 14 ) | ( (Function) << 2 ) (Method) )
列表4-8. 用来构建I/O控制编码的CTL_CODE()宏
DDK的主要头文件ntddk.h和SDK中的Win32文件winioctl.h均定义了一个简单但非常有用的宏---- CTL_CLOSE(),如列表4-8所示。该宏可方便的建立图4-6所示的IOCTL编码。该编码中的四个部分分别服务于以下四个目的:
1. DeviceType 这是一个16位的设备类型ID。ntddk.h列出了一对预定义的类型,由符号常量FILE_DEVICE_*表示。0x0000到0x7FFF保留给微软内部使用,开发人员可使用0x8000到0xFFFF。Spy驱动程序定义了它自己的设备ID:FILE_DEVICE_SPY,其值为0x8000。
2. 2位的访问检查值用来确定IOCTL操作所需的访问权限。可能的值有:FILE_ANY_ACCESS (0),FILE_READ_ACCESS (1),FILE_WRITE_ACCESS (2)和最后两个的组合:FILE_READ_ACCESS | FILE_WRITE_ACCESS (3)。详见ntddk.h。
3. 12个位的ID表示所选择的操作函数,所选操作将由设备来执行。0x0000到0x7FFF保留给微软内部使用,开发人员可使用0x8000到0xFFFF。Spy设备采用的IOCTL函数ID位于0x8000到0xFFFF。
4. 传输模式占用2个位,可在四个可用I/O传输模式中选择一个,这四个模式为:METHOD_BUFFERED (0),METHOD_IN_DIRECT (1),METHOD_OUT_DIRECT (2)和METHOD_NETTHER (3),可在ntddk.h中找到这些定义。Spy设备针对所有请求使用METHOD_BUFFERED,这是一个非常安全但有些慢的模式,因为数据需要在客户端和系统缓冲区之间进行复制。因为Memory Spy对I/O的处理速度并不敏感,所以选择安全是一个不错的注意。如果你希望知道其他模式的细节,请参考我在Windows Developer’s Journal(Schreiber 1997)发表的文章“A Spy Filter Driver for Windows NT”
表4-2列出了w2k_spy.sys支持的所有IOCTL函数。0到10的函数ID为最基本的内存探测函数,绝大部分的任务都会用到它们;本章稍候将讨论它们。剩余的函数ID从11到23分属于不同的IOCTL组,在下一章我们将讨论它们,在下一章,我们将讨论Native API hook和在用户模式下调用内核。注意某些IOCTL编码需要写入权限,由第15号位表示(参见图4-6)。确切的说,所有形如0x80006nnn的IOCTL命令只需读权限,而形如0x8000Ennn的命令需要读/写权限。典型的要求读权限的例子是CreateFile(),它通过指定dwDesiredAccess参数为GENERIC_READ和GENERIC_WRITE的组合来打开设备。
表4-2最左面的函数名称同样出现在SpyDispatcher()(见列表4-7)中那个庞大的switch/case语句中。这些函数首先获取设备的dispatcher mutex,这样就能保证,如果一个以上的客户端或一个多线程的程序和设备通讯时,在同一时间只有一个请求被执行。MUTEX_WAIT()是KeWaitForMutexObject()的外包宏(wrapper marco),KeWaitForMutexObject()至少需要5个参数。KeWaitForMutexObject()本身也是一个宏,它将传入的参数向前传递给KeWaitForSingleObject()。列表4-9给出了MUTEX_WAIT()以及它的伙伴MUTEX_RELEASE()和MUTEX_INITIALIZE()。在mutex对象变为有信号(signaled)状态后,根据接收到的IOCTL编码,SpyDispatcher()会转向不同的分支,每个分支都包含多种简单的代码序列。
表4-2. w2k_spy.sys支持的IOCTL函数
函数名称
ID
IOCTL编码
描 述
SPY_IO_VERSION_INFO
0
0x80006000
返回Spy的版本信息
SPY_IO_OS_INFO
1
0x80006004
返回操作系统的版本信息
SPY_IO_SEGMENT
2
0x80006008
返回一个段的属性
SPY_IO_INTERRUPT
3
0x8000600C
返回一个中断门的属性
SPY_IO_PHYSICAL
4
0x80006010
线性地址转换为物理地址
SPY_IO_CPU_INFO
5
0x80006014
返回特殊CPU寄存器的值
SPY_IO_PDE_ARRAY
6
0x80006018
返回位于0xC0300000的PDE数组
SPY_IO_PAGE_ENTRY
7
0x8000601C
Return the PDE or PTE of a linear address
SPY_IO_MEMORY_DATA
8
0x80006020
返回内存块中的内容
SPY_IO_MEMORY_BLOCK
9
0x80006024
返回内存块中的内容
SPY_IO_HANDLE_INFO
10
0x80006028
从句柄中查找对象属性
SPY_IO_HOOK_INFO
11
0x8000602C
返回有关Native API Hook的信息
SPY_IO_HOOK_INSTALL
12
0x8000E030
安装Native API Hook
SPY_IO_HOOK_REMOVE
13
0x8000E034
移除一个Native API Hook
SPY_IO_HOOK_PAUSE
14
0x8000E038
暂停/恢复 Hook协议
SPY_IO_HOOK_FILTER
15
0x8000E03C
允许/禁止 Hook协议过滤器
SPY_IO_HOOK_RESET
16
0x8000E040
清除Hook协议
SPY_IO_HOOK_READ
17
0x8000E044
从Hook协议中读取数据
SPY_IO_HOOK_WRITE
18
0x8000E048
向Hook协议中写入输入
SPY_IO_MODULE_INFO
19
0x8000E04C
返回已加载模块的信息
SPY_IO_PE_HEADER
20
0x8000E050
返回IMAGE_NT_HEADERS数据
SPY_IO_PE_EXPORT
21
0x8000E054
返回IMAGE_EXPORT_DIRECTORY数据
SPY_IO_PE_SYMBOL
22
0x8000E058
返回导出的系统符号的地址
SPY_IO_CALL
23
0x8000E05C
调用已加载模块中的一个函数
#define MUTEX_INITIALIZE(_mutex)
KeInitializeMutex
(&(_mutex), 0)
#define MUTEX_WAIT(_mutex)
KeWaitForMutexObject
(&(_mutex), Executive, KernelMode, FALSE, NULL)
#define MUTEX_RELEASE(_mutex)
KeReleaseMutex
(&(_mutex), FALSE)
列表4-9. 管理Kernel-Mutex的宏
SpyDispatcher()使用一对帮助函数来读取输入参数,以获取被请求的数据,并将产生的数据写入调用者提供的输出缓冲区中。就像前面提到的,内核模式的驱动程序总是过分挑剔的对待它接受到的来自用户模式的参数。以驱动程序的观点来看,所有用户模式下的代码都是有害的,它们除了让系统崩溃就什么都不知道了。这种多少有些多疑症的观点并不是荒谬的----仅有很小的比率会导致整个系统立即终止,同时出现蓝屏。因此,如果一个客户端程序说:“这是我的缓冲区-----它最多可容纳4,096个字节”,驱动程序不会接受这个缓冲区----即使该缓冲区指向有效的内存,并且其大小也是正确的。在IOCTL的可缓冲的I/O模式(Buffered I/O)下(如果IOCTL编码的模式部分为METHOD_BUFFERED),系统会很小心的检查并分配一个足够容纳所有输入/输出数据的缓冲区。然而,其他的I/O传输模式,尤其是METHOD_NETTHER,驱动程序会接受原始的用户模式的缓冲区指针。
……………..待续……………