0

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

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

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

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

  1. Инициализация переменных
  2. Стандартная функция вывода
  3. Математические операции
  4. Вызов функций
  5. Циклы
  6. Условный оператор
  7. Оператор выбора
  8. Пользовательский ввод

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

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

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

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

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():

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

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

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

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