Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Есть множество решений для защиты программ, которые определяют работу внутри изолированных сред, работают с антиотладочными приемами, контролируют целостность своего кода и динамически шифруют свои данные в памяти, защищаясь от дампа. Еще одна мощная техника защиты — это виртуализация кода. В этой статье я покажу, как она работает.
Здесь я имею в виду не виртуальные машины вроде VirtualBox или VMware, а те, при помощи которых запутывают исполняемый код, чтобы затруднить анализ программной логики. В этой статье мы коснемся принципов работы виртуальных машин, компиляторов, трансляции кода, а также напишем свою виртуальную машину, которая будет понимать наш собственный язык программирования.
Итак, виртуальные машины, предназначенные для запутывания кода, основаны на идее замены «обычного» байт-кода, который, например, используется в архитектуре x86-64, на тот байт-код, который мы изобретем сами. Чтобы реконструировать поток управления в программе, подвергшейся виртуализации, необходимо проанализировать каждый опкод и разобраться, что он делает. Чтобы понимать, что происходит, нужно немного коснуться работы процессора — ведь, по сути, перед нами стоит задача «написать процессор».
Нам предстоит написать некое подобие транслятора-интерпретатора кода — чтобы исходный код, который мы будем писать, начал обрабатываться внутри нашей виртуальной машины. Можно провести аналогию с процессорами: современные процессоры представляют собой сложные устройства, которые управляются микрокодом. Многие наборы инструкций, особенно современные, типа Advanced Vector Extensions (AVX), — это, по сути, подпрограммы на микрокоде процессора, который, в свою очередь, напрямую взаимодействует с железом процессора.
Получается, что современные процессоры похожи больше на софт, а не на железо: сложные инструкции типа VBROADCASTSS
, VINSERTF128
, VMASKMOVPS
реализованы исключительно «софтверно» при помощи программ, состоящих из микрокодов. А таких наборов инструкций, как ты, возможно, знаешь, много — достаточно открыть техническое описание какого-нибудь Skylake и посмотреть на поддерживаемые наборы инструкций.
Нам необходимо будет эмулировать, помимо работы процессора, работу памяти (RAM). Для этого мы воспользуемся реализацией собственного стека, который будет работать по принципу LIFO.
В этом нет ничего сложного — по сути, это просто массив данных с указателем на них. Для наглядности код:
// Размер памяти 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 (читай комментарии):
Источник: xakep.ru