Аналіз коду: асемблер, налагоджувач, патчинг
Аналіз коду: асемблер, налагоджувач, патчинг
🎯 Мета розділу
- Читати прості фрагменти 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.
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 у a | a = 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++ — «менше нуля», а перехід у коді — «більше або дорівнює»: компілятор інвертує умову, бо перехід оминає блок.
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.
target_game.exe у x64dbg: вкладка CPU показує дизасемблер у синтаксисі Intel. Або імпортуйте файл у Ghidra.-O2) виглядає інакше: змінні живуть у регістрах, рядки C++ змішуються, sub може перетворитися на lea. Реальні ігри збираються з оптимізацією, тому спершу шукайте відомі зміщення (+0x10), а не слідуйте рядкам вихідного коду.куди, звідки, в AT&T — навпаки (звідки, куди), регістри мають префікс %. У курсі використовується Intel, його показують x64dbg, Ghidra і IDA.mov eax, dword ptr [rcx + 0x20]
dec eax
mov dword ptr [rcx + 0x20], eax
0x20 (32) структури, на яку вказує rcx, зменшує на 1 і записує назад. У грі-мішені це --p.weapon.ammo: зміщення 32 — це кількість набоїв.Налагоджувач: хто змінює здоров'я
Зовнішній тренер перезаписує значення, але гра все одно його змінює: ми лише випереджаємо її кожні 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.
gdb -p $(pgrep target_game)
(gdb) watch *(int*)0x555555a80
(gdb) continue
# у грі: d
(gdb) x/8i $pc-12
(gdb) info registers
- Файл → Attach, оберіть процес.
- У вкладці Dump перейдіть до адреси
health(Ctrl+G). - ПКМ по першому байту → Breakpoint → Hardware, Write → Dword.
- Продовжте (
F9), в грі виконайтеd.
_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)? Це поточна структура: гравець чи ворог.
int3. Дані не виконуються, тому на них потрібні апаратні регістри налагодження. Їх лише чотири, і кожен покриває до 8 байтів.IsDebuggerPresent, ptrace(PT_DENY_ATTACH) на macOS, читання /proc/self/status). Антічіти також. У нашій грі-мішені і AssaultCube таких перевірок нема.Патчинг коду: 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_vm_writev не пише в сторінки без права запису.
// Для патчу код пишуть через /proc/<pid>/mem (відкритий для запису від того ж користувача/ root)
int fd = open("/proc/PID/mem", O_RDWR);
pwrite(fd, bytes, n, addr);
mach_vm_protect(task, addr, n, FALSE, VM_PROT_READ | VM_PROT_WRITE | VM_PROT_COPY);
mach_vm_write(task, addr, (vm_offset_t)bytes, n);
mach_vm_protect(task, addr, n, FALSE, VM_PROT_READ | VM_PROT_EXECUTE);
У репозиторії Process::write для Windows і macOS уже виконує зміну прав. На Linux використовуйте /proc/<pid>/mem.
Недоліки патча
- Патч діє на всіх, хто виконує цей код: якщо та сама інструкція зменшує здоров'я ворогам, вони теж стануть безсмертними. Рішення: умовний перехід — «якщо база дорівнює гравцеві, пропустити віднімання». Це потребує code cave — вільного місця для власного коду, і розглянуто в internal-варіанті (розділ «Internal-чіт»).
- Оновлення гри змінює адресу і байти. Тому тренери шукають код за шаблоном байтів (signature scan):
83 E9 ?? 89 48 10, де??— довільний байт. - Самоперевірка цілісності гри виявить зміну.
0x90 — це xchg eax, eax, обмін регістра із самим собою, що нічого не змінює. Його вибрали як однобайтовий «нічого». На ARM64 NOP — чотирибайтова 1F 20 03 D5.d не змінює HP, а команда h лікування працює.sub лежить у гілці cmd == "d" функції main. Опкод залежить від компілятора: беріть його з вашого дизасемблера, а не з цього розділу. Після патча p.health залишається 100, а m і s працюють як раніше.