Что такое Assembler
Ассемблер (язык assembler) - способ представить команды процессора в виде, доступном для чтения человеком. Когда человек пишет код на каком-либо языке, будь то C++, Rust или любой другой язык - этот код выполняется процессором не напрямую. Языки имеют множество различных конструкций и команд, которые просто невозможно было бы уместить в процессоре, поэтому весь написанный код тем или иным образом перед выполнением компилируется / интерпретируется в машинный код. Сам по себе машинный код это просто набор байтов - нулей и единиц, который понятен только процессору и не удобен для восприятия человеком. Поэтому чтобы понимать команды выполняемые процессором существует язык ассемблер.
Пример компиляции C++ и разбор ASM кода
Чтобы было понятнее можно разобрать такой пример:
#include <cstdio>
int main() {
int a;
int b;
if (scanf_s("%d %d", &a, &b) != 2) {
return 1;
}
int result = a + b;
printf("%d\n", result);
return 0;
}
После компиляции и открытия этого кода через дизассемблер IDA мы наблюдаем следующую картину:
mov edx, [rsp+38h+var_18]
lea rcx, Format ; "%d\n"
add edx, [rsp+38h+var_14]
call sub_140001020
Разумеется, это не весь код. Это только часть где выполняется сложение двух чисел и вывод результата.
Теперь давайте касательно каждого фрагмента кода.
mov edx, [rsp+38h+var_18]
Тут мы заносим значение первой переменной из стека в регистр EDX.
lea rcx, Format ; "%d\n"
Далее чтобы вызвать printf нам нужен первый аргумент - формат вывода, который заносится в регистр RCX.
add edx, [rsp+38h+var_14]
После этого мы выполняем сложение двух чисел - первое берём из регистра EDX, а второе - из стека, результат записываем в EDX.
call sub_140001020
А далее нам остаётся вызвать саму функцию printf. Аргументы в этом случае, согласно Windows x64 calling convention, передаются по текущим значениям регистров RCX (первый аргумент) и EDX (второй аргумент).
Отличие машинного кода от ассемблера
После разобранного примера мы уже знаем, что такое ассемблер, но что такое машинный код и как он выглядит? Давайте так же, на примере:
mov eax, 1
Это код на языке ассемблера, а представление в виде машинного кода будет выглядеть следующим образом:
B8 01 00 00 00
Думаю, что все согласятся с тем, что первый вариант более читаемый.
То, как выглядит код на этом языке зависит от архитектуры процессора, у каждой архитектуры свой набор инструкций и регистров. В данном примере мы имеем дело с регистрами EDX, RSP, ECX и инструкциями MOV, LEA, ADD, CALL.
В контексте x64 под ассемблером обычно подразумевается набор инструкций архитектуры x86-64, расширяющей классический x86 и используемой всеми современными десктопными и серверными процессорами компаний AMD и Intel.
Важно понимать, что ассемблер - это не язык программирования в привычном смысле, а текстовое представление того, что реально выполняется на уровне процессора. Любая высокоуровневая логика и абстракции сводятся к переходам, работе с регистрами и памятью.
Что такое архитектура x64
Предшественник x64 архитектуры это x86, эволюция произошла по причине ограниченности объёма информации, которым мог оперировать процессор. Если говорить конкретнее, то максимальный размер указателя был 32 бита, максимально адресуемая память составляла 4 GB, что по сегодняшним меркам считается смешным. Поэтому в один момент стало ясно - нужны 64-битные адреса.
Таким образом x64 архитектура расширила регистры до 64 бит, так же в неё были добавлены регистры общего назначения.
Регистры
В x64 практически вся логика строится вокруг регистров. Память используется как хранилище, но вычисления выполняются именно в регистрах.
Базовый набор регистров общего назначения выглядит следующим образом:
- rax, rbx, rcx, rdx
- rsi, rdi
- rbp, rsp
- r8–r15
Каждый из них может использоваться как целиком (64 бита), так и частично (32, 16 и 8 бит), что часто встречается в скомпилированном коде.
Некоторые регистры имеют устоявшиеся роли:
- rax - возвращаемое значение функции
- rsp - указатель стека
- rbp - базовый указатель фрейма
- rcx, rdx, r8, r9 - аргументы функций в Windows x64 calling convention
Инструкции
Набор инструкций - это совокупность всех команд, которые процессор может выполнять напрямую. Он включает инструкции для:
- арифметические и логические операции: add, sub, imul, xor, and, or
- операции перемещения данных: mov, lea, push, pop
- операции управления потоком: jmp, call, ret, jz, jnz
- побитовые операции: shl, shr, rol, ror, bt
- операции взаимодействия с процессором: nop, hlt, cli, sti
И это ещё не полный список. Если брать классический x86 без расширений то это набор где-то из ~200-250 инструкций, но эта архитектура развивалась десятилетиями и инструкции добавлялись вместе с выходом новых поколений процессоров. Например SIMD (single instruction, multiple data) - в архитектуре x64 SIMD реализован через отдельные наборы инструкций и регистров, это дополнение позволяет выполнять одну математическую операцию сразу над несколькими значениями. В один такой регистр помещается сразу несколько чисел, например четыре float или восемь int, и инструкция сложения выполнится над всеми элементами параллельно.
Нужно ли знать все инструкции? - нет, это буквально бесполезный навык для разработки читов. Нужно знать и максимально осознавать принцип выполнения кода и базовый набор инструкций, этого будет достаточно чтобы хорошо ориентироваться в ассемблере.
Стек
Стек - ключевая структура для понимания ассемблера. Это область памяти, управляемая регистром rsp, которая используется процессором и компилятором для организации выполнения функций. При вызове функции в стек помещается адрес возврата, а также могут сохраняться значения регистров и локальные переменные. Любая инструкция call неявно работает со стеком, уменьшая rsp и записывая туда адрес следующей инструкции, а ret извлекает этот адрес обратно. Именно поэтому при анализе кода так важно понимать, как меняется rsp: ошибки в работе со стеком почти всегда приводят к крашу. В скомпилированном коде стек активно используется даже там, где это не очевидно, и без понимания его устройства невозможно анализировать вызовы функций.
Его можно представить как стопку книг, чтобы взять какую-либо из неё необходимо сначала взять все, лежащие сверху. Логика связанная со стеком обычно сложнее для понимания у новичков.
Заключение
Современные декомпиляторы позволяют буквально одной кнопкой преобразовывать машинный код в приближённое представление на языке C, но без базового понимания ассемблера вы не сможете банально написать вставку и перенаправить поток управления, ни говоря уже об использовании техники Code Caves.
При разработке читов это один из необходимых навыков.