Asuka Nakajima

문서화되지 않은 커널 데이터 구조를 사용하여 핫키 기반 키로거 탐지하기

이 아티클에서는 핫키 기반 키로거란 무엇이며 이를 어떻게 탐지하는지 알아봅니다. 구체적으로 이러한 키로거가 키 입력을 가로채는 방법을 설명한 다음, 커널 공간에서 문서화되지 않은 핫키 테이블을 활용하는 탐지 기법을 소개합니다.

Detecting Hotkey-Based Keyloggers Using an Undocumented Kernel Data Structure

문서화되지 않은 커널 데이터 구조를 사용하여 핫키 기반 키로거 탐지하기

이 아티클에서는 핫키 기반 키로거란 무엇이며 이를 어떻게 탐지하는지 알아봅니다. 구체적으로 이러한 키로거가 키 입력을 가로채는 방법을 설명한 다음, 커널 공간에서 문서화되지 않은 핫키 테이블을 활용하는 탐지 기법을 소개합니다.

서문

2024년 5월, Elastic Security Labs는 Windows에서 실행되는 키로거 탐지를 강화하기 위해 Elastic Defend(8.12부터 시작)에 추가된 새로운 기능을 강조하는 아티클을 게시했습니다. 이 글에서는 사이버 공격에 흔히 사용되는 네 가지 유형의 키로거로, 폴링 기반 키로거, 후킹 기반 키로거, 원시 입력 모델을 사용하는 키로거, DirectInput을 사용하는 키로거를 다루고 탐지 방법론을 설명했습니다. 특히 Windows용 이벤트 추적 (ETW) 내에서 Microsoft-Windows-Win32k 공급자를 사용하는 동작 기반 탐지 방법을 도입했습니다.

아티클을 게시한 직후, Microsoft의 수석 보안 연구원인 Jonathan Bar Or의 주목을 받게 되어 영광으로 생각합니다. 그는 핫키 기반 키로거의 존재를 지적하며 귀중한 피드백을 제공했고, 개념 증명(PoC) 코드까지 공유해 주었습니다. 이 아티클에서는 그의 PoC 코드 Hotkeyz를 시작점으로 삼아, 핫키 기반 키로거를 감지하는 잠재적인 방법 중 하나를 제시합니다.

핫키 기반 키로거 개요

핫키란 무엇인가요?

핫키 기반 키로거에 대해 자세히 알아보기 전에, 먼저 핫키가 무엇인지 명확히 알아보겠습니다. 핫키는 단일 키 또는 키 조합을 눌러 컴퓨터의 특정 기능을 직접 실행하는 일종의 키보드 단축키입니다. 예를 들어, 많은 Windows 사용자들은 작업(즉, 창) 사이를 전환하기 위해 Alt + Tab을 누릅니다. 이 경우 Alt + Tab이 작업 전환 기능을 직접 실행하는 핫키 역할을 합니다.

(참고: 다른 유형의 키보드 단축키도 존재하지만 이 글에서는 핫키에만 초점을 맞춥니다. 또한 여기에 있는 모든 정보는 가상화 기반 보안이 없는 Windows 10 버전 22H2 OS 빌드 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를 모니터링하지 않습니다

이전 아티클에서 설명한 접근 방식에 따라, 저희는 먼저 Windows용 이벤트 추적(ETW)을 사용하여 핫키 기반 키로거를 탐지할 수 있는지 조사해 봤습니다. 연구 결과, ETW는 현재 RegisterHotKey 또는 UnregisterHotKey API를 모니터링하지 않는 것으로 나타났습니다. Microsoft-Windows-Win32k 공급자에 대한 매니페스트 파일을 검토하는 것 외에도 RegisterHotKey API의 내부, 특히 win32kfull.sys의 NtUserRegisterHotKey 함수를 리버스 엔지니어링했습니다. 안타깝게도 이러한 API가 실행될 때 ETW 이벤트를 트리거한다는 증거는 발견되지 않았습니다.

아래 이미지에서는 NtUserGetAsyncKeyState(ETW에서 모니터링됨)와 NtUserRegisterHotKey의 디컴파일된 코드 비교를 확인할 수 있습니다. NtUserGetAsyncKeyState의 시작 부분에는 ETW 이벤트 로깅과 관련된 함수인 EtwTraceGetAsyncKeyState에 대한 호출이 있는 반면, NtUserRegisterHotKey에는 그러한 호출이 포함되어 있지 않습니다.

Figure 1: Comparison of the Decompiled Code for NtUserGetAsyncKeyState and NtUserRegisterHotKey
그림 1: NtUserGetAsyncKeyState와 NtUserRegisterHotKey의 디컴파일된 코드 비교
 
RegisterHotKey API 호출을 간접적으로 모니터링하기 위해 Microsoft-Windows-Win32k 이외의 ETW 제공자를 사용하는 것도 고려했지만, 다음에 소개할 '핫키 테이블'을 사용하는 탐지 방법(ETW에 의존하지 않음)이 RegisterHotKey API 모니터링과 유사하거나 더 나은 결과를 얻을 수 있음을 알게 되었습니다. 결국 저희는 이 방법을 구현하기로 했습니다.

핫키 테이블(gphkHashTable)을 사용한 탐지)

ETW가 RegisterHotKey API에 대한 호출을 직접 모니터링할 수 없다는 사실을 발견한 후, ETW에 의존하지 않는 탐지 방법을 모색하기 시작했습니다. 조사하면서 든 궁금점은 '등록된 핫키 정보가 어딘가에 저장되어 있지 않을까? 그렇다면 그 데이터를 탐지에 사용할 수 있을까?'였습니다. 이 가설에 따라 NtUserRegisterHotKey 내에서 gphkHashTable이라는 이름의 해시 테이블을 빠르게 발견했습니다. Microsoft의 온라인 설명서를 검색했지만 gphkHashTable에 대한 자세한 내용은 없었는데, 이는 문서화되지 않은 커널 데이터 구조임을 시사합니다.

Figure 2: The hotkey table (gphkHashTable), discovered within the RegisterHotKey function called inside NtUserRegisterHotKey
그림 2: NtUserRegisterHotKey 내에서 호출된 RegisterHotKey 함수 안에 발견된 핫키 테이블(gphkHashTable)

리버스 엔지니어링을 통해 이 해시 테이블이 등록된 핫키에 대한 정보를 포함하는 객체를 저장한다는 점을 발견했습니다. 각 객체는 RegisterHotKey API의 인수에 지정된 가상 키 코드 및 수정자와 같은 세부 정보를 포함합니다. 그림 3 의 오른쪽에는 HOT_KEY라는 핫키 객체의 구조 정의 일부가 표시되고, 왼쪽에는 WinDbg를 통해 액세스할 때 등록된 핫키 객체가 나타나는 방식이 표시됩니다.

Figure 3: Hotkey Object Details. WinDbg view (left) and HOT_KEY structure details (right)
그림 3: 핫키 객체 세부 정보. WinDbg 보기(왼쪽) 및 HOT_KEY 구조 세부 정보(오른쪽)

또한 ghpkHashTable이 그림 4와 같이 구조화되어 있음을 확인했습니다. 구체적으로 말씀드리자면, RegisterHotKey API로 지정된 가상 키 코드에 대한 모듈로 연산(0x80 사용)의 결과를 해시 테이블의 인덱스로 사용합니다. 동일한 인덱스를 공유하는 단축키 객체는 목록에 함께 연결되어 있어, 가상 키 코드는 동일하지만 수정자가 다른 경우에도 테이블에 핫키 정보를 저장하고 관리할 수 있습니다.

Figure 4: Structure of gphkHashTable
그림 4: gphkHashTable의 구조

즉, ghpkHashTable에 저장된 모든 HOT_KEY 객체를 스캔하면 등록된 모든 핫키에 대한 세부 정보를 검색할 수 있습니다. 모든 주요 키(예: 각 개별 영숫자 키)가 별도의 핫키로 등록된 경우, 이는 활성 핫키 기반 키로거의 존재를 강력하게 나타냅니다.

탐지 도구 구현

이제 탐지 도구를 구현하는 단계로 넘어가 보겠습니다. gphkHashTable은 커널 공간에 있기 때문에 사용자 모드 애플리케이션에서는 액세스할 수 없습니다. 이러한 이유로 탐지를 위한 장치 드라이버를 개발해야 했습니다. 더 구체적으로 말씀드리면, 저희는 gphkHashTable의 주소를 얻고 해시 테이블에 저장된 모든 핫키 객체를 스캔하는 장치 드라이버를 개발하기로 했습니다. 핫키로 등록된 영숫자 키의 수가 사전 정의된 임계값을 초과하면, 장치 드라이버에서 핫키 기반 키로거가 있을 가능성을 경고합니다.

gphkHashTable의 주소를 얻는 방법

탐지 도구를 개발하는 동안 직면한 첫 번째 과제 중 하나는 gphkHashTable의 주소를 얻는 방법이었습니다. 어느 정도 고려한 후 gphkHashTable에 액세스하는 win32kfull.sys 드라이버의 명령어에서 직접 주소를 추출하기로 했습니다.

리버스 엔지니어링을 통해 IsHotKey 함수의 시작 부분에 gphkHashTable에 액세스하는 lea 명령어(lea rbx, gphkHashTable)가 있다는 것을 발견했습니다. 해당 명령어의 옵코드 바이트 시퀀스(0x48, 0x8d, 0x1d)를 서명으로 사용하여 해당 줄을 찾은 다음, 얻은 32비트(4바이트) 오프셋을 사용하여 gphkHashTable의 주소를 계산했습니다.

Figure 5: Inside the IsHotKey function
그림 5: IsHotKey 함수 내부

또한 IsHotKey는 내보낸 함수가 아니므로 gphkHashTable을 찾기 전에 해당 주소도 알아야 합니다. 추가 리버스 엔지니어링을 통해 내보낸 함수 EditionIsHotKeyIsHotKey 함수를 호출한다는 것을 발견했습니다. 따라서 앞서 설명한 동일한 방법을 사용하여 EditionIsHotKey 함수 내에서 IsHotKey의 주소를 계산하기로 했습니다. (참고로 win32kfull.sys의 기본 주소는 PsLoadedModuleList API를 사용하여 찾을 수 있습니다.)

win32kfull.sys의 메모리 공간에 액세스하기

gphkHashTable의 주소를 얻는 방법을 확정한 후, 그 주소를 검색하기 위해 win32kfull.sys의 메모리 공간에 액세스하는 코드를 작성하기 시작했습니다. 이 단계에서 직면한 한 가지 문제는 win32kfull.sys가 세션 드라이버라는 점입니다. 더 진행하기 전에 세션이 무엇인지 간단히 설명해 드리겠습니다.

Windows에서 사용자가 로그인할 때, 각 사용자에게 별도의 세션(세션 번호는 1부터 시작)이 할당됩니다. 간단히 말해, 처음 로그인하는 사용자에게 Session 1이 할당됩니다. 다른 사용자가 해당 세션이 활성 상태일 때 로그인하면, 그 사용자에게 Session 2가 할당됩니다. 그러면 각 사용자는 할당된 세션 내에서 자신만의 데스크톱 환경을 갖게 됩니다.

각 세션별로(즉, 로그인한 사용자별로) 별도로 관리해야 하는 커널 데이터는 세션 공간이라는 커널 메모리의 격리된 영역에 저장됩니다. 여기에는 win32k 드라이버가 관리하는 GUI 객체(예: 윈도우 및 마우스/키보드 입력 데이터)가 포함되어 있어, 화면과 입력이 사용자 간에 적절하게 분리되도록 합니다.

(간단하게 추려서 설명해 드렸습니다. 세션에 관한 자세한 내용은 James Forshaw의 블로그 게시물을 참조하세요.).)

Figure 6: Overview of Sessions. Session 0 is dedicated exclusively to service processes
그림 6: 세션 개요. Session 0 (은)는 서비스 프로세스 전용입니다.

위의 내용을 기반으로 win32kfull.sys세션 드라이버로 알려져 있습니다. 즉, 예를 들어 처음 로그인한 사용자의 세션(Session 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 탐지

아래에 탐지 도구(핫키 기반 키로거 탐지기)가 공개되었습니다. 자세한 사용 지침도 함께 제공됩니다. 또한 이 연구는 NULLCON Goa 2025에서 발표되었으며 프레젠테이션 슬라이드를 이용하실 수 있습니다.

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

다음은 핫키 기반 키로거 탐지기가 Hotkeyz를 탐지하는 방법을 보여 주는 데모 동영상입니다.

DEMO_VIDEO.mp4

감사의 말씀

이 자리를 빌어 Jonathan Bar Or에게 진심으로 감사의 말씀을 전합니다. 이전 아티클을 읽고, 핫키 기반 키로거에 대한 인사이트를 공유하고, PoC 도구 Hotkeyz를 아낌없이 공개해 주셨습니다.