事件跟踪用于 Windows(Event Tracing for Windows,简称 ETW)是一项内核机制,旨在记录系统中发生的某些特定活动。尽管其描述看似平淡无奇,但 ETW 却能成为宝贵的信息来源,对于反作弊程序和其他驱动程序而言,它也是一个非常有趣的钩取点(hook point)。
第一部分:寻找Hook点
所有 ETW 日志记录函数最终都会进入 nt!EtwpLogKernelEvent 函数,概括来说,该函数先使用 nt!EtwpReserveTraceBuffer 为日志预留一个缓冲区,然后将日志写入该缓冲区。
在 nt!EtwpReserveTraceBuffer 函数的深层,真正的“乐趣”才刚刚开始。该函数会访问一个 _WMI_LOGGER_CONTEXT 结构体——这是内核对日志记录器的表示形式——并查看 GetCpuClock 成员变量,然后据此决定如何获取当前时间。
任何研究过 InfinityHook 工作原理的人,都会立刻认出这个成员变量,因为它的创建者正是将该变量作为钩取点。过去,这个变量是一个函数指针,可以直接替换,从而轻松地在每次捕获事件时获得执行权限。为了修复 InfinityHook 带来的问题,微软将该变量改为了一个索引,每个索引代表一种不同的获取时间的方式。
查看 EtwpReserveTraceBuffer 函数中的相关代码,我们可以推断出哪些索引是有效的,以及它们的含义:
- const auto get_cpu_clock = LoggerContext->GetCpuClock;
- LARGE_INTEGER current_time = { .QuadPart = 0 };
-
- // Crash the computer if the index is invalid.
- if (get_cpu_clock > 3)
- KeBugCheck(KERNEL_SECURITY_CHECK_FAILURE);
-
- switch (get_cpu_clock)
- {
- case 3:
- current_time.QuadPart = __rdtsc();
- break;
- case 2:
- HalPrivateDispatchTable.HalTimerQueryHostPerformanceCounter(¤t_time);
- break;
- case 1:
- current_time = KeQueryPerformanceCounter(nullptr);
- break;
- case 0:
- current_time = RtlGetSystemTimePrecise();
- break;
- default:
- KeBugCheck(KERNEL_SECURITY_CHECK_FAILURE);
- }
复制代码
- LARGE_INTEGER result = { .QuadPart = 0 };
-
- // This seems to always be true - the TimerProcessor constant (= 5) comes from hal!_KNOWN_TIMER_TYPE.
- if (HalpPerformanceCounter.KnownType == TimerProcessor)
- {
- PVOID internal_data = HalpTimerGetInternalData(HalpPerformanceCounter);
- if (HalpTimerReferencePage)
- {
- result = HalpPerformanceCounter.FunctionTable.QueryCounter(internal_data);
- }
- else
- {
- // ...
- result = HalpPerformanceCounter.FunctionTable.QueryCounter(internal_data);
- // ...
- }
- }
复制代码
第二部分:配置日志记录器
让 ETW 调用我们的钩取函数并非易事——我们首先需要访问 _WMI_LOGGER_CONTEXT 结构体中的 GetCpuClock 变量,以使内核调用我们的钩取函数。虽然可以创建一个新的日志记录器,并以此方式获取指向该结构体的指针,但我选择劫持循环内核上下文日志记录器(Circular Kernel Context Logger,CKCL),因为它通常不会用于任何重要用途。要获取指向其上下文的指针相当容易,因为存在一条可直接指向它的指针链。
这条指针链在所有经过测试的 Windows 版本中均保持稳定,且未来不太可能发生改变。它起始于未公开的 nt!EtwpDebuggerData 全局变量,其相对虚拟地址(RVA)可通过解析 ntoskrnl.exe 的程序数据库(PDB)文件找到。
- PWMI_LOGGER_CONTEXT GetCKCLContext(
- IN UINT_PTR EtwpDebuggerData
- )
- {
- PVOID* debugger_data_silo = *reinterpret_cast<PVOID**>(EtwpDebuggerData + 0x10);
- return static_cast<PWMI_LOGGER_CONTEXT>(debugger_data_silo[2]);
- }
复制代码 我们还需要配置日志记录器的目标事件(在内部称为 EnableFlags)。这可通过 nt!ZwTraceControl 函数实现,幸运的是,该函数已导出,可供所有驱动程序使用。
此函数接受一个 _WMI_LOGGER_INFORMATION 结构体作为输入缓冲区。虽然微软未对此结构体进行文档说明,但其定义可在 PHNT 头文件中找到。在该结构体中,我们需要指定要针对的日志记录器。这可通过设置 GUID(全局唯一标识符)和 LoggerName(日志记录器名称)来实现。
既然我们已经获取了 _WMI_LOGGER_CONTEXT 结构体,那么提取相关信息就简单了:
- kd> dt _WMI_LOGGER_CONTEXT poi(poi(EtwpDebuggerData+0x10)+0x10)
- nt!_WMI_LOGGER_CONTEXT
- ...
- +0x088 LoggerName : _UNICODE_STRING "Circular Kernel Context Logger"
- ...
- +0x114 InstanceGuid : _GUID {54dea73a-ed1f-42a4-af71-3e63d056f174}
复制代码 在配置好日志记录器并启动它之后,我们就可以大功告成、开始运行(实施后续操作)了。
第三部分:Hook上下文切换
现在,我们拥有了一个在每次上下文切换时都会被调用的函数——太棒了!找到新线程很简单——我们是在该线程的上下文中执行,这意味着调用 KeGetCurrentThread 函数就能获取指向该线程对象的指针。
查看在我们钩取函数之前被调用的函数,我们发现,最后一个能访问 OldThread(旧线程)和 NewThread(新线程)参数的函数是 EtwpLogContextSwapEvent,这两个参数分别通过 rdx 和 r8 寄存器传入。在该函数处设置断点后发现,rbx 和 rdi 寄存器中分别存储了这两个参数的副本。
- 1: kd> r rbx, rdx, rdi, r8
- rbx=ffffd8878177d080 rdx=ffffd8878177d080
- rdi=ffffd8878627c080 r8=ffffd8878627c080
- nt!EtwpLogContextSwapEvent:
- fffff8028bbd79d0 48895c2410 mov qword ptr [rsp+10h],rbx ss:0018:fffff500a54bbef8=fffff8028bbd7885
复制代码 在函数序言(prologue)部分,这两个寄存器(rbx 和 rdi)的值都会被压入栈中,且当前线程(存储在 rdi 和 r8 中)的相关信息会先被压栈:
- kd> uu EtwpLogContextSwapEvent
- nt!EtwpLogContextSwapEvent:
- fffff805`81bd79d0 48895c2410 mov qword ptr [rsp+10h],rbx
- fffff805`81bd79d5 55 push rbp
- fffff805`81bd79d6 56 push rsi
- fffff805`81bd79d7 57 push rdi
复制代码 查看相关代码后,我们可以确定,在栈上rbx相对于rdi会始终保持一个0x28的固定偏移量。既然我们已经知道rdi的值(它是当前线程的指针),那么就可以从钩取点开始向上扫描栈,并逐一检查每个可能的线程:
- // We loop until stack_limit - 0x28 to prevent OOB access when checking the previous thread.
- for (ULONG_PTR iterator = rsp; iterator < (stack_limit - 0x28); iterator += sizeof(PKTHREAD))
- {
- PKTHREAD thread_at_iterator = *reinterpret_cast<PKTHREAD*>(iterator);
-
- // If we found our own thread's pointer on the stack
- if (thread_at_iterator == current_thread)
- {
- // Look at the thread at the target offset
- PKTHREAD possible_prev_thread = *reinterpret_cast<PKTHREAD*>(iterator + 0x28);
- PDISPATCHER_HEADER possible_dispatcher_header = reinterpret_cast<PDISPATCHER_HEADER>(possible_prev_thread) - 1;
-
- const ULONG_PTR possible_prev_thread_raw = *reinterpret_cast<ULONG_PTR*>(iterator + 0x28);
- // Threads are not stack-allocated.
- if (possible_prev_thread_raw >= stack_base && possible_prev_thread_raw <= stack_limit)
- continue;
-
- // Threads are not in userspace.
- if (possible_prev_thread < MmSystemRangeStart)
- continue;
-
- // Threads have accessible memory.
- if (!MmIsAddressValid(possible_prev_thread) || !MmIsAddressValid(possible_dispatcher_header))
- continue;
-
- // Reference the thread to check the object type.
- NTSTATUS status = ObReferenceObjectByPointer(
- possible_prev_thread,
- 0,
- *PsThreadType,
- KernelMode
- );
-
- // If the function fails, we can be sure that the address is not one of a thread.
- if (!NT_SUCCESS(status))
- continue;
-
- // Dereference the thread, and store it.
- ObfDereferenceObject(possible_prev_thread);
- previous_thread = possible_prev_thread;
- break;
- }
- }
复制代码
第四部分:用途与检测
许多反作弊解决方案已开始钩取上下文切换,试图创建仅对系统中特定线程可见的隐藏内存区域。一个显著的例子是Riot Vanguard,它采用了一种不同的方法,我肯定会在不久的将来详细介绍。
该钩取技术还可用于检测在未签名内存中执行的线程,因为几乎没有什么能阻止你遍历旧线程的栈,并查看代码是否在不应运行的任何内存区域中运行。
至于检测方面,存在一个明显的痕迹:HalpPerformanceCounter + 0x70指向ntoskrnl.exe外部,以及在循环内核上下文日志记录器(CKCL)中GetCpuClock被设置为1。尽管后者可能在正常系统操作中出现(因此可能触发误报),但在我的测试过程中,它从未被默认设置过。
第五部分:结语
这是我撰写的第一篇文章,灵感源自阅读无数比我聪明得多的人所发布的帖子。有一个人我必须特别提及,那就是丹尼斯·斯克沃尔佐夫(Denis Skvortcov),他在两年多前对这种方法进行过阐述,当时他是在对Avast杀毒软件进行逆向工程分析时写下的相关内容。
我也要感谢你,亲爱的读者,能一直读到这里——希望下次我们还能再会!
原文链接 |