Реверс-инжиниринг для начинающих: продвинутые концепции программирования

Обложка поста

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

Примечание Для дизассемблирования в этой статье используется IDA Pro, но многие её функции (например блок-схемы, перевод в псевдокод и т. д.) можно найти в качестве надстроек в бесплатных дизассемблерах (radare2). Более того, для лучшего понимания имена некоторых дизассемблированных переменных были изменены с «v20» на имена, которые были у них в С. Также в этой статье исполняемый файл был скомпилирован в 64-битной версии, а для дизассемблирования используется 64-битная версия IDA Pro. Это на случай, если вы захотите повторить всё самостоятельно, потому что это может повлиять на конечный результат (например, на массивах будет сильное различие 32 и 64-битных версий, а также в 64-битной версии регистры становятся в два раза больше).

Массивы

Итак, начнём с массивов. Сначала рассмотрим код на Си:

void BasicArrays() {
	// объявление массива с константным размером
	int litArray[10];

	// объявление массива с динамическим размером (массив переменного размера)
	int ArraySize = 10;
	int varArray[ArraySize];

	// объявление массива с предопределёнными значениями
	int objArray[10] = {0,1,2,3,4,5,6,7,8,9};

	// запись элемента массива по индексу
	litArray[0] = 1337;

	// чтение элемента из массива
	int leet = litArray[0];

	// создание матрицы с константным размером
	int litMatrix[13][37];

	// создание матрицы переменного размера
	int rows = 13;
	int cols = 37;

	int varMatrix[rows][cols];

	// введение значения в матрицу
	varMatrix[4][20] = 1337;

	// получение значения элемента матрицы
	int MatrixLeet = varMatrix[4][20];
}

Эти 12 строк кода превращаются в довольно внушительный блок машинного кода. Давайте рассмотрим его детально:

// объявление массива с константным размером
	int litArray[10];

Объявление массива с литералом — дизассемблированный вид

При инициализации массива с константным размером компилятор просто инициализирует длину массива через локальную переменную.

Во время компиляции будет выделено место только под одно значение массива litArray[0], которое и будет использоваться (можно увидеть на скриншоте ниже). Такой приём позволяет компилятору значительно увеличить производительности приложений.

Локальные переменные — массивы

// объявление массива с динамическим размером (массив переменного размера)
	int ArraySize = 10;
	int varArray[ArraySize];

Объявление массива через переменную — машинный код

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

// объявление массива с предопределёнными значениями
	int objArray[10] = {0,1,2,3,4,5,6,7,8,9};

Объявление массива с предопределёнными значениями — машинный код

При объявлении массива с предопределёнными значениями компилятор сохраняет каждое значение в свою переменную, которая представлена индексом массива (например objArray4 = objArray[4]).

	// запись элемента массива по индексу
	litArray[0] = 1337;

Инициализация элемента массива через индекс — машинный код

Так же как и с предопределёнными значениями, компилятор создаёт новую переменную для указанного индексного значения при инициализации элемента массива через индекс.

	// чтение элемента из массива
	int leet = litArray[0];

Извлечение элемента массива — машинный код

При извлечении элемента массива значение элемента берётся по указанному индексу и записывается в нужную переменную.

	// создание матрицы динамического размера
	int rows = 13;
	int cols = 37;

	int varMatrix[rows][cols];

Создание матрицы с переменными — машинный код

При создании матрицы сначала её размер устанавливается в соответствии со значениями row и col. Затем рассчитываются максимальный и минимальный индексы для строк и столбцов, которые используются для расчёта базового местоположения и общего размера матрицы в памяти.

	// введение значения в матрицу
	varMatrix[4][20] = 1337;

Задание значения переменной в матрице — машинный код

При вводе в матрицу сначала определяется местоположение желаемого элемента массива с использованием базового местоположения матрицы. Затем содержимое указанного элемента массива устанавливается на желаемое входное значение (т.е. 1337).

	// получение значения элемента матрицы
	int MatrixLeet = varMatrix[4][20];

Извлечение значения из матрицы — машинный код

При извлечении значения из матрицы происходят такие же вычисления, как и при внесении значения в неё. Однако при этом ничего не записывается — содержимое извлекается и записывается в нужную переменную (например MatrixLeet).

Указатели

Теперь, когда мы понимаем, как массивы используются и выглядят в машинном коде, давайте перейдём к указателям.

int Pointers() {
	int num = 10;

	// указатель на целочисленную переменную
	// содержит адрес этой переменной
	int *pointer;

	// &<переменная> возвращает адрес указанной переменной
	pointer = #

	printf("num: %d\n", num);
	printf("*pointer: %d\n", *pointer);
	printf("Address of num: %p\n", &num);
	printf("Address of num using pointer: %p\n", pointer);
	printf("Address of pointer: %p\n", &pointer);

	return 0;
}

Давайте сразу разберёмся в машинном коде:

int num = 10 в машинном коде

Сначала мы присваиваем переменной num значение 10.

pointer = &num

Затем указателю pointer присваивается адрес переменной num.

Вывод num — машинный код

Вывод переменной num на экран.

Вывод *pointer — assembly

Вывод переменной pointer на экран.

Вывод адреса num — машинный код

Вывод адреса переменной num происходит с помощью инструкции lea (загрузка результирующего адреса) вместо mov.

Вывод адреса num с помощью переменной pointer — машинный код

Вывод адреса переменной num через указатель pointer.

Вывод адреса pointer — машинный код

Вывод адреса переменной pointer происходит с помощью инструкции lea вместо mov.

Динамическое распределение памяти

В этой статье будут рассмотрены следующие виды динамического распределения памяти:

  1. malloc.
  2. calloc.
  3. realloc.

malloc — динамическое выделение памяти

Сначала разберёмся в коде:

Прим. перев. В оригинале статьи выделяется 11 байтов, хотя правильно будет 12. В конце строки ещё добавляется символ с кодом 0.

int malloc_ex() {
	char *mem_alloc;
	// выделение памяти под 11 символов
	mem_alloc = malloc(11 * sizeof(char));

	// запись в память строки "Hello World"
	strcpy(mem_alloc, "Hello World");

	printf("malloc-mem_alloc: %s\n", mem_alloc);

	// освобождение выделенной памяти
	free(mem_alloc);

	return 0;
}

В этой функции выделяется место под 11 символов с помощью malloc(), а затем в выделенное пространство памяти копируется «Hello World».

Теперь давайте посмотрим на машинный код:

Примечание Во время сборки вы можете увидеть инструкции «nop». Эти инструкции были специально размещены на этапе подготовки к статье, чтобы различные части кода было проще понимать.

Динамическое распределение памяти с помощью malloc — машинный код

При использовании malloc() размер выделенной памяти (0x0B) сначала перемещается в регистр edi. Затем системная функция _malloc вызывается для выделения памяти. Выделенная область памяти затем сохраняется в переменной ptr. Потом строка «Hello World» разбивается на «Hello Wo» и «rld», поскольку она копируется в выделенное пространство памяти. Наконец, вновь скопированная строка «Hello World» выводится на экран, а выделенная память освобождается с помощью функции _free.

calloc — динамическое чистое выделение памяти

Посмотрим на код:

int calloc_ex() {
	char *mem_alloc;
	// выделение памяти под 11 символов
	mem_alloc = calloc(11 * sizeof(char));

	// запись в память строки "Hello World"
	strcpy(mem_alloc, "Hello World");

	printf("calloc-mem_alloc: %s\n", mem_alloc);

	// освобождение выделенной памяти
	free(mem_alloc);

	return 0;
}

Как и в методе malloc(), место для 11 символов выделяется, а строка «Hello World» копируется в указанное пространство. Затем вновь перемещённый «Hello World» распечатывается, и выделенное пространство памяти освобождается.

Динамическое распределение памяти с помощью calloc — машинный код

Динамическое распределение памяти через calloc() выглядит почти идентично динамическому распределению памяти через malloc() в машинном коде.
Во-первых, пространство для 11 символов (0x0B) выделяется с помощью системной функции _calloc. Затем строка «Hello World» разбивается на «Hello Wo» и «rld„, поскольку она копируется во вновь выделенную область памяти. Затем вновь перемещённая строка “Hello World» выводится на экран, а выделенная область памяти освобождается с помощью функции _free.

realloc — динамическое перераспределение памяти

Сначала посмотрим код.

int realloc_free() {
	char *mem_alloc;

	// выделение памяти под 11 символов
	mem_alloc = malloc(11 * sizeof(char));

	// запись в память строки "Hello World"
	strcpy(mem_alloc, "Hello World");

	printf("malloc-mem_alloc: %s\n", mem_alloc);

	// выделение памяти под 11 символов
	mem_alloc = realloc(mem_alloc, 21 * sizeof(char));

	// запись в память строки "Hello World"
	strcpy(mem_alloc, "1337 h4x0r @nonymoose");

	printf("realloc-free-mem_alloc realloc: %s\n", mem_alloc);

	// освобождение выделенной памяти
	free(mem_alloc);

	return 0;
}

В этой функции память для 11 символов выделяется с помощью malloc(). Затем «Hello World» копируется в только что выделенное пространство памяти, прежде чем указанное расположение памяти перераспределяется, чтобы соответствовать 21 символу.

Прим. перев.: Должно быть 22 символа. Снова автор забыл символ с кодом 0, используя realloc()

Наконец, «1337 h4x0r @nonymoose» копируется в только что перераспределённое пространство. Наконец, после вывода на экран память освобождается.

Теперь посмотрим машинный код:

Динамическое распределение памяти с помощью realloc — машинный код

Сначала память выделяется с помощью malloc(). Затем, после вывода на экран только что перемещённой строки «Hello World», вызывается realloc() для переменной ptr (которая представляет переменную mem_alloc в коде), а также передаётся новый размер 0x15 (21 в десятичном виде). Затем «1337 h4x0r @nonymoose» разбивается на «1337 h4x„, “0r @nony», «moos» и «e„, поскольку он копируется в только что перераспределённое пространство памяти. Наконец, пространство освобождается с помощью функции _free.

Программирование сокетов

Далее мы рассмотрим программирование сокетов, разобрав очень простую систему клиент-серверного TCP-чата.

Прежде чем мы начнём разбирать код сервера или клиента, важно указать следующую строку кода в верхней части файла:

#define PORT 1337

Эта строка определяет константу PORT как 1337. Эта константа будет использоваться как на клиенте, так и на сервере в качестве сетевого порта, используемого для создания соединения.

Серверная часть

Сначала посмотрим на код:

int Server() {
	// инициализация переменных, необходимых для работы сервера
	int server, sock, value;
	struct sockaddr_in address;
	int opt = 1;
	int addrlen = sizeof(address);
	char buffer[1024] = {0};
	const char *serverhello = "Server Hello";

	// создание сокета
	server = socket(AF_INF, SOCK_STREAM, 0);

	// настройка сокета
	setsockopt(server, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));

	// настройка адреса
	address.sin_family = AF_INET;
	address.sin_addr.s_addr = INADDR_ANY;
	address.sin_port = htons(PORT);

	// привязка сокета к серверу
	bind(server, (struct sockaddr *)&address, (socklen_t*)&addrlen);

	// ожидание клиентов
	listen(server, 3);

	// принятие соединения
	sock = accept(server, (struct sockaddr *)&address, (socklen_t*)&addrlen);

	// чтение сообщения, полученного через сокет
	value = read(sock, buffer, 1024);

	printf("%s\n", buffer);

	// отсылка сообщения через сокет
	send(sock, serverhello, strlen(serverhello), 0);
	return 0;
}

Сначала создаётся файловый дескриптор сокета server с доменом AF_INET, типом SOCK_STREAM и кодом протокола 0. Далее настраиваются параметры сокета и адрес. Затем сокет привязывается к сетевому адресу (порту), и сервер начинает прослушивать указанный порт с максимальной длиной очереди 3. После получения соединения сервер принимает его в переменную sock и считывает переданное значение в переменную value.

Наконец, сервер отправляет строку serverhello по соединению до возврата функции.

Теперь давайте разберём его в машинном коде:

Инициализация серверных переменных

Сначала создаются и инициализируются переменные сервера.

server = socket(…) — машинный код

Затем создаётся файловый дескриптор сокетов server с помощью системной функции _socket. Параметры для функции — протокол, тип и доменное имя передаются с помощью регистров edx, esi и edi соответственно.

setockopt(…) — машинный код

Затем вызывается _setsockopt для задания параметров сокета в файле дескриптора “server».

Инициализация address — машинный код

Инициализируется серверный адрес с помощью adress.sin_family, address.sin_addr.s_addr и address.sin_port.

bind(…) — машинный код

После того как сервер был сконфигурирован, он привязывается к интернет-адресу с помощью _bind.

listen(…) — машинный код

После привязки сервер слушает сокет, передав файловый дескриптор server. Максимальная длина очереди равна 3.

sock = accept(…) — машинный код

Как только соединение установлено, сервер принимает соединение сокета в переменную sock.

value = read(…) — машинный код

Затем сервер считывает переданное в переменную value сообщение с помощью _read.

send(…) — машинный код

В конце концов, сервер отсылает сообщение serverhello через переменную s в машинном коде.

Клиентская часть

Сначала разберёмся в коде:

int Client() {
	struct sockaddr_in address;
	int sock = 0, value;
	struct sockaddr_in server_addr;
	char* helloclient = "Client Hello";
	char buffer[1024] = {0};

	// создание сокета
	server = socket(AF_INF, SOCK_STREAM, 0);

	// настройка объекта сокета
	memset(&server_addr, 0, sizeof(server_addr));

	server_addr.sin_family = AF_INET;
	server_addr.sin_port = htons(PORT);

	// форматирование IP-адреса в бинарный формат
	inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);

	// подсоединение к серверу
	connect(sock, (struct sockaddr *)&server_addr, sizeof(server_addr));

	// отсылка сообщения серверу
	send(sock, helloclient, strlen(helloclient), 0);

	// чтение ответа от сервера
	value = read(sock, buffer, 1024);

	printf("%s\n", buffer);
	return 0;
}

Сначала создаётся файловый дескриптор сокета sock с помощью переменной домена AF_INET типа SOCK_STREAM и кода протокола 0. Затем memset используется для заполнения области памяти server_addr нулями до того, как информация об адресе будет установлена ​​с помощью server_addr.sin_family и server_addr.sin_port. До того как клиент подключится к серверу, информация об адресе преобразуется из текстового в двоичный формат с использованием inet_pton. После подключения клиент отправляет строку helloclient и затем принимает ответ сервера в переменную value. Наконец, переменная value выводится на экран, и происходит возврат из функции.

Теперь разбёремся в машинном коде:

Инициализация переменных клиента — машинный код

Сначала инициализируются локальные переменные клиента.

sock = socket(…) — машинный код

Дескриптор файла сокета «sock» создается путём вызова системной функции _socket и передачи информации о протоколе, типе и домене через регистры edx, esi и edi соответственно.

memset(…) — машинный код

Переменная server_address (в машинном коде «s») заполняется нулями (0x30) с помощью системного вызова _memset.

Клиент  — настройка адреса — машинный код

Потом настраивается адресная информация сервера.

inet_pton(…) — машинный код

Затем адрес переводится из текстового в двоичный формат с помощью системной функции _inet_pton. Обратите внимание, что, поскольку в коде явно не указан адрес, предполагается localhost (127.0.0.1).

Клиент подключается к серверу с помощью системного вызова _connect.

send(…) — машинный код

После подключения клиент отправляет строку helloClient на сервер.

value = read(…)

Наконец, клиент получает ответ сервера в переменную value с помощью системного вызова _read.

Многопоточность

Наконец, мы рассмотрим основы потоков в C.

Во-первых, давайте посмотрим на код:

void *mythread(void *vargp) {
	// пауза на 1 секунду
	sleep(1);

	printf("Hello from my thread\n");
}
void Threading() {
	pthread_t tid;

	printf("This is before the thread\n");

	//создание нового потока
	pthread_create(&tid, NULL, mythread, NULL);

	// присоединение потока к главному потоку
	pthread_join(tid, NULL);

	printf("This is after the thread\n");
}

Как вы можете видеть, программа сначала печатает «This is before the thread», затем создаёт новый поток, который указывает на функцию *mythread(), используя функцию pthread_create(). По завершении функции *mythread() (после сна длиной в 1 секунду и вывода на экрана «Hello from mythread») новый поток присоединяется к основному потоку с помощью функции pthread_join() и выводится на экран «This is after the thread».

Теперь давайте разберём машинный код:

printf “This is before the thread” — машинный код

Сначала программа печатает «This is before the thread».

Создание нового потока — машинный код

Затем создаётся новый поток с помощью системного вызова _pthread_create. Этот поток получает mythread() в качестве аргумента.

Функция mythread() — машинный код

Как вы можете видеть, функция mythread() просто спит одну секунду перед выводом «Hello from mythread».

Примечание Внутри функции mythread() вы увидите два нопа. Они были специально размещены для облегчения навигации на этапе подготовки этой статьи.

Присоединение потока функции mythread к главному потоку — машинный код

После возврата из функции mythread() новый поток соединяется с основным потоком с помощью функции _pthread_join.

printf “This is after the thread” — машинный код

Наконец, на экран выводится «This is after the thread» и происходит возврат из функции.

Заключение

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

Перевод статьи «BOLO: Reverse Engineering — Part 2 (Advanced Programming Concepts)»

Варвара Николаева