10、SSDT与系统服务:SSDT结构、系统服务号、Hook SSDT、Shadow SSDT、Inline Hook

好,咱们今天聊点硬核的——SSDT。这东西,说白了就是Windows内核给用户态程序开的一扇门。你想想看,一个普通的exe怎么调用到内核里?比如CreateFile,最终会走到NtCreateFile,而这个NtCreateFile的地址,就存在SSDT里。

我刚开始做驱动开发那会儿,对SSDT的理解也就停留在“一个表”的层面。直到有一次,我想拦截某个系统调用,结果蓝屏了一整天……嗯,从那以后,我才真正开始研究它的结构。

10.1 SSDT的结构

SSDT全称是System Service Dispatch Table。它的核心就是一个函数指针数组。每个系统服务号对应一个函数地址。Windows内核通过这个表,把用户态的请求分发到对应的内核函数。

它的结构大致是这样的:

typedef struct _SERVICE_DESCRIPTOR_TABLE {
    PVOID   ServiceTableBase;      // SSDT基地址
    PVOID   ServiceCounterTable;   // 服务计数器(调试用)
    ULONG   NumberOfServices;      // 服务数量
    PVOID   ParamTableBase;        // 参数表基地址
} SERVICE_DESCRIPTOR_TABLE, *PSERVICE_DESCRIPTOR_TABLE;

这里要注意,ServiceTableBase指向的就是那个函数指针数组。每个指针占4字节(32位系统)或8字节(64位系统)。NumberOfServices告诉你这个表里有多少个服务。

关键点:Windows有两个SSDT表。一个是KeServiceDescriptorTable,另一个是KeServiceDescriptorTableShadow。前者管内核服务,后者还管Win32k.sys的GUI服务。

10.2 系统服务号

每个系统调用都有一个唯一的服务号。比如NtCreateFile的服务号是0x55(在Windows 10 x64上可能不同)。这个服务号就是SSDT数组的索引。

用户态调用系统服务时,会把服务号放在eax寄存器里,然后执行syscall指令。内核的KiSystemServiceHandler会根据eax里的值,去SSDT里找对应的函数地址。

我曾在项目中遇到过一个问题:不同Windows版本的服务号不一样。比如Windows 7和Windows 10,同一个NtCreateFile的服务号可能差了好几个。所以写Hook代码时,千万别写死服务号,得动态获取。

小技巧:可以用ZwQuerySystemInformation来枚举系统服务,或者直接解析ntoskrnl.exe的导出表,找到NtCreateFile的地址,然后反推服务号。

10.3 Hook SSDT

Hook SSDT,说白了就是修改SSDT表里的函数指针,让它指向你自己的函数。这样当用户态调用某个系统服务时,就会先执行你的代码。

基本步骤是这样的:

  1. 获取KeServiceDescriptorTable的地址
  2. 找到你要Hook的服务号对应的表项
  3. 把原来的函数地址保存下来
  4. 把表项改成你的Hook函数地址
  5. 在你的Hook函数里,处理完逻辑后,调用原函数

代码示例:

// 保存原函数地址
typedef NTSTATUS (*NTCREATEFILE)(
    PHANDLE FileHandle,
    ACCESS_MASK DesiredAccess,
    POBJECT_ATTRIBUTES ObjectAttributes,
    PIO_STATUS_BLOCK IoStatusBlock,
    PLARGE_INTEGER AllocationSize,
    ULONG FileAttributes,
    ULONG ShareAccess,
    ULONG CreateDisposition,
    ULONG CreateOptions,
    PVOID EaBuffer,
    ULONG EaLength
);

NTCREATEFILE OriginalNtCreateFile = NULL;

// Hook函数
NTSTATUS HookNtCreateFile(
    PHANDLE FileHandle,
    ACCESS_MASK DesiredAccess,
    POBJECT_ATTRIBUTES ObjectAttributes,
    PIO_STATUS_BLOCK IoStatusBlock,
    PLARGE_INTEGER AllocationSize,
    ULONG FileAttributes,
    ULONG ShareAccess,
    ULONG CreateDisposition,
    ULONG CreateOptions,
    PVOID EaBuffer,
    ULONG EaLength
) {
    // 在这里做你的拦截逻辑
    DbgPrint("File opened: %wZ\n", ObjectAttributes->ObjectName);
    
    // 调用原函数
    return OriginalNtCreateFile(
        FileHandle, DesiredAccess, ObjectAttributes,
        IoStatusBlock, AllocationSize, FileAttributes,
        ShareAccess, CreateDisposition, CreateOptions,
        EaBuffer, EaLength
    );
}

// 安装Hook
VOID InstallHook() {
    ULONG serviceIndex = 0x55; // NtCreateFile的服务号
    OriginalNtCreateFile = (NTCREATEFILE)
        KeServiceDescriptorTable->ServiceTableBase[serviceIndex];
    
    // 注意:需要先关闭写保护
    _disable();
    KeServiceDescriptorTable->ServiceTableBase[serviceIndex] = HookNtCreateFile;
    _enable();
}

警告:修改SSDT需要关闭CR0寄存器的写保护位。在64位系统上,还需要禁用PatchGuard。否则系统会蓝屏给你看。我曾经在Windows 10上试过,没关PatchGuard,结果系统直接崩溃了。

10.4 Shadow SSDT

Shadow SSDT是KeServiceDescriptorTableShadow。它比普通的SSDT多了一个表,专门处理Win32k.sys的GUI服务。比如NtUserCreateWindowEx、NtGdiBitBlt这些。

为什么要单独搞一个Shadow SSDT?因为GUI服务涉及到窗口管理、绘图这些,跟普通的内核服务不一样。它们需要访问Win32k.sys里的数据结构。

Hook Shadow SSDT的步骤跟普通SSDT差不多,但有个坑:Shadow SSDT的地址不是导出的。你得自己找。我记得有一种方法是通过解析KTHREAD结构里的ServiceTable字段来定位。

// 获取Shadow SSDT的地址
PVOID GetShadowSSDT() {
    // 通过当前线程的KTHREAD结构获取
    PKTHREAD currentThread = KeGetCurrentThread();
    PSERVICE_DESCRIPTOR_TABLE shadowTable = 
        (PSERVICE_DESCRIPTOR_TABLE)currentThread->ServiceTable;
    
    // 注意:ServiceTable[1]才是Shadow SSDT
    return shadowTable[1].ServiceTableBase;
}

提示:在64位系统上,Shadow SSDT的Hook更复杂。因为PatchGuard会检查SSDT的完整性。我建议用Inline Hook代替直接修改表项。

10.5 Inline Hook

Inline Hook,说白了就是直接修改目标函数的代码。在函数开头写入一个jmp指令,跳转到你的Hook函数。这样就不需要动SSDT表了。

它的好处是:

  • 不需要修改SSDT,避免被PatchGuard检测
  • 可以Hook任意内核函数,不限于系统服务
  • 更灵活,可以只Hook函数的某一部分

但缺点也很明显:

  • 需要处理指令对齐,否则会破坏函数逻辑
  • 多核环境下需要同步
  • 如果函数被多次Inline Hook,容易出问题

代码示例:

// Inline Hook的jmp指令
// x64: jmp [rip+offset]
BYTE jmpCode[] = {
    0xFF, 0x25, 0x00, 0x00, 0x00, 0x00, // jmp [rip+0]
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 // 目标地址
};

VOID InstallInlineHook(PVOID targetFunc, PVOID hookFunc) {
    // 保存原函数的前几个字节
    memcpy(originalBytes, targetFunc, sizeof(jmpCode));
    
    // 写入jmp指令
    // 注意:需要先关闭中断和写保护
    _disable();
    memcpy(targetFunc, jmpCode, sizeof(jmpCode));
    _enable();
}

VOID UninstallInlineHook(PVOID targetFunc) {
    // 恢复原函数
    _disable();
    memcpy(targetFunc, originalBytes, sizeof(jmpCode));
    _enable();
}

注意:Inline Hook时,一定要确保你覆盖的指令是完整的。比如一个指令是5字节,你只覆盖了3字节,那函数执行到一半就崩了。我曾经因为这个原因,调试了整整两天。

10.6 知识体系图

下面这张图展示了SSDT相关的核心概念和它们之间的关系:

SSDT与系统服务知识体系 SSDT ServiceTableBase NumberOfServices ParamTableBase 系统服务号(索引) NtCreateFile = 0x55 Hook SSDT Shadow SSDT Inline Hook SSDT是系统服务调用的核心 Hook方法各有优劣,需根据场景选择 64位系统需注意PatchGuard保护

10.7 避坑指南

做SSDT Hook,有几个坑是绕不开的:

  • PatchGuard:64位系统上,PatchGuard会定期检查SSDT的完整性。一旦发现被修改,直接蓝屏。我建议用Inline Hook或者直接绕过PatchGuard(但风险很大)。
  • 多核同步:修改SSDT时,如果另一个核心正在调用同一个服务,可能会出问题。最好在修改前暂停所有核心。
  • 版本兼容性:不同Windows版本的服务号不一样。写代码时一定要动态获取,别写死。
  • 函数原型:Hook函数的参数和返回值必须跟原函数完全一致。否则栈会乱掉。

个人经验:我建议初学者先从Inline Hook入手。它比直接修改SSDT更安全,也更容易调试。等熟悉了内核调用的流程,再尝试SSDT Hook。

好了,SSDT这块的内容就讲到这里。记住,Hook不是目的,理解系统服务的调用流程才是关键。下次遇到需要拦截系统调用的需求,你就能游刃有余了。

公众号:蓝海资料掘金营,微信deep3321