До того как заняться реверс-инжинирингом, исполняемые файлы казались мне черной магией. Я всегда интересовался, как все работает под капотом, как двоичный код представлен внутри .exe файлов, и насколько сложно модифицировать “исполняемый код” без доступа к исходникам.

Но одним из главных препятствий был язык ассемблера, отпугивающий большинство людей от изучения этой области.

Это главная причина, по которой я задумался о написании этой статьи, содержащей только самые важные вещи, с которыми чаще всего сталкиваются в реверс-инжиниринге, хотя она и упускает некоторые детали для краткости, предполагая, что у читателя имеются навыки поиска определений и ответов в Интернете и, что более важно, придумывания примеров / идей / проектов для практики.

Цель состоит в том, чтобы направить начинающего реверс-инженера и вдохновить на изучение этого, казалось бы, труднопостижимого увлечения.

Примечание: предполагается, что читатель обладает элементарными знаниями о шестнадцатеричной системе счисления, а также о языке программирования С. В качестве примера используется 32-разрядный исполняемый файл Windows — результаты могут отличаться на других ОС/архитектурах.

Вступление

Компиляция

Код, написанный на компилируемом языке, компилируется (еще бы) в выходной двоичный файл (например, exe. файл).

Это действие выполняют сложные программы, компиляторы. Они проверяют корректность синтаксиса вашего кода, прежде чем компилировать и оптимизировать получившийся машинный код путем минимизации его размера и повышения производительности, ког��а это применимо.

Двоичный код

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

Преимущественно, это арифметические инструкции. Они манипулируют регистрами/флагами CPU, а также энергозависимой памятью по мере выполнения.

Регистры процессора

Регистр CPU чем-то похож на временную целочисленную переменную — они имеются в небольшом фиксированном количестве. В отличие от переменных на основе памяти, к ним можно быстро получить доступ. Регистры помогают CPU отслеживать данные (результаты, операнды, счетчики и т. д.) во время исполнения.

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

Регистры CPU, визуализированные при отладке 32-разрядного процесса в инструменте отладки x64dbg.
Регистры CPU, визуализированные при отладке 32-разрядного процесса в инструменте отладки x64dbg.

Некоторые из этих регистров представлены во фрагменте, приведенном выше, а именно: 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

Теперь, если вы внимательно за всем следили и все еще в замешательстве, можете задать себе один из двух вопросов:

  1. Почему мы должны сместить EBP на 8, чтобы получить первый аргумент?

    Если вы проверите определение инструкции call, упоминаемой ранее, то поймете, что внутренне она помещает значение EIP в стек. И если вы проверите определение команды push, то обнаружите, что она уменьшает значение ESP (которое скопировано в EBP в прологе) на 4 байта. К тому же, первая инструкция пролога — это также push, поэтому получаем 2 декремента по 4, следовательно, необходимо добавить 8.

  2. Что случилось с прологом и эпилогом, почему они кажутся «усеченными»?

    Это просто потому, что мы не использовали стек во время выполнения нашей функции — если вы заметили, 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

Вы можете спросить себя — подождите секунду, если это одни и те же инструкции, то почему их байты разные?

Это потому, что инструкции calljmp) (обычно) принимают в качестве операнда смещение (относительный адрес), а не абсолютный адрес.

Смещение — это в основном разница между текущим местоположением в памяти и пунктом назначения. Это также означает, что оно может быть как отрицательным, так и положительным.

Как вы можете видеть, опкод инструкции call, содержащей 32-битное смещение, — это E8. После него следует само смещение — полная инструкция имеет вид: E8 XX XX XX XX.

Вытащите свой калькулятор (почему вы закрыли его так рано?!) и вычислите разницу между смещением обеих инструкций (не забывайте о порядке байтов).

Вы заметите, что это разница такая же, как разница между адресами инструкций (10000001F10000010 = F):

Еще одна небольшая деталь, о которой нужно упомянуть — процессор выполняет инструкцию только после ее полного «чтения». Это означает, что к тому времени, когда CPU начинает «выполнение», EIP (указатель инструкций) уже указывает на следующую команду.

Смещения учитывают такое поведение, а это означает, что для получения реального адреса целевой функции мы также должны добавить размер инструкции call: 5.

Теперь применим все это, чтобы узнать адрес printf() из первой инструкции в примере:

10000010   E8 DB F8 FF FF    call   _printf
  1. Извлеките смещение из инструкции E8 (DB F8 FF FF) -> FFFFF8D8 (-1829)

  2. Сложите его с адресом инструкции: 100000010 + FFFFF8D8 = 0FFFF8EB

  3. И наконец, прибавьте размер инструкции: 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-сертификаты, администрирование серверов и поддержка сайтов.