未公開のカーネルデータ構造を使用したホットキー型キーロガーの検出
この記事では、ホットキーベースのキーロガーとは何か、そしてそれを検出する方法を探ります。具体的には、これらのキーロガーがキーストロークを傍受する方法を説明し、カーネル空間の未公開ホットキーテーブルを利用する検出技術を紹介します。
はじめに
2024年5月、Elastic Security Labsは、記事を公開し、Elastic Defend(8.12以降)に追加された新機能が、Windowsで実行されているキーロガーの検出を強化する方法を紹介しました。その投稿では、サイバー攻撃で一般的に使用される4種類のキーロガー(ポーリングベース、フックベース、Raw Inputモデルを使用したキーロガー、DirectInputを使用したキーロガー)について解説し、当社の検出方法論を説明しました。特に、Event Tracing for Windows(ETW)のMicrosoft-Windows-Win32kプロバイダーを使用した、振る舞いに基づく検出方法を紹介しました。
公開後すぐ、記事がMicrosoftの主任セキュリティ研究者であるJonathan Bar Or氏に注目されたことは光栄でした。同氏はホットキー型キーロガーの存在を指摘し、当社と概念実証(PoC)コードを共有し、非常に貴重なフィードバックを提供してくれました。同氏のPoCコード Hotkeyz を出発点として活用し、この記事ではホットキー型キーロガーを検出するための1つの潜在的な方法を紹介します。
ホットキー型キーロガーの概要
ホットキーとは?
ホットキーを利用したキーロガーについて詳しく説明する前に、まずホットキーとは何かを明確にしましょう。ホットキーとは、1つのキーまたはキーの組み合わせを押すことで、コンピュータ上の特定の機能を直接呼び出すタイプのキーボードショートカットです。例えば、多くのWindowsユーザーは、Alt + Tabを押してタスク(別の言い方ではウィンドウ)を切り替えます。この場合、Alt + Tabはタスク切り替え機能を直接起動するホットキーとして機能します。
(注:他にもさまざまな種類のキーボードショートカットがありますが、この記事ではホットキーのみを取り上げています。また、本記事に記載されているすべての情報は、仮想化ベースのセキュリティなしで、Windows 10 バージョン22H2 OSビルド 19045.5371を基にしています。内部データ構造および動作は、他のバージョンのWindowsでは異なる場合がありますので、ご注意ください。)
カスタムホットキー登録機能の悪用
前の例で示したように、Windowsに事前公営されているホットキーを使用することに加えて、独自のカスタムホットキーを登録することもできます。これを行うにはさまざまな方法がありますが、1つの簡単な方法として、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_SPACE や VK_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は現在 RegisterHotKey および UnregisterHotKey APIを監視していないことが迅速に判明しました。Microsoft-Windows-Win32kプロバイダーのマニフェストファイルの確認に加えて、RegisterHotKey APIの内部、特にwin32kfull.sysのNtUserRegisterHotKey関数をリバースエンジニアリングしました。残念なことに、これらのAPIが実行された場合にETWイベントがトリガーされる証拠は見つかりませんでした。
以下の画像は、NtUserGetAsyncKeyState(ETWが監視)とNtUserRegisterHotKeyの逆コンパイルされたコードの比較を示しています。NtUserGetAsyncKeyStateの先頭には、ETWイベントのログを記録する関数であるEtwTraceGetAsyncKeyStateの呼び出しがありますが、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のアドレスを取得する方法
検出ツールを開発する際に直面した最初の課題の1つは、gphkHashTableのアドレスの取得方法でした。いくつか検討した結果、gphkHashTableにアクセスするwin32kfull.sysドライバーの命令から直接アドレスを抽出することにしました。
リバースエンジニアリングを通じて、IsHotKey関数の冒頭に、gphkHashTableにアクセスするlea命令(lea rbx, gphkHashTable)があることを発見しました。その命令のオペコードバイトシーケンス(0x48, 0x8d, 0x1d)をシグネチャとして使用して該当行を特定し、得られた32ビット(4バイト)のオフセットを使用してgphkHashTableのアドレスを計算しました。
さらに、IsHotKeyはエクスポートされた関数ではないため、gphkHashTableを探す前にそのアドレスを知っておく必要があります。リバースエンジニアリングを進めた結果、エクスポートされた関数EditionIsHotKeyがIsHotKey関数を呼び出すことが判明しました。そのため、先ほど説明した同じ方法を使用して、EditionIsHotKey関数内でIsHotKeyのアドレスを計算することに決めました。(参考までに、win32kfull.sysのベースアドレスは PsLoadedModuleList APIを使用して見つけることができます。)
win32kfull.sysのメモリスペースへのアクセス
gphkHashTableのアドレスの取得方法を確定した後、win32kfull.sysのメモリスペースにアクセスし、そのアドレスを取得するためのコードの作成を開始しました。この段階で直面した課題の1つは、win32kfull.sysがセッションドライバーであることでした。先に進む前に、セッションとは何かについて簡単に説明します。
Windows では、ユーザーがログインすると、各ユーザーに個別のセッション(セッション番号は 1 から始まる)が割り当てられます。端的に言えば、最初にログインしたユーザーにセッション1が割り当てられます。そのセッションがアクティブな間に別のユーザーがログインすると、そのユーザーにはセッション2が割り当てられ、以降も同様です。各ユーザーは、割り当てられたセッション内でそれぞれのデスクトップ環境を持ちます。
セッションごとに(例:ログイン中のユーザーごとに)個別に管理する必要があるカーネルデータは、セッションスペースと呼ばれるカーネルメモリの独立した領域に格納されます。これには、win32kドライバーによって管理されるウィンドウやマウス/キーボードインプットデータなどのGUIオブジェクトが含まれ、画面とインプットがユーザー間で適切に分離されていることが保証されます。
(これは簡単な説明です。セッションに関する詳細な議論については、James Forshawのブログ記事をご覧ください。
上記を踏まえて、win32kfull.sysはセッションドライバーとして知られています。これは、たとえば、最初にログインしたユーザーのセッション (セッション 1) に登録されたホットキー情報には、その同じセッション内からのみアクセスできることを意味します。では、どうすればこの制限に対処できるでしょうか?このような場合、KeStackAttachProcessを使用できることが知られています。
KeStackAttachProcessは、現在のスレッドが特定のプロセスのアドレス空間に一時的にアタッチするのを可能にします。ターゲットセッションのGUIプロセス、より正確にはwin32kfull.sysをロードしたプロセスにアタッチできれば、そのセッション内のwin32kfull.sysと関連データにアクセスできます。私たちの実装では、ユーザーが1人だけログインしていると仮定し、ユーザーのログオン操作を処理するプロセスである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の検出
検出ツール(ホットキー型キーロガー検出器)を下記にリリースしました。詳細な使用説明も提供されています。さらに、この研究はNULLCON Goa 2025で発表され、プレゼンテーションのスライドが利用可能です。
https://github.com/AsuNa-jp/HotkeybasedKeyloggerDetector
以下は、ホットキー型キーロガー検出器がHotkeyzを検出する方法を紹介するデモ動画です。
謝辞
Jonathan Bar Or様に、前回の記事をお読みいただき、ホットキーベースのキーロガーに関する洞察を共有し、PoCツールHotkeyzを惜しみなく公開していただいたことに心より感謝申し上げます。