Тренер і internal-модуль на C++
Тренер і internal-модуль на C++
🎯 Мета розділу
- Зрозуміти будову тренера: прикладна логіка окремо від системних викликів.
- Прочитати код
Processдля Windows, Linux і macOS. - Запустити сканування, читання, запис і режим
godу грі-мішені. - Зрозуміти, чим internal-модуль відрізняється від зовнішнього тренера.
- Завантажити власну динамічну бібліотеку в гру на Windows, Linux та macOS.
- Знайти структуру гравця зсередини процесу за сигнатурою.
🔑 Ключові терміни
- Зовнішній (external) тренер: окрема програма, яка працює з пам'яттю гри через виклики ОС.
- Абстракція: спільний інтерфейс, за яким ховаються різні реалізації.
- Internal-модуль: код, що виконується в адресному просторі гри.
- DLL /
.so/.dylib: динамічна бібліотека Windows / Linux / macOS. - Інжекція: завантаження бібліотеки в чужий процес.
- Сигнатура: впізнаваний набір байтів, за яким знаходимо структуру чи код.
Зовнішній тренер на C++: один код для трьох ОС
Тренер виконує три дії: отримує доступ до процесу, читає або пише байти, обходить області пам'яті. На кожній ОС вони виконуються різними викликами, тому код розділено: клас Process має однаковий інтерфейс, а в трьох файлах process_win.cpp, process_linux.cpp, process_mac.cpp лежать реалізації. CMake компілює всі три, а директиви #if defined(...) залишають активним лише відповідний.
Структура проєкту
Повний код — у labs/reverse-engineering/trainer/. Файл main.cpp не містить жодного системного виклику: він працює лише через Process.
Сканер
Для кожної записуваної області тренер зчитує її цілком у буфер і проходить по ньому з кроком 4 байти. Крок у 4 байти використовується, бо int32_t зазвичай вирівняний за адресою, кратною 4.
for (const Region& r : p.writable_regions()) {
buf.resize(r.size);
if (!p.read(r.start, buf.data(), r.size)) continue;
for (size_t i = 0; i + 4 <= r.size; i += 4) {
int32_t v;
std::memcpy(&v, &buf[i], 4);
if (v == value) printf("0x%llx\n", r.start + i);
}
}
memcpy замість приведення вказівника потрібен, щоб уникнути порушення вирівнювання та правил псевдонімів. Читання за один виклик на область швидше, ніж по 4 байти.
Команди
| Команда | Дія |
|---|---|
trainer scan <pid> <int> | вивести всі адреси зі значенням |
trainer read <pid> <addr> [float] | прочитати значення |
trainer write <pid> <addr> <int> | записати ціле |
trainer chain <pid> <Game*> | пройти ланцюжок і показати HP, броню, набої |
trainer god <pid> <Game*> | кожні 50 мс виставляти HP=999 і набої=99 |
Запуск на трьох ОС
Запустіть термінал від адміністратора, якщо гра запущена від адміністратора. Інакше достатньо звичайного.
trainer.exe scan 12345 100
./build/trainer scan $(pgrep target_game) 100
Якщо отримано помилку доступу, перевірте ptrace_scope (розділ «Процеси, віртуальна пам'ять і доступ до чужого процесу») або використайте sudo.
sudo ./build/trainer scan $(pgrep target_game) 100
Без sudo буде повідомлення task_for_pid не вдався.
god змінює значення кожні 50 мс. Гра покаже HP=999, поки тренер працює. Зупинка тренера (Ctrl+C) повертає звичайну поведінку, але змінені значення залишаються до наступного оновлення.armor <pid> <Game*>, який ставить броню 100 один раз. Використайте OFF_ARMOR.if (cmd == "armor" && argc >= 4) {
uintptr_t game = parse_addr(argv[3]), player = 0;
if (!resolve_player(p, game, player)) return 3;
return p.write_value<int32_t>(player + OFF_ARMOR, 100) ? 0 : 3;
}
Internal-чіт: бібліотека всередині процесу гри
Зовнішній тренер звертається до гри через виклики ОС, кожне читання проходить через ядро. Бібліотека, завантажена в процес, живе в тому самому адресному просторі й бачить пам'ять гри напряму. Вона може читати вказівники, викликати функції гри та міняти код без ReadProcessMemory.
| External | Internal | |
|---|---|---|
| Де виконується | окремий процес | всередині гри |
| Швидкість доступу | виклик ядра на кожне читання | прямий доступ до пам'яті |
| Виклик функцій гри | складно | прямо за адресою |
| Складність запуску | проста | потрібна інжекція |
| Легше виявити | аналізом дескрипторів процесу | аналізом списку модулів |
Як бібліотека потрапляє в процес
Змінна LD_PRELOAD змушує завантажувач підвантажити бібліотеку першою:
LD_PRELOAD=$PWD/internal/build/libinternal.so ./target-game/build/target_game
Аналог — DYLD_INSERT_LIBRARIES:
DYLD_INSERT_LIBRARIES=$PWD/internal/build/libinternal.dylib ./target-game/build/target_game
Працює для власних збірок без hardened runtime. Системні програми та підписані застосунки зі SIP/hardened runtime ігнорують змінну.
Запущеному процесу бібліотеку підвантажує інжектор: він виділяє пам'ять у грі, записує шлях до DLL і створює віддалений потік, що викликає LoadLibraryA.
HANDLE h = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
void* remote = VirtualAllocEx(h, nullptr, len, MEM_COMMIT, PAGE_READWRITE);
WriteProcessMemory(h, remote, dllPath, len, nullptr);
CreateRemoteThread(h, nullptr, 0,
(LPTHREAD_START_ROUTINE)GetProcAddress(GetModuleHandleA("kernel32.dll"), "LoadLibraryA"),
remote, 0, nullptr);
Після завантаження система викликає DllMain з DLL_PROCESS_ATTACH.
Код цього модуля на Windows не перевірявся на цій машині: інжектор написаний за документацією WinAPI.
Код модуля
Модуль викликається під час завантаження (конструктор у Linux/macOS, DllMain у Windows) і запускає окремий потік. Потім потік знаходить гравця й підтримує значення.
__attribute__((constructor)) static void on_load() { start(); } // Linux, macOS
// Windows: DllMain -> case DLL_PROCESS_ATTACH -> start();
Через std::thread робота виконується не в потоці завантажувача: DllMain на Windows не повинен чекати. Для зв'язку з пам'яттю гри використано той самий клас Process, але для власного PID, тож код однаковий для всіх ОС.
Пошук гравця за сигнатурою
Ланцюжок вказівників (розділ «Ланцюжки вказівників») потребує відомої адреси Game*. Зсередини процесу простіше знайти структуру за її вмістом: ім'я Hero, потім health == 100 на зміщенні 16 та armor == 50 на зміщенні 20.
for (size_t i = 0; i + 36 <= r.size; i += 16) {
if (std::memcmp(&buf[i], "Hero\0", 5) != 0) continue;
int32_t hp, armor;
std::memcpy(&hp, &buf[i + 16], 4);
std::memcpy(&armor, &buf[i + 20], 4);
if (hp == 100 && armor == 50) return r.start + i;
}
Крок 16 використано, бо new вирівнює об'єкти за 16 байтів. Сигнатура має бути унікальною; одного імені мало, тому додано перевірку значень.
Результат на macOS
Збірка бібліотеки та запуск гри з нею:
Вивід отримано на macOS (Apple Silicon) зі збіркою гри, зробленою за інструкцією з розділу «Інструменти». Адреса гравця щоразу інша.
struct Player з правильними зміщеннями й працювати з нею напряму. У нашому коді використано Process для єдиного коду на трьох ОС. Для реальних проєктів Player* p = reinterpret_cast<Player*>(addr) — стандартний підхід.health, коли значення стало менше 100.int32_t hp = 0;
self.read_value(player + 16, hp);
if (hp < 100) self.write_value<int32_t>(player + 16, 100);