До того как заняться реверс-инжинирингом, исполняемые файлы казались мне черной магией. Я всегда интересовался, как все работает под капотом, как двоичный код представлен внутри .exe файлов, и насколько сложно модифицировать “исполняемый код” без доступа к исходникам.
Но одним из главных препятствий был язык ассемблера, отпугивающий большинство людей от изучения этой области.
Это главная причина, по которой я задумался о написании этой статьи, содержащей только самые важные вещи, с которыми чаще всего сталкиваются в реверс-инжиниринге, хотя она и упускает некоторые детали для краткости, предполагая, что у читателя имеются навыки поиска определений и ответов в Интернете и, что более важно, придумывания примеров / идей / проектов для практики.
Цель состоит в том, чтобы направить начинающего реверс-инженера и вдохновить на изучение этого, казалось бы, труднопостижимого увлечения.
Примечание: предполагается, что читатель обладает элементарными знаниями о шестнадцатеричной системе счисления, а также о языке программирования С. В качестве примера используется 32-разрядный исполняемый файл Windows — результаты могут отличаться на других ОС/архитектурах.
Вступление
Компиляция
Код, написанный на компилируемом языке, компилируется (еще бы) в выходной двоичный файл (например, exe. файл).

Это действие выполняют сложные программы, компиляторы. Они проверяют корректность синтаксиса вашего кода, прежде чем компилировать и оптимизировать получившийся машинный код путем минимизации его размера и повышения производительности, ког��а это применимо.
Двоичный код
Как мы уже говорили, результирующий выходной файл содержит двоичный код, понятный для CPU. По сути, это последовательность инструкций различной длины, которые должны выполняться по порядку — вот так выглядят некоторые из них:

Преимущественно, это арифметические инструкции. Они манипулируют регистрами/флагами CPU, а также энергозависимой памятью по мере выполнения.
Регистры процессора
Регистр CPU чем-то похож на временную целочисленную переменную — они имеются в небольшом фиксированном количестве. В отличие от переменных на основе памяти, к ним можно быстро получить доступ. Регистры помогают CPU отслеживать данные (результаты, операнды, счетчики и т. д.) во время исполнения.
Важно отметить наличие специального регистра, называемого FLAGS (EFLAGS в 32-битном формате), в котором находится набор флагов (логических индикаторов), содержащих информацию о состоянии процессора, включая сведения о последней арифметической операции (ноль: ZF; переполнение: OF; четность: PF; знак: SF и т. д.).

Некоторые из этих регистров представлены во фрагменте, приведенном выше, а именно: EAX, ESP (указатель стека), EBP (базовый указатель).
Доступ к памяти
Когда CPU что-то выполняет, ему необходимо обращаться к памяти и взаимодействовать с ней. Вот тут-то и вступают в игру стек и куча.
Стек
Более простая и быстрая из двух сущностей — это линейная непрерывная структура данных LIFO (последний вошел = первый вышел) с механизмом push/pop. Служит для хранения локальных переменных, аргументов функций и отслеживания вызовов (слышали когда-либо о трассировке стека?)
Куча
Куча довольно не упорядочена и предназначена для более сложных структур данных. Обычно используется для динамического выделения памяти, когда размер буфера изначально неизвестен, и/или если он слишком большой, и/или объем должен быть изменен в будущем.
Инструкции ассемблера
Как я упоминал ранее, ассемблерные инструкции имеют разный «размер в байтах» и различное количество операндов.
Операндами могут быть либо непосредственные значения (значение указывается прямо в команде), либо регистры, в зависимости от инструкции:
55 push ebp ; size: 1 byte, argument: register 6A 01 push 1 ; size: 2 bytes, argument: immediate
Давайте быстро пробежимся по очень небольшому набору некоторых из наиболее употребляемых команд — не стесняйтесь самостоятельно изучать для получения более подробной информации:
Стековые операции
push
value; помещает значение в стек (ESPуменьшается на 4, размер одной «единицы» стека).pop
register; помещает значение в регистр (ESPувеличивается на 4).
Передача данных
mov
destination,source; копирует значение из/в регистр.mov
destination, [expression]; копирует значение из памяти по адресу, получаемому из ‘регистрового выражения’ (одиночный регистр или арифметическое выражение, содержащее один или больше регистров) в регистр.
Поток выполнения
jmp
destination; переходит к команде по адресу (устанавливаетEIP(указатель инструкций)).jz/je
destination; переходит к команде по адресу, если установленZF(нулевой флаг).jnz/jne
destination; переходит к команде по адресу, еслиZFне установлен.
Операции
сmp
operand1,operand2; сравнивает 2 операнда и устанавливаетZF, если они равны.add
operand1,operand2; операнд1 += операнд2.sub
operand1,operand2; операнд1 -= операнд2.
Переходы функций
call
function; вызывает функцию (помещает текущее значениеEIPв стек, затем переходит в функцию).retn; возврат в вызываемую функцию (извлекает из стека предыдущее значение
EIP).
Примечание: вы могли заметить, что слова «равно» и «ноль» взаимозаменяемы в терминологии x86 — это из-за того, что инструкции сравнения внутренне выполняют вычита��ие, которое означает, что, если два операнда равны, то устанавливает ZF.
Шаблоны в ассемблере
Теперь, когда у нас есть приблизительное представление об основных элементах, используемых во время выполнения программы, давайте познакомимся с шаблонами инструкций, с которыми можно столкнуться при реверс-инжиниринге обычного 32-битного двоичного файла PE.
Пролог функции
Пролог функции — это некоторый код, внедренный в начало большинства функций и служащий для установки нового стекового кадра указанной функции.
Обычно он выглядит так (X — число):
55 push ebp ; preserve caller function's base pointer in stack 8B EC mov ebp, esp ; caller function's stack pointer becomes base pointer (new stack frame) 83 EC XX sub esp, X ; adjust the stack pointer by X bytes to reserve space for local variables
Эпилог функции
Эпилог — это просто противоположность пролога. Он отменяет его шаги для восстановления стека вызывающей функции, прежде чем вернуться к ней:
8B E5 mov esp, ebp ; restore caller function's stack pointer (current base pointer) 5D pop ebp ; restore base pointer from the stack C3 retn ; return to caller function
Теперь может возникнуть вопрос — как функции взаимодействуют друг с другом? Как именно вы отправляете/обращаетесь к аргументам при вызове функций, и как вы получаете возвращаемое значение? Именно для этого существуют соглашения о вызовах.
Соглашения о вызовах: __cdecl
Соглашение о вызове — это протокол для взаимодействия функций, есть разные варианты, но все они используют общий принцип.
Мы рассмотрим соглашение __cdecl (от C declaration), которое является стандартным при компиляции кода С.
В __cdecl (32-bit) аргументы функции передаются в стек (помещаются в обратном порядке), а возвращаемое значение передается через EAX регистр (при условии, что это не число с плавающей точкой).
Это означает, что при вызове func(1, 2, 3) будет сгенерировано следующее:
6A 03 push 3 6A 02 push 2 6A 01 push 1 E8 XX XX XX XX call func
Собираем все вместе
Предположим, что func() просто складывает аргументы и возвращает результат. Вероятно, это будет выглядеть так:
int __cdecl func(int, int, int): prologue: 55 push ebp ; save base pointer 8B EC mov ebp, esp ; new stack frame body: 8B 45 08 mov eax, [ebp+8] ; load first argument to EAX (return value) 03 45 0C add eax, [ebp+0Ch] ; add 2nd argument 03 45 10 add eax, [ebp+10h] ; add 3rd argument epilogue: 5D pop ebp ; restore base pointer C3 retn ; return to caller
Теперь, если вы внимательно за всем следили и все еще в замешательстве, можете задать себе один из двух вопросов:
Почему мы должны сместить
EBPна 8, чтобы получить первый аргумент?Если вы проверите определение инструкции
call, упоминаемой ранее, то поймете, что внутренне она помещает значениеEIPв стек. И если вы проверите определение командыpush, то обнаружите, что она уменьшает значениеESP(которое скопировано вEBPв прологе) на 4 байта. К тому же, первая инструкция пролога — это такжеpush, поэтому получаем 2 декремента по 4, следовательно, необходимо добавить 8.Что случилось с прологом и эпилогом, почему они кажутся «усеченными»?
Это просто потому, что мы не использовали стек во время выполнения нашей функции — если вы заметили,
ESPвообще не изменялся, а это значит, что нам не нужно его восстанавливать.
Условный оператор
Чтобы продемонстрировать ассемблерные инструкции управления потоком выполнения, я бы хотел добавить еще один пример, иллюстрирующий, во что скомпилируется оператор if в ассемблере.
Предположим, у нас есть следующая функция:
void print_equal(int a, int b) { if (a == b) { printf("equal"); } else { printf("nah"); } }
После ее компиляции вот дизассемблированный вид, который я получил с помощью IDA:
void __cdecl print_equal(int, int): 10000000 55 push ebp 10000001 8B EC mov ebp, esp 10000003 8B 45 08 mov eax, [ebp+8] ; load 1st argument 10000006 3B 45 0C cmp eax, [ebp+0Ch] ; compare it with 2nd ┌┅ 10000009 75 0F jnz short loc_1000001A ; jump if not equal ┊ 1000000B 68 94 67 00 10 push offset aEqual ; "equal" ┊ 10000010 E8 DB F8 FF FF call _printf ┊ 10000015 83 C4 04 add esp, 4 ┌─┊─ 10000018 EB 0D jmp short loc_10000027 │ ┊ │ └ loc_1000001A: │ 1000001A 68 9C 67 00 10 push offset aNah ; "nah" │ 1000001F E8 CC F8 FF FF call _printf │ 10000024 83 C4 04 add esp, 4 │ └── loc_10000027: 10000027 5D pop ebp 10000028 C3 retn
Дайте себе минутку и попытайтесь разобраться в этом дизассемблированном коде (для простоты, я изменил реальные адреса и сделал начало функции с 10000000).
В случае, если вам интересно, зачем нужна команда add esp, 4, то это просто приведение ESP к исходному значению (такой же эффект, что и у pop, только без изменения какого-либо регистра), поскольку у нас есть push строкового аргумента для printf.
Базовые структуры данных
Давайте двигаться дальше. Поговорим о том, как хранятся данные (особенно целые числа и строки).
Endianness
Endianness — это порядок байтов, представляющих значение в памяти компьютера.
Есть 2 типа: big-endian и little-endian

Для справки, процессоры семейства x86 (которые есть практически на любом компьютере) всегда используют little-endian.
Чтобы привести живой пример этой концепции, я скомпилировал консольное приложение на С++ в Visual Studio, в котором объявил переменную int со значением 1337, а затем вывел адрес переменной, используя функцию printf().
После я запустил программу с отладчиком, чтобы проверить напечатанный адрес переменной в шестнадцатеричном представлении памяти, и вот результат, который я получил:

Уточним этот момент — переменная int имеет длину 4 байта (32 бита) (на случай, если вы не знали), поэтому это означает, что если переменная начинается с адреса D2FCB8, то она заканчивается прямо перед D2FCBC (+4).
Чтобы перейти от значения, удобочитаемого человеком, к байтам памяти, выполните следующие действия:
Десятичное: 1337 -> шестнадцатеричное: 539 -> 00 00 05 39 -> little-endian: 39 05 00 00
Знаковые целые числа
Это часть интересна, но относительно проста. Здесь вы должны знать, что представление знака у целых чисел (положительных/отрицательных) обычно выполняется на компьютерах с помощью концепции, называемой дополнением до двух.
Суть в том, что наименьшая/первая половина целых чисел зарезервирована для положительных чисел, а наибольшая/вторая половина предназначена для отрицательных, вот как это выглядит в шестнадцатеричном формате для 32-битного знакового int (выделено = шестнадцатеричный формат, в скобках = десятичный):
Положительные (1/2): 00000000 (0) -> 7FFFFFFF (2 147 483 647 или INT_MAX)
Отрицательные (2/2): 80000000 (-2 137 483 648 или INT_MIN) -> FFFFFFFF (-1)
Если вы заметили, значения у нас всегда возрастают. Независимо от того, поднимемся ли мы в шестнадцатеричном или десятичном формате. И это ключевой момент этой концепции — арифметические операции не должны делать ничего особенного для обработки знака, они могут просто работать со всеми значениями как с беззнаковыми/положительными, и результат все равно будет интерпретироваться правильно (если мы будем в пределах INT_MAX и INT_MIN). Так происходит потому, что целые числа будут ‘переворачиваться’ при переполнении по принципу, схожему с аналоговым одометром.

Совет: калькулятор Windows — очень полезный инструмент. Вы можете войти в режим программиста и установить размер в DWORD (4 байт��), затем ввести отрицательные десятичные значения и визуализировать их в шестнадцатеричном и двоичном формате, получая удовольствие от выполнения операций с ними.

Строки
В C строки хранятся в виде массивов char, поэтому здесь нет ничего особенного, кроме того, что называется null termination.
Если вы когда-нибудь задумывались, как strlen() узнает размер строки, то все очень просто — строки имеют символ, обозначающий их конец, и это нулевой байт/символ — 00 или ‘\0’.
Если вы объявите строковую константу в C и наведете на нее курсор, например, в Visual Studio, то покажется размер сгенерированного массива, и, как можете видеть, в нем на один элемент больше, чем в видимом размере строки.

Примечание: концепция порядка байтов не применима к массивам, только к одиночным переменным. Следовательно, порядок символов в памяти здесь будет нормальным.
Смысл call и jmp инструкций
Теперь, когда вы знаете все это, то, вероятно, уже можете начать разбираться в каком-то машинном коде и в некоторой степени, так сказать, имитировать центральный процессор своим мозгом.
Возьмем пример с print_equal(), но на этот раз сосредоточимся только на инструкциях call printf().
void print_equal(int, int): ... 10000010 E8 DB F8 FF FF call _printf ... 1000001F E8 CC F8 FF FF call _printf
Вы можете спросить себя — подождите секунду, если это одни и те же инструкции, то почему их байты разные?
Это потому, что инструкции call (и jmp) (обычно) принимают в качестве операнда смещение (относительный адрес), а не абсолютный адрес.
Смещение — это в основном разница между текущим местоположением в памяти и пунктом назначения. Это также означает, что оно может быть как отрицательным, так и положительным.
Как вы можете видеть, опкод инструкции call, содержащей 32-битное смещение, — это E8. После него следует само смещение — полная инструкция имеет вид: E8 XX XX XX XX.
Вытащите свой калькулятор (почему вы закрыли его так рано?!) и вычислите разницу между смещением обеих инструкций (не забывайте о порядке байтов).
Вы заметите, что это разница такая же, как разница между адресами инструкций (10000001F — 10000010 = F):

Еще одна небольшая деталь, о которой нужно упомянуть — процессор выполняет инструкцию только после ее полного «чтения». Это означает, что к тому времени, когда CPU начинает «выполнение», EIP (указатель инструкций) уже указывает на следующую команду.
Смещения учитывают такое поведение, а это означает, что для получения реального адреса целевой функции мы также должны добавить размер инструкции call: 5.
Теперь применим все это, чтобы узнать адрес printf() из первой инструкции в примере:
10000010 E8 DB F8 FF FF call _printf
Извлеките смещение из инструкции
E8 (DB F8 FF FF)->FFFFF8D8(-1829)Сложите его с адресом инструкции:
100000010+FFFFF8D8=0FFFF8EBИ наконец, прибавьте размер инструкции:
0FFFF8EB+5=OFFFF8F0(&printf)
Точно такой же принцип применяется к инструкции jmp:
... ┌─── 10000018 EB 0D jmp short loc_10000027 ... └── loc_10000027: 10000027 5D pop ebp ...
Единственное отличие в этом примере состоит в том, что EB XX — это короткая версия jmp. Это означает, что в ней используется только 8-битное (1 байт) смещение.
Следовательно: 10000018 + 0D + 2 = 10000027
Вывод
Вот и все! Теперь у вас должно быть достаточно информации (и, надеюсь, мотивации), чтобы начать свое путешествие в реверс-инжиниринге исполняемых файлов.
Начните с написания игрушечного кода на С, его компиляции и пошаговой отладки (Visual Studio, кстати, позволяет это сделать).
Compiler Explorer также является чрезвычайно полезным веб-сайтом, который компилирует код C в ассемблер в реальном времени с использованием нескольких компиляторов (выберите компилятор x86 msvc для 32-разрядной версии Windows).
После этого вы можете попытать счастья с собственным двоичными файлами с закрытым исходным кодом с помощью дизассемблеров, таких как Ghidra и IDA, и отладчиков, таких как x64dbg.
Дата-центр ITSOFT — размещение и аренда серверов и стоек в двух дата-центрах в Москве. UPTIME 100%. Размещение GPU-ферм и ASIC-майнеров, аренда GPU-серверов, лицензии связи, SSL-сертификаты, администрирование серверов и поддержка сайтов.