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

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

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

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

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

Примечание Программный код для этой статьи компилируется с помощью Microsoft Visual Studio 2015, так что некоторые функции в новых версиях могут использоваться по-другому. В качестве дизассемблера используется IDA Pro.

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

Переменные — одна из основных составляющих программирования. Они делятся на несколько видов, вот некоторые из них:

  • строка;
  • целое число;
  • логическая переменная;
  • символ;
  • вещественное число с двойной точностью;
  • вещественное число;
  • массив символов.

Стандартные переменные:

string stringvar = "Hello World";
int intvar = 100;
bool boolvar = false;
char charvar = 'B';
double doublevar = 3.1415;
float floatvar = 3.14159265;
char carray[] = { 'a', 'b', 'c', 'd', 'e' };

Примечание в С++ строка — не примитивная переменная, но важно понять, как она будет выглядеть в машинном коде.

Давайте посмотрим на ассемблерный код:

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

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

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

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

Инициализация строковой переменной в C++

Для инициализации строки требуется вызов встроенной функции.

Стандартная функция вывода

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

Для вывода данных было решено использовать printf(), а не cout.

Стандартный вывод:

printf("Hello String Literal");
printf("%s", stringvar);
printf("%i", intvar);
printf("%c", charvar);
printf("%f", doublevar);
printf("%f", floatvar);
printf("%c", carray[3]);

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

Вывод строкового литерала

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

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

Вывод переменной

Как можно заметить, сначала переменная intvar помещается в регистр EAX, который в свою очередь записывается в стек вместе со строковым литералом %i, используемым для обозначения целочисленного вывода. Эти переменные затем берутся из стека и используются в качестве параметров при вызове функции printf().

Математические операции

Сейчас мы поговорим о следующих математических операциях:

  1. Сложение.
  2. Вычитание.
  3. Умножение.
  4. Деление.
  5. Поразрядная конъюнкция (И).
  6. Поразрядная дизъюнкция (ИЛИ).
  7. Поразрядное исключающее ИЛИ.
  8. Поразрядное отрицание.
  9. Битовый сдвиг вправо.
  10. Битовый сдвиг влево.
void mathfunctions() { // математические операции

    int A = 10;
    int B = 15;
    int add = A + B;
    int sub = A - B;
    int mult = A * B;
    int div = A / B;
    int and = A & B;
    int or = A | B;
    int xor = A ^ B;
    int not = ~A;
    int rshift = A >> B;
    int lshift = A << B;
}

Переведём каждую операцию в ассемблерный код:

сначала присвоим переменной A значение 0A в шестнадцатеричной системе счисления или 10 в десятичной. Переменной B0F, что равно 15 в десятичной.

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

Для сложения мы используем инструкцию add:

Сложение

При вычитании используется инструкция sub:

Вычитание

При умножении — imul:

Умножение

Для деления используется инструкция idiv. Также мы используем оператор cdq, чтобы удвоить размер EAX и результат деления уместился в регистре.

Деление

При поразрядной конъюнкции используется инструкция and:

Поразрядная конъюнкция

При поразрядной дизъюнкции — or:

Поразрядная дизъюнкция

При поразрядном исключающем ИЛИ — xor:

Поразрядное исключающее ИЛИ

При поразрядном отрицании — not:

Поразрядное отрицание

При битовом сдвиге вправо — sar:

Битовый сдвиг вправо

При битовом сдвиге влево — shl:

Битовый сдвиг влево

Вызов функций

Мы рассмотрим три вида функций:

  1. Функция, не возвращающая значение (void).
  2. Функция, возвращающая целое число.
  3. Функция с параметрами.

Вызов функций:

newfunc();
newfuncret();
funcparams(intvar, stringvar, charvar);

Сначала посмотрим, как происходит вызов функций newfunc() и newfuncret(), которые вызываются без параметров.

Вызов функций без параметров

Функция newfunc() просто выводит сообщение «Hello! I’m a new function!»:

void newfunc() { // новая функция без параметров
    printf("Hello! I'm a new function"!);
}

Функция newfunc()

Эта функция использует инструкцию retn, но только для возврата к предыдущему местоположению (чтобы программа могла продолжить свою работу после завершения функции). Посмотрим на функцию newfuncret(), которая генерирует случайное целое число с помощью функции С++ rand() и затем его возвращает.

int newfuncret() { // новая функция, которая что-то возвращает
    int A = rand();
    
    return A;
}

Функция newfuncret()

Сначала выделяется место под переменную A. Затем вызывается функция rand(), результат которой помещается в регистр EAX. Затем значение EAX помещается в место, выделенное под переменную A, фактически присваивая переменной A результат функции rand(). Наконец, переменная A помещается в регистр EAX, чтобы функция могла его использовать в качестве возвращаемого параметра. Теперь, когда мы разобрались, как происходит вызов функций без параметров и что происходит при возврате значения из функции, поговорим о вызове функции с параметрами.

Вызов такой функции выглядит следующим образом:

funcparams(intvar, stringvar, charvar);

Вызов функции с параметрами

Строки в С++ требуют вызова функции basic_string, но концепция вызова функции с параметрами не зависит от типа данных. Сначала переменная помещается в регистр, затем оттуда в стек, а потом происходит вызов функции.

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

void funcparams (int iparam, string sparam, char cparam) { // функция с параметрами

    printf("%i \n", iparam);
    printf("%s \n", sparam);
    printf("%c \n", cparam);
}

Функция funcparams()

Эта функция берёт строку, целое число и символ и печатает их с помощью функции printf(). Как видите, сначала переменные размещаются в начале функции, затем они помещаются в стек для вызова в качестве параметров функции printf(). Очень просто.

Циклы

Теперь, когда мы изучили вызов функции, вывод, переменные и математику, перейдём к контролю порядка выполнения кода (flow control). Сначала мы изучим цикл for:

void forloop (int max) { // обычный цикл for
    for (int i = 0; i < max; ++i){
        printf("%i \n", i);
    }
}

Графический обзор цикла for

Прежде чем разбить ассемблерный код на более мелкие части, посмотрим на общий вариант. Как вы можете видеть, когда цикл for запускается, у него есть 2 варианта:

  • он может перейти к блоку справа (зелёная стрелка) и вернуться в основную программу;
  • он может перейти к блоку слева (красная стрелка) и вернуться к началу цикла for.

Цикл for подробно

Сначала сравниваются переменные i и max, чтобы проверить, достигла ли переменная максимального значения. Если переменная i не больше или не равна переменной max, то подпрограмма пойдёт по красной стрелке (вниз влево) и выведет переменную i, затем i увеличится на 1 и произойдёт возврат к началу цикла. Если переменная i больше или равна max, то подпрограмма пойдёт по зелёной стрелке, то есть выйдет из цикла for и вернётся в основную программу.

Теперь давайте взглянем на цикл while:

void whileloop() { // цикл while

    int A = 0;
    while (A<10) {
        A = 0 + (rand()%(int)(20-0+1))
    }
    printf("I'm out!");
}

Цикл while

В этом цикле генерируется случайное число от 0 до 20. Если число больше 10, то произойдёт выход из цикла со словами «I’m out!», в противном случае продолжится работа в цикле.

В машинном коде переменная А сначала инициализируется и приравнивается к нулю, а затем инициализируется цикл, A сравнивается с шестнадцатеричным числом 0A, которое равно 10 в десятичной системе счисления. Если А не больше и не равно 10, то генерируется новое случайное число, которое записывается в А, и снова происходит сравнение. Если А больше или равно 10, то происходит выход из цикла и возврат в основную программу.

Условный оператор

Теперь поговорим об условных операторах. Для начала посмотрим код:

void ifstatement() { // условные операторы
	int A = 0 + (rand()%(int)(20-0+1));

	if (A < 15) {
		if (A < 10) {
			if (A < 5) {
				printf("less than 5");
			}
			else {
				printf("less than 10, greater than 5");
			}
		}
		else {
			printf("less than 15, greater than 10");
		}
	}
	else {
		printf("greater than 15");
	}
}

Эта функция генерирует случайное число от 0 до 20 и сохраняет получившееся значение в переменной А. Если А больше 15, то программа выведет «greater than 15». Если А меньше 15, но больше 10 — «less than 15, greater than 10». Если меньше 5 — «less than 5».

Посмотрим на ассемблерный граф:

Ассемблерный граф для условного оператора

Граф структурирован аналогично фактическому коду, потому что условный оператор выглядит просто: «Если X, то Y, иначе Z». Если посмотреть на первую сверху пару стрелок, то оператору предшествует сравнение А с 0F, что равно 15 в десятичной системе счисления. Если А больше или равно 15, то подпрограмма выведет «greater than 15» и вернётся в основную программу. В другом случае произойдёт сравнение А с 0A (1010). Так будет продолжаться, пока программа не выведет что-нибудь на экран и не вернётся.

Оператор выбора

Оператор выбора очень похож на оператор условия, только в операторе выбора одна переменная или выражение сравнивается с несколькими «случаями» (возможными эквивалентностями). Посмотрим код:

void switchcase() { // оператор выбора
	int A = 0 + (rand()%(int)(10-0+1));

	switch (A) {
		case 0:
			printf("0");
			break;
		case 1:
			printf("1");
			break;
		case 2:
			printf("2");
			break;
		case 3:
			printf("3");
			break;
		case 4:
			printf("4");
			break;
		case 5:
			printf("5");
			break;
		case 6:
			printf("6");
			break;
		case 7:
			printf("7");
			break;
		case 8:
			printf("8");
			break;
		case 9:
			printf("9");
			break;
		case 10:
			printf("10");
			break;
	}
}

В этой функции переменная А получает случайное значение от 0 до 10. Затем А сравнивается с несколькими случаями, используя switch. Если значение А равно одному из случаев, то на экране появится соответствующее число, а затем произойдёт выход из оператора выбора и возврат в основную программу.

Оператор выбора не следует правилу «Если X, то Y, иначе Z» в отличии от условного оператора. Вместо этого программа сравнивает входное значение с существующими случаями и выполняет только тот случай, который соответствует входному значению. Рассмотрим два первых блока подробней.

Два первых блока оператора выбора

Сначала генерируется случайное число и записывается в А. Теперь программа инициализирует оператор выбора, приравняв временную переменную var_D0 к А, затем проверяет, что она равна хотя бы одному из случаев. Если var_D0 требуется значение по умолчанию, то программа пойдёт по зелёной стрелке в секцию окончательного возврата из подпрограммы. Иначе программа совершит переход в нужный case.

Если var_D0 (A) равно 5, то код перейдёт в секцию, которая показана выше, выведет «5» и затем перейдёт в секцию возврата.

Пользовательский ввод

В этом разделе мы рассмотрим ввод пользователя с помощью потока сin из C++. Во-первых, посмотрим на код:

void userinput() { // ввод с клавиатуры

    string sentence;
    cin >> sentence;

    printf("%s", sentence);

}

В этой функции мы просто записываем строку в переменную sentence с помощью функции C++ cin и затем выводим предложение с помощью оператора printf().

Разберём это в машинном коде. Во-первых, функция cin:

cin (C++)

Сначала происходит инициализация строковой переменной sentence, затем вызов cin и запись введённых данных в sentence.

Функция C++ cin детальнее

Сначала программа устанавливает содержимое переменной sentence в EAX, затем помещает EAX в стек, откуда значение переменной будет использоваться в качестве параметра для потока cin, затем вызывается оператор потока >>. Его вывод помещается в ECX, который затем помещается в стек для оператора printf():

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

Смотрите также: Эксплуатация уязвимостей исполняемых файлов для новичков

Не смешно? А здесь смешно: @ithumor