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

INFO

Читай другие статьи автора по теме дебага и защиты от него.

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

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

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

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

INFO

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

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

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

INFO

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

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

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

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

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

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

// Проверка стека на пустоту
// Функция вернет 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 (читай комментарии):

// Поддерживаемые мнемоники 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;

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

// Исполняемый код
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
};

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

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

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

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

// Трансляция кода 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;
  }
  }
}

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

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

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

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

Работа VM
Работа VM

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

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

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

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

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 (побитовое исключающее ИЛИ) применяется к каждому значению кодов наших мнемоник. Но обрати внимание на то, как инициализируется эта переменная.

static unsigned long time = (unsigned int)__TIMESTAMP__;

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

Заключение

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

  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    4 комментариев
    Старые
    Новые Популярные
    Межтекстовые Отзывы
    Посмотреть все комментарии