Игра Яндекс Практикума
Игра Яндекс Практикума
Игра Яндекс Практикума

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

Отредактировано

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

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

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

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

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

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

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

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

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

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

Для вывода данных было решено использовать 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]);
		

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Функция 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;
}
		
Реверс-инжиниринг для начинающих: основные концепции программирования 17

Сначала выделяется место под переменную 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);
}
		
Реверс-инжиниринг для начинающих: основные концепции программирования 18

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

Циклы

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

			void forloop (int max) { // обычный цикл for
    for (int i = 0; i < max; ++i){
        printf("%i \n", i);
    }
}
		
Реверс-инжиниринг для начинающих: основные концепции программирования 19

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

  • он может перейти к блоку справа (зелёная стрелка) и вернуться в основную программу;
  • он может перейти к блоку слева (красная стрелка) и вернуться к началу цикла for.
Реверс-инжиниринг для начинающих: основные концепции программирования 20

Сначала сравниваются переменные 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!");
}
		
Реверс-инжиниринг для начинающих: основные концепции программирования 21

В этом цикле генерируется случайное число от 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».

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

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

Граф структурирован аналогично фактическому коду, потому что условный оператор выглядит просто: «Если 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» в отличии от условного оператора. Вместо этого программа сравнивает входное значение с существующими случаями и выполняет только тот случай, который соответствует входному значению. Рассмотрим два первых блока подробней.

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

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

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

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

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

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

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

    string sentence;
    cin >> sentence;

    printf("%s", sentence);

}
		

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

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

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

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

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

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

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

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

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