Простейшее приложение
В первой статье мы получили представление о строении машинных инструкций. Во второй статье научились составлять с использованием подручных средств файлы любого уровня сложности. Наконец, в прошлой статье начали создавать исполняемые файлы, которые система "признает" в качестве своих. Теперь мы вплотную стоим перед дверями, открывающими доступ к неисчислимым сокровищам мира 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 осуществляет его автоматически при запуске приложения, нужно только указать ему, какие функции и из каких модулей нам потребуются. Вот для этой цели и служат таблица импорта и сопутствующие ей данные, о которых упоминалось в прошлой статье. Теперь познакомимся с ними поближе.
Число записей в таблице импорта равно числу импортируемых модулей. Последняя запись должна содержать нули для обозначения конца таблицы. Каждая запись имеет следующий формат:
СмещениеРазмер, байтПоле 04Смещение таблицы поиска 44Используется для предварительного связывания; здесь - 0 8 4Перенаправление; здесь - 0 124Смещение строки с именем модуля (dll) 164Смещение таблицы импортируемых адресов (IAT) Записи этой таблицы ссылаются на другие вспомогательные таблицы и строки; их взаимоотношение показано на рисунке. Импортировать функции можно по именам или по их порядковым номерам в соответствующем модуле. Все импортируемые из одного модуля функции должны быть указаны в таблице поиска, на которую ссылается таблица импорта, следующим образом. Каждой импортируемой функции соответствует 32-разрядное значение. Если старший бит этого значения установлен (1), импорт осуществляется по номерам, и оставшийся 31 бит является значением этого номера. Если же он сброшен (0), оставшийся 31 бит является смещением (относительно базового адреса загрузки) на соответствующую строку таблицы имен. Первые две байта этой строки являются "подсказкой" загрузчику, в каком месте импортируемого модуля может находиться соответствующее имя (если "подсказки" нет, они равны 0). За ними следует сама строка с именем импортируемой функции. Таблица поиска завершается 4-байтным полем, содержащим нули.
Таблица импортируемых адресов должна находится в самом начале секции. При загрузке она должна быть идентична таблице поиска. В процессе загрузки система заполняет поля этой таблицы адресами соответствующих функций. Таким образом, мы должны указать в инструкциях вызова функций именно эти адреса.
Теперь мы можем составить нужные для нашего приложения данные. Разместим их в секции .rdata (со смещением 2000h относительно адреса загрузки). Создадим файл rdata.txt. Это как раз тот случай, когда могут оказаться полезными два прохода, чтобы узнать относительные взаимные смещения различных таблиц. Учтите, что все смещения должны указываться относительно базового адреса загрузки. Я приведу здесь уже готовый вариант:
n rdata.bin r cx 200 f 2000 l 200 0 a 2000 ; 1 IAT db 2A 20 0 0 0 0 0 0 ; 2 IAT db 38 20 0 0 0 0 0 0 ; имя 1 модуля db "User32.dll" 0 0 ; имя 2 модуля db "Kernel32.dll" 0 0 ; имя 1 функции db 0 0 "MessageBoxA" 0 ; имя 2 функции db 0 0 "ExitProcess" 0 ; таблица поиска 1 db 2A 20 0 0 0 0 0 0 ; таблица поиска 2 db 38 20 0 0 0 0 0 0 ; таблица импорта: ; 1 модуль ; указатель на 1 таблицу поиска db 46 20 0 0 ; 2 пустых поля db 0 0 0 0 0 0 0 0 ; указатель на имя 1 модуля db 10 20 0 0 ; указатель на 1 IAT db 0 20 0 0 ; 2 модуль ; указатель на 2 таблицу поиска db 4E 20 0 0 ; 2 пустых db 0 0 0 0 0 0 0 0 ; указатель на имя 2 модуля db 1C 20 0 0 ; указатель на 2 IAT db 08 20 0 0 ; последняя запись - все нули
m 2000 l 200 100 w q
Теперь мы можем закончить и кодовую секцию. Адрес функции MessageBoxA будет находиться в поле первой IAT по адресу 402000, используем в инструкции ModR/M с непосредственным смещением (Mod = 00, R/M = 101; затем следуют 4 байта адреса, где находится операнд):
11111111 00 010 101 , или FF 15 00 20 40 00.
Аналогично адрес функции ExitProcess будет по адресу 402008, а инструкция выглядит так: FF 15 08 20 40 00.
Составим файл code.txt:
n code.bin r cx 200 f 100 l 200 0 a 100 ; помещаем в стек параметры MessageBoxA db 6a 00 db 68 00 30 40 00 db 68 a0 30 40 00 db 6a 00 ; вызываем MessageBoxA db ff 15 00 20 40 00 ; помещаем в стек параметр (0) db 6a 00 ; вызываем ExitProcess db ff 15 08 20 40 00
w q
Готово почти все; единственное, что осталось - подправить PE-заголовок в нашем шаблоне. Скопируем файл Header.txt, созданный в прошлый раз, в рабочий каталог. Теперь в нашем приложении есть таблица импорта, и надо указать ее смещение (2056h) и размер (3Ch) в каталоге. Найдите в файле Header.txt строку "; Здесь начинается первый элемент каталога:". Теперь переделайте его начало следующим образом:
; смещение таблицы экспорта (4 байта) db 0 0 0 0 ; размер таблицы экспорта (4 байта) db 0 0 0 0 ; Второй элемент каталога: ; смещение таблицы импорта (4 байта) db 56 20 0 0 ; размер таблицы импорта (4 байта) db 3c 0 0 0
Все! Пишем заключительный файл сборки (make.bat):
@echo off debug < header.txt > report.lst debug < code.txt >> report.lst debug < rdata.txt >> report.lst debug < data.txt >> report.lst copy /b header.bin+code.bin+rdata.bin+data.bin msg.exe
Запустив make.bat, мы получим файл msg.exe. Прежде чем запускать его, внимательно проверьте файл отчета report.lst на предмет сообщений об ошибках. Всего один неверно введенный символ (например, русская буква е в команде e) вызовет ошибку, отладчик не выполнит соответствующую команду, в результате создаваемая нами структура окажется неверной, что может привести к совершенно неожиданным результатам и даже вызвать сбой всей системы (особенно если это ошибка в PE-заголовке).
Если же все нормально - хлопайте в ладоши! Вот оно, наше окно, собственноручно созданное самым честным образом в самых что ни на есть настоящих машинных кодах. Теперь самое время изучить функцию Win32 API MessageBoxA подробнее, давая ей в качестве параметров другие строки и значения стилей - для этого они и были здесь приведены. Успехов!