§7.5*. Память, Си и ассемблер

Мы завершим тему реверс-инжиниринга, немного рассказав о том, как программы исполняет непосредственно процессор, как их данные располагаются в памяти и как понимать и менять их логику. Это сложная тема, но мы постараемся рассказать всё очень понятно.

Настоящие программы

В предыдущей главе мы писали программы на языке Python, которые интерпретируются, т.е. выполняются строка за строкой отдельной программой — интерпретатором. Следующими по сложности идут виртуальные машины, например, Java, которой мы коснулись в предыдущем параграфе. Наконец, код на таких языках программирования как C++, Rust или Haskell, компилируется (преобразуется отдельной программой) в машинный код — код, который может исполнять непосредственно процессор. Самый известный изык этого типа — язык Си, созданный для написания ядра операционной системы Unix. Давайте разберём простую программу на этом языке и поговорим о том, как она выполняется после компиляции.

Для тех, кто не встречался с ним ранее: язык Си очень прост и не имеет многих привычных современным разработчикам понятий, таких как классы, или даже встроенных структур данных. При этом во многом он близок по своему уровню к машинному, предоставляя лишь ограниченную функциональность сверх него. Иногда его так и называют, «кроссплатформенный ассемблер» (намекая на то что, в отличие от ассемблера, код на Си может быть собран для разных процессоров).

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

/* Подключим библиотеку для работы с терминалом и файлами. */
#include <stdio.h>

/* Главная функция, с которой начинается выполнение программы,
 * всегда называется main. Она возвращает число: 0, если программа
 * выполнена успешно, а если нет — какое-нибудь другое.
 */
int main()
{
    /* Объявим две числовые переменные, a и b. */
    int a, b;
    /* Считаем из терминала два числа через пробел
     * и сохраним их в переменные a и b.
     * %i здесь означает «число».
     */
    int read_number = scanf("%i %i", &a, &b);
    /* Если не получилось считать два числа, вернуть ошибку. */
    if (read_number != 2)
        return 1;

    /* Сложим два числа. */
    int result = a + b;

    /* Выведем на экран результат. */
    printf("Sum: %i\n", result);

    /* Вернуть успех. */
    return 0;
}

Как можно увидеть, в каком-то смысле код на Си похож на своих более продвинутых собратьев — здесь тоже есть функции, переменные и их типы, оператор if и другие конструкции, похожие на знакомые нам. Если скомпилировать этот код, например, программой gcc, то мы получим исполняемый файл. Его можно запустить в терминале и получить результат, например:

$ gcc -o sum sum.c
$ ./sum
13 37
Sum: 50
$

Здесь sum.c — это файл с исходным кодом на Си, а параметром -o sum мы указываем название исполняемого файла, который будет создан. Мы указываем ./ перед sum, чтобы получился путь к исполняемому файлу — это обязательно для запуска.

Мы не будем изучать Си подробно, наша цель на этот раз — понять основы того, как выполняется машинный код. Для этого давайте напишем программу чуть сложнее:

#include <stdio.h>

// Функция, принимающая и возвращающая число.
int fibonacci(int n)
{
  if (n == 0) {
    return 0;
  } else if (n == 1) {
    return 1;
  } else {
    int a = fibonacci(n - 1);
    int b = fibonacci(n - 2);
    return a + b;
  }
}

// Глобальная переменная.
int number = 4;

int main()
{
  int res = fibonacci(number);
  printf("Result: %i\n", res);
  return 0;
}

Здесь мы вычисляем элемент последовательности Фибоначчи — классическая задача, которой можно проиллюстрировать работу процессора. Вы можете скомпилировать программу, запустить и посмотреть на результат. Нас же интересует, как она выглядит после сборки, и как она выполняется. Для этого мы подробнее поговорим про ассемблер — текстовое представление машинного кода. На ассемблере пишутся некоторые модули операционных систем и те части программ, над которыми необходим особый контроль (в основном, ради производительности).

Язык процессора

Язык ассемблера — самое низкоуровневое представление программы, при этом читаемое человеком как текст. Ниже него уровнем — только сам машинный код, последовательность бинарных опкодов, понятных процессору команд, прочитать которые можно только пользуясь таблицами соответствия. На самом деле ассемблер и есть почти что текстовое представление опкодов — например, инструкция MOV для процессоров на основе x86 в зависимости от её операндов (аргументов) может транслироваться в опкоды 0x89, 0x8B и некоторые другие, и такая трансляция полностью обратима. Любую скомпилированную программу можно дизассемблировать, то есть превратить машинный код обратно в код на ассемблере.

Процессор — в принципе несложное устройство, но дьявол, как всегда, кроется в деталях. Процессор состоит из устройства управления (подсистемы, следящей за выполнением программы и загружающей код для выполнения), арифметико-логического устройства (АЛУ; вычислительного модуля, выполняющего операции сложения, вычитания, логических операций и так далее) и регистров (внутренней памяти процессора). Регистры отличаются от оперативной памяти тем, что каждый регистр хранит единственное значение. Например, регистр EAX хранит одно 32-битное целое число. Количество регистров в современных процессорах достигает сотен.

Кроме того, доступные регистры и другие особенности кода различаются в зависимости от битности — максимального размера числа, которым может оперировать процессор в одном регистре. Для простоты мы будем изучать 32-битный режим, то есть максимальный размер в регистре числа, которым оперирует процессор, составляет 32 бита, или 4 байта. 32-битных регистров общего назначения восемь: EAX, EBX, ECX, EDX, ESI, EDI, ESP и EBP, плюс регистр EIP, который хранит адрес в памяти следующей исполняемой инструкции. Не нужно запоминать все эти регистры и их особенности сразу. Пока будем считать, что все из них, кроме ESP, EBP и EIP, равнозначны, и их можно свободно использовать для своих нужд.

Продолжим про инструкции. В современных процессорах большое количество инструкций, предназначенных для выполнения самых разных операций. Например, современные процессоры могут выполнять тригонометрические операции, имеют встроенный генератор случайных чисел и даже могут шифровать данные алгоритмом AES. Количество операндов в инструкциях меняется от нуля до трёх, но большинство инструкций имеют один или два операнда.

Мы начнём с арифметических инструкций и инструкций перемещения данных между регистрами. Напомним, регистр — это своеобразная ячейка памяти, в которую помещается одно число заданного размера. Вся работа с данными внутри процессора организуется через регистры. Например, нельзя сложить два числа в памяти. Вместо этого одно из этих чисел нужно загрузить в регистр (например, EAX), прибавить к нему второе (можно из памяти), переместить результат назад в память. Код на ассемблере будет выглядеть так:

mov eax, [variable1]
add eax, [variable2]
mov [result], eax

Давайте разберём этот код по строкам. Сначала используется инструкция MOV (англ. move — переместить). Несмотря на своё название, она не перемещает, а копирует значения. Инструкция принимает в качестве первого операнда цель, а в качестве второго — источник. Копировать можно как между регистрами, так и между памятью и регистром (но не между двумя позициями в памяти). С помощью неё мы загружаем в регистр EAX значение из памяти по адресу variable1 (про наименования адресов мы поговорим позже, пока лишь нужно запомнить, что операнд, выделенный квадратные скобки, означает «взять данные по этому адресу в памяти»).

Затем используется инструкция ADD (англ. добавить). Она принимает два операнда — аккумулятор и снова источник. Инструкция выполняет арифметическое сложение — прибавляет к значению в аккумуляторе число из источника. Таким образом, после её выполнения значение в EAX увеличится на значение, расположенное по адресу памяти variable2. Необходимо также упомянуть инструкцию SUB (от англ. subtract — вычесть), которая выполняет противоположное от ADD действие — вычитает из первого операнда второй.

Наконец, финальная инструкция MOV копирует результат из регистра назад в память.

Ещё одна важная инструкция — CMP. Эта инструкция принимает два операнда и сравнивает их значения. Если они равны, CMP выставляет флаг ZF в регистре EFLAGS. Это — специальный регистр, в котором хранятся флаги (размером в 1 бит каждый), сообщающие подробности о результатах выполнения предыдущих инструкций. Флаг ZF (англ. zero flag — нулевой флаг) выставляется в двух случаях: если результат предыдущей арифметической операции равен нулю, или, как уже описано, — как возможный результат работы CMP. Например, следующая команда всегда будет выставлять флаг ZF, так как любой регистр равен самому себе:

cmp eax, eax

Груды и прыжки

Теперь давайте рассмотрим инструкции работы со стеком. Стек — это область памяти, предназначенная для хранения аргументов функции, локальных переменных, а также адресов возврата — адресов в памяти, на которые нужно вернуться после завершения выполнения вложенной функции. В стек можно вставлять и извлекать элементы, работа при этом обычно ведётся с вершиной стека — последним вставленным значением. В архитектуре x86 стек растёт сверху вниз, то есть следующие значения в стеке после крайнего находятся дальше в памяти, чем его начало. Мы покажем этот процесс подробнее позже. Пока же важно знать, что текущее положение вершины стека традиционно хранит регистр ESP. Инструкции, меняющие стек, меняют также и адрес, сохранённый в этом регистре. Например, инструкция PUSH сохраняет свой операнд (здесь — регистр EAX) в стек, и сдвигает указатель стека ESP вниз на размер операнда (здесь — 4 байта, по размеру EAX):

push eax

Таким образом, значение EAX становится новой вершиной стека. Соответственно, инструкция POP извлекает значение из стека и помещает в свой операнд, сдвигая ESP вверх на его размер. Сохранённое только что в стек значение EAX мы можем извлечь назад, используя инструкцию:

pop eax

Комбинация PUSH EAX и POP EAX возвращает стек в исходное состояние. Другие две инструкции, используемые для управления стеком, вам уже знакомы — это ADD и SUB. Поскольку ESP формально является обычным регистром, мы можем выделять память на стеке, просто вычитая необходимый размер из текущего указателя на вершину стека. Например, вот так можно выделить 64 байта:

sub esp, 64

Обратная инструкция, ADD, позволяет вернуть выделенное пространство в стек без чтения в регистр. Можно сказать, push eax на самом деле состоит из двух инструкций:

sub esp, 4
mov [esp], eax

И точно также pop eax:

mov eax, [esp]
add esp, 4

Изначально область памяти под стек выделяется операционной системой, для нас важно только понимать, как он работает.

Наконец, рассмотрим инструкции управления потоком. Они меняют значение в регистре EIP, который хранит адрес в памяти следующей выполняемой инструкции. Таким образом становится возможным, например, вызов функций — он реализован как прыжок на первую инструкцию вызываемой функции с некоторыми дополнительными действиями. Прыжки обычно выполняются на метки — именованные в коде на ассемблере точки в памяти. Например, рассмотрим инструкцию JMP (англ. jump — прыгнуть), которая производит безусловный переход, то есть просто меняет значение в регистре EIP на заданное. Предположим, мы хотим переместиться в другое место программы:

  jmp foo
  ...
foo:
  ...

Здесь foo: — метка, которая может стоять в произвольном месте. Метки при этом могут указывать не только на место в коде, но и на данные, как мы показывали ранее в примере со сложением. Программы на ассемблере разделены на секции — области, предназначенные для кода или для данных. Это необходимо для безопасности: секции с кодом ОС помечает как «только для чтения», тем самым не давая злоумышленнику, получившему частичный контроль над памятью программы, видоизменять исполняемый код. Секция для данных называется .data, а с кодом — .text. Более полный пример сложения, в котором определены используемые переменные, таков:

section .data

variable1:
  dd 13
variable2:
  dd 37
result:
  dd 0

section .text

add_numbers:
  mov eax, [variable1]
  add eax, [variable2]
  mov [result], eax

После выполнениия этого кода в области памяти result будет хранится число 50. Здесь мы познакомились с особой ассемблерной инструкцией — DD. Она означает, в случае variable1, что в эту область памяти должно быть помещено число размером 32 бита с начальным значением 13. Мы уже немного касались устройства процессора и знаем, что в современных архитектурах код и данные лежат в памяти наравне. Ассемблерная инструкция MOV с операндами — это просто обозначение, задающее в памяти несколько байт, соответствующих опкоду процессорной команды MOV. Таким образом, весь код на ассемблере описывает некий слепок памяти, который будет загружен в неё и передан на выполнение процессору. С этой точки зрения инстуркция DD ничем не отличается от той же MOV.

Существуют и другие инструкции из этой серии, различающиеся разным размером сохраняемых данных. В дальнейшем нам также пригодится DB, означающая «в этой области памяти должны быть следующие байты». Такой функционал используется, например, чтобы хранить в памяти строки. Например:

hello_string:
  db "Hello", 10, 0

Это выражение означает тоже самое, что в Си:

static char* hello_string = "Hello\n";

10, 0 в коде на ассемблере соответствуют \n\0 в Си. Заметим, что в Си любая строка автоматически заканчивается нулевым байтом, а в ассемблере его нужно прописывать явно. Это позволяет задавать строки, которые можно передавать любым уже существующим функциям, ожидающим, что строка закончится нулевым байтом.

Ранее мы использовали метку [variable1] и другие, заключённые в квадратные скобки. Упоминание метки без них означает «адрес метки variable1», тогда как метка в квадратных скобках означает «значение по адресу метки variable1». Те, кто знаком с языком Си, могут подумать про оператор разыменования * — квадратные скобки это его аналог в ассемблере.

Итак, вернёмся к управлению потоком. Мы уже рассмотрели JMP, которая позволяет переходить на произвольную метку (и не только). Рассмотрим ещё две похожие инструкции: JE и JNE. Они реализуют базовый блок написания программ — оператор if. Обе инструкции совершают переход тогда и только тогда, когда было выполнено некое условие, а именно когда был (или, в случае JNE, не был) установлен флаг ZF, который упоминался выше. Таким образом, следующий код проверяет равенство EBX нулю и переходит на метку только в случае успеха проверки:

  cmp ebx, 0
  jz ebx_is_zero
ebx_is_nonzero:
  mov eax, 1
  jmp continue
ebx_is_zero:
  mov eax, 0
continue:
  ...

После выполнения инструкций в EAX будет сохранено число 0, если EBX был равен нулю, и 1, если не равен. Заметим, что метки сами по себе никак не влияют на исполнение кода: здесь метка ebx_is_nonzero играет сугубо пояснительную роль, поскольку переходов на неё мы не выполняли. При провале проверки JZ просто не выполнит переход, и следующей выполненной инструкцией будет mov eax, 1. Также здесь мы показываем стандартый паттерн реализации условных переходов на ассемблере, когда используется дополнительная метка continue. Она завершает весь условный блок, и мы переходим на неё в конце исполнения ветки ebx_is_nonzero. На псевдоязыке, похожем на Си, этот код соответствовал бы следующему:

if (ebx == 0) {
  // eax_is_zero
  eax = 0;
} else {
  // eax_is_nonzero
  eax = 1;
}
// continue
...

Наконец, рассмотрим самые сложные инструкции, которые мы будем использовать — CALL и RET. Они служат для вызова вложенных функций. Инструкция CALL (англ. вызов) похожа на JMP, однако дополнительно сохраняет адрес следующей после неё инструкции в стек. Концептуально CALL эквивалентен вот такому коду:

  push return_address
  jmp function
return_address:
  ...

Однако использовать CALL гораздо удобнее, чем задавать новые метки в каждом месте, где необходимо вызвать функцию. Для чего же нужно сохранять этот адрес на стек? Это делает возможным команду RET (англ. return — возврат). Она выполняет обратное действие — извлекает из стека значение, интерпретирует его как адрес в памяти, и переходит по этому адресу. Другими словами, RET — это pop eip, но, как мы уже усвоили, напрямую модифицировать значения в EIP стандартными инструкциями нельзя.

Всё вышеописанное позволяет реализовывать функции, которые можно вызывать, как в высокоуровневых языках программирования. Например, код для проверки EBX выше мы могли бы отделить в функцию, которую можно вызывать из других областей программы:

check_ebx_is_zero:
  cmp ebx, 0
  jz ebx_is_zero
ebx_is_nonzero:
  mov eax, 1
  jmp continue
ebx_is_zero:
  mov eax, 0
continue:
  ret

...
  mov ebx, 4
  call check_ebx_is_zero
  ; eax == 1

Отметим, что в диалекте ассемблера Intel x86 символ ; означает комментарий.

Вызываем, вызываем, отвечаем

Мы подошли вплотную к тому, чтобы рассмотреть исходную программу расчёта числа Фибоначчи. Осталось лишь обсудить одну маленькую деталь — соглашения о вызовах, также называемые конвенциями.

Как мы видели раньше, на ассемблере возможно задавать произвольные функции, используя CALL и RET. Однако остаются закономерные вопросы: как именно передавать в функцию аргументы? Как она возвращает результат? Также неочевидный вопрос состоит в том, какие регистры функции разрешается использовать. Например, ваш код использует по максимуму все регистры процессора, и вам необходимо вызвать функцию. Внутри функции также будут использоваться регистры, поэтому часть из используемых вами самими регистров необходимо сохранить на стеке перед вызовом, а затем восстановить. Возможен и другой вариант, когда сама вызываемая функция использует любые регистры, только сохранив перед этим их исходное значение, и восстанавливая их перед возвратом в вызывающий код.

Все эти договорённости принимаются заранее, и называются соглашениями о вызовах. Обычно такое соглашение принято использовать повсеместно в рамках конкретной операционной системы. Кроме того, конкретное соглашение зависит от платформы (в нашем случае, x86) и от битности (в нашем случае, 32 бита). Например, на Windows стандартное соглашение называется stdcall. Мы рассмотрим подробнее стандартное соглашение о вызовах, пришедшее из языка Си. Ей следуют большинство компиляторов Си для платформы x86, и как следствие — Unix-совместимые операционные системы.

Согласно этой конвенции, все аргументы функции кладутся на стек в обратном порядке — так, что первый аргумент функции оказывается ближе к вершине стека. Результат функции возвращается в регистре EAX. Также это соглашение относится к так называемым конвенциям с обслуживанием стека вызывающим, т.е. после возврата из функции вызывающий код должен сам освободить память на стеке, занятую аргументами. В этом соглашении заявляются три регистра, которые должен сохранять вызывающий код перед тем, как вызывать функцию: EAX, ECX и EDX. Все остальыне регистры вызываемая функция обязана восстановить по завершении своей работы.

Все функции стандартной библиотеки языка Си, как уже говорилось, следуют данной конвенции. Это означает, что мы можем воспользоваться всем богатством этой библиотеки из своего ассемблерного кода — нужно лишь знать, как правильно их вызывать.

Кроме конвенции о вызовах мы изучим паттерн начала и конца вызываемых функций. Все функции, следующие заданной конвенции, начинаются и кончаются одинаково. Итак, функциям для работы зачастую нужно место на стеке для хранения локальных переменных. Также на стеке располагаются аргументы функции, к которым необходимо получать доступ во время работы. Регистр ESP при этом постоянно меняется, например, для сохранения регистров при обращении к другим функциям. Как обеспечить доступ к локальным переменным и аргументам в этом случае? На помощь приходит ещё один регистр общего назначения, EBP. Он используется для хранения значения ESP в момент входа в функцию. Таким образом, доступ к нужным значениям обеспечивается не через ESP, а через EBP. Давайте рассмотрим следующую функцию на Си:

int multiply_2(int arg)
{
  int ret = arg + arg;
  return ret;
}

Эта функция принимает один аргумент и возвращает его удвоенным. Давайте скомпилируем и затем дизассемблируем эту функцию воображаемым компилятором. Мы получим следующий код на ассемблере:

multiply_2:
  ; Сохраняем старое значение EBP (помним про конвенцию о вызовах).
  push ebp
  ; Сохраняем текущее значение ESP в EBP.
  mov ebp, esp
  ; Выделяем 4 байта памяти на стеке для хранения локальных переменных.
  sub esp, 4

  ; int ret = arg + arg.
  ; arg будет хранится по адресу [ebp+8].
  mov ecx, [ebp+8]
  add ecx, ecx
  ; По адресу [ebp-4] находится выделенная нами память для локальных переменных.
  mov [ebp-4], ecx
  ; return arg
  ; Возврат значения происходит через регистр eax.
  ; Формально можно было бы просто посчитать результат сразу в eax, но
  ; тогда не получилось бы показать работу с локальными переменными.
  mov eax, [ebp-4]

  ; Восстанавливаем старое значение ESP.
  mov esp, ebp
  ; Восстанавливаем старое значение EBP.
  pop ebp
  ; Возвращаем управление.
  ret

Функция multiply_2 здесь использует 4 байта места на стеке для хранения локальной переменной — результата вычисления. Вызов такой функции может выглядеть так:

; Кладём аргумент функции на стек.
mov eax, 4
push eax
; Вызываем функцию.
call multiply_2
; Освобождаем занятую аргументом память.
add esp, 4
; eax == 8

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

Считаем ряды

Наконец-то мы можем вернуться к исходной программе на Си, которую изучали в начале параграфа. Давайте посмотрим, как она выглядит после декомпилирования. Для упрощения процесса (мы всё-таки только начинаем работу с ассемблером!) здесь будет показан не настоящий декомпилированный код: мы переименовали метки и специально собрали программу с выключенными оптимизациями, чтобы код получился читаемым. Тем не менее, именно так может выглядеть декомпилированная программа:

section .data

number:
  dd 4
format_string:
  db 'Result: %i', 10, 0

section .text

; Использовать функцию printf из стандартной библиотеки Си.
extern printf

fibonacci:
  push ebp
  mov ebp, esp
  sub esp, 4

  ; Получим аргумент из стека.
  mov ecx, [ebp+8]
  ; Запрашивается нулевой элемент последовательности?
  cmp ecx, 0
  jne fibonacci_non_zero
  ; Вернуть 0.
  mov eax, 0
  jmp fibonacci_return
fibonacci_non_zero:
  ; Запрашивается первый элемент последовательности?
  cmp ecx, 1
  jne fibonacci_non_one
  ; Вернуть 1.
  mov eax, 1
  jmp fibonacci_return
fibonacci_non_one:
  ; Посчитать число Фибоначчи N - 1.
  sub ecx, 1
  push ecx
  call fibonacci
  add esp, 4
  ; Положить результат на стек как локальную переменную.
  mov [ebp-4], eax
  ; Посчитать число Фибоначчи N - 2.
  mov ecx, [ebp+8]
  sub ecx, 2
  push ecx
  call fibonacci
  add esp, 4
  ; Сложить оба числа в eax и вернуть их.
  add eax, [ebp-4]

fibonacci_return:
  mov esp, ebp
  pop ebp
  ret

; Объявить функцию main как доступную извне.
; Это позволяет скомпилировать программу.
global main
main:
  push ebp
  mov ebp, esp

  mov eax, [number]
  push eax
  call fibonacci
  add esp, 4
  ; Вызвать printf.
  push eax
  push format_string
  call printf
  add esp, 8
  mov eax, 0

  mov esp, ebp
  pop ebp
  ret

Данный код вы также можете сами собрать при помощи ассемблера NASM. Для этого сохраните код в файл (скажем, fibonacci.asm) и выполните в терминале команды:

$ nasm -felf32 fibonacci.asm
$ gcc -m32 -o fibonacci fibonacci.o

Вы получите исполняемый файл с названием fibonacci, совпадающий по функциональности с файлом, скомпилированным из кода на Си выше.

Декомпилируем сами

Теперь, когда мы научились читать ассемблер, самое время научиться работать с произвольными исполняемыми файлами. Вы можете скачать скомпилированную программу, которую мы разбираем в этом параграфе. Если запустить её, вы получите вывод Result: 3, что соответствует пятому члену последовательности Фибоначчи.

Примечание

  • Помните: запускать исполняемые файлы из недоверенных источников, включая наш курс, опасно! Для такого анализа следует использовать виртуальные машины, и даже в этом случае проявлять осторожность и ограничивать, например, доступ машины к сети и к файлам на компьютере-хосте.

Давайте попробуем проанализировать её с помощью инструмента Radare2/Cutter, и модифицировать исполняемый файл, чтобы посчитать следующий элемент последовательности. После запуска Cutter и экрана приветствия нам предложат выбрать файл для анализа. Выбираем скачанное приложение. В следующем окне нужно включить опцию Load in write mode (-w) (англ. загрузить в режиме записи). Это позволит нам вносить изменения в приложение. Остальные настройки оставляем по умолчанию и нажимаем Ok.

Рекомендуем осмотреться в программе; в частности, выберите в боковом окне функцию loc.fibonacci и изучите дизассемблированный код. Вы заметите, что анализатор обнаружил количество аргументов у функции, а также локальную переменную на стеке. Кроме того, анализатор стрелками показывает возможные цели прыжков. Для более наглядного отображения выберите вкладку Graph (loc.fibonacci) внизу программы. Cutter нарисует граф, показывающий переходы между блоками внутри функции. В таком виде зачастую удобнее анализировать структуру и логику программы.

Вернёмся к листингу ассемблера (вкладка Disassembly) и перейдём снова к функции main. Найдём внутри неё место, где значение из статической переменной кладётся в регистр как аргумент для функции fibonacci:

0x080491a9      mov     eax, dword [number] ; 0x804c008

Перейдём к метке number двойным кликом на неё. Мы увидим на первый взгляд странную картину: по этому адресу будет пара инструкций ADD. Тайна прояснится, когда мы вспомним про гомогенность (единообразие) кода и данных — анализатор не может понять, как интерпретировать данные по этому адресу: как инструкции для машины или как обычное значение, и сопоставляет последовательности байт 0x00000004 следующие инструкции:

0x0804c008      add     al, 0
0x0804c00a      add     byte [eax], al

Давайте поправим ошибку анализатора. Для этого нажмём правой кнопкой мыши на первую из инструкций и выберем пункт меню Set as... → Data... → Dword (интерпретировать как двойное слово, так называют значения длиной 4 байта). Несколько инструкций уступили место искомому шестнадцатеричному значению.

Теперь отредактируем значение. Снова откройте меню для этого значения и выберите Edit → Bytes. Вам откроется окно, где можно будет задать новое значение для этой переменной. Установите значение 0x0005 и нажмите OK.

Вот и всё, мы успешно отредактировали программу! Убедитесь в этом, закрыв Cutter (проект можно не сохранять — это нужно только для анализа, изменения уже были внесены) и вновь запустив приложение из терминала. На этот раз программа выведет Result: 5.

Конечно, мы рассмотрели лишь самые основы анализа и модификации приложений. Тем не менее, надеемся, что у вас получилось извлечь из этого параграфа понимание процесса реверс-инжиниринга, и вы перестали бояться ассемблера.

Выводы

  1. Процессор исполняет машинный код — последовательность бинарных инструкций, опкодов. Машинный код различен для каждой архитектуры. Для удобства написания его человеком был придумал язык ассемблера — текстовое представление опкодов.

  2. Процессор состоит из устройства управления, АЛУ (арифметико-логического устройства) и регистров. Устройство управления следит за текущим выполняемым кодом и загружает новый, АЛУ выполняет простые операции на числах, а регистры представляют собой локальную «память» процессора.

  3. Инструкции процессора — это команды, которые также могут принимать операнды. Операндом может быть регистр или область в памяти. Например, инструкция ADD складывает числа из двух операндов и записывает результат в первый.

  4. Кроме регистров процессором активно используется стек — выделенная область памяти, в которой программы хранят локальные переменные и адреса возвратов из функций.

  5. Для наведения порядка среди функций на ассемблере придумали соглашения о вызовах — наборы правил, которым каждая функция обазана следовать в части работы со стеком и регистрами. Такие соглашения выбирают одинаковыми в пределах целой ОС. Для них существуют стандартные преамбула и завершение функций, которые также используются при компиляции из языков выше уровнем.

  6. Даже скомпилированные программы можно дизассемблировать, изучать и править. Для этого существуют различные утилиты, например Radare2/Cutter.

Задача А. Казино ⟶