До того как заняться реверс-инжинирингом, исполняемые файлы казались мне черной магией. Я всегда интересовался, как все работает под капотом, как двоичный код представлен внутри .exe файлов, и насколько сложно модифицировать “исполняемый код” без доступа к исходникам. Но одним из главных препятствий был язык ассемблера, отпугивающий большинство людей от изучения этой области. Это главная причина, по которой я задумался о написании этой статьи, содержащей только самые важные вещи, с которыми чаще всего сталкиваются в реверс-инжиниринге, хотя она и упускает некоторые детали для краткости, предполагая, что у читателя имеются навыки поиска определений и ответов в Интернете и, что более важно, придумывания примеров / идей / проектов для практики. Цель состоит в том, чтобы направить начинающего реверс-инженера и вдохновить на изучение этого, казалось бы, труднопостижимого увлечения. Примечание: предполагается, что читатель обладает элементарными знаниями о шестнадцатеричной системе счисления, а также о языке программирования С. В качестве примера используется 32-разрядный исполняемый файл Windows — результаты могут отличаться на других ОС/архитектурах. Код, написанный на компилируемом языке, компилируется (еще бы) в выходной двоичный файл (например, exe. файл).   Это действие выполняют сложные программы, компиляторы. Они проверяют корректность синтаксиса вашего кода, прежде чем компилировать и оптимизировать получившийся машинный код путем минимизации его размера и повышения производительности, когда это применимо.   Как мы уже говорили, результирующий выходной файл содержит двоичный код, понятный для CPU. По сути, это последовательность инструкций различной длины, которые должны выполняться по порядку — вот так выглядят некоторые из них:   Преимущественно, это арифметические инструкции. Они манипулируют регистрами/флагами CPU, а также энергозависимой памятью по мере выполнения.   Регистр CPU чем-то похож на временную целочисленную переменную — они имеются в небольшом фиксированном количестве. В отличие от переменных на основе памяти, к ним можно быстро получить доступ. Регистры помогают CPU отслеживать данные (результаты, операнды, счетчики и т. д.) во время исполнения.   Важно отметить наличие специального регистра, называемого FLAGS (EFLAGS в 32-битном формате), в котором находится набор флагов (логических индикаторов), содержащих информацию о состоянии процессора, включая сведения о последней арифметической операции (ноль: ZF; переполнение: OF; четность: PF; знак: SF и т. д.). Некоторые из этих регистров представлены во фрагменте, приведенном выше, а именно: EAX, ESP (указатель стека), EBP (базовый указатель). Когда CPU что-то выполняет, ему необходимо обращаться к памяти и взаимодействовать с ней. Вот тут-то и вступают в игру стек и куча.   Стек Более простая и быстрая из двух сущностей — это линейная непрерывная структура данных LIFO (последний вошел = первый вышел) с механизмом push/pop. Служит для хранения локальных переменных, аргументов функций и отслеживания вызовов (слышали когда-либо о трассировке стека?) Куча Куча довольно не упорядочена и предназначена для более сложных структур данных. Обычно используется для динамического выделения памяти, когда размер буфера изначально неизвестен, и/или если он слишком большой, и/или объем должен быть изменен в будущем. Как я упоминал ранее, ассемблерные инструкции имеют разный «размер в байтах» и различное количество операндов.   Операндами могут быть либо непосредственные значения (значение указывается прямо в команде), либо регистры, в зависимости от инструкции: Давайте быстро пробежимся по очень небольшому набору некоторых из наиболее употребляемых команд — не стесняйтесь самостоятельно изучать для получения более подробной информации: Стековые операции 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 — число): Эпилог — это просто противоположность пролога. Он отменяет его шаги для восстановления стека вызывающей функции, прежде чем вернуться к ней: Теперь может возникнуть вопрос — как функции взаимодействуют друг с другом? Как именно вы отправляете/обращаетесь к аргументам при вызове функций, и как вы получаете возвращаемое значение? Именно для этого существуют соглашения о вызовах. Соглашение о вызове — это протокол для взаимодействия функций, есть разные варианты, но все они используют общий принцип.   Мы рассмотрим соглашение __cdecl (от C declaration), которое является стандартным при компиляции кода С. В __cdecl (32-bit) аргументы функции передаются в стек (помещаются в обратном порядке), а возвращаемое значение передается через EAX регистр (при условии, что это не число с плавающей точкой). Это означает, что при вызове func(1, 2, 3) будет сгенерировано следующее: Предположим, что func() просто складывает аргументы и возвращает результат. Вероятно, это будет выглядеть так:   Теперь, если вы внимательно за всем следили и все еще в замешательстве, можете задать себе один из двух вопросов: Почему мы должны сместить EBP на 8, чтобы получить первый аргумент? Если вы проверите определение инструкции call, упоминаемой ранее, то поймете, что внутренне она помещает значение EIP в стек. И если вы проверите определение команды push, то обнаружите, что она уменьшает значение ESP (которое скопировано в EBP в прологе) на 4 байта. К тому же, первая инструкция пролога — это также push, поэтому получаем 2 декремента по 4, следовательно, необходимо добавить 8. Что случилось с прологом и эпилогом, почему они кажутся «усеченными»? Это просто потому, что мы не использовали стек во время выполнения нашей функции — если вы заметили, ESP вообще не изменялся, а это значит, что нам не нужно его восстанавливать. Чтобы продемонстрировать ассемблерные инструкции управления потоком выполнения, я бы хотел добавить еще один пример, иллюстрирующий, во что скомпилируется оператор if в ассемблере.   Предположим, у нас есть следующая функция: После ее компиляции вот дизассемблированный вид, который я получил с помощью IDA: Дайте себе минутку и попытайтесь разобраться в этом дизассемблированном коде (для простоты, я изменил реальные адреса и сделал начало функции с 10000000). В случае, если вам интересно, зачем нужна команда add esp, 4, то это просто приведение ESP к исходному значению (такой же эффект, что и у pop, только без изменения какого-либо регистра), поскольку у нас есть push строкового аргумента для printf. Давайте двигаться дальше. Поговорим о том, как хранятся данные (особенно целые числа и строки).   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, то покажется размер сгенерированного массива, и, как можете видеть, в нем на один элемент больше, чем в видимом размере строки. Примечание: концепция порядка байтов не применима к массивам, только к одиночным переменным. Следовательно, порядок символов в памяти здесь будет нормальным.   Теперь, когда вы знаете все это, то, вероятно, уже можете начать разбираться в каком-то машинном коде и в некоторой степени, так сказать, имитировать центральный процессор своим мозгом.   Возьмем пример с print_equal(), но на этот раз сосредоточимся только на инструкциях call printf(). Вы можете спросить себя — подождите секунду, если это одни и те же инструкции, то почему их байты разные? Это потому, что инструкции call (и jmp) (обычно) принимают в качестве операнда смещение (относительный адрес), а не абсолютный адрес. Смещение — это в основном разница между текущим местоположением в памяти и пунктом назначения. Это также означает, что оно может быть как отрицательным, так и положительным. Как вы можете видеть, опкод инструкции call, содержащей 32-битное смещение, — это E8. После него следует само смещение — полная инструкция имеет вид: E8 XX XX XX XX. Вытащите свой калькулятор (почему вы закрыли его так рано?!) и вычислите разницу между смещением обеих инструкций (не забывайте о порядке байтов). Вы заметите, что это разница такая же, как разница между адресами инструкций (10000001F — 10000010 = F): Еще одна небольшая деталь, о которой нужно упомянуть — процессор выполняет инструкцию только после ее полного «чтения». Это означает, что к тому времени, когда CPU начинает «выполнение», EIP (указатель инструкций) уже указывает на следующую команду.   Смещения учитывают такое поведение, а это означает, что для получения реального адреса целевой функции мы также должны добавить размер инструкции call: 5. Теперь применим все это, чтобы узнать адрес printf() из первой инструкции в примере: Извлеките смещение из инструкции E8 (DB F8 FF FF) -> FFFFF8D8 (-1829) Сложите его с адресом инструкции: 100000010 + FFFFF8D8 = 0FFFF8EB И наконец, прибавьте размер инструкции: 0FFFF8EB + 5 = OFFFF8F0 (&printf) Точно такой же принцип применяется к инструкции jmp:   Единственное отличие в этом примере состоит в том, что 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-сертификаты, администрирование серверов и поддержка сайтов.