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

Аватар Евгений Туренко
Отредактировано

В статье будут рассмотрены массивы, указатели, динамическое распределение памяти, программирование сокетов (сетевое программирование) и многопоточность.

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

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

Примечание Для дизассемблирования в этой статье используется 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];
		
Реверс-инжиниринг для начинающих: продвинутые концепции программирования 1

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

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

Реверс-инжиниринг для начинающих: продвинутые концепции программирования 2
			// объявление массива с динамическим размером (массив переменного размера)
	int ArraySize = 10;
	int varArray[ArraySize];
		
Реверс-инжиниринг для начинающих: продвинутые концепции программирования 3

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

			// объявление массива с предопределёнными значениями
	int objArray[10] = {0,1,2,3,4,5,6,7,8,9};
		
Реверс-инжиниринг для начинающих: продвинутые концепции программирования 4

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

			// запись элемента массива по индексу
	litArray[0] = 1337;
		
Реверс-инжиниринг для начинающих: продвинутые концепции программирования 5

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

			// чтение элемента из массива
	int leet = litArray[0];
		
Реверс-инжиниринг для начинающих: продвинутые концепции программирования 6

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

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

	int varMatrix[rows][cols];
		
Реверс-инжиниринг для начинающих: продвинутые концепции программирования 7

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

			// введение значения в матрицу
	varMatrix[4][20] = 1337;
		
Реверс-инжиниринг для начинающих: продвинутые концепции программирования 8

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

			// получение значения элемента матрицы
	int MatrixLeet = varMatrix[4][20];
		
Реверс-инжиниринг для начинающих: продвинутые концепции программирования 9

При извлечении значения из матрицы происходят такие же вычисления, как и при внесении значения в неё. Однако при этом ничего не записывается — содержимое извлекается и записывается в нужную переменную (например 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;
}
		

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Вывод адреса переменной 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». Эти инструкции были специально размещены на этапе подготовки к статье, чтобы различные части кода было проще понимать.

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

При использовании 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» распечатывается, и выделенное пространство памяти освобождается.

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

Динамическое распределение памяти через 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» копируется в только что перераспределённое пространство. Наконец, после вывода на экран память освобождается.

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

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

Сначала память выделяется с помощью 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 по соединению до возврата функции.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

В конце концов, сервер отсылает сообщение 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 выводится на экран, и происходит возврат из функции.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Наконец, клиент получает ответ сервера в переменную 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».

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

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

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

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

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

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

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

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

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

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

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

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

Заключение

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

Следите за новыми постами
Следите за новыми постами по любимым темам
7К открытий7К показов