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

Аналіз коду: асемблер, налагоджувач, патчинг

Читання x86-64, пошук інструкції, що змінює значення, і патчинг коду.

Аналіз коду: асемблер, налагоджувач, патчинг

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

  • Читати прості фрагменти x86-64 асемблера (синтаксис Intel).
  • Бачити в інструкції [rax+0x10] доступ до поля структури за зміщенням.
  • Пов'язувати рядок C++ з його асемблерним відповідником.
  • Зрозуміти, що таке апаратна точка зупину (hardware watchpoint) на запис.
  • Знайти інструкцію, яка змінює health, у налагоджувачі своєї ОС.
  • Відрізнити потрібне спрацювання від випадкових (системні бібліотеки, ініціалізація).
  • Змінити поведінку гри, замінивши байти інструкції, а не значення даних.
  • Розрізняти NOP-патч, зміну константи та інверсію переходу.
  • Врахувати права сторінок коду на Windows, Linux і macOS.

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

  • Регістр: комірка всередині процесора (rax, rcx, rbp, rsp).
  • Інструкція: одна команда процесора (mov, sub, cmp, jge).
  • Операнд: значення, з яким працює інструкція: регістр, число або адреса [...].
  • Точка зупину (breakpoint): зупинка процесу на заданій інструкції.
  • Watchpoint: зупинка, коли хтось читає чи пише задану адресу.
  • Крок (step): виконання однієї інструкції.
  • Патч (patch): заміна байтів машинного коду.
  • NOP: інструкція «нічого не робити», на x86 це байт 0x90.
  • Опкод (opcode): байти, що кодують інструкцію.

Асемблер для реверсу: регістри, mov, sub, cmp, jmp

Сканер пам'яті знаходить дані, але відповісти на питання «хто зменшує здоров'я?» може лише код. Код у виконуваному файлі — це машинні команди, а дизасемблер показує їх як асемблер. Читати його потрібно мінімум, щоб знайти потрібну інструкцію.

Рядок C++ і його асемблер

У грі-мішені удар виконується рядком:

p.health -= 15;

Компілятор, зібраний для x86-64 без оптимізацій, перетворює його на чотири інструкції:

mov rax, qword ptr [rbp - 0x58]   ; rax = адреса локальної змінної p (Player*)
mov ecx, dword ptr [rax + 0x10]   ; ecx = p->health  (зміщення 0x10 = 16)
sub ecx, 0xf                      ; ecx -= 15
mov dword ptr [rax + 0x10], ecx   ; p->health = ecx

Це реальний вивід clang -arch x86_64 -O0 + objdump -d -M intel для main.cpp. Конструкція [rax + 0x10] — це «пам'ять за адресою rax плюс 16». Саме тут проявляється структура з розділу «Структури в пам'яті»: rax містить базу гравця, 0x10 — зміщення health.

Mac з Apple Silicon (arm64) і телефони використовують іншу архітектуру ARM64, де інструкції називаються ldr, str, sub. Принцип той самий. Для AssaultCube і більшості ігор для ПК значення має x86/x86-64.

Регістри

РегістрТипове призначення
rax, rcx, rdx, rbxзагального призначення; eax, ecx — їхні молодші 32 біти
rsi, rdiзагального призначення; часто аргументи функцій
rbpбаза кадру стека (локальні змінні [rbp - X])
rspвершина стека
ripадреса наступної інструкції

dword ptr означає розмір 4 байти, qword ptr — 8 байтів, byte ptr — 1 байт. Розмір визначає тип поля: dword — int, qword — вказівник.

Інструкції, які трапляються найчастіше

ІнструкціяДіяC++
mov a, bкопіювати b у aa = b
add a, b / sub a, bдодати / віднятиa += b / a -= b
lea a, [x+y]порахувати адресу, не читаючи пам'ятьa = &arr[i]
cmp a, bпорівняти, встановити прапориa ? b
jmp, je, jne, jge, jlперехід (безумовний / за прапорами)if, while
call, retвиклик функції та поверненняf()
nopнічого не робити (1 байт, 0x90)—

Умовний перехід на прикладі

Рядок if (p.health < 0) p.health = 0; у тому ж виводі має вигляд:

cmp dword ptr [rax + 0x10], 0x0   ; порівняти health з 0
jge 0x265                         ; якщо health >= 0, перейти далі
mov rax, qword ptr [rbp - 0x58]
mov dword ptr [rax + 0x10], 0x0   ; health = 0

Умова в C++ — «менше нуля», а перехід у коді — «більше або дорівнює»: компілятор інвертує умову, бо перехід оминає блок.

Не потрібно знати всі інструкції. Дізнайтеся про незнайому в довіднику (Intel SDM або felixcloutier.com/x86) і шукайте в коді шаблони: доступ [reg+offset], пара cmp+jcc, call.

Як отримати дизасемблер

clang++ -arch x86_64 -c -O0 main.cpp -o main.o     # на Linux: g++ -c -O0 -o main.o
objdump -d -M intel main.o | less

Для вже зібраного бінарника: objdump -d -M intel build/target_game.

Оптимізований код (-O2) виглядає інакше: змінні живуть у регістрах, рядки C++ змішуються, sub може перетворитися на lea. Реальні ігри збираються з оптимізацією, тому спершу шукайте відомі зміщення (+0x10), а не слідуйте рядкам вихідного коду.

Налагоджувач: хто змінює здоров'я

Зовнішній тренер перезаписує значення, але гра все одно його змінює: ми лише випереджаємо її кожні 50 мс. Щоб змінити саму логіку («удар не знімає здоров'я»), потрібно знайти інструкцію, що виконує віднімання. Для цього налагоджувач зупиняє гру в момент запису за відомою адресою.

Як це працює

Процесор має спеціальні регістри налагодження: до чотирьох адрес, звернення до яких зупиняє виконання. Це апаратна підтримка, тому гра працює без уповільнення, а зупинка настає рівно на інструкції, що виконала запис. Налагоджувач показує її адресу, регістри і дизасемблер навколо.

Покроково на грі-мішені

Знайти адресу

Скануванням (розділ «Сканування пам'яті») знайдіть адресу health, наприклад 0x...a80.

Встановити watchpoint на запис

Налагоджувач підключається до процесу й просить зупинитися при записі в ці 4 байти.

Спровокувати запис

У грі введіть d (удар). Налагоджувач зупиняє процес.

Прочитати інструкцію

Дивимось дизасемблер біля rip — наприклад mov [rax+0x10], ecx. Зміщення 0x10 збігається з нашою розкладкою.

Піднятися в код

Дивимось кілька інструкцій вище: бачимо sub ecx, 0xf, тобто віднімання 15.

Команди

lldb
(lldb) process attach --pid 66038
(lldb) watchpoint set expression -w write -s 4 -- 0x104e89a80
(lldb) continue
# у грі: d
(lldb) disassemble -p -c 8        # інструкція, на якій зупинилися
(lldb) register read              # регістри

Інструкція, що спричинила зупинку, лежить у target_game, а не в libsystem.

Перші спрацювання можуть бути не від потрібної команди. У наших експериментах на macOS watchpoint спрацьовував у системній _platform_memset (звільнення/обнулення пам'яті), а не в коді гри. Дивіться на модуль у назві кадру: якщо це не target_game, виконайте continue і повторіть дію в грі. На Apple Silicon процесор arm64, тому замість mov/sub ви побачите ldr/sub/str, при цьому логіка та сама.

Що вчитися бачити

На x86-64 із розділу «Асемблер для реверсу» запис виглядає так:

mov ecx, dword ptr [rax + 0x10]
sub ecx, 0xf                       ; ця інструкція знімає 15 здоров'я
mov dword ptr [rax + 0x10], ecx    ; на цій watchpoint зупиниться

Watchpoint зупиняється після запису, тому rip вказує на наступну інструкцію. Інструкція запису — на один рядок вище.

Корисні питання до знайденої інструкції:

  • Скільки різних місць у коді пишуть у цю адресу? Одне — це спільна функція «отримати шкоду», кілька — окремі випадки.
  • Чи ця сама інструкція пише і гравцеві, і ворогам? Якщо так, зміна коду вплине на всіх (розділ «Патчинг коду»).
  • Що лежить у регістрі бази (rax)? Це поточна структура: гравець чи ворог.

Патчинг коду: NOP, зміна переходів і безсмертя без циклу запису

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

Три способи змінити поведінку

СпосібПрикладРезультат
NOPзамінити sub ecx, 0xf на nop × кількість байтіввіднімання зникає
Змінити константуsub ecx, 0xf → sub ecx, 0x0шкода дорівнює 0
Інвертувати перехідjne → jeумова виконується навпаки

Для sub ecx, 0xf машинний код — 83 E9 0F (три байти). Замінимо їх на 90 90 90. Довжина має збігатися: якщо записати менше байтів, залишок зіпсує наступну інструкцію, а якщо більше — затре сусідню.

Перш ніж міняти, перевірте довжину інструкції у дизасемблері (стовпець байтів). Помилка в довжині дає крах гри, а іноді й тихе пошкодження даних.

Крок за кроком

Знайти інструкцію

За розділом «Налагоджувач»: sub ecx, 0xf за адресою в модулі гри.

Записати її байти

Дизасемблер показує опкод (83 E9 0F). Запишіть його, щоб відновити за потреби.

Записати NOP

Тренер пише 90 90 90 за адресою інструкції. Після цього гра не змінює health.

Відновити

Записати назад 83 E9 0F.

Запис у сторінки коду

Код живе в сторінках з правом виконання, але без запису. Перед записом права потрібно змінити.

DWORD old;
VirtualProtectEx(h, (LPVOID)addr, n, PAGE_EXECUTE_READWRITE, &old);
WriteProcessMemory(h, (LPVOID)addr, bytes, n, nullptr);
VirtualProtectEx(h, (LPVOID)addr, n, old, &old);

У репозиторії Process::write для Windows і macOS уже виконує зміну прав. На Linux використовуйте /proc/<pid>/mem.

На macOS підписаний код має захист цілісності: сторінки коду підписаного бінарника не можна змінити, якщо процес запущений із hardened runtime. Для власних збірок без підпису це не діє, а для будь-якої Apple Silicon-програми потрібна додаткова обережність (див. розділ «Захист»).

Недоліки патча

  • Патч діє на всіх, хто виконує цей код: якщо та сама інструкція зменшує здоров'я ворогам, вони теж стануть безсмертними. Рішення: умовний перехід — «якщо база дорівнює гравцеві, пропустити віднімання». Це потребує code cave — вільного місця для власного коду, і розглянуто в internal-варіанті (розділ «Internal-чіт»).
  • Оновлення гри змінює адресу і байти. Тому тренери шукають код за шаблоном байтів (signature scan): 83 E9 ?? 89 48 10, де ?? — довільний байт.
  • Самоперевірка цілісності гри виявить зміну.
Copyright © 2026