Windows SSDT Hook(二)
获取SSDT基址
在32位Windows系统中,SSDT(System Service Descriptor Table)作为系统服务调用的核心数据结构,其基址信息是公开且稳定的。由于ntoskrnl.exe
内核模块默认导出KeServiceDescriptorTable
符号,开发者只需使用extern
声明即可直接访问:
extern PKSERVICE_TABLE_DESCRIPTOR KeServiceDescriptorTable;
然而,64位系统架构带来了重大变化。微软出于安全考虑,不再导出SSDT相关符号,并引入了以下保护机制:
- SSDT表基址变为动态变化,每次系统启动都会重新随机化
- 移除了直接的符号导出
- 增加了内核
PatchGuard
保护
因此,想要获取64位SSDT基址就需要一些特殊技术手段来实现,常见方法包括:
-
借助第三方符号(PDB)或调试器
-
通过
msr
寄存器读取
通过读取msr 0xC0000082
可得到 KiSystemCall64
的函数地址,从此函数地址向下可以定位到KiSystemServiceRepeat
函数,从而间接获取 SSDT。
从上图中可以看出,4C 8D 15[lea r10]
指令后面的操作数即为SSDT地址的相对偏移,因此在通过特征值定位到此处时,可以通过以下公式计算出实际的SSDT地址。
SSDT地址 = Rip + offset = 0x140070FF9 + 0x237847
这种方法的实现在网络上可以轻松获取,这里就不详细展开了。值得注意的一点是,win10 1903版本之后,由于引入了内核隔离机制,通过msr
获取到的地址不再是 KiSystemCall64
,而是KiSystemCall64Shadow
,这也导致了无法按照以上方法找到SSDT表。
- 通过特征码扫描内核映像
由于无法再通过msr
来实现查找,因此在此基础上又延伸出了类似的方案。即通过某些确定的系统函数地址,向上或向下寻找SSDT的特征值。通过IDA分析内核模块中Zw*
系统调用接口(这里并非ntdll中的用户态调用接口,而是提供给wdm驱动使用的内核态接口),可以看出内核模块中的系统调用实际上同样是通过SSDT表实现的。以ZwCreateFile
函数为例,内部调用KiServiceInternal
,然后通过jmp
指令跳转到KiSystemCall64
函数中。
PLONG HookHandler::GetSSDTBase() {
#ifdef _WIN64const PVOID zw_close = ZwClose;const ULONG offset = 0x400;static PSSDTEntry ssdt_address = nullptr;__try {if (ssdt_address) return ssdt_address->ServiceTableBase;PUCHAR service_internal_address = nullptr;PUCHAR zw_route_address = (PUCHAR)zw_close;// .text:000000014006A653 50 push rax// .text:000000014006A654 B8 0C 00 00 00 mov eax, 0Ch// .text:000000014006A659 E9 E2 67 00 00 jmp KiServiceInternalconst UCHAR service_internal_code[] = {0x50, 0xB8, 0x00, 0x00, 0x00, 0x00, 0xE9, 0x00};for (ULONG i = 0; i < offset; i++) {if ((*(PULONGLONG)&zw_route_address[i] & *(PULONGLONG)&service_internal_code) ==*(PULONGLONG)&service_internal_code) {ULONG jmp_offset = *(PULONG)&zw_route_address[i + 7];service_internal_address = &zw_route_address[i + 6] + 5 + jmp_offset;break;}}if (!service_internal_address) return nullptr;// .text:0000000140070FF2 4C 8D 15 47 78 23 00 lea r10, KeServiceDescriptorTable// .text:0000000140070FF9 4C 8D 1D 80 78 23 00 lea r11, KeServiceDescriptorTableShadowconst UCHAR ssdt_table_code[] = {0x4C, 0x8D, 0x15, 0x00};for (ULONG i = 0; i < offset; i++) {if ((*(PULONG)&service_internal_address[i] & *(PULONG)&ssdt_table_code) ==*(PULONG)&ssdt_table_code) {ULONG jmp_offset = *(PULONG)&service_internal_address[i + 3];ssdt_address = (PSSDTEntry)(&service_internal_address[i] + 7 + jmp_offset);break;}}if (!ssdt_address) return nullptr;return ssdt_address->ServiceTableBase;} __except (EXCEPTION_EXECUTE_HANDLER) {return nullptr;}
#elsereturn KeServiceDescriptorTable.ServiceTableBase;
#endif
}
除了上述方法,在网上还见过一种通过在_strnicmp
函数到KdDebuggerNotPresent
之间搜索8b f8 c1 ef 07 83 e7 20 25 ff 0f 00 00
的定位方式。
SSDT解析
前一篇中提到SSDT中的ServiceTableBase
指向的一个ULONG
数组,32位系统中的数组元素代表函数地址,而在64位系统中,由于ULONG类型无法存储完整的指针类型,因此实际存储的是函数地址相对于SSDT基址的"偏移"。需要注意的是这里的偏移并不仅仅是简单的偏移,而是需要固定公式计算出的偏移长度。
// 计算函数地址
PVOID func_addr = (PVOID)(((LONG)ssdt_base[info.func_index] >> 4) + (ULONGLONG)func_addr);
// 计算偏移
ssdt_base[info.func_index] = (LONG)((ULONGLONG)target_addr - (ULONGLONG)ssdt_base) << 4
跳转指令
由于ServiceTableBase
中存储的实际上是相对于SSDT基址的"偏移",因此hook函数的地址必须在以SSDT基址为中心的4GB内存范围内。而通常情况下我们自己的驱动的加载地址必然在4GB范围外,因此我们不能简单的将Hook函数的地址写入SSDT表,而应该在范围内选择一块地址作为函数跳转的中转。
第一步,选择合适的跳转指令
这里我们选择长度最小的12字节跳转法,实现从中转地址跳转到Hook函数。
#pragma pack(push, 1)
struct HookShellCode {USHORT mov;ULONG_PTR addr;UCHAR push;UCHAR ret;
};
#pragma pack(pop)
HookShellCode shell_code;
shell_code.mov = 0xB848;
shell_code.addr = (ULONG_PTR)func_addr;
shell_code.push = 0x50;
shell_code.ret = 0xC3;
第二步,遍历所有的代码段,从中找出合适的中转地址
PULONG kernel_base = GetKernelBase();// 获取内核模块基址
if (!kernel_base) return nullptr;// Dos Header
PIMAGE_DOS_HEADER dos_header = (PIMAGE_DOS_HEADER)kernel_base;
if (dos_header->e_magic != IMAGE_DOS_SIGNATURE) return nullptr;// NT Header
PIMAGE_NT_HEADERS nt_header = (PIMAGE_NT_HEADERS)((PUCHAR)dos_header + dos_header->e_lfanew);
if (nt_header->Signature != IMAGE_NT_SIGNATURE) return nullptr;// Section Header
IMAGE_SECTION_HEADER* section_header = IMAGE_FIRST_SECTION(nt_header);
ULONG rva = (ULONG)((PUCHAR)base_addr - (PUCHAR)kernel_base);
// 遍历代码段
for (USHORT i = 0; i < nt_header->FileHeader.NumberOfSections; i++) {// find the section which function existsif (section_header[i].VirtualAddress <= rva &§ion_header[i].VirtualAddress + section_header[i].Misc.VirtualSize > rva) {PUCHAR section_addr = (PUCHAR)kernel_base + section_header[i].VirtualAddress;for (ULONG offset = 0, size = 0; offset < section_header[i].SizeOfRawData; offset++) {if (section_addr[i] == 0x90 || section_addr[i] == 0xCC) // NOP or INT3size++;elsesize = 0;// 找到大小匹配的空闲内存if (size == HOOK_CODE_SIZE)return (PULONG)(section_addr + offset - HOOK_CODE_SIZE + 1);}}
}
PatchGuard
基于以上内容,就可以实现在64位系统上的SSDT Hook了。然而,当实际运行Hook驱动程序时,会发现正常运行一段时间后,系统就会触发BSOD(错误代码:0x109,内核检测到关键内核代码或数据损坏),而这就是PatchGuard
的作用了。
到目前为止,通过PatchGuard
的方法未作深入研究,仅通过网络得知可能存在以下两种方法:
- 通过某种方式伪装骗过PG检测,例如EPT Hook等。
- 直接与PG对抗