《Undocumented Windows 2000 Secrets》翻译 --- 第五章(3)
第五章 监控 Native API 调用
翻译: Kendiv( fcczj@263.net )
更新: Thursday, March 24, 2005
声明:转载请注明出处,并保证文章的完整性,本人保留译文的所有权利。
本书设计的 hook 机制的最大特色就是它是完全数据驱动的( data-driven )。只需简单的增加一个新的 API 符号表,该 hook dispatcher 就可适应 Windows 2000 的新版本。而且,通过向 apdSdtFormats[] 数组中加入新的 API 函数的格式化字符串就可在任何时候记录对这些附加的 API 函数的调用。这并不需要编写任何附加的代码 ---API Spy 的动作可完全由一组字符串来确定!不过,在定义新的格式化字符串是必须要小心,因为 w2k_spy.sys 是运行于内核模式的驱动程序。因为在这一系统层次上,系统不能温和的处理发生错误。给 Win32 API 函数提供了一个无效的参数并不是问题 ----- 你会收到一个错误提示窗口,同时程序会被系统自动终止。在内核模式下,一个微小的访问违规都会引发系统蓝屏。因此,一定要小心。在需要的地方如果没有出现一个正确的格式化控制 ID 或缺失了这一 ID 都会使你的系统彻底崩溃。即使一个简单的字符串有时都是致命的!
现在仅剩 SpyHookInitializeEx() 中的那一大块 ASM 代码还未讨论,这段代码由 SpyHook2 和 SpyHook9 标识。这段代码的一个有趣的特性是:在 SpyHookInitializeEx() 被调用的时候,它们从来都不会被执行。在进入 SpyHookInitializeEx() 后,函数代码将跳过这一整段代码,然后在 SpyHook9 标签处开始恢复执行,此处包含 aSpyHooks[] 数组的初始化代码。这一大块 ASM 代码只有通过 aSpyHooks[] 数组中的 Handler 成员才能进入。稍候,我将展示这些进入点是如何连接到 SDT 的。
在设计这段 ASM 代码时,我的重要目标之一就是使其是完全非侵入式的。截获操作系统调用非常危险,因为你从来不会知道被调用的代码是否会依赖调用上下文( calling context )的某些未知特性。理论上来说,这些 ASM 代码完全符合 __stdcall 约定,但仍存在出错的可能性。我不得不选择将原始的 Native API 处理例程放入几乎完全相同的环境中,这意味着这些原始函数将使用最初的参数堆栈并且可以访问所有的 CPU 寄存器,就像它们被正常调用一样。当然,必须接受由于插入 hook 所带来的最低限度的危险,否则,监控将不可能实现。在这里,有意义的改动就是维护堆栈中的返回地址。如果你翻回到 图 5-3 ,你会发现在进入函数时,调用者的返回地址并不位于堆栈的顶部。 SpyHookInitializeEx() 中的 hook dispatcher 占用了此地址,将它自己的 SpyHook6 标签的地址写在了这里。因此,原始 Native API 处理例程将被打断,然后进入 SpyHook6 中,这样 hook dispatcher 才能检查原始 Native API 处理例程的参数和它要返回的数据。
在调用原始处理例程之前, dispatcher 将建立一个 SPY_CALL (参见 列表 5-3 )控制块,该控制块中包含它稍候将会用到的参数。其中的一些参数在正确记录 API 调用时会用到,另外一些则提供了有关调用者的信息,因此 dispatcher 可以在写完 log 后,把控制返回给调用者,就像什么都没有发生一样。 Spy 设备在它的全局数据块 DEVICE_CONTEXT 中维护着一个 SPY_CALL 结构的数组,可通过全局变量 gpDeviceContext 来访问。 Hook Dispatcher 通过检查 SPY_CALL 结构中的 InUse 成员来在数组中找到一个空的 SPY_CALL 。 Hook Dispatcher 使用 CPU 的 XCHG 指令来加载和设置该成员的值(译注: XCHG 指令可以保证此操作为原子操作)。这一点非常重要,因为当代码运行于多线程环境中时,读写全局数据时必须采取保护措施以避免条件竞争。如果在数组中找到了一个空的 SPY_CALL , dispatcher 就会将调用者的线程 ID (通过 PsGetCurrentThreadId() 获取)、与当前 API 函数相关的 SPY_HOOK_ENTRY 结构的地址以及整个参数堆栈保存到该 SPY_CALL 结构中。需要复制的参数的字节数取自 KiArqumentTable 数组,该数组保存在系统的 SDT 中。如果所有的 SPY_CALL 都被使用了,原始的 API 函数处理例程将被调用而不会产生任何日志记录。
必须采用 SPY_CALL 数组是因为 Windows 2000 的多线程本性。当 Native API 函数被暂停( suspended )时,这种情况就会经常出现 ---- 此时,另一个线程将获得控制权,然后在它自己的时间片( time slice )内调用另一个 Native API 函数。这意味着 Spy 设备的 Hook Dispatcher 必须允许在任何时间和任何执行点上的重进入( reenter )。如果 Hook Dispatcher 有单一的全局 SPY_CALL 存储区域,它就可能在处于等待状态的线程使用完之前被当前运行的线程覆写( overwritten )。而这种情况正是蓝屏的最佳候选人。为了进一步了解 Native API 的嵌套,我在 Spy 的 DEVICE_CONTEXT 结构中增加了 dLevel 和 dMisses 成员。无论何时只要重进入 hook dispatcher (如,向 SPY_CALL 数组中增加一个新的 SPY_CALL ) dLevel 都不会累加一个 1 。如果超过了最大嵌套层数(如, SPY_CALL 数组已满), dMisses 就会累加一个 1 ,来标识丢失了一个日志记录。根据我的观察,在实际环境下,可以很容易的发现嵌套层达到 4 。这表示即时在高负载( heavy-load )的情况下, Native API 也会被重进入,因此,我将嵌套层数的上限设为 256 。
在调用原始的 API 处理例程之前, Hook Dispatcher 会保存所有的 CPU 寄存器(包括 EFLAGS ),随后执行路径将导向函数的进入点。这会在 列表 5-3 中的 SpyHook5 标签之前立即完成。此时, SpyHook6 将位于栈顶,仅随其后的是调用者的参数。一旦 API 处理例程推出了,控制将被传回到 hook dispatcher 的 SpyHook6 标签。从此处开始执行的代码也被设计为非入侵的。此时,主要目标是允许调用者可以看到调用上下文,这和原始 API 函数建立的上下文几乎完全一致。 Dispatcher 的主要问题是要能立即找到保存有当前 API 调用信息的 SPY_CALL 结构。唯一可以依赖的就是调用者的线程 ID ,该 ID 保存在 SPY_CALL 结构的 hThread 成员中。因此, Dispatcher 循环遍历整个 SPY_CALL 数组以寻找匹配的线程 ID 。注意,代码不会关心 fmuse 标志的值;这并不是必须的,因为数组中所有未使用的 SPY_CALL 结构的 hThread 都被设为了 0 ,这是系统空闲线程的 ID 。循环会在到达数组结尾时终止。否则的话(译注:即没有找到匹配的线程 ID ), Dispatcher 不会将控制返回给调用者,因为这样做将是致命的。在这种情况下,代码的选择余地很小,因此,它会进入 KeBugCheck() ,这样做的结果当然是使系统以受控的方式终止。不过这种情况应该从来不会发生,但如果它发生了,那表示系统必然出现了很严重的错误,因此,使系统终止是最佳解决方案。
如果发现了匹配的 SPY_CALL , Hook Dispatcher 将结束它的工作。最后的动作是调用日志记录函数 SpyHookProtocol() ,需要给该函数传入一个指向 SPY_CALL 结构的指针。日志记录所需的信息都保存在该结构中。当 SpyHookProtocol() 返回后, Dispatcher 就释放它刚才使用的 SPY_CALL ,恢复所有的 CPU 寄存器,然后返回到调用者。
API HOOK 协议
一个好的 API Spy 应该可以在原始函数被调用后还能察看它使用的参数,因为函数可能会通过传入的缓冲区返回附加的数据。因此,日志函数 SpyHookProtocol() 在 hook 例程结束时将被调用,而此时 API 函数还未返回到调用者。在讨论它的实现秘诀之前,请先看看下面给出的两个示例性的协议( Protocol ),它们会为你提供一个大概的方向。 图 5-6 是在命令行下执行 dir c: 时产生的日志文件的快照。
请对比 图 5-6 中列出的日志项和 列表 5-6 给出的协议格式化字符串。在 示列 5-1 中, NtOpenFile() 和 NtClose() 的格式化字符串分别对应 图 5-6 中的第一行和第四行。它们有着惊人的相似处;每一个格式化控制 ID 都紧随在一个 % 号后(参考 表 5-2 ),与其相关的参数项将包含在协议中。不过,协议还包含一些附加的信息,这些信息明显不属于格式字符串。稍后我将解释这种差异的原因。
示例 5-2 给出了一个协议项的一般格式。每一项包含相同个数的域,这些域采用分隔符隔开。这样分隔可以使程序很容易的解析它。这些域按照如下的一组简单的基本规则来构建:
l 所有的数字都已十六进制表示,没有 0 前缀或常见的前缀“ 0x ”
l 函数的多个参数由逗号隔开
l 字符串参数将位于一对双引号中
l 结构体成员的值由“ . ”符号隔开
图 5-6. 命令 dir c: 的示列协议
'%s=NtOpenFile(%+,%n,%o,%i,%n, %n) '
18:sO=NtOpenFile(+46C.18,nl00001,o'??C:',i0.1,n3,n4021)lBFEE5AE05B6710,278,2
'%s=NtClose(%-l)'
lB:sO=NtClose(-46C.18='??C:')lBFEE5AE05B6710,278,l
示列 5-1. 比较格式化字符串和协议项
<#> : <status>=<function> (<arguments>) <time> , <thread>, <handles>
示列 5-2. 协议项的一般格式
l 与句柄相关的对象名称和句柄的值采用“ = ”进行分割。
l 日期 / 时间的 stamp 为 1601-01-01 至今逝去的毫秒数,其格式依赖 Windows 2000 的基本时间格式,精度可达到 1/10 毫秒。
l 线程 ID 是调用 API 函数的线程的唯一数字标识。
l 句柄计数的状态表示当前注册到 Spy 设备句柄列表中的句柄的数量。协议函数使用该列表查找与对象名称相关的句柄。
图 5-7. 命令 type c:boot.ini 的示列协议
图 5-7 是在控制台中执行: type c:boot.ini 命令产生的 API Spy 协议结果。下面给出日志项中的某些列的含义:
l 在 0x31 行,调用了 NtCreateFile() 来打开 ??c:boot.ini 文件。( o”??c:boot.ini” )该函数返回的 NTSTATUS 的值为 0 ( s0 ),即 STATUS_SUCCESS ,并分配了一个新的文件句柄,其值为 0 小 8 ,该句柄属于进程 0x46c ( +46C.18 )。因此,句柄计数从 1 增加到 2 。
l 在 0x36 行, type 命令将文件 ??c:boot.ini 的前 512 个字节( n200 )读入位于线性地址 0x0012F5B4 处的缓冲区中,并把从 NtCreateFile() 获取的句柄解析给 NtReadFile() 函数。系统成功的返回 512 字节( io.200 )。
l 在 0x39 行,将处理另一块 512 个字节的文件块。这一次,将到达文件的末尾,因此 NtReadFile() 仅返回了 75 个字节( io.4B )。显然,我的 boot.ini 文件的大小为: 512+75=587 字节。
l 在 0x3C 行, NtClose() 成功的释放了指向 ??c:boot.ini 的文件句柄( -46.18=”??c:boot.ini” ),因此,句柄计数将从 2 减少为 1 。
现在,你应该已经明白 Spy 协议的 API 是如何构建的了,这会帮助你掌握协议生成机制的细节,接下来我们将讨论这一机制。在前面我曾提及过,用于日志记录的主要 API 函数是 SpyHookProtocol() 。 列表 5-7 给出了该函数,它将使用 SPY_CALL 结构中的数据来为每个 API 函数生成一个协议记录并将其写入一个环形缓冲区中,这里的 SPY_CALL 结构由 Hook Dispatcher 传入。一个 Spy 设备的客户端可以通过 IOCTL 调用来读去这一协议。每个记录项都是一行文本,每行都由单个行结束符(即 C 语言中的 ”n” )表示行的结束。通过使用内核的 Mutext KMUTEX kmProtcol 来实现串行读去协议缓冲区, kmProtocol 位于 Spy 设备的全局结构 DEVICE_CONTEXT 中。 列表 5-7 中的 SpyHookWait() 和 SpyHookRelease() 函数用于请求和释放此 Mutext 对象。所有对协议缓冲区的访问都必须由 SpyHookWait() 预处理并在结束时由 SpyHookRelease() 处理, SpyHookProtocol() 函数展示了这种行为。
NTSTATUS SpyHookWait (void)
{
return MUTEX_WAIT (gpDeviceContext->kmProtocol);
}
// -----------------------------------------------------------------
LONG SpyHookRelease (void)
{
return MUTEX_RELEASE (gpDeviceContext->kmProtocol);
}
// -----------------------------------------------------------------
// <#>:<status>=<function>(<arguments>)<time>,<thread>,<handles>
void SpyHookProtocol (PSPY_CALL psc)
{
LARGE_INTEGER liTime;
PSPY_PROTOCOL psp = &gpDeviceContext->SpyProtocol;
KeQuerySystemTime (&liTime);
SpyHookWait ();
if (SpyWriteFilter (psp, psc->pshe->pbFormat,
psc->adParameters,
psc->dParameters))
{
SpyWriteNumber (psp, 0, ++(psp->sh.dCalls)); // <#>:
SpyWriteChar (psp, 0, ':');
// <status>=
SpyWriteFormat (psp, psc->pshe->pbFormat, // <function>
psc->adParameters); // (<arguments>)
SpyWriteLarge (psp, 0, &liTime); // <time>,
SpyWriteChar (psp, 0, ',');
SpyWriteNumber (psp, 0, (DWord) psc->hThread); // <thread>,
SpyWriteChar (psp, 0, ',');
SpyWriteNumber (psp, 0, psp->sh.dHandles); // <handles>
SpyWriteChar (psp, 0, 'n');
}
SpyHookRelease ();
return;
}
列表 5-7. 主要的 Hook 协议函数 SpyHookProtocol()
如果你比较一下 列表 5-7 给出的 SpyHookProtocol() 函数的主要部分和 示列 5-2 给出的协议项的一般格式,将很容易找出那个语句生成了协议项中的哪一个域( fIEld )。这样一来一切就很清楚了为什么 列表 5-6 中的协议字符串没有说明整个数据项 --- 有些独立于功能的数据将由 SpyHookProtocol() 添加,而这将不需要格式字符串的帮助。 SpyHookProtocl() 的核心调用是 SpyWriteFormat() ,该函数生成 <status>=<function>[<arguments>] 部分,这依赖于与要记录的当前 API 函数相关的格式字符串。请参考位于随书光盘的 srcw2k_spy 目录下的源文件 w2k_spy.c 和 w2k_spy.h ,以获取 Spy 设备驱动程序中使用的 SpyWrite*() 函数的更多实现信息。
请注意,这些代码稍微有些危险。这些代码编写与 1997 年是针对 Windows NT 4.0 的。在移植到 Windows 2000 之后,当 hook 工作一段较长时间后会偶尔引发蓝屏。更糟糕的是,有些特殊的操作将立即引发蓝屏,例如,在 My Favoriter 文本编辑器的 FileOpen 对话框中打开我的电脑时。在分析过多过 crash dump 后,我发现是由于将 NULL 指针传递给了某些函数从而导致了系统崩溃。一但 Spy 设备试图使用这些指针中的某个来记录该指针引用的数据时,系统就会崩溃。典型的就是,指向 IO_STATUS_BLOCK 结构的指针,在 UNICODE_STRING 和 OBJECT_ATTRIBUTES 结构中存在无效的字符串指针。我还发现某些有 Buffer 成员的 UNICODE_STRING 结构没有 结束符。因此,我再次强调你不应该假定所有的 UNICODE_STRING 结构都以 结束。在不能确定时,请使用 Length 成员,它总能正确地告诉你在 Buffer 中存放的有效的字节数。
为了修正这一问题,我为所有使用客户指针的日志函数增加了指针有效性检查。在结束时,我使用第四章讨论过的 SpyMemoryTestAddress() 函数来检验一个线性地址指针是否指向一个有效的页表项( PTE )。更详细的信息请参考 列表 4-22 和 列表 4-24 。另一种可能的替代方案是使用结构化异常( __try/__except )。