Share:

Управляем игровым миром с помощью C++ / C#

YPermitinв.NET

2024-05-31

#.NET

#C#

#C++

#Windows

#игры

#разработка

#трейнеры

#читы

«Вам случалось любоваться Матрицей? Ее гениальностью…»
(с) Агент Смит.

Создание простого трейнера для игры Grand Theft Auto 2 (GTA 2) на C++ и C#. Начинаем с небольшой порции теории и заканчиваем готовым приложением. Коснемся темы WinAPI и некоторых других нюансов.

Содержание

Игра не по правилам

Игры остаются со мной большую часть жизни. Все начиналось с приключений в мирах приставки NES несколько десятилетий назад. Тогда было трудно представить, как далеко этот путь может завести. На дворе 2024 год и до сих пор приключения не закончились. Считаю это прекрасным фактом.

Недавно перепрошел всю серию игр Gtand Theft Auto ,начиная с 1 части еще мира DOS и заканчивая последней выпущенной версией GTA 5. Даже прошел GTA: Vice City Stories, выпущенную только на Play Station 2. Да, я фанат GTA!

Но речь сегодня не об этом. Погружаясь в другие миры, правильно принимать те правила, на которых этот мир построен. Иначе весь процесс не будет приносить удовольствия и удовлетворения, а смысл и посыл игры вообще может быть утерян. Поэтому официально заявляю, что я против читов и хулиганства в играх, особенно в онлайн. Но иногда читы все же не являются чем-то плохим. Например, если это просто веселье в однопользовательской игре, которую Вы перепрошли уже десятки раз. Самое главное, что таким "хулиганством" Вы не мешаете другим!

Поэтому мы будем создавать программу - трейнер для игры GTA 2, которая даст нам бесконечное количество денег в игре. Но это этичный хакинг игры, так как она однопользовательская, и мы никому этим действием не будем мешать. И да, если Вы не проходили GTA 2 до этого, то крайне не рекомендую пользоваться трейнером. Пройдите игру самостоятельно без жульничества! Вы только посмотрите на этот шедевр!

Ни в коем случае не пропагандирую такую езду! Будьте аккуратны на дорогах!

Итак, мы погружаемся в мир взлома игр. Начнем с теории и коснемся внутреннего API операционной системы Windows. Далее перейдем к инструментам анализа работы игры. И уже после напишем трейнер на C++, чтобы почувствовать крупицу его мощи. И то же самое сделаем на C#.

Поехали брать виртуальный мир под контроль!

WinAPI нам поможет

Итак, наша задача написать трейнер для GTA 2. Его главной функцией будет увеличение суммы игровых денег до значения 9.999.999$ и фиксирование этого значения, чтобы наши финансы в игре стали неограниченными.

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

Мы имеем дело с операционной системой Windows. Значит, для низкоуровневой работы нам придется обратиться к WinAPI - набору базовых интерфейсов ОС Windows, позволяющих работать с возможностями операционной системы напрямую из приложений. По факту, большая часть работы приложений, написанных на разных языках, в любом случае используют WinAPI для взаимодействия с ОС. И мы не будем исключением при решении нашей задачи.

Итак, обращаясь к документации WinAPI, мы можем выяснить, что нам понадобится ряд функций:

  • GetWindowThreadProcessId - для получения идентификатора потока / процесса, создавшее окно. Оно нам понадобится, чтобы определить эти параметры для запущенной игры.
  • OpenProcess - для открытия запущенного процесса.
  • ReadProcessMemory - для чтения памяти процесса.
  • WriteProcessMemory - для записи (изменения) памяти процесса.

Не переживайте, если назначение этих функций и вообще смысл WinAPI Вам сейчас не понятен. Мы посмотрим работу этих функций ниже на практике.

Самое важное сейчас - это понять, что с помощью этих функций WinAPI мы вмешаемся в работу процесса игры и будем изменять ее память так, как нам нужно. Процессы взламывают процессы...

Прежде чем перейти непосредственно к разработке, нам нужно проанализировать работу игры и понять что именно в памяти нужно изменять. Для этого нам поможет утилита Cheat Engine.

Cheat Engine. Заглядываем внутрь игры

Cheat Engine - программа, или даже окружение, для создания модов игр или других приложений. И только для персонального использования (как гласит официальное описание)!

Конечно, решить задачу можно и без этого инструмента, но тогда будет затрачено значительно больше времени на анализ приложения. В нашем же случае в этом нет никакого смысла, поэтому используем готовый инструмент без изобретения "костылей".

Многие помнят такую программу как ArtMoney - программу для редактирования параметров в компьютерных играх для бесконечных денег, жизней и других ресурсов. То же самое можно сделать и с помощью Cheat Engine. В контексте GTA 2 мы можем проделать этот трюк в несколько шагов.

  • Запускаем игру, сворачиваем через Alt+Tab и запускаем Cheat Engine. Далее выбираем процесс игры.
  • После выбора процесса начинаем поиск значения по команде "First Scan" - в нашем случае у нас было 226$. Программа найдет множества значений у запущенной игры.
  • Возвращаемся в игру и, немного поиграв, изменяем сумму денег. Например до 256$.
  • Сворачиваем игру и выполняем повторный поиск по команде "Next Scan". В итоге будет найдено единственное значение в памяти, соответствующее прошлому и новому значению. Оно то нам и нужно!
  • Добавляем найденный адрес в памяти и значение в список адресов внизу экрана (двойным кликом).
  • Это целое значение (4 байта). Поменяем его значение и вернемся в игру посмотреть результат.
  • Ура! Теперь мы богаты!
Посмотрите все эти действия на видео ниже.

Возникает вопрос: а зачем писать дополнительно трейнер, если можно вручную делать все через Cheat Engine. Причин несколько:

  • Манипуляции с поиском и изменением значения нужно будет повторять каждый раз после перезапуска игры, потому что найденный адрес памяти со значением игровых денег будет изменяться.
  • Могут быть и более сложные манипуляции с данными игры, которые повторять вручную никто не захочет.
  • Потому что это весело! Копаться в играх намного интереснее, чем в корпоративном софте :).

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

Первое, что нужно знать - при запуске процесс игры получает некоторый начальный адрес в памяти, отталкиваясь от которого можно получать другие адреса памяти с некоторым смещением. Затем игра инициализирует внутренние структуры данных с информацией игры. В большинстве игр этот процесс относительно предсказуем, и мы можем найти указатель на нужный адрес памяти через постоянные смещения от начального адреса. Это описание очень упрощенное, но в целом отражает суть.

Как мы уже говорили, найденный нами адрес памяти для значения $$$ будет меняться каждый раз при запуске игры. Поэтому нам нужно найти стабильный указатель, который и будет тем путем, через который мы сможем контролировать наше финансовое благополучие в игре.

На помощь нам опять же приходит Cheat Engine, но в этот раз мы сделаем серию поисков значений с перезапуском процесса игры. На первом этапе найдем адрес памяти для изменения суммы игровых $$$, как это делали ранее в примере, но дополнительно сформируем карту указателей (pointmap) для дальнейшего анализа:

  • Запускаем игру и "зарабатываем" начальное значение суммы денег. Например, у нас 30$.
  • Запускаем Cheat Engine, подключаемся к процессу игры и находим возможные значения в памяти игры через "New Scan".
  • Затем в игре изменяем сумму $$$, например, на 40 и ищем адрес в памяти повторно через "Next Scan". В примере нам не повезло и мы получили два адреса в памяти. Для поиска точного адреса сначала мы изменили значение у первого найденного элемента, но сумму $$$ это никак не повлияло. Затем попробовали на втором найденном элементе и все получилось. Теперь у нас есть точный адрес в памяти для изменения значения суммы игровых денег. Для удобства задали описание для найденного адреса на "Money 1".
  • И на последнем шаге мы у найденного адреса выбрали "Generate pointmap" для создания карты указателей для найденного адреса и сохранили результат в файл. Эта информация нам будет нужна для повторного анализа процесса после перезапуска игры.
  • Закрываем игру. Но Cheat Engine оставляем запущенным!
Ниже наглядное представление всех этих действий.
Первый этап закончен, идем дальше.

На втором этапе нам нужно частично повторить действия:

  • Запускаем игру заново и зарабатываем первую сумму $$$, например, 45$.
  • Возвращаемся в Cheat Engine, подключаемся к новому процессу игры и ищем новое значение.
  • Возвращаемся в игру, изменяем сумму $$$, например, до 75$.
  • Возвращаемся в Cheat Engine, находим новый адрес памяти со значением $$$ и добавляем его в список адресов. В примере мы нашли изначально два адреса, как это происходило у нас в самом начале. Сразу выбрали второй адрес "наугад" и проверили, что именно он указывает на фактическую сумму игровых денег, изменив сумму на 666$. Сработало! Для удобства даем имя найденному адресу "Money 2".
  • Выполняем команду "Pointer scan for this address" для поиска указателей на найденны адрес (Money 2), но при этом нужно учесть ранее сохраненную карту указателей в файл. Для этого в настройках сканера выбираем "Compare results with other saved pointermap(s)" и выбираем ранее сохраненный файл "1.scandata". Программа автоматически подставит адрес "Money 1" для этого файла, который был найден на первом этапе. Это нам и нужно! Результат поиска сохраняем опять же в файл, можете назвать его как угодно.
  • В результате мы получим множество найденных указателей, почти 6.5 тысяч! Отсортируем их по возрастанию по колонке "Offset 1" и увидим, что есть два указателя с самым "коротким" (в плане количества смещений) путем до нужного значения. Добавим двойным кликом их в список адресов.
  • Предположительно у нас есть надежные указатели на значение игровых денег, которые будут сохраняться после перезапуска процесса. Но мы это проверим на 3 этапе.
  • Закрываем игру. Но Cheat Engine оставляем запущенным!
Ниже наглядное представление этих шагов.
Второй этап закончен, проверим что найденные указатели действительно работают.

На последнем третьем этапе проверим, что мы собрали надежные указатели.

  • Запускаем игру повторно и зарабатываем любую начальную сумму игровых денег.
  • Возвращаемся в Cheat Engine и переподключаемся к новому игровому процессу.
  • В добавленных указателях в списке адресов появилось значение актуальной суммы $$$ в игре, хотя процесс уже новый!
  • Повторим действие. Вернемся в игру и заработаем еще немного $$$. После свернем игру и посмотрим на значения указателей. Все корректно, значения обновились до актуальных!
  • Обновим значение одного из указателей до 777$. При этом как в самой игре, так и в обоих ранее найденных указателях мы получим новое значение.
  • Мы можем использовать оба указателя для контроля игровых финансов! Успех!
Наглядно эти действия ниже. Вы только посмотрите на сколько суровая игра, в те времена было принято ездить на капоте авто! :)
Теперь у нас есть все что нужно для создания трейнера.

Но какой из двух указателей выбрать? Ответ очень просто: любой! В нашем случае они оба рабочие. В дальнейших примерах мы будем использовать первый вида:

"GTA2.EXE" + 002649A8 + 188
Выбранный указатель
В примере с C++ это уже будет объесняно детальней.

Теперь у нас есть все данные, чтобы автоматизировать изменение денег с помощью трейнера.

Мир вращается вокруг C++

Начнем с написания трейнера на C++. Окружение для разработки у нас будет простейшее:

  • Visual Studio Code
  • Mingw-w64
  • И некоторые другие составляющие части :)
В детали погружаться не будем. Если Вам нужно настроить окружение, то проделайте все, что сказано в этой документации.

Создаем в VSCode файл main.cpp с простым содержимым.

#include <iostream>
using namespace std;
int main()
{
// 1. Находим процесс игры и получаем доступ к процессу
// 2. Находим базовый модуль и его адрес
// 3. Рассчитываем адрес указателя с учетом первого смещения
// и получаем его значение
// 4. Рассчитываем адрес указателя на значение игровых денег
// с учетом смещения
// 5. Считываем текущее значение суммы игровых денег.
// 6. Изменяем значение на 9 999 999.
// 7. Затем раз в секунду проверяем изменилось ли значение и если есть изменения,
// то возвращаем значение к 9 999 999.
// Выполняем этот цикл до тех пор, пока не будет нажата клавиша TAB.
return 0;
}

Здесь же мы сразу "набросали" план, что должна делать наша программа. Если кратко, то при запуске находит процесс игры, меняет в нем сумму денег на 9.999.999 и фиксирует это значение. Но все по порядку. Сначала найдем окно игры и получим доступ к ее процессу. Для этого нам как раз пригодятся функции WinAPI "FindWindowA", "GetWindowThreadProcessId" и "OpenProcess", о которых мы говорили в самом начале.

#include <iostream>
using namespace std;
int main()
{
// 1. Находим процесс игры и получаем доступ к процессу
// Находим окно игры
HWND hwnd = FindWindowA(NULL, "GTA2");
DWORD procID;
// Получаем идентификатор процесса
GetWindowThreadProcessId(hwnd, &procID);
// Получаем доступ к процессу. В результате получим дескриптор процесса.
HANDLE processHandle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, procID);
if(hwnd == 0x0 || processHandle == 0x0 || procID == 0)
{
cout << "Не найдено приложение "GTA 2". Процесс завершен.";
exit(0);
}
// 2. Находим базовый модуль и его адрес
// 3. Рассчитываем адрес указателя с учетом первого смещения
// и получаем его значение
// 4. Рассчитываем адрес указателя на значение игровых денег
// с учетом смещения
// 5. Считываем текущее значение суммы игровых денег.
// 6. Изменяем значение на 9 999 999.
// 7. Затем раз в секунду проверяем изменилось ли значение и если есть изменения,
// то возвращаем значение к 9 999 999.
// Выполняем этот цикл до тех пор, пока не будет нажата клавиша TAB.
return 0;
}

Теперь нужно получить базовый модуль процесса и его адрес для следующих вычислений. Теперь добавим функцию GetBaseModule для удобства, она и получит базовый модуль, из которого мы извлечем базовый адрес. В комментариях найдете более детальную информацию.

#include <iostream>
using namespace std;
HMODULE GetBaseModule(const HANDLE hProcess) {
if (hProcess == NULL)
return NULL; // Нет доступа к процессу
// Массив для сохранения списка модулей
HMODULE lphModule[1024];
// Результат вызова EnumProcessModules.
// Указывает количество байт, необходимых для сохранения всех дескрипторов модулей массива lphModule
DWORD lpcbNeeded(NULL);
// Получение списка модулей
// https://learn.microsoft.com/en-us/windows/win32/api/psapi/nf-psapi-enumprocessmodules
if (!EnumProcessModules(hProcess, lphModule, sizeof(lphModule), &lpcbNeeded))
return NULL; // Не удалось прочитать информацию о модулях
// Получение пути файла для модуля.
// В качестве базового модуля используется первый в полученном списке.
TCHAR szModName[MAX_PATH];
// https://learn.microsoft.com/ru-ru/windows/win32/api/psapi/nf-psapi-getmodulefilenameexa
if (!GetModuleFileNameEx(hProcess, lphModule[0], szModName, sizeof(szModName) / sizeof(TCHAR)))
return NULL; // Не удалось прочитать информацию о модулях
// Элемент модуля с индексом 0 практически всегда является самим исполняемым файлом,
// то есть базовым модулем процесса
return (HMODULE)lphModule[0];
}
int main()
{
// 1. Находим процесс игры и получаем доступ к процессу
// Находим окно игры
HWND hwnd = FindWindowA(NULL, "GTA2");
DWORD procID;
// Получаем идентификатор процесса
GetWindowThreadProcessId(hwnd, &procID);
// Получаем доступ к процессу. В результате получим дескриптор процесса.
HANDLE processHandle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, procID);
if(hwnd == 0x0 || processHandle == 0x0 || procID == 0)
{
cout << "Не найдено приложение "GTA 2". Процесс завершен.";
exit(0);
}
// 2. Находим базовый модуль и его адрес
HMODULE baseModule = GetBaseModule(processHandle);
ULONG_PTR baseModuleAddress = (ULONG_PTR)baseModule;
// 3. Рассчитываем адрес указателя с учетом первого смещения
// и получаем его значение
// 4. Рассчитываем адрес указателя на значение игровых денег
// с учетом смещения
// 5. Считываем текущее значение суммы игровых денег.
// 6. Изменяем значение на 9 999 999.
// 7. Затем раз в секунду проверяем изменилось ли значение и если есть изменения,
// то возвращаем значение к 9 999 999.
// Выполняем этот цикл до тех пор, пока не будет нажата клавиша TAB.
return 0;
}

Теперь самое время для вычисления итогового указателя на значение игровой валюты. Ранее мы писали, что будем использовать этот указатель:

"GTA2.EXE" + 002649A8 + 188
Адрес базового модуля у нас уже есть в переменной baseModuleAddress. Реализуем 3 и 4 шаги следующим образом.

#include <iostream>
using namespace std;
HMODULE GetBaseModule(const HANDLE hProcess) {
if (hProcess == NULL)
return NULL; // Нет доступа к процессу
// Массив для сохранения списка модулей
HMODULE lphModule[1024];
// Результат вызова EnumProcessModules.
// Указывает количество байт, необходимых для сохранения всех дескрипторов модулей массива lphModule
DWORD lpcbNeeded(NULL);
// Получение списка модулей
// https://learn.microsoft.com/en-us/windows/win32/api/psapi/nf-psapi-enumprocessmodules
if (!EnumProcessModules(hProcess, lphModule, sizeof(lphModule), &lpcbNeeded))
return NULL; // Не удалось прочитать информацию о модулях
// Получение пути файла для модуля.
// В качестве базового модуля используется первый в полученном списке.
TCHAR szModName[MAX_PATH];
// https://learn.microsoft.com/ru-ru/windows/win32/api/psapi/nf-psapi-getmodulefilenameexa
if (!GetModuleFileNameEx(hProcess, lphModule[0], szModName, sizeof(szModName) / sizeof(TCHAR)))
return NULL; // Не удалось прочитать информацию о модулях
// Элемент модуля с индексом 0 практически всегда является самим исполняемым файлом,
// то есть базовым модулем процесса
return (HMODULE)lphModule[0];
}
int main()
{
// 1. Находим процесс игры и получаем доступ к процессу
// Находим окно игры
HWND hwnd = FindWindowA(NULL, "GTA2");
DWORD procID;
// Получаем идентификатор процесса
GetWindowThreadProcessId(hwnd, &procID);
// Получаем доступ к процессу. В результате получим дескриптор процесса.
HANDLE processHandle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, procID);
if(hwnd == 0x0 || processHandle == 0x0 || procID == 0)
{
cout << "Не найдено приложение "GTA 2". Процесс завершен.";
exit(0);
}
// 2. Находим базовый модуль и его адрес
HMODULE baseModule = GetBaseModule(processHandle);
ULONG_PTR baseModuleAddress = (ULONG_PTR)baseModule;
// 3. Рассчитываем адрес указателя с учетом первого смещения
// и получаем его значение
// Это указатель "GTA2.EXE" + 002649A8
ULONG_PTR basePointerAddressWithOffset = baseModuleAddress + 0x002649A8;
// Считываем значение этого указателя
int basePointer; // 165861172
ReadProcessMemory(processHandle, (LPVOID)basePointerAddressWithOffset, &basePointer, sizeof(basePointer), 0);
// 4. Рассчитываем адрес указателя на значение игровых денег
// с учетом смещения
// Это ЗНАЧЕНИЕ указателя ("GTA2.EXE" + 002649A8), которое мы получили выше
// и добавление к нему смещения 188 (0x4E8)
ULONG_PTR moneyPointerAddress = (ULONG_PTR)basePointer + 0x4E8;
// Считываем значение полученного указателя
int moneyPointer;
ReadProcessMemory(processHandle, (LPVOID)moneyPointerAddress, &moneyPointer, sizeof(moneyPointer), 0);
// 5. Считываем текущее значение суммы игровых денег.
int currentMoney;
ReadProcessMemory(processHandle, (LPVOID)moneyPointer, &currentMoney, sizeof(currentMoney), 0);
// 6. Изменяем значение на 9 999 999.
// 7. Затем раз в секунду проверяем изменилось ли значение и если есть изменения,
// то возвращаем значение к 9 999 999.
// Выполняем этот цикл до тех пор, пока не будет нажата клавиша TAB.
return 0;
}

В этот раз мы вычислили цепочку указателей и получили финальное значение указателя на значение суммы игровых денег в переменную moneyPointer. Чтение значения указателей мы выполняли через функцию WinAPI ReadProcessMemory, которую упомянали ранее.

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

#include <iostream>
using namespace std;
HMODULE GetBaseModule(const HANDLE hProcess) {
if (hProcess == NULL)
return NULL; // Нет доступа к процессу
// Массив для сохранения списка модулей
HMODULE lphModule[1024];
// Результат вызова EnumProcessModules.
// Указывает количество байт, необходимых для сохранения всех дескрипторов модулей массива lphModule
DWORD lpcbNeeded(NULL);
// Получение списка модулей
// https://learn.microsoft.com/en-us/windows/win32/api/psapi/nf-psapi-enumprocessmodules
if (!EnumProcessModules(hProcess, lphModule, sizeof(lphModule), &lpcbNeeded))
return NULL; // Не удалось прочитать информацию о модулях
// Получение пути файла для модуля.
// В качестве базового модуля используется первый в полученном списке.
TCHAR szModName[MAX_PATH];
// https://learn.microsoft.com/ru-ru/windows/win32/api/psapi/nf-psapi-getmodulefilenameexa
if (!GetModuleFileNameEx(hProcess, lphModule[0], szModName, sizeof(szModName) / sizeof(TCHAR)))
return NULL; // Не удалось прочитать информацию о модулях
// Элемент модуля с индексом 0 практически всегда является самим исполняемым файлом,
// то есть базовым модулем процесса
return (HMODULE)lphModule[0];
}
int main()
{
// 1. Находим процесс игры и получаем доступ к процессу
// Находим окно игры
HWND hwnd = FindWindowA(NULL, "GTA2");
DWORD procID;
// Получаем идентификатор процесса
GetWindowThreadProcessId(hwnd, &procID);
// Получаем доступ к процессу. В результате получим дескриптор процесса.
HANDLE processHandle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, procID);
if(hwnd == 0x0 || processHandle == 0x0 || procID == 0)
{
cout << "Не найдено приложение "GTA 2". Процесс завершен.";
exit(0);
}
// 2. Находим базовый модуль и его адрес
HMODULE baseModule = GetBaseModule(processHandle);
ULONG_PTR baseModuleAddress = (ULONG_PTR)baseModule;
// 3. Рассчитываем адрес указателя с учетом первого смещения
// и получаем его значение
// Это указатель "GTA2.EXE" + 002649A8
ULONG_PTR basePointerAddressWithOffset = baseModuleAddress + 0x002649A8;
// Считываем значение этого указателя
int basePointer; // 165861172
ReadProcessMemory(processHandle, (LPVOID)basePointerAddressWithOffset, &basePointer, sizeof(basePointer), 0);
// 4. Рассчитываем адрес указателя на значение игровых денег
// с учетом смещения
// Это ЗНАЧЕНИЕ указателя ("GTA2.EXE" + 002649A8), которое мы получили выше
// и добавление к нему смещения 188 (0x4E8)
ULONG_PTR moneyPointerAddress = (ULONG_PTR)basePointer + 0x4E8;
// Считываем значение полученного указателя
int moneyPointer;
ReadProcessMemory(processHandle, (LPVOID)moneyPointerAddress, &moneyPointer, sizeof(moneyPointer), 0);
// 5. Считываем текущее значение суммы игровых денег.
int currentMoney;
ReadProcessMemory(processHandle, (LPVOID)moneyPointer, &currentMoney, sizeof(currentMoney), 0);
// 6. Изменяем значение на 9 999 999.
int fixMoney = 9999999;
WriteProcessMemory(processHandle, (LPVOID)moneyPointer, &fixMoney, sizeof(fixMoney), 0);
// 7. Затем раз в секунду проверяем изменилось ли значение и если есть изменения,
// то возвращаем значение к 9 999 999.
// Выполняем этот цикл до тех пор, пока не будет нажата клавиша TAB.
cout << "Для выхода нажмите TAB..." << endl;
// Сохраняем предыдущее значение денег для будущих сравнений.
// Через это значение будем определять было ли изменение $$$ в игре.
int previousMoney = currentMoney;
// Через функцию GetAsyncKeyState определяем была ли нажата указанная клавиша на момент вызова функции.
// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getasynckeystate
while(!GetAsyncKeyState(VK_TAB))
{
// Читаем текущее значение $$$ в игре
ReadProcessMemory(processHandle, (LPVOID)moneyPointer, &currentMoney, sizeof(currentMoney), 0);
// Если значение было изменено с момента последнего чтения,
// то выводим на экране текущее значение и возвращаем исходное.
if(previousMoney != currentMoney)
{
cout << "Текущая сумма денег: " << currentMoney << endl;
previousMoney = currentMoney;
WriteProcessMemory(processHandle, (LPVOID)moneyPointer, &fixMoney, sizeof(fixMoney), 0);
cout << "Сумма денег восстановлена до: " << fixMoney << endl;
}
// Ожидаем 1000 мс (1 сек).
Sleep(1000);
}
return 0;
}

На 6 шаге устанавливаем сумму игровых денег на 9.999.999$, а далее в бесконечном цикле проверяем изменение суммы денег в игре. Если сумма изменилась, то выводим новое значение на экран, а после сбрасываем его к исходному 9.999.999$. Изменение памяти выполняем с помощью функции WinAPI WriteProcessMemory, о которой мы также говорили выше.

Полный листинг программы как раз выше, можете запустить его самостоятельно! Код оставлен максимально топорным и простым, чтобы с ним можно было проще начать разбираться. Если поставить цель привести его в порядок, то нужно изменить преобразования типов, добавить дополнительные проверки при работе с функциями WinAPI и многое другое. Но преждевременными оптимизациями и улучшениями заниматься не будем, давайте лучше двигаться дальше.

Таким образом, у нас теперь бесконечная сумма денег и мы можем себя чувствовать как короли в Grand Theft Auto 2!

Эх, в жизни бы так! :)

Шаг к .NET и C#

Мы сделали трейнер на C++, и он даже работает! Но нам этого недостаточно! Мы должны сделать трейнер на C# (.NET 8, хотя и другие версии в целом тоже подойдут). Давайте рассмотрим как из C# работать с функциями WinAPI и вообще в чем плюс его использования для подобных задач.

Создадим проект консольного приложения. Тут ничего сложного.

В проекте нужно сделать две важных настройки:

  • Целевую ОС установим в Windows, ведь игра и трейнер будут работать только в этой ОС, что логично. Мы ведь нацелены на работу с WinAPI.
    Установка целевой платформы
  • Целевую платформу установим в x86. Игра GTA 2 создана для 32'битной платформы. Для корректной работы с типами наш трейнейр также будет 32'битным. Иначе могут возникнуть проблемы взаимодействия с памятью процесса игры.
    Установка целевой платформы
  • Разрешаем небезопасный код для работы с функциями WinAPI, указателями и памятью.
    Разрешаем небезопасный код

Затем создадим вспомогательный класс WinAPI для работы с функциями WinAPI ReadProcessMemory и WriteProcessMemory. В самом C# и плтформе .NET нет возможности работать с низкоуровневыми функциями и редактировть память процессов напрямую, т.к. это противоречит самой концепции упрaвляемых приложений. Но если очень хочется, то можно. Для этого в .NET есть такое понятие как небезопасный код. По факту код является небезопасным, если есть применение указателей, что не позволяет среде выполнения .NET автоматически управлять памятью, и эта задача полностью ложится на разработчика. Нас это устраивает при решении такой задачи.

Содержимое класса WinAPI ниже.

using System.Runtime.InteropServices;
namespace YPermitin.GTA2Trainer
{
public static class WinAPI
{
/// <summary>
/// Чтение памяти процесса
/// </summary>
[DllImport("Kernel32.dll")]
static extern bool ReadProcessMemory(
IntPtr hProcess,
IntPtr lpBaseAddress,
[Out] byte[] lpBuffer,
int nSize,
IntPtr lpNumberOfBytesRead
);
/// <summary>
/// Запись памяти процесса
/// </summary>
[DllImport("kernel32.dll")]
static extern bool WriteProcessMemory(
IntPtr hProcess,
IntPtr lpBaseAddress,
byte[] lpBuffer,
int size,
IntPtr lpNumberOfBytesWritten
);
/// <summary>
/// Чтение указателя из памяти процесса по указанному адресу и смещению
/// </summary>
public static IntPtr ReadPointer(nint procHandle, IntPtr address, int offset)
{
byte[] buffer = new byte[4];
ReadProcessMemory(procHandle, address + offset, buffer, buffer.Length, IntPtr.Zero);
return (IntPtr)BitConverter.ToInt32(buffer);
}
/// <summary>
/// Чтение произвольного массива байт из памяти процесса по указанному адресу и смещению
/// </summary>
public static byte[] ReadBytes(nint procHandle, IntPtr address, int bytes)
{
byte[] buffer = new byte[bytes];
ReadProcessMemory(procHandle, address, buffer, buffer.Length, IntPtr.Zero);
return buffer;
}
/// <summary>
/// Чтение целочисленного значения (4 байта) из памяти процесса по указанному адресу
/// </summary>
public static int ReadInt(nint procHandle, IntPtr address)
{
return BitConverter.ToInt32(ReadBytes(procHandle, address, 4));
}
/// <summary>
/// Запись произвольного массива байт в память процесса по указанному адресу и смещению
/// </summary>
public static bool WriteBytes(nint procHandle, IntPtr address, byte[] newBytes)
{
return WriteProcessMemory(procHandle, address, newBytes, newBytes.Length, IntPtr.Zero);
}
/// <summary>
/// Запись целочисленного значения (4 байта) в память процесса по указанному адресу
/// </summary>
public static bool WriteInt(nint procHandle, IntPtr address, int value)
{
return WriteBytes(procHandle, address, BitConverter.GetBytes(value));
}
}
}

Это статический класс с 7 методами:

  • ReadProcessMemory - чтение памяти процесса, фактически оберточный метод над одноименным методом WinAPI. Именно поэтому для него используется директива [DllImport("Kernel32.dll")].
  • WriteProcessMemory - запись памяти процесса, фактически оберточный метод над одноименным методом WinAPI. Именно поэтому для него используется директива [DllImport("Kernel32.dll")].
  • ReadPointer - вспомогательный метод для удобного чтения указателя из памяти процесса по указанному адресу и смещению.
  • ReadBytes - вспомогательный метод для удобного чтения произвольного массива байт из памяти процесса по указанному адресу и смещению.
  • ReadInt - вспомогательный метод для удобного чтения целочисленного значения (4 байта) из памяти процесса по указанному адресу.
  • WriteBytes - вспомогательный метод для удобной записи произвольного массива байт в память процесса по указанному адресу и смещению.
  • WriteInt - вспомогательный метод для удобной записи целочисленного значения (4 байта) в память процесса по указанному адресу.
Первые два метода это фактически вызов методов WinAPI, которые мы описывали выше и уже использовали в трейнере на C++. Остальные методы это лишь удобный способ манипулирования данными указателей и целочисленными значениями.

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

Структура проекта
У нас есть класс вспомогательных функций "WinAPI", о котором мы говорили чуть выше, а также класс основной программы "Program". Переходим к последнему для финальных действий.

Фактически, нам нужно воспроизвести все то, что мы делали в трейнере на С++. С учетом добавленного вспомогательного класса WinAPI, основной код программы по объему будет меньше, чем аналогичный на C++. Но в целом с учетом всех модулей приложения разница не существенная. Ниже Вы можете видеть финальную версию программы с детальными комментариями.

using System.Diagnostics;
using YPermitin.GTA2Trainer;
// 1. Получаем объект процесса, дескриптор, базовый модуль и его адрес
string procName = "GTA2";
Process proc = Process.GetProcessesByName(procName).FirstOrDefault();
if (proc == null)
{
Console.WriteLine($"Не найдено приложение {procName}. Процесс завершен.");
return;
}
nint procHandle = proc.Handle;
ProcessModule baseModule = proc.MainModule;
IntPtr baseModuleAddress = baseModule.BaseAddress;
// 2. Рассчитываем адрес указателя с учетом первого смещения
// и получаем его значение
IntPtr basePointer = WinAPI.ReadPointer(procHandle, baseModuleAddress, 0x002649A8);
// 3. Рассчитываем адрес указателя на значение игровых денег
// с учетом смещения
var currentMoneyPointer = WinAPI.ReadPointer(procHandle, basePointer, 0x4E8);
// 4. Считываем изначальное значение игровых денег и устанавливаем свое значение
var currentMoney = WinAPI.ReadInt(procHandle, currentMoneyPointer);
Console.WriteLine($"Исходное значение денег: {currentMoney}$");
int fixMoney = 9999999;
WinAPI.WriteInt(procHandle, currentMoneyPointer, fixMoney);
int previousMoney = currentMoney;
// Запускаем отслеживание игровых денег в отдельной фоновой задаче,
// которая будет активной до тех пор, пока основной поток приложения не будет закрыт.
// То есть пока пользователь не нажмет любую клавишу.
Task task = Task.Run(() =>
{
while (true)
{
// Читаем текущее значение $$$ в игре
currentMoney = WinAPI.ReadInt(procHandle, currentMoneyPointer);
// Если значение было изменено с момента последнего чтения,
// то выводим на экране текущее значение и возвращаем исходное.
if (currentMoney != previousMoney)
{
Console.WriteLine($"Текущая сумма денег: {currentMoney}$");
previousMoney = currentMoney;
WinAPI.WriteInt(procHandle, currentMoneyPointer, fixMoney);
Console.WriteLine($"Сумма денег восстановлена дог: {fixMoney}$");
}
// Ожидаем 1000 мс (1 сек).
Thread.Sleep(1000);
}
});
Console.WriteLine("Для выхода нажмите любую клавишу...");
Console.ReadKey();

При запуске получим тот же результат, что и с вариантом на C++. Но если нет разницы, то зачем использовать C#?

Плюсами использования платформы .NET (и в т.ч. C#) являются:

  • Удобное создание графического интерфейса для приложений. В отличии от C++ у .NET с технологиями WinForms, WPF, MAUI и др. намного проще, богаче и эффективнее инструментарий для создания настольных графических интерфейсов. В примерах мы ограничились консольными утилитами, т.к. для демо создавать GUI избыточно. Но чаще всего трейнеры поставляются пользователям в красивом виде, иначе ими просто не будут пользоваться.
  • Возможность создания кроссплатформенных трейнеров. Хоть мы и ориентировались на Windows, в целом можно создать кросплатформенные решения как для *.nix-игр, так и для Mac, или для Android и т.д. В коде придется учитывать специфику, но технических преград к этому нет.
  • C# проще при работе с WinAPI, хоть это может показаться и не так на первый взгляд. Достаточно сделать пару оберток, а затем использовать эти вспомогательные классы. Мы примерно так и сделали. Но, надо признать, при использовании C++ некоторые вещи работают прозрачнее.
  • В Вашем распоряжении будет вся мощь платформы .NET для удобного создания приложений, с любимым синтаксическим сахаром и отличной документацией :)
  • И другие плюсы, но зависящие от контекста задачи.

Таким образом, создание трейнеров на C# (.NET) отличный вариант.

Радуемся результату

Потрясающе! Мы создали два трейнера для Grand Theft Auto 2 и стали мультимиллионерами в этой вселенной! :)

Радуемся результату

Все это лишь самые простые шаги в направлении модерства игр, создания трейнеров, хаков и просто исследования этих потрясающих приложений. Ведь сделать еще можно так много! Почему только деньги? Можно реализовать телепортацию, неуязвимость, изменение поведения NPC и проче, прочее, прочее.

К тому же это занятие позволяет разобраться в работе операционных систем, процессов. Все, что мы делали сегодня для Windows, можно сделать и в *.nix, как уже говорилось ранее. И это еще интереснее!

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

Желаю Вам приятного времяприпровождения в играх, неугосающего интереса и отличного настроения!

А мне пора проходить The Elder Scrolls: Morrowind...

Это интересно

Y

YPermitin

.NET, TSQL, DevOps, 1C:Enterprise

Developer, just developer.

Поделиться

Другие статьи

Расширение для SQL Server. Быстро и просто. SQLCLR снова в деле
Расширение для SQL Server. Быстро и просто. SQLCLR снова в деле
Решение проблем с модулями VMware в Ubuntu 22.04
Решение проблем с модулями VMware в Ubuntu 22.04
Берем процессы под контроль в .NET
Берем процессы под контроль в .NET

Все статьи от автора: YPermitin

Copyright © 2024 Убежище инженера