win32k.sys是啥

如果你是玩过Shadow SSDT Hook的朋友, 那么估计对win32k.sys绝对不陌生.
这里仅仅简单介绍一下win32k.sys在系统中起到的一部分作用, 并且只重点介绍我们现在要研究的这一部分.
感兴趣的朋友可以稍后自行搜索win32k.sys, 了解这个驱动模块的完整运作流程.


用户层与内核层

开篇说到了Windows的SSDT/Shadow SSDT, 那么必然要介绍下什么是SSDT和Shadow SSDT.
这里我们说的 SSDT 其实既是 System Services Descriptor Table, 翻译过来也就是 "系统服务描述符表".
要说这个表的存在意义, 那么有必要通俗的说一下CPU的用户层和内核层之间的关系.
已经有相关经验的朋友可以直接跳到后面的正文去阅读.

首先, 要知道, 我们的所有应用程序进程, 其实都运行在用户层(Ring3).
正如你所见, 例如现在你正在使用浏览器查看这个页面, 浏览器就是运行在Ring3层的一个应用程序. 再比如系统自带的资源管理器 explorer.exe , 没错, 这也是一个运行在Ring3的应用程序.
那么内核层呢? 顾名思义, 内核层(Ring0)往往运行着操作系统内核. 比如Windows内核 ntoskrnl.exe , 以及鼠标驱动 mouhid.sys , 以及其他的一些驱动模块等等……都是运行在Ring0的 "系统程序".

说到这里, 你可能要问了, 为什么CPU要分化成内核层和应用层?
原因很简单, 为了实现安全的进程隔离.

举一个非常简单的例子, 我们知道, 操作系统一般不会出现什么严重问题, 往往出问题最多的都是用户应用程序. 有些时候, 往往一个空指针问题都可以导致应用程序的崩溃.
但是为什么应用程序发生了崩溃或无响应, 操作系统不但不会因此受到牵连, 反而能够准确地给出报错提示, 将错误控制在当前进程范围内, 最终准确的关闭这个发生异常的进程呢?
其实, 这就是因为操作系统内核运行在内核层, 而应用软件运行在用户层的原因. 即使用户层的软件崩溃了, 内核层依旧可以正常运行并捕获这个异常, 从而对其进行正确的接管处理.

当然, 你可能会说, 那万一内核层代码出现了问题怎么办?
这个问题嘛……简单呀, 当然是直接蓝屏啦…… (x
没错, 你看到的蓝屏就是因为内核层出现问题, 继而引发的不可恢复错误.

再举一个例子, 有时候我们进行一些操作, 系统会提醒我们 "此操作需要管理员权限". 那么, 你可能会想, 修改这个软件的代码可以绕过这个错误么?
抱歉, 不可以. 其实软件本身并没有进行任何的 "权限检查", 而这个 "管理员权限检查", 其实是来自于操作系统内核的.
原理很简单. 用户层软件将数据准备好之后, 根据 "服务编号" , 调用对应的内核服务. 内核服务可以是文件读写, 也可以是窗口绘制, 也可以是进程管理, 也可以是其他的.
不同的内核服务, 由不同的服务编号来决定. 而刚刚所说的权限检查, 实际上是在这些内核服务里面.

那么, 你可能会说, 我难道不可以直接跳转到内核层的代码执行么, 非要走内核服务?
抱歉, 还是不可以. Ring3直接访问Ring0, 将会触发CPU的一般保护性错误(GP). 继而被操作系统捕获, 并导致进程崩溃.
而Ring3唯一去调用内核函数的办法, 就是走内核服务. 也就是CPU的syscall指令.


回到SSDT和Shadow SSDT

刚刚我们说了R0层和R3层, 并讲解了一个叫 内核服务 的存在.
其实, 操作系统处理内核服务的时候, 要根据对应的内核服务编号来找到对应的处理函数.
而SSDT和Shadow SSDT, 其实就是用来做这个的. SSDT与Shadow SSDT, 实际上在内核层中就是两个数组. 数组中保存了内核服务处理函数的函数指针 (或相对偏移量), 而这个数组对应的下标, 就是内核服务编号.

那么, 你可能会想, 既然内核服务函数这么重要, 那我修改SSDT/Shadow SSDT, 或者修改内核服务函数的代码, 岂不是可以实现自己的功能, 或者拦截来自Ring3层程序的调用了?
没错, 这就是我们经常说的SSDT Hook与SSDT Inline Hook. 此做法因为可以有效拦截来自用户层程序的请求, 常常被用于制作安全防护软件.
(题外话: 当然, 从Win7 x64之后这招行不通咯……要Hook也需要想点其他的法子才行(比如利用VT的EPT), 没办法直接修改代码来实现Hook了. 因为多了个叫PatchGuard的东西, 篡改内核代码将会被微软检测到, 进而发生0x109的蓝屏. 但这个思路在Linux上也是几乎一模一样的, 感兴趣的可以尝试玩一下Linux的Hook.)


再看win32k.sys

说了这么多, 可算说到win32k.sys了.

要说SSDT与Shadow SSDT的关系, 那就是前者对应ntoskrnl.exe中的服务函数, 这些函数实现了如文件管理、进程管理、设备管理等等相关的功能. 而后者则位于win32k.sys, 重点实现创建窗口、查找窗口、窗口绘图等等Gdi与用户交互相关的功能.
今天我们要研究的, 就是这个win32k.sys的Shadow SSDT部分.

首先, 获取Shadow SSDT的代码, 这里不在多讲. 原理是在KiSystemCall64附近搜索特征码, 进而找到指向Shadow SSDT的指针.
要注意的是, 如果安装了微软的Meltdown漏洞补丁, 则下面这段代码将会失效, 原因是0xc0000082的msr寄存器不再直接指向KiSystemCall64, 而是一个影子函数. 而解决办法也可以直接搜到, 这里不再多写.

ULONG64 GetKeServiceDescriptorTableShadow64()
{
    PUCHAR StartSearchAddress = (PUCHAR)__readmsr(0xC0000082);
    PUCHAR EndSearchAddress = StartSearchAddress + 0x500;
    PUCHAR i = NULL;
    UCHAR b1 = 0, b2 = 0, b3 = 0;
    ULONG templong = 0;
    ULONG64 addr = 0;
    for (i = StartSearchAddress; i<EndSearchAddress; i++)
    {
        if (MmIsAddressValid(i) && MmIsAddressValid(i + 1) && MmIsAddressValid(i + 2))
        {
            b1 = *i;
            b2 = *(i + 1);
            b3 = *(i + 2);
            if (b1 == 0x4c && b2 == 0x8d && b3 == 0x1d) //4c8d1d
            {
                memcpy(&templong, i + 3, 4);
                addr = (ULONG64)templong + (ULONG64)i + 7;
                return addr;
            }
        }
    }
    return addr;
}

找到Shadow SSDT之后, 理论上直接对Shadow SSDT进行枚举即可找到所有的win32k.sys的服务函数.
但是问题是, win32k.sys并不是一直驻留在Ring0的内存空间的. 只有在有GUI的线程当中, 这个sys的内存才可以被访问. 否则, 这个sys是不可以被直接访问到的.
那么我们在获取Shadow SSDT的函数之前, 依然是使用老办法, 切换到csrss.exe的进程当中获取.

PEPROCESS getProcessByName(PCSZ processName)
{
    PEPROCESS process = NULL;
    SIZE_T processNameLen = strlen(processName);
    ULONG pid = 4;
    PCSZ processNameGet;
    while (pid < 0x186a0)
    {
        if (NT_SUCCESS(PsLookupProcessByProcessId((HANDLE)pid, &process)))
        {
            processNameGet = (PCSZ)PsGetProcessImageFileName1(process);
            ObDereferenceObject(process);
            if (_strnicmp(processName, processNameGet, processNameLen) == 0)
                return process;
        }
        pid += 4;
    }
    return NULL;
}

PVOID getShadowSSDTFunction(unsigned int id)
{
    PVOID shadowSSDT = (PVOID)GetKeServiceDescriptorTableShadow64();
    if (!shadowSSDT)
        return NULL;
    PKPROCESS csrss = getProcessByName("csrss.exe");
    if (!csrss)
        return NULL;
    KAPC_STATE apc;
    PVOID function;
    KeStackAttachProcess(csrss, &apc);
    ULONG64 shadowSSDTTable = *(ULONG64 *)((PCHAR)shadowSSDT + 0x20);
    LONG32 *shadowSSDTEntry = (LONG32 *)shadowSSDTTable;
    LONG32 offset = shadowSSDTEntry[id] >> 4;
    function = (PVOID)(offset + shadowSSDTTable);
    KeUnstackDetachProcess(&apc);
    return function;
}

好了, 接下来重点来了. 我们看获取到的函数在Win10发生了什么变化?
我们以NtUserFindWindowEx为例, 首先使用上面的代码获取到函数所在地址, 然后对其地址使用反汇编:


看到了没有, 这里的所有的函数都变成了一个跳转表.
与之前的Windows系统有些不同, 这里并不是再直接实现函数功能, 而是跳转到别的地方去执行了.
那么我们来跟入这个跳转的目标地址看一下:

可以看到代码从win32k!NtUserFindWindowEx跳转到了win32kfull!NtUserFindWindowEx.
这也就是说, win32k.sys不再直接处理来自用户层的系统服务调用, 而真正去处理用户的系统服务调用的函数, 实际上是win32kfull.sys中的同名函数.


Inline Hook建议

针对有能力绕过PatchGuard进行Hook的朋友, 可以参考一下如下的Hook建议.


如图, 我们可以看到, 由于win32k.sys的所有函数都变成了一个跳转表, 这导致我们能直接安装Hook的空间只剩下了6个字节.
若超过6个字节, 多余的代码将会覆盖到下面一个跳转, 影响其他的函数功能.
显然编写一个6个字节的紧凑代码来完成Hook显得略有难度. 那么, 若我们跟进win32kfull.sys, 对win32kfull中的函数进行Hook, 这样就不会遇到代码长度限制的问题了.

如图, 跟进这个函数之后, 留给我们安装Hook的空间宽裕了很多很多.

当然, 其实还有一种Hook的办法.


因为跳转是jmp qword ptr的形式, 从一个指针中取出目标地址然后跳转, 因此我们可以将如图的跳转的目标地址给换掉.
这个问题相对于Inline Hook, 更多的问题在于如何绕开PatchGuard的内存检查, 除此之外还是非常可靠的一种办法, 有些类似于IAT Hook.

最后, 我们剩下的问题就是如何判断win32k.sys中的函数是否变成了跳转表.
由于有些时候我们必须要兼容Win7, 因此还是有必要判断一下win32k.sys中的函数是否是跳转表的形式. 如果是, 则跟入win32kfull进行hook, 如果不是, 则直接安装hook.
除了RtlGetVersion()获取操作系统版本之外, 其实有个非常简单的办法:


有没有发现跳转指令对应的机器码全部是FF 25开头的?
没错, 直接判断首字节是否为FF 25即可解决问题.


结语

研究这些, 是因为刚好做到了一个小需求, 需要在Win10下实现应用层窗口保护.
本想像之前那样直接对win32k进行Hook, 结果意外的发现Hook之后直接蓝屏了. 追踪原因, 才发现Win10的win32k发生了很大的变化.
因此特别写个文章记录一下自己的这个小发现, 以便以后自己或其他人能够用到.