Реверс-інжиніринг і чіти

Пошук даних у пам'яті: сканування, структури, ланцюжки вказівників

Сканування значень, зміщення полів структури та ланцюжки вказівників, що переживають перезапуск гри.

Пошук даних у пам'яті: сканування, структури, ланцюжки вказівників

🎯 Мета розділу

  • Знайти адресу змінної в чужому процесі, не знаючи вихідного коду.
  • Зрозуміти алгоритм «перше сканування → зміна → повторне сканування».
  • Змінити значення і побачити результат у грі.
  • Зрозуміти, що знайдена адреса здоров'я — це початок структури гравця плюс зміщення.
  • Обчислювати зміщення полів із визначення структури та перевіряти їх у пам'яті.
  • Відновлювати розкладку невідомої структури за кількома знайденими полями.
  • Зрозуміти, чому адреса health змінюється після кожного запуску гри.
  • Знайти вказівник, який веде до структури гравця, і побудувати ланцюжок.
  • Читати ланцюжок у тренері так, щоб він працював після перезапуску.

🔑 Ключові терміни

  • Перше сканування: пошук усіх адрес із заданим значенням.
  • Наступне сканування: фільтрація попереднього списку після зміни значення.
  • Кандидат: адреса, яка досі відповідає всім зробленим перевіркам.
  • Зміщення (offset): відстань у байтах від початку структури до поля.
  • Вирівнювання (alignment): вимога розміщувати поле за адресою, кратною його розміру.
  • База об'єкта: адреса початку структури, відносно якої рахуються зміщення.
  • Динамічна адреса: адреса об'єкта, створеного під час роботи (new), змінюється між запусками.
  • Статична адреса: адреса глобальної змінної, стала відносно бази модуля.
  • Ланцюжок вказівників (pointer chain): база → читаємо вказівник → додаємо зміщення → читаємо наступний вказівник.

Сканування пам'яті: знаходимо здоров'я гравця

У грі-мішені здоров'я дорівнює 100. У процесі в сотні мегабайтів пам'яті число 100 може траплятися тисячі разів, тому одного сканування замало. Розв'язок — змінювати значення в грі й залишати лише ті адреси, які змінилися так само.

Алгоритм

Перше сканування

Шукаємо всі 4-байтові цілі, рівні поточному значенню (100).

Зміна в грі

Командою d отримуємо удар: здоров'я стає 85.

Наступне сканування

З попереднього списку лишаємо адреси, де тепер 85. Повторюємо, поки лишається кілька кандидатів або один.

Зміна значення

Записуємо за знайденою адресою число 999 і перевіряємо, що в грі HP=999.

Практика зі своїм сканером

У трьох терміналах: у першому запущена гра, у другому тренер, у третьому — обмін командами. Спочатку знаходимо PID гри у її першому рядку виводу.

trainer scan
$ sudo ./build/trainer scan 66038 100 > before.txt
знайдено: N (залежить від ОС і запуску)
# у грі: d (здоров'я стає 85)
$ sudo ./build/trainer scan 66038 85 > after.txt
$ sort before.txt after.txt | uniq -d
0x... <- адреса здоров'я

Команда uniq -d виводить адреси, що є в обох файлах: за цими адресами спершу було 100, а після удару стало 85. Наш сканер зберігає лише адреси, тому порівняння списків замінює повторне читання значень.

У першому скануванні можуть траплятися адреси з числом 100, які пов'язані з іншими даними (наприклад, armor у цього гравця ніколи не буде 100 на старті, а інші змінні в бібліотеках можуть). Повторення циклу «зміна → скан» з іншим значенням (70, 55) прибирає випадкові збіги.

Якщо після кількох ітерацій лишилася одна адреса, записуємо значення:

sudo ./build/trainer write 66038 0x... 999

У грі наступний вивід показує HP=999. Гра сама нічого не помітила: вона довіряє пам'яті.

Те саме в Cheat Engine і scanmem

  1. Відкрийте Cheat Engine, натисніть значок комп'ютера й оберіть процес target_game.exe.
  2. У полі Value введіть 100, тип 4 Bytes, натисніть First Scan.
  3. У грі отримайте удар. У Cheat Engine введіть 85 і натисніть Next Scan.
  4. Двічі клацніть знайдену адресу, щоб додати її в нижню таблицю, змініть Value на 999.
Значення з плаваючою комою (x, y) зберігаються у форматі IEEE 754, а не як цілі. Для них у сканері потрібен тип Float, а точний збіг працює рідко: використовуйте «змінилося» / «збільшилося».

Структури в пам'яті: від однієї адреси до всього гравця

У розділі про сканування знайшлась адреса health. Але гравець — це структура, в якій поруч лежать броня, координати та зброя. Якщо ми знаємо базу структури, усі її поля доступні за сталими зміщеннями.

Розкладка структури гравця

Структура гри-мішені:

struct Weapon { int32_t ammo; int32_t magazine; float damage; };

struct Player {
    char    name[16];   // +0
    int32_t health;     // +16
    int32_t armor;      // +20
    float   x;          // +24
    float   y;          // +28
    Weapon  weapon;     // +32
};

Компілятор розміщує поля одне за одним, додаючи порожні байти для вирівнювання. У цій структурі всі поля чотирибайтові, тому проміжків нема.

Якщо health знайдено за адресою 0x10215da80, база гравця дорівнює 0x10215da80 − 16 = 0x10215da70, а armor лежить на 0x10215da84. У гру-мішень вбудовано перевірку static_assert(offsetof(Player, health) == 16 ...), тож зміщення в тренері й у цьому розділі збігаються зі збіркою.

Перевірка в налагоджувачі

Зміщення ми розрахували за кодом. У реверс-інжинірингу коду немає, тому розкладку перевіряють у пам'яті. Дамп 48 байтів від бази гравця в LLDB:

lldb: memory read --size 4 --count 12 --format d
0x10215da80: 100 // health (+16)
0x10215da84: 50 // armor (+20)
0x10215da88: 1092616192 // x = 10.0f, біти як ціле
0x10215da8c: 1101004800 // y = 20.0f
0x10215da90: 30 // weapon.ammo (+32)
0x10215da94: 30 // weapon.magazine
0x10215da98: 1103626240 // weapon.damage = 25.0f

Числа 1092616192 і 1101004800 — це біти float, показані як ціле. Тому поруч зі health легко побачити пару «кілька малих цілих, потім великі числа»: великі числа свідчать про координати.

У LLDB формат вказується як --format f, щоб побачити float: memory read --size 4 --count 2 --format f 0x10215da88. У GDB — x/2f. У Cheat Engine — «Dissect data/structures».

Як відновити невідому структуру

Знайти одне поле

Скануванням знаходимо health (розділ «Сканування пам'яті»). Це перша точка.

Знайти сусідні поля

Дивимось у дамп навколо: броня зазвичай поряд зі здоров'ям, далі координати, зброя. Змінюємо в грі броню або набої й спостерігаємо, яке число поряд змінилося.

Записати розкладку

Складаємо таблицю «зміщення → призначення → тип». У реальних іграх вона росте поступово, і її зберігають у власній struct з порожніми полями-заповнювачами (char pad[0x40]).

Перевірити на іншому об'єкті

Знаходимо другого гравця (ворога) і перевіряємо, що з тими ж зміщеннями отримуємо розумні значення.

Різні версії гри мають різні зміщення. Розробник додав поле — усі наступні змістилися. Тому тренери зазвичай перевіряють версію гри або шукають шаблон байтів, а не фіксовану адресу.

Ланцюжки вказівників: як знайти гравця після перезапуску

Після перезапуску гри health опиняється за іншою адресою, і збережена адреса вказує на сміття. Але десь у пам'яті лежить вказівник на структуру Player, і сам цей вказівник зберігається у Game, який, у свою чергу, доступний за сталим шляхом. Задача — знайти цей шлях.

Пошук вказівника на структуру

Ми знаємо базу гравця: health − 16. Вказівник на гравця — це 8 байтів, значення яких дорівнює цій адресі. Тому шукаємо в пам'яті 8-байтове число Player*.

Обчислити базу

Адреса health мінус зміщення (16) дає базу гравця, наприклад 0x10215da70.

Знайти вказівники на базу

Сканування з типом 8 bytes і значенням бази. У Cheat Engine — шестнадцяткове значення, у scanmem — 0x10215da70. Знайдена адреса — поле Game::player.

Піднятися на рівень вище

База об'єкта Game = адреса знайденого поля мінус 8 (зміщення player у Game). Шукаємо вказівник на неї, поки не дійдемо до адреси всередині самого виконуваного файлу.

Запам'ятати ланцюжок

Записуємо: [база_модуля + X] + 0x8 → Player*, Player* + 0x10 → health.

У нашій грі-мішені Game теж створюється через new, тому його вказівника не існує в глобальних даних. Це повторює ситуацію в реальних іграх, де ланцюжок часто закінчується глобальною змінною localPlayer. Щоб відтворити такий кінець ланцюжка, використаємо адресу Game*, яку гра друкує з параметром --show-addr, і розглянемо, що з нею робить тренер.

Тренер із ланцюжком

Команда chain приймає PID і адресу Game*, читає вказівник Player* і виводить поля. Вона працює правильно після перезапуску гри, якщо передати нову Game*:

uintptr_t player = 0;
p.read_value(game + OFF_GAME_PLAYER, player);   // Player* із Game::player
int32_t hp;
p.read_value(player + OFF_HEALTH, hp);          // health

Ключовий момент — вказівник читається щоразу заново: тренер не запам'ятовує абсолютну адресу гравця, а проходить ланцюжок. Режим god робить це в циклі кожні 50 мс:

trainer god
$ sudo ./build/trainer god 66038 0x10215da60
# у грі після команди d: HP=999 (тренер перезаписує значення кожні 50 мс)
У команді god адреса Game* береться з виводу --show-addr — це підказка, що замінює крок «піднятися до статичної адреси». У реальній грі ланцюжок закінчується в секції даних виконуваного файлу, і тоді замість абсолютної адреси використовують база_модуля + зміщення.

Базова адреса модуля

Статична адреса записується як module+offset, бо сам модуль завантажується за випадковою базою (ASLR).

Перелічити модулі процесу: CreateToolhelp32Snapshot з TH32CS_SNAPMODULE, поле modBaseAddr для потрібного .exe. Cheat Engine показує такі адреси як game.exe+17E0A8.

Коли ланцюжок «рветься»

  • Гравець створюється заново після смерті або переходу на нову мапу, його Player* змінюється. Ланцюжок усе одно коректний, якщо читати вказівник щоразу.
  • Ланцюжок веде до тимчасового об'єкта (наприклад, буфера) і ламається після кадру. Лікує повторне сканування з іншої ситуації та порівняння знайдених ланцюжків: правильний повторюється.
  • Оновлення гри змінило зміщення. Ланцюжок знаходять заново за описаним вище алгоритмом.
Copyright © 2026