На заре компьютерной истории, когда общение с мейнфреймами велось исключительно на уровне машинных кодов, уже существовали эмуляторы. В первую очередь это было связано с необходимостью выполнять код программы, написанный для одного типа процессора на другой машине. В те времена понятие совместимости отсутствовало напрочь, а необходимость выполнения "не родных" для данного процессора программ возникала очень часто.
Большой популярностью пользовались уже забытые сегодня архитектуры с загружаемым набором инструкций, что позволяло процессору исполнять любой код.
Производители аппаратного обеспечения были разобщены, и каждый использовал свой уникальный набор технических и архитектурных решений, зачастую диаметрально противоположный моделям конкурирующих фирм. С высоты сегодняшнего развития очень трудно дать адекватную оценку данной ситуации. С одной стороны, это, бесспорно, способствовало прогрессу путем последовательного перебора возможных реализаций для нахождения оптимального решения. С другой стороны, накладные расходы на создание ПО для каждой новой модели и обучение обслуживающего персонала выходили за все разумные границы. Отсутствие стандартов на форматы данных было труднопреодолимой преградой для обмена данными между разными платформами и даже между разным программным обеспечением в рамках одной платформы.
Ясно, что этой анархии должен был прийти конец. Но это уже совсем другая история, а до эпохи внедрения стандартов единственным выходом из создавшейся ситуации была программная, реже аппаратная, эмуляция.
Первым серьезным завоеванием эмуляторов стало широкое применение программируемых логических интегральных схем (ПЛИС). Они позволяли программно скомпоновать на одном кристалле электронную схему, эквивалентную аппаратной реализации на стандартных ИС. ПЛИСы представляют собой матрицу логических ячеек, соединенных логическими ключами. Поведение ключей зависит от введенной в память микросхемы логической матрицы (программы). Это позволяет на основе стандартной аппаратной реализации получать различные логические устройства. Таким образом, мы получаем универсальный программно-аппаратный эмулятор, по техническим параметрам ничуть не уступающий своим прототипам.
Вторым завоеванием стало использование в процессорах Intel 80486+ RISC-ядра, эмулирующего набор инструкций предшествующих моделей. Это дает производительность, сравнимую с "чистыми" RISC-процессорами, но с сохранением совместимости с существующим программным обеспечением.
Удивительно, что при такой интенсивной эксплуатации технологий эмуляции доступной информации о таких технологиях ничтожно мало, особенно на русском языке. В рамках тематики данной книги эмуляторы играют едва ли не ключевую роль. Парадоксально, но эмуляция - это мощнейшее оружие двух сторон - злоумышленников и разработчиков систем защиты.
Действительно, против эмулятора бессильны любые антиотладочные приемы, и в них широко используется усиление отладочных и отслеживающих механизмов. Эмуляторы позволяют "отвязывать" ПО от аппаратных электронных ключей, реализуя их на программном уровне. Еще больше распространены и легки в изготовлении "виртуальные" ключевые диски, реализуемые в большинстве случаев через программный интерфейс int 0x13, и только в достаточно мощных защитах - посредством перехвата обращений к портам.
Использование эмулятора виртуального процессора в системе защиты на несколько порядков повышает трудозатраты на взлом. Для виртуального процессора существующий инструментарий хакера (отладчики, дизассемблеры) окажется бесполезным или в лучшем случае малоэффективным. Попутно заметим, что умение снимать защиту штатными средствами никак не подразумевает наличие навыков создания собственного отладчика или дизассемблера. Кроме того, это настолько трудоемкое занятие, что нужна чрезвычайно стоящая программа, чтобы взлом оказался рентабельным. Не стоит забывать, что тогда как штатные процессоры проектируются из соображений достижения лучшего соотношения цена/производительность, то виртуально-процессорное "ядро" защиты может быть сконструировано максимально затрудняющим взлом образом. Например, мною был разработан виртуальный процессор, непосредственно работающий с упакованным LZ кодом. Это приводило к невозможности осуществления "бит-хака" (т.е. изменения пары байт) приемлемыми трудозатратами. В самом деле, изменение даже одного бита в упакованном LZ фрагменте приведет к неработоспособности всей программы, а распаковка, редактирование и упаковка изменят длину упакованного фрагмента, что приведет к искажению всех ссылок и смещений внутри него. Поэтому, как минимум, необходимо полное дизассемблирование с последующим отслеживанием смещений, которые лексически неотличимы от констант, и последующего ассемблирования. В большинстве случаев указанные трудозатраты окажутся нерентабельными.
Словом, нужно вытянуть хакера из привычного для него окружения и заставить играть по своим правилам. В последние время, правда, в арсенале хакеров появился достаточно мощный инструментарий, облегчающий решение поставленной задачи. Один из популярнейших дизассемблеров, - IDA, - имеет встроенную "виртуальную" машину, позволяющую загрузить в нее логику любого виртуального процессора за минимальное время. Поэтому на первый план борьбы выходит усложнение архитектуры виртуального процессора до такой степени, чтобы его анализ требовал высокой квалификации взломщика и был чрезвычайно трудоемким и утомительным. По своему опыту могу сказать, что наиболее сложен анализ многопоточных виртуальных машин с динамическим набором команд и множеством циклов выборки и декодирования. Дополнительной выгодой таких моделей является улучшенная производительность. К сожалению, виртуальные машины теряют все свои достоинства в серийных защитах. Если хакер результаты анализа одной виртуальной машины может использовать для взлома множества построенных на ее основе программ, то рентабельность взлома приблизится к единице, а это сведет на нет все технические и финансовые затраты, направленные на создание виртуальных машин.
Популярные системы программирования Бейсик и ФоксПро являются типичными виртуальными машинами (мы говорим, конечно, только об интерпретаторах). К моему удивлению такая трактовка встретила возражение со стороны ряда лиц, однако, интерпретатор - это рядовая виртуальная машина, разве что спроектированная, в первую очередь, для удобства общения, а не для достижения высокой производительности. Но разницы между ядром интерпретатора и эмулятора процессора практически нет.
Давайте подробнее рассмотрим механизмы эмуляторов. Прежде всего, любой программный эмулятор будет состоять из следующих функциональных частей: модуля лексического анализа, цикла выборки команд, блока декодирования инструкций и эмулятора АЛУ (Арифметико-Логического Устройства). Задача эмуляции АЛУ упрощается тем, что в большинстве случаев набор арифметический и логических операций может быть выполнен базовым процессором, поэтому большая часть АЛУ представляет собой простые переходники. Блок исполнения микрокода в разных архитектурах может примыкать как непосредственно к блоку декодирования инструкций или АЛУ, или же может быть выделен в независимый модуль.
На программном уровне эмулятора он представляет собой просто библиотеку функций, реализованную на множестве команд базового процессора. Например, пусть в гипотетической виртуальной машине присутствует инструкция CalculateCRC32. Разумеется, для ее реализации потребуется написать на 80x86 специальную подпрограмму, поскольку непосредственно процессор Intel 80x86 не обеспечивает такой возможности. Но почему бы реализацию этой функции не отнести к АЛУ? Действительно, некоторые, не самые лучшие архитектуры, относят эту инструкцию к АЛУ, но это не только не самое лучшее, но и иерархически неправильное решение! АЛУ должно обеспечивать базовый набор арифметико-логический функций, на котором строится все подмножество команд виртуальной машины. В таком случае любая команда исполняется по цепочке Базовый CPU->АЛУ->Блок исполнения микрокода. Сам блок исполнения непосредственного доступа к CPU не имеет. Такая архитектура упростит перенос эмулятора на другие платформы, а так же облегчит процесс отладки эмулятора.
Рассмотрим представленную архитектуру на примере гипотетического эмулятора процессора Intel 80086.
Пусть указатель команд emIP указывает на начало команды LOOP 0x77. Задачей блока выборки инструкций будет выбрать инструкцию из байта 0xE2. Как известно в процессорах 80x86 код команды занимает 6 старших битов. Но в нашем случае команда занимает все восемь. Все остальное - это операнды. Теперь мы имеем два варианта программной реализации выборки команды. Можно продолжить анализ операндов, а можно установить регистр emIP на начало первого операнда и поручить оставшуюся работу блоку декодирования. Если блок декодирования не может разобраться в числе и размере операндов команды, то окончательное позиционирование регистра emIP осуществит блок исполнения микрокода. Соответственно эти решения называются Альфа, Бета и Гамма декодерами. С точки зрения канонического ООП каждый объект, представленный, в нашем случае, командой, должен самостоятельно отвечать за формат операндов. Поручение этого отдельному модулю вызывает необходимость унификации операндов всех команд, что далеко не всегда удобно. Процессоры 80x86 имеют жесткую систему адресаций операндов, поэтому лучшим вариантом для них будет Бета-декодер. RISC процессоры оперируют командами фиксированного размера, поэтому всегда реализуются через Альфа-декодеры.
В нашем примере на входе в блок декодирования регистр emIP указывает на первый операнд 0х72. Переданная блоком выборки команда 0xE2 ожидает только одного операнда размером в один байт, блок декодирования загружает этот байт и смещает указатель команд. Но что же представляет собой этот байт? Правильно, короткий относительный адрес перехода от текущего указателя. Вопрос - кто возьмется его преобразовать в абсолютный адрес? Можно поручить это специальному блоку формирования адреса, можно обработать непосредственно в декодере или поручить обработчику конкретной команды. Вариантов, как мы видим, много, и правильный выбор сделать трудно. На архитектуре младших моделей Intel формировать физический адрес можно непосредственно в блоке декодирования, но уже для 80286 эмуляцию памяти выгодно выполнять отдельным модулем.
На блок исполнения микрокода мы подаем готовый опкод инструкции и сформированный физический адрес. На уровне микрокода команда LOOP представляется как DEC emCX/JNZ addr. Обе эти инструкции относятся к элементарным и вызываются из АЛУ. Многие разработчики допускают очевидную ошибку и вызывают DEC и JNZ базового процессора. Это работает, но часто приводит к трудно обнаруживаемым ошибкам и нарушает всю иерархию команд.
Для реализации АЛУ требуется еще один заключенный в нем, но не показанный модуль. Это, конечно, HAL - Hardware Abstraction Layer - Модуль Абстрагирования от Аппарата (или Уровень Аппаратных Абстракций). Необходимо так спланировать эмулятор, что бы HAL получился по возможности компактным и легко переносимым.
Один из главных компонентов HAL - это регистрово-адресный преобразователь. Поскольку архитектура виртуальных регистров и памяти зачастую отличается от физической, то постоянно требуется преобразователь. В простейшем случае все виртуальные регистры отображаются на физическую память, а виртуальная память реализуется через эмулятор диспетчера страниц. В простейшем случае он отсутствует, а память эмулятора проецируется на выделенный фрагмент физической памяти.
Теперь мы создадим свою виртуальную машину и напишем для нее простой пример защиты, который вскроем написанным дизассемблером.
Сначала нужно разработать архитектуру виртуальной машины. Пусть это будет простой RISC-процессор с фиксированным набором команд и жесткой адресацией памяти. Если команда не требует операндов, то все равно они должны присутствовать, но их значение игнорируется. Для простоты ограничимся минимальным набором команд. Из арифметических будет достаточно команды ADD, единственной логической конструкцией - if (a,b) go to Below, Eqular, Above, логика которой следующая:
если: a<b go to Below a=b go to Eqular a>b go to Above
Этих двух команд вполне достаточно для реализации микроядра, но для удобства мы добавим команду безусловного перехода и вызова/возврата процедуры.
Пусть будут два адресных пространства для кода и данных и один-единственный порт ввода-вывода. Он будет предназначен для виртуального телетайпа. Запись в порт приведет к появлению символа на экране, а чтение - к вводу символа с клавиатуры.
Замечу, что большинство виртуальных машин не использует архитектуру портов, а реализуют данные функции непосредственно в командах виртуального процессора. Выбор конкретной реализации всегда остается за разработчиком, но использование виртуальных портов является не только хорошим стилем, но и позволяет ко множеству виртуальных портов "присоединять" любые виртуальные устройства или переходники к физическим. Точно так же можно организовать и межпроцессорное взаимодействие виртуальных машин.
Заказ книги по почте: solon.pub@relcom.ru или Rem.Serv@relcom.ru
Телефоны: (095) 252-72-79 или (095) 254-44-10