Security

Создание виртуальной машины для защиты от отладки исходного кода.

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

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

Итак, виртуальные машины, предназначенные для запутывания кода, основаны на идее замены «обычного» байт-кода, который, например, используется в архитектуре x86-64, на тот байт-код, который мы изобретем сами. Чтобы реконструировать поток управления в программе, подвергшейся виртуализации, необходимо проанализировать каждый опкод и разобраться, что он делает. Чтобы понимать, что происходит, нужно немного коснуться работы процессора — ведь, по сути, перед нами стоит задача «написать процессор».

Нам предстоит написать некое подобие транслятора-интерпретатора кода — чтобы исходный код, который мы будем писать, начал обрабатываться внутри нашей виртуальной машины. Можно провести аналогию с процессорами: современные процессоры представляют собой сложные устройства, которые управляются микрокодом. Многие наборы инструкций, особенно современные, типа Advanced Vector Extensions (AVX), — это, по сути, подпрограммы на микрокоде процессора, который, в свою очередь, напрямую взаимодействует с железом процессора.

Получается, что современные процессоры похожи больше на софт, а не на железо: сложные инструкции типа VBROADCASTSS, VINSERTF128, VMASKMOVPS реализованы исключительно «софтверно» при помощи программ, состоящих из микрокодов. А таких наборов инструкций, как ты, возможно, знаешь, много — достаточно открыть техническое описание какого-нибудь Skylake и посмотреть на поддерживаемые наборы инструкций.

Микропрограммы процессора состоят из микроинструкций, а они, в свою очередь, реализуют элементарные операции процессора — операции, которые уже нельзя разделить на более мелкие, например работа с арифметико-логическим устройством (АЛУ) процессора: подсоединение регистров к входам АЛУ, обновление кодов состояния АЛУ, настройка АЛУ на выполнение математических операций.

Стековая виртуальная машина

Нам необходимо будет эмулировать, помимо работы процессора, работу памяти (RAM). Для этого мы воспользуемся реализацией собственного стека, который будет работать по принципу LIFO.

LIFO (last in, first out) — способ организации хранения данных, который похож на стопку журналов на столе: если нужный журнал лежит в середине стопки, нельзя его просто вытащить, можно только поочередно убирать журналы сверху и так до него добраться. Получается, мы всегда работаем только с верхушкой этой стопки.

В этом нет ничего сложного — по сути, это просто массив данных с указателем на них. Для наглядности код:

C++:
// Размер памяти VM
const int MAXMEM = 5;
// Массив памяти, который состоит из элементов типа int
int stack[MAXMEM];
// Указатель на положение данных в стеке, сейчас стек не инициализирован
int sp = -1;

Этот стек станет оперативной памятью нашей виртуальной машины. Чтобы
путешествовать по нему, достаточно обычных операций с массивами:

C++:
stack[++sp] = data1;      // Положим данные
int data2 = stack[--sp];  // Возьмем данные

Далее, чтобы наша память не «сломалась», нам необходимо позаботиться о
проверках, чтобы не срабатывали попытки взять данные, когда память
пуста, либо положить больше данных, чем она может вместить.

C++:
// Проверка стека на пустоту
// Функция вернет TRUE (1), если стек пуст,
// и FALSE (0), если данные есть
int empty_sp() {
  return sp == -1 ? 1 : 0;
}

// Проверка стека на заполненность
// Функция вернет TRUE (1), если стек полон,
// и FALSE (0), если место еще есть
int full_sp() {
  return sp == MAXMEM ? 1 : 0;
}

Как видишь, никакой магии нет! Мы успешно запрограммировали память для нашей будущей VM.

Далее переходим к командам.

Создадим перечисление под названием mnemonics и заполним его инструкциями для нашей VM (читай комментарии):

C++:
// Поддерживаемые мнемоники VM
typedef enum {
  // Положить значение на стек. Этот параметр имеет один аргумент
  PUSH  = 0x00d00201,
  // Получить значение со стека. Берется верхушка стека
  POP = 0x00d00205,
  // Сложить два верхних значения стека
  ADD = 0x00d00202,
  // Вычесть два верхних значения стека
  SUB = 0x00d00206,
  // Поделить два значения
  DIV = 0x00d00203,
  // Перемножить два значения. Во всех четырех операциях результат кладется на стек
  MUL = 0x00d00204,
  // Ввести данные
  ENTER = 0x00d00211,
  // Сверка данных с шаблоном
  TEST  = 0x00d00209,
  // Вывести верхушку стека
  PRINT = 0x00d00210,
  // Вывести все данные, которые находятся в нашей памяти
  RAM = 0x00d00208,
  // Завершить работу виртуальной машины
  EXIT  = 0x00d00207
} mnemonics;

У каждой мнемоники есть значение в шестнадцатеричном формате, которое мы присвоили самостоятельно. Если бы мы не сделали этого, в
перечислении все элементы были бы пронумерованы начиная с нуля с шагом в единицу. Зачем мы так поступили, я объясню позже, а мы теперь готовы
написать программу, которая состоит из наших инструкций, я также ее прокомментирую.

C++:
// Исполняемый код
const int code[] = {
  PUSH, 22,   // Кладем на стек 22
  PUSH, 45,   // Кладем на стек 45
  RAM,        // Выводим содержимое памяти
  SUB,        // Вычитание
  POP,        // Вытащить результат из стека
  PUSH, 23,   // Кладем на стек 23
  PUSH, 9,    // Кладем на стек 9
  PUSH, 5,    // Проверка на ошибку
  RAM,        // Выводим содержимое памяти
  PRINT,      // Выводим верхушку стека
  ADD,        // Сложение
  POP,        // Вытащить результат
  PUSH, 7,    // Кладем на стек 7
  PUSH, 7,    // Кладем на стек 7
  RAM,        // Выводим содержимое памяти
  ADD,        // Сложение
  POP,        // Вытащить результат
  POP,        // Проверка на ошибку
  ENTER,      // Ввод данных
  PRINT,      // Выводим верхушку стека
  TEST,       // Проверяем данные
  EXIT        // Остановка VM
};

Как видишь, это такой же простой массив. Единственное, что может немного смутить, — манера записи, но это лишь для наглядности.

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

C++:
int ip = 0; // Указатель на инструкцию (мнемонику)

Теперь мы подошли к самому интересному — основному циклу виртуальной машины.

Именно этот цикл оживит наши инструкции и придаст им смысл. Я приведу полный листинг с комментариями.

C++:
// Трансляция кода VM
void decoder(int instr) {
  switch (instr) {
  case PUSH: {
    // Проверяем, есть ли место в памяти
    if (full_sp()) {
      printf("Memory is full\n");
      break;
    }
    // Перемещаемся в свободную ячейку памяти
    sp++;
    // В массиве кода берем следующее за мнемоникой PUSH значение
    // и кладем его в ячейку памяти
    stack[sp] = code[++ip];
    break;
  }
  case POP: {
    // Проверка памяти на пустоту
    if (empty_sp()) {
      printf("Memory is empty\n");
      break;
    }
    // Берем значение с верхушки стека
    int pop_value = stack[sp--];
    // и выводим его
    printf("Result: %d \n", pop_value);
    break;
  }
  case ADD: {
    // Берем два верхних значения стека
    int a = stack[sp--];
    int b = stack[sp--];
    sp++;
    // Складываем их и кладем результат на стек
    stack[sp] = b + a;
    // Выводим сообщение
    printf("ADD->");
    break;
  }
  case SUB: {
    // Берем два верхних значения стека
    int a = stack[sp--];
    int b = stack[sp--];
    sp++;
    // Вычитаем их и кладем результат на стек
    stack[sp] = a - b;
    // Выводим сообщение
    printf("SUB->");
    break;
  }
  case DIV: {
    // Берем два верхних значения стека
    int a = stack[sp--];
    int b = stack[sp--];
    sp++;
    // Делим их и кладем результат на стек
    stack[sp] = a / b;
    // Выводим сообщение
    printf("DIV->");
    break;
  }
  case MUL: {
    // Берем два верхних значения стека
    int a = stack[sp--];
    int b = stack[sp--];
    sp++;
    // Перемножаем их и кладем результат на стек
    stack[sp] = a * b;
    // Выводим сообщение
    printf("DIV->");
    break;
  }
  case RAM: {
    // Это простой цикл вывода всех значений массива
    int x = sp;
    for (; x >= 0; --x) {
      printf("RAM[%u]: %u\n", x, stack[x]);
    }
    break;
}
  case TEST: {
    // Сверка верхнего значения стека с числом 0x31337 
    // Если числа совпадают, выводится сообщение «Good Pass!»,
    // иначе «Bad Pass!»
    stack[sp--] == 0x31337 ? printf("Good Pass!\n") : printf("Bad Pass!\n");
    break;
  }
  case PRINT: {
    printf("PRINT Stack[%u]: %u\n", sp, stack[sp]);
    break;
  }
  case ENTER: {
    printf("ENTER Password: ");
    // Перемещаемся вверх по памяти
    // и при помощи scanf_s записываем данные в наш массив
    // Введенные данные окажутся на верхушке массива
    sp++;
    scanf_s("%i", &stack[sp]);
    break;
  }
  case EXIT: {
    // Установка глобальной переменной в FALSE,
    // чтобы прервать работу VM
    VM = false;
    printf("Exit VM\n");
    break;
  }
  }
}

Разумеется, это не самый стабильный код в мире, и можно добавить еще разные проверки для повышения стабильности, но я попытался найти золотую
середину между сложностью кода и легкостью его восприятия.

Итак, мы реализовали команды виртуальной машины. Давай теперь ее запустим!

C++:
int main() {
  while (VM) { // Переменная, которая контролирует работу VM
    decoder(code[ip]);
    ip++;
  }
  system("pause");
}

Надо сказать, что я установил значение переменной MAXMEM равным единице, чтобы память VM вмещала только два значения.

Нам все равно больше не нужно, но это полезно для демонстрации работы функций, которые контролируют переполнение или опустошение памяти. Вот скриншот работы виртуальной машины.

 
Работа VM

Вроде бы все работает как надо. Теперь давай посмотрим, как распутывать код, который спрятан внутри подобной виртуалки.

На самом деле все очень просто: нужно загрузить этот файл в дизассемблер, найти наш цикл switch/case и посмотреть на места, где
происходит сравнение с нашими константами-инструкциями.

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

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

Получается, потратив некоторое время на анализ виртуальной машины, можно потом написать универсальный скрипт, который будет «снимать» эту VM с любой программы.

Мы ведь будем знать все значения констант-инструкций! Но это не совсем так…

Помнишь перечисление mnemonics, в котором мы присваивали значения нашим командам? Я обещал еще вернуться к нему. Его можно записать немного по-другому:

C++:
static unsigned long time = (unsigned int)__TIMESTAMP__;
#define time x

typedef enum {
  // Положить значение на стек. Этот параметр имеет один аргумент
  PUSH  = 0x00d00201 ^ x,
  // Получить значение со стека. Берется верхушка стека
  POP = 0x00d00205 ^ x,
  // Сложить два верхних значения стека
  ADD = 0x00d00202 ^ x,
  // Вычесть два верхних значения стека
  SUB = 0x00d00206 ^ x,
  // Поделить два значения
  DIV = 0x00d00203 ^ x,
  // Перемножить два значения. Во всех четырех операциях результат кладется на стек
  MUL = 0x00d00204 ^ x,
  // Ввести данные
  ENTER = 0x00d00211 ^ x,
  // Сверка данных с шаблоном
  TEST  = 0x00d00209 ^ x,
  // Вывести верхушку стека
  PRINT = 0x00d00210 ^ x,
  // Вывести все данные, которые находятся в нашей памяти
  RAM = 0x00d00208 ^ x,
  // Завершить работу виртуальной машины
  EXIT  = 0x00d00207 ^ x,
} mnemonics;

Ничего необычного, просто операция xor (побитовое исключающее ИЛИ) применяется к каждому значению кодов наших мнемоник. Но
обрати внимание на то, как инициализируется эта переменная.

C++:
static unsigned long time = (unsigned int)__TIMESTAMP__;

Мы используем предопределенную константу __TIMESTAMP__, приводим ее в число и берем его для xor. Эта константа берет время компиляции, поэтому для каждой скомпилированной программы значения операций будут отличаться. При таком раскладе становится сложнее написать универсальный скрипт, разбирающий VM в автоматическом режиме.

Заключение

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

Click to rate this post!
[Total: 3 Average: 2.7]
cryptoworld

Специалист в области кибер-безопасности. Работал в ведущих компаниях занимающихся защитой и аналитикой компьютерных угроз. Цель данного блога - простым языком рассказать о сложных моментах защиты IT инфраструктур и сетей.

View Comments

Recent Posts

Лучший адаптер беспроводной сети для взлома Wi-Fi

Чтобы взломать сеть Wi-Fi с помощью Kali Linux, вам нужна беспроводная карта, поддерживающая режим мониторинга…

12 месяцев ago

Как пользоваться инструментом FFmpeg

Работа с консолью считается более эффективной, чем работа с графическим интерфейсом по нескольким причинам.Во-первых, ввод…

1 год ago

Как создать собственный VPN-сервис

Конечно, вы также можете приобрести подписку на соответствующую услугу, но наличие SSH-доступа к компьютеру с…

1 год ago

ChatGPT против HIX Chat: какой чат-бот с искусственным интеллектом лучше?

С тех пор как ChatGPT вышел на арену, возросла потребность в поддержке чата на базе…

1 год ago

Разведка по Wi-Fi и GPS с помощью Sparrow-wifi

Если вы когда-нибудь окажетесь в ситуации, когда вам нужно взглянуть на спектр беспроводной связи, будь…

1 год ago

Как обнаружить угрозы в памяти

Elastic Security стремится превзойти противников в инновациях и обеспечить защиту от новейших технологий злоумышленников. В…

1 год ago