SpyHook1:
push eax
mov eax, offset SpyHook2
call eax
push eax
mov eax, offset SpyHook2
call eax
;244 boring repetitions cimitted
push eax
mov eax, offset SpyHook2
call eax
push eax
mov eax, offset SpyHook2
call eax
SpyHook2:
pop eax
列表 5-4. 扩充 SpyHook 宏调用
例如, 列表 5-4 中第一个 CALL EAX 指令的返回地址是其下一个语句的地址。通常,第 N 个 CALL EAX 指令的返回地址是第 N+1 个语句的地址,但最后一个除外,最后这个将返回 SpyHook2 。因此,从 0 开始的所有进入点的索引可以由 图 5-4 中的通用公式计算出来。这三条规则中的潜在规则是: SDT_SYMBOLS_MAX 进入点符合内存块 SpyHook2---SpyHook1 。那么有多少个进入点符合 ReturnAddress---SpyHook1 呢?因为计算结果是位于 0 到 SDT_SYMBOLS_MAX 中的某一个数值,所以,肯定要使用该数值来获取一个从 0 开始的索引。

图 5-4. 通过 Hook 进入点的返回地址确定一个 Hook 进入点
图 5-4 所示公式的实现方式可以在 列表 5-3 中找到,在汇编标签 SpyHook2 的右边。在 图 5-5 的左下角也给出了该公式的实现代码,它展示了 Hook Dispatcher 机制的基本原理。注意, i386 的 mul 指令会在 EDX:EAX 寄存器中产生一个 64 位的结果值,这正是其后的 div 指令所期望的,因此,这里没有整数溢出的危险。在 图 5-5 的左上角,是对 KiServiceTable 的描述,该表将被 SpyHook 宏生成的进入点地址修改。在图的中部展示了展开后的宏代码(来自 列表 5-4 中)。进入点的线性地址位于图的右手边。为了完全一致,每个进入点的大小都是 8 字节,因此,通过将 KiServiceTable 中每个函数的索引值乘以 8 ,然后再将乘积加上 SpyHook1 的地址就可得出进入点的地址。
事实上,每个进入点并不都是纯粹的 8 字节长。我花费了大量的时间来寻找最佳的 hook 函数的实现方式。尽管按照 32 位边界对齐代码并不是必须的,但这从来都不是个坏主意,因为这会提高性能。当然,能提升的性能十分有限。你或许会奇怪:为什么我要通过 EAX 寄存器间接的调用 SpyHook2 ,而不是直接使用 CALL SpyHook2 指令,这不是更高效吗?是的!不过,问题是 i386 的 CALL (还有 jmp )指令可以有多种实现方式,而且都具有相同的效果,但是产生的指令大小却不相同。请参考: Intel's Instruction Set Reference of the Pentium CPU family ( Intel 199c )。因为最终的实现方式要由编译器 / 汇编器来确定,这不能保证所有的进入点都会有相同的编码。换句话说, MOV EAX 和一个 32 位常量操作数总是以相同的方式编码,同样的,这也适用于 CALL EAX 指令。

图 5-5. Hook Dispatcher 的功能原理
列表 5-3 中还有一点需要澄清。让我们从 SpyHook9 标签后的最后一快 C 代码段开始。紧随 SpyHook9 之后的汇编代码将 SpyHook1 和 SpyHook2 的线性地址保存在 dHook1 和 dHook2 变量中。接下来,变量 n 被设为每个进入点的大小(由进入点数组的大小除以进入点的个数而得出)。当然,这个值将是 8 。 列表 5-3 的剩余部分是一个循环语句,用来初始化全局数组 aSpyHooks[] 中的所有项。这个数组所包含的 SPY_HOOK_ENTRY 结构定义于列 表 5-3 的顶部,该数组中的每一项都对应一个 Native API 函数。要理解该结构中的 Handler 和 pbFormat 成员是如何被设置的,就必须进一步了解传递给 SpyHookInitializeEx() 的 ppbSymbols 和 ppbFormats 参数, 列表 5-5 给出了外包函数 SpyHookInitialize() ,该函数会选择适合当前 OS 版本的参数来调用 SpyHookInitializeEx() 。前面已经提示过,我使用的代码不直接测试 OS 版本或 Build Number ,而是用常量 SPY_SYMBOLS_NT4 、 SPY_SYMBOLS_NT5 和 SDT 中与 ntoskrnl.exe 相关的 ServiceLimit 成员的值进行比较。如果没有一个匹配, Spy 设备将把 aSpyHooks[] 数组内容全部初始化为 NULL ,从而有效的禁止 Native API Hook 机制。
BOOL SpyHookInitialize (void)
{
BOOL fOk = TRUE;
switch (KeServiceDescriptorTable->ntoskrnl.ServiceLimit)
{
case SDT_SYMBOLS_NT4:
{
SpyHookInitializeEx (apbSdtSymbolsNT4, apbSdtFormats);
break;
}
case SDT_SYMBOLS_NT5:
{
SpyHookInitializeEx (apbSdtSymbolsNT5, apbSdtFormats);
break;
}
default:
{
SpyHookInitializeEx (NULL, NULL);
fOk = FALSE;
break;
}
}
return fOk;
}
列表 5-5. SpyHookInitialize() 选择匹配当前 OS 版本的符号表
将全局数组: apbSdtSymbolsNT4[] 和 apbSdtSymbolsNT5[] 传递给 SpyHookInitializeEx() 函数作为其第一个参数 ppbSymbols ,这两个数组只是简单的字符串数组,包含 Windows NT 4.0 和 windows 2000 的所有 Native API 函数的名称,按照它们在 KiServiceTable 中的索引顺序来存储,最后以 NULL 结束。 列表 5-6 给出了 apbStdFormats[] 字符串数组。这个格式字符串列表也是 hook 机制中很重要的一部分,因为它确定了记录了那个 Native API 调用,以及每个记录项的格式。显然,这些字符串的结构借鉴了 C 运行时库中的 printf() 函数,但针对 Native API 经常使用的数据类型进行了修改。 表 5-2 列出了所有可被 API Logger 识别的格式化 ID 。
PBYTE apbSdtFormats [] =
{
"%s=NtCancelIoFile(%!,%i)",
"%s=NtClose(%-)",
"%s=NtCreateFile(%+,%n,%o,%i,%l,%n,%n,%n,%n,%p,%n)",
"%s=NtCreateKey(%+,%n,%o,%n,%u,%n,%d)",
"%s=NtDeleteFile(%o)",
"%s=NtDeleteKey(%-)",
"%s=NtDeleteValueKey(%!,%u)",
"%s=NtDeviceIoControlFile(%!,%p,%p,%p,%i,%n,%p,%n,%p,%n)",
"%s=NtEnumerateKey(%!,%n,%n,%p,%n,%d)",
"%s=NtEnumerateValueKey(%!,%n,%n,%p,%n,%d)",
"%s=NtFlushBuffersFile(%!,%i)",
"%s=NtFlushKey(%!)",
"%s=NtFsControlFile(%!,%p,%p,%p,%i,%n,%p,%n,%p,%n)",
"%s=NtLoadKey(%o,%o)",
"%s=NtLoadKey2(%o,%o,%n)",
"%s=NtNotifyChangeKey(%!,%p,%p,%p,%i,%n,%b,%p,%n,%b)",
"%s=NtNotifyChangeMultipleKeys(%!,%n,%o,%p,%p,%p,%i,%n,%b,%p,%n,%b)",
"%s=NtOpenFile(%+,%n,%o,%i,%n,%n)",
"%s=NtOpenKey(%+,%n,%o)",
"%s=NtOpenProcess(%+,%n,%o,%c)",
"%s=NtOpenThread(%+,%n,%o,%c)",
"%s=NtQueryDirectoryFile(%!,%p,%p,%p,%i,%p,%n,%n,%b,%u,%b)",
"%s=NtQueryInformationFile(%!,%i,%p,%n,%n)",
"%s=NtQueryInformationProcess(%!,%n,%p,%n,%d)",
"%s=NtQueryInformationThread(%!,%n,%p,%n,%d)",
"%s=NtQueryKey(%!,%n,%p,%n,%d)",
"%s=NtQueryMultipleValueKey(%!,%p,%n,%p,%d,%d)",
"%s=NtQueryOpenSubKeys(%o,%d)",
"%s=NtQuerySystemInformation(%n,%p,%n,%d)",
"%s=NtQuerySystemTime(%l)",
"%s=NtQueryValueKey(%!,%u,%n,%p,%n,%d)",
"%s=NtQueryVolumeInformationFile(%!,%i,%p,%n,%n)",
"%s=NtReadFile(%!,%p,%p,%p,%i,%p,%n,%l,%d)",
"%s=NtReplaceKey(%o,%!,%o)",
"%s=NtSetInformationKey(%!,%n,%p,%n)",
"%s=NtSetInformationFile(%!,%i,%p,%n,%n)",
"%s=NtSetInformationProcess(%!,%n,%p,%n)",
"%s=NtSetInformationThread(%!,%n,%p,%n)",
"%s=NtSetSystemInformation(%n,%p,%n)",
"%s=NtSetSystemTime(%l,%l)",
"%s=NtSetValueKey(%!,%u,%n,%n,%p,%n)",
"%s=NtSetVolumeInformationFile(%!,%i,%p,%n,%n)",
"%s=NtUnloadKey(%o)",
"%s=NtWriteFile(%!,%p,%p,%p,%i,%p,%n,%l,%d)",
NULL
};
列表 5-6. Native API Logger 使用的格式化字符串
这里要特别提出的是:每个格式字符串要求必须提供函数名的正确拼写。 SpyHookInitializeEx() 遍历它接受到的 Native API 符号列表(通过 ppbSymbols 参数),并试图从 ppbFormats 列表中找出与函数名匹配的格式字符串。由帮助函数 SpySearchFormat() 来进行比较工作, 列表 5-3 底部的 if 语句中调用了该函数。因为要执行大量的字符串查找操作,我使用了一个高度优化的查找引擎,该引擎基于“ Shift/And ”搜索算法。如果你想更多的学习它的实现方式,请察看随书 CD 的 \src\w2k_spy\w2k_spy.c 源文件中的 SpySearch*() 函数。当 SpyHookInitializeEx() 推出循环后, aSpyHooks[] 中的所有 Handler 成员都将指向适当的 Hook 进入点, pbFormat 成员提供与之匹配的格式字符串。对于 Windows NT 4.0 ,所有索引值在 0xD3---0xF8 的数组成员都将被设为 NULL ,因为在 NT4 中,它们并没有被定义。
表 5-2. 可识别的格式控制 ID
|
ID |
名 称 |
描 述 |
|
%+ |
句柄(登记) |
将句柄和对象名写入日志,并将其加入句柄表。 |
|
%! |
句柄(检索) |
将句柄写入日志,并从句柄表中检索其对应的对象名。 |
|
%- |
句柄(撤销登记) |
将句柄和对象名写入日志,并将其从句柄表移除 |
|
%a |
ANSI 字符串 |
将一个由 8 位 ANSI 字符构成的字符串写入日志 |
|
%b |
BOOLEAN |
将一个 8 位的逻辑值写入日志 |
|
%c |
CLIENT_ID* |
将 CLIENT_ID 结构的成员写入日志 |
|
%d |
DWORD * |
将该 DWORD 所指变量的值写入日志 |
|
%i |
IO_STATUS_BLOCK * |
将 IO_STATUS_BLOCK 结构的成员写入日志 |
|
%l |
LARGE_INTEGER * |
将一个 LARGE_INTEGER 的值写入日志 |
|
%n |
数值 (DWORD) |
将一个 32 位无符号数写入日志 |
|
%o |
OBJECT_ATTRIBUTES * |
将对象的 ObjectName 写入日志 |
|
%p |
指针 |
将指针的目标地址写入日志 |
|
%s |
状态 (NTSTATUS) |
将 NT 状态代码写入日志 |
|
%u |
UNICODE_STRING * |
将 UNICOD_STRING 结构的 Buffer 成员写入日志 |
|
%w |
宽字符串 |
将一个由 16 位字符构成的字符串写入日志 |
|
%% |
百分号转义符 |
将一个“ % ”号写入日志 |