找回密码
 立即注册
搜索
查看: 889|回复: 0

[翻译] 使用 ETW 挂钩上下文切换

[复制链接]

251

主题

0

回帖

2345

积分

管理员

积分
2345
发表于 2025-10-10 17:04:06 | 显示全部楼层 |阅读模式
    事件跟踪用于 Windows(Event Tracing for Windows,简称 ETW)是一项内核机制,旨在记录系统中发生的某些特定活动。尽管其描述看似平淡无奇,但 ETW 却能成为宝贵的信息来源,对于反作弊程序和其他驱动程序而言,它也是一个非常有趣的钩取点(hook point)。

第一部分:寻找Hook点
    所有 ETW 日志记录函数最终都会进入 nt!EtwpLogKernelEvent 函数,概括来说,该函数先使用 nt!EtwpReserveTraceBuffer 为日志预留一个缓冲区,然后将日志写入该缓冲区。
    在 nt!EtwpReserveTraceBuffer 函数的深层,真正的“乐趣”才刚刚开始。该函数会访问一个 _WMI_LOGGER_CONTEXT 结构体——这是内核对日志记录器的表示形式——并查看 GetCpuClock 成员变量,然后据此决定如何获取当前时间。
    任何研究过 InfinityHook 工作原理的人,都会立刻认出这个成员变量,因为它的创建者正是将该变量作为钩取点。过去,这个变量是一个函数指针,可以直接替换,从而轻松地在每次捕获事件时获得执行权限。为了修复 InfinityHook 带来的问题,微软将该变量改为了一个索引,每个索引代表一种不同的获取时间的方式。

    查看 EtwpReserveTraceBuffer 函数中的相关代码,我们可以推断出哪些索引是有效的,以及它们的含义:
  1. const auto get_cpu_clock = LoggerContext->GetCpuClock;
  2. LARGE_INTEGER current_time = { .QuadPart = 0 };
  3. // Crash the computer if the index is invalid.
  4. if (get_cpu_clock > 3)
  5.     KeBugCheck(KERNEL_SECURITY_CHECK_FAILURE);
  6. switch (get_cpu_clock)
  7. {
  8. case 3:
  9.     current_time.QuadPart = __rdtsc();
  10.     break;
  11. case 2:
  12.     HalPrivateDispatchTable.HalTimerQueryHostPerformanceCounter(&current_time);
  13.     break;
  14. case 1:
  15.     current_time = KeQueryPerformanceCounter(nullptr);
  16.     break;
  17. case 0:
  18.     current_time = RtlGetSystemTimePrecise();
  19.     break;
  20. default:
  21.     KeBugCheck(KERNEL_SECURITY_CHECK_FAILURE);
  22. }
复制代码
  1. LARGE_INTEGER result = { .QuadPart = 0 };
  2. // This seems to always be true - the TimerProcessor constant (= 5) comes from hal!_KNOWN_TIMER_TYPE.
  3. if (HalpPerformanceCounter.KnownType == TimerProcessor)
  4. {
  5.     PVOID internal_data = HalpTimerGetInternalData(HalpPerformanceCounter);
  6.     if (HalpTimerReferencePage)
  7.     {
  8.         result = HalpPerformanceCounter.FunctionTable.QueryCounter(internal_data);
  9.     }
  10.     else
  11.     {
  12.         // ...
  13.         result = HalpPerformanceCounter.FunctionTable.QueryCounter(internal_data);
  14.         // ...
  15.     }
  16. }
复制代码

第二部分:配置日志记录器
    让 ETW 调用我们的钩取函数并非易事——我们首先需要访问 _WMI_LOGGER_CONTEXT 结构体中的 GetCpuClock 变量,以使内核调用我们的钩取函数。虽然可以创建一个新的日志记录器,并以此方式获取指向该结构体的指针,但我选择劫持循环内核上下文日志记录器(Circular Kernel Context Logger,CKCL),因为它通常不会用于任何重要用途。要获取指向其上下文的指针相当容易,因为存在一条可直接指向它的指针链。
    这条指针链在所有经过测试的 Windows 版本中均保持稳定,且未来不太可能发生改变。它起始于未公开的 nt!EtwpDebuggerData 全局变量,其相对虚拟地址(RVA)可通过解析 ntoskrnl.exe 的程序数据库(PDB)文件找到。
  1. PWMI_LOGGER_CONTEXT GetCKCLContext(
  2.     IN UINT_PTR EtwpDebuggerData
  3. )
  4. {
  5.     PVOID* debugger_data_silo = *reinterpret_cast<PVOID**>(EtwpDebuggerData + 0x10);
  6.     return static_cast<PWMI_LOGGER_CONTEXT>(debugger_data_silo[2]);
  7. }
复制代码
    我们还需要配置日志记录器的目标事件(在内部称为 EnableFlags)。这可通过 nt!ZwTraceControl 函数实现,幸运的是,该函数已导出,可供所有驱动程序使用。
    此函数接受一个 _WMI_LOGGER_INFORMATION 结构体作为输入缓冲区。虽然微软未对此结构体进行文档说明,但其定义可在 PHNT 头文件中找到。在该结构体中,我们需要指定要针对的日志记录器。这可通过设置 GUID(全局唯一标识符)和 LoggerName(日志记录器名称)来实现。
    既然我们已经获取了 _WMI_LOGGER_CONTEXT 结构体,那么提取相关信息就简单了:
  1. kd> dt _WMI_LOGGER_CONTEXT poi(poi(EtwpDebuggerData+0x10)+0x10)
  2. nt!_WMI_LOGGER_CONTEXT
  3.     ...
  4.     +0x088 LoggerName       : _UNICODE_STRING "Circular Kernel Context Logger"
  5.     ...
  6.     +0x114 InstanceGuid     : _GUID {54dea73a-ed1f-42a4-af71-3e63d056f174}
复制代码
    在配置好日志记录器并启动它之后,我们就可以大功告成、开始运行(实施后续操作)了。

第三部分:Hook上下文切换
    现在,我们拥有了一个在每次上下文切换时都会被调用的函数——太棒了!找到新线程很简单——我们是在该线程的上下文中执行,这意味着调用 KeGetCurrentThread 函数就能获取指向该线程对象的指针。
    查看在我们钩取函数之前被调用的函数,我们发现,最后一个能访问 OldThread(旧线程)和 NewThread(新线程)参数的函数是 EtwpLogContextSwapEvent,这两个参数分别通过 rdx 和 r8 寄存器传入。在该函数处设置断点后发现,rbx 和 rdi 寄存器中分别存储了这两个参数的副本。
  1. 1: kd> r rbx, rdx, rdi, r8
  2. rbx=ffffd8878177d080 rdx=ffffd8878177d080
  3. rdi=ffffd8878627c080 r8=ffffd8878627c080
  4. nt!EtwpLogContextSwapEvent:
  5. fffff8028bbd79d0 48895c2410      mov     qword ptr [rsp+10h],rbx ss:0018:fffff500a54bbef8=fffff8028bbd7885
复制代码
    在函数序言(prologue)部分,这两个寄存器(rbx 和 rdi)的值都会被压入栈中,且当前线程(存储在 rdi 和 r8 中)的相关信息会先被压栈:
  1. kd> uu EtwpLogContextSwapEvent
  2. nt!EtwpLogContextSwapEvent:
  3. fffff805`81bd79d0 48895c2410      mov     qword ptr [rsp+10h],rbx
  4. fffff805`81bd79d5 55              push    rbp
  5. fffff805`81bd79d6 56              push    rsi
  6. fffff805`81bd79d7 57              push    rdi
复制代码
    查看相关代码后,我们可以确定,在栈上rbx相对于rdi会始终保持一个0x28的固定偏移量。既然我们已经知道rdi的值(它是当前线程的指针),那么就可以从钩取点开始向上扫描栈,并逐一检查每个可能的线程:
  1. // We loop until stack_limit - 0x28 to prevent OOB access when checking the previous thread.
  2. for (ULONG_PTR iterator = rsp; iterator < (stack_limit - 0x28); iterator += sizeof(PKTHREAD))
  3. {
  4.     PKTHREAD thread_at_iterator = *reinterpret_cast<PKTHREAD*>(iterator);
  5.     // If we found our own thread's pointer on the stack
  6.     if (thread_at_iterator == current_thread)
  7.     {
  8.         // Look at the thread at the target offset
  9.         PKTHREAD possible_prev_thread = *reinterpret_cast<PKTHREAD*>(iterator + 0x28);
  10.         PDISPATCHER_HEADER possible_dispatcher_header = reinterpret_cast<PDISPATCHER_HEADER>(possible_prev_thread) - 1;
  11.         const ULONG_PTR possible_prev_thread_raw = *reinterpret_cast<ULONG_PTR*>(iterator + 0x28);
  12.         // Threads are not stack-allocated.
  13.         if (possible_prev_thread_raw >= stack_base && possible_prev_thread_raw <= stack_limit)
  14.             continue;
  15.         // Threads are not in userspace.
  16.         if (possible_prev_thread < MmSystemRangeStart)
  17.             continue;
  18.         // Threads have accessible memory.
  19.         if (!MmIsAddressValid(possible_prev_thread) || !MmIsAddressValid(possible_dispatcher_header))
  20.             continue;
  21.         // Reference the thread to check the object type.
  22.         NTSTATUS status = ObReferenceObjectByPointer(
  23.             possible_prev_thread,
  24.             0,
  25.             *PsThreadType,
  26.             KernelMode
  27.         );
  28.         // If the function fails, we can be sure that the address is not one of a thread.
  29.         if (!NT_SUCCESS(status))
  30.             continue;
  31.         // Dereference the thread, and store it.
  32.         ObfDereferenceObject(possible_prev_thread);
  33.         previous_thread = possible_prev_thread;
  34.         break;
  35.     }
  36. }
复制代码

第四部分:用途与检测
    许多反作弊解决方案已开始钩取上下文切换,试图创建仅对系统中特定线程可见的隐藏内存区域。一个显著的例子是Riot Vanguard,它采用了一种不同的方法,我肯定会在不久的将来详细介绍。
    该钩取技术还可用于检测在未签名内存中执行的线程,因为几乎没有什么能阻止你遍历旧线程的栈,并查看代码是否在不应运行的任何内存区域中运行。
    至于检测方面,存在一个明显的痕迹:HalpPerformanceCounter + 0x70指向ntoskrnl.exe外部,以及在循环内核上下文日志记录器(CKCL)中GetCpuClock被设置为1。尽管后者可能在正常系统操作中出现(因此可能触发误报),但在我的测试过程中,它从未被默认设置过。

第五部分:结语
    这是我撰写的第一篇文章,灵感源自阅读无数比我聪明得多的人所发布的帖子。有一个人我必须特别提及,那就是丹尼斯·斯克沃尔佐夫(Denis Skvortcov),他在两年多前对这种方法进行过阐述,当时他是在对Avast杀毒软件进行逆向工程分析时写下的相关内容。
    我也要感谢你,亲爱的读者,能一直读到这里——希望下次我们还能再会!

原文链接
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

快速回复 返回顶部 返回列表