На заре компьютерной истории, когда общение с майнфреймами велось исключительно на уровне машинных кодов, уже существовали эмуляторы. В первую очередь это было связано с необходимостью выполнять код программы, написанный для одного типа процессора, на другой машине. В те времена понятие совместимости отсутствовало напрочь, а необходимость выполнения "неродных" для данного процессора программ возникала очень часто.
Большой популярностью пользовались уже забытые сегодня архитектуры с загружаемым набором инструкций, что позволяло процессору исполнять любой код.
Производители аппаратного обеспечения были разобщены, и каждый использовал свой уникальный набор технических и архитектурных решений, зачастую диаметрально противоположный моделям конкурирующих фирм. С высоты сегодняшнего развития очень трудно дать адекватную оценку данной ситуации. С одной стороны последовательный перебор возможных реализаций в поисках оптимального решения бесспорно, способствовал прогрессу. С другой, накладные расходы на создание ПО для каждой новой модели и обучение обслуживающего ее персонала выходили за все разумные границы. Отсутствие стандартов на форматы данных было труднопреодолимой преградой для обмена данными между разными платформами и даже разным программным обеспечением в рамках одной платформы.
Ясно, что этой анархии должен был прийти конец. Но это уже совсем другая история, а до эпохи внедрения стандартов единственным выходом из создавшейся ситуации была программная (реже аппаратная) эмуляция.
Первым серьезным завоеванием эмуляторов стало широкое применение программируемых логических интегральных схем (ПЛИС). Они позволяли программно скомпоновать на одном кристалле электронную схему, эквивалентную аппаратной реализации на стандартных ИС. ПЛИСы представляют собой матрицу логических ячеек, соединенных логическими ключами. Поведение ключей зависит от введенной в память микросхемы логической матрицы (программы). Это позволяет на основе стандартной аппаратной реализации получать различные логические устройства. Таким образом, мы получаем универсальный программно-аппаратный эмулятор, по техническим параметрам ничуть ни уступающий своим прототипам.
Вторым завоеванием стало использование в процессорах Intel 80486+ RISC-ядра, эмулирующего набор инструкций предшествующих моделей. Это дает производительность, сравнимую с "чистыми" RISC-процессорами, но с сохранением программной совместимости с существующим программным обеспечением.
Удивительно, что при такой интенсивной эксплуатации технологий эмуляции доступной информации о последних (особенно на русском языке) ничтожно мало. В рамках данной книги эмуляторы играют едва ли не ключевую роль. Парадоксально, но эмуляция — это мощнейшее оружие двух сторон: злоумышленников и разработчиков систем защиты.
Действительно, против эмулятора бессильны любые антиотладочные приемы, и в них широко используется усиление отладочных и отслеживающих механизмов. Эмуляторы позволяют "отвязывать" ПО от аппаратных электронных ключей, реализуя последние на программном уровне. Еще больше распространены (и легки в изготовлении) "виртуальные" ключевые диски, реализуемые в большинстве случаев через программный интерфейс int 0х13 и только в достаточно мощных защитах посредством перехвата обращений к портам.
Однако использование эмулятора виртуального процессора в системе защиты на несколько порядков повышает трудозатраты на взлом. Для виртуального процессора существующий инструментарий хакера (отладчики, дизассемблеры) окажется бесполезным или в лучшем случае малоэффективным. Попутно заметим, что умение снимать защиту штатными средствами никак не подразумевает наличие навыков создания собственного отладчика или дизассемблера. Кроме того. это настолько трудоемкое занятие, что нужна чрезвычайно стоящая программа, чтобы взлом оказался рентабельным. Не стоит забывать, что тогда как штатные процессоры проектируются из соображений цена/производительность, то виртуально-процессорное "ядро" защиты может быть сконструировано максимально затрудняющим взлом образом. Например, мной был разработан виртуаль' иый процессор, непосредственно работающий с упакованным LZ-кодом. Это приводило к невозможности осуществления "бит-хака" (т.е. изменения пары битов) с приемлемыми трудозатратами. В самом деле, изменение даже одного бита в упакованном LZ-фрагменте приведет к неработоспособности всей программы, а распаковка/редактирование/упаковка изменит длину упакованного фрагмента, что приведет к искажению всех ссылок и смещений внутри его. Поэтому необходимо как минимум полное дизассемблирование с последующим отслеживанием смещений (которые лексически неотличимы от констант) и, далее, ассемблированием. В большинстве случаев такие затраты труда будут нерентабельны.
Словом, нужно вытянуть хакера из привычного ему окружения и заставить играть по другим правилам. В последнее время, правда, в арсенале хакеров появился достаточно мощный инструментарий, облегчающий решение поставленной задачи. Один из популярнейших дизассемблеров — IDA — имеет встроенную "виртуальную" машину, позволяющую загрузить в нее логику любого виртуального процессора за минимальное время. Поэтому на первый план борьбы выходит усложнение архитектуры виртуального процессора до такой степени, чтобы его анализ требовал высокой квалификации взломщика и был чрезвычайно трудоемким и утомительным. По своему опыту могу сказать, что наиболее сложен анализ многопоточных виртуальных машин с динамическим набором команд и множеством циклов выборки и декодирования. Дополнительным преимуществом таких моделей является улучшенная производительность. К сожалению, виртуальные машины теряют все свои достоинства в серийных защитах. Если хакер может использовать результаты анализа одной виртуальной машины для взлома множества построенных на ней программ, то рентабельность взлома приблизится к единице, а это сведет на нет все технические и финансовые издержки использования виртуальных машин.
Популярные системы программирования Бейсик, ФоксПро являются типичными виртуальными машинами. К моему удивлению, такая трактовка встретила возражение со стороны ряда лиц, однако интерпретатор — это рядовая виртуальная машина, разве что спроектированная в первую очередь для удобства общения, а не для большей производительности, но разницы между ядром интерпретатора и эмулятора процессора практически нет.
Рассмотрим подробнее механизмы эмуляторов. Прежде всего, любой программный эмулятор состоит из следующих функциональных частей: модуля лексического анализа, цикла выборки команд, блока декодирования инструкций и эмулятора АЛУ (арифметическо-логического устройства). Задача эмуляции АЛУ упрощается тем, что в большинстве случаев набор арифметических и логических операций может быть выполнен базовым процессором, поэтому большей частью АЛУ представляют собой простые переходники. Блок исполнения микрокода в разных архитектурах может примыкать непосредственно к блоку декодирования инструкций или АЛУ, или даже может быть выделен в независимый модуль. На программном уровне эмулятора он представляет собой просто библиотеку функций, реализованную на множестве команд базового процессора. Например, пусть в гипотетической виртуальной машине присутствует инструкция CalculateCRC32. Разумеется, для ее реализации потребуется написать на 80х86 специальную подпрограмму, поскольку непосредственно он не обеспечивает такой возможности. Но почему бы реализацию этой функции не отнести к АЛУ? Действительно, некоторые (не самые лучшие архитектуры) относят эту инструкцию к АЛУ, но это не только нс самое лучшее, но и иерархически не правильное решение! АЛУ должно обеспечивать базовый арифметико-логический набор функций, на котором строится все подмножество команд виртуальной машины. В таком случае любая команда исполняется по цепочке "Базовый CPU—>АЛУ—>Блок исполнения микрокода". Сам блок исполнения непосредственного доступа к CPU не имеет. Такая архитектура упростит перенос эмулятора на другие платформы, а также облегчит процесс отладки эмулятора.
Рассмотрим представленную архитектуру на примере гипотетического эмулятора 8086 процессора.
E2 72 90 90 ………………………..
Блок выборки Блок исполнения микрокода
Блок декодирования АЛУ
Пусть указатель команд ernIP указывает на начало команды LOOP 0х77. Задачей блока выборки инструкций будет выбрать инструкцию из байта ОхЕ2. Как известно, в 80х86-процессорах код команды занимает 6 старших битов. Но в нашем случае команда занимает все восемь. Все остальное — это операнды. Теперь мы имеем два варианта программной реализации выборки команды. Можно продолжить анализ операндов или установить регистр ernIP на начало первого операнда и поручить оставшуюся работу блоку декодирования. Если блок декодирования не может разобраться в числе и размере операндов команды, то окончательное позиционирование регистра егп1Р осуществит блок исполнения микрокода. Эти решения называются соответственно Альфа-, Бета- и Гамма-декодерами. С точки зрения канонического ООП каждый объект, представленный в нашем случае командой, должен самостоятельно отвечать за формат операндов. Поручение этого отдельному модулю вызывает необходимость унификации операндов всех команд, что далеко не всегда удобно. Процессоры 80х86 имеют жесткую систему адресации операндов, поэтому лучшим вариантом для них будет Бета-декодер. RISC-процессоры оперируют командами фиксированного размера, поэтому всегда реализуются через Альфа-декодеры.
В нашем примере на входе в блок декодирования регистр ernIP указывает на первый операнд 0х72. Переданная блоком выборки команда ОхЕ2 ожидает только одного операнда размером в байт, блок декодирования загружает этот байт и смещает указатель команд. Но что же представляет собой этот байт? Правильно, короткий относительный адрес перехода от текущего указателя. Вопрос: кто возьмется его преобразовать в абсолютный адрес? Можно поручить это специальному блоку формирования адреса, можно обработать непосредственно в декодере или поручить обработчику конкретной команды. Вариантов, как мы видим, много, и правильный выбор сделать трудно. На архитектуре младших моделей Intel формировать физический адрес можно непосредственно в блоке декодирования, во уже для 80286 эмуляцию памяти выгодно выполнять отдельным модулем.
На блок исполнения микрокода мы подаем готовый олкод инструкции и сформированный физический адрес. На уровне микрокода команда LOOP представляется как DEC emCXJNZ addr. Обе эти инструкции относятся к элементарным и вызываются из АЛУ. Многие разработчики допускают очевидную ошибку и вызывают DEC и JNZ базового процессора. Это работает, но часто приводит к трудно обнаруживаемым ошибкам и нарушает всю иерархию команд.
Для реализации АЛУ требуется еще один заключенный, но нс показанный в нем модуль. Это, конечно, HAL — модуль абстрагирования от базового оборудования. Необходимо так спланировать эмулятор, чтобы HAL получился по возможности компактным и легко переносимым.
Один из главных компонентов в HAL — это регистрово-адресный преобразователь. Поскольку архитектура виртуальных регистров и памяти зачастую отличается от физической, то постоянно требуется преобразователь. В простейшем случае все виртуальные регистры отображаются на физическую память, а виртуальная память реализуется через эмулятор диспетчера страниц. В простейшем случае он отсутствует, а память эмулятора проецируется на выделенный фрагмент физической памяти.
Теперь мы создадим свою виртуальную машину и напишем для нее простой пример защиты, который вскроем написанным дизассемблером.
Сначала нужно разработать архитектуру виртуальной машины. Пусть это будет простой RISC-процессор с фиксированным набором команд и жесткой адресацией памяти. Если команда не требует операндов, то все равно они должны присутствовать, но их значение игнорируется. Для простоты ограничимся минимальным набором команд. Из арифметических будет достаточно команды ADD, единственной логической конструкции if (a,b) go to Bellow, Equiar, Above, имеющей следующую логику:
если: a<b go to Beilow
a=b go to Equiar
a>b go to Above
Этих двух команд вполне достаточно для реализации микроядра, но для удобства мы добавим команду безусловного перехода и вызова/возврата процедуры.
Пусть будут два адресных пространства для кода и данных и один-единствен-ный порт ввода-вывода. Он будет предназначен для виртуального телетайпа. Запись в порт приведет к появлению символа на экране, а чтение — к вводу символа с клавиатуры.
Замечу, что большинство виртуальных машин не используют архитектуру портов, а реализуют данные функции непосредственно в командах виртуального процессора. Выбор конкретной реализации всегда остается за разработчиком, но использование виртуальных портов не только хороший стиль, но и позволяет "присоединять" любые виртуальные устройства ко множеству виртуальных портов или переходники к физическим. Точно так же можно организовать и межпроцессорное взаимодействие виртуальных машин.