Простейшее приложение
В первой статье мы получили представление о строении машинных инструкций. Во второй статье научились составлять с использованием подручных средств файлы любого уровня сложности. Наконец, в прошлой статье начали создавать исполняемые файлы, которые система "признает" в качестве своих. Теперь мы вплотную стоим перед дверями, открывающими доступ к неисчислимым сокровищам мира Windows.
Это API - Application Programming Interface - интерфейс прикладного программирования, огромная библиотека уже готовых наборов инструкций, входящая в состав самой системы и служащая для выполнения разнообразнейших задач почти на все случаи жизни - имеется в виду, жизни в мире Windows :).
Сокровища эти упакованы в виде процедур и функций и размещены в системных модулях - dll. Чтобы получить к ним доступ, необходимо связаться с соответствующими модулями и импортировать из них нужные нам функции. Попробуем сегодня создать элементарное приложение, выводящее простое сообщение с набранным нами текстом и на этом завершающее свою работу. Эту работу осуществляет функция MessageBoxA, находящаяся в модуле User32.dll. Чтобы не пришлось "прибивать" наше приложение из менеджера задач, как в прошлый раз, оно должно также содержать для своего нормального завершения вызов функции ExitProcess из модуля Kernel32.dll.
Обычно для своей работы эти системные функции используют какие-то наши данные, которые мы им должны передать в виде параметров. Например, функции MessageBoxA мы должны предоставить текст, который хотим отобразить. В первой статье мы рассматривали инструкции, позволяющие копировать данные из одного места в другое. Однако, в данном случае мы ничего не знаем о внутреннем "устройстве" вызываемых функций и о том, в каком месте они хранят данные, с которыми работают. Как раз для подобных случаев был изобретен в свое время механизм, получивший название стека.
Стек - это некоторая область в виртуальном адресном пространстве процесса, специально предназначенная для временного хранения данных. Если помните, при создании PE-заголовка мы заполняли поля с размером выделяемого стека. Место, где разместить стек, выбирает система, а соответствующий адрес памяти сохраняет в регистре ESP. Этот регистр специально предназначен для работы со стеком и поэтому не должен использоваться ни для каких других данных.
Доступ же к стеку осуществляется последовательным образом, причем операнд всегда должен иметь размер в 4 байта (для 32-разрядного режима). Для записи очередного числа значение ESP уменьшается на 4 и указывает на "чистую" область размером в 4 байта. В эту область и копируется единственный операнд соответствующей инструкции. При извлечении сохраненного числа из стека копируется 4-байтное значение, адрес которого находится в настоящий момент в ESP, а затем значение ESP увеличивается на 4 и указывает уже на предыдущее сохраненное число. Единственное, за чем необходимо следить - чтобы каждому случаю помещения данных в стек соответствовал ровно один случай извлечения этих данных из стека (это называется сбалансированностью стека).
Рассмотрим инструкции помещения данных в стек (инструкции извлечения данных из стека нам пока не понадобятся, и мы займемся ими в другой раз). На ассемблере группа данных инструкций обозначается мнемоникой PUSH. Сначала инструкция помещения в стек непосредственного значения:
011010 s 0 <байты данных>
Эта инструкция имеет бит знакового расширения s. Если s = 0, то за опкодом следуют 4 байта, которые необходимо скопировать в стек. Если же s = 1, за опкодом следует всего один байт. Но перед тем, как поместить его в стек, производится его т.н. знаковое расширение до 4 байтов: старшие 3 байта заполняются значением старшего бита данного байта. Например, если байт данных был 01000000, он становится 00000000 00000000 00000000 01000000. Если же он был 10000000, то получается 11111111 11111111 11111111 10000000. Получившиеся 4 байта и помещаются в стек. Это позволяет сохранить знак числа, записанного в виде двоичного дополнения, и в то же время сэкономить 3 байта при операциях с небольшими числами (от -128 до +127). Например, команда помещения в стек нулевого значения (PUSH 0) кодируется так:
01101010 00000000 (6Ah 00h)
Следующая инструкция сохраняет в стеке значение общего регистра:
01010 reg
Инструкция сохранения в стеке значения регистра EAX (PUSH EAX) займет всего 1 байт: 01010000 (50h).
Наконец, еще одна инструкция, использующая уже знакомый нам по первой статье байт ModR/M. Этот байт позволяет записывать в стек значения, хранящиеся в памяти. Но в данном случае есть одна особенность использования этого байта. Вспомните, что байт ModR/M предполагает наличие двух операндов (один из которых всегда находится в регистре). Здесь же необходимо указывать лишь один операнд - другой все время один и тот же и задается неявно (адрес в ESP). Поэтому поле REG байта ModR/M, служившее для обозначения регистра, теперь используется в качестве расширения опкода и для данной конкретной инструкции всегда одно и то же (постоянно). А сама инструкция вместе с байтом ModR/M выглядит так:
11111111 Mod 110 R/M
Обратите внимание, что у нас снова появляется альтернативное кодирование - теперь для команды помещения в стек значений регистров (при Mod=11). Например, указанная выше инструкция PUSH EAX может быть закодирована и таким образом:
11111111 11110000 (FFh F0h)
Данные в виде параметров передаются функциям Windows именно через стек. Рассмотрим функцию MessageBoxA, которая принимает 4 параметра. Первым в стек помещается число, указывающее на стиль создаваемого окна сообщения. Это число представляет собой битовую структуру (см. рис.)
Флаги стиля MessageBoxA
Поля структуры могут содержать следующие значения, определяющие внешний вид и поведение окна сообщения. Тип окна:
0 - содержит одну кнопку OK;
1 - содержит две кнопки: OK и Cancel;
2 - содержит три кнопки: Abort, Retry, Ignore;
3 - содержит три кнопки: Yes, No, Cancel;
4 - содержит две кнопки: Yes и No;
5 - содержит две кнопки: Retry и Cancel.
Поле значок содержит значение, определяющее вид отображаемой пиктограммы в окне:
0 - нет значка;
1 - красный кружок с крестиком (значок стоп);
2 - вопросительный знак;
3 - восклицательный знак;
4 - кружочек с буквой i.
Поле кнопка определяет кнопку по умолчанию, т.е. ту, которая ассоциируется с нажатием на клавишу 'Enter' при появлении окна сообщения на экране:
0 - 1-я кнопка;
1 - 2-я кнопка;
2 - 3-я кнопка;
3 - 4-я кнопка.
Значение поля режим определяет способ взаимодействия окна сообщения с другими окнами. Кроме этих полей, могут быть установлены некоторые другие биты. Например, установка 14-го бита добавляет к окну сообщения кнопку 'Help'; установка 18-го бита заставляет окно все время находиться сверху и т.д.
Таким образом, число 0 в качестве стиля означает простое окно сообщения с одной кнопкой 'OK' и без всяких значков. Инструкция для помещения 0 в стек нам уже знакома: 6Ah 00h.
Вторым в стек должен быть помещен адрес начала строки, являющейся заголовком окна сообщения. Эту строку разместим в секции данных нашей программы; используя ту же схему, что и в прошлый раз, это будет третья секция, начинающаяся со смещения 3000h относительно базового адреса загрузки, который равен 400000h. В качестве заголовка можно выбрать любой текст; пусть это будет просто "Заголовок". В конце строки обязательно должен быть нулевой байт. В результате адрес нашей строки в памяти будет 403000h; поместим это непосредственное значение в стек (на этот раз мы указываем все 4 байта, поэтому бит s = 0): 68 00 30 40 00 (h).
Третьим в стек помещается адрес начала другой строки, которая является собственно выводимым сообщением. Пусть будет "Текст сообщения для вывода"; расположим ее сразу после первой строки (после ее завершающего 0) - по адресу 4030A0h: 68 A0 30 40 00 (h).
Последний параметр для функции MessageBoxA - описатель (handle) другого окна, являющегося владельцем нашего окна сообщения. Если такое окно имеется, оно (в зависимости от значения в поле "Режим") может быть "заморожено" на время отображения нашего окна сообщения. В данном случае других окон в приложении нет, поэтому оставим 0: 6A 00 (h).
Функция ExitProcess принимает единственный аргумент - т.н. код завершения. Он обычно равен 0 при нормальном завершении приложения. Мы также оставим это значение: 6A 00 (h).
Больше данных в нашем приложении не предвидится; поэтому мы можем сразу построить секцию данных. Создадим отдельную папку и разместим в ней файл "data.txt" со следующим содержимым:
n data.bin r cx 200 f 0 l 200 0 e 0 "Заголовок" 0 e a0 "Текст сообщения для вывода" 0 m 0 l 200 100 w q
Все аргументы подготовлены; настало время рассмотреть вызовы самих функций. При нормальном ходе исполнения очередная инструкция извлекается из памяти по адресу, содержащемуся в регистре EIP. В процессе декодирования сначала определяется длина инструкции, и это значение прибавляется к содержимому регистра EIP, который, таким образом, указывает на следующую инструкцию.
Но существуют отклонения от этого последовательного хода исполнения; одним из таких случаев является вызов функций. В этом случае содержимое регистра EIP автоматически сохраняется в стеке (это значение называется адресом возврата), а в регистр EIP заносится операнд инструкции вызова функции, который является адресом первой команды вызываемой функции. В свою очередь, последней командой функции должна быть инструкция возврата управления, которая восстанавливает сохраненный ранее в стеке адрес возврата в регистре EIP, и управление переходит на следующую после вызова функции инструкцию. В результате вызов функции выглядит так, будто это обычная одиночная инструкция и не было всего этого "путешествия" куда-то в другие области адресного пространства.
Рассмотрим инструкции вызова функций (эта группа обозначается на ассемблере мнемоникой CALL). Инструкция с непосредственным смещением:
11101000 <4 байта смещения>
Следующие за опкодом 4 байта являются знаковым смещением относительно адреса следующей инструкции: это значение добавляется к содержимому EIP, и полученное число является конечным адресом. Причем если самый старший бит смещения равен 1, число рассматривается как отрицательное (представленное в виде двоичного дополнения). Не вдаваясь в детали, преобразование положительных двоичных чисел в отрицательные и обратно осуществляется так: все нули исходного числа меняются на единицы, а единицы - на нули, и к этому числу добавляется единица. Например, в случае байта, 00000001 - это +1, меняем на 11111110 и добавляем 1, получая 11111111 - это будет -1 в двоичном виде.
Есть также инструкция с косвенной адресацией; она использует байт ModR/M:
11111111 Mod 010 R/M
Операнд в виде адреса назначения находится в этом случае в регистре или в памяти. Обратите внимание, поскольку у этой инструкции единственный операнд (на самом деле, второй операнд - регистр EIP - задан неявно), поле REG байта ModR/M также используется в качестве расширения опкода. Более того, сам опкод (FFh) совпадает с опкодом для инструкции помещения данных в стек с байтом R/M - процессор различает эти команды как раз по полю REG байта R/M: в случае стековой инструкции это 110, а для инструкции вызова функции - 010. Забегая вперед, отметим, что здесь возможны и другие значения, создающие соответственно другие инструкции.
Хорошо; но как получить адрес нужной нам функции? Это делается посредством процесса, который называется импортом. Загрузчик Windows осуществляет его автоматически при запуске приложения, нужно только указать ему, какие функции и из каких модулей нам потребуются. Вот для этой цели и служат таблица импорта и сопутствующие ей данные, о которых упоминалось в прошлой статье. Теперь познакомимся с ними поближе.
Число записей в таблице импорта равно числу импортируемых модулей. Последняя запись должна содержать нули для обозначения конца таблицы. Каждая запись имеет следующий формат:
0 | 4 | Смещение таблицы поиска |
4 | 4 | Используется для предварительного связывания; здесь - 0 |
8 | 4 | Перенаправление; здесь - 0 |
12 | 4 | Смещение строки с именем модуля (dll) |
16 | 4 | Смещение таблицы импортируемых адресов (IAT) |