《Undocumented Windows 2000 Secrets》翻译 --- 第二章(1)
本章对于 Windows 2000 Native API 的讨论,主要集中在这些 API 和系统模块之间的关系,将重点介绍 Windows 2000 采用的中断机制。 Windows 2000 利用此机制将对内核服务的请求从用户模式向内核模式传递。另外, Win32K 接口和一些与 Native API 相关的主要运行时库也会被提及,同时还将介绍一些经常使用的数据类型。
有关 Windows 2000 架构的详细讨论已经很多。许多有关 Windows NT 的讨论同样适用于 Windows 2000 。《 Inside Windows NT 》( Custer 1993, Solomon 1998 )的第一、二版都是有关此方面的好书,同样的还有《 Inside Windows 2000 》( Solomon and Russinovich 2000 )。
NT*() 和 Zw*() 函数集
有关 Windows 2000 架构的一个有趣的事实是:它模拟了多个操作系统。 Windows 2000 内置三个子系统来支持 Win32 、 POSIX 和 OS/2 应用程序。 Win32 子系统是最流行的一个,因此它更多的被开发人员和操作系统所关照。在 Windows 9x 中, Win32 接口实际上是作为整个系统的基础结构来实现的。但是, Windows 2000 的设计却有很大不同。尽管 Win32 子系统包含一个名为 kernel32.dll 的系统模块,但这并不是实际的操作系统内核。它仅仅是 Win32 子系统的一个基本组件。在很多编程书籍中, Windows NT/2000 的软件开发被简化为与 Win32 API 打交道的工作, NT 平台暴露出的一个隐藏的事实是存在另一个更为基础的调用接口: Native API 。相信编写 kernel-mode driver 或 file system driver 的开发人员已经对 Native API 非常熟悉了,因为 kernel-mode 模块位于更低的系统层,在那里子系统是不可见的。然而,你并不需要到驱动程序一层才能访问此接口 ---- 即使一个普通的 Win32 应用程序也可在任何时候调用 Native API 。这没什么技术上的限制 ---- 仅仅是微软不支持此种应用程序开发模式而已。因此,有关此话题的信息并不是很多, neither SDK nor the DDK make the Native API available to Win32 Application.
未文档化的级别
本书中的多数东西都来自被称为未文档化的信息。这通常意味着微软没有公开发布这些信息。然而,未文档化也存在几个级别,这是因为可能被公布的有关庞大的操作系统(如 Windows 2000 )的信息非常的多。我个人的系统分类如下:
l 正式文档 :这些信息来自微软出版的书、文件或者开发工具。大多数重要信息来自 SDK 、 DDK 和 MSDN 。
l 半文档化的( Semidocumented ) :尽管不是正式文档,但这些信息还是可以从微软正式发布的文件中挖掘出来的。例如, Windows 2000 的很多函数和结构体并没有在 SDK 或 DDK 文档中提到,但出现在一些头文件或示列程序中。以 Windows 2000 为例,很多重要的半文档化信息都源自头文件 ntddk.h 和 ntdef.h ,这两个文件都是 DDK 的一部分。
l 未文档化,但并没有隐藏 :这些信息不能在任何官方文档和开发文档中找到,但其中的一部分对调试工具是可用的。可执行文件或符号文件中的所有符号化信息都属于这一部分。最好的例子是内核调试器的 !processfIElds 和 !threadfields 命令,这两个命令会给出两个未文档化的结构: EPROCESS 和 ETHREAD 的成员名称及其偏移量。
l 完全未文档化的 :微软很好的隐藏了某些信息,要获得它们只能通过逆向工程和推理。此类信息包含很多实现细节的信息,没有人认为 Windows 2000 开发人员需要关注它们,但是这些信息对于系统开发人员和开发调试软件的人来说却非常宝贵。挖掘系统内部的信息是非常困难的,但同样是非常有趣的。
本书讨论的 Windows 2000 的内部细节覆盖了上述系统分类的后三个。
系统服务分配器( System Service Dispatcher )
Win32 子系统和 Native API 之间的关系可以由 Win32 核心模块与 Windows 2000 内核模块之间的依赖关系很好的解释。 图 2-1 展示了模块间的依赖关系,方框表示系统模块,箭头表示模块间的依赖关系。如果一个箭头从模块 A 指向模块 B ,这表示 A 依赖于 B ,即,模块 A 调用 B 中的函数。模块由双向箭头连接,表示二者之间相互依赖。在 图 2-1 中,模块: user32.dll 、 advapi32.dll 、 gdi32.dll 、 rpcrt4.dll 以及 kernel32.dll 实现了基本的 Win32 API 。当然,还有其他的 DLL (如 version.dll 、 shell32.dll 和 comctl32.dll )也为 Win32 API 提供支持,为了更清晰些,我省略了它们。 图 2-1 表现出的一个特性非常有趣,所有的 Win32 API 调用最后都转移到了 ntdll.dll ,而 ntdll.dll 又将其转移到了 ntoskrnl.exe 。
Ntdll.dll 是一个操作系统组件,它为 Native API 准确地提供服务, ntdll.dll 是 Native API 在用户模式下的前端。 Native API 真正的接口在 ntoskrnl.exe 中实现。从其文件名可以猜出它就是 NT 操作系统内核。事实上,内核模式驱动程序对系统服务的请求多数时候都会进入该模块。 Ntdll.dll 的主要任务就是为运行于用户模式的程序提供一个确定的内核函数的子集,这其中就包括 Win32 子系统 DLLs 。在 图 2-1 中,从 ntdll.dll 指向 ntoskrnl.exe 的箭头旁标注的 INT 2eh 表示 Windows 2000 使用此中断将 CPU 特权级从用户模式切换到内核模式。开发内核( kernel-mode )模式程序的人员认为用户模式的代码是具有攻击性的、充满错误的和危险的。因此,必须让这些代码远离内核函数。而通过在调用 API 的过程中将特权级别从用户模式切换到内核模式是一种可控制这些问题的方式。调用程序从来不可能触及内核,它只能察看它们。
例如,由 kernel32.dll 导出的 Win32 API 函数 DeviceIoControl() 最终会调用由 ntdll.dll 导出的 NtDeviceIoControlFile() 。通过反编译该函数会发现此函数令人惊讶的实现方式 — 它是如此的简单! 示列 2-1 展示了这些。首先, CPU 寄存器 EAX 被装入了一个“魔术”数字 0x38 ,这是一个分派 ID 。接下来,寄存器 EDX 被设置指向堆栈中的某处,其地址为堆栈指针 ESP 加上 4 ,因此, EDX 将指向堆栈中返回地址的后面,该返回地址在进入 NtDeviceIoControlFile() 时将被立即保存下来。显而易见, EDX 指向的位置是用来临时存放传递进来的参数的。接下来的指令是一个简单的 INT 2eh ,该指令将跳转到中断描述符表( Interrupt Descriptor Table,IDT )的 0x2e 位置上存放的中断处理例程( interrupt handler )中。这看上去是不是很熟悉?事实上,这有些像 Dos 下的 INT 21h 调用。然而, Windows 2000 的 INT 2eh 接口要远比一个简单的 API 调用有用,分配器( dispatcher )利用它从用户模式进入内核模式。请注意,这种模式切换方式是 x86 处理器特有的。在 Alpha 平台上,有不同的方式来实现此种功能。
NtDeviceIoControlFile:
mov eax, 38h
lea edx, [esp+4]
int 2Eh
ret 28h
示列 2-1. ntdll.NtDeviceIoControlFile() 的实现方式
Windows 2000 Native API 由 248 个函数组成,这些函数都采用上述方式进入内核。与 Windows NT 4.0 相比多出了 37 个。你很容易在 ntdll.dll 的导出列表中通过 Nt 前缀来认出它们。 Ntdll.dll 总共导出了 249 个这样的符号。多出的那个函数是 NtCurrentTeb() ,该函数是一个纯粹的用户模式函数,它无需进入内核。 附录 B 中的 表 B-1 列出了所有可用的 Native API 。该表同时还指出那个函数是由 ntoskrnl.exe 导出的。令人奇怪的是,在处于内核模式的模块中,只能调用 Native API 的一个子集。另一方面, ntoskrnl.exe 导出了两个 ntdll.dll 没有提供的 Nt* 符号(指以 Nt 开头的符号): NtBuildNumber 和 NtGlobalFlag 。这两个符号都没有指向函数的入口地址,而是指向 ntoskrnl.exe 中的变量。驱动模块( driver module )可以使用 C 编译器的 extern 关键字来导入这些变量。 Window 2000 采用此种方式导出了很多变量,稍后我将给出一个示例代码来使用其中的几个。
你可能会奇怪为什么 表 B-1 (位于附录 B 中)分别为 ntdll.dll 和 ntoskrnl.exe 提供了两列,其名称分别为: ntdll.Nt* 、 ntdll.Zw* 和 ntoskrnl.Nt* 、 ntoskrnl.Zw* 。原因是,这两个模块导出了两组相互关联的 Native API 符号。在 表 B-1 (位于附录 B 中)的最左列给出了所有名字中包含 Nt 前缀的符号。另一个集合包含相似的名字,不过由 Zw 前缀代替了 Nt 。反编译 ndll.dll 可看出每对符号都指向相同的代码。这看起来似乎是浪费内存。然而,如果你反编译 ntoskrnl.exe ,你就会发现 Nt* 符号指向实际的代码而 Zw* 指向 INT 2eh stubs (如 示列 2-1 列出的)。这意味着 Zw* 函数集合将从用户模式转入内核模式,而 Nt* 符号直接指向的代码会在模式切换后被执行。
表 B-1 (位于附录 B 中)中有两件事需要特别注意。首先, NtCurrentTeb() 函数没有对应的 Zw* 函数。这不是什么大问题,因为 ntdll.dll 以相似的方式导出 Nt* 和 Zw* 函数。其次, ntoskrnl.exe 不再一贯的成对的导出 Nt/Zw 函数。其中的一些仅以 Nt* 或 Zw* 的形式出现。我不知道为什么会这样,我猜测 ntoskrnl.exe 仅导出了在 Windows 2000 DDK 中有文档记录的函数以及其它系统模块必须的那些函数。注意,保留的 Native API 函数仍然实现于 ntoskrnl.exe 的内部。这些函数并没有公开的进入点,但可通过 INT 2eh 到达他们。
服务描述符表( The Service Descriptor Tables )
从 示例 2-1 给出的反编译代码可看出, INT 2eh 随同传入 CPU 寄存器 EAX 和 EDX 的两个参数一起被调用。我已经提到过 EAX 中的“魔术”数字是一个分派 ID 。除 NtCurrentTeb() 之外的所有 Native API 都采用此种方式,处理 INT 2eh 的代码必须确定每个调用将被分配到那个函数。这就是提供分派 ID 的原因。位于 ntoskrnl.exe 中的中断处理例程将 EAX 中的数值作为一个索引来查询一个特定的表。这个表被称作系统服务表( System Service Table, SST )该表对应的 C 结构体 ---SYSTEM_SERVICE_TABLE 的定义在 列表 2-1 中给出。在该列表中还包含 SERVICE_DESCRIPTOR_TABLE 结构的定义,该结构共有四个 SST 类型的数组,其中的前两个用于特定目的。
尽管上述的两个表是系统基本的数据类型,但他们在 Windows 2000 DDK 中 并没有相应的文档记载,本书中出现的许多代码片断都包含未文档化的数据类型和函数。因此,不能保证这些信息是完全真实可信的。所有符号化的信息,如结构名 称、结构成员和参数都是如此。在创建这些符号时,我试图使用适当的名称,这些名称基于从已知符号的一个很小的子集(包括从符号文件中得到的那些)中得出的 命名方案。然而,在很多场合这种启发式方法并不成功。只有在原始的代码中包含所有的信息,但我无法得到它们。实际上,我并不打算阅读这些源代码,因为这需 要和微软签订一个 NDA ( Non-Disclosure Agreement, ,不可泄漏协议),由于该 NDA 的限制,将很难写出一本有关非文档化信息的书。
typedef NTSTATUS (NTAPI*NTPROC)();
typedef NTPROC* PNTPROC;
#define NTPROC_ sizeof(NTPROC)
typedef struct _SYSTEM_SERVICE_TABLE
{
PNTPROC ServiceTable; // array of entry points
PDOWRD CounterTable; // array of usage counters
DWord ServiceLimit; // number of table entries
PBYTE ArgumentTable; // array of byte counts
}
SYSTEM_SERVICE_TABLE,
*PSYSTEM_SERVICE_TABLE,
**PPSYSTEM_SERVICE_TABLE;
//-----------------------------------------------------------------------------------------------------------
typedef struct _SERVICE_DESCRIPTOR_TABLE
{
SYSTEM_SERVICE_TABLE ntoskrnl; // ntoskrnl.exe ( native api )
SYSTEM_SERVICE_TABLE win32k; // win32k.sys (gdi/user support)
SYSTEM_SERVICE_TABLE Table3; // not used
SYSTEM_SERVICE_TABLE Table4; // not used
}
SYSTEM_DESCRIPTOR_TABLE,
*PSYSTEM_DESCRIPTOR_TABLE,
**PPSYSTEM_DESCRIPTOR_TABLE;
列表 2-1 系统服务描述符表的结构定义
现在,回到 SDT ( Service Descriptor Table )的秘密上来。从 列表 2-1 给出的该结构的定义可看出该结构的头两个数组保留给了 ntoskrnl.exe 和 Win32 子系统(位于 win32k.sys )中的内核模式( kernel-mode )部分。来自 gdi32.dll 和 user32.dll 的调用都通过 Win32k 的系统服务表( SST )进行分派。 Ntolkrnl.exe 导出了一个指针(符号为 KeServiceDescriptorTable )指向其主服务描述符表( Main SDT )。内核还维护了一个替代的 SDT ,其名称为: KeServiceDescriptorTableShadow ,但这个 SDT 并没有被导出。从处于内核模式的模块中访问主服务描述符表( SDT )非常容易,你只需要两个 C 指令,如 列表 2-2 所示。首先是由 extern 关键字修饰的变量说明,这告诉链接器该变量并不包含在此模块中,而且不需要在链接时解析相应的符号名称。当该模块被加载到进程的地址空间后,针对该符号的引用才会动态连接到相应的模块中。 列表 2-2 中第二个 C 指令就是这样的一个引用。将类型为 PSERVER_DESCRIPTOR_TABLE 的变量赋值为 KeServiceDescriptorTable 时,就会和 ntoskrnl.exe 建立一个动态连接。这很像调用一个 DLL 中的 API 函数。
// Import SDT pointer
extern PSERVICE_DESCRIPTOR_TABLE KeServiceDescriptorTable;
// Create SDT reference
PSERVICE_DESCRIPTOR_TABLE psdt = KeServiceDescriptorTable;
列表 2-2 访问系统服务描述符表
SDT 中的每个 SST 的 ServiceTable 成员都是一个指针,指向一个由函数指针构成的数组,此函数指针的类型为: NTPROC ,这为 Native API 提供了占位符,这种方式和在 Win32 编程中使用的 PROC 类型很相似。 NTPROC 的定义在前面的 列表 2-1 中给出。 Native API 函数通常返回一个 NTSTATUS 类型的代码并且使用 NTAPI 调用方式, NTAPI 实际上就是 _stdcall 。 ServiceLimit 成员保存在 ServieTable 数组中发现的入口地址的个数。在 Windows 2000 中,其默认值为 248 。 ArgumentTable 成员是一个 BTYE 类型的数组,它和 ServiceTable 所指的数组一一对应,并给出其中每个函数指针所需的参数在调用者的堆栈中的字节数。此信息随 EDX 寄存器提供的指针一起使用。当内核从调用者的堆栈中复制参数到自己的堆栈时就需要这些信息。 CounterTable 成员在 Windows 2000 的 Free Build 版中不被使用。在 Debug Build 版中,该成员指向一个 DWORD 类型的数组,作为每个函数的使用计数器( usage counters )。 This information can be used for profiling purposes.
使用 Windows 2000 的内核调试器可方便的显示 SDT 中的内容。如果你还没有设置好这个有用的程序,那请参考第一章。在 示列 2-2 中,我首次使用了 dd KeServiceDescriptorTable 命令。调试器会将此公开符号解析为 0x8046AB80 ,同时显示该地址之后的 32 个 DWORD 的 16 进制转储。不过仅有前面的四行才是有意义的,它们分别对应 列表 2-1 中的四个 SDT 成员。为了更清晰些,它们都将以黑体显示。如果你仔细观察,你会发现第五行与第一行十分相像,这是另一个 SDT 吗?这是测试内核调试器的 ln 命令的好机会。在示列 2-2 中,在显示完 KeServiceDescriptorTable 的十六进制 dump 之后,我输入 ln 8046abc0 命令。显然,调试器知道地址 0x8046abc0 ,它将此地址转化为对应的符号 KeServiceDescriptorTableShadow 可以看出,这是内核维护的第二个 SDT 。二者之间的显著区别是:第二个 SDT 包含 Win32k.sys 的入口地址。这两个表的的第三和第四个成员都是空的。 Ntoskrnl.exe 提供了一个函数 KeAddSystemServiceTabel() 来填充这两个成员。
注意,我截断了 ln 命令的输出信息,仅保留了基本的信息。
从地址 0x8046ab88 开始,是 KeServiceDescriptorTable 的十六进制转储,在那儿可以找到 ServiceLimit 成员,可看到其值为 0xF8 (十进制 248 ),这和我们预期的一样。 ServiceTable 和 ArgumentTable 的值分别指向地址 0x804704d8 和 0x804708bc 。用 ln 命令察看着两个地址,可得到其符号: KiServiceTable 和 KiArgumentTable 。这两个符号都没有从 ntoskrnl.exe 中导出,但是调试器可通过察看 Windows 2000 的符号文件识别它们。 ln 命令还可应用到 Win32k SST 指针上,针对其 ServiceTable 和 ArgumentTable 成员,调试器分别给出了其对应的符号 w32pServiceTable 和 W32pArgumenTable 。这两个符号都来自 Win32k.sys 的符号文件。如果调试器无法解析这些地址,可使用 .reload 命令强制重新加载所有可用符号文件,然后再进行解析。
示例 2-2 的剩余部分是 KiServiceTable 和 KiArgumentTable 最前面的 128 个字节的十六进制转储。到目前为止,如果我说的有关 Native API 的东西都是正确的,那么 NtClose() 函数的地址应位于 KiServiceTable 数组的第 24 个位置上,其地址为 0x80470538 。在该地址处,可发现其值为 0x8044c422 ,在 dd KiServiceTable 的输出中,该地址以黑体标记。用 ln 察看 0x8044c422 ,会看到其对应的符号正是 NtClose() 。
kd> dd KeServiceDescriptorTable
8046ab80 804704d8 00000000 000000f8 804708bc
8046ab90 00000000 00000000 00000000 00000000
8046aba0 00000000 00000000 00000000 00000000
8046abb0 00000000 00000000 00000000 00000000
8046abc0 804704d8 00000000 000000f8 804708bc
8046abd0 a0186bc0 00000000 0000027f a0187840
8046abe0 00000000 00000000 00000000 00000000
8046abf0 00000000 00000000 00000000 00000000
kd> ln 8046abc0
(8046abc0) nt!KeServiceDescriptorTableShadow
kd> ln 804704d8
(804704d8) nt!KiServiceTable
kd> ln 804708bc
(804708bc) nt!KiArgumentTable
kd> ln a0186bc0
(a0186bc0) win32k!W32pServiceTable
kd> ln a0187840
(a0187840) win32k!W32pArgumentTable
kd> dd KiServiceTable
804704d8 804ab3bf 804ae86b 804bdef3 8050b034
804704e8 804c11f4 80459214 8050c2ff 8050c33f
804704f8 804b581c 80508874 8049860a 804fc7e2
80470508 804955f7 8049c8a6 80448472 804a8d50
80470518 804b6bfb 804f0cef 804fcb95 8040189a
80470528 804d06cb 80418f66 804f69d4 8049e0cc
80470538 8044c422 80496f58 804ab849 804aa9da
80470548 80465250 804f4bd5 8049bc80 804ca7a5
kd> db KiArgumentTable
804708bc 18 20 2c 2c 40 2c 40 44-0c 18 18 08 04 04 0c 10 . ,,@,@D........
804708cc 18 08 08 0c 08 08 04 04-04 0c 04 20 08 0c 14 0c ........... ....
804708dc 2c 10 0c 1c 20 10 38 10-14 20 24 1c 14 10 20 10 ,... .8.. $... .
804708ec 34 14 08 04 04 04 0c 08-28 04 1c 18 18 18 08 18 4.......(.......
804708fc 0c 08 0c 04 10 00 0c 10-28 08 08 10 00 1c 04 08 ........(.......
8047090c 0c 04 10 00 08 04 08 0c-28 10 04 0c 0c 28 24 28 ........(....($(
8047091c 30 0c 0c 0c 18 0c 0c 0c-0c 30 10 0c 0c 0c 0c 10 0........0......
8047092c 10 0c 0c 14 0c 14 18 14-08 14 08 08 04 2c 1c 24 .............,.$
kd> ln 8044c422
(8044c422) nt!NtClose
示例 2-2 检查服务描述符表
译注:
在 Windows XP 中, KeServiceDescriptorTable 和 KeServiceDescriptorTableShadow 和 Windows 2000 有所区别。在 XP 中,后者位于前者的前面,而在 W2K 中,后者位于前者的后面。
INT 2eh 系统服务处理例程( System Service Handler )
隐藏在内核模式中的 INT 2eh 中断处理例程为 KiSystemService() 。再强调一次,这是一个内部符号, ntoskrnl.exe 并没有导出该符号,不过,它却包含在 Windows 2000 的符号文件中。因此,内核调试器可以正确的解析该符号。从本质上来看, KiSystemService() 将执行如下操作:
1. 从当前线程的控制块( thread's control block )中检索 SDT 指针。
2. 通过测试 EAX 寄存器中的分派 ID 的第 12 、 14 位来确定使用 SDT 中的那个 SST ( SDT 中有四个 SST )。如果分派 ID 位于 0x0000-0x0FFF ,将选择 ntoskrnl 表;位于 0x1000-0x1FFF 则选择 Win32k 表。 0x2000-0x2FFF 和 0x3000-0x3FFF 由 SDT 的 Table3 和 Table4 保留。如果分配 ID 超过了 0x3FFF ,在分派前多余的位将被屏蔽掉。
3. 通过检查分派 ID 的 0 到 11 位来确定该 ID 在所选 SST 中对应的 ServiceLimit 成员。如果 ID 超出了范围,将返回错误代码: STATUS_INVALID_SYSTEM_SERVICE 。在一个未使用的 SST 中, ServiceLimit 成员始终是 0 ,从而为所有可能的分派 ID 产生一个错误代码。
4. 通过检查 EDX 中保存的参数堆栈指针,来取得 MmUserProbeAddress 的值。这是由 ntoskrnl.exe 导出的一个公开变量。参数指针通常会与 0x7FFF0000 进行比较。如果没有低于该地址,那么将返回 STATUS_ACCESS_VIOLATION 。
5. 根据在 SST 的 ArgumentTable 中查找到的参数堆栈的字节数,将所有函数参数从调用者堆栈中复制到当前的内核堆栈中。
6. 在从服务调用( Service Call )中返回后,将控制权传递给内部函数 KiServiceExit()
非常有趣的是 INT 2eh 中断处理例程并不使用全局 SDT (即 KeServiceDescriptorTable ),而是使用线程专属的指针替代之。显然,每个线程可以拥有不同的 SDT 。在线程初始化时, KeInitializeThread() 会将 KeServiceDescriptorTable 的指针写入线程控制块( Thread Control Block )中。不过,此默认值在稍后可能会改变,如改为指向 KeServiceDescriptorTableShadow 。
Win32 内核模式接口( Win32 Kernel-mode Interface )
从前面对 SDT 的讨论,可看出存在着与 Native API 相关的第二个主内核模式接口( main Kernel-mode Interface )。该接口将 Win32 子系统的图形设备接口( Graphics Device Interface, GDI )、窗口管理器(即 User 模块)连接至内核组件 ---Win32K (即 Win32k.sys ) . ,该组件随同 Windows NT 4.0 引入。引入该组件是为了克服 Win32 图形引擎固有的性能限制(由于 Windows NT 子系统的最初设计导致)。在 Windows NT 3.x 中, Win32 子系统采用的是客户 - 服务器模式( Client-Server model ),这样就必须从用户模式切换到内核模式才能进行内核调用( Kernel Involved )。通过将图形引擎的绝大部分移至内核组件 ---Win32k.sys ,从而避免了大部分因内核切换导致的性能损失。
Win32K 分派 ID ( Win32K Dispatch IDs )
现在该介绍 Win32k.sys 了,也是该更新 图 2-1 的时候了。 图 2-2 基于 图 2-1 ,但在 ntoskrnl.exe 左面加入了 Win32k.sys 。同时我还加入了从 GDI32.DLL 和 USER32.DLL 指向 Win32k.sys 的箭头。当然,这不是百分之百正确,因为这些模块中的 INT 2eh 调用实际上指向 ntoskrnl.exe ,在 ntoskrnl.exe 中才有该中断的处理例程。然而,调用最后还是由 Win32k.sys 管理,这也是箭头这样指的原因。
稍早提到过, Win32K 接口同样基于 INT 2eh 分派器( INT 2eh Dispatcher ),这与 Native API 非常相似。仅有的区别在于 Win32K 使用另一区段的分派 ID 。尽管与所有 Native API 调用相关的分派 ID 都位于 0x0000----0x0FFF ,而 Win32K 分派 ID 位于 0x1000---0x1FFF 之间。如 图 2-2 所示, Win32K 的主要客户端是 GDI32.DLL 和 USER32.DLL 。因此,通过反编译这些模块(指 gdi32.dll 和 user32.dll )可能会找到与 Win32K 分派 ID 相关的符号化名称。通过反编译可发现在这些模块( gdi32.dll 和 user32.dll )的导出节( export sections )中仅包含 INT 2eh 调用的一个很小的子集,看来是时候再次使用内核调试器了。如 示例 2-3 所示,我通过使用 dd W32pServiceTable 命令,来确定 Win32k.sys 的符号是可用的,在此之前请先使用 .reload 命令以加载所有可用符号文件。
在 示例 2-3 的最后三行中,我使用 ln 命令显示与 W32pServiceTable 的第一个入口地址相关的符号。显然,可看到分派 ID 为 0 的 Win32K 函数为 NtGdiAbortDoc() 。你可以针对所有 639 个 ID 来重复此过程,但是最好能自动进行符号的查找。现在,我已经为你完成了这项工作,所有分派 ID 对应的符号名称都收录在 附录 B 的 表 B-2 中。符号从 gdi32.dll 和 user32.dll 映射到 win32k.sys 十分简单: GDI 符号可通过在其前面添加 NtGdi 前缀就可转换为 Win32K 符号, USER 符号则添加 NtUser 前缀。然而,有一少部分例外。例如,如果一个 GDI 符号以 Gdi 开始,那么其前缀就减少为 Nt ,这可能是为了避免出现 NtGdiGdi 这样的字符序列。在其他的一些例子中,字符的大小写会有些不同(比如 EnableEUDC() 转化后则变成了 NtGdiEnableEudc() ),或者用符号名称尾部的 W 来表示没有对应的 Unicode 函数(如, CopyAcceleratorTableW() 转化后成为 NtUserCopyAcceleratorTable() )。
提供 Win32K API 的详细文档需要很大的努力。这些函数几乎是 Native API 的三倍。或许某天有人会为这些 API 编写一本不错的参考手册,就像 Gary Nebbett 编写的 Native API 手册。不过,在本书范围内,有关这些 API 的信息已经足够了。