Справочник функций

Ваш аккаунт

Войти через: 
Забыли пароль?
Регистрация
Информацию о новых материалах можно получать и без регистрации:

Почтовая рассылка

Подписчиков: -1
Последний выпуск: 19.06.2015

Техника и философия хакерских атак

Крис Касперски

Продолжение...

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

Первым серьезным завоеванием эмуляторов стало широкое применение программируемых логических интегральных схем (ПЛИС). Они позволяли программно скомпоновать на одном кристалле электронную схему, эквивалентную аппаратной реализации на стандартных ИС. ПЛИСы представляют собой матрицу логических ячеек, соединенных логическими ключами. Поведение ключей зависит от введенной в память микросхемы логической матрицы (программы). Это позволяет на основе стандартной аппаратной реализации получать различные логические устройства. Таким образом, мы получаем универсальный программно-аппаратный эмулятор, по техническим параметрам ничуть ни уступающий своим прототипам.

Вторым завоеванием стало использование в процессорах Intel 80486+ RISC ядра, эмулирующего набор инструкций предшествующих моделей. Это дает производительность, сравнимую с "чистыми" RISC процессорами, но с сохранением программной совместимости с существующим программным обеспечением.

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

Действительно, против эмулятора бессильны любые антиотладочные приемы, и в них широко используется усиление отладочных и отслеживающих механизмов. Эмуляторы позволяют "отвязывать" ПО от аппаратных электронных ключей, реализуя последние на программном уровне. Еще больше распространены (и легки в изготовлении) "виртуальные" ключевые диски, реализуемые в большинстве случаев через программный интерфейс int 0x13 и только в достаточно мощных защитах посредством перехвата обращений к портам.

Однако использование эмулятора виртуального процессора в системе защиты на несколько порядков повышает трудозатраты на взлом. Для виртуального процессора существующий инструментарий хакера (отладчики, дизассемблеры) окажется бесполезным или в лучшем случае малоэффективным. Попутно заметим, что умение снимать защиту штатными средствами никак не подразумевает наличие навыков создания собственного отладчика или дизассемблера. Кроме того, это настолько трудоемкое занятие, что нужна чрезвычайно стоящая программа, чтобы взлом оказался рентабельным. Не стоит забывать, что тогда как штатные процессоры проектируются из соображений цена\производительность, то виртуально-процессорное "ядро" защиты может быть сконструировано максимально затрудняющим взлом образом. Например, мной был разработан виртуальный процессор, непосредственно работающий с упакованным LZ кодом. Это приводило к невозможности осуществления "бит-хака" (т.е. изменения пары байт) с приемлемыми трудозатратами. В самом деле, изменение даже одного бита в упакованном LZ фрагменте приведет к неработоспособности всей программы, а распаковка\редактирование\упаковка изменит длину упакованного фрагмента, что приведет к искажению всех ссылок и смещений внутри его. Поэтому необходимо как минимум полное дизассемблирование с последующим отслеживанием смещений (которые лексически неотличимы от констант) и, далее, ассемблированием. В большинстве случаев такие затраты труда будут нерентабельны.

Словом, нужно вытянуть хакера из привычного ему окружения и заставить играть по другим правилам. В последнее время, правда, в арсенале хакеров появился достаточно мощный инструментарий, облегчающий решение поставленной задачи. Один из популярнейших дизассемблеров - IDA - имеет встроенную "виртуальную" машину, позволяющую загрузить в нее логику любого виртуального процессора за минимальное время. Поэтому на первый план борьбы выходит усложнение архитектуры виртуального процессора до такой степени, чтобы его анализ требовал высокой квалификации взломщика и был чрезвычайно трудоемким и утомительным. По своему опыту могу сказать, что наиболее сложен анализ многопоточных виртуальных машин с динамическим набором команд и множеством циклов выборки и декодирования. Дополнительным преимуществом таких моделей является улучшенная производительность. К сожалению, виртуальные машины теряют все свои достоинства в серийных защитах. Если хакер может использовать результаты анализа одной виртуальной машины для взлома множества построенных на ней программ, то рентабельность взлома приблизится к единице, а это сведет на нет все технические и финансовые издержки использования виртуальных машин.

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

Рассмотрим подробнее механизмы эмуляторов. Прежде всего, любой программный эмулятор состоит из следующих функциональных частей: модуля лексического анализа, цикла выборки команд, блока декодирования инструкций и эмулятора АЛУ (арифметическо-логического устройства). Задача эмуляции АЛУ упрощается тем, что в большинстве случаев набор арифметических и логических операций может быть выполнен базовым процессором, поэтому большей частью АЛУ представляют собой простые переходники. Блок исполнения микрокода в разных архитектурах может примыкать непосредственно к блоку декодирования инструкций или АЛУ, или даже можеи быть выделен в независимый модуль. На программном уровне эмулятора он представляет собой просто библиотеку функций, реализованную на множестве команд базового процессора. Например, пусть в гипотетической виртуальной машине присутствует инструкция CalculateCRC32. Разумеется, для ее реализации потребуется написать на 80x86 специальную подпрограмму, поскольку непосредственно он не обеспечивает такой возможности. Но почему бы реализацию этой функции не отнести к АЛУ? Действительно, некоторые (не самые лучшие архитектуры) относят эту инструкцию к АЛУ, но это не только не самое лучшее, но и иерархически не правильное решение! АЛУ должно обеспечивать базовый арифметическо-логический набор функций, на котором строится все подмножество команд виртуальной машины. В таком случае любая команда исполняется по цепочке "Базовый CPU->АЛУ->Блок исполнения микрокода". Сам блок исполнения непосредственного доступа к CPU не имеет. Такая архитектура упростит перенос эмулятора на другие платформы, а также облегчит процесс отладки эмулятора.

Рассмотрим представленную архитектуру на примере гипотетического эмулятора 8086 процессора.

              -----------------------------------------
                  E2 72 90 90 90 .. .. .. .. .. ..
              -----------------------------------------
                  ¦   ¦
                  ¦   ¦
                     ¦
      ---------------¬¦       ----------------------------¬
      ¦ блок выборки ¦¦       ¦ блок исполнения микрокода ¦
      L-----------T---¦       L----------------------------
                                                
                ---------------------¬       -----------¬
                ¦ блок декодирования ¦       ¦   АЛУ    ¦
                L---------------------       L-----------

Пусть указатель команд emIP указывает на начало команды LOOP 0x77. Задачей блока выборки инструкций будет выбрать инструкцию из байта 0xE2. Как известно, в 80x86 процессорах код команды занимает 6 старших битов.Но в нашем случае команда занимает все восемь. Все остальное - это операнды. Теперь мы имеем два варианта программной реализации выборки команды. Можно продолжить анализ операндов или установить регистр emIP на начало первого операнда и поручить оставшуюся работу блоку декодирования. Если блок декодирования не может разобраться в числе и размере операндов команды, то окончательное позиционирование регистра emIP осуществит блок исполнения микрокода. Эти решения называются соответственно Альфа-, Бета- и Гамма- декодерами. С точки зрения канонического ООП каждый объект, представленный в нашем случае командой, должен самостоятельно отвечать за формат операндов. Поручение этого отдельному модулю вызывает необходимость унификации операндов всех команд, что далеко не всегда удобно. Процессоры 80x86 имеют жесткую систему адресации операндов, поэтому лучшим вариантом для них будет Бета-декодер. RISC процессоры оперируют командами фиксированного размера, поэтому всегда реализуются через Альфа-декодеры.

В нашем примере на входе в блок декодирования регистр emIP указывает на первый операнд 0х72. Переданная блоком выборки команда 0xE2 ожидает только одного операнда размером в байт, блок декодирования загружает этот байт и смещает указатель команд. Но что же представляет собой этот байт? Правильно, короткий относительный адрес перехода от текущего указателя. Вопрос: кто возьмется его преобразовать в абсолютный адрес? Можно поручить это специальному блоку формирования адреса, можно обработать непосредственно в декодере или поручить обработчику конкретной команды. Вариантов, как мы видим, много, и правильный выбор сделать трудно. На архитектуре младших моделей Интела формировать физический адрес можно непосредственно в блоке декодирования, но уже для 80286 эмуляцию пямяти выгодно выполнять отдельным модулем.

На блок исполнения микрокода мы подаем готовый опкод инструкции и сформированный физический адрес. На уровне микрокода команда LOOP представляется как DEC emCX\JNZ addr. Обе эти инструкции относятся к элементарным и вызываются из АЛУ. Многие разработчики допускают очевидную ошибку и вызвают DEC и JNZ базового процессора. Это работает, но часто приводит к трудно обнаруживаемым ошибкам и нарушает всю иерархию команд.

Для реализации АЛУ требуется еще один заключенный, но не показанный в нем модуль. Это, конечно, HAL - модуль абстрагирования от базового оборудования. Необходимо так спланировать эмулятор, чтобы HAL получился по возможности компактным и легко переносимым.

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

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

Сначала нужно разработать архитектуру виртуальной машины. Пусть это будет простой RISC процессор с фиксированным набором команд и жесткой адресацией памяти. Если команда не требует операндов, то все равно они должны присутствовать, но их значение игнорируется. Для простоты ограничимся минимальным набором команд. Из арифметических будет достаточно команды ADD, единственной логической конструкции if (a,b) go to Bellow, Eqular, Above, имеющей следующую логику:

если :

a<b go to Bellow

a=b go to Eqular

a>b go to Above

Этих двух команд вполне достаточно для реализации микроядра, но для удобства мы добавим команду безусловного перехода и вызова\возврата процедуры.

Пусть будут два адресных пространства для кода и данных и один-единственный порт ввода-вывода. Он будет предназначен для виртуального телетайпа. Запись в порт приведет к появлению символа на экране, а чтение - к вводу символа с клавиатуры.

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

Тонкости дизассемблирования

Дизассемблирование в уме

- Мне известны политические аргументы. - Но меня интересуют человеческие доводы.

Ф. Херберт. "Мессия Дюны".

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

Предположим, у нас есть простейший шестнадцатиричный редактор, вроде того, какой встроен в DN, и, если очень повезет, debug.com, входящий ~~11 в поставку Windows и часто остающийся не удаленным владельцами машины. Вот этим-то мы и воспользуемся. Скажу сразу, что придется очень нелегко. Большая часть нижеописанного требует труда и упорства, но дает вам практически неограниченную власть над техникой и владеющими ею людьми.

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

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

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

Структура команд INTEL 80x86

- Потому ты и опасен, что овладел своими страстями.

Ф. Херберт. "Мессия Дюны".

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

Для начала разберемся с форматом инструкций архитектуры Intel:

 ----------T-------T--------T-----T----------T-------------------¬
 ¦ префикс ¦ опкод ¦ ModR/M ¦ SIB ¦ смещение ¦ непосред. операнд ¦
 L---------+-------+--T-----+---T-+----------+--------------------
                      ¦         ¦
           ------------         L--------------¬
                                              
 --------------------T-----¬       --------T-------T-------¬
 ¦ Mod  ¦ Reg/Opcode ¦ R/M ¦       ¦ Scale ¦ Index ¦ Base  ¦
 L-------------------+------       L-------+-------+--------

Заметим, что все поля, кроме поля опкода, являются факультативными. Т.е. в одних командах они могут присутствовать, а в других нет.

Само поле опкода занимает восемь бит и часто (но не всегда) имеет следующий формат:

                     -----------T-------------T--------¬
                     ¦          ¦ Направление ¦ Размер ¦
                     ¦       ---+-------------+--------+
                     ¦ Опкод ¦      р е г и с т р      ¦
                     ¦    ---+----------------T--------+
                     ¦    ¦    у с л о в и е  ¦ инверс ¦
                     L----+-------------------+---------
                     7    4  3  2             1        0

Поле размера равно нулю, если операнды имеют размер в байт. Единичное значение указывает на слово (двойное слово в 32-режиме или с префиксом 0х66 в 16-разрядном режиме).

Направление обозначает операнд-приемник. Нулевое значение присваивает результат правому операнду, а единица левому. Рассмотрим это на примере инструкции mov bx,dx:

8BDA                         mov       bx,dx 
^^ 
10001011b 

89DA                         mov       dx,bx 
^^ 
10001001b 

Не правда ли, мы можем как по мановению волшебной палочки менять местами операнды, меняя всего один бит? Однако задумаемся, как это поле будет вести себя, когда один из операндов имеет непосредственное значение? Разумеется, оно не может быть приемником и независимо от значения этого поля будет только источником. Инженеры Интел учли такую ситуацию и нашли оригинальное применение, часто экономящее до трех байтов. Рассмотрим ситуацию, когда операнду размером в слово или двойное слово присваивается непосредственное значение, меньшее по модулю 0x100. Ясно, что значащим является только младший байт, а нули, стоящие слева, по правилам математики можно и отбросить. Но попробуйте объяснить это процессору! Нужно пожертвовать хотя бы одним битом, чтобы указать ему на такую ситуацию. Вот тут-то и используется байт направления. Рассмотрим следующую команду:

810623016600 add w,[00123],0066 ^^ ^^^^ ^^^^

¦¦            *
L+----> 10000001

Если теперь флаг направления установить в единицу, то произойдет следующее

8306230166 add w,[00123],0066 ^^ ^^ ^^^^

¦¦
¦¦            *
L+----> 10000011

Таким образом, мы экономим один байт в 16-разрядном режиме и целых три в 32-разрядном. Этот факт следует учитывать при написании самомодифицирующегося кода. Большинство ассемблеров генерируют второй (оптимизированный) вариант, и длина команды оказывается меньше ожидаемой. На этом, кстати, основан очень любопытный прием против отладчиков. Посмотрите на следующий пример:

00000100: 810600010200                 add       w,[00100],00002 
00000106: B406      ^^                 mov       ah,006 
00000108: B207                         mov       dl,007 
0000010A: CD21                         int       021 
0000010C: C3                           retn 

После выполнения инструкция в строке 0х100 приобретет следующий вид:

00000100: 8206000102 add b,[00100],002 00000105: 00

¦B406B2                    add       [si][0B206],dh
          ^^¦
            L----> ip

Т.е. текущая команда станет на байт короче! И "отрезанный" ноль теперь - часть другой команды! Но при выполнении на "живом" процессоре этого не произойдет, т.к. следующее значение ip вычисляется еще до выполнения команды на стадии ее декодирования.

Совсем другое дело - отладчики, и особенно отладчики-эмуляторы, которые часто вычисляют значение ip _после_ выполнения команды (это легче запрограммировать). В результате наступает крах. Несущественное, казалось бы обстоятельство - до или после выполнения команды вычисляется ip - оказалось роковым. Приведу в подтверждение дамп экрана:

г=[¦]=CPU Pentium Pro============================T=======1=[][]=¬
¦  cs:0100 8306000102     add    word ptr [0100],  ax 0000   ¦c=0¦
¦  cs:0105 00B406B2       add    [si-4DFA],dh    ¦  bx 0000   ¦z=0¦
¦  cs:0109 07             pop    es              -  cx 0000   ¦s=0¦
¦  cs:010A CD21           int    21              -  dx 0000   ¦o=0¦
¦  cs:010C C3             ret                    -  si 0000   ¦p=0¦

Заметим, что этот прием может быть бессилен против трассирующих отладчиков (Debug.com, DeGlucker, Cup386), поскольку значение ip за них вычисляет процессор и вычисляет его правильно.

Однако на "обычные" отладчики управа всегда найдется (см. соответствующую главу), а с эмуляционными справиться гораздо труднее и приведенный пример один из немногих, где обеспечивается воздействие на виртуальный процессор.

Перейдем теперь к рассмотрению префиксов. Они делятся на четыре группы:

1. Префиксы блокировки и повторения:

0xF0 LOCK - префикс.

0хF2 REPNZ (только для строковых инструкций).

0xF3 REP (только для строковых инструкций).

2. Префиксы переопределения сегмента:

0х2E CS:

0х36 SS:

0х3E DS:

0х26 ES:

0х64 FS:

0х65 GS:

3. Префикс переопределения размеров операндов:

0х66

4. Префикс переопределения размеров адреса:

0х67

Если используется более одного префикса из той же самой группы, то действие команды не определено и по-своему реализовано на разных типах процессоров.

Префикс переопределения размера операндов используется в 16-разрядном режиме для манипуляции с 32-битными операндами и наоборот. При этом он может стоять перед любой командой, например 0x66 : CLI будет работать! А почему бы и нет? Интересно, но отладчики этого не учитывают и отказываются работать. То же относится и к дизассемблерам, к примеру IDA:

seg000:0100 start           proc near 
seg000:0100                 db      66h 
seg000:0100                 cli 
seg000:0102                 db      67h 
seg000:0102                 sti 
seg000:0104                 retn 

На этом же основан один очень любопытный прием противостояния отладчикам, в том числе и знаменитому отладчику-эмулятору cup386. Рассмотрим, как работает конструкция 0x66 : RETN. Казалось бы, коль скоро функция retn не имеет операндов, то префикс 0x66 можно просто игнорировать. На самом деле все не так просто. retn работает с неявным операндом - регистром ip/eip. Именно это и изменяет префикс. Разумеется, в реальном и 16-разрядном режиме указатель команд всегда обрезается до 16 бит, и поэтому на первый взгляд возврат сработает корректно. Но стек-то окажется несбалансированным! Из него вместо одного слова взяли целых два! Так нетрудно получить и исключение 0xC - исчерпание стека. Попробуйте отладить чем-нибудь пример crack1E.com - даже cup386 во всех режимах откажется это сделать, а Turbo-Debuger вообще зависнет! IDA не сможет отследить стек и вместе с ним все локальные переменные.

Как видим, прием крайне прост, но и крайне надежен. Впрочем, следует признать, что перехват int 0xC под операционной системой Windows бесполезен и, несмотря на все ухищрения, приложение, породившее такое исключение, будет безжалостно закрыто. Хотя в реальном режиме это работает превосходно. Попробуйте убедиться в этом на примере crack1E.com . Забавно наблюдать реакцию на него эмулирующих отладчиков. Все они либо неправильно работают (снимают одно слово из стека, а не два), либо совершают очень далекий переход по 32-битному eip (в результате чего виснут), либо - чаще всего - просто аварийно прекращают работу по исключению 0xC (так ведет себя cup386).

Еще интереснее получится, если попытаться исполнить в 16-разрядном сегменте команду CALL. Если адрес перехода лежит в пределах сегмента, то ничего необычного ожидать не приходится. Инстурукция работает нормально. Чудеса начинаются, когда адрес выходит за эти границы. В защищенном 16-разрядном режиме при уровне привилегий CL0 с большой вероятностью регистр EIP "обрежется" до шестнадцати бит и инструкция сработает (но похоже, что не на всех процессорах). Если уровнень не CL0, то генерируется исключение защиты 0xD. В реальном же режиме эта инструкция может вести себя непредсказуемо. Хотя в общем случае должно генерироваться прерывание int 0xD. В реальном режиме его нетрудно перехватить и совершить далекий 'far' переход в требуемый сегмент. Так поступает, например, моя собственная операционная система OS\7R, дающая в реальном режиме плоскую память. Разумеется, такой поворот событий не может пережить ни один отладчик. Ни трассировщики реального режима, ни v86, ни protect-mode debuger, ни даже эмуляторы (во всяком случае те, что мне известны), с этим справиться не в состоянии.

Одно плохо - все эти приемы не работают под Windows и другими операционными системами. Это вызвано тем, что обработка прерываний типа исключения общей защиты всецело лежит на ядре операционной системы и оно не позволяет приложениям распоряжаться им по своему усмотрению. Забавно, что в режиме эмуляции MS-DOS некоторые EMS-драйверы ведут себя в этом случае совершенно непредсказуемо. Часто при этом они не генерируют ни исключения 0xC, ни 0xD. Это следует учитывать при разработке защит, основанных на приведенных выше приемах.

Обратим внимание также на последовательности типа 0x66 0x66 [xxx]. Хотя фирма Intel не гарантирует поведение своих процессоров в такой ситуации, но фактически все они правильно ее интерпретируют. Иное дело некоторые отладчики и дизассемблеры, которые спотыкаются и начинают вести себя некорректно.

Есть еще один интересный момент, связанный с работой декодера микропроцессора.

     ------------T-T-T-T-T-T-T-T-T-T-T-T-T-T-T-T-T------
     ------------+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+------
                 ^  F E D C B A 9 8 7 6 5 4 3 2 1^
                 ¦                               ¦
                 ¦===============================¦
                 ¦                               ¦
                 L------¬                ---------
                        ¦                ¦
                        ¦                ¦
                     

                     -------------------------¬
                     ¦        декодер         ¦
                     L-------------------------

Декодер за один раз считывает только 16 байт, и если команда "не уместится", то он попросту не сможет считать "продолжение" и сгенерирует общее исключение защиты. Однако иначе ведут себя эмуляторы, которые корректно обрабатывают "длинные" инструкции.

Впрочем, все это очень процессорно-зависимо. Никак не гарантируется сохранность и преемственность работы в будущих моделях. Поэтому и злоупотреблять этим не стоит. Иначе ваша защита откажет в работе.

Префиксы перекрытия сегмента могут встречаться перед любой командой, в том числе и не обращающейся к памяти, - например, CS:NOP вполне успешно выполнится. А вот некоторые дизассемблеры сбиться могут. К счастью, IDA к ним не относится. Самое интересное, что комбинация из

DS:FS:FG:CS:MOV AX,[100] 

работает вполне нормально (хотя не гарантируется фирмой Intel). При этом последний префикс в цепочке перекрывает все остальные. Некоторые отладчики, наоборот, ориентируются на первый префикс в цепочке, что дает неверный результат. Этот пример хорош тем, что великолепно выполняется под Windows и другими операционными системами. К сожалению, на декодирование каждого префикса тратится один такт и все это может медленно работать.

Вернемся к формату опкода. Выше была описана структура первого байта. Отметим, что это фактически недокументировано и Intel уделяет ему всего два слова. Действительно, формат команд разнится от одной команды к другой. Однако можно выделить и некоторые общие правила. Практически для каждой команды, если регистром-приемником фигурирует AX (AL) существует специальный однобайтовый опкод, который в трех младших битах содержит регистр-источник. Этот факт следует учитывать при оптимизации. Так, среди двух инструкций XCHG AX,BX и XCHG BX, DX следует всегда автоматически выбирать первую, т.к. она на байт короче. (Кстати, инструкция XCHG AX,AX более известна нам как NOP. О достоверности этого факта часто спорят в конференциях, но на странице 340 руководства 24319101 фирмы Intel об этом сказано недвусмысленно. Выходит, никто из многочисленных спорщиков не знаком даже с оригинальным руководством производителя).

Для многих команд (Jx) четыре младшие бита обозначают условие операции. Точнее говоря, условие задается в битах 1-2-3, а младший бит приводит к его инверсии.

COLUMNS(3), DIMENSION(IN), COLWIDTHS(1.4200,1.9392,1.9242), WIDTH(4.2600), ABOVE(.0984), BELOW(.0984), HGUTTER(.0555), VGUTTER(.0433), BOX(Z_DOUBLE), HGRID(Z_SINGLE), VGRID(Z_SINGLE), KEEP(OFF), ALIGN(CT)

ZGT, ZGT, ZGT

Код, Мнемоника, Условие

TTL9, TTL9, TTL9

0000, O, Переполнение

0010, B,, NAE, Меньше

0100, Z, Равно

0110, BE,, NA, Меньше или равно

1000, S, Знак

1010, P,, PE, Четно

1100, L,, NGE, Меньше (знаковое)

1110, LE,, NG, Меньше или равно (знаковое)

Как видим, условий совсем немного, - чтобы никаких проблем их запоминания не возникало. Теперь уже не нужно мучительно вспоминать: 'JZ' - это 0x74 или 0x75. Т.к. младший бит первого равен нулю, то jz это 0x74, а jnz соответственно 0x75.

Далеко не все опкоды смогли поместиться в первый байт. Инженеры Intel задумались о поиске дополнительного места для размещения еще нескольких бит и при этом обратили внимание на байт modR/M. Подробнее он описан ниже, а пока рассмотрим приведенный выше рисунок. Трехбитовое поле reg, содержащее регистр-источник, очевидно не используется, когда вслед за ним идет непосредственный операнд. Так почему бы его не использовать для задания опкода? Однако требуется указать процессору на такую ситуацию. Это делает префикс 0xF, размещенный в первом байте опкода. Да, именно префикс, хотя документация Intel этого прямо и не подтверждает. При этом на не MMX процессорах для его декодирования требуется дополнительный такт. Intel же предпочитает называть первый байт основным, а второй уточняющим опкодом. Заметим, что то же поле используют многие инструкции, оперирующие с одним операндом (jmp,call). Это все очень сильно затрудняет написание собственного ассемблера\дизассемблера, но зато дает простор для самомодифицирующегося кода и, кроме того, вызывает уважение к инженерам Intel, до минимума сократившим размеры команд. Конечно, это далось весьма не просто. И далеко не все дизассемблеры работают правильно. С другой стороны, именно благодаря этому и существуют успешно противостоящие им защиты.

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

К тонкостям кодирования команд мы вернемся ниже, а пока приготовимся к разбору поля modR/M. Два трехбитовых поля могут задавать код регистра общего назначения по следующей таблице:

COLUMNS(5), DIMENSION(IN), COLWIDTHS(1.4200,.9608,1.0208,.9442,.9533), WIDTH(4.7667), ABOVE(.0984), BELOW(.0984), HGUTTER(.0555), VGUTTER(.0433), BOX(Z_DOUBLE), HGRID(Z_SINGLE), VGRID(Z_SINGLE), KEEP(OFF), ALIGN(CT)

ZGT, ZGT, ZGT, ZGT, ZGT

, , 8 бит операнд, 16 бит операнд, 32 бит операнд

TTL9, TTL9, TTL9, TTL9, TTL9

Код, 000, AL, AX, EAX

, 001, CL, CX, ECX

, 010, DL, DX, EDX

, 011, BL, BX, EBX

, 100, AH, SP, ESP

, 101, CH, BP, EBP

, 110, DH, SI, ESI

, 111, BH, DI, EDI

Опять можно восхититься лаконичностью решения инженеров Intel, которые ухитрились закодировать столько регистров всего в трех битах. Отсюда, кстати, становится ясно, почему нельзя выборочно обращаться к старшим и младшим байтам регистров SP,BP,SI,DI и, аналогично, старшему слову всех 32-битных регистрв. Во всем "виновата" оптимизация и архитектура команд. Просто нет свободных полей, в которые можно было бы "вместить" дополнительные регистры. Сегодня мы вынуждены расхлебывать результаты архитектурных решений, выглядевших такими удачными всего лишь десятилетие назад.

Обратите внимание на порядок регистров AX-CX-DX-BX-SP-BP-SI-DI. Алфавитный порядок немного нарушен, верно? Особенно странно в этом отношении выглядит BX. Но если понять причины, то не будет никакой нужды запоминать это исключение, все станет на свои места. BX - это индексный регистр. И стоит первым среди индексных.

Таким образом, мы уже можем "вручную", без дизассемблера, распознавать в шестнадцатиричном дампе регистры-операнды. Очень неплохо для начала! Или писать самомодифицирующийся код. Например:

00000000: 800E070024                   or        b,[00007],024 ; 
00000005: FA                           cli 
00000006: 33C0                         xor       ax,ax 
00000008: FB                           sti 

Он изменит строку 0x6 на xor sp,sp. Это "завесит" многие отладчики и, кроме того, не позволит дизассемблерам отслеживать локальные переменные адресуемые через SP. Хотя IDA позволяет скорректировать стек вручную, но для этого сперва нужно понять, что sp обнулился. В приведенном примере это очевидно (хотя, к стати, и не бросается в глаза), а если это произойдет в многопоточной системе? Тогда изменение кода очень трудно будет отследить, особенно в листинге дизассемблера. Однако нужно помнить, что самомодифицирующийся код все же уходит в историю. Сегодня он встречается все реже и реже.

COLUMNS(2), DIMENSION(IN), COLWIDTHS(1.2042,4.0883), WIDTH(8.1767), ABOVE(.0984), BELOW(.0984), HGUTTER(.0555), VGUTTER(.0433), BOX(Z_DOUBLE), HGRID(Z_SINGLE), VGRID(Z_SINGLE), KEEP(OFF), ALIGN(CT)

ZGT, ZGT

2-битная кодировка, 3-битная кодировка

TTL9, TTL9

00 ES, 000 ES

01 CS, 001 CS

10 SS, 010 SS

11 DS, 011 DS

, 100 FS

, 101 GS

, 110 Reserved*

, 111 Reserved*

Первоначально сегментые регистры кодировались всего двумя битами, и этого хватало, т.к. их было всего четыре. Позже, когда их стало больше, перешли на трехбитную кодировку. При этом два регистра 110b и 111b пока отсутствуют и вряд ли будут добавлены в ближайшем будующем. Но что же будет, если попытаться их использовать? Генерация int 0x6. А вот отладчики - эмуляторы могут вести себя странно. Иные при этом не генерируют прерывания, чем себя и выдают, а другие часто ведут себя непредсказуемо, т.к. требуемый регистр может находиться в области памяти, занятой другой переменной (это происходит, когда ячейка памяти опеределяется по индексу регистра; при этом считываются три бита и суммируются с базой, но никак не проверяются пределы).

Поведение дизассемблеров так же разнообразно. Вот, например,

hiew: 
   00000000: 8E                           ??? 
   00000001: F8                           clc 
   00000002: C3                           retn 

qview: 
   00000000: 8EF8                         mov    !s,ax 
   00000002: C3                           ret 

IDA: 
   seg000:0100 start                      db  8Eh ; 
   seg000:0101                            db 0F8h ; 
   seg000:0102                            db 0C3h ; 

Кстати, IDA вообще отказывается анализировать весь последующий код. Как это можно использовать? Да очень просто - если эмулировать еще два сегментных регистра в обработчике int 0x6, то очень трудна будет как отладка, так и дизассемблирование. Однако это опять-таки не работает под Win32!

Управляющие\отладочные регистры кодируются нижеследующим образом:

COLUMNS(3), DIMENSION(IN), COLWIDTHS(1.4200,1.9392,1.9242), WIDTH(4.2600), ABOVE(.0984), BELOW(.0984), HGUTTER(.0555), VGUTTER(.0433), BOX(Z_DOUBLE), HGRID(Z_SINGLE), VGRID(Z_SINGLE), KEEP(OFF), ALIGN(CT)

ZGT, ZGT, ZGT

, Control Register, Debug Register

TTL9, TTL9, TTL9

000, CR0, DR0

001, Reserved*, DR1

010, CR2, DR2

011, CR3, DR3

100, CR4, Reserved*

101, Reserved*, Reserved*

110, Reserved*, DR6

111, Reserved*, DR7

Заметим, что опкоды операций mov, манипулирующих с ними, различны, поэтому-то и получается кажущееся совпадение имен. С управляющими регистрами связана одна любопытная мелочь. Регистр CR1, как известно, в настоящее время зарезервирован и не используется. Так во всяком случае написано в русскоязычной документации. На самом же деле регистр CR1 просто не существует! И любая попытка обращения к нему вызывает генерацию исключения int 0x6. Например, cup386 в режиме эмуляции процессора этого не учитывает и неверно исполняет программу. А все дизассемблеры, за исключением IDA, неправильно дизассемблируют этот несуществующий регистр:

IDA:

seg000:0100 start           db  0Fh 
seg000:0101                 db  20h 
seg000:0102                 db 0C8h 
seg000:0103                 db 0C3h 

SOURCER:

43C5:0100                       start: 
43C5:0100  0F 20 C8                             mov     eax,cr1 
43C5:0103  C3                                   retn 

Или:

43C5:0100                       start: 
43C5:0100  0F 20 F8                             mov     eax,cr7 
43C5:0103  C3                                   retn 

Всех этих команд на самом деле не существует, и они приводят к вызову прерывания int 0x6. Не так очевидно, правда? И еще менее очевидно, что при обращении к регистрам DR4-DR5 исключения не генерируется. Между прочим, IDA 3.84 не дизассемблирует ни один регистр. Зато великолепно ассемблирует все (кстати, ассемблер был добавлен другим разработчиком).

Пользуясь случаем, акцентируем внимание на сложностях, которые подстерегают при написании собственного ассемблера (дизассемблера). Документация Intel местами все же недостаточно ясна (как в приведенном примере), и неаккуратность в обращении с ней приводит к ошибкам, которыми может воспользоваться разработчик защиты против хакеров.

Теперь перейдем к описанию режимов адресации микропроцессоров Intel. Тема очень интересная и познавательная - не только для оптимизации кода, но и для борьбы с отладчиками.

Первым ключевым элементом является байт modR/M.

                            ---T--T-T-T-T-T-T-¬
                            ¦ mod ¦ reg ¦ r/m ¦
                            L--+--+-----+------

Если mod == 11b, то два следующие поля будут представлять собой регистры.(Это так называемая регистровая адресация.)

Например :

             00000000: 33C3      xor  ax,  bx
                         ^^      ---T--^--T-^--¬
                         L+----->¦11¦ 000 ¦ 011¦
                                 L--+-----+-----
             

             00000000: 32C3      xor  al,  bl
                         ^^      ---T--^--T-^--¬
                         L+----->¦11¦ 000 ¦ 011¦
                                 L--+-----+-----

Как отмечалось выше, по байту modeR/M нельзя точно установить регистры. В зависимости от кода операции и префиксов размера операндов, результат может варьироваться в ту или иную сторону.

Биты 3-5 могут вместо регистра представлять уточняющий опкод (в случае если один из операндов представлен непосредственным значением). Младшие три бита всегда либо регистр, либо способ адресации. Последнее зависит от значения 'mod'. Отметим, что биты 3-5 никак не зависят от выбранного режима адресации и всегда задают либо регистр, либо непосредственный операнд.

Формат поля R/M, строго говоря, не документирован, однако достаточно очевиден. Во всяком случе понимание этого позволяет избежать утомительного запоминания совершенно нелогичной на первый взгляд таблицы адресаций (см. ниже).

                                R/M
                           ----T---T---¬
                           ¦ x ¦ x ¦ x ¦
                           L---+---+----
                             ^   ^   ^
     0 - нет  базирования ¦---   ¦   L--------T-
     1 - есть базирование ¦      ¦   2bit = 0 ¦ '0' - SI, '1' - DI
                                 ¦   2bit = 1 ¦ '0' - BP, '1' - BX
                                 ¦
                                 L----T
                             3bit = 0 ¦ '0' - база BX, '1' - BP
                             3bit = 1 ¦ '0' - Индексный региср,'1' - базов

Части: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15

Оставить комментарий

Комментарий:
можно использовать BB-коды
Максимальная длина комментария - 4000 символов.
 
Реклама на сайте | Обмен ссылками | Ссылки | Экспорт (RSS) | Контакты
Добавить статью | Добавить исходник | Добавить хостинг-провайдера | Добавить сайт в каталог