Главная » Песочница 2 » Обфускация с помощью виртуализации кода

Обфускация с помощью виртуализации кода

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

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

Обфускация с помощью виртуализации кода

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

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

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

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

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

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

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

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

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

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

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

Как видишь, никакой магии нет! Мы успешно запрограммировали память для нашей будущей VM. Далее переходим к командам. Создадим перечисление под названием mnemonics и заполним его инструкциями для нашей VM (читай комментарии):

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Заключение

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

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *