P/Invoke та Windows API — Міст між .NET та Native Code
P/Invoke та Windows API — Міст між .NET та Native Code
Навіщо Виходити за Межі Managed Code?
.NET Runtime надає величезну бібліотеку класів (BCL), що покриває більшість повсякденних завдань: файли, мережа, потоки, колекції. Проте існують сценарії, коли managed API або не існує, або недостатньо для вирішення задачі. Саме тоді розробники звертаються до Platform Invocation Services (P/Invoke) — механізму виклику нативних функцій з C/C++ бібліотек безпосередньо з C# коду.
Розглянемо три типові сценарії, що вимагають P/Invoke:
Сценарій перший: Низькорівневий доступ до ОС. Потрібно отримати детальну інформацію про систему, яку BCL не надає. Наприклад, визначити точну архітектуру процесора (x86, x64, ARM64), отримати серійний номер BIOS, або дізнатися версію firmware материнської плати. Win32 API функція GetSystemInfo() з kernel32.dll надає структуру з 20+ полями, тоді як Environment.ProcessorCount дає лише кількість ядер.
Сценарій другий: Інтеграція зі сторонніми native бібліотеками. Компанія має legacy C++ бібліотеку для обробки зображень, що працює швидше за managed альтернативи завдяки SIMD-інструкціям та ручній оптимізації. Переписувати 100,000 рядків коду на C# — економічно недоцільно. P/Invoke дозволяє викликати функції з DLL безпосередньо, передаючи вказівники на пам'ять та отримуючи результати.
Сценарій третій: Автоматизація UI та системні хуки. Потрібно створити глобальний hotkey (Ctrl+Shift+F12), що працює навіть коли застосунок не у фокусі, або перехопити всі натискання клавіш у системі для keylogger-функціональності (легітимної, наприклад, для accessibility tools). RegisterHotKey() та SetWindowsHookEx() з user32.dll — єдиний спосіб реалізувати це на Windows.
.NET не ізолює вас від платформи — навпаки, надає контрольований міст до нативного коду. P/Invoke — це той міст. Але як і будь-який міст між двома світами (managed та unmanaged), він вимагає розуміння обох сторін: як працює CLR, як працює Win32, і як правильно перекладати дані між ними.
Архітектура Windows API: Історія та Структура
Від Win16 до Win32: Еволюція API
Windows API (також відомий як Win32 API) — це набір C-функцій та структур даних, що становлять фундаментальний інтерфейс операційної системи Windows. Історія його розвитку допомагає зрозуміти деякі дивні на перший погляд рішення.
Windows 1.0–3.11 (1985–1994): Win16 Era. Перші версії Windows працювали у 16-бітному режимі процесорів Intel 8086/80286. API складався з ~450 функцій, розподілених між трьома основними DLL: KERNEL (управління пам'яттю, процеси), USER (вікна, повідомлення), GDI (графіка). Функції мали обмеження: максимум 64 КБ на сегмент пам'яті, кооперативна багатозадачність (програма сама мала віддавати управління).
Windows 95/NT (1995): Win32 Revolution. Перехід на 32-бітну архітектуру (Intel 80386+) дав плоску модель пам'яті (4 ГБ адресного простору), preemptive multitasking, захищену пам'ять. API розширився до ~2000 функцій. З'явилися суфікси A (ANSI) та W (Wide/Unicode) для функцій, що працюють з рядками: CreateFileA() приймає char*, CreateFileW() — wchar_t*. Windows NT використовував Unicode всередині, тому CreateFileA() насправді викликає CreateFileW() після конвертації.
Windows XP–11 (2001–2024): Win32 Maturity. API стабілізувався на ~4000 функціях. Додалися нові DLL: advapi32.dll (безпека, реєстр), ws2_32.dll (сокети), ole32.dll (COM). З'явилася WinRT (Windows Runtime) для UWP застосунків, але класичний Win32 залишається основою для desktop програм.
Структура Win32 API: Основні DLL
Win32 API не є монолітною бібліотекою — це колекція DLL, кожна з яких відповідає за певну підсистему:
CreateProcess, TerminateProcess), потоками (CreateThread), пам'яттю (VirtualAlloc, HeapAlloc), файлами (CreateFile, ReadFile, WriteFile), синхронізацією (CreateMutex, WaitForSingleObject). Це найнижчий рівень, що доступний з user mode.CreateWindow), обробка повідомлень (GetMessage, DispatchMessage), діалоги (MessageBox), меню, іконки. Кожен GUI застосунок на Windows використовує user32.dll.LineTo, Rectangle, TextOut), шрифти, кольори, bitmap-и. До Windows Vista це був єдиний спосіб малювати на екрані. Зараз частково замінений на Direct2D/Direct3D, але залишається для сумісності.RegOpenKeyEx, RegQueryValueEx), безпека (OpenProcessToken, AdjustTokenPrivileges), служби (CreateService, StartService), Event Log. Багато функцій вимагають підвищених прав.SHFileOperation), ярлики, іконки файлів, діалоги вибору папок (SHBrowseForFolder). Інтеграція з Explorer.socket(), connect(), send(), recv(). .NET System.Net.Sockets використовує це під капотом.Handles: Універсальна Абстракція Ресурсів
Центральна концепція Win32 API — Handle (дескриптор). Це непрозоре 32/64-бітне значення (тип HANDLE у C, IntPtr у C#), що ідентифікує системний ресурс: файл, процес, потік, mutex, вікно, bitmap, шрифт.
Handle — це не вказівник на об'єкт (хоча на ранніх версіях Windows це було саме так). Це індекс у таблиці дескрипторів процесу, що зберігається у kernel memory. Kernel зберігає реальний вказівник на _OBJECT структуру, а процесу повертає лише індекс. Це забезпечує безпеку: процес не може безпосередньо читати kernel memory, навіть якщо знає handle.
// Приклад: Handle на файл
IntPtr fileHandle = CreateFile(
"data.txt",
GENERIC_READ,
FILE_SHARE_READ,
IntPtr.Zero,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
IntPtr.Zero
);
if (fileHandle == INVALID_HANDLE_VALUE)
{
// Помилка відкриття файлу
int error = Marshal.GetLastWin32Error();
throw new Win32Exception(error);
}
// Використання handle...
// ОБОВ'ЯЗКОВО закрити handle після використання
CloseHandle(fileHandle);
DllImport: Базовий Синтаксис P/Invoke
Перший Виклик: MessageBox
Найпростіший спосіб зрозуміти P/Invoke — викликати функцію, що не вимагає складних параметрів. MessageBox() з user32.dll — ідеальний кандидат.
Сигнатура у C (з MSDN):
int MessageBoxW(
HWND hWnd, // Handle на батьківське вікно (NULL = немає)
LPCWSTR lpText, // Текст повідомлення (Unicode string)
LPCWSTR lpCaption, // Заголовок вікна
UINT uType // Кнопки та іконка (MB_OK, MB_YESNO, тощо)
);
Переклад на C#:
using System.Runtime.InteropServices;
class Program
{
// Атрибут [DllImport] вказує CLR, де знайти функцію
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
static extern int MessageBox(
IntPtr hWnd, // HWND → IntPtr (handle)
string lpText, // LPCWSTR → string (CLR автоматично конвертує)
string lpCaption, // LPCWSTR → string
uint uType // UINT → uint
);
// Константи для uType (з winuser.h)
const uint MB_OK = 0x00000000;
const uint MB_ICONINFORMATION = 0x00000040;
const uint MB_YESNO = 0x00000004;
const uint MB_ICONQUESTION = 0x00000020;
static void Main()
{
// Виклик виглядає як звичайний C# метод
int result = MessageBox(
IntPtr.Zero, // Немає батьківського вікна
"Це виклик нативної Win32 функції!", // Текст
"P/Invoke Demo", // Заголовок
MB_OK | MB_ICONINFORMATION // Кнопка OK + іконка інформації
);
Console.WriteLine($"Користувач натиснув кнопку з кодом: {result}");
}
}
Анатомія Атрибута DllImport
[DllImport] — це спеціальний атрибут, що інструктує CLR виконати наступні дії під час виклику методу:
Крок 1: Завантаження DLL. При першому виклику MessageBox() CLR шукає user32.dll у стандартних шляхах Windows: C:\Windows\System32, C:\Windows\System, поточна директорія, PATH. Якщо DLL не знайдено — викидається DllNotFoundException.
Крок 2: Пошук функції. CLR шукає експортовану функцію з іменем MessageBox у таблиці експорту DLL. Якщо не знайдено — EntryPointNotFoundException. Важливо: ім'я має точно збігатися, включаючи регістр (хоча Windows API зазвичай case-insensitive).
Крок 3: Marshalling параметрів. CLR конвертує managed типи (C#) у unmanaged (C):
string→wchar_t*(UTF-16 Unicode) — CLR виділяє unmanaged пам'ять, копіює рядок, передає вказівникIntPtr→void*— передається "як є"uint→unsigned int— передається "як є"
Крок 4: Виклик функції. CLR переключається з managed code у unmanaged, викликає функцію через function pointer, чекає повернення.
Крок 5: Marshalling результату. Повернене значення int копіюється назад у managed heap. CLR звільняє тимчасову unmanaged пам'ять, виділену для рядків.
Крок 6: Обробка помилок. Якщо функція повертає код помилки (зазвичай 0 або -1), CLR може автоматично викинути Win32Exception якщо вказано SetLastError = true.
Властивості DllImport: Повний Список
A/W або для уникнення конфліктів імен.[DllImport("user32.dll", EntryPoint = "MessageBoxW")]
static extern int ShowMessage(IntPtr hWnd, string text, string caption, uint type);
CharSet.Ansi (ANSI/UTF-8, додає суфікс A), CharSet.Unicode (UTF-16, додає суфікс W), CharSet.Auto (Unicode на NT-based Windows, Ansi на Win9x — застаріло). Рекомендація: завжди CharSet.Unicode для сучасних Windows.true — CLR зберігає значення GetLastError() після виклику функції. Доступ через Marshal.GetLastWin32Error(). Обов'язково для функцій, що повідомляють про помилки через GetLastError() (більшість Win32 API).[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr CreateFile(string lpFileName, uint dwDesiredAccess, ...);
IntPtr handle = CreateFile("test.txt", GENERIC_READ, ...);
if (handle == INVALID_HANDLE_VALUE)
{
int error = Marshal.GetLastWin32Error();
throw new Win32Exception(error); // Автоматично форматує повідомлення помилки
}
CallingConvention.Winapi (за замовчуванням, StdCall на x86, Cdecl на x64), StdCall (параметри справа наліво, callee очищає стек), Cdecl (caller очищує стек). Для Win32 API завжди StdCall або Winapi.true — CLR не додає суфікси A/W автоматично. Корисно для функцій без Unicode/ANSI варіантів або коли ви явно вказали EntryPoint.true. Якщо false — CLR перетворює HRESULT (COM error code) у exception. Використовується для COM Interop, не для Win32 API.CharSet та Unicode: Чому Це Важливо
Windows внутрішньо використовує UTF-16 Unicode для всіх рядків. Функції з суфіксом A (ANSI) — це обгортки, що конвертують char* → wchar_t*, викликають W-версію, конвертують назад. Це додає overhead та може призвести до втрати даних (не всі Unicode символи можна представити в ANSI).
// ❌ ПОГАНО: використання ANSI
[DllImport("user32.dll", CharSet = CharSet.Ansi)]
static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type);
MessageBox(IntPtr.Zero, "Привіт 🚀", "Test", MB_OK);
// Результат: "Привіт ?" — emoji втрачено при конвертації в ANSI
// ✅ ДОБРЕ: використання Unicode
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type);
MessageBox(IntPtr.Zero, "Привіт 🚀", "Test", MB_OK);
// Результат: "Привіт 🚀" — все коректно
CharSet = CharSet.Unicode для Win32 API. Це уникає зайвих конвертацій, зберігає всі символи та відповідає внутрішній реалізації Windows.Marshalling: Перетворення Типів між Світами
Blittable vs Non-Blittable Types
Marshalling — це процес конвертації даних між managed (CLR) та unmanaged (native) пам'яттю. Не всі типи вимагають конвертації. Типи, що мають однакове представлення в обох світах, називаються blittable (від "blit" — block transfer).
Blittable типи (можна копіювати побайтово без змін):
byte, sbyte, short, ushort, int, uint, long, ulong
float, double
IntPtr, UIntPtr
Ці типи передаються "як є" — CLR просто копіює байти. Швидко та безпечно.
Non-blittable типи (вимагають конвертації):
bool // C#: 1 байт, C: 4 байти (int) або 1 байт (BOOL)
char // C#: 2 байти UTF-16, C: 1 байт ANSI або 2 байти wchar_t
string // C#: managed object, C: char* або wchar_t*
arrays // C#: managed array, C: pointer
Для цих типів CLR виконує складну роботу: виділяє unmanaged пам'ять, копіює дані, конвертує формат, передає вказівник, після виклику звільняє пам'ять.
Marshalling Рядків: Детальний Розбір
Рядки — найскладніший випадок marshalling. Існує кілька стратегій:
Стратегія 1: Автоматичний marshalling (рекомендовано для більшості випадків):
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type);
// CLR автоматично:
// 1. Виділяє unmanaged пам'ять
// 2. Копіює рядок у UTF-16
// 3. Передає wchar_t* у функцію
// 4. Після виклику звільняє пам'ять
Стратегія 2: StringBuilder для out-параметрів:
Коли функція записує дані у буфер, що ви надаєте:
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern uint GetCurrentDirectory(uint nBufferLength, StringBuilder lpBuffer);
var buffer = new StringBuilder(260); // MAX_PATH
uint length = GetCurrentDirectory((uint)buffer.Capacity, buffer);
if (length == 0)
throw new Win32Exception(Marshal.GetLastWin32Error());
string currentDir = buffer.ToString();
Console.WriteLine($"Поточна директорія: {currentDir}");
StringBuilder — це managed клас, але CLR має спеціальну логіку marshalling для нього: передає вказівник на внутрішній буфер, дозволяє native коду писати туди, після виклику синхронізує довжину рядка.Стратегія 3: Ручний marshalling через IntPtr:
Для максимального контролю або коли автоматичний marshalling не підходить:
string managedString = "Hello, Win32!";
// Виділяємо unmanaged пам'ять та копіюємо рядок
IntPtr unmanagedString = Marshal.StringToHGlobalUni(managedString);
try
{
// Передаємо IntPtr у функцію
SomeNativeFunction(unmanagedString);
}
finally
{
// ОБОВ'ЯЗКОВО звільняємо пам'ять
Marshal.FreeHGlobal(unmanagedString);
}
Marshal.StringToHGlobalUni(), НЕ управляється GC. Якщо забути FreeHGlobal() — це memory leak. Завжди використовуйте try/finally або using з custom wrapper.Marshalling Структур: Layout та Alignment
Проблема: Різні Правила Розміщення
У C# структури за замовчуванням мають автоматичний layout — CLR може переставляти поля для оптимізації (вирівнювання, щільність). У C структури мають послідовний layout — поля розміщені у порядку оголошення з вирівнюванням за правилами платформи.
Коли ви передаєте C# структуру у Win32 API, CLR має знати, як розмістити поля у пам'яті, щоб native код правильно їх прочитав. Для цього використовується атрибут [StructLayout].
Приклад: SYSTEMTIME Structure
Win32 API функція GetSystemTime() заповнює структуру SYSTEMTIME поточним часом UTC:
Визначення у C (з winbase.h):
typedef struct _SYSTEMTIME {
WORD wYear; // 2 байти
WORD wMonth; // 2 байти
WORD wDayOfWeek; // 2 байти
WORD wDay; // 2 байти
WORD wHour; // 2 байти
WORD wMinute; // 2 байти
WORD wSecond; // 2 байти
WORD wMilliseconds; // 2 байти
} SYSTEMTIME; // Загалом: 16 байт
Переклад на C#:
using System.Runtime.InteropServices;
// StructLayout.Sequential — поля у порядку оголошення
// CharSet.Unicode — для сумісності з іншими структурами, що містять рядки
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
struct SYSTEMTIME
{
public ushort wYear; // WORD → ushort (2 байти)
public ushort wMonth;
public ushort wDayOfWeek;
public ushort wDay;
public ushort wHour;
public ushort wMinute;
public ushort wSecond;
public ushort wMilliseconds;
}
class Program
{
[DllImport("kernel32.dll")]
static extern void GetSystemTime(out SYSTEMTIME lpSystemTime);
static void Main()
{
SYSTEMTIME st;
GetSystemTime(out st);
Console.WriteLine($"UTC Time: {st.wYear:D4}-{st.wMonth:D2}-{st.wDay:D2} " +
$"{st.wHour:D2}:{st.wMinute:D2}:{st.wSecond:D2}.{st.wMilliseconds:D3}");
Console.WriteLine($"Day of week: {(DayOfWeek)st.wDayOfWeek}");
}
}
StructLayout: Три Режими
[FieldOffset(N)]. Дозволяє створювати union-и (кілька полів за однією адресою) або пропускати байти. Складніший, але дає повний контроль.[StructLayout(LayoutKind.Explicit)]
struct Union
{
[FieldOffset(0)] public int IntValue;
[FieldOffset(0)] public float FloatValue; // Той самий offset — union!
[FieldOffset(0)] public byte Byte0;
[FieldOffset(1)] public byte Byte1;
[FieldOffset(2)] public byte Byte2;
[FieldOffset(3)] public byte Byte3;
}
Alignment та Padding: Чому Розмір Структури Може Відрізнятися
Процесори ефективніше читають дані, вирівняні за певними адресами. Наприклад, 4-байтове int краще читати з адреси, кратної 4. Компілятор C та CLR автоматично додають padding (порожні байти) для вирівнювання.
// Приклад: структура з padding
[StructLayout(LayoutKind.Sequential)]
struct Example
{
public byte b1; // Offset 0, розмір 1
// [padding 3 байти]
public int i; // Offset 4, розмір 4 (вирівняно за 4)
public byte b2; // Offset 8, розмір 1
// [padding 3 байти]
}
// Загальний розмір: 12 байт (не 6!)
Console.WriteLine(Marshal.SizeOf<Example>()); // Виведе: 12
Якщо C структура має інший padding (наприклад, через #pragma pack), ви можете вказати Pack у StructLayout:
[StructLayout(LayoutKind.Sequential, Pack = 1)] // Без padding
struct Packed
{
public byte b1; // Offset 0
public int i; // Offset 1 (не 4!)
public byte b2; // Offset 5
}
// Розмір: 6 байт
Pack тільки якщо C код також використовує #pragma pack.Marshalling Масивів у Структурах
Коли структура містить масив фіксованого розміру, використовуйте [MarshalAs]:
// C структура:
// typedef struct {
// char name[256];
// int id;
// } Person;
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
struct Person
{
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string name; // Фіксований масив 256 байт
public int id;
}
Для масивів чисел:
[StructLayout(LayoutKind.Sequential)]
struct Data
{
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)]
public int[] values; // Масив з 10 int-ів (40 байт)
}
LibraryImport: Сучасна Альтернатива (.NET 7+)
Проблема DllImport: Runtime Marshalling
Традиційний [DllImport] виконує marshalling у runtime — CLR генерує IL-код для конвертації типів під час виконання програми. Це має кілька недоліків:
- Overhead: Кожен виклик P/Invoke проходить через marshalling stub, що додає 10-50 наносекунд
- Reflection: CLR використовує reflection для аналізу типів, що повільно
- Trimming: При публікації з
PublishTrimmed=trueважко визначити, які типи використовуються для marshalling
LibraryImport: Source Generator Approach
.NET 7 додав [LibraryImport] — атрибут, що використовує source generator для генерації marshalling коду на етапі компіляції. Це дає:
- Швидкість: Marshalling код inline-иться, немає runtime overhead
- AOT-сумісність: Працює з Native AOT (повна компіляція у native код без JIT)
- Trimming-friendly: Компілятор точно знає, які типи потрібні
Порівняння синтаксису:
// Старий підхід (DllImport)
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type);
// Новий підхід (LibraryImport) — .NET 7+
[LibraryImport("user32.dll", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
static partial int MessageBox(IntPtr hWnd, string text, string caption, uint type);
// ^^^^^^^ Обов'язково partial — source generator додасть реалізацію
Source generator створює приблизно такий код (спрощено):
static partial int MessageBox(IntPtr hWnd, string text, string caption, uint type)
{
IntPtr textPtr = Marshal.StringToCoTaskMemUni(text);
IntPtr captionPtr = Marshal.StringToCoTaskMemUni(caption);
try
{
return MessageBoxW(hWnd, textPtr, captionPtr, type);
}
finally
{
Marshal.FreeCoTaskMem(textPtr);
Marshal.FreeCoTaskMem(captionPtr);
}
}
[DllImport("user32.dll", EntryPoint = "MessageBoxW", ExactSpelling = true)]
static extern int MessageBoxW(IntPtr hWnd, IntPtr text, IntPtr caption, uint type);
Коли Використовувати LibraryImport
✅ Використовуйте LibraryImport
- Новий код на .NET 7+
- Потрібна максимальна продуктивність (hot path)
- Публікація з Native AOT
- Trimming-friendly застосунки
⚠️ Залишайтеся на DllImport
- Підтримка .NET Framework або .NET Core 3.1
- Складний marshalling (custom marshalers)
- Legacy код, що працює стабільно
- COM Interop (LibraryImport не підтримує COM)
SafeHandle: Безпечне Управління Ресурсами
Проблема: IntPtr та Finalizers
Коли ви отримуєте handle з Win32 API як IntPtr, виникає проблема: GC не знає, що це системний ресурс, який потрібно звільнити. Якщо об'єкт, що тримає IntPtr, збирається GC до виклику CloseHandle() — handle leak.
Наївний підхід (небезпечний):
class FileWrapper
{
private IntPtr _handle;
public FileWrapper(string path)
{
_handle = CreateFile(path, GENERIC_READ, ...);
if (_handle == INVALID_HANDLE_VALUE)
throw new Win32Exception();
}
// ❌ ПРОБЛЕМА: якщо забути викликати Dispose — handle leak
public void Dispose()
{
if (_handle != IntPtr.Zero && _handle != INVALID_HANDLE_VALUE)
{
CloseHandle(_handle);
_handle = IntPtr.Zero;
}
}
// ❌ ПРОБЛЕМА: finalizer може викликатися у будь-якому потоці,
// після того як об'єкт частково зруйновано
~FileWrapper()
{
Dispose();
}
}
SafeHandle: Правильне Рішення
SafeHandle — абстрактний клас з BCL, що вирішує всі проблеми:
- Critical Finalizer: Гарантовано викликається навіть при
AppDomainunload абоThread.Abort() - Reference Counting: Захист від race condition при закритті handle
- GC Integration: GC знає, що це системний ресурс
Реалізація для файлових handles:
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
// SafeHandleZeroOrMinusOneIsInvalid — базовий клас для handles,
// де 0 або -1 означають невалідний handle
class SafeFileHandle : SafeHandleZeroOrMinusOneIsInvalid
{
// Конструктор для створення "порожнього" handle
public SafeFileHandle() : base(ownsHandle: true) { }
// Конструктор для обгортання існуючого handle
public SafeFileHandle(IntPtr handle, bool ownsHandle) : base(ownsHandle)
{
SetHandle(handle);
}
// Метод звільнення ресурсу — викликається автоматично GC або при Dispose
protected override bool ReleaseHandle()
{
return CloseHandle(handle);
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CloseHandle(IntPtr handle);
}
class Program
{
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern SafeFileHandle CreateFile(
string lpFileName,
uint dwDesiredAccess,
uint dwShareMode,
IntPtr lpSecurityAttributes,
uint dwCreationDisposition,
uint dwFlagsAndAttributes,
IntPtr hTemplateFile
);
const uint GENERIC_READ = 0x80000000;
const uint OPEN_EXISTING = 3;
static void Main()
{
// using гарантує виклик Dispose, навіть при exception
using SafeFileHandle handle = CreateFile(
"test.txt",
GENERIC_READ,
0,
IntPtr.Zero,
OPEN_EXISTING,
0,
IntPtr.Zero
);
if (handle.IsInvalid)
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
Console.WriteLine("Файл відкрито успішно!");
// handle автоматично закриється при виході з using
}
}
SafeFileHandle (для файлів), SafeWaitHandle (для синхронізації), SafeProcessHandle (для процесів). Використовуйте їх замість власних реалізацій.Callback Functions: Виклики з Native у Managed
Проблема: Native Код Викликає C# Метод
Багато Win32 API функцій приймають callback — вказівник на функцію, яку вони викликатимуть для повідомлення про події. Класичний приклад — EnumWindows(), що перебирає всі вікна у системі та викликає ваш callback для кожного.
Сигнатура у C:
BOOL EnumWindows(
WNDENUMPROC lpEnumFunc, // Callback функція
LPARAM lParam // Користувацькі дані
);
// Тип callback-функції
typedef BOOL (CALLBACK *WNDENUMPROC)(
HWND hwnd, // Handle вікна
LPARAM lParam // Користувацькі дані
);
Delegate як Function Pointer
У C# делегат може бути перетворений на function pointer для передачі у native код. Використовується атрибут [UnmanagedFunctionPointer]:
using System.Runtime.InteropServices;
using System.Text;
class Program
{
// Делегат, що відповідає сигнатурі WNDENUMPROC
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")]
static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
[DllImport("user32.dll")]
static extern bool IsWindowVisible(IntPtr hWnd);
static void Main()
{
var windows = new List<(IntPtr Handle, string Title)>();
// Callback функція — буде викликана для кожного вікна
bool EnumWindowCallback(IntPtr hWnd, IntPtr lParam)
{
// Фільтруємо тільки видимі вікна
if (!IsWindowVisible(hWnd))
return true; // true = продовжити перебір
var title = new StringBuilder(256);
int length = GetWindowText(hWnd, title, title.Capacity);
if (length > 0)
{
windows.Add((hWnd, title.ToString()));
}
return true; // Продовжити перебір
}
// Передаємо делегат у native функцію
EnumWindows(EnumWindowCallback, IntPtr.Zero);
Console.WriteLine($"Знайдено {windows.Count} видимих вікон:\n");
foreach (var (handle, title) in windows.Take(10))
{
Console.WriteLine($"[0x{handle:X8}] {title}");
}
}
}
GC та Callback: Критична Проблема
Коли ви передаєте делегат у native код, CLR створює function pointer (thunk), що перенаправляє виклик з unmanaged у managed. Проблема: GC не знає, що native код тримає посилання на делегат.
Сценарій помилки:
// ❌ НЕБЕЗПЕЧНО!
void RegisterCallback()
{
EnumWindows((hWnd, lParam) =>
{
Console.WriteLine($"Window: {hWnd}");
return true;
}, IntPtr.Zero);
}
// Делегат (lambda) — локальна змінна, може бути зібрана GC
// Native код може викликати callback після того, як GC зібрав делегат
// Результат: AccessViolationException або corrupted state
Правильний підхід:
class WindowEnumerator
{
// Зберігаємо делегат як поле класу — GC не збере його
private readonly EnumWindowsProc _callback;
public WindowEnumerator()
{
_callback = EnumWindowCallback;
}
private bool EnumWindowCallback(IntPtr hWnd, IntPtr lParam)
{
// Обробка...
return true;
}
public void Enumerate()
{
EnumWindows(_callback, IntPtr.Zero);
}
}
GCHandle.Alloc: Pinning для Довгоживучих Callbacks
Для callbacks, що живуть довго (наприклад, Windows Hooks), використовуйте GCHandle.Alloc з GCHandleType.Normal:
class HookManager
{
private EnumWindowsProc _callback;
private GCHandle _callbackHandle;
public void Install()
{
_callback = MyCallback;
// "Закріплюємо" делегат — GC не зможе його зібрати
_callbackHandle = GCHandle.Alloc(_callback, GCHandleType.Normal);
// Передаємо у native код...
SetWindowsHookEx(..., _callback, ...);
}
public void Uninstall()
{
UnhookWindowsHookEx(...);
// Звільняємо GCHandle — тепер GC може зібрати делегат
if (_callbackHandle.IsAllocated)
_callbackHandle.Free();
}
private bool MyCallback(IntPtr hWnd, IntPtr lParam)
{
// ...
return true;
}
}
Практичний Приклад: GetSystemInfo
Побудуємо повноцінну утиліту для отримання детальної інформації про систему через Win32 API.
Крок 1: Визначення структури SYSTEM_INFO
using System.Runtime.InteropServices;
[StructLayout(LayoutKind.Sequential)]
struct SYSTEM_INFO
{
public ushort wProcessorArchitecture;
public ushort wReserved;
public uint dwPageSize;
public IntPtr lpMinimumApplicationAddress;
public IntPtr lpMaximumApplicationAddress;
public IntPtr dwActiveProcessorMask;
public uint dwNumberOfProcessors;
public uint dwProcessorType;
public uint dwAllocationGranularity;
public ushort wProcessorLevel;
public ushort wProcessorRevision;
}
// Константи для wProcessorArchitecture
enum ProcessorArchitecture : ushort
{
Intel = 0,
MIPS = 1,
Alpha = 2,
PowerPC = 3,
ARM = 5,
IA64 = 6,
AMD64 = 9,
ARM64 = 12,
Unknown = 0xFFFF
}
Крок 2: P/Invoke декларація
static class NativeMethods
{
[DllImport("kernel32.dll")]
public static extern void GetSystemInfo(out SYSTEM_INFO lpSystemInfo);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
public static extern void GetSystemDirectory(
StringBuilder lpBuffer,
uint uSize
);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
public static extern void GetWindowsDirectory(
StringBuilder lpBuffer,
uint uSize
);
}
Крок 3: Обгортка з зручним API
using System.Text;
class SystemInfoHelper
{
public static void PrintSystemInfo()
{
SYSTEM_INFO sysInfo;
NativeMethods.GetSystemInfo(out sysInfo);
Console.WriteLine("═══════════════════════════════════════════════");
Console.WriteLine(" SYSTEM INFORMATION");
Console.WriteLine("═══════════════════════════════════════════════\n");
// Архітектура процесора
var arch = (ProcessorArchitecture)sysInfo.wProcessorArchitecture;
Console.WriteLine($"Processor Architecture: {arch}");
string archDescription = arch switch
{
ProcessorArchitecture.Intel => "x86 (32-bit)",
ProcessorArchitecture.AMD64 => "x64 (64-bit)",
ProcessorArchitecture.ARM64 => "ARM64 (64-bit)",
ProcessorArchitecture.ARM => "ARM (32-bit)",
_ => "Unknown"
};
Console.WriteLine($" → {archDescription}\n");
// Кількість процесорів
Console.WriteLine($"Number of Processors: {sysInfo.dwNumberOfProcessors}");
Console.WriteLine($" → Logical cores available to OS\n");
// Розмір сторінки пам'яті
Console.WriteLine($"Page Size: {sysInfo.dwPageSize:N0} bytes");
Console.WriteLine($" → {sysInfo.dwPageSize / 1024} KB (memory allocation unit)\n");
// Діапазон адресного простору
Console.WriteLine($"Application Address Space:");
Console.WriteLine($" Min: 0x{sysInfo.lpMinimumApplicationAddress:X16}");
Console.WriteLine($" Max: 0x{sysInfo.lpMaximumApplicationAddress:X16}");
long addressSpaceSize = sysInfo.lpMaximumApplicationAddress.ToInt64() -
sysInfo.lpMinimumApplicationAddress.ToInt64();
Console.WriteLine($" → {addressSpaceSize / (1024.0 * 1024 * 1024):F1} GB virtual address space\n");
// Granularity для VirtualAlloc
Console.WriteLine($"Allocation Granularity: {sysInfo.dwAllocationGranularity:N0} bytes");
Console.WriteLine($" → {sysInfo.dwAllocationGranularity / 1024} KB (VirtualAlloc alignment)\n");
// Системні директорії
var sysDir = new StringBuilder(260);
var winDir = new StringBuilder(260);
NativeMethods.GetSystemDirectory(sysDir, (uint)sysDir.Capacity);
NativeMethods.GetWindowsDirectory(winDir, (uint)winDir.Capacity);
Console.WriteLine($"System Directory: {sysDir}");
Console.WriteLine($"Windows Directory: {winDir}\n");
// Маска активних процесорів (bitmap)
Console.WriteLine($"Active Processor Mask: 0x{sysInfo.dwActiveProcessorMask:X}");
Console.WriteLine($" → Binary: {Convert.ToString(sysInfo.dwActiveProcessorMask.ToInt64(), 2).PadLeft(64, '0')}");
int activeCores = CountBits(sysInfo.dwActiveProcessorMask.ToInt64());
Console.WriteLine($" → {activeCores} cores enabled\n");
Console.WriteLine("═══════════════════════════════════════════════");
}
private static int CountBits(long value)
{
int count = 0;
while (value != 0)
{
count += (int)(value & 1);
value >>= 1;
}
return count;
}
}
Крок 4: Точка входу
class Program
{
static void Main()
{
try
{
SystemInfoHelper.PrintSystemInfo();
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"Error: {ex.Message}");
Console.ResetColor();
}
}
}
Крок 5: Запуск
dotnet run
Marshal Class: Швейцарський Ніж P/Invoke
Клас System.Runtime.InteropServices.Marshal надає статичні методи для ручного управління unmanaged пам'яттю та конвертації типів. Це низькорівневий API, що дає повний контроль.
Виділення та Звільнення Пам'яті
GlobalAlloc Win32 API). Повертає вказівник. Пам'ять не ініціалізована (містить сміття).IntPtr ptr = Marshal.AllocHGlobal(1024); // 1 KB
try
{
// Використання пам'яті...
}
finally
{
Marshal.FreeHGlobal(ptr); // ОБОВ'ЯЗКОВО звільнити
}
CoTaskMemAlloc). Використовується для COM Interop та деяких Win32 API, що очікують саме цей allocator.AllocHGlobal. Виклик з невалідним вказівником — undefined behavior (crash).AllocCoTaskMem.Копіювання Даних між Managed та Unmanaged
byte[] data = { 1, 2, 3, 4, 5 };
IntPtr ptr = Marshal.AllocHGlobal(data.Length);
Marshal.Copy(data, 0, ptr, data.Length);
// Тепер unmanaged пам'ять містить {1, 2, 3, 4, 5}
IntPtr ptr = GetSomeNativeStructure();
SYSTEMTIME st = Marshal.PtrToStructure<SYSTEMTIME>(ptr);
Робота з Рядками
AllocHGlobal.string.Отримання Інформації про Типи
int size = Marshal.SizeOf<SYSTEMTIME>(); // 16 байт
IntPtr offset = Marshal.OffsetOf<SYSTEMTIME>("wYear"); // 0
IntPtr offset2 = Marshal.OffsetOf<SYSTEMTIME>("wMonth"); // 2
Наскрізний Приклад: Window Automation Tool
Побудуємо інструмент для автоматизації роботи з вікнами Windows: пошук вікон за заголовком, зміна позиції/розміру, відправка повідомлень.
Крок 1: Структури та константи
using System.Runtime.InteropServices;
[StructLayout(LayoutKind.Sequential)]
struct RECT
{
public int Left;
public int Top;
public int Right;
public int Bottom;
public int Width => Right - Left;
public int Height => Bottom - Top;
public override string ToString() =>
$"({Left}, {Top}) - ({Right}, {Bottom}) [{Width}x{Height}]";
}
[StructLayout(LayoutKind.Sequential)]
struct POINT
{
public int X;
public int Y;
public override string ToString() => $"({X}, {Y})";
}
static class WindowConstants
{
// ShowWindow commands
public const int SW_HIDE = 0;
public const int SW_SHOWNORMAL = 1;
public const int SW_SHOWMINIMIZED = 2;
public const int SW_SHOWMAXIMIZED = 3;
public const int SW_RESTORE = 9;
// SetWindowPos flags
public const uint SWP_NOSIZE = 0x0001;
public const uint SWP_NOMOVE = 0x0002;
public const uint SWP_NOZORDER = 0x0004;
public const uint SWP_SHOWWINDOW = 0x0040;
// Window messages
public const uint WM_CLOSE = 0x0010;
public const uint WM_SETTEXT = 0x000C;
}
Крок 2: Native методи
using System.Runtime.InteropServices;
using System.Text;
static class WindowNativeMethods
{
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern IntPtr FindWindow(string? lpClassName, string? lpWindowName);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
[DllImport("user32.dll")]
public static extern int GetWindowTextLength(IntPtr hWnd);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool SetWindowPos(
IntPtr hWnd,
IntPtr hWndInsertAfter,
int X,
int Y,
int cx,
int cy,
uint uFlags
);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool IsIconic(IntPtr hWnd); // Мінімізоване
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool IsZoomed(IntPtr hWnd); // Максимізоване
[DllImport("user32.dll")]
public static extern IntPtr SendMessage(
IntPtr hWnd,
uint Msg,
IntPtr wParam,
IntPtr lParam
);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool SetForegroundWindow(IntPtr hWnd);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
}
Крок 3: Обгортка Window
using System.Text;
class WindowWrapper
{
public IntPtr Handle { get; }
public WindowWrapper(IntPtr handle)
{
if (handle == IntPtr.Zero)
throw new ArgumentException("Invalid window handle", nameof(handle));
Handle = handle;
}
public string GetTitle()
{
int length = WindowNativeMethods.GetWindowTextLength(Handle);
if (length == 0)
return string.Empty;
var sb = new StringBuilder(length + 1);
WindowNativeMethods.GetWindowText(Handle, sb, sb.Capacity);
return sb.ToString();
}
public void SetTitle(string title)
{
IntPtr titlePtr = Marshal.StringToHGlobalUni(title);
try
{
WindowNativeMethods.SendMessage(
Handle,
WindowConstants.WM_SETTEXT,
IntPtr.Zero,
titlePtr
);
}
finally
{
Marshal.FreeHGlobal(titlePtr);
}
}
public RECT GetBounds()
{
if (!WindowNativeMethods.GetWindowRect(Handle, out RECT rect))
throw new InvalidOperationException("Failed to get window bounds");
return rect;
}
public void SetBounds(int x, int y, int width, int height)
{
WindowNativeMethods.SetWindowPos(
Handle,
IntPtr.Zero,
x, y, width, height,
WindowConstants.SWP_NOZORDER | WindowConstants.SWP_SHOWWINDOW
);
}
public void MoveTo(int x, int y)
{
WindowNativeMethods.SetWindowPos(
Handle,
IntPtr.Zero,
x, y, 0, 0,
WindowConstants.SWP_NOSIZE | WindowConstants.SWP_NOZORDER
);
}
public void Resize(int width, int height)
{
WindowNativeMethods.SetWindowPos(
Handle,
IntPtr.Zero,
0, 0, width, height,
WindowConstants.SWP_NOMOVE | WindowConstants.SWP_NOZORDER
);
}
public void Show() =>
WindowNativeMethods.ShowWindow(Handle, WindowConstants.SW_SHOWNORMAL);
public void Hide() =>
WindowNativeMethods.ShowWindow(Handle, WindowConstants.SW_HIDE);
public void Minimize() =>
WindowNativeMethods.ShowWindow(Handle, WindowConstants.SW_SHOWMINIMIZED);
public void Maximize() =>
WindowNativeMethods.ShowWindow(Handle, WindowConstants.SW_SHOWMAXIMIZED);
public void Restore() =>
WindowNativeMethods.ShowWindow(Handle, WindowConstants.SW_RESTORE);
public void BringToFront() =>
WindowNativeMethods.SetForegroundWindow(Handle);
public void Close() =>
WindowNativeMethods.SendMessage(Handle, WindowConstants.WM_CLOSE, IntPtr.Zero, IntPtr.Zero);
public bool IsVisible =>
WindowNativeMethods.IsWindowVisible(Handle);
public bool IsMinimized =>
WindowNativeMethods.IsIconic(Handle);
public bool IsMaximized =>
WindowNativeMethods.IsZoomed(Handle);
public string GetState()
{
if (!IsVisible) return "Hidden";
if (IsMinimized) return "Minimized";
if (IsMaximized) return "Maximized";
return "Normal";
}
}
Крок 4: Window Manager
class WindowManager
{
public static WindowWrapper? FindByTitle(string title)
{
IntPtr handle = WindowNativeMethods.FindWindow(null, title);
return handle != IntPtr.Zero ? new WindowWrapper(handle) : null;
}
public static WindowWrapper? FindByTitleContains(string partialTitle)
{
WindowWrapper? found = null;
WindowNativeMethods.EnumWindows((hWnd, lParam) =>
{
if (!WindowNativeMethods.IsWindowVisible(hWnd))
return true;
var window = new WindowWrapper(hWnd);
string title = window.GetTitle();
if (title.Contains(partialTitle, StringComparison.OrdinalIgnoreCase))
{
found = window;
return false; // Зупинити перебір
}
return true;
}, IntPtr.Zero);
return found;
}
public static List<WindowWrapper> GetAllVisibleWindows()
{
var windows = new List<WindowWrapper>();
WindowNativeMethods.EnumWindows((hWnd, lParam) =>
{
if (WindowNativeMethods.IsWindowVisible(hWnd))
{
var window = new WindowWrapper(hWnd);
string title = window.GetTitle();
if (!string.IsNullOrWhiteSpace(title))
windows.Add(window);
}
return true;
}, IntPtr.Zero);
return windows;
}
}
Крок 5: CLI інтерфейс
class Program
{
static void Main(string[] args)
{
Console.OutputEncoding = System.Text.Encoding.UTF8;
if (args.Length == 0)
{
ShowHelp();
return;
}
try
{
string command = args[0].ToLower();
switch (command)
{
case "list":
ListWindows();
break;
case "find":
if (args.Length < 2)
{
Console.WriteLine("Usage: find <title>");
return;
}
FindWindow(args[1]);
break;
case "move":
if (args.Length < 4)
{
Console.WriteLine("Usage: move <title> <x> <y>");
return;
}
MoveWindow(args[1], int.Parse(args[2]), int.Parse(args[3]));
break;
case "resize":
if (args.Length < 4)
{
Console.WriteLine("Usage: resize <title> <width> <height>");
return;
}
ResizeWindow(args[1], int.Parse(args[2]), int.Parse(args[3]));
break;
default:
Console.WriteLine($"Unknown command: {command}");
ShowHelp();
break;
}
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"Error: {ex.Message}");
Console.ResetColor();
}
}
static void ShowHelp()
{
Console.WriteLine("Window Automation Tool");
Console.WriteLine("======================\n");
Console.WriteLine("Commands:");
Console.WriteLine(" list - List all visible windows");
Console.WriteLine(" find <title> - Find window by title");
Console.WriteLine(" move <title> <x> <y> - Move window to position");
Console.WriteLine(" resize <title> <w> <h> - Resize window");
}
static void ListWindows()
{
var windows = WindowManager.GetAllVisibleWindows();
Console.WriteLine($"Found {windows.Count} visible windows:\n");
Console.WriteLine($"{"Handle",-12} {"State",-12} {"Bounds",-30} {"Title"}");
Console.WriteLine(new string('─', 100));
foreach (var window in windows.Take(20))
{
try
{
var bounds = window.GetBounds();
Console.WriteLine(
$"0x{window.Handle:X8} " +
$"{window.GetState(),-12} " +
$"{bounds.Width}x{bounds.Height} at ({bounds.Left},{bounds.Top})".PadRight(30) +
$"{window.GetTitle()}"
);
}
catch { }
}
}
static void FindWindow(string title)
{
var window = WindowManager.FindByTitleContains(title);
if (window == null)
{
Console.WriteLine($"Window not found: {title}");
return;
}
Console.WriteLine($"Found window:");
Console.WriteLine($" Handle: 0x{window.Handle:X8}");
Console.WriteLine($" Title: {window.GetTitle()}");
Console.WriteLine($" State: {window.GetState()}");
Console.WriteLine($" Bounds: {window.GetBounds()}");
}
static void MoveWindow(string title, int x, int y)
{
var window = WindowManager.FindByTitleContains(title);
if (window == null)
{
Console.WriteLine($"Window not found: {title}");
return;
}
window.MoveTo(x, y);
Console.WriteLine($"✓ Moved '{window.GetTitle()}' to ({x}, {y})");
}
static void ResizeWindow(string title, int width, int height)
{
var window = WindowManager.FindByTitleContains(title);
if (window == null)
{
Console.WriteLine($"Window not found: {title}");
return;
}
window.Resize(width, height);
Console.WriteLine($"✓ Resized '{window.GetTitle()}' to {width}x{height}");
}
}
Крок 6: Використання
# Список всіх вікон
dotnet run list
# Знайти вікно
dotnet run find "Chrome"
# Перемістити вікно
dotnet run move "Chrome" 100 100
# Змінити розмір
dotnet run resize "Chrome" 1280 720
Практичний Кейс: Створення Game Trainer (AssaultCube)
Game Hacking — це класична та захоплююча область системного програмування, де P/Invoke та Win32 API є основним інструментом. У цьому розділі ми з нуля створимо консольний трейнер для популярного open-source шутера AssaultCube (версії 1.2+). Наш трейнер буде зчитувати показники здоров'я та набоїв гравця, а також дозволить активувати "God Mode" та "Нескінченні набої".
!IMPORTANTЕтична та юридична примітка: Цей матеріал створено виключно з навчальною метою для демонстрації роботи з віртуальною пам'яттю процесів у Windows. Модифікація пам'яті дозволена лише в офлайн-режимі (Singleplayer) та на іграх без античіт-систем (як-от AssaultCube). Ніколи не використовуйте ці методи в онлайн-іграх, оскільки це призведе до бану та порушує ліцензійні угоди користувача (EULA).
Теорія: Як влаштована пам'ять процесу
Кожен процес у Windows виконується у власному віртуальному адресному просторі (Virtual Address Space). Це ізольована пісочниця: процес A за замовчуванням не може прочитати або записати дані в пам'ять процесу B. Це основа безпеки та стабільності ОС.
Проте операційна система надає спеціальні низькорівневі механізми через Win32 API для налагодження (debugging) та діагностики, які дозволяють керувати пам'яттю інших процесів. Щоб отримати доступ до іншого процесу, нам потрібно пройти наступні етапи:
graph TD
A[Знайти Process ID гри] --> B[Викликати OpenProcess]
B --> C{Отримати Handle з правами}
C -->|VM_READ| D[ReadProcessMemory: Читання покажчиків і значень]
C -->|VM_WRITE / VM_OPERATION| E[WriteProcessMemory: Модифікація значень]
D --> F[Закрити Handle через CloseHandle]
E --> F
- Ідентифікація процесу: Знайти цільовий процес за його назвою (наприклад,
ac_client.exe) та отримати його унікальний ідентифікатор процесу (PID). - Отримання дескриптора (Handle): Викликати
OpenProcessз прапорцями безпеки, що дозволяють читання та запис пам'яті. - Читання/Запис: Використати
ReadProcessMemoryдля зчитування даних іWriteProcessMemoryдля їх зміни. - Очищення: Закрити дескриптор за допомогою
CloseHandleдля запобігання витоку ресурсів.
Динамічна пам'ять та ланцюжки покажчиків (Pointer Chains)
Чому ми не можемо просто один раз знайти адресу здоров'я гравця (наприклад, 0x028F45A0) і записувати туди значення?
Тому що сучасні ОС та компілятори використовують:
- ASLR (Address Space Layout Randomization): При кожному запуску гри її модулі (наприклад,
ac_client.exe) завантажуються за новими випадковими адресами. - Динамічне виділення пам'яті: Об'єкт гравця створюється в купі (Heap) динамічно через оператор
new. Адреса об'єкта залежить від порядку завантаження ресурсів, карти та інших факторів.
Щоб знайти потрібну характеристику, розробники трейнерів використовують ланцюжки покажчиків (Pointer Chains). В пам'яті виконуваного модуля (ac_client.exe) є статична (фіксована) змінна, яка завжди зберігає адресу об'єкта поточного гравця. Ця змінна називається Local Player Base Pointer.
Схема розіменування для AssaultCube виглядає так:
[ac_client.exe + 0x10F4F4] (Статичний покажчик)
│
▼ (містить адресу, наприклад: 0x00A38140)
┌─────────────────┐
│ Структура Player│
│ │
│ 0x00: Name │
│ ... │
│ 0xEC: Health ◄─── [Player Base + Offset 0xEC]
│ 0xF0: Armor ◄─── [Player Base + Offset 0xF0]
│ 0x140: Ammo ◄─── [Player Base + Offset 0x140]
└─────────────────┘
Щоб отримати поточне здоров'я, наша програма повинна виконати такі кроки:
- Знайти базову адресу завантаження модуля
ac_client.exe(наприклад,0x00400000). - Додати до неї зміщення статичного покажчика (
0x10F4F4). Отримаємо адресу0x0050F4F4. - Зчитати 4 байти з адреси
0x0050F4F4. Це значення буде реальною адресою гравця в купі (наприклад,0x00A38140). Цей процес називається розіменуванням покажчика (dereferencing). - Додати зміщення здоров'я (
0xECабо0xF8залежно від версії AssaultCube) до адреси гравця. Отримаємо0x00A38140 + 0xEC = 0x00A3822C. - Зчитати/записати значення за адресою
0x00A3822C.
Крок 1: Оголошення Win32 API функцій
Для створення нашого трейнера нам потрібні наступні P/Invoke декларації. Ми об'єднаємо їх в окремий статичний клас NativeMethods.
using System;
using System.Runtime.InteropServices;
internal static class NativeMethods
{
// Прапорці доступу до процесу
public const uint PROCESS_VM_READ = 0x0010;
public const uint PROCESS_VM_WRITE = 0x0020;
public const uint PROCESS_VM_OPERATION = 0x0008;
// Відкриття дескриптора процесу
[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr OpenProcess(
uint dwDesiredAccess,
[MarshalAs(UnmanagedType.Bool)] bool bInheritHandle,
int dwProcessId
);
// Читання віртуальної пам'яті
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool ReadProcessMemory(
IntPtr hProcess,
IntPtr lpBaseAddress,
[Out] byte[] lpBuffer,
int nSize,
out IntPtr lpNumberOfBytesRead
);
// Запис у віртуальну пам'ять
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool WriteProcessMemory(
IntPtr hProcess,
IntPtr lpBaseAddress,
byte[] lpBuffer,
int nSize,
out IntPtr lpNumberOfBytesWritten
);
// Закриття дескриптора
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool CloseHandle(IntPtr hObject);
}
Крок 2: Розробка класу MemoryManager
Щоб спростити роботу з P/Invoke, створимо обгортку MemoryManager, яка інкапсулює відкритий handle процесу та реалізує безпечне читання/запис типів даних.
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
public class MemoryManager : IDisposable
{
private readonly IntPtr _processHandle;
public IntPtr BaseAddress { get; }
public MemoryManager(string processName)
{
// Шукаємо запущений процес
Process[] processes = Process.GetProcessesByName(processName);
if (processes.Length == 0)
{
throw new Exception($"Процес {processName}.exe не знайдено! Переконайтеся, що гра запущена.");
}
Process targetProcess = processes[0];
BaseAddress = targetProcess.MainModule.BaseAddress;
// Запитуємо права на читання, запис та віртуальні операції
uint accessFlags = NativeMethods.PROCESS_VM_READ |
NativeMethods.PROCESS_VM_WRITE |
NativeMethods.PROCESS_VM_OPERATION;
_processHandle = NativeMethods.OpenProcess(accessFlags, false, targetProcess.Id);
if (_processHandle == IntPtr.Zero)
{
int errorCode = Marshal.GetLastWin32Error();
throw new Win32Exception(errorCode, "Не вдалося відкрити процес для маніпуляцій з пам'яттю.");
}
}
// Читання примітивного значення типу T
public T Read<T>(IntPtr address) where T : struct
{
int size = Marshal.SizeOf<T>();
byte[] buffer = new byte[size];
if (!NativeMethods.ReadProcessMemory(_processHandle, address, buffer, size, out _))
{
int errorCode = Marshal.GetLastWin32Error();
throw new Win32Exception(errorCode, $"Помилка читання пам'яті за адресою 0x{address.ToInt64():X}");
}
// Перетворюємо байти у структуру T
GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
return Marshal.PtrToStructure<T>(handle.AddrOfPinnedObject());
}
finally
{
handle.Free();
}
}
// Запис примітивного значення типу T
public void Write<T>(IntPtr address, T value) where T : struct
{
int size = Marshal.SizeOf<T>();
byte[] buffer = new byte[size];
// Записуємо структуру T у масив байтів
GCHandle handle = GCHandle.Alloc(value, GCHandleType.Pinned);
try
{
Marshal.Copy(handle.AddrOfPinnedObject(), buffer, 0, size);
}
finally
{
handle.Free();
}
if (!NativeMethods.WriteProcessMemory(_processHandle, address, buffer, size, out _))
{
int errorCode = Marshal.GetLastWin32Error();
throw new Win32Exception(errorCode, $"Помилка запису в пам'ять за адресою 0x{address.ToInt64():X}");
}
}
// Розіменування ланцюжка покажчиків (Pointer Chain)
// Наприклад: BaseAddress + Offset1 -> Address1 + Offset2 -> Address2 + Offset3 -> Final Address
public IntPtr ResolvePointerChain(IntPtr basePtr, int[] offsets)
{
IntPtr currentAddress = basePtr;
// Перебираємо всі зміщення, крім останнього
for (int i = 0; i < offsets.Length - 1; i++)
{
// Для 32-бітної гри зчитуємо 4 байти покажчика (Int32/IntPtr)
int pointerValue = Read<int>(currentAddress + offsets[i]);
currentAddress = (IntPtr)pointerValue;
}
// Додаємо останнє зміщення для отримання фінальної фізичної адреси
return currentAddress + offsets[offsets.Length - 1];
}
public void Dispose()
{
if (_processHandle != IntPtr.Zero)
{
NativeMethods.CloseHandle(_processHandle);
}
GC.SuppressFinalize(this);
}
~MemoryManager()
{
Dispose();
}
}
Крок 3: Створення логіки трейнера
Тепер, використовуючи наш MemoryManager, напишемо інтерактивну консольну програму, яка взаємодіє з AssaultCube.
Нижче наведено константи та зміщення пам'яті для двох популярних версій AssaultCube (v1.2 та v1.3).
using System;
using System.Threading;
class Program
{
// === КОНСТАНТИ ТА ЗМІЩЕННЯ ДЛЯ ASSAULTCUBE ===
// (Ви можете змінити їх залежно від версії гри)
// Версія 1.2.0.2:
private const int PLAYER_BASE_OFFSET_V12 = 0x10F4F4;
private const int HEALTH_OFFSET_V12 = 0xF8;
private const int ARMOR_OFFSET_V12 = 0xFC;
private const int RIFLE_AMMO_OFFSET_V12 = 0x150;
// Версія 1.3.0.2 (новіша):
private const int PLAYER_BASE_OFFSET_V13 = 0x17E0A8;
private const int HEALTH_OFFSET_V13 = 0xEC;
private const int ARMOR_OFFSET_V13 = 0xF0;
private const int RIFLE_AMMO_OFFSET_V13 = 0x140;
static void Main()
{
Console.OutputEncoding = System.Text.Encoding.UTF8;
Console.Title = "AssaultCube C# Trainer - Навчальний проект";
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("====================================================");
Console.WriteLine(" ASSAULTCUBE C# WINAPI MEMORY TRAINER ");
Console.WriteLine("====================================================");
Console.ResetColor();
try
{
// Ініціалізуємо менеджер пам'яті для гри
using var memory = new MemoryManager("ac_client");
Console.WriteLine($"[+] Успішно підключено до ac_client.exe!");
Console.WriteLine($"[+] Базова адреса модуля: 0x{memory.BaseAddress.ToInt64():X}\n");
// Вибір версії гри для застосування коректних зміщень
Console.WriteLine("Оберіть версію AssaultCube:");
Console.WriteLine("1. Версія 1.2.0.2");
Console.WriteLine("2. Версія 1.3.0.2 (або новіша)");
Console.Write("Ваш вибір (1 або 2): ");
string choice = Console.ReadLine();
int playerBaseOffset = choice == "1" ? PLAYER_BASE_OFFSET_V12 : PLAYER_BASE_OFFSET_V13;
int healthOffset = choice == "1" ? HEALTH_OFFSET_V12 : HEALTH_OFFSET_V13;
int armorOffset = choice == "1" ? ARMOR_OFFSET_V12 : ARMOR_OFFSET_V13;
int rifleAmmoOffset = choice == "1" ? RIFLE_AMMO_OFFSET_V12 : RIFLE_AMMO_OFFSET_V13;
// Визначаємо ланцюжки зміщень
IntPtr localPlayerPtr = memory.BaseAddress + playerBaseOffset;
// Здоров'я: PlayerPtr -> [PlayerPtr] + healthOffset
int[] healthChain = new int[] { 0x0, healthOffset };
// Набої автомата: PlayerPtr -> [PlayerPtr] + rifleAmmoOffset
int[] ammoChain = new int[] { 0x0, rifleAmmoOffset };
// Броня: PlayerPtr -> [PlayerPtr] + armorOffset
int[] armorChain = new int[] { 0x0, armorOffset };
bool isGodMode = false;
bool isInfAmmo = false;
// Запускаємо фоновий потік для "заморожування" значень (Freezing Values)
var workerThread = new Thread(() =>
{
while (true)
{
try
{
if (isGodMode)
{
IntPtr healthAddress = memory.ResolvePointerChain(localPlayerPtr, healthChain);
memory.Write<int>(healthAddress, 9999);
IntPtr armorAddress = memory.ResolvePointerChain(localPlayerPtr, armorChain);
memory.Write<int>(armorAddress, 9999);
}
if (isInfAmmo)
{
IntPtr ammoAddress = memory.ResolvePointerChain(localPlayerPtr, ammoChain);
memory.Write<int>(ammoAddress, 1337);
}
}
catch
{
// Якщо гра закрилася під час роботи потоку
break;
}
Thread.Sleep(10); // Невелике очікування, щоб не перевантажувати процесор
}
}) { IsBackground = true };
workerThread.Start();
// Основний цикл CLI меню
while (true)
{
Console.Clear();
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("====================================================");
Console.WriteLine(" ASSAULTCUBE TRAINER INTERFACE ");
Console.WriteLine("====================================================");
Console.ResetColor();
try
{
// Отримуємо актуальні адреси в купі
IntPtr healthAddress = memory.ResolvePointerChain(localPlayerPtr, healthChain);
IntPtr ammoAddress = memory.ResolvePointerChain(localPlayerPtr, ammoChain);
// Зчитуємо поточні показники з пам'яті гри
int currentHealth = memory.Read<int>(healthAddress);
int currentAmmo = memory.Read<int>(ammoAddress);
Console.WriteLine($"[Дані з пам'яті гри]");
Console.WriteLine($"Адреса структури гравця: 0x{healthAddress.ToInt64() - healthOffset:X}");
Console.WriteLine($"Поточне здоров'я: {currentHealth}");
Console.WriteLine($"Набої в автоматі: {currentAmmo}\n");
Console.WriteLine("[Функції трейнера]");
Console.Write("1. [");
WriteState(isGodMode);
Console.WriteLine("] God Mode + Infinite Armor (Заморожування на 9999)");
Console.Write("2. [");
WriteState(isInfAmmo);
Console.WriteLine("] Infinite Rifle Ammo (Заморожування на 1337)");
Console.WriteLine("3. Миттєво встановити здоров'я на 500");
Console.WriteLine("4. Миттєво встановити набої на 500");
Console.WriteLine("5. Вихід");
Console.Write("\nОберіть дію (1-5): ");
char menuKey = Console.ReadKey(true).KeyChar;
if (menuKey == '1')
{
isGodMode = !isGodMode;
}
else if (menuKey == '2')
{
isInfAmmo = !isInfAmmo;
}
else if (menuKey == '3')
{
memory.Write<int>(healthAddress, 500);
Console.WriteLine("\n[+] Здоров'я змінено на 500!");
Thread.Sleep(500);
}
else if (menuKey == '4')
{
memory.Write<int>(ammoAddress, 500);
Console.WriteLine("\n[+] Набої автомата змінено на 500!");
Thread.Sleep(500);
}
else if (menuKey == '5')
{
Console.WriteLine("\n[-] Вихід з трейнера. Дескриптор процесу закрито.");
break;
}
}
catch (Win32Exception)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("\n[Помилка] Не вдалося зчитати пам'ять. Можливо, ви в полі зору меню, померли, або гра перезавантажила рівень.");
Console.ResetColor();
Console.WriteLine("Спробуйте увійти в раунд та натиснути будь-яку клавішу для повтору...");
Console.ReadKey(true);
}
}
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"\n[Помилка ініціалізації]: {ex.Message}");
Console.ResetColor();
Console.WriteLine("Натисніть будь-яку клавішу для виходу...");
Console.ReadKey(true);
}
}
private static void WriteState(bool state)
{
if (state)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.Write("УВІМКНЕНО");
}
else
{
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.Write("ВИМКНЕНО");
}
Console.ResetColor();
}
}
Чому це працює: Анатомія низькорівневих викликів
Process.GetProcessesByName("ac_client"): Це стандартна managed обгортка, яка під капотом викликаєEnumProcessesта інші Win32 API для переліку процесів у системі. За її допомогою ми отримуємоProcess ID(ідентифікатор процесу) таBaseAddressголовного модуля (адресу завантаженняac_client.exeв пам'ять).OpenProcess: Цей виклик звертається до ядра Windows, запитуючи створення дескриптора (Handle).- Ми вказуємо прапорці доступу:
PROCESS_VM_READ— дозволяє системі дозволити нашому процесу використовуватиReadProcessMemoryдля цього дескриптора.PROCESS_VM_WRITEтаPROCESS_VM_OPERATION— необхідні дляWriteProcessMemory. Якщо їх не передати, будь-яка спроба змінити пам'ять поверне помилкуAccess Denied(код помилки 5).
- Ми вказуємо прапорці доступу:
GCHandle.Alloc: Коли мы передаємо масив байтів або структуру уReadProcessMemory/WriteProcessMemory, CLR Garbage Collector може паралельно перемістити наш масив у купі managed пам'яті (через дефрагментацію). Щоб цього не сталося під час виконання нативного виклику, ми використовуємоGCHandleType.Pinned(або ключове словоfixedу C#), фіксуючи адресу об'єкта в керованій пам'яті.ResolvePointerChain: Це серце нашої роботи з непрямим адресуванням. Метод послідовно зчитує 4 байти покажчиків і додає зміщення. Оскільки AssaultCube є 32-бітною (x86) грою, кожен покажчик має розмір ровно 4 байти. Тому для зчитування проміжних адрес ми використовуємоRead<int>. Якби гра була 64-бітною (x64), ми б використовувалиRead<long>абоRead<IntPtr>, оскільки адреси були б 8-байтними.Thread.Sleep(10): Заморожування значень (наприклад, нескінченне здоров'я) відбувається у нескінченному циклі. Без очікуванняSleep(10)цей цикл завантажить одне ядро процесора на 100%. Очікування в 10 мілісекунд знижує споживання CPU трейнером майже до 0%, забезпечуючи при цьому оновлення значень 100 разів на секунду, чого з головою вистачає для гри.
Практичний Кейс: DLL Injection та Впровадження Коду
У попередньому розділі ми розглянули зовнішній (External) підхід до модифікації пам'яті гри за допомогою ReadProcessMemory та WriteProcessMemory. Це безпечно з точки зору стабільності процесу, але вимагає багато системних викликів (system calls), що сповільнює роботу при великій кількості операцій.
Існує інший, набагато потужніший підхід — внутрішній (Internal). Замість того, щоб маніпулювати грою ззовні, ми можемо впровадити нашу власну динамічну бібліотеку (DLL) безпосередньо в адресний простір гри. Тоді наш код виконуватиметься всередині процесу гри і отримає прямий доступ до її функцій, об'єктів та пам'яті через звичайні C++ покажчики (без використання Read/WriteProcessMemory).
Найпопулярніший спосіб реалізувати це — DLL Injection (впровадження DLL) через Win32 API.
Теорія: Як працює DLL Injection
Оскільки операційна система Windows не дозволяє одному процесу просто так завантажувати бібліотеки в інший процес, розробники використовують витончений трюк, що спирається на архітектурні особливості Windows API:
sequenceDiagram
participant Injector as C# Injector
participant Target as Цільовий Процес (Гра)
participant Kernel as Ядро Windows (Win32 API)
Injector->>Target: 1. OpenProcess (отримання прав доступу)
Injector->>Target: 2. VirtualAllocEx (виділення пам'яті під рядок шляху до DLL)
Injector->>Target: 3. WriteProcessMemory (запис шляху "C:\cheat.dll" в target)
Injector->>Kernel: 4. GetProcAddress (знаходження адреси LoadLibraryW у kernel32.dll)
Injector->>Target: 5. CreateRemoteThread (запуск потоку, що виконує LoadLibraryW(шлях))
Target->>Target: 6. LoadLibraryW завантажує DLL та викликає DllMain
- Отримання доступу: Інжектор відкриває дескриптор цільового процесу за допомогою
OpenProcessз правами на створення потоків та виділення пам'яті. - Виділення буфера під шлях: Ми не можемо просто передати адресу рядка з пам'яті нашого інжектора цільовому процесу — гра не матиме туди доступу. Тому ми викликаємо
VirtualAllocEx, щоб виділити блок пам'яті безпосередньо в адресному просторі цільового процесу. - Запис шляху до DLL: За допомогою
WriteProcessMemoryми записуємо рядок із повним шляхом до нашої DLL (наприклад,C:\test\hack.dll) у щойно виділену пам'ять цільового процесу. - Виклик LoadLibrary: Windows завантажує динамічні бібліотеки через функцію
LoadLibraryW(абоLoadLibraryA), яка знаходиться вkernel32.dll. Ця функція приймає один параметр — шлях до файлу DLL. - Створення віддаленого потоку: Ми викликаємо
CreateRemoteThread, вказуючи як точку входу адресу функціїLoadLibraryW, а як аргумент — адресу буфера в цільовому процесі, де лежить записаний нами шлях до DLL. - Виконання: Цільовий процес створює новий потік, який викликає
LoadLibraryW. Бібліотека завантажується, і ОС автоматично викликає точку входу нашої DLL — функціюDllMainз подієюDLL_PROCESS_ATTACH.
!NOTEЧому адреса LoadLibraryW однакова в обох процесах? Windows оптимізує завантаження базових системних DLL (як
kernel32.dll,ntdll.dll,user32.dll). Вони завантажуються за однаковою базовою адресою (Image Base) для всіх процесів у системі. Тому адресаLoadLibraryWу нашому інжекторі буде точно такою самою, як і в грі.
Крок 1: P/Invoke декларації для інжектора
Для виділення пам'яті та створення потоку в іншому процесі додамо нові декларації в наш клас NativeMethods.
using System;
using System.Runtime.InteropServices;
internal static class NativeMethods
{
// Додаткові прапорці доступу для інжекту
public const uint PROCESS_CREATE_THREAD = 0x0002;
public const uint PROCESS_QUERY_INFORMATION = 0x0400;
public const uint MEM_COMMIT = 0x1000;
public const uint MEM_RESERVE = 0x2000;
public const uint MEM_RELEASE = 0x8000;
public const uint PAGE_READWRITE = 0x04;
// Виділення віртуальної пам'яті в іншому процесі
[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr VirtualAllocEx(
IntPtr hProcess,
IntPtr lpAddress,
uint dwSize,
uint flAllocationType,
uint flProtect
);
// Звільнення віртуальної пам'яті в іншому процесі
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool VirtualFreeEx(
IntPtr hProcess,
IntPtr lpAddress,
uint dwSize,
uint dwFreeType
);
// Створення потоку в іншому процесі
[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr CreateRemoteThread(
IntPtr hProcess,
IntPtr lpThreadAttributes,
uint dwStackSize,
IntPtr lpStartAddress,
IntPtr lpParameter,
uint dwCreationFlags,
out IntPtr lpThreadId
);
// Отримання адреси функції з завантаженої DLL
[DllImport("kernel32.dll", CharSet = CharSet.Ansi, ExactSpelling = true, SetLastError = true)]
public static extern IntPtr GetProcAddress(IntPtr hModule, string procName);
// Отримання дескриптора вже завантаженого модуля
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern IntPtr GetModuleHandle(string lpModuleName);
// (Попередні методи OpenProcess, WriteProcessMemory, CloseHandle залишаються без змін)
}
Крок 2: Код DLL Injector на C#
Напишемо утиліту, яка шукає гру та виконує інжект вказаного файлу DLL.
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
class DllInjector
{
public static bool Inject(string processName, string dllPath)
{
Console.WriteLine($"[I] Початок інжекції {Path.GetFileName(dllPath)} у процес {processName}...");
if (!File.Exists(dllPath))
{
Console.WriteLine("[!] Помилка: Файл DLL не існує за вказаним шляхом.");
return false;
}
// 1. Шукаємо процес
Process[] processes = Process.GetProcessesByName(processName);
if (processes.Length == 0)
{
Console.WriteLine($"[!] Помилка: Процес {processName}.exe не знайдено.");
return false;
}
Process targetProcess = processes[0];
// 2. Відкриваємо дескриптор процесу з необхідними правами
uint desiredAccess = NativeMethods.PROCESS_VM_READ |
NativeMethods.PROCESS_VM_WRITE |
NativeMethods.PROCESS_VM_OPERATION |
NativeMethods.PROCESS_CREATE_THREAD |
NativeMethods.PROCESS_QUERY_INFORMATION;
IntPtr hProcess = NativeMethods.OpenProcess(desiredAccess, false, targetProcess.Id);
if (hProcess == IntPtr.Zero)
{
Console.WriteLine($"[!] Помилка OpenProcess: Код помилки {Marshal.GetLastWin32Error()}");
return false;
}
IntPtr allocAddress = IntPtr.Zero;
IntPtr hThread = IntPtr.Zero;
try
{
// 3. Виділяємо пам'ять у цільовому процесі під рядок шляху до DLL
// Довжина рядка у байтах (Unicode версія LoadLibraryW потребує UTF-16, тому множимо на 2 + 2 байти на null-terminator)
uint size = (uint)((dllPath.Length + 1) * 2);
allocAddress = NativeMethods.VirtualAllocEx(
hProcess,
IntPtr.Zero,
size,
NativeMethods.MEM_COMMIT | NativeMethods.MEM_RESERVE,
NativeMethods.PAGE_READWRITE
);
if (allocAddress == IntPtr.Zero)
{
Console.WriteLine($"[!] Помилка VirtualAllocEx: Код помилки {Marshal.GetLastWin32Error()}");
return false;
}
// 4. Записуємо шлях DLL в пам'ять цільового процесу
byte[] dllPathBytes = Encoding.Unicode.GetBytes(dllPath + "\0");
bool writeSuccess = NativeMethods.WriteProcessMemory(
hProcess,
allocAddress,
dllPathBytes,
dllPathBytes.Length,
out _
);
if (!writeSuccess)
{
Console.WriteLine($"[!] Помилка WriteProcessMemory: Код помилки {Marshal.GetLastWin32Error()}");
return false;
}
// 5. Знаходимо адресу LoadLibraryW в kernel32.dll
IntPtr hKernel = NativeMethods.GetModuleHandle("kernel32.dll");
IntPtr loadLibraryAddress = NativeMethods.GetProcAddress(hKernel, "LoadLibraryW");
if (loadLibraryAddress == IntPtr.Zero)
{
Console.WriteLine($"[!] Помилка GetProcAddress: Не знайдено LoadLibraryW.");
return false;
}
Console.WriteLine($"[+] Адреса LoadLibraryW: 0x{loadLibraryAddress.ToInt64():X}");
Console.WriteLine($"[+] Буфер виділено за адресою: 0x{allocAddress.ToInt64():X}");
// 6. Створюємо віддалений потік, який виконає LoadLibraryW(allocAddress)
hThread = NativeMethods.CreateRemoteThread(
hProcess,
IntPtr.Zero,
0,
loadLibraryAddress,
allocAddress,
0,
out _
);
if (hThread == IntPtr.Zero)
{
Console.WriteLine($"[!] Помилка CreateRemoteThread: Код помилки {Marshal.GetLastWin32Error()}");
return false;
}
Console.WriteLine("[+] Потік інжекції створено успішно!");
return true;
}
finally
{
// Очищення handles
if (hThread != IntPtr.Zero)
{
NativeMethods.CloseHandle(hThread);
}
// Зверніть увагу: ми НЕ викликаємо VirtualFreeEx одразу,
// оскільки LoadLibraryW працює асинхронно у новому потоці.
// Зазвичай чекають завершення потоку через WaitForSingleObject,
// після чого звільняють пам'ять.
if (hProcess != IntPtr.Zero)
{
NativeMethods.CloseHandle(hProcess);
}
}
}
}
Крок 3: Код впроваджуваної DLL на C++
Для створення самої бібліотеки нам знадобиться компілятор C++ (наприклад, у Visual Studio). Створимо проект Dynamic-Link Library (DLL) і напишемо код, який підключається до гри та змінює здоров'я гравця напряму.
#include <windows.h>
#include <iostream>
// Функція, яка буде виконуватися у окремому потоці після інжекції
DWORD WINAPI InternalTrainerThread(LPVOID lpParam)
{
// Створюємо консоль для відладки (опціонально)
AllocConsole();
FILE* f;
freopen_s(&f, "CONOUT$", "w", stdout);
std::cout << "=========================================\n";
std::cout << " HELLO FROM INSIDE THE TARGET PROCESS \n";
std::cout << "=========================================\n";
// Отримуємо базову адресу головного модуля гри ac_client.exe
uintptr_t baseModule = (uintptr_t)GetModuleHandleA(NULL);
std::cout << "[+] Base Module Address: 0x" << std::hex << baseModule << "\n";
// Зміщення для AssaultCube v1.2.0.2:
uintptr_t playerBaseOffset = 0x10F4F4;
uintptr_t healthOffset = 0xF8;
// Розіменовуємо покажчик на локального гравця напряму!
// Ми використовуємо звичайні C++ покажчики, оскільки знаходимося в одному адресному просторі з грою.
uintptr_t* localPlayerPtr = (uintptr_t*)(baseModule + playerBaseOffset);
if (localPlayerPtr != nullptr && *localPlayerPtr != 0)
{
std::cout << "[+] Player structure located at: 0x" << std::hex << *localPlayerPtr << "\n";
// Отримуємо прямий покажчик на здоров'я
int* health = (int*)(*localPlayerPtr + healthOffset);
std::cout << "[+] Current health (Read directly): " << std::dec << *health << "\n";
std::cout << "[+] Modifying health internally...\n";
// Записуємо 9999 HP безпосередньо в змінну
*health = 9999;
std::cout << "[+] New health: " << *health << "\n";
}
else
{
std::cout << "[!] Could not resolve local player pointer. Enter a round first.\n";
}
std::cout << "\nPress END to eject DLL...\n";
// Очікуємо натискання клавіші END для коректного вивантаження DLL
while (!GetAsyncKeyState(VK_END))
{
Sleep(100);
}
// Закриваємо консоль та звільняємо її
fclose(f);
FreeConsole();
// Вивантажуємо нашу DLL з пам'яті гри
FreeLibraryAndExitThread((HMODULE)lpParam, 0);
return 0;
}
// Головна точка входу DLL
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
// Створюємо потік для виконання нашого коду.
// Це критично важливо, оскільки DllMain виконується під завантажувальною блокировкою (Loader Lock).
// Якщо виконувати складні операції в DllMain, процес гри зависне (Deadlock).
CreateThread(NULL, 0, InternalTrainerThread, hModule, 0, NULL);
break;
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
Порівняння: External vs. Internal
Зовнішній трейнер (C# / RPM & WPM)
- Безпека: Якщо трейнер впаде, гра продовжить працювати.
- Складність розробки: Середня. Потрібно постійно копіювати байти туди-сюди.
- Швидкість роботи: Низька. Кожен виклик
Read/WriteProcessMemoryробить перехід з User Mode в Kernel Mode і назад. - Виявлення: Легко детектується антивірусами/античітами за викликом
OpenProcessз високими правами.
Внутрішній трейнер (Injected C++ DLL)
- Безпека: Будь-яка помилка або NullPointer Exception у DLL призведе до миттєвого падіння (Crash) гри.
- Складність розробки: Вища. Потребує розуміння системних структур C++ та синхронізації потоків.
- Швидкість роботи: Максимальна. Прямий доступ до пам'яті на швидкості процесора.
- Виявлення: Може перехоплювати функції гри (Hooking), викликати оригінальні функції рушія гри, підміняти таблиці віртуальних методів (VMT Hooking).
Підсумок
P/Invoke Основи
[DllImport]— атрибут для виклику native функційCharSet.Unicode— завжди для Win32 APISetLastError = true— для обробки помилок черезMarshal.GetLastWin32Error()[LibraryImport]— сучасна альтернатива (.NET 7+) з source generation
Marshalling
- Blittable типи (int, float, IntPtr) — без конвертації
- Non-blittable (string, bool, arrays) — вимагають marshalling
[StructLayout(LayoutKind.Sequential)]— для структур[MarshalAs]— для складних типів (масиви, рядки фіксованого розміру)
SafeHandle
- Безпечне управління handles
- Critical finalizer — гарантоване звільнення
- Reference counting — захист від race conditions
- Використовуйте готові:
SafeFileHandle,SafeWaitHandle,SafeProcessHandle
Callbacks
- Делегат як function pointer
[UnmanagedFunctionPointer]— вказати calling convention- Критично: тримати посилання на делегат (поле класу) — інакше GC збере
GCHandle.Alloc— для довгоживучих callbacks
Практичні Завдання
Рівень 1: System Information Extended
Розширте приклад GetSystemInfo:
- Додайте виклик
GetComputerName()для отримання імені комп'ютера - Додайте
GetUserName()для поточного користувача - Додайте
GetTickCount64()для uptime системи (мілісекунди з останнього завантаження) - Виведіть інформацію у форматованому вигляді з кольорами
Рівень 2: File Attributes Manager
Створіть CLI утиліту для роботи з атрибутами файлів через Win32 API:
- Використайте
GetFileAttributes()таSetFileAttributes()зkernel32.dll - Підтримка атрибутів:
ReadOnly,Hidden,System,Archive - Команди:
get <file>,set <file> <attributes>,clear <file> <attributes> - Обробка помилок через
GetLastError()
Рівень 3: Global Hotkey Manager
Реалізуйте систему глобальних hotkey-ів:
- Використайте
RegisterHotKey()таUnregisterHotKey()зuser32.dll - Message loop через
GetMessage()таDispatchMessage() - Підтримка модифікаторів: Ctrl, Alt, Shift, Win
- Конфігурація hotkey-ів з JSON файлу
- Виконання команд при натисканні (запуск програм, відкриття файлів)
- Коректне завершення при Ctrl+C (звільнення всіх hotkey-ів)
Unsafe Code та Вказівники
Повний розбір unsafe коду в C# — вказівники, pointer arithmetic, fixed statement для pinning, stackalloc та Span<T>, sizeof, function pointers (C# 9+), та практичні сценарії використання для high-performance коду.
Реєстр Windows — Центральна База Конфігурації Системи
Повний розбір Windows Registry — від архітектури Hives та Keys до практичних прикладів автозапуску програм, файлових асоціацій, персоналізації системи та моніторингу змін. Теорія, API та вау-ефекти з детальними прикладами.