通过KiSystemServiceUser获取SSDT基址
技术原理
KeServiceDescriptorTable
在64位Windows中,SSDT被隐藏起来了,但是通过windbg调试发现,nt中导出了KeServiceDescriptorTable,该数组的第一个元素的第一个字段就是SSDT的基址。

下图是KeServiceDescriptorTable数组中元素的类型定义:

下图是KeServiceDescriptorTable数组的定义:
c
DECLSPEC_CACHEALIGN KSERVICE_TABLE_DESCRIPTOR KeServiceDescriptorTable[NUMBER_SERVICE_TABLES];
DECLSPEC_CACHEALIGN KSERVICE_TABLE_DESCRIPTOR KeServiceDescriptorTableShadow[NUMBER_SERVICE_TABLES];
所以我们如果可以找到KeServiceDescriptorTable,就可以找到SSDT。
KiSystemServiceUser
调试发现,KiSystemServiceUser中有这样一段代码:
txt
fffff804`7e8bc3d4 4c8d15e554b400 lea r10,[nt!KeServiceDescriptorTable (fffff804`7f4018c0)]
fffff804`7e8bc3db 4c8d1ddeae9000 lea r11,[nt!KeServiceDescriptorTableShadow (fffff804`7f1c72c0)]
那么我们就可以扫描KiSystemServiceUser的字节码,定位到上面的指令,就可以得到KeServiceDescriptorTable的地址了。
问题是,KiSystemServiceUser并不是一个文档化的函数,在ntoskrnl中也没有被导出,如何得到KiSystemServiceUser的地址?
我们需要从一个可以确定读出的地址,加上偏移得到KiSystemServiceUser的地址。
MSR(0xC0000082)
这个MSR就是syscall之后执行的首条指令的地址,可以通过rdmsr指令读出:
txt
0: kd> u fffff8047edbc200
nt!KiSystemCall64Shadow:
fffff804`7edbc200 0f01f8 swapgs
fffff804`7edbc203 654889242510b00000 mov qword ptr gs:[0B010h],rsp
fffff804`7edbc20c 65488b242500b00000 mov rsp,qword ptr gs:[0B000h]
fffff804`7edbc215 650fba242518b0000001 bt dword ptr gs:[0B018h],1
fffff804`7edbc21f 7203 jb nt!KiSystemCall64Shadow+0x24 (fffff804`7edbc224)
fffff804`7edbc221 0f22dc mov cr3,rsp
fffff804`7edbc224 65488b242508b00000 mov rsp,qword ptr gs:[0B008h]
fffff804`7edbc22d 6a2b push 2Bh
就是KiSystemCall64Shadow的首地址。然后我们查看KiSystemServiceUser的地址:
0: kd> u KiSystemServiceUser
nt!KiSystemServiceUser:
fffff804`7e8bc2e2 c645ab02 mov byte ptr [rbp-55h],2
fffff804`7e8bc2e6 c645a801 mov byte ptr [rbp-58h],1
fffff804`7e8bc2ea 65488b1c2588010000 mov rbx,qword ptr gs:[188h]
fffff804`7e8bc2f3 c6833202000001 mov byte ptr [rbx+232h],1
fffff804`7e8bc2fa 0f0d8b90000000 prefetchw [rbx+90h]
fffff804`7e8bc301 0fae5dac stmxcsr dword ptr [rbp-54h]
fffff804`7e8bc305 650fae142580010000 ldmxcsr dword ptr gs:[180h]
fffff804`7e8bc30e 4c8945c8 mov qword ptr [rbp-38h],r8
用两者的首地址相减,就得到了KiSystemServiceUser相对于KiSystemCall64Shadow的偏移:
txt
0: kd> ? fffff804`7edbc200 - fffff804`7e8bc2e2
Evaluate expression: 5242654 = 00000000`004fff1e
在利用前面扫描到的加载KeSystemServiceUser的指令,就可以得到SDT,进而得到SSDT。
为什么要这样麻烦?其实还是因为ASLR,每次系统启动后,KiSystemCall64Shadow、KiSystemServiceUser等的地址都会发生变化,所以得这样层层计算得到SSDT地址。
下面看看代码实现。
示例代码
c
/*
fffff804`7e8bc3d4 4c8d15e554b400 lea r10,[nt!KeServiceDescriptorTable (fffff804`7f4018c0)]
fffff804`7e8bc3db 4c8d1ddeae9000 lea r11,[nt!KeServiceDescriptorTableShadow (fffff804`7f1c72c0)]
*/
#include <ntifs.h>
#include <intrin.h>
#include <windef.h>
#pragma intrinsic(__readmsr)
const ULONGLONG offset = 0x4fff1e;
static ULONGLONG sg_ullSSDTAddr = 0;
// 获取nt!KeServiceDescriptorTable
ULONGLONG GetSDTAddr()
{
ULONGLONG addr = 0;
ULONGLONG startAddr = __readmsr(0xC0000082) - offset; // 搜索起始地址,就是KiSystemServiceUser开始的地址
ULONGLONG endAddr = startAddr + 0x1000; // 搜索结束地址
UCHAR c1, c2, c3;
ULONGLONG ullSDTOffset = 0;
for (PUCHAR cursor = (PUCHAR)startAddr; cursor < (PUCHAR)endAddr; cursor++)
{
if (MmIsAddressValid(cursor) && MmIsAddressValid(cursor + 1) && MmIsAddressValid(cursor + 2))
{
c1 = *cursor;
c2 = *(cursor + 1);
c3 = *(cursor + 2);
if (c1 == 0x4c && c2 == 0x8d && c3 == 0x15)
{
memcpy_s(&ullSDTOffset, sizeof(ULONGLONG), cursor + 3, 4);
addr = ullSDTOffset + (ULONGLONG)cursor + 7; // 相对寻址指令的偏移量是相对于下一条指令的地址计算的
}
}
}
return addr;
}
ULONGLONG GetSSDTFuncAddr(DWORD dwIndex)
{
DWORD dwOffset = ((DWORD*)sg_ullSSDTAddr)[dwIndex * 4];
ULONGLONG ullFuncAddr = sg_ullSSDTAddr + (dwOffset >> 4);
return ullFuncAddr;
}
VOID DriverUnload(PDRIVER_OBJECT pDriverObj)
{
UNREFERENCED_PARAMETER(pDriverObj);
}
EXTERN_C NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObj, PUNICODE_STRING pRegPath)
{
UNREFERENCED_PARAMETER(pRegPath);
pDriverObj->DriverUnload = DriverUnload;
ULONGLONG sdtBase = GetSDTAddr();
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL, "SDT Address: %p", (PVOID)sdtBase);
sg_ullSSDTAddr = *((ULONGLONG*)sdtBase);
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL, "SSDT Address: %p", (PVOID)sg_ullSSDTAddr);
ULONGLONG ullFuncAddr = GetSSDTFuncAddr(0);
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL, "nt!NtAccessCheck Address: %p", (PVOID)ullFuncAddr);
return STATUS_SUCCESS;
}
计算SSDT中函数的实际地址
在 x64 Windows 系统中,从 SDT(KeServiceDescriptorTable)获取的 SSDT 地址(ServiceTableBase)指向的不是直接的函数指针数组,而是一个经过编码的 32 位偏移数组。因此需要"解密"才能得到真实的函数地址。这是 x64 与 x86 在 SSDT 实现上的关键区别。
根本原因:x64 地址是 64 位(8 字节),直接存储会占用双倍内存,且影响缓存效率。Windows 内核采用了一种压缩存储方案:
-
将 64 位函数地址转换为 32 位有符号偏移(相对于 SSDT 基地址)。
-
存储时进行 4 位右移编码(即 偏移 = (实际地址 - SSDT基地址) << 4)。
-
读取时反向操作:实际地址 = SSDT基地址 + (偏移 >> 4)。
所以计算函数真实地址的公式是:
在x64平台上:FuncAddress = KeServiceDescriptorTable+4\*Index>>4 + KeServiceDescriptorTable
更小的 SSDT 表能更好利用 CPU 缓存,加速系统调用查找。偏移相对于 SSDT 基地址计算,与 KASLR(内核地址空间布局随机化) 兼容,即使内核基址随机化,相对偏移保持不变。