Asuka Nakajima

使用未公开的内核数据结构检测基于热键的键盘记录器

在本文中,我们将探讨什么是基于热键的键盘记录器以及如何检测它们。具体来说,我们将解释这些键盘记录程序如何拦截按键,然后介绍一种利用内核空间中未公开的热键表的检测技术。

阅读时间:15 分钟安全研究检测科学
使用未公开的内核数据结构检测基于热键的键盘记录器

使用未公开的内核数据结构检测基于热键的键盘记录器

在本文中,我们将探讨什么是基于热键的键盘记录器以及如何检测它们。具体来说,我们将解释这些键盘记录程序如何拦截按键,然后介绍一种利用内核空间中未公开的热键表的检测技术。

简介

2024 年 5 月,Elastic Security Labs 发表了 一篇文章,重点介绍了 Elastic Defend(从 8.12 开始)中添加的新功能,以增强对 Windows 上运行的键盘记录器的检测。在那篇文章中,我们介绍了网络攻击中常用的四种键盘记录器——基于轮询的键盘记录器、基于钩子技术的键盘记录器、使用原始输入模型的键盘记录器和使用 DirectInput 的键盘记录器——并解释了我们的检测方法。特别是,我们在 Windows 事件跟踪 (ETW) 中引入了一种使用 Microsoft-Windows-Win32k 提供程序的基于行为的检测方法。

文章发布后不久,我们很荣幸地看到 Microsoft 的首席安全研究员 Jonathan Bar Or 注意到了我们的文章。他提供了宝贵的反馈意见,指出了基于热键的键盘记录程序的存在,甚至还与我们分享了概念验证 (PoC) 代码。本文以他的 PoC 代码 Hotkeyz 为起点,提出了一种检测基于热键的键盘记录器的潜在方法。

基于热键的键盘记录器概述

什么是热键?

在深入研究基于热键的键盘记录器之前,我们先解释一下什么是热键。热键是一种键盘快捷键,通过按下单个键或组合键直接调用计算机上的特定功能。例如,许多 Windows 用户可以按下 Alt + Tab 键在任务之间切换(即切换窗口)。在这种情况下,Alt + Tab 作为热键直接触发任务切换功能。

(注意:虽然存在其他类型的键盘快捷键,但本文仅专注于热键。)此外,此处的所有信息均基于 Windows 10 版本 22H2 操作系统版本 19045.5371,未启用虚拟化安全功能。请注意,其他版本的 Windows 中的内部数据结构和行为可能会有所不同。

滥用自定义热键注册功能

除了可以使用上例中展示的 Windows 预配置热键,您还可以自行注册的自定义热键。有多种方法可以实现这一点,但一种简单的方法是使用 Windows API 函数 RegisterHotKey,该函数允许用户将特定按键注册为热键。例如,以下代码片段演示了如何使用 RegisterHotKey API 将 A 键(虚拟键代码为 0x41)注册为全局热键:

/*
BOOL RegisterHotKey(
  [in, optional] HWND hWnd, 
  [in]           int  id,
  [in]           UINT fsModifiers,
  [in]           UINT vk
);
*/
RegisterHotKey(NULL, 1, 0, 0x41);

注册热键后,当按下注册的键时,WM_HOTKEY 消息会被发送到被指定为 RegisterHotKey API 第一个参数的窗口的消息队列(如果使用 NULL,则发送到注册热键的线程)。下面的代码演示了一个消息循环,该循环使用 GetMessage API 检查消息队列中的 WM_HOTKEY 消息,如果收到消息,它会从消息中提取虚拟键代码(在本例中为 0x41)。

MSG msg = { 0 };
while (GetMessage(&msg, NULL, 0, 0)) {
    if (msg.message == WM_HOTKEY) {
        int vkCode = HIWORD(msg.lParam);
        std::cout << "WM_HOTKEY received! Virtual-Key Code: 0x"
            << std::hex << vkCode << std::dec << std::endl;
    }
}

换句话说,想象一下您正在记事本应用程序中写东西。如果按下 A 键,该字符不会被视为普通文本输入,而是会被识别为全局热键。

在此示例中,只有 A 键被注册为热键。不过,您可以同时将多个按键(如 B、C 或 D)注册为独立的热键。这意味着任何可以通过 RegisterHotKey API 注册的键(即任何虚拟键代码)都有可能被劫持为全局热键。基于热键的键盘记录程序滥用此功能以捕获用户输入的按键。

根据我们的测试,我们发现不仅是字母数字键和基本符号键,连与 SHIFT 修改键组合使用的键都可以通过 RegisterHotKey API 注册为热键。这意味着键盘记录器可以有效地监测每一个窃取敏感信息所需的按键。

隐秘地捕获按键

让我们以 Hotkeyz 热键键盘记录器为例,逐步介绍热键键盘记录器如何捕捉按键记录的实际过程。

在 Hotkeyz 中,首先使用 RegisterHotKey API 将每个字母数字虚拟键代码和一些其他键(例如 VK_SPACEVK_RETURN)注册为单独的热键。

然后,在键盘记录器的消息循环中,使用 PeekMessageW API 检查这些已注册热键的 WM_HOTKEY 消息是否出现在消息队列中。当检测到 WM_HOTKEY 消息时,提取其中的虚拟键代码,并最终保存到文本文件中。下面是消息循环代码的摘录,重点突出了最重要的部分。

while (...)
{
    // Get the message in a non-blocking manner and poll if necessary
    if (!PeekMessageW(&tMsg, NULL, WM_HOTKEY, WM_HOTKEY, PM_REMOVE))
    {
        Sleep(POLL_TIME_MILLIS);
        continue;
    }
....
   // Get the key from the message
   cCurrVk = (BYTE)((((DWORD)tMsg.lParam) & 0xFFFF0000) >> 16);

   // Send the key to the OS and re-register
   (VOID)UnregisterHotKey(NULL, adwVkToIdMapping[cCurrVk]);
   keybd_event(cCurrVk, 0, 0, (ULONG_PTR)NULL);
   if (!RegisterHotKey(NULL, adwVkToIdMapping[cCurrVk], 0, cCurrVk))
   {
       adwVkToIdMapping[cCurrVk] = 0;
       DEBUG_MSG(L"RegisterHotKey() failed for re-registration (cCurrVk=%lu,    LastError=%lu).", cCurrVk, GetLastError());
       goto lblCleanup;
   }
   // Write to the file
  if (!WriteFile(hFile, &cCurrVk, sizeof(cCurrVk), &cbBytesWritten, NULL))
  {
....

有一个重要的细节是:为了避免用户注意到键盘记录器的存在,一旦从消息中提取出虚拟键代码,该键的热键注册就会通过 UnregisterHotKey API 被暂时取消。之后,使用 keybd_event 来模拟按键,使用户感觉按键是正常按下的。按键模拟完成后,按键将使用 RegisterHotKey API 重新注册,以等待进一步输入。这是热键记录器操作的核心机制。

检测基于热键的键盘记录器

现在我们已经了解了什么是基于热键的键盘记录器以及它们的工作原理,接下来我们来解释如何检测它们。

ETW 不监测 RegisterHotKey API

按照前文所述的方法,我们首先研究了是否可以使用 Event Tracing for Windows (ETW) 来检测基于热键的键盘记录器。我们的研究很快揭示,ETW 目前不监测 RegisterHotKeyUnregisterHotKey API。除了查看 Microsoft-Windows-Win32k 提供程序的清单文件外,我们还对 RegisterHotKey API 的内部进行了反向工程,具体来说,是 win32kfull.sys 中的 NtUserRegisterHotKey 函数。不幸的是,我们没有找到任何证据表明这些 API 在执行时会触发任何 ETW 事件。

下图显示了 NtUserGetAsyncKeyState(由 ETW 监控)和 NtUserRegisterHotKey 的反编译代码之间的比较。请注意,在 NtUserGetAsyncKeyState 的开头,有一个对 EtwTraceGetAsyncKeyState 的调用——这是一个与记录 ETW 事件相关的函数——而 NtUserRegisterHotKey 不包含这样的调用。


尽管我们也考虑过使用 Microsoft-Windows-Win32k 以外的 ETW 提供程序来间接监测对 RegisterHotKey API 的调用,但我们发现使用“热键表”(将在下文中介绍,该方法不依赖于 ETW)进行检测的结果与监测 RegisterHotKey API 的结果相当,甚至更好。最终,我们选择实施此方法。

使用热键表 (gphkHashTable) 进行检测

在发现 ETW 无法直接监测对 RegisterHotKey API 的调用后,我们开始探索不依赖 ETW 的检测方法。在我们的调查过程中,我们想知道,“已注册的热键信息是否存储在某个地方?如果是这样,是否可以使用这些数据进行检测?”基于这个假设,我们迅速在 NtUserRegisterHotKey 中找到了一个标记为 gphkHashTable 的哈希表。搜索 Microsoft 的在线文档没有发现 gphkHashTable 的详细信息,这表明它是一个未记录的内核数据结构。

通过逆向工程,我们发现该哈希表存储了包含已注册热键信息的对象。每个对象都包含在 RegisterHotKey API 参数中指定的虚拟键代码和修饰符等详细信息。图 3 的右侧显示了一个名为 HOT_KEY 的热键对象的部分结构定义,而左侧显示了通过 WinDbg 访问时注册热键对象如何显示。

我们还确定ghpkHashTable的结构如图 4 所示。具体来说,它使用对虚拟键代码(由 RegisterHotKey API 指定)进行模运算(以 0x80 为模)的结果作为哈希表的索引。共享相同索引的热键对象在列表中链接在一起,这使得即使虚拟键代码相同但修饰符不同,表格也能存储和管理热键信息。

换句话说,通过扫描存储在ghpkHashTable中的所有 HOT_KEY 对象,我们可以检索每个已注册热键的详细信息。如果我们发现每个主键(例如每个单独的字母数字键)都被注册为单独的热键,这强烈表明存在一个活跃的基于热键的键盘记录器。

实施检测工具

现在,让我们开始实施检测工具。由于 gphkHashTable 位于内核空间,因此用户模式应用程序无法对其进行访问。因此,有必要开发一个用于检测的设备驱动程序。更具体地说,我们决定开发一个设备驱动程序,用于获取 gphkHashTable 的地址并扫描哈希表中存储的所有热键对象。如果注册为热键的字母数字键数量超过预定义的阈值,这将会提醒我们可能存在基于热键的键盘记录器。

如何获取 gphkHashTable 的地址

在开发检测工具时,我们面临的首要挑战之一是如何获取 gphkHashTable 的地址。经过一番考虑,我们决定直接从访问 gphkHashTablewin32kfull.sys 驱动程序的指令中提取地址。

通过逆向工程,我们发现,在 IsHotKey 函数的开头,有一个 lea 指令(lea rbx, gphkHashTable)访问 gphkHashTable。我们使用该指令的操作码字节序列(0x48, 0x8d, 0x1d)作为标识来定位相应的行,然后使用获得的 32 位(4 字节)偏移量计算gphkHashTable的地址。

此外,由于 IsHotKey 不是一个导出的函数,因此在查找 gphkHashTable 之前,我们还需要知道它的地址。通过进一步的反向工程,我们发现导出的函数 EditionIsHotKey 调用了 IsHotKey 函数。因此,我们决定在 EditionIsHotKey 函数中使用前面介绍的方法来计算 IsHotKey 的地址。(作为参考,您可以使用 PsLoadedModuleList API 找到 win32kfull.sys 的基址。)

访问 win32kfull.sys 的内存空间

一旦我们最终确定了获取 gphkHashTable 地址的方法,我们便开始编写代码以访问 win32kfull.sys 的内存空间以检索该地址。我们在这一阶段遇到的一个难题是,win32kfull.sys 是一个会话驱动程序。在继续之前,我们先简要介绍什么是会话

在 Windows 中,当用户登录时,每个用户都会被分配一个独立的会话(会话编号从 1 开始)。简单地说,第一个登录的用户将被分配会话 1。如果另一位用户在该会话处于活动状态时登录,该用户将被分配会话 2,依此类推。每位用户在其分配的会话中都拥有各自的桌面环境。

必须为每个会话(即每个登录用户)单独管理的内核数据存储在称为会话空间的内核内存隔离区域中。这包括由 win32k 驱动程序管理的 GUI 对象,例如窗口和鼠标/键盘输入数据,确保屏幕和输入在用户之间保持适当的分离。

(这是一个简化的说明。)有关会话的更详细讨论,请参阅 James Forshaw 的博客文章

综上所述,win32kfull.sys 被称为 会话驱动程序。这意味着,例如,在第一个登录用户(会话 1)的会话中注册的热键信息只能在该会话中访问。那么,我们如何解决这个限制呢?在这种情况下,已知可以使用 KeStackAttachProcess

KeStackAttachProcess 允许当前线程临时附加到指定进程的地址空间。如果我们能够附加到目标会话中的 GUI 进程——更准确地说,是已加载 win32kfull.sys 的进程——那么我们就可以访问该会话中的 win32kfull.sys 及其相关数据。在我们的实施过程中,假设只有一个用户登录,我们决定定位并附加到 winlogon.exe,这是负责处理用户登录操作的进程。

枚举已注册的热键

一旦我们成功附加到 winlogon.exe 进程并确定 gphkHashTable 的地址,下一步就是扫描 gphkHashTable 以检查注册的热键。以下是该代码的摘录:

BOOL CheckRegisteredHotKeys(_In_ const PVOID& gphkHashTableAddr)
{
-[skip]-
    // Cast the gphkHashTable address to an array of pointers.
    PVOID* tableArray = static_cast<PVOID*>(gphkHashTableAddr);
    // Iterate through the hash table entries.
    for (USHORT j = 0; j < 0x80; j++)
    {
        PVOID item = tableArray[j];
        PHOT_KEY hk = reinterpret_cast<PHOT_KEY>(item);
        if (hk)
        {
            CheckHotkeyNode(hk);
        }
    }
-[skip]-
}

VOID CheckHotkeyNode(_In_ const PHOT_KEY& hk)
{
    if (MmIsAddressValid(hk->pNext)) {
        CheckHotkeyNode(hk->pNext);
    }

    // Check whether this is a single numeric hotkey.
    if ((hk->vk >= 0x30) && (hk->vk <= 0x39) && (hk->modifiers1 == 0))
    {
        KdPrint(("[+] hk->id: %u hk->vk: %x\n", hk->id, hk->vk));
        hotkeyCounter++;
    }
    // Check whether this is a single alphabet hotkey.
    else if ((hk->vk >= 0x41) && (hk->vk <= 0x5A) && (hk->modifiers1 == 0))
    {
        KdPrint(("[+] hk->id: %u hk->vk: %x\n", hk->id, hk->vk));
        hotkeyCounter++;
    }
-[skip]-
}
....
if (CheckRegisteredHotKeys(gphkHashTableAddr) && hotkeyCounter >= 36)
{
   detected = TRUE;
   goto Cleanup;
}

代码本身非常简单明了:它遍历哈希表的每个索引,沿着链表访问每个 HOT_KEY 对象,并检查注册热键是否对应于不带任何修饰符的字母数字键。在我们的检测工具中,如果每个字母数字键都被注册为热键,就会发出警报,表明可能存在基于热键的键盘记录器。为简单起见,此实现仅针对字母数字键热键,但您可以轻松扩展工具以检查带有修饰符(如 SHIFT)的热键。

检测 Hotkeyz

检测工具 (Hotkey-based Keylogger Detector)已在下方发布。我们还提供了详细的使用说明。此外,这项研究还在 NULLCON Goa 2025 上进行了展示,演讲幻灯片可供查阅。

https://github.com/AsuNa-jp/HotkeybasedKeyloggerDetector

以下是一个演示视频,展示了基于热键的键盘记录器检测器如何检测 Hotkeyz。

DEMO_VIDEO.mp4

致谢

我们衷心感谢 Jonathan Bar Or 阅读我们之前的文章,分享他对基于热键的键盘记录程序的见解,并慷慨发布 PoC 工具Hotkeyz